From a43020742719b2bbf94334b9c42e3f46097314a0 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 26 Jul 2026 14:09:31 +0800 Subject: [PATCH 01/35] 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 49131c47c514466e460c251c8e021e6f642cb087 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 14:23:03 +0800 Subject: [PATCH 02/35] fix(web): address model retry review feedback --- ...-21-bounded-llm-request-recovery.i18n.yaml | 4 +- ...2026-06-21-bounded-llm-request-recovery.md | 4 +- ...6-06-21-bounded-llm-request-recovery.zh.md | 4 +- apps/web/tests/session-title.snapshot.ts | 25 ++++ .../tests/snapshots/model-retry-cancel.json | 5 + .../client/connection/src/client/fixture.ts | 18 +++ .../client/connection/tests/fixture.spec.ts | 6 + packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- packages/client/runtime/package.json | 1 + .../src/client/sessions/conversation.ts | 5 + .../runtime/src/client/sessions/session.ts | 73 +++++++++--- packages/client/runtime/tests/event-script.ts | 2 +- packages/client/runtime/tests/session.spec.ts | 112 +++++++++++++++--- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/chat/ChatView.tsx | 2 +- .../src/client/chat/MessageItem.tsx | 16 ++- .../tests/chat-branch-tails.spec.tsx | 27 ++++- .../ui-conversation/tests/chat-view.spec.tsx | 15 ++- .../client/ui-trajectory/README.i18n.yaml | 6 +- packages/client/ui-trajectory/README.md | 2 +- packages/client/ui-trajectory/README.zh.md | 2 +- .../client/ui-trajectory/src/client/spans.ts | 7 +- .../client/ui-trajectory/tests/views.spec.tsx | 7 +- pnpm-lock.yaml | 3 + 28 files changed, 301 insertions(+), 61 deletions(-) create mode 100644 apps/web/tests/snapshots/model-retry-cancel.json diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml index f6dee03e61..8193e5e839 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.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-06-21-bounded-llm-request-recovery.md -2026-06-21-bounded-llm-request-recovery.md: aadd73bc921e942f523c26027e8fc06aaea38be7 -2026-06-21-bounded-llm-request-recovery.zh.md: 847e20a39bcee697fe61329b68964364c057f666 +2026-06-21-bounded-llm-request-recovery.md: 5c76ed5d754ea40f41dff78cb56ee7fc139a32b1 +2026-06-21-bounded-llm-request-recovery.zh.md: 1fa56f3fe0405cab663c2843d423a78d910170dd 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 aadd73bc92..5c76ed5d75 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 @@ -82,7 +82,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 closes the failed turn, opens the next numbered turn, 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 failure. Web clears the failed partial at `llm/retry`, projects consecutive retry-turn 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. +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 closes the failed turn, opens the next numbered turn, 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 failure. Web validates the complete retry payload contract, clears the failed partial at `llm/retry`, projects consecutive retry-turn events into one stable row updated to the latest attempt, and derives scheduled, started, or cancelled status from subsequent turn facts. Its countdown anchors the scheduled delay to browser receipt rather than the Host event clock, uses ceiling-rounded seconds with a one-second floor, animates only while unresolved, and keeps exact latest failure details collapsed behind the row. Retry nodes anchor their own trajectory turn even when the failed attempt has no assistant node. 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. @@ -116,7 +116,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 turn, 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 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. +- 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. Client tests cover complete wire validation, clock-independent countdown, cancellation versus completed retry labels, and trajectory attribution; 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/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md index 847e20a39b..1fa56f3fe0 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md @@ -82,7 +82,7 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 ### 在现有日志中分隔尝试 -一次失败尝试可以在已关闭的步骤中留下 `assistant/chunk` 事件,但绝不会追加 `assistant/message`,也不会分发工具。重试会关闭失败轮次,开启下一个编号轮次,从持久表层重建请求,并生成自己的分片。步骤仍处于打开状态时,UI 可以渲染实时分片;当 `llm/retry` 标识失败步骤,或 `turn/end` 记录失败时,UI 再标记或清除这份暂时视图。Web 会在 `llm/retry` 到达时清除失败的部分输出,将连续重试轮次的事件投影为稳定的一行,并用最新一次尝试更新该行;它按向上取整且不低于 1 秒的秒数对延迟倒计时,仅在重试尚未结束时显示动画,并把最近一次失败的准确详情折叠在该行之后。消息派生仍会忽略失败分片;Web 在重建历史时也会应用同一投影,因此刷新页面不会让已丢弃的部分输出重新出现,也不会生成重复的重试行。 +一次失败尝试可以在已关闭的步骤中留下 `assistant/chunk` 事件,但绝不会追加 `assistant/message`,也不会分发工具。重试会关闭失败轮次,开启下一个编号轮次,从持久表层重建请求,并生成自己的分片。步骤仍处于打开状态时,UI 可以渲染实时分片;当 `llm/retry` 标识失败步骤,或 `turn/end` 记录失败时,UI 再标记或清除这份暂时视图。Web 会验证完整的重试载荷契约,在 `llm/retry` 到达时清除失败的部分输出,将连续重试轮次的事件投影为稳定的一行,并用最新一次尝试更新该行,再从后续轮次事实派生 scheduled、started 或 cancelled 状态。倒计时以浏览器收到事件的时刻为计划延迟的起点,而不是使用 Host 事件时钟;它按向上取整且不低于 1 秒的秒数显示,仅在重试尚未结束时显示动画,并把最近一次失败的准确详情折叠在该行之后。即使失败尝试没有 assistant 节点,重试节点也会锚定自身的轨迹轮次。消息派生仍会忽略失败分片;Web 在重建历史时也会应用同一投影,因此刷新页面不会让已丢弃的部分输出重新出现,也不会生成重复的重试行。 如果恢复预算耗尽,最终失败会连同结构化事实在 `turn/end.reason` 中存储一次。如果暂时性恢复继续,`llm/retry` 就是该次尝试的失败与延迟的持久归属位置。本决策不增加独立的最终错误事件或响应 id 词汇。 @@ -116,7 +116,7 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 - 纯单元测试覆盖暂时性 code 选择、指数退避和抖动边界、有效及超出上限的 `Retry-After`、耗尽的预算、确定性定时器/随机数 seam,以及退避期间中止。 - 真实 agent-loop 测试覆盖分片前失败、部分分片后失败、抛出及带内失败、在新轮次中重试至成功、耗尽后写入结构化 `turn/end.reason`,以及与 `dsh-compact-basic` 上下文溢出恢复的组合。 - 部分分片集成测试证明:失败分片仍归属于失败步骤,该步骤不会提交 assistant 消息或工具副作用,成功的重试具有不同的来源信息。 -- 插件拥有的不进入表层的 `llm/retry` 事件可在 JSONL 和 SQLite 往返后保留,被消息派生忽略,并驱动 TUI 和 Web 撤回及计划重试渲染。无密钥 UI 快照覆盖 Web 的调度与成功,真实 Web 组合测试覆盖部分传输失败直至恢复,ACP 自动化快照确认,被丢弃的尝试不会通过协议发出,而恢复后的回复会正常发出。 +- 插件拥有的不进入表层的 `llm/retry` 事件可在 JSONL 和 SQLite 往返后保留,被消息派生忽略,并驱动 TUI 和 Web 撤回及计划重试渲染。客户端测试覆盖完整的 wire 验证、独立于时钟的倒计时、已取消与已完成重试标签的区别以及轨迹归属;无密钥 UI 快照覆盖 Web 的调度与成功,真实 Web 组合测试覆盖部分传输失败直至恢复,ACP 自动化快照确认,被丢弃的尝试不会通过协议发出,而恢复后的回复会正常发出。 - 空闲看门狗测试证明:只有 `next()` 尚未完成时才会重新布防稳定信号;在消费方思考期间及 `finally` 中会解除布防;它与总调用 deadline 以及更早发生的调用方中止分开分类。适配器测试证明该信号会终止底层请求,而不只是与其脱离。 - `ctx.llm.stream()` 的直接调用方仍只尝试一次,并收到相同的结构化失败事实。 diff --git a/apps/web/tests/session-title.snapshot.ts b/apps/web/tests/session-title.snapshot.ts index b77f82edd5..849ea4a879 100644 --- a/apps/web/tests/session-title.snapshot.ts +++ b/apps/web/tests/session-title.snapshot.ts @@ -33,6 +33,7 @@ interface FixtureTiming { appendTitle(id: string, title: string): void beginModelRetry(id: string): void scheduleModelRetry(id: string, retry?: number, delayMs?: number): void + cancelModelRetryDuringBackoff(id: string, delayMs?: number): void completeModelRetry(id: string): void } @@ -216,3 +217,27 @@ it('retracts a failed stream at llm/retry and retains the durable notice after r await expect(`${JSON.stringify({ beforeRetry, firstRetry, scheduled, expanded, completed }, null, 2)}\n`) .toMatchFileSnapshot('./snapshots/model-retry.json') }) + +it('labels a retry cancelled during backoff without claiming that it started', async () => { + bootFixtureApp() + await selectFixtureSession() + const timing = (globalThis as Record).__fxTiming as FixtureTiming + + act(() => { timing.beginModelRetry('fx-alpha') }) + await screen.findByText('应撤回的半截回复') + act(() => { timing.cancelModelRetryDuringBackoff('fx-alpha', 1_500) }) + + const notice = await screen.findByRole('status') + await waitFor(() => { expect(notice.textContent).toContain('重试已取消') }) + await waitFor(() => { expect(screen.queryByText('应撤回的半截回复')).toBeNull() }) + const disclosure = notice.closest('details') + if (disclosure === null) throw new Error('cancelled retry disclosure missing') + const cancelled = { + notice: notice.textContent, + partialVisible: screen.queryByText('应撤回的半截回复') !== null, + animated: disclosure.dataset.active === 'true', + } + + await expect(`${JSON.stringify(cancelled, null, 2)}\n`) + .toMatchFileSnapshot('./snapshots/model-retry-cancel.json') +}) diff --git a/apps/web/tests/snapshots/model-retry-cancel.json b/apps/web/tests/snapshots/model-retry-cancel.json new file mode 100644 index 0000000000..a61756904b --- /dev/null +++ b/apps/web/tests/snapshots/model-retry-cancel.json @@ -0,0 +1,5 @@ +{ + "notice": "模型请求重试已取消(1/2) · 2s", + "partialVisible": false, + "animated": false +} diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 1ba6d84173..269203b4ce 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -792,6 +792,24 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { scenario.turn = next scenario.stepStarted = false }, + /** Record one retry decision, then cancel its source turn before the retry starts. */ + cancelModelRetryDuringBackoff(id: string, 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 failure = { code: 'TRANSPORT', message: '连接被重置' } + append(sessionId, { + type: 'llm/retry', + data: { + turn: scenario.turn, step: 1, + provider: 'fixture', mode: 'normal', policyKey: 'fixture-normal', + retry: 1, maxRetries: 2, delayMs, failure, + }, + }) + append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'aborted' } } }) + retryScenarios.delete(sessionId) + setRunning(sessionId, false) + }, /** Finish the timing-hook retry with a finalized response in the open retry turn. */ completeModelRetry(id: string): void { const sessionId = sid(id) diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index beb855597a..1734f540b3 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -21,6 +21,7 @@ interface TimingHooks { appendTitle(id: string, title: string): void beginModelRetry(id: string): void scheduleModelRetry(id: string, retry?: number, delayMs?: number): void + cancelModelRetryDuringBackoff(id: string, delayMs?: number): void completeModelRetry(id: string): void appendSilent(id: string, msg: string): void breakStreams(): void @@ -630,10 +631,15 @@ describe('createFixtureApi', () => { hooks.beginModelRetry('fx-alpha') hooks.scheduleModelRetry('fx-alpha') hooks.completeModelRetry('fx-alpha') + hooks.beginModelRetry('fx-alpha') + hooks.cancelModelRetryDuringBackoff('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/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' + && f.event.type === 'turn/end' + && f.event.data.reason.kind === 'aborted')).toBe(true) expect(seen.some(f => f.type === 'session/projection' && f.key === 'title' && f.value === 'Fixture 修订标题')).toBe(true) }) expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('静默丢帧'))).toBe(false) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 6e00fb6c7b..1cf0e42f7b 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/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/runtime/README.md -README.md: 4f4a957baa8e8e24486318d3ffde235155b5cf73 -README.zh.md: 51f8bc91d59b6971d4a3ff9dfe3d0edd210d2557 +README.md: 45cc032db81aee79061fc2f6a9d9062513629000 +README.zh.md: a769661f6e4f7b1eb6d9872c5b979a871aba3eba diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 4f4a957baa..45cc032db8 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -26,7 +26,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and ## Model retry projection -The Session object validates plugin-owned, provider-routed `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. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. 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. +The Session object validates plugin-owned, provider-routed `llm/retry` payloads at the event wire boundary against the producer's complete field contract, including timer, integer, status, provider-delay, and non-empty diagnostic bounds. A valid event removes the matching failed step's streaming partial and inserts a durable retry notice at the event's sequence position. The notice is `scheduled` until a following retry turn starts; an aborted or disposed source turn marks it `cancelled`, while the retry turn marks it `started`. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. 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. ## Session model selection diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 51f8bc91d5..a769661f6e 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -26,7 +26,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## 模型重试投影 -Session 对象会在事件 wire 边界验证由插件负责、按提供方路由的 `llm/retry` 载荷。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。normal mode 提示携带其有限上限;always mode 提示则保持显式无界。窗口重建与历史回放应用相同的投影,因此刷新后,来自已丢弃尝试的日志分片绝不会重新显示为中断回复。没有 `llm/retry` 的终止轮次保留现有行为:可见但尚未定稿的输出会冻结为中断的 assistant 节点。 +Session 对象会在事件 wire 边界依据生产方的完整字段契约,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。该提示在后续重试轮次开始前为 `scheduled`;源轮次中止或释放会将其标记为 `cancelled`,重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限;always mode 提示则保持显式无界。窗口重建与历史回放应用相同的投影,因此刷新后,来自已丢弃尝试的日志分片绝不会重新显示为中断回复。没有 `llm/retry` 的终止轮次保留现有行为:可见但尚未定稿的输出会冻结为中断的 assistant 节点。 ## 会话模型选择 diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index 850b504fb3..a3897e9523 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -50,6 +50,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", "@types/react": "~18.3.1", "cordis": "^4.0.0-rc.7" }, diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 6d1a448685..530d47f197 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -97,6 +97,11 @@ export type ModelRetryNode = LlmRetryEventData & { seq: number /** Unix epoch ms from the llm/retry session event. */ time: number + /** + * Client-derived lifecycle: scheduled until a retry turn starts, started + * once it does, or cancelled when the failed turn aborts first. + */ + retryState: 'scheduled' | 'started' | 'cancelled' } /** A tool result paired (when in-window) with its call head. */ diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index d8e0bb7978..9be61438d1 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -13,8 +13,8 @@ import type { import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import type { SessionFace } from '../contract/session.ts' import type { - CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState, - PromptError, QueuedMessage, RunningToolCall, + CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, ModelRetryNode, + OpenState, PromptError, QueuedMessage, RunningToolCall, } from './conversation.ts' import type { PendingInteraction } from './pending.ts' import { PendingWait } from './pending.ts' @@ -27,6 +27,10 @@ import type { ProjectionsBaseline } from './projection-store.ts' /** Messages requested per history page. */ export const PAGE_MESSAGES = 50 +// Browser bundles cannot value-import the host timeout library. This protocol +// bound is pinned to @deepseek-ai/dsh-timeout's MAX_TIMER_DELAY_MS in tests. +const MAX_RETRY_DELAY_MS = 2_147_483_647 + /** Manager-owned observers of a Session object's local state edges. */ export interface SessionOptions { /** @@ -637,6 +641,7 @@ export class Session implements SessionFace { kind: 'model-retry', seq: event.seq, time: event.time, + retryState: 'scheduled', ...data, }) this.derivedRev++ @@ -702,6 +707,10 @@ export class Session implements SessionFace { return } switch (event.type) { + case 'turn/start': { + if (event.data.trigger.kind === 'retry') this.settleScheduledRetry('started') + return + } case 'assistant/chunk': { const { turn, step, chunk } = event.data if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) { @@ -730,6 +739,9 @@ export class Session implements SessionFace { return } case 'turn/end': { + if (event.data.reason.kind === 'aborted' || event.data.reason.kind === 'disposed') { + this.settleScheduledRetry('cancelled', event.data.turn) + } // Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it // into an interrupted terminal node (pulse stops, text survives) instead of deleting it. // Shared by live and window-replay paths, so a refresh reconstructs the same frozen node @@ -771,6 +783,27 @@ export class Session implements SessionFace { } } + /** + * Settle the newest scheduled retry, optionally restricted to its failed turn. + * @param retryState - next client projection state to publish. + * @param turn - failed turn required for cancellation; omitted for the next retry turn start. + */ + private settleScheduledRetry( + retryState: Exclude, + turn?: number, + ): void { + const index = this.derivedNodes.findLastIndex(node => + node.kind === 'model-retry' + && node.retryState === 'scheduled' + && (turn === undefined || node.turn === turn)) + if (index < 0) return + const node = this.derivedNodes[index] + /* v8 ignore next -- findLastIndex's predicate narrows the indexed node only at runtime. */ + if (node?.kind !== 'model-retry') return + this.derivedNodes[index] = { ...node, retryState } + this.derivedRev++ + } + /** Re-derive state (partial/openCalls/derivedNodes) from raw window events after a rebuild — keeps * paging/stitching consistent, and makes live handling and history replay converge on the same * retry notices and interrupted nodes. */ @@ -854,37 +887,49 @@ function parseRetryEventData(value: unknown): LlmRetryEventData | null { const failure = data.failure if (failure === null || typeof failure !== 'object') return null const failureData = failure as Record - if (!nonNegativeInteger(data.turn) - || !nonNegativeInteger(data.step) + if (!nonNegativeSafeInteger(data.turn) + || !nonNegativeSafeInteger(data.step) || typeof data.provider !== 'string' || data.provider.length === 0 || typeof data.policyKey !== 'string' || data.policyKey.length === 0 - || !positiveInteger(data.retry) + || !positiveSafeInteger(data.retry) || typeof data.delayMs !== 'number' || !Number.isFinite(data.delayMs) || data.delayMs < 0 + || data.delayMs > MAX_RETRY_DELAY_MS || typeof failureData.message !== 'string' - || typeof failureData.code !== 'string') return null + || failureData.message.length === 0 + || typeof failureData.code !== 'string' + || failureData.code.length === 0) return null if (data.mode === 'normal') { - if (!positiveInteger(data.maxRetries) || data.retry > data.maxRetries) return null + if (!positiveSafeInteger(data.maxRetries) || data.retry > data.maxRetries) return null } else if (data.mode === 'always') { if ('maxRetries' in data) return null } else { 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 + if (failureData.status !== undefined + && (typeof failureData.status !== 'number' + || !Number.isInteger(failureData.status) + || failureData.status < 100 + || failureData.status > 599)) return null + if (failureData.providerRetryAfterMs !== undefined + && (typeof failureData.providerRetryAfterMs !== 'number' + || !Number.isFinite(failureData.providerRetryAfterMs) + || failureData.providerRetryAfterMs <= 0)) return null + if (failureData.requestId !== undefined + && (typeof failureData.requestId !== 'string' + || failureData.requestId.length === 0)) 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 nonNegativeSafeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 } -function positiveInteger(value: unknown): value is number { - return nonNegativeInteger(value) && value > 0 +function positiveSafeInteger(value: unknown): value is number { + return nonNegativeSafeInteger(value) && value > 0 } /** diff --git a/packages/client/runtime/tests/event-script.ts b/packages/client/runtime/tests/event-script.ts index d77c3a7251..b96fffaae9 100644 --- a/packages/client/runtime/tests/event-script.ts +++ b/packages/client/runtime/tests/event-script.ts @@ -81,7 +81,7 @@ export const ev = { failure: { code: 'TRANSPORT', message }, }, }), - turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent => + turnEnd: (seq: number, turn: number, reason: 'completed' | 'aborted' | 'disposed' = 'completed'): SessionEvent => at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }), commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent => at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }), diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 99bd9bf0c3..df465b063f 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -8,6 +8,7 @@ import { describe, expect, it, vi } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { Session } from '../src/client/sessions/session.ts' import { FakeApiClient, deferred, err, ok } from './fake-api.ts' @@ -179,6 +180,7 @@ describe('live event path', () => { expect(snapshot.partial).toBeNull() expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'model-retry', + retryState: 'scheduled', turn: 1, step: 0, provider: 'fake', @@ -194,6 +196,7 @@ describe('live event path', () => { 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(-2)).toMatchObject({ kind: 'model-retry', retryState: 'started' }) expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '完整回复' }] }) const replay = makeSession() @@ -203,31 +206,88 @@ describe('live event path', () => { expect(replay.session.getSnapshot().partial).toBeNull() }) - it('ignores malformed retry payloads without retracting the current partial', async () => { + it('rejects retry payloads outside the producer contract 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 valid = { + turn: 1, step: 0, + provider: 'fake', mode: 'normal', policyKey: 'fake-normal', + retry: 1, maxRetries: 2, delayMs: 500, + failure: { code: 'TRANSPORT', message: 'temporary failure' }, + } + const invalid = [ + { ...valid, turn: Number.MAX_SAFE_INTEGER + 1 }, + { ...valid, step: Number.MAX_SAFE_INTEGER + 1 }, + { ...valid, provider: '' }, + { ...valid, policyKey: '' }, + { ...valid, retry: Number.MAX_SAFE_INTEGER + 1 }, + { ...valid, maxRetries: Number.MAX_SAFE_INTEGER + 1 }, + { ...valid, delayMs: -1 }, + { ...valid, delayMs: Number.POSITIVE_INFINITY }, + { ...valid, delayMs: MAX_TIMER_DELAY_MS + 1 }, + { ...valid, failure: { ...valid.failure, message: '' } }, + { ...valid, failure: { ...valid.failure, code: '' } }, + { ...valid, failure: { ...valid.failure, status: '429' } }, + { ...valid, failure: { ...valid.failure, status: 99 } }, + { ...valid, failure: { ...valid.failure, status: 429.5 } }, + { ...valid, failure: { ...valid.failure, status: 600 } }, + { ...valid, failure: { ...valid.failure, providerRetryAfterMs: 0 } }, + { ...valid, failure: { ...valid.failure, providerRetryAfterMs: Number.POSITIVE_INFINITY } }, + { ...valid, failure: { ...valid.failure, requestId: 1 } }, + { ...valid, failure: { ...valid.failure, requestId: '' } }, + ] const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) try { - feed(at(9, { - type: 'llm/retry', - data: { - turn: 1, step: 0, - provider: 'fake', mode: 'normal', policyKey: 'fake-normal', - retry: 3, maxRetries: 2, delayMs: 500, - failure: { code: 'TRANSPORT', message: 'bad budget' }, - }, - })) + for (const [index, data] of invalid.entries()) { + feed(at(9 + index, { type: 'llm/retry', data })) + } expect(session.getSnapshot().partial?.blocks).toEqual([{ kind: 'text', text: '仍在生成' }]) expect(session.getSnapshot().nodes.filter(node => node.kind === 'model-retry')).toEqual([]) + expect(errorSpy).toHaveBeenCalledTimes(invalid.length) expect(errorSpy).toHaveBeenCalledWith('[web-runtime] ignored malformed llm/retry event at seq 9') } finally { errorSpy.mockRestore() } }) + it('accepts complete retry payloads at the producer field boundaries', async () => { + const { session } = await opened() + session.handleMuxEnvelope('r' as never, { + type: 'session/event', + sessionId: SID, + event: at(6, { + type: 'llm/retry', + data: { + turn: Number.MAX_SAFE_INTEGER, + step: Number.MAX_SAFE_INTEGER, + provider: 'fake', + mode: 'normal', + policyKey: 'fake-normal', + retry: Number.MAX_SAFE_INTEGER, + maxRetries: Number.MAX_SAFE_INTEGER, + delayMs: MAX_TIMER_DELAY_MS, + failure: { + code: 'RATE_LIMIT', + message: 'provider busy', + status: 599, + providerRetryAfterMs: Number.MIN_VALUE, + requestId: 'req-1', + }, + }, + }), + }) + expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ + kind: 'model-retry', + retryState: 'scheduled', + retry: Number.MAX_SAFE_INTEGER, + delayMs: MAX_TIMER_DELAY_MS, + failure: { status: 599, providerRetryAfterMs: Number.MIN_VALUE, requestId: 'req-1' }, + }) + }) + it('projects always-mode retries and rejects mode-specific maximums or unknown modes', async () => { const { session } = await opened() const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } @@ -244,6 +304,7 @@ describe('live event path', () => { })) expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ kind: 'model-retry', + retryState: 'scheduled', mode: 'always', retry: 3, }) @@ -273,6 +334,27 @@ describe('live event path', () => { } }) + it.each(['aborted', 'disposed'] as const)( + 'marks a scheduled retry as cancelled when its failed turn ends %s', + async (reason) => { + 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.retry(7, 1)) + expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ + kind: 'model-retry', + retryState: 'scheduled', + }) + feed(ev.turnEnd(8, 1, reason)) + expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ + kind: 'model-retry', + retryState: 'cancelled', + }) + }, + ) + 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 }) } @@ -280,7 +362,7 @@ describe('live event path', () => { feed(ev.user(7, '要被打断的')) feed(ev.chunkStart(8, 1)) feed(ev.chunkText(9, 1, '说到一半')) - feed(ev.turnEnd(10, 1, 'cancelled')) // no assistant/message ever arrives + feed(ev.turnEnd(10, 1, 'aborted')) // no assistant/message ever arrives const snapshot = session.getSnapshot() expect(snapshot.partial).toBeNull() const frozen = snapshot.nodes.at(-1) @@ -299,7 +381,7 @@ describe('live event path', () => { expect(session.getSnapshot().runningCalls).toEqual([]) // Second call never resolves: turn/end freezes it as an error card. feed(ev.toolCall(9, 1, 'c2', 'slow_tool', '{}')) - feed(ev.turnEnd(10, 1, 'cancelled')) + feed(ev.turnEnd(10, 1, 'aborted')) const snapshot = session.getSnapshot() expect(snapshot.runningCalls).toEqual([]) expect(snapshot.nodes.at(-1)).toMatchObject({ @@ -615,7 +697,7 @@ describe('remaining branches', () => { 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)) // empty text block only, no delta - feed(ev.turnEnd(8, 1, 'cancelled')) + feed(ev.turnEnd(8, 1, 'aborted')) const snapshot = session.getSnapshot() expect(snapshot.partial).toBeNull() expect(snapshot.nodes.filter(n => n.kind === 'assistant' && (n as { interrupted?: true }).interrupted)).toEqual([]) @@ -629,7 +711,7 @@ describe('remaining branches', () => { feed(ev.turnStart(6, 1)) feed(ev.toolCall(7, 1, 'turn1-call', 'echo', '{}')) feed(ev.toolCall(8, 2, 'turn2-call', 'echo', '{}')) // stray call attributed to a later turn - feed(ev.turnEnd(9, 1, 'cancelled')) + feed(ev.turnEnd(9, 1, 'aborted')) const snapshot = session.getSnapshot() expect(snapshot.runningCalls.map(c => c.callId)).toEqual(['turn2-call']) expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'tool-result', callId: 'turn1-call', isError: true }) @@ -723,7 +805,7 @@ describe('remaining branches', () => { const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } feed(ev.turnStart(6, 1)) feed(at(7, { type: 'assistant/chunk', data: { turn: 1, step: 0, chunk: { type: 'tool-call-delta', index: 0, id: 'c1', name: 'echo', argumentsDelta: '{' } } })) - feed(ev.turnEnd(8, 1, 'cancelled')) + feed(ev.turnEnd(8, 1, 'aborted')) const frozen = session.getSnapshot().nodes.at(-1) expect(frozen).toMatchObject({ kind: 'assistant', interrupted: true, blocks: [{ kind: 'tool-call', callId: 'c1' }] }) }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index ffc82ea731..4e7f3e017e 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: cd72aaca7c82a02777ededdf542bf5272359fb77 -README.zh.md: ffbeace6d5b7c6af8b4ce6e50e643ea65f446584 +README.md: 408a76ed86c6a48762db2bae8534e950219c816a +README.zh.md: 68128c7a4c271b491e4a1463e3c7ce4a4e86b1eb diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index cd72aaca7c..408a76ed86 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -10,7 +10,7 @@ The view ring IS a slot: the conversation registration declares the `'conversati 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. -The chat flow projects consecutive model-retry nodes across retry turns 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. Normal policy rows show the finite retry maximum; always policy rows show `∞`. 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. +The chat flow projects consecutive model-retry nodes across retry turns 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 anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. 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`/`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 ffbeace6d5..68128c7a4c 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -10,7 +10,7 @@ 通用工具行把内置的 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 变体的可展开源码渲染。 -聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时由计划延迟派生,剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画,完成后则稳定显示为静态的已完成标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。 +聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试,Host 的 running 位只控制实时动画;随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。 工具行同样是 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/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index c45c6f1c8c..cafbf4dfcd 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -50,7 +50,7 @@ function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): n for (let index = nodes.length - 1; index >= 0; index -= 1) { const node = nodes[index] if (node === undefined) continue - if (node.kind === 'model-retry') return node.seq + if (node.kind === 'model-retry') return node.retryState === 'cancelled' ? null : node.seq if (node.kind === 'assistant' || node.kind === 'user') return null } return null diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index cdd0efacad..5c3c99d1e0 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -4,7 +4,7 @@ // off the snapshot cache; memo holds across streaming because unchanged nodes // keep their references. -import { memo, useCallback, useEffect, useState, type ReactNode } from 'react' +import { memo, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' import type { ContextMessageNode, ModelRetryNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' @@ -40,7 +40,9 @@ interface RetryCountdown { } function ModelRetryItem({ node, active }: { node: ModelRetryNode; active: boolean }) { - const deadline = node.time + node.delayMs + // Anchor the host-scheduled delay to this browser's first render of the + // retry node. Host event time and Date.now() may belong to different clocks. + const deadline = useMemo(() => Date.now() + node.delayMs, [node.delayMs, node.seq]) const scheduledSeconds = retrySeconds(node.delayMs) const maximum = node.mode === 'normal' ? node.maxRetries : '∞' const [countdown, setCountdown] = useState(() => ({ @@ -69,11 +71,19 @@ function ModelRetryItem({ node, active }: { node: ModelRetryNode; active: boolea return () => { window.clearInterval(timer) } }, [active, deadline]) + const label = active + ? '正在重试模型请求' + : node.retryState === 'cancelled' + ? '模型请求重试已取消' + : node.retryState === 'started' + ? '已重试模型请求' + : '等待重试模型请求' + return (
- {active ? '正在重试' : '已重试'}模型请求({node.retry}/{maximum}) · {active ? remainingSeconds : scheduledSeconds}s + {label}({node.retry}/{maximum}) · {active ? remainingSeconds : scheduledSeconds}s
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 7eda67ba91..502602c073 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -125,6 +125,7 @@ describe('MessageItem arms', () => { kind: 'model-retry', seq: 5, time: 10_000, + retryState: 'scheduled', turn: 1, step: 0, provider: 'mock', @@ -157,6 +158,7 @@ describe('MessageItem arms', () => { kind: 'model-retry', seq: 6, time: 12_100, + retryState: 'scheduled', turn: 2, step: 0, provider: 'mock', @@ -180,6 +182,7 @@ describe('MessageItem arms', () => { kind: 'model-retry', seq: 6, time: 12_100, + retryState: 'started', turn: 2, step: 0, provider: 'mock', @@ -200,6 +203,7 @@ describe('MessageItem arms', () => { kind: 'model-retry', seq: 7, time: 12_100, + retryState: 'started', turn: 3, step: 0, provider: 'mock', @@ -212,6 +216,26 @@ describe('MessageItem arms', () => { />, ) expect(view.getByRole('status').textContent).toBe('已重试模型请求(3/∞) · 4s') + + view.rerender( + , + ) + expect(view.getByRole('status').textContent).toBe('模型请求重试已取消(1/2) · 4s') }) it('synchronizes the countdown when an inactive retry becomes active at the one-second floor', () => { @@ -221,6 +245,7 @@ describe('MessageItem arms', () => { kind: 'model-retry', seq: 5, time: 10_000, + retryState: 'scheduled', turn: 1, step: 0, provider: 'mock', @@ -232,7 +257,7 @@ describe('MessageItem arms', () => { failure: { code: 'TRANSPORT', message: '连接被重置' }, } as const const view = render() - expect(view.getByRole('status').textContent).toBe('已重试模型请求(1/2) · 5s') + expect(view.getByRole('status').textContent).toBe('等待重试模型请求(1/2) · 5s') act(() => { vi.advanceTimersByTime(4_200) }) view.rerender() diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 9ae2a6519d..8bc196aa80 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -67,6 +67,7 @@ const assistant = (seq: number, text: string): AssistantMessageNode => ({ }) const retry = (seq: number): ModelRetryNode => ({ kind: 'model-retry', seq, time: seq * 1_000, turn: 1, step: 0, + retryState: 'scheduled', provider: 'mock', mode: 'normal', policyKey: 'mock-normal', retry: 1, maxRetries: 2, delayMs: 450, failure: { code: 'TRANSPORT', message: '连接被重置' }, @@ -232,15 +233,25 @@ describe('ChatView', () => { expect(view.getByRole('status').textContent).toBe('正在重试模型请求(2/2) · 1s') act(() => { - h.set({ nodes: [user(1, 'try'), retryNode, nextRetry, context, assistant(5, 'done')] }) + h.set({ + nodes: [ + user(1, 'try'), + retryNode, + { ...nextRetry, retryState: 'started' }, + context, + assistant(5, 'done'), + ], + running: false, + }) }) 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 }) + h.set({ nodes: [user(1, 'try'), { ...retry(6), retryState: 'cancelled' }], running: true }) }) expect(disclosure?.dataset.active).toBeUndefined() + expect(view.getByRole('status').textContent).toContain('重试已取消') }) it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => { diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index b07fe7a1fd..57cff40b19 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/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: 9e7dee8d5572baad13d7598d1fbde7b042dd5ab4 -README.zh.md: da14d5265c16acb4e75d21bc445ef9702c83e697 +# pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md +README.md: fa8dee1b40c661f46a8e9962bd4606267b030a23 +README.zh.md: 0e4c6071d7cff04d2f1b40945332d9b66ca2da1b diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 9e7dee8d55..fa8dee1b40 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Trajectory turn-list chrome (sticky Turn / Message·Step groups / step cells) plus Waterfall placeholder; the pure-consumer minimal plugin exemplar (registers two view tabs into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8. +Trajectory turn-list chrome (sticky Turn / Message·Step groups / step cells) plus Waterfall placeholder; the pure-consumer minimal plugin exemplar (registers two view tabs into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Conversation nodes carrying a turn, including model-retry notices without assistant output, anchor their own trajectory span instead of inheriting the preceding turn. Contract: api-contracts v3 §8. ## Model Experience diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index da14d5265c..0e4c6071d7 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -轨迹轮次列表 chrome(吸顶 Turn/Message·Step 分组/步骤单元格)及 Waterfall 占位符;这是纯消费方最小插件范例(向会话的 `'conversation.view'` slot 环注册两个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。 +轨迹轮次列表 chrome(吸顶 Turn/Message·Step 分组/步骤单元格)及 Waterfall 占位符;这是纯消费方最小插件范例(向会话的 `'conversation.view'` slot 环注册两个视图标签页,不提供服务,也不声明 Context 合并)。携带轮次的会话节点(包括没有 assistant 输出的模型重试提示)会锚定自身的轨迹区段,而不会继承前一个轮次。契约:api-contracts v3 §8。 ## 模型体验 diff --git a/packages/client/ui-trajectory/src/client/spans.ts b/packages/client/ui-trajectory/src/client/spans.ts index 585a336333..e52c152725 100644 --- a/packages/client/ui-trajectory/src/client/spans.ts +++ b/packages/client/ui-trajectory/src/client/spans.ts @@ -43,8 +43,9 @@ export interface SpanStats { /** * Fold snapshot nodes into per-turn spans. Only assistant nodes carry a turn - * number; user/steering/context/tool nodes attach to the turn last seen in - * sequence order (turn 0 collects the pre-assistant prologue). + * number; retry and steering nodes also carry their owning turn, while + * user/context/tool nodes attach to the turn last seen in sequence order + * (turn 0 collects the pre-assistant prologue). * @param nodes - snapshot nodes in surface order. * @returns spans ordered by first appearance. */ @@ -85,7 +86,7 @@ export function deriveSpanStats(spans: readonly TurnSpan[]): SpanStats { } function hasTurn(node: ConversationNode): node is ConversationNode & { turn: number } { - return node.kind === 'assistant' || node.kind === 'steering' + return node.kind === 'assistant' || node.kind === 'steering' || node.kind === 'model-retry' } /** diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 917beaccc6..432b06ccc1 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -208,18 +208,21 @@ describe('tab switching in ConversationRoot', () => { }) describe('span derivation', () => { - it('attributes prologue to turn 0 and follows steering turn tags', () => { + it('attributes prologue to turn 0 and follows steering and retry turn tags', () => { const nodes = [ { kind: 'user', seq: 1 }, { kind: 'steering', seq: 2, turn: 5 }, { kind: 'user', seq: 3 }, + { kind: 'model-retry', seq: 4, turn: 6 }, + { kind: 'user', seq: 5 }, ] as unknown as ConversationSnapshot['nodes'] const spans = deriveSpans(nodes) expect(spans).toEqual([ { turn: 0, steps: 0, calls: 0, nodes: 1 }, { turn: 5, steps: 0, calls: 0, nodes: 2 }, + { turn: 6, steps: 0, calls: 0, nodes: 2 }, ]) - expect(deriveSpanStats(spans)).toEqual({ turns: 2, steps: 0, calls: 0 }) + expect(deriveSpanStats(spans)).toEqual({ turns: 3, steps: 0, calls: 0 }) }) it('empty inputs produce zero stats and standalone components render their empty forms', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bbb55d747..430dac9073 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -935,6 +935,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout '@types/react': specifier: ~18.3.1 version: 18.3.31 From 751b970997fe340340ae9a2176db2c46da159cc2 Mon Sep 17 00:00:00 2001 From: kingwl Date: Thu, 30 Jul 2026 12:25:48 +0800 Subject: [PATCH 03/35] fix(web): align queue panel with responsive composer --- apps/web/tests/queue-actions.e2e.ts | 20 +++++++++++++++++++ .../src/client/queue/QueueDock.module.css | 17 +++++++++++++--- .../src/client/queue/QueueDock.tsx | 2 +- .../skeleton/ConversationRoot.module.css | 5 +++++ .../src/client/skeleton/HeroShell.module.css | 4 ++-- .../src/client/skeleton/InputBar.module.css | 9 +++++---- .../src/client/skeleton/TodoPanel.module.css | 18 +++++++++++++---- 7 files changed, 61 insertions(+), 14 deletions(-) diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index be5e73f9a6..19a5005e50 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -85,6 +85,26 @@ describe('web e2e: queue row actions', () => { { timeout: 10_000 }, ).toBe(2) + await page.setViewportSize({ width: 640, height: 1000 }) + const queueBox = await page.locator('[data-queue-dock]').boundingBox() + const composerBox = await page.locator('[data-composer-card]').boundingBox() + expect(queueBox).not.toBeNull() + expect(composerBox).not.toBeNull() + expect(queueBox!.x).toBeGreaterThanOrEqual(composerBox!.x) + expect(queueBox!.x + queueBox!.width) + .toBeLessThanOrEqual(composerBox!.x + composerBox!.width) + const queueLeftInset = queueBox!.x - composerBox!.x + const queueRightInset = composerBox!.x + composerBox!.width - queueBox!.x - queueBox!.width + const composerMetrics = await page.locator('[data-composer-card]').evaluate((element) => { + const style = getComputedStyle(element) + return { + dockInset: Number.parseFloat(style.getPropertyValue('--dsh-composer-dock-inset')), + } + }) + expect(queueLeftInset).toBeCloseTo(composerMetrics.dockInset, 1) + expect(queueRightInset).toBeCloseTo(composerMetrics.dockInset, 1) + await page.setViewportSize({ width: 1680, height: 1000 }) + const editRow = page.getByText(EDIT, { exact: true }).locator('..') await editRow.getByRole('button', { name: '编辑排队消息' }).click() const editor = page.getByRole('textbox', { name: '编辑排队消息' }) diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css index 4c05c2cbca..5a918a3c6d 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -1,10 +1,21 @@ -/* Figma .FileContainerText 1:791: 776px wrapper around the inset 752px panel. */ +/* Figma .FileContainerText 1:791: the wrapper uses the shared dock inset + inside the composer card around the inset panel. */ .dock { box-sizing: border-box; flex: none; - width: 100%; - max-width: 776px; + width: calc( + 100% - + var(--dsh-composer-side-clearance) - + var(--dsh-composer-side-clearance) - + var(--dsh-composer-dock-inset) - + var(--dsh-composer-dock-inset) + ); + max-width: calc( + var(--dsh-composer-card-max-width) - + var(--dsh-composer-dock-inset) - + var(--dsh-composer-dock-inset) + ); /* Eat InputBar's 6px top padding and tuck the panel 2px under the card; the later composer sibling paints its surface and shadow over this edge. */ margin: 0 auto -10px; diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index 99b91f301d..b55ef8b007 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -61,7 +61,7 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) { } return ( -
+
    {queue.map(row => ( diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 272bbae873..a3ba4b15da 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -131,6 +131,11 @@ .composerStack { display: flex; flex-direction: column; + /* InputBar and dock registrants derive their horizontal geometry from the + same card width, outer clearance, and dock inset. */ + --dsh-composer-card-max-width: 800px; + --dsh-composer-side-clearance: 32px; + --dsh-composer-dock-inset: 12px; } /* Common seat for the composer chain (fallback + elected overlay siblings). */ diff --git a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css index 71b90bfdfa..523f640c0b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css @@ -11,7 +11,7 @@ padding: 0 24px; } -/* Cap matches InputBar card width (800). Glow may paint past the sides. */ +/* Cap matches the InputBar card. Glow may paint past the sides. */ .stack { display: flex; flex-direction: column; @@ -19,7 +19,7 @@ /* figma 75:8208: 12 between title block / workspace / card. */ gap: 12px; width: 100%; - max-width: 800px; + max-width: var(--dsh-composer-card-max-width); overflow: visible; } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 8837752830..401bbb73d8 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -23,7 +23,7 @@ /* figma Input_Bottom: pad L32/R32/B12; the bottom gradient mask is owned by the chat scroller. Top 6 is the gap under the dock todo strip (12px todo margin + 6px here); error/status strips still carry their own margin. */ - padding: 6px 32px 12px; + padding: 6px var(--dsh-composer-side-clearance) 12px; } .hero { @@ -33,7 +33,7 @@ .error, .status { width: 100%; - max-width: 800px; + max-width: var(--dsh-composer-card-max-width); margin-bottom: 6px; padding: 4px 8px; border-radius: 8px; @@ -48,7 +48,7 @@ .notice { width: 100%; - max-width: 800px; + max-width: var(--dsh-composer-card-max-width); margin-bottom: 6px; padding: 4px 8px; border-radius: 8px; @@ -69,6 +69,7 @@ } .card { + box-sizing: border-box; position: relative; /* overlay anchor positioning context */ display: flex; flex-direction: column; @@ -76,7 +77,7 @@ top pad on the card before .InputText. */ gap: 12px; width: 100%; - max-width: 800px; + max-width: var(--dsh-composer-card-max-width); padding-top: 10px; /* Input stroke: black/0.10 light, white/0.06 dark (figma darkmode note says the input border is one notch weaker than buttons) — exactly the diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css index 7b506b5553..94abf3871a 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css @@ -1,13 +1,23 @@ /* Todo strip above the composer (figma 772:51905 / 772:52972 / 772:53419): - tip surface, 14px radius, status icons + secondary item labels. Column is - calc(100% - 88px) / max 776, centered; InputBar top pad supplies the gap. */ + tip surface, 14px radius, status icons + secondary item labels. It shares + the composer card geometry and adds the dock inset on both sides. */ .root { flex: none; overflow: hidden; margin: 0 auto; - width: calc(100% - 88px); - max-width: 776px; + width: calc( + 100% - + var(--dsh-composer-side-clearance) - + var(--dsh-composer-side-clearance) - + var(--dsh-composer-dock-inset) - + var(--dsh-composer-dock-inset) + ); + max-width: calc( + var(--dsh-composer-card-max-width) - + var(--dsh-composer-dock-inset) - + var(--dsh-composer-dock-inset) + ); border: 1px solid var(--dsw-alias-border-l1); border-radius: 14px; background: var(--dsw-specific-tip); From 544d3f826741ef74172097d3016b43cbfa5f5130 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Thu, 30 Jul 2026 15:52:20 +0800 Subject: [PATCH 04/35] docs: refresh root README for current capabilities --- ...-07-22-product-first-root-readme.i18n.yaml | 6 ++ .../2026-07-22-product-first-root-readme.md | 35 ++++++++ ...2026-07-22-product-first-root-readme.zh.md | 35 ++++++++ README.i18n.yaml | 4 +- README.md | 83 ++++++++---------- README.zh.md | 85 +++++++------------ .../request-response.expected.json | 4 +- 7 files changed, 147 insertions(+), 105 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-22-product-first-root-readme.md create mode 100644 .agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md 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 new file mode 100644 index 0000000000..bd424e628d --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.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/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 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 new file mode 100644 index 0000000000..bed7d3b49c --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md @@ -0,0 +1,35 @@ +# Agent Note: Product-first root README + +Status: implemented + +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. + +## 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. + +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. + +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. + +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. + +## 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. + +**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. + +## 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. 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 new file mode 100644 index 0000000000..fbfb726b2c --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 产品优先的根 README + +Status: implemented + +[English](2026-07-22-product-first-root-readme.md) | 中文 + +## 问题 + +根 README 是仓库的产品入口,但仅以产品视角描述 coding agent(编程智能体)会掩盖 SDK 与当前运行时的广度,而以 SDK 为先的包(package)清单则会把启动可运行 agent 的最短路径放到后面。如果把 README 当作通用营销文案,而非持续维护的产品契约,命令、能力声明和入口描述也会逐渐漂移。 + +## 决策 + +根 README 将 DeepSeek Harness 定义为以插件为原生构成单元的 coding agent 运行时,同时交付可组合的 SDK 与组装完成的 `dsh` agent。它将使命定位与已交付事实分开,并首先给出受支持的单行安装命令。 + +安装命令之前的一则说明感谢早期用户,坦率说明内测版本仍未完成、整体完成度还很低,距离团队希望交付的体验还有差距,并邀请用户直接反馈故障、困惑和所有不顺手之处。它明确这些不足是产品的问题,而非用户的问题;紧接其后的预发布提醒则清楚说明兼容性边界。 + +README 列出 TUI、Web、Headless、ACP(Agent Client Protocol)以及 Python/JSON-RPC 入口,并为每个入口提供命令或归属文档链接。它按编码、编排和运维三个类别概述能力,同时说明每种组合都自行选择插件。包与服务的完整清单仍由生成图和包分组文档维护。 + +以插件为原生构成单元是 README 的组织原则,而非一句口号:README 通过 `cordis.yml` 将可替换服务、类型化事件与组合方式关联起来,并明确面向模型的行为、持久化、回放、查询、遥测和 UI 投影都以权威会话日志为基础。详细契约仍由架构文档、CLI(命令行界面)、示例、实操手册(cookbook)和生成目录各自维护。 + +中英文 README 采用相同的技术结构。两侧的社区章节分别指向各自语言受众的主要交流渠道。文档网站继续使用独立的用户指南首页;仓库 README 不加入该投影。 + +## 考虑过的替代方案 + +**只展示组装完成的 coding agent。** 这样能给出最简短的产品介绍,但会让 SDK、其他入口和可替换的运行时 seam 显得无足轻重,尽管它们都是仓库中已经交付的组成部分。 + +**将仓库呈现为 SDK 和包清单。** 这样能立即展现实现广度,却会迫使新读者从包名反推出产品。包索引与生成的能力图仍是权威清单。 + +**使用包含截图、徽章和重复教程的长篇营销页面。** 富媒体能够展示稳定的产品使用路径,但其内容会独立于命令和源码契约而逐渐陈旧。根 README 保持紧凑,并链接到可运行示例和各自维护的指南。 + +**将根 README 投影为文档网站首页。** 使用同一个首页可以避免两套叙事,但文档网站的用户指南与仓库面向开发者和产品的入口在导航和维护需求上并不相同。两者继续作为独立来源,并链接到相同的架构文档和指南。 + +## 结果 + +新读者可以在了解包拓扑之前完成安装或选择运行时入口,SDK 读者也能理解扩展模型,而无需把生成目录复制进正文。任何受影响的命令、入口、预发布边界或高层能力类别发生变化时,README 都必须同步更新;每项声明都可以依据源码或归属文档进行评审核验。 diff --git a/README.i18n.yaml b/README.i18n.yaml index 7584d4f293..590995b989 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: f9f7294b42e29132d5cd46c0ab6a5f5265a1d8f3 -README.zh.md: 88cbf8522d8f1a183a48dc7e80858d1a0ced8f0f +README.md: 4f87a95ecd921748e5ac8b93884ffe1254cb3ddf +README.zh.md: fee6e4ad1c08ac7a9b99e6d7479454e00cf431cf diff --git a/README.md b/README.md index f9f7294b42..4f87a95ecd 100644 --- a/README.md +++ b/README.md @@ -2,81 +2,66 @@ English | [中文](README.zh.md) -DeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK. +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. -It uses an architecture where **everything is a plugin**. +**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. -## Install +## Before you begin, thank you -Install `dsh` with one command: +Thank you for taking the time to try DeepSeek Harness. It is still in internal testing, and it is far from complete. It is nowhere near the product we want to ship. Some features are unfinished, and some parts are rough to use. Problems that show up in real use may lead us to rethink designs we have today. + +We will keep working to get these parts right, and we want to hear what using it is actually like. Please tell us plainly where it fails. We also want to know what is confusing or gets in your way. If it does not help you, or makes your work harder, we have not done our job. The specific problems you run into and any suggestions you have will help us decide what to fix first. Thank you for spending time with it before it is ready, and for helping us make it better one step at a time. + +> **Pre-release notice:** Package APIs, configuration, and persisted formats may change without compatibility shims until the first tagged release. + +## Start in 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` when it is missing, and prompts for a DeepSeek API key. +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 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. +## Choose a surface -## Use DeepSeek Harness +| 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 | -### Web UI +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. -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): +## What ships -```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 -``` +Capabilities are selected by composition. The repository's shipped plugins cover: -The Web UI is served at `http://127.0.0.1:3080` by default. +- **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. -### TUI +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. -Start the full-screen terminal interface: +## Extend the harness -```sh -dsh -``` +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. -### Headless +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. -Run one task, print the final answer, and exit: - -```sh -dsh -p "summarize this workspace" -``` - -## Why DeepSeek Harness - -Built-in capabilities cover file reading, editing, and search; shell execution; reusable skills; task tracking; subagents and workflows; persistent sessions; and context compaction. 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. -- **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 Twitter for project updates. - -## Development +## Develop ```sh pnpm install -pnpm run test:coverage +pnpm run demo:tui ``` -Start with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages. +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. -For agents, follow [AGENTS.md](AGENTS.md). +## Community -DeepSeek Harness is currently pre-release. +Follow DeepSeek Harness on X for project updates. ## License diff --git a/README.zh.md b/README.zh.md index 88cbf8522d..fee6e4ad1c 100644 --- a/README.zh.md +++ b/README.zh.md @@ -2,85 +2,66 @@ [English](README.md) | 中文 -DeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。 +DeepSeek Harness 是一个面向 coding agent(智能体)的开源、插件原生运行时。本仓库同时提供可组合的 SDK,以及由同一组包(package)组装而成、可直接运行的 agent `dsh`。 -它采用了**一切皆插件**的架构。 +**使命。** 构建能力强大的 agent 产品,而不把产品选择硬编码到单一循环中。模型、工具、策略、存储、上下文、接口,乃至循环本身,都是 [Cordis 插件](docs/architecture.md);会话日志是权威记录,模型历史、持久化、回放、查询、遥测和 UI 均从中派生。 -## 安装 +## 使用前,想先说声谢谢 -使用一条命令安装 `dsh`: +感谢你愿意花时间试用 DeepSeek Harness。它还在内测,整体完成度不高,也远没有达到我们想交付的样子。有些功能还没做完,有些地方用起来会很粗糙。真实使用中暴露出来的问题,也可能让我们推翻现在的设计。 + +我们会继续认真把这些地方做好,也希望你能把真实感受直接告诉我们。哪里失败了,哪里让你困惑或不好用,都请直说。如果它没有帮到你,反而给工作添了麻烦,那就是我们没有做好。你遇到的具体问题和任何建议,都会帮助我们判断接下来先改什么。谢谢你愿意在它还不成熟的时候花时间试用,也谢谢你愿意和我们一起把它一点点做好。 + +> **预发布说明:** 在首个带标签的版本发布之前,包 API、配置和持久化格式可能直接变更,不提供兼容层。 + +## 一条命令开始 ```sh curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh ``` -安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。 +安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,可代为安装 `pnpm`,会提示输入 DeepSeek API 密钥,并在当前目录启动 TUI。它把受管检出放在 `~/.dsh/source` 下;再次运行同一命令即可更新。其他安装位置和非交互选项见 [`scripts/install.sh`](scripts/install.sh)。 -安装器会把所有检出都放在 `~/.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)。 +## 选择使用方式 -## 使用 DeepSeek Harness +| 使用方式 | 入口 | +|---|---| +| 全屏 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) 及其自带的运行时 | -### Web UI +一行安装命令可直接启动从源码运行的 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。通过 `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 -``` +能力由组合决定。本仓库交付的插件涵盖: -Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。 +- **编程:** 文件系统读写、编辑与搜索,shell 和持久 PTY 执行,LSP 导航,Web 搜索与抓取,可复用 skill(技能),以及由模型编写的 Code Mode 程序。 +- **编排:** subagent、后台任务、工作线程工作流、同一会话内的目标、计划状态、待办事项,以及向用户提问。 +- **运维:** 工作区沙箱与审批、会话持久化/恢复/fork/查询、压缩(compaction)与 spill、投影、标题,以及 OpenTelemetry 导出。 -### TUI +凡是模型可见的内容,都必须能从会话日志中重建。这样一来,不同 UI、持久化后端、回放和运维工具都成为同一事件流的消费方,而不是彼此并行的真源。 -启动全屏终端界面: +## 扩展 harness -```sh -dsh -``` +一项可替换能力通常会将接口、实现和消费方彼此分离。你可以为 `ctx.llm`、`ctx.fs`、`ctx.pty`、`ctx.web` 或 `ctx.subagents` 等服务添加或替换提供方;通过 `ctx.tools` 注册面向模型的行为;通过类型化事件挂接策略和请求整形;再在 `cordis.yml` 中组合这些部分,无需 fork agent loop(智能体循环)。 -### Headless - -运行一项任务,打印最终答案后退出: - -```sh -dsh -p "summarize this workspace" -``` - -## 为什么选择 DeepSeek Harness - -内置功能涵盖文件读取、编辑与搜索、shell 执行、可复用 skill(技能)、任务跟踪、subagent 与工作流、持久化会话,以及上下文压缩(context compaction)。TUI 还包含 Plan Mode。 - -- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。 -- **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 微信社区二维码 -

    +从[第一个插件指南](docs/user/develop/basic/index.md)和[扩展实操手册](docs/cookbook/extension-cookbook.md)开始。需要系统图时查看[架构](docs/architecture.md),需要当前服务关系时查看生成的[能力图](docs/capability-seams.md),需要所有权细节时查看[包图](packages/README.md)。 ## 开发 ```sh pnpm install -pnpm run test:coverage +pnpm run demo:tui ``` -请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。 +将 `DEEPSEEK_API_KEY` 设置在环境变量或根目录 `.env` 中。环境搭建和验证由[开发指南](docs/development.md)统一说明;修改 `packages/` 前请阅读[架构](docs/architecture.md),在本仓库工作时请遵循 [AGENTS.md](AGENTS.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 acc18f64b4..af62bdcdc1 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## 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## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell execution; reusable skills; task tracking; subagents and workflows; persistent sessions; and context compaction. 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- **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 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. It is still in internal testing, and it is far from complete. It is nowhere near the product we want to ship. Some features are unfinished, and some parts are rough to use. Problems that show up in real use may lead us to rethink designs we have today.\n\nWe will keep working to get these parts right, and we want to hear what using it is actually like. Please tell us plainly where it fails. We also want to know what is confusing or gets in your way. If it does not help you, or makes your work harder, we have not done our job. The specific problems you run into and any suggestions you have will help us decide what to fix first. Thank you for spending time with it before it is ready, and for helping us make it better one step at a time.\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" }, { "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使用一条命令安装 `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## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 执行、可复用 skill(技能)、任务跟踪、subagent 与工作流、持久化会话,以及上下文压缩(context compaction)。TUI 还包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\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 是一个面向 coding agent(智能体)的开源、插件原生运行时。本仓库同时提供可组合的 SDK,以及由同一组包(package)组装而成、可直接运行的 agent `dsh`。\n\n**使命。** 构建能力强大的 agent 产品,而不把产品选择硬编码到单一循环中。模型、工具、策略、存储、上下文、接口,乃至循环本身,都是 [Cordis 插件](docs/architecture.md);会话日志是权威记录,模型历史、持久化、回放、查询、遥测和 UI 均从中派生。\n\n## 使用前,想先说声谢谢\n\n感谢你愿意花时间试用 DeepSeek Harness。它还在内测,整体完成度不高,也远没有达到我们想交付的样子。有些功能还没做完,有些地方用起来会很粗糙。真实使用中暴露出来的问题,也可能让我们推翻现在的设计。\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" }, { "role": "user", From 254ead987116d91f397b0796fb05e5c27317d650 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Thu, 30 Jul 2026 16:10:35 +0800 Subject: [PATCH 05/35] docs: add candid preview note --- README.i18n.yaml | 4 ++-- README.md | 6 ++++-- README.zh.md | 6 ++++-- .../translation-prompt-v4/request-response.expected.json | 4 ++-- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/README.i18n.yaml b/README.i18n.yaml index 590995b989..7bf728a630 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: 4f87a95ecd921748e5ac8b93884ffe1254cb3ddf -README.zh.md: fee6e4ad1c08ac7a9b99e6d7479454e00cf431cf +README.md: 169722e07c9505c457a0ab7dbdca4e22f1178c92 +README.zh.md: d67e21daa58d203c42eab5e01414e688f2c8d1d3 diff --git a/README.md b/README.md index 4f87a95ecd..169722e07c 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,11 @@ DeepSeek Harness is an open-source, plugin-native runtime for coding agents. Thi ## Before you begin, thank you -Thank you for taking the time to try DeepSeek Harness. It is still in internal testing, and it is far from complete. It is nowhere near the product we want to ship. Some features are unfinished, and some parts are rough to use. Problems that show up in real use may lead us to rethink designs we have today. +Thank you for taking the time to try DeepSeek Harness. -We will keep working to get these parts right, and we want to hear what using it is actually like. Please tell us plainly where it fails. We also want to know what is confusing or gets in your way. If it does not help you, or makes your work harder, we have not done our job. The specific problems you run into and any suggestions you have will help us decide what to fix first. Thank you for spending time with it before it is ready, and for helping us make it better one step at a time. +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. + +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. > **Pre-release notice:** Package APIs, configuration, and persisted formats may change without compatibility shims until the first tagged release. diff --git a/README.zh.md b/README.zh.md index fee6e4ad1c..d67e21daa5 100644 --- a/README.zh.md +++ b/README.zh.md @@ -8,9 +8,11 @@ DeepSeek Harness 是一个面向 coding agent(智能体)的开源、插件 ## 使用前,想先说声谢谢 -感谢你愿意花时间试用 DeepSeek Harness。它还在内测,整体完成度不高,也远没有达到我们想交付的样子。有些功能还没做完,有些地方用起来会很粗糙。真实使用中暴露出来的问题,也可能让我们推翻现在的设计。 +感谢你愿意花时间试用 DeepSeek Harness。 -我们会继续认真把这些地方做好,也希望你能把真实感受直接告诉我们。哪里失败了,哪里让你困惑或不好用,都请直说。如果它没有帮到你,反而给工作添了麻烦,那就是我们没有做好。你遇到的具体问题和任何建议,都会帮助我们判断接下来先改什么。谢谢你愿意在它还不成熟的时候花时间试用,也谢谢你愿意和我们一起把它一点点做好。 +目前版本仅供内测,整体完成度不高,也远没有达到我们想交付的样子。有些功能还没做完,有些地方用起来会很粗糙。真实使用中暴露出来的问题,也可能让我们推翻现在的设计。 + +我们会继续认真打磨,也真诚希望听到直接的反馈——尤其是那些失败、困惑或不顺手的时刻。如果它没有帮到你,或者反而给工作添了麻烦,可以在企业微信群中留言,向我们反馈。 > **预发布说明:** 在首个带标签的版本发布之前,包 API、配置和持久化格式可能直接变更,不提供兼容层。 diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index af62bdcdc1..c1928b047d 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. It is still in internal testing, and it is far from complete. It is nowhere near the product we want to ship. Some features are unfinished, and some parts are rough to use. Problems that show up in real use may lead us to rethink designs we have today.\n\nWe will keep working to get these parts right, and we want to hear what using it is actually like. Please tell us plainly where it fails. We also want to know what is confusing or gets in your way. If it does not help you, or makes your work harder, we have not done our job. The specific problems you run into and any suggestions you have will help us decide what to fix first. Thank you for spending time with it before it is ready, and for helping us make it better one step at a time.\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 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" }, { "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> **预发布说明:** 在首个带标签的版本发布之前,包 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 是一个面向 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" }, { "role": "user", From d2582b8dc13ac8229a1ee46da8a862f1da2c201b Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 16:11:59 +0800 Subject: [PATCH 06/35] feat(web): render write/edit tool output as a diff card The write/edit tools already declare card:'diff' with applied hunks on callView/resultView, but the Web client discarded it: a mutation landed on GenericToolCard and the details panel flattened the result to a
    . Add
    DiffBlock (ui-primitives), diff-card-model (the single callView/resultView
    derivation), and FileMutationRow (keyed under write and edit), and make the
    generic fallback row and the details panel diff-aware. The +/- block form,
    per-file path header, same-file gap, and footer mirror the TUI diff card;
    the chat row caps at CHAT_DIFF_MAX_LINES against the panel's full height.
    ---
     .../2026-07-30-web-diff-card.i18n.yaml        |   6 +
     .../feature/2026-07-30-web-diff-card.md       |  56 ++++
     .../feature/2026-07-30-web-diff-card.zh.md    |  56 ++++
     .../client/connection/src/client/fixture.ts   |  28 +-
     .../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       |   6 +
     .../src/client/chat/GenericToolCard.tsx       |   7 +-
     .../src/client/chat/ToolRow.tsx               |  31 ++-
     .../src/client/contract/diff-card-model.ts    |  66 +++++
     .../src/client/skeleton/DetailsPanel.tsx      |  11 +-
     .../toolviews/file-mutation-row.module.css    | 119 +++++++++
     .../client/toolviews/file-mutation-row.tsx    |  97 +++++++
     .../ui-conversation/tests/diff-card.spec.tsx  | 248 ++++++++++++++++++
     .../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/DiffBlock.module.css    | 103 ++++++++
     .../client/ui-primitives/src/DiffBlock.tsx    | 171 ++++++++++++
     packages/client/ui-primitives/src/index.ts    |   2 +
     .../ui-primitives/tests/diff-block.spec.tsx   | 162 ++++++++++++
     22 files changed, 1173 insertions(+), 20 deletions(-)
     create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml
     create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-diff-card.md
     create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md
     create mode 100644 packages/client/ui-conversation/src/client/contract/diff-card-model.ts
     create mode 100644 packages/client/ui-conversation/src/client/toolviews/file-mutation-row.module.css
     create mode 100644 packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx
     create mode 100644 packages/client/ui-conversation/tests/diff-card.spec.tsx
     create mode 100644 packages/client/ui-primitives/src/DiffBlock.module.css
     create mode 100644 packages/client/ui-primitives/src/DiffBlock.tsx
     create mode 100644 packages/client/ui-primitives/tests/diff-block.spec.tsx
    
    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
    new file mode 100644
    index 0000000000..18f2d5178b
    --- /dev/null
    +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.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-diff-card.md
    +2026-07-30-web-diff-card.md: 5e43d5d29f7f4000efebc166724ec9d921d2b441
    +2026-07-30-web-diff-card.zh.md: aac577cfa8dd9e0bf5f17a729d049d207a64d374
    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
    new file mode 100644
    index 0000000000..5e43d5d29f
    --- /dev/null
    +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md
    @@ -0,0 +1,56 @@
    +# Agent Note: Web diff card — the write/edit render intent reaches the browser
    +
    +Status: implemented
    +
    +English | [中文](2026-07-30-web-diff-card.zh.md)
    +
    +## Problem
    +
    +The `write` and `edit` tools declare `card: 'diff'` for both their call and their result ([render-intent union](../architecture/2026-07-02-tool-render-intent-union.md)): the call view carries the intended change derived from the arguments, and the result view carries the applied contextual hunks (`FileDiff[]`, computed by `packages/fs/tool-fs/src/diff.ts` and persisted in the result `meta` so replay reproduces it). That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `callView`/`resultView` — and the TUI already renders it as per-file `+`/`-` blocks with a `+A -R · N file(s)` footer.
    +
    +The Web client ignored it. A write/edit call landed on `GenericToolCard`, whose row is derived from raw tool args, and the details panel flattened the result's content blocks into one `
    `. The `diffs` payload — the whole point of the result — was discarded, so a file mutation read as a one-line confirmation with no visible change.
    +
    +This is the [terminal card](2026-07-28-web-terminal-card.md) done for the `diff` arm: that change made the Web client a consumer of the `terminal` render intent; this one makes it a consumer of the `diff` render intent, reusing the same four-layer shape.
    +
    +## Decision
    +
    +`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:
    +
    +- **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 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.
    +
    +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.
    +
    +The chat row renders the diff resident under its path-link summary, capped at `CHAT_DIFF_MAX_LINES` (8) against the panel's 16 — the same inline-output decision and the same in-flow-vs-reading-surface split recorded for the [terminal card](2026-07-28-web-terminal-card.md#inline-output-in-the-chat-row-reverses-a-stated-convention). A write/edit row is single-file, so its summary stays an openable path link AND its diff card expands; the two coexist because the card is not the path's args body.
    +
    +## Alternatives considered
    +
    +**A side-by-side (two-column) diff.** Rejected for now by the owner: it is denser but does not fit the narrow chat row, and the goal was parity with the TUI's single-column unified form. A two-column mode in the details panel is a later props change, not a redesign.
    +
    +**Git-style line-number gutters.** The `FileDiff` contract carries only `{ path, oldText, newText }` — `structuredPatch`'s hunk start lines are dropped in `diff.ts`, so no line number reaches the client. Rendering a numbered gutter needs a backend contract change (carry `oldStart`/`newStart`) and a matching TUI upgrade to stay consistent; deferred so this PR stays a pure Web consumer of the existing contract.
    +
    +**Reuse `CodeBlock`.** Rejected for the same reason the terminal card was: `CodeBlock` soft-wraps and has no per-line `+`/`-` role, no path headers, and no footer. The two share geometry and font tokens, which is the only part where one implementation is correct for both.
    +
    +## Consequences
    +
    +`DiffBlock` reads only the diff view's fields, so it stays a pure function of what the render intent carries — replay-safe like the presenters that produce the view. A UI without the diff capability still gets the bridge's generic fallback; nothing about the tool's result shape changed. No new runtime dependency: unlike the terminal card's `anser`, a diff needs no parser.
    +
    +The multi-file arm of `DiffBlock` (one card, several path headers) has no producer today: `write`/`edit` each mutate one file per call, so a real card shows one file with one or more hunks. The arm is built and tested for a future multi-file mutation tool, not for a current consumer.
    +
    +## Testing
    +
    +`packages/client/ui-primitives/tests/diff-block.spec.tsx` pins the component: the create arm (added-only, no removed side), the edit arm (removed above added), the same-file `⋯` gap versus a new file's own header, the empty-diffs null render, the footer counts and their singular/plural, the head/tail cap with its `aria-expanded` toggle, and the copy control asserting the prefixed diff text on both the accepted and refused clipboard paths. Per-file 100%.
    +
    +`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).
    +
    +## Related
    +
    +- [Web terminal card](2026-07-28-web-terminal-card.md) — the same four-layer shape for the `terminal` arm; this note reuses its inline-output decision and its head/tail cap arithmetic.
    +- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary this consumes; the Web client is now a consumer of the `diff` arm too.
    +- [Web client architecture](../architecture/2026-07-19-gui-web-client-architecture.md) — the slot and snapshot layering the two render sites sit in.
    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
    new file mode 100644
    index 0000000000..aac577cfa8
    --- /dev/null
    +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md
    @@ -0,0 +1,56 @@
    +# Agent Note: Web diff 卡片 —— write/edit 渲染意图抵达浏览器
    +
    +Status: implemented
    +
    +[English](2026-07-30-web-diff-card.md) | 中文
    +
    +## Problem
    +
    +`write` 和 `edit` 工具为其 call 和 result 都声明了 `card: 'diff'`([render-intent union](../architecture/2026-07-02-tool-render-intent-union.md)):call view 携带从参数推导的预期改动,result view 携带已应用的上下文 hunk(`FileDiff[]`,由 `packages/fs/tool-fs/src/diff.ts` 计算,并持久化在 result `meta` 中以便回放重建)。该视图早已抵达浏览器 —— host、connection、runtime 将它作为 `callView`/`resultView` 投递到 `ConversationSnapshot` —— TUI 也已将其渲染为按文件分组的 `+`/`-` 块加 `+A -R · N file(s)` 页脚。
    +
    +Web 客户端忽略了它。write/edit 调用落到 `GenericToolCard`,其行从原始工具参数推导,详情面板把 result 的 content block 摊平进一个 `
    `。`diffs` 载荷 —— result 的全部意义 —— 被丢弃,于是一次文件改动读起来只是一行确认、看不到任何改动。
    +
    +这是把 [terminal 卡片](2026-07-28-web-terminal-card.md) 对 `diff` 这一支重做一遍:那次改动让 Web 客户端成为 `terminal` 渲染意图的消费者;这次让它成为 `diff` 渲染意图的消费者,复用同一套四层结构。
    +
    +## Decision
    +
    +`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 在两个前端读起来一致:
    +
    +- **每个文件一个路径头。** 新文件开启一个粗体路径头;同文件的第二个 hunk(分散编辑,或 `replace_all`)以一个 `⋯` gap 开启,而非重复路径。`N file(s)` 页脚统计去重后的路径数。
    +- **改动用 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),使多文件复制保持可归属。
    +
    +几何、圆角、字体镜像 `CodeBlock`/`TerminalBlock`,使 diff 卡片、terminal 卡片、代码块读起来是一家;`white-space: pre` 加横向滚动是刻意的分歧。复制控件浮在卡片右上角,而非占据自己的 banner 行,因为只放一个复制按钮的 banner 会在第一行 diff 上方画出一条空带 —— TUI 的 diff 卡片也没有 banner,只有页脚。
    +
    +chat 行把 diff 常驻渲染在路径链接摘要之下,上限 `CHAT_DIFF_MAX_LINES`(8),对应面板的 16 —— 与 [terminal 卡片](2026-07-28-web-terminal-card.md#inline-output-in-the-chat-row-reverses-a-stated-convention)记录的内联输出决策、以及流内表面对单调阅读表面的同一划分一致。write/edit 行是单文件的,所以它的摘要既是可打开的路径链接,其 diff 卡片又展开;两者共存,因为卡片不是路径的参数体。
    +
    +## Alternatives considered
    +
    +**并排(双栏)diff。** owner 目前拒绝:它更密但不适合狭窄的 chat 行,目标是与 TUI 单栏统一形式对齐。详情面板里的双栏模式是后续的 props 改动,不是重设计。
    +
    +**git 式行号槽。** `FileDiff` 契约只携带 `{ path, oldText, newText }` —— `structuredPatch` 的 hunk 起始行在 `diff.ts` 里被丢弃,所以没有行号抵达客户端。渲染行号槽需要后端契约改动(携带 `oldStart`/`newStart`)并同步升级 TUI 以保持一致;推迟,使本 PR 保持为对既有契约的纯 Web 消费。
    +
    +**复用 `CodeBlock`。** 因与 terminal 卡片相同的理由拒绝:`CodeBlock` 会折行,且没有每行 `+`/`-` 角色、没有路径头、没有页脚。两者共享几何与字体 token,那是唯一一处一个实现对两者都正确的部分。
    +
    +## Consequences
    +
    +`DiffBlock` 只读 diff view 的字段,因此它是渲染意图所携带内容的纯函数 —— 与产出该视图的 presenter 一样回放安全。没有 diff 能力的 UI 仍得到 bridge 的通用回退;工具的 result 形状没有任何改变。无新增运行时依赖:不同于 terminal 卡片的 `anser`,diff 不需要解析器。
    +
    +`DiffBlock` 的多文件支路(一张卡、多个路径头)今天没有生产者:`write`/`edit` 每次调用各改一个文件,所以真实卡片显示一个文件带一个或多个 hunk。该支路为将来的多文件改动工具而构建并测试,不是为当前消费者。
    +
    +## Testing
    +
    +`packages/client/ui-primitives/tests/diff-block.spec.tsx` 钉住组件:新建支路(只有新增、无删除侧)、编辑支路(删除在新增之上)、同文件 `⋯` gap 对比新文件自己的头、空 diffs 的 null 渲染、页脚计数及其单复数、头尾上限及其 `aria-expanded` 切换、以及复制控件在接受与拒绝两条剪贴板路径上断言带前缀的 diff 文本。Per-file 100%。
    +
    +`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)。
    +
    +## Related
    +
    +- [Web terminal 卡片](2026-07-28-web-terminal-card.md) —— `terminal` 支路的同一套四层结构;本 note 复用其内联输出决策与头尾上限算术。
    +- [工具调用呈现的标签化 render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) —— 本改动消费的 `card` 标签词汇;Web 客户端现在也是 `diff` 支路的消费者。
    +- [Web 客户端架构](../architecture/2026-07-19-gui-web-client-architecture.md) —— 两个渲染点所处的 slot 与快照分层。
    diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts
    index cb79a6b9a2..4fddf6e9c0 100644
    --- a/packages/client/connection/src/client/fixture.ts
    +++ b/packages/client/connection/src/client/fixture.ts
    @@ -232,6 +232,13 @@ function buildAlphaLog(): SessionEvent[] {
       toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
       toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
       toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')
    +  // Turn 67: a multi-hunk edit — two scattered replacements in one file. Named
    +  // `edit` so it lands on the keyed FileMutationRow (the resident diff card the
    +  // single-hunk turn 62 also uses), and file_path `src/config.ts` is the marker
    +  // 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"}', '已编辑')
       // 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
    @@ -333,9 +340,26 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
             diffs: [{ path: str(args.path), oldText: null, newText: str(args.content) }],
           }
         case 'edit':
    -      return { card: 'generic', title: `Edit ${str(args.file_path)}`, kind: 'edit', rawInput: args }
    +      // The multi-hunk sample (turn 67) is keyed on its file_path, so the two
    +      // scattered hunks share one path header and the card draws the `⋯` gap.
    +      if (str(args.file_path) === 'src/config.ts') {
    +        return {
    +          card: 'diff', title: `Edit ${str(args.file_path)}`,
    +          diffs: [
    +            { path: str(args.file_path), oldText: 'const timeout = 30', newText: 'const timeout = 60' },
    +            { path: str(args.file_path), oldText: 'retries: 1', newText: 'retries: 3' },
    +          ],
    +        }
    +      }
    +      return {
    +        card: 'diff', title: `Edit ${str(args.file_path)}`,
    +        diffs: [{ path: str(args.file_path), oldText: str(args.old_string), newText: str(args.new_string) }],
    +      }
         case 'write':
    -      return { card: 'generic', title: `Write ${str(args.file_path)}`, kind: 'edit', rawInput: args }
    +      return {
    +        card: 'diff', title: `Write ${str(args.file_path)}`,
    +        diffs: [{ path: str(args.file_path), oldText: null, newText: str(args.content) }],
    +      }
         default:
           return undefined // echo et al: the documented no-view fallback path
       }
    diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
    index 654722b589..a8ec59bb6a 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: 3973c14f2b8fe746549bb74af85a7a60a7d66aea
    -README.zh.md: a6bb15c4cdd53d05bf28147b97d9d64d1c59da2b
    +README.md: d3cd5cc268b60b58bb4dbb6c3b6c118084c0def8
    +README.zh.md: f3a835ed82ecbd266b9f0829acc6182209940cb5
    diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
    index 3973c14f2b..d3cd5cc268 100644
    --- a/packages/client/ui-conversation/README.md
    +++ b/packages/client/ui-conversation/README.md
    @@ -14,6 +14,8 @@ 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 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 `diff` render intent (the `write`/`edit` tools) renders its applied change inline through ui-primitives' `DiffBlock`, the same four-layer shape. `contract/diff-card-model.ts` is the single derivation from the `callView`/`resultView` pair; the settled result's hunks replace the call-time diff, and it yields null — the generic path — for any other card tag or a generic result view (write/edit's execution errors). The keyed `FileMutationRow` (registered under both `write` and `edit`) carries the card resident below its summary, whose path link still opens the file through the host; the render-site fallback and the details panel are diff-aware too. Rows cap at `CHAT_DIFF_MAX_LINES` (8) against the panel's 16 ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.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).
     
     The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"/ tasks ·  in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
    diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
    index a6bb15c4cd..f3a835ed82 100644
    --- a/packages/client/ui-conversation/README.zh.md
    +++ b/packages/client/ui-conversation/README.zh.md
    @@ -12,6 +12,8 @@
     
     声明 `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))。
     
    +声明 `diff` 渲染意图的工具调用(`write`/`edit` 工具),通过 ui-primitives 的 `DiffBlock` 内联渲染其已应用的改动,采用同一套四层结构。`contract/diff-card-model.ts` 是从 `callView`/`resultView` 对推导的唯一位置;已结算 result 的 hunk 替换 call 时 diff,对任何其他 card 标签或 generic result view(write/edit 的执行错误)它返回 null,落回通用路径。键控的 `FileMutationRow`(在 `write` 与 `edit` 下都注册)把卡片常驻在摘要之下,其路径链接仍经 host 打开文件;渲染点兜底行与详情面板同样感知 diff。行的上限是 `CHAT_DIFF_MAX_LINES`(8),面板为 16([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.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 会拒绝没有任何渲染方的声明)。
     
     审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,kebab-case 预设名渲染为 Title Case 标签(与 `/permission` popup 的显示变换孪生),选中会经由输入栏注入的 `command` 回调提交 `/permission ` 命令行。
    diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts
    index 7f3aeb38cc..75f8ade379 100644
    --- a/packages/client/ui-conversation/src/client/apply.ts
    +++ b/packages/client/ui-conversation/src/client/apply.ts
    @@ -19,6 +19,7 @@ import { InputBar } from './skeleton/InputBar.tsx'
     import { ChatView } from './chat/ChatView.tsx'
     import { StatsLine } from './chat/StatsLine.tsx'
     import { bashToolviewSample } from './toolviews/bash-sample.tsx'
    +import { fileMutationToolview } from './toolviews/file-mutation-row.tsx'
     import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
     import { todoToolview } from './toolviews/todo-row.tsx'
     import { todoDockEntry } from './skeleton/TodoPanel.tsx'
    @@ -254,6 +255,11 @@ export function apply(ctx: Context): void {
       // (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions).
       ctx.plugin(bashToolviewSample)
     
    +  // The write/edit rows ride the same seam: a file-mutation call declares the
    +  // diff render intent, so these rows stack the applied diff card under their
    +  // path-link summary (the terminal card's posture, applied to diffs).
    +  ctx.plugin(fileMutationToolview)
    +
       // The todo_write row rides the same seam (a product registration, not a sample).
       ctx.plugin(todoToolview)
     
    diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
    index ce55d84f57..8a707045e8 100644
    --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
    +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
    @@ -10,6 +10,7 @@ import {
       IconThinkOutline14,
     } from '@deepseek-ai/dsh-client-ui-primitives'
     import type { ToolRowOwnerProps } from '../contract/slots.ts'
    +import { diffCardModel } from '../contract/diff-card-model.ts'
     import { terminalCardModel } from '../contract/terminal-card-model.ts'
     import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
     import { ToolRow } from './ToolRow.tsx'
    @@ -29,6 +30,7 @@ const VARIANT_ICONS: Record = {
     export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) {
       const model = toolRowModel(toolName, block, cwd)
       const terminal = terminalCardModel(block, cwd)
    +  const diff = diffCardModel(block)
       const singleFile = model.filePath !== undefined
       return (
         
    -        : variant === 'code'
    -          ? 
    -          : 
    {text}
    )} + : diffBody !== null + ? + : variant === 'code' + ? + :
    {text}
    )}
) } 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 new file mode 100644 index 0000000000..f02ccd5930 --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/diff-card-model.ts @@ -0,0 +1,66 @@ +/** + * Pure derivation of the diff-card props from a frozen call slice: the + * `card:'diff'` render intent the write/edit tools declare arrives on the + * snapshot as `callView`/`resultView`, and this is the one place that turns + * that pair into what {@link DiffBlock} draws. Both conversation render sites + * (the chat tool row's expanded body and the details panel's Output section) + * call this, so the hunks they show are derived once. + * @module + */ +import type { DiffBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolCallBlock } from './tool-call-model.ts' + +/** + * Diff-body lines the chat row 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. The + * same split {@link CHAT_TERMINAL_MAX_LINES} draws for a terminal card, so the + * two card kinds cap a long body at the same place in the flow. A design + * constant of this UI's row geometry, not a deployment choice. + */ +export const CHAT_DIFF_MAX_LINES = 8 + +/** + * The {@link DiffBlock} props this derivation owns. Picked off the primitive's + * props so the two stay in step; `maxLines`/`className` belong to each render + * site. + */ +export interface DiffCardModel { + /** + * The props {@link DiffBlock} draws. Held as a nested object so a render site + * spreads exactly the primitive's own surface and can never leak a + * neighbouring field into it. + */ + card: Pick +} + +/** + * 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. + * + * The result side is authoritative once the call settles: the write/edit tools + * return the applied contextual hunks there (an edit's real before/after, a + * create's whole-file diff), which replace the call-time diff derived from the + * arguments alone. While the call is still running only the call side exists, + * so a running write/edit shows its intended change. Null is the documented + * generic-card default and covers every non-diff card — including a `card` + * value this UI version does not know, which arrives over the wire and cannot + * 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). + * @param block - RunningToolCall or ToolResultNode off the snapshot caches. + * @returns the diff-card props, or null for the generic path. + */ +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 } } + } + // 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 } } +} diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index 9fc5a04ff6..d09d3ea256 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx @@ -7,10 +7,11 @@ // 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, DiffBlock, TerminalBlock } 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 { diffCardModel } from '../contract/diff-card-model.ts' import { terminalCardModel } from '../contract/terminal-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 diff-card call — a + * write/edit's applied change — renders through the shared DiffBlock at the same + * full height. Every other call, and a running call with neither 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,8 @@ function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | u ) } + const diff = diffCardModel(material.block) + 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.module.css b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.module.css new file mode 100644 index 0000000000..b87103aa3a --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.module.css @@ -0,0 +1,119 @@ +/* File-mutation toolview: same geometry/tokens as ToolRow (figma + {Edit,Write} · path), plus the diff card the row stacks under its summary + line. Mirrors bash-sample.module.css, whose terminal card this replaces with + a diff card. */ + +/* Summary line over the diff 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. */ +.diff { + 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-file-mutation-row-sweep 2.6s ease-out infinite; + pointer-events: none; +} + +@keyframes dsh-file-mutation-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); +} + +/* File-tool path: same geometry as .summary; hover underline + pointer. */ +.fileLink { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin: 0; + padding: 0; + border: none; + background: none; + font: inherit; + text-align: left; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.fileLink:hover { + text-decoration: underline; +} + +.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/file-mutation-row.tsx b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx new file mode 100644 index 0000000000..0862eb4fd5 --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx @@ -0,0 +1,97 @@ +// File-mutation toolview registrant: third-party posture over the keyed +// toolview hole (ctx.slots.register + ToolRowProps only — never imports the +// chat domain), registered under both `edit` and `write`. Product chrome +// matches ToolRow (figma: {Edit,Write} · {path}). +// +// A write/edit call declares the diff render intent, so this row renders the +// applied change through DiffBlock resident below its summary line — the same +// posture BashRow gives a terminal card. The row has no expand control and is +// not a details-panel target (tool rows stopped being one), so the diff body +// is resident rather than expand-gated, and the card's own copy and expand +// controls are the row's only interactions. CHAT_DIFF_MAX_LINES caps the body +// against the message flow; the details panel keeps the block's full default. +// The summary stays a path link (the file-tool interaction) that opens through +// the host. + +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 css from './file-mutation-row.module.css' + +function leadingFor(state: ToolRowState) { + switch (state) { + case 'error': return + case 'stopped': return + // Running keeps the icon — the row sweep carries the in-flight signal. + default: return + } +} + +/** 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 + } +} + +/** + * 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. + */ +export function FileMutationRow({ toolName, block, cwd, openFile }: ToolRowProps) { + const model = toolRowModel(toolName, block, cwd) + const diff = diffCardModel(block) + const status = stateStatus(model.state) + const filePath = model.filePath + return ( +
+
+ {leadingFor(model.state)} + {status !== null && {status}} + {model.title} + + {filePath !== undefined ? ( + + ) : ( + {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 07/35] 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 08/35] 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 09/35] 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 8c5c4b46c83562611eb4bf3fe9adf60fdc35c81b Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 18:32:55 +0800 Subject: [PATCH 10/35] =?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 11/35] 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 452f11907e0618610bc83ef66d43f14628d0816f Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 19:42:11 +0800 Subject: [PATCH 12/35] test(ui-primitives): exclude DiffBlock's assertNever default arm from coverage The copyText switch's default arm calls assertNever, the closed-union backstop that the per-file 100% coverage gate cannot reach without a forged row kind. The assertNever function itself already carries the v8 ignore; mark the switch arm that reaches it the same way, matching TodoPanel's StatusGlyph default arm. --- packages/client/ui-primitives/src/DiffBlock.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/client/ui-primitives/src/DiffBlock.tsx b/packages/client/ui-primitives/src/DiffBlock.tsx index 12c389c72b..5ae28bc9b2 100644 --- a/packages/client/ui-primitives/src/DiffBlock.tsx +++ b/packages/client/ui-primitives/src/DiffBlock.tsx @@ -128,6 +128,7 @@ function copyText(rows: DiffRow[]): string { case 'add': return `+ ${row.text}` case 'path': return row.text case 'gap': return row.text + /* v8 ignore next -- closed-union backstop; only reached if a row kind is forged */ default: return assertNever(row.kind) } }).join('\n') From 9eb0ef4c3e40bf7d71ae907729770ddf9b2f8adb Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 04:50:18 -0700 Subject: [PATCH 13/35] feat(client): confirm before enabling full access --- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 24 ++++ .../src/client/contract/slots.ts | 2 + .../src/client/skeleton/InputBar.tsx | 4 +- .../skeleton/PermissionSelect.module.css | 70 ++++++++++ .../src/client/skeleton/PermissionSelect.tsx | 128 ++++++++++++++---- .../ui-conversation/tests/input-bar.spec.tsx | 97 +++++++++++-- .../tests/input-matrix.spec.tsx | 1 + .../tests/input-scenarios.spec.tsx | 1 + .../ui-conversation/tests/skeleton.spec.tsx | 1 + packages/client/ui-primitives/src/Modal.tsx | 6 +- .../client/ui-primitives/tests/atoms.spec.tsx | 3 +- 13 files changed, 289 insertions(+), 52 deletions(-) diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index fc466190a7..c0e04e0d12 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -8,7 +8,7 @@ The resident conversation shell survives no-session and session transitions. Wit The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. -Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels (the `/permission` popup's display transform twin), and a pick submits the `/permission ` command line through the bar's injected `command` callback. +Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded 141px scrollport shows bounded inline JSON for both `content` and `source`, and no tool state, summary, or keyed toolview dispatch is synthesized ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index f6fbff9c1e..193cfbe92e 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -16,7 +16,7 @@ 工具行同样是 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 会拒绝没有任何渲染方的声明)。 -审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,kebab-case 预设名渲染为 Title Case 标签(与 `/permission` popup 的显示变换孪生),选中会经由输入栏注入的 `command` 回调提交 `/permission ` 命令行。 +审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。 todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 10` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 和 Queue 之间),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index c6b597ae79..8511c2c0ba 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -95,6 +95,28 @@ export function apply(ctx: Context): void { }, 'ui-conversation: command hint dictionaries') const translateHint = ctx.locale.bind(HINT_NS) + const ACCESS_NS = 'conversation.access' + 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-conversation: Access control dictionaries') + const translateAccess = ctx.locale.bind(ACCESS_NS) + // Apply-time construction keeps store identity bound to this fiber. const chatStore = createChatStore() @@ -199,6 +221,7 @@ export function apply(ctx: Context): void { stop: undefined, command: undefined, translateHint, + translateAccess, hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON }, } } @@ -217,6 +240,7 @@ export function apply(ctx: Context): void { return result.ok && result.value.matched }, translateHint, + translateAccess, hooks: { notices: shell.notices, lexicon: shell.lexicon }, } }, diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 1684e616b2..093f10edc5 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -284,6 +284,8 @@ export interface ComposerBarInjected { command: ((line: string) => Promise) | undefined /** Locale-aware hint translator for claimed command placeholders (session-independent — always present). */ translateHint: (key: string) => string + /** Locale-aware copy for the Access control's Full access risk confirmation. */ + translateAccess: (key: string) => string /** * Registrant hooks compartment: the renderer binds these to * useNotices/useLexicon (static absent sources without a session — hook diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 77747e0ae2..4e2b366f7f 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -33,7 +33,7 @@ export interface InputBarError { export type InputBarProps = ComposerBarProps export function InputBar({ - useSession, useInput, inputActions, keyboard, stop, command, translateHint, renderSlot, useNotices, useLexicon, + useSession, useInput, inputActions, keyboard, stop, command, translateHint, translateAccess, renderSlot, useNotices, useLexicon, useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer, onAdd, addLabel = 'Add attachment', }: InputBarProps) { @@ -272,7 +272,7 @@ export function InputBar({ // or while the command face is absent with the session). const accessSelect: ReactNode = command === undefined ? null - : + : // Mirror-layer decorations: a visible backdrop with transparent text. The // claim token highlights through behind the textarea glyphs; each U+FFFC 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 50dce3913f..bbc51b10e9 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css @@ -41,3 +41,73 @@ 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 873c8c11c4..567abaa27e 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx @@ -1,9 +1,11 @@ import { useState } from 'react' import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission/client' -import { Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, IconWarningOutline16, Menu, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives' import css from './PermissionSelect.module.css' +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 @@ -15,58 +17,124 @@ function displayName(name: string): string { return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') } +function optionLabel(option: PermissionSelectValue['options'][number]): string { + return option.value === FULL_ACCESS ? 'Full access' : displayName(option.name) +} + export interface PermissionSelectProps { value: PermissionSelectValue | undefined locked: boolean command: (line: string) => Promise + t: (key: string) => string } -export function PermissionSelect({ value, locked, command }: PermissionSelectProps) { +export function PermissionSelect({ value, locked, command, t }: PermissionSelectProps) { const [pick, setPick] = useState(null) const [open, setOpen] = useState(false) + const [confirmation, setConfirmation] = useState(null) + const [acknowledged, setAcknowledged] = useState(false) if (value === undefined) return null const currentValue = pick ?? value.currentValue const current = value.options.find(option => option.value === currentValue) - const busy = pick !== null + const busy = pick !== null || confirmation !== null const items: MenuEntry[] = value.options .filter(o => o.value !== 'custom') - .map(option => ({ id: option.value, label: displayName(option.name) })) + .map(option => ({ id: option.value, label: optionLabel(option) })) - const choose = (id: string): void => { - setOpen(false) - if (id === value.currentValue) return + const submit = (id: string): void => { setPick(id) void command(`/permission ${id}`) .catch(() => false) .then(() => { setPick(null) }) } + const choose = (id: string): void => { + setOpen(false) + if (id === value.currentValue) return + if (id === FULL_ACCESS) { + setAcknowledged(false) + setConfirmation(id) + return + } + submit(id) + } + + const closeConfirmation = (): void => { + setAcknowledged(false) + setConfirmation(null) + } + + const confirmFullAccess = (): void => { + if (!acknowledged || confirmation === null) return + const id = confirmation + closeConfirmation() + submit(id) + } + return ( - { setOpen(false) }} - side="top" - anchor={ - - } - /> + <> + { 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 14/35] 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 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 15/35] 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((
+ + + + + )} + />
) } diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index 48431ddacf..5cc6782dca 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -9,8 +9,13 @@ export const en = { dormant: 'Inactive', edit: 'Edit', remove: 'Delete', + deleteTitle: 'Delete model provider?', + deleteDescription: 'Deleting this model provider removes its configuration. You will not be able to use its models until you add the provider again.', + deleteConfirm: 'Delete provider', + deleting: 'Deleting provider…', add: 'Add provider', provider: 'Provider', + close: 'Close', cancel: 'Cancel', apply: 'Apply', applying: 'Applying…', @@ -46,8 +51,13 @@ export const zh: typeof en = { dormant: '未启用', edit: '编辑', remove: '删除', + deleteTitle: '删除模型提供方?', + deleteDescription: '删除此模型提供方会移除其配置。在重新添加前,你将无法继续使用其模型。', + deleteConfirm: '删除提供方', + deleting: '正在删除提供方…', add: '添加提供方', provider: '提供方', + close: '关闭', cancel: '取消', apply: '保存', applying: '保存中…', diff --git a/packages/client/ui-models/tests/apply.spec.ts b/packages/client/ui-models/tests/apply.spec.ts index eb34b162a0..6c64a93058 100644 --- a/packages/client/ui-models/tests/apply.spec.ts +++ b/packages/client/ui-models/tests/apply.spec.ts @@ -48,6 +48,7 @@ describe('ui-models apply', () => { expect(resolveSlotLabel(entry.options.label)).toBe('模型') const injected = (entry.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected)() expect(injected.t('nav')).toBe('模型') + expect(injected.t('deleteTitle')).toBe('删除模型提供方?') expect(typeof injected.controller.load).toBe('function') expect(typeof injected.useSnapshot).toBe('function') expect(injected.api).toBeDefined() @@ -73,8 +74,11 @@ describe('ui-models apply', () => { await b.ctx.plugin({ inject: [...inject], apply }).await() b.locale.setLocale('en') expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Models') + const injected = b.slots.entries('settings.section')[0]!.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected + expect(injected().t('deleteTitle')).toBe('Delete model provider?') b.locale.setLocale('zh') expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('模型') + expect(injected().t('deleteTitle')).toBe('删除模型提供方?') }) it('locale change while the slot is undeclared stays a no-op', async () => { diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index a4f3734fbd..47aec0dd5e 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom /** Section, setup-card, and hand-written editor behavior over a scripted wire face. */ -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import Schema from 'schemastery' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' @@ -471,10 +471,28 @@ describe('ModelsSection', () => { await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) }) }) - it('removes a user-added provider by unsetting its path', async () => { + it('requires confirmation before removing a user-added provider', async () => { const { replace, mutate } = await mountSection() fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement) + const dialog = screen.getByRole('dialog', { name: en.deleteTitle }) + expect(dialog.textContent).toContain(en.deleteDescription) + expect(document.activeElement).toBe(within(dialog).getByRole('button', { name: en.cancel })) + expect(mutate).not.toHaveBeenCalled() + fireEvent.click(within(dialog).getByRole('button', { name: en.cancel })) + expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() + expect(mutate).not.toHaveBeenCalled() + + fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement) + fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle })) + .getByRole('button', { name: en.close })) + expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() + expect(mutate).not.toHaveBeenCalled() + + fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement) + fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle })) + .getByRole('button', { name: en.deleteConfirm })) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) + expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() expect(replace).not.toHaveBeenCalled() expect(mutate.mock.calls[0]?.[0]).toEqual({ ns: 'llm-pi-ai', @@ -482,6 +500,28 @@ describe('ModelsSection', () => { }) }) + it('blocks duplicate deletion while the confirmed removal is pending', async () => { + let resolveRemoval!: (response: RpcResponse) => void + const mutate = vi.fn(() => new Promise>((resolve) => { + resolveRemoval = resolve + })) + await mountSection({ mutate }) + fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement) + const dialog = screen.getByRole('dialog', { name: en.deleteTitle }) + const confirm = within(dialog).getByRole('button', { name: en.deleteConfirm }) + fireEvent.click(confirm) + fireEvent.click(confirm) + expect(mutate).toHaveBeenCalledOnce() + expect(confirm.disabled).toBe(true) + expect(within(dialog).getByRole('button', { name: en.cancel }).disabled).toBe(true) + expect(within(dialog).getByRole('button', { name: en.deleting })).toBe(confirm) + fireEvent.click(within(dialog).getByRole('button', { name: en.close })) + expect(screen.getByRole('dialog', { name: en.deleteTitle })).toBe(dialog) + expect(mutate).toHaveBeenCalledOnce() + await act(async () => { resolveRemoval(ok(wireNamespaces()[2]!)) }) + await waitFor(() => { expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() }) + }) + it('renders the load failure with a retry control', async () => { const face = scriptedFace() face.face.llm.providers = vi.fn(() => Promise.resolve(fail('directory down', 'internal'))) as never @@ -589,6 +629,8 @@ describe('ModelsSection', () => { // would appear — rather than the row silently staying put. await mountSection({ mutate: vi.fn(() => Promise.reject(new Error('the host refused'))) }) fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement) + fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle })) + .getByRole('button', { name: en.deleteConfirm })) await screen.findByText(`${en.loadFailed}: the host refused`) }) diff --git a/packages/client/ui-models/tests/styles.spec.ts b/packages/client/ui-models/tests/styles.spec.ts new file mode 100644 index 0000000000..478046454b --- /dev/null +++ b/packages/client/ui-models/tests/styles.spec.ts @@ -0,0 +1,13 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const css = readFileSync(fileURLToPath(new URL('../src/client/ModelsSection.module.css', import.meta.url)), 'utf8') + +describe('ModelsSection theme styles', () => { + it('uses the shared theme tokens without light-only fallbacks', () => { + expect(css).not.toMatch(/var\(--(?:surface|text-|border|accent-strong)/) + expect(css).toContain('background: var(--dsw-alias-bg-layer-3)') + expect(css).toContain('color: var(--dsw-alias-label-primary)') + }) +}) 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 29/35] 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 30/35] 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 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 31/35] 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 788b9eb9866f714e2144ed1c4d6ff3748943dbe5 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 31 Jul 2026 11:32:10 +0800 Subject: [PATCH 32/35] fix(web): hide provider liveness badges --- .../2026-07-30-web-config-plane.i18n.yaml | 4 +-- .../2026-07-30-web-config-plane.md | 2 +- .../2026-07-30-web-config-plane.zh.md | 2 +- apps/web/tests/models-settings.e2e.ts | 9 +++-- .../models-settings/configured.expected.md | 2 +- packages/client/ui-models/README.i18n.yaml | 4 +-- packages/client/ui-models/README.md | 2 +- packages/client/ui-models/README.zh.md | 2 +- .../src/client/ModelsSection.module.css | 33 +------------------ .../ui-models/src/client/ModelsSection.tsx | 5 --- .../client/ui-models/src/client/locales.ts | 4 --- .../ui-models/tests/components.spec.tsx | 5 ++- 12 files changed, 16 insertions(+), 58 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index d7f3ce8a17..fedfc7e489 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.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-web-config-plane.md -2026-07-30-web-config-plane.md: e4c72d1ad555e1d542593b8eb4b7fafc1afb8e0a -2026-07-30-web-config-plane.zh.md: 73dc2b7450c940e893b795cb29c4d2a7752a9bb0 +2026-07-30-web-config-plane.md: 6d1a8c242c1888ee4fca9e21ebc814f7a345d633 +2026-07-30-web-config-plane.zh.md: c3255cacfdd1f06d12f7bb2631f95273536b7ef9 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index e4c72d1ad5..6d1a8c242c 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -20,7 +20,7 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer **A hand-written editor over a schema model layer.** `dsh-client-schema-form` rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The user chose the hand-written direction over adding a hint/grouping system, and a second round removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, plus `reasoningEffort` for deepseek / `reasoning` for pi-ai), with every other field owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, so a hand-coded field that drifts from its schema fails loud on save rather than silently. -**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder; badges come from route liveness. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value. Profile edits and removals land as minimal path-addressed `settings.mutate` operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized model-provider confirmation dialog; cancellation, its close button, and its mask leave the profile untouched, while the destructive confirmation submits the single unset and blocks duplicate submission until it settles. +**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder. Route liveness still gates readiness and invalidates the join, but the page does not render it as provider status because configuration presence and runtime availability are distinct. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value. Profile edits and removals land as minimal path-addressed `settings.mutate` operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized model-provider confirmation dialog; cancellation, its close button, and its mask leave the profile untouched, while the destructive confirmation submits the single unset and blocks duplicate submission until it settles. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index 73dc2b7450..c3255cacfd 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md @@ -20,7 +20,7 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 **架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`,另加 deepseek 的 `reasoningEffort`/pi-ai 的 `reasoning`),其余每个字段都归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,因此偏离其 schema 的手写字段会在保存时大声失败,而非静默失败。 -**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目;徽标来自路由存活状态。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化的模型提供方确认对话框;取消操作、关闭按钮和遮罩均不会改动 profile,而破坏性确认会提交唯一一条 unset,并在其完成前阻止重复提交。 +**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目。路由存活状态仍用于就绪判定,并会使该联接失效,但页面不将其渲染为提供方状态,因为配置存在与运行时可用性是两个不同概念。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化的模型提供方确认对话框;取消操作、关闭按钮和遮罩均不会改动 profile,而破坏性确认会提交唯一一条 unset,并在其完成前阻止重复提交。 ## 曾考虑的替代方案 diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index a175191b33..9c2215ed7f 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -1,10 +1,10 @@ // Web e2e scenario: the Models settings page end to end through the real // wire — the add card offers the dormant pi-ai catalog, typing an API key // stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`) -// while the settings document records only that reference, and the saved -// route registers live (the row's 已启用 badge is the topology invalidation -// landing). The customized-settings fold writes the curated reasoning field -// as a merge patch. Zero model calls: configuration is pure +// while the settings document records only that reference; the saved row +// appears after the route topology invalidation without presenting liveness +// as provider status. The customized-settings fold writes the curated +// reasoning field as a merge patch. Zero model calls: configuration is pure // settings/credentials/llm-domain traffic, so there is no fixture and a // stray stream would fail loud on the open seam. The provider under test is // minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can @@ -84,7 +84,6 @@ describe('web e2e: Models settings page configures a dormant provider', () => { // registers, and the topology frame invalidates the page into the row. const row = dialog.getByText('minimax-cn', { exact: true }).first() await row.waitFor({ timeout: 10_000 }) - await dialog.getByText('已启用').waitFor({ timeout: 10_000 }) const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') expect(document).toContain('minimax-cn:') expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY') diff --git a/apps/web/tests/snapshots/models-settings/configured.expected.md b/apps/web/tests/snapshots/models-settings/configured.expected.md index 8b9c4ad6e1..251352ee00 100644 --- a/apps/web/tests/snapshots/models-settings/configured.expected.md +++ b/apps/web/tests/snapshots/models-settings/configured.expected.md @@ -14,7 +14,7 @@ - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - list: - listitem: - - text: minimax-cn 已启用 + - text: minimax-cn - button "编辑" - button "删除" - button "+ 添加提供方" diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index fcb12d5cd5..0cd3fa6269 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/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-models/README.md -README.md: 9dd09faeb515bb8e8336c52418ab5cab5ede1b34 -README.zh.md: 753ee24f6dda647afef42b78cfc97f36dde5d739 +README.md: 4edb34ccbe8f628c04e410a6dd2f002e247623f3 +README.zh.md: 68a1e0ee205d3ba764620bcfeba7c11a88ee8736 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index 9dd09faeb5..4edb34ccbe 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Models settings plugin: the provider configuration page and official-DeepSeek first-run routing overlay. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time. +Models settings plugin: the provider configuration page and official-DeepSeek first-run routing overlay. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status. Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset. diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 753ee24f6d..68a1e0ee20 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -模型设置插件:提供方配置页和 DeepSeek 官方首次使用跳转浮层。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片。 +模型设置插件:提供方配置页和 DeepSeek 官方首次使用跳转浮层。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。 行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另加 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai);其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset。 diff --git a/packages/client/ui-models/src/client/ModelsSection.module.css b/packages/client/ui-models/src/client/ModelsSection.module.css index 11e51b66ec..a8b28db46c 100644 --- a/packages/client/ui-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-models/src/client/ModelsSection.module.css @@ -54,41 +54,10 @@ font-weight: 600; } -.badges { - display: inline-flex; - gap: 6px; - flex: 1; -} - -.badgeOk { - display: inline-flex; - align-items: center; - gap: 5px; - color: var(--dsw-alias-state-success-primary); - font-size: 12px; -} - -.badgeOk::before { - content: ''; - width: 6px; - height: 6px; - border-radius: 999px; - background: currentcolor; -} - -.badgeMuted { - color: var(--dsw-alias-label-tertiary); - font-size: 12px; -} - -.badgeWarn { - color: var(--dsw-alias-state-warn-label); - font-size: 12px; -} - .rowActions { display: inline-flex; gap: 8px; + margin-left: auto; } .primaryButton { diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index 24f1ab2e4c..c206dbd864 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -198,11 +198,6 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
  • {row.entry.displayName} - - {row.entry.active - ? {t('active')} - : {t('dormant')}} -