diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml index d4ad622140..0dc3371001 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml @@ -6,23 +6,23 @@ en: ca120635fc40431c zh: 6be7c5ae3c86cdcc /agent-note-web-capability-seam-stable-tools-over-multiple-providers/problem: - en: c8b932b02a4e1c80 - zh: 96c466be1a521f3f + en: df1e82a74c42fa1d + zh: 8d5452360a0dec8a /agent-note-web-capability-seam-stable-tools-over-multiple-providers/decision: - en: 2e08d801099cbe18 - zh: 8c5ab162ada9cc41 + en: 7a4921d82cd11749 + zh: 6ab506109afc1351 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/package-topology: - en: 5ea3ddfae82df181 - zh: 2d05d95e51081696 + en: 3394139c17d28ba8 + zh: 66857733ba51d98c /agent-note-web-capability-seam-stable-tools-over-multiple-providers/ctx-web-contract: en: b432f80a424c3e49 zh: faa26925fdd98387 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/provider-availability-and-selection: - en: 4e4ec068c2a22383 - zh: b7030d0f00bfdb74 + en: afc540d986e00d68 + zh: 84795770d00228c3 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/search-request-and-result-schema: - en: ec3703b7e7c6b23d - zh: 325755e6e90092da + en: ccf6404ae7f3172e + zh: 3253035ea49e2548 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/fetch-request-and-result-schema: en: 6dba3b9a51c28b6d zh: 58a93f6ba2d269ea @@ -33,8 +33,8 @@ en: 3440f2b34b93f4a1 zh: 57a8f784c7e26eff /agent-note-web-capability-seam-stable-tools-over-multiple-providers/testing: - en: 7f73699ac4459432 - zh: c16be49ac50c34c8 + en: 03da2f3422183384 + zh: 10fede51a415ce11 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered: en: 6119e2aff3b6be5a zh: 76136ef5249c23eb @@ -42,8 +42,8 @@ en: e0d84a9b1b9c2123 zh: 9e116a7b96751563 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/put-provider-dispatch-directly-in-dsh-tool-web: - en: dc01d014d03b49d2 - zh: b956b02f41c62682 + en: 68ef45e8e5a36de9 + zh: 0013786ed77e03a8 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/split-search-and-fetch-into-two-seams-dsh-search-dsh-fetch: en: 3fb72ba3e554f20a zh: aff5f0add5773388 @@ -63,11 +63,11 @@ en: 3ea94ffb142d144a zh: 89645f68412f6769 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/consequences: - en: 9774448fd6b21c41 - zh: 8411c590ad70d5c4 + en: 8e7523b2858efaf3 + zh: aff8fc74eb439ed6 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/deferred-work: - en: 48b7b8e7310cd56b - zh: d406cd6a8a649e58 + en: ddb599fd2ac1647c + zh: c0b8f446cf483b3c /agent-note-web-capability-seam-stable-tools-over-multiple-providers/open-questions: en: 63848c6ef9a6ab44 zh: 041b340802091e60 diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md index 74e88534ee..b1fe6636ab 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md @@ -6,7 +6,7 @@ English | [中文](2026-06-24-web-capability-seam.zh.md) ## Problem -The harness needs model-facing web tools without binding the model contract to one vendor's API shape. Search is the immediate pressure point: supporting both Exa search and Perplexity search from the start — two deliberately different provider shapes (Exa returns a flat `results[]` of `{title, url, highlights, publishedDate}`; Perplexity returns a generated answer plus citations) — is what proves the normalized web contract does not just mirror one vendor. Fetch is a separate operation: an anonymous public HTTP(S) fetch backend has transport, security, redirect, decoding, and size-limit concerns that are not the same as provider-backed search. +The harness needs model-facing web tools without binding the model contract to one vendor's API shape. Search is the immediate pressure point: search APIs return deliberately different formats (Exa returns a flat `results[]` of `{title, url, highlights, publishedDate}`, Perplexity returns a generated answer plus citations, and the DeepSeek Search API returns a flat `results[]` with snippets and summaries), and the normalized web contract must accept each of them without mirroring one vendor. The seam was designed against the Exa and Perplexity formats; neither provider ships, and a plugin can register one. Fetch is a separate operation: an anonymous public HTTP(S) fetch backend has transport, security, redirect, decoding, and size-limit concerns that are not the same as provider-backed search. The model-facing API must stay stable while backends change. A search provider swap should not change how the model asks for a query, and a fetch implementation swap should not change how the model asks for a URL. Conversely, a provider package should not expose its own model-facing tool schema just because it has extra provider-specific knobs. @@ -19,7 +19,7 @@ There is also a provider-selection question. Existing `tool-bash` and `tool-fs` Web access is a first-class capability seam following [the capability-seam Agent Note](2026-06-13-capability-seams.md): 1. `@deepseek-ai/dsh-web` (`packages/web/web`) owns `ctx.web`, provider registration, provider selection, shared request/result vocabulary, and web-specific errors. -2. Provider packages implement concrete backends and register capabilities with `ctx.web`, for example `@deepseek-ai/dsh-web-search-exa`, `@deepseek-ai/dsh-web-search-perplexity`, `@deepseek-ai/dsh-web-search-deepseek`, and `@deepseek-ai/dsh-web-fetch-http`. +2. Provider packages implement concrete backends and register capabilities with `ctx.web`, for example `@deepseek-ai/dsh-web-search-deepseek`, `@deepseek-ai/dsh-web-deepseek-api`, and `@deepseek-ai/dsh-web-fetch-http`. 3. `@deepseek-ai/dsh-tool-web` (`packages/web/tool-web`) owns the model-facing `web_search` and `web_fetch` tool schemas, prompt sections, argument validation, result formatting, and tool-owned presentation over `ctx.web`. Providers do not register tools. Providers register capabilities. `dsh-tool-web` is the only owner of model-facing names, descriptions, prompt guidance, JSON schemas, and presentation. @@ -32,9 +32,9 @@ Search and fetch are separate tools but one web-access seam. `ctx.web` owns prov - A tool is never unregistered merely because its selected provider is missing, misconfigured, missing credentials, ambiguous, or temporarily unavailable. - The provider is resolved at execution time, and a structured `WebError` is returned when the selected capability cannot run. -This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. If web search is enabled but no usable search provider exists, `web_search` remains visible and execution fails with a structured `WebError` such as `WEB_PROVIDER_UNAVAILABLE` or `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. `dsh-tool-web` narrows this per model request: it [withholds `web_search` while the selected provider reports that the request's search cannot authenticate](2026-09-27-web-search-offered-by-provider-readiness.md). If a provider appears after `dsh-tool-web`, the next execution can use it without changing the schema. If a provider disappears mid-call, execution fails with a structured `WebError` instead of silently choosing another provider or falling through to `UNKNOWN_TOOL`. +This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. If web search is enabled but no usable search provider exists, `web_search` remains visible and execution fails with a structured `WebError` such as `WEB_PROVIDER_UNAVAILABLE` or `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. `dsh-tool-web` narrows this per model request: it [withholds `web_search` or `web_fetch` while the selected provider reports that the request's operation cannot authenticate](2026-09-27-web-search-offered-by-provider-readiness.md). If a provider appears after `dsh-tool-web`, the next execution can use it without changing the schema. If a provider disappears mid-call, execution fails with a structured `WebError` instead of silently choosing another provider or falling through to `UNKNOWN_TOOL`. -The seam deliberately exposes no observation surface — no registry-change event and no aggregated capability-status query. Unavailability is a fact a caller observes by executing: `search()`/`fetch()` resolve the provider at call time and throw the structured `WebError` that names what failed. [The observation-surface Agent Note](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) records that judgment: derived-on-call selection and enablement-based registration leave no consumer that needs a change signal or an availability probe distinct from executing and routing the error, and a future provider-status panel reintroduces the smallest signal or query it actually consumes. +The seam deliberately exposes no availability observation — no aggregated capability-status query and no per-provider availability report. Unavailability is a fact a caller observes by executing: `search()`/`fetch()` resolve the provider at call time and throw the structured `WebError` that names what failed. [The observation-surface Agent Note](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) records that judgment: derived-on-call selection and enablement-based registration leave no consumer that needs a change signal or an availability probe distinct from executing and routing the error, and a future provider-status panel reintroduces the smallest signal or query it actually consumes. Provider pickers consume registration, not availability: `listProviders()` and the Remote method `web/listProviders` list every registered provider with its optional `title` and `description` (including unavailable providers), and the payload-free `web/providers-changed` event fires after each registry mutation. The [web provider selection decision](2026-10-29-web-provider-selection.md) records why registration is observable while availability is not. ## Package topology @@ -43,11 +43,9 @@ The three-package Service Definition / Service Provider / Consumer split follows The dependency direction mirrors bash and filesystem: ```text -@deepseek-ai/dsh-tool-web --depends on--> @deepseek-ai/dsh-web <--depends on-- @deepseek-ai/dsh-web-search-exa +@deepseek-ai/dsh-tool-web --depends on--> @deepseek-ai/dsh-web <--depends on-- @deepseek-ai/dsh-web-search-deepseek consumer interface implementation - <--depends on-- @deepseek-ai/dsh-web-search-perplexity - implementation - <--depends on-- @deepseek-ai/dsh-web-search-deepseek + <--depends on-- @deepseek-ai/dsh-web-deepseek-api implementation <--depends on-- @deepseek-ai/dsh-web-fetch-http implementation @@ -57,9 +55,7 @@ At runtime, provider packages register capabilities with `ctx.web`; `tool-web` r ```mermaid flowchart LR - exa["@deepseek-ai/dsh-web-search-exa"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"] - perplexity["@deepseek-ai/dsh-web-search-perplexity"] -->|registerSearchProvider| web - deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web + deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"] deepseekApi["@deepseek-ai/dsh-web-deepseek-api"] -->|registerSearchProvider / registerFetchProvider| web fetchLocal["@deepseek-ai/dsh-web-fetch-http"] -->|registerFetchProvider| web toolWeb["@deepseek-ai/dsh-tool-web"] -->|search/fetch| web @@ -67,9 +63,9 @@ flowchart LR toolWeb -->|ctx.tools.register| webFetch["tool: web_fetch"] ``` -`@deepseek-ai/dsh-web` depends only on Cordis and low-level harness support. It declares `ctx.web`, provider interfaces, request/result types, the provider availability contract, and error codes. It does not import tool, agent, session, LLM, or provider packages. +`@deepseek-ai/dsh-web` depends only on Cordis and low-level harness support, including the Typert protocol for its Remote method. It declares `ctx.web`, provider interfaces, request/result types, the provider availability contract, and error codes. It does not import tool, agent, session, LLM, or provider packages. -Provider packages depend only on `dsh-web` and Cordis. They own credentials, endpoints, wire mapping, parsing, and `WebError` translation, using platform `fetch`. Each provider injects the shared service and registers a backend; only `dsh-web` owns the `ctx.web` key. Provider-private protocol shapes do not create dependencies on `ctx.llm` or a Cordis HTTP service. +Provider packages depend on `dsh-web`, Cordis, and their credential/configuration helpers. They own credentials, endpoints, wire mapping, parsing, and `WebError` translation, using platform `fetch`. Each provider injects the shared service and registers a backend; only `dsh-web` owns the `ctx.web` key. Provider-private protocol shapes do not create dependencies on `ctx.llm` or a Cordis HTTP service. `@deepseek-ai/dsh-tool-web` depends on `@deepseek-ai/dsh-web`, `@deepseek-ai/dsh-tools`, `@deepseek-ai/dsh-system-prompt`, and Cordis. It never imports concrete provider packages. @@ -78,16 +74,22 @@ Provider packages depend only on `dsh-web` and Cordis. They own credentials, end `ctx.web` is a provider registry plus a provider-selecting execution API. The registry half stays close to `LlmRuntime`: a `Map` per capability kind, `registerSearchProvider` / `registerFetchProvider` methods that return disposers, duplicate ids that throw `WebError`, and execution-time resolution that throws when the selected provider is absent or unusable. The authoritative signatures live in `packages/web/web/src/types.ts`; the seam's shape: ```ts -import type { WebFetchRequest, WebFetchResult, WebSearchRequest, WebSearchResult } from '@deepseek-ai/dsh-web' +import type { + WebFetchRequest, WebFetchResult, WebProviderInfo, WebProviderList, WebSearchRequest, WebSearchResult, +} from '@deepseek-ai/dsh-web' interface WebSearchProvider { readonly id: string + readonly title?: string + readonly description?: WebProviderInfo['description'] available(): boolean search(request: WebSearchRequest, signal?: AbortSignal): Promise } interface WebFetchProvider { readonly id: string + readonly title?: string + readonly description?: WebProviderInfo['description'] available(): boolean fetch(request: WebFetchRequest, signal?: AbortSignal): Promise } @@ -95,6 +97,7 @@ interface WebFetchProvider { interface WebRuntime { registerSearchProvider(provider: WebSearchProvider): () => void registerFetchProvider(provider: WebFetchProvider): () => void + listProviders(): WebProviderList search(request: WebSearchRequest, signal?: AbortSignal): Promise fetch(request: WebFetchRequest, signal?: AbortSignal): Promise @@ -131,18 +134,15 @@ The "single provider auto-selects" rule is for tests, demos, and simple deployme - id: web name: '@deepseek-ai/dsh-web' config: - searchProvider: exa + searchProvider: deepseek-search-api fetchProvider: http -- id: web-search-exa - name: '@deepseek-ai/dsh-web-search-exa' - -- id: web-search-perplexity - name: '@deepseek-ai/dsh-web-search-perplexity' - - id: web-search-deepseek name: '@deepseek-ai/dsh-web-search-deepseek' +- id: web-deepseek-api + name: '@deepseek-ai/dsh-web-deepseek-api' + - id: web-fetch-http name: '@deepseek-ai/dsh-web-fetch-http' @@ -150,9 +150,9 @@ The "single provider auto-selects" rule is for tests, demos, and simple deployme name: '@deepseek-ai/dsh-tool-web' ``` -Operational overrides feed the same explicit selection path: `DSH_WEB_SEARCH_PROVIDER=perplexity` is equivalent to config `searchProvider: perplexity`, not a hidden priority chain inside `dsh-tool-web`. +Operational overrides feed the same explicit selection path: `DSH_WEB_SEARCH_PROVIDER=deepseek-search-api` is equivalent to config `searchProvider: deepseek-search-api`, not a hidden priority chain inside `dsh-tool-web`. -Each search or fetch reads the configured id when it runs, so a saved settings change selects another provider for the next operation without remounting `dsh-web`. The Web search settings switch relies on this to move both capabilities to the [DeepSeek Search and Fetch APIs](../feature/2026-10-15-deepseek-search-fetch-api.md) at once. +Each search or fetch reads the configured id when it runs, so a saved settings change selects another provider for the next operation without remounting `dsh-web`. The Search and Fetch menus on the Web search and fetch settings page rely on this to switch each capability's provider independently, including the [DeepSeek Search and Fetch APIs](../feature/2026-10-15-deepseek-search-fetch-api.md); the [web provider selection decision](2026-10-29-web-provider-selection.md) owns the shipped defaults. `ctx.web.search()` and `ctx.web.fetch()` resolve the provider at execution time using the selection rules above. If the selected capability is unavailable, they throw `WebError` with a structured code such as `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, or `WEB_PROVIDER_AMBIGUOUS`. If no provider is explicitly configured and no usable provider exists, the execution error is the generic `WEB_PROVIDER_UNAVAILABLE` case; there is deliberately no diagnostic summary of every unavailable provider. @@ -168,8 +168,8 @@ The `web_search` model-facing tool is small. The only model-facing argument is: - `dsh-tool-web` owns the value and puts it on `WebSearchRequest.maxResults`. - `ctx.web` passes the request through to the selected provider unchanged. -- A provider applies `maxResults` at the request layer when its API supports it (Exa's `numResults`), as a cost/latency optimization. -- `ctx.web` enforces the bound on the result: if a provider returns more than `maxResults` sources — because its API has no result-count control (Perplexity) or ignored the hint — the seam truncates `sources[]` to `maxResults` and sets `WebSearchResult.truncated` to `true` before returning. This makes the bound a single cross-provider guarantee the model-facing layer can rely on, rather than something each provider must remember to honor. +- A provider applies `maxResults` at the request layer when its API supports it (the DeepSeek Search API's `count`), as a cost/latency optimization. +- `ctx.web` enforces the bound on the result: if a provider returns more than `maxResults` sources — because its API has no result-count control or ignored the hint — the seam truncates `sources[]` to `maxResults` and sets `WebSearchResult.truncated` to `true` before returning. This makes the bound a single cross-provider guarantee the model-facing layer can rely on, rather than something each provider must remember to honor. The seam request carries no provider-specific controls — no Perplexity model selection, search recency, domain filters, Exa `livecrawl`, Exa `type`, regional hints, generated-answer budgets, or search depth. Such a field is added only when it has provider-neutral semantics that both the tool schema and selected providers can honor honestly. @@ -190,13 +190,14 @@ interface WebSearchSource { readonly url: string readonly title?: string readonly snippet?: string + readonly summary?: string readonly publishedAt?: string } ``` -`content` is optional provider-generated answer text, search context, or summary. `sources[]` is the portable citation shape. A source always has a URL; title, snippet, and `publishedAt` are optional because not every provider returns them. `title` is not required: Perplexity-style citations may provide only URLs, and forcing adapters to invent titles would make the seam lie. `dsh-tool-web` renders a `title ?? hostname(url)`-style fallback label for display. `publishedAt` is an optional publication/crawl timestamp as an ISO-8601 string — Exa returns it as `publishedDate` on each result and Perplexity returns a `date` on search results, so it is real provider data, not derived; the seam carries it as a string and leaves date parsing to the consumer. +`content` is optional provider-generated answer text, search context, or summary. `sources[]` is the portable citation shape. A source always has a URL; title, snippet, summary, and `publishedAt` are optional because not every provider returns them. `snippet` is a short excerpt for display, and `summary` is a longer excerpt for model reading whose length the consumer bounds. `title` is not required: Perplexity-style citations may provide only URLs, and forcing adapters to invent titles would make the seam lie. `dsh-tool-web` renders a `title ?? hostname(url)`-style fallback label for display. `publishedAt` is an optional publication/crawl timestamp as an ISO-8601 string — the DeepSeek Search API returns it as a nullable `date` on each result, Exa as `publishedDate`, and Perplexity as `date`, so it is real provider data, not derived; the seam carries it as a string and leaves date parsing to the consumer. -Exa search maps each entry of the provider's flat `results[]` into a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← the first `highlights[]` entry (an entry with no highlight has no portable snippet and is dropped), `publishedAt` ← `publishedDate`. Exa returns no provider-generated answer, so `content` is omitted. Perplexity search maps `choices[0].message.content` to `content` and prefers the structured top-level `search_results[]` for `sources[]` — `url` ← `url`, `title` ← `title`, `snippet` ← `snippet` (often empty), `publishedAt` ← `date` — falling back to the URL-only `citations[]` array only when `search_results` is absent (those sources carry just a `url`). If a provider returns fewer structured fields than the seam supports, the adapter omits those optional fields. +The DeepSeek Search API provider maps each entry of the response's `results[]` into a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← `snippet`, `summary` ← `summary`, `publishedAt` ← `date`, omitting empty values. It returns no provider-generated answer, so `content` is omitted. If a provider returns fewer structured fields than the seam supports, the adapter omits those optional fields. Full page retrieval remains the job of `web_fetch(url)`. Search snippets are discovery context, not fetched page bodies. @@ -283,7 +284,7 @@ Tool execution lets these errors flow through `ToolRuntime.execute()`, which alr ## Testing -Each layer is pinned at its own boundary: the registry/selection/truncation/abort contract and the `WebError` codes in `dsh-web`; per-provider request/response mapping over recorded fixtures (Perplexity fixtures include URL-only citations so the optional source fields stay honest) plus a self-skipping with-key smoke per real provider; real local-HTTP behavior in `web-fetch-http`; and enablement-driven registration, structured execution errors, and result formatting through the real tool registry in `dsh-tool-web`. A real-Loader smoke guards the two export shapes ([postmortem 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)): `dsh-web` is a default-exported service, while the providers and `tool-web` are namespace plugins where a stray `export default` would drop `inject`. +Each layer is pinned at its own boundary: the registry/selection/truncation/abort contract and the `WebError` codes in `dsh-web`; per-provider request/response mapping over recorded fixtures plus a self-skipping with-key smoke per real provider; real local-HTTP behavior in `web-fetch-http`; and enablement-driven registration, structured execution errors, and result formatting through the real tool registry in `dsh-tool-web`. A real-Loader smoke guards the two export shapes ([postmortem 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)): `dsh-web` is a default-exported service, while the providers and `tool-web` are namespace plugins where a stray `export default` would drop `inject`. ## Alternatives considered @@ -293,7 +294,7 @@ This matches the most flexible provider-plugin systems: every provider can expos ### Put provider dispatch directly in `dsh-tool-web` -This resembles OpenCode's local web search: one stable `websearch` tool dispatches to Exa or Parallel internally. It is acceptable for a small product path but wrong as a harness foundation. The tool package would own provider selection, credentials, request mapping, transport, response parsing, and presentation, making it hard to add Exa and Perplexity without baking their differences into the tool schema. +This resembles OpenCode's local web search: one stable `websearch` tool dispatches to Exa or Parallel internally. It is acceptable for a small product path but wrong as a harness foundation. The tool package would own provider selection, credentials, request mapping, transport, response parsing, and presentation, making it hard to add providers with different formats without baking their differences into the tool schema. ### Split search and fetch into two seams (`dsh-search`, `dsh-fetch`) @@ -321,9 +322,9 @@ Rejected for the shipped presets. Per-call confirmation would interrupt ordinary ## Consequences -**The search schema is deliberately thin.** Exa and Perplexity both expose useful provider-specific controls; a control is added only once it can be defined provider-neutrally and enforced honestly by both tool registration and provider execution. +**The search schema is deliberately thin.** Search APIs expose useful provider-specific controls, such as Exa's `livecrawl`, Perplexity's search recency, and the DeepSeek Search API's `localization`; a control is added only once it can be defined provider-neutrally and enforced honestly by both tool registration and provider execution. -**Perplexity citations can be sparse.** A citation may be only a URL. Making `title` and `snippet` optional keeps the seam truthful but means `tool-web` renders fallback labels. +**Citations can be sparse.** A Perplexity-style citation may be only a URL. Making `title` and `snippet` optional keeps the seam truthful but means `tool-web` renders fallback labels. **Stable tool registration defers misconfiguration to execution.** Keeping the tool visible is correct when the product enabled web access, but product apps that expect web search should surface the structured `WEB_PROVIDER_CONFIGURED_MISSING` / `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` / `WEB_PROVIDER_AMBIGUOUS` failures loudly so users do not discover setup problems only after the model calls the tool. @@ -337,7 +338,7 @@ Rejected for the shipped presets. Per-call confirmation would interrupt ordinary - A `pdf` `WebFetchBody` kind: the `http` provider decodes text-extractable PDFs (best-effort, capped, `truncated`) into a `{ kind: 'pdf'; content; pageCount? }` arm, and `tool-web` renders it. This is fetch, not `web_extract` — PDF retrieval is a concrete HTTP 200 plus deterministic local decoding, not provider-side extraction of a non-HTTP resource. Adding it is a coordinated change across `dsh-web` (declare the arm), the provider (decode + narrow "binary rejection" to "reject binary except text-extractable PDF"; scanned/image PDFs needing OCR stay out of scope), and `tool-web` (render). The closed `WebFetchBody` union makes the consumer side fail to compile until the new arm is handled. - Provider-backed extraction as a separate `web_extract` capability, rather than widening `web_fetch` silently. -- Provider-neutral search controls beyond `query` and `maxResults`, once Exa and Perplexity can both honor them honestly. +- Provider-neutral search controls beyond `query` and `maxResults`, once the registered providers can honor them honestly. ## Open questions diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md index ac5f935c92..7977f68d34 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -harness 需要面向模型的 web 工具,但不能将模型约定绑定到某一家厂商的 API 形状上。搜索是当前的压力点:从一开始就同时支持 Exa 搜索和 Perplexity 搜索——两种刻意不同的提供方形状(Exa 返回扁平的 `results[]`,每项包含 `{title, url, highlights, publishedDate}`;Perplexity 返回一段生成式回答加引用列表)——正是用来证明归一化的 web 约定并非只是镜像某一家厂商。Fetch 是另一项独立操作:匿名公开 HTTP(S) fetch 后端涉及传输、安全、重定向、解码和大小限制等关注点,与提供方支撑的搜索并不相同。 +harness 需要面向模型的 web 工具,但不能将模型约定绑定到某一家厂商的 API 形状上。搜索是当前的压力点:各搜索 API 返回刻意不同的格式(Exa 返回扁平的 `results[]`,每项包含 `{title, url, highlights, publishedDate}`;Perplexity 返回一段生成式回答加引用列表;DeepSeek Search API 返回带 snippet 与摘要的扁平 `results[]`),归一化的 web 约定必须能接纳每一种格式,而不是镜像某一家厂商。seam 是以 Exa 和 Perplexity 的格式为参照设计的;这两个提供方都不随附,插件可以注册它们。Fetch 是另一项独立操作:匿名公开 HTTP(S) fetch 后端涉及传输、安全、重定向、解码和大小限制等关注点,与提供方支撑的搜索并不相同。 面向模型的 API 必须保持稳定,而后端可以更换。更换搜索提供方不应改变模型发起查询的方式;更换 fetch 实现不应改变模型请求 URL 的方式。反过来,提供方包也不应仅仅因为自己有额外的提供方特有旋钮就暴露自己的面向模型工具 schema。 @@ -19,7 +19,7 @@ harness 需要面向模型的 web 工具,但不能将模型约定绑定到某 Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13-capability-seams.zh.md): 1. `@deepseek-ai/dsh-web`(`packages/web/web`)拥有 `ctx.web`、提供方注册、提供方选择、共享的请求/结果词汇,以及 web 特有的错误。 -2. 提供方包实现具体后端并向 `ctx.web` 注册能力,例如 `@deepseek-ai/dsh-web-search-exa`、`@deepseek-ai/dsh-web-search-perplexity`、`@deepseek-ai/dsh-web-search-deepseek` 和 `@deepseek-ai/dsh-web-fetch-http`。 +2. 提供方包实现具体后端并向 `ctx.web` 注册能力,例如 `@deepseek-ai/dsh-web-search-deepseek`、`@deepseek-ai/dsh-web-deepseek-api` 和 `@deepseek-ai/dsh-web-fetch-http`。 3. `@deepseek-ai/dsh-tool-web`(`packages/web/tool-web`)拥有面向模型的 `web_search` 和 `web_fetch` 工具 schema、提示词段落、参数校验、结果格式化,以及通过 `ctx.web` 实现的工具展示。 提供方不注册工具。提供方注册能力。`dsh-tool-web` 是面向模型的名称、描述、提示词引导、JSON Schema、展示的唯一所有者。 @@ -32,9 +32,9 @@ Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13 - 工具绝不会仅仅因为其选定的提供方缺失、配置错误、缺少凭证、存在歧义或暂时不可用就被注销。 - 提供方在执行时解析,当选定的能力无法运行时返回结构化的 `WebError`。 -这使模型 schema 保持稳定,而不将插件加载顺序、凭证状态或 HMR(热模块替换)时序纳入面向模型的约定。如果 web 搜索已启用但不存在可用的搜索提供方,`web_search` 仍然可见,执行时以结构化的 `WebError`(如 `WEB_PROVIDER_UNAVAILABLE` 或 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`)失败。发行版的 `dsh-tool-web` 按模型请求收窄了这一点:选中的提供方报告该请求的搜索无法鉴权时,它会[撤下 `web_search`](2026-09-27-web-search-offered-by-provider-readiness.zh.md)。如果某个提供方在 `dsh-tool-web` 之后出现,下一次执行即可使用它而无需更改 schema。如果某个提供方在调用过程中消失,执行以结构化的 `WebError` 失败,而不是静默选择另一个提供方或回退到 `UNKNOWN_TOOL`。 +这使模型 schema 保持稳定,而不将插件加载顺序、凭证状态或 HMR(热模块替换)时序纳入面向模型的约定。如果 web 搜索已启用但不存在可用的搜索提供方,`web_search` 仍然可见,执行时以结构化的 `WebError`(如 `WEB_PROVIDER_UNAVAILABLE` 或 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`)失败。`dsh-tool-web` 按模型请求收窄了这一点:选中的提供方报告该请求的操作无法鉴权时,它会[撤下 `web_search` 或 `web_fetch`](2026-09-27-web-search-offered-by-provider-readiness.zh.md)。如果某个提供方在 `dsh-tool-web` 之后出现,下一次执行即可使用它而无需更改 schema。如果某个提供方在调用过程中消失,执行以结构化的 `WebError` 失败,而不是静默选择另一个提供方或回退到 `UNKNOWN_TOOL`。 -该 seam 刻意不暴露任何观察面——没有注册表变更事件,也没有聚合的能力状态查询。不可用性是调用方通过执行观察到的事实:`search()`/`fetch()` 在调用时解析提供方,并抛出命名了失败原因的结构化 `WebError`。[观察面 Agent Note](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) 记录了这一判断:基于调用的派生选择与基于启用的注册使得没有消费方需要变更信号或独立于执行和错误路由的可用性探测;未来的提供方状态面板会重新引入它实际消费的最小信号或查询。 +该 seam 刻意不暴露可用性观察——没有聚合的能力状态查询,也没有逐个提供方的可用性报告。不可用性是调用方通过执行观察到的事实:`search()`/`fetch()` 在调用时解析提供方,并抛出命名了失败原因的结构化 `WebError`。[观察面 Agent Note](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) 记录了这一判断:基于调用的派生选择与基于启用的注册使得没有消费方需要变更信号或独立于执行和错误路由的可用性探测;未来的提供方状态面板会重新引入它实际消费的最小信号或查询。提供方选择器消费的是注册情况而非可用性:`listProviders()` 与 Remote 方法 `web/listProviders` 列出每个已注册提供方及其可选的 `title` 与 `description`(包括不可用的提供方),且每次注册表变更后都会触发无载荷的 `web/providers-changed` 事件。[Web 提供方选择决策](2026-10-29-web-provider-selection.zh.md)记录了为何注册情况可观察而可用性不可观察。 ## 包拓扑 @@ -43,11 +43,9 @@ Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13 依赖方向与 bash 和 filesystem 一致: ```text -@deepseek-ai/dsh-tool-web --depends on--> @deepseek-ai/dsh-web <--depends on-- @deepseek-ai/dsh-web-search-exa +@deepseek-ai/dsh-tool-web --depends on--> @deepseek-ai/dsh-web <--depends on-- @deepseek-ai/dsh-web-search-deepseek consumer interface implementation - <--depends on-- @deepseek-ai/dsh-web-search-perplexity - implementation - <--depends on-- @deepseek-ai/dsh-web-search-deepseek + <--depends on-- @deepseek-ai/dsh-web-deepseek-api implementation <--depends on-- @deepseek-ai/dsh-web-fetch-http implementation @@ -57,9 +55,7 @@ Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13 ```mermaid flowchart LR - exa["@deepseek-ai/dsh-web-search-exa"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"] - perplexity["@deepseek-ai/dsh-web-search-perplexity"] -->|registerSearchProvider| web - deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web + deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"] deepseekApi["@deepseek-ai/dsh-web-deepseek-api"] -->|registerSearchProvider / registerFetchProvider| web fetchLocal["@deepseek-ai/dsh-web-fetch-http"] -->|registerFetchProvider| web toolWeb["@deepseek-ai/dsh-tool-web"] -->|search/fetch| web @@ -67,9 +63,9 @@ flowchart LR toolWeb -->|ctx.tools.register| webFetch["tool: web_fetch"] ``` -`@deepseek-ai/dsh-web` 仅依赖 Cordis 和底层 harness 支持。它声明 `ctx.web`、提供方接口、请求/结果类型、提供方可用性约定和错误码。它不导入工具、agent(智能体)、会话、LLM 或提供方包。 +`@deepseek-ai/dsh-web` 仅依赖 Cordis 和底层 harness 支持,其中包括其 Remote 方法所用的 Typert 协议。它声明 `ctx.web`、提供方接口、请求/结果类型、提供方可用性约定和错误码。它不导入工具、agent(智能体)、会话、LLM 或提供方包。 -提供方包仅依赖 `dsh-web` 和 Cordis。它们拥有凭证、端点、协议格式映射、解析和 `WebError` 转换,使用平台 `fetch`。每个提供方注入共享服务并注册后端;只有 `dsh-web` 拥有 `ctx.web` 键。提供方私有的协议形状不会产生对 `ctx.llm` 或 Cordis HTTP 服务的依赖。 +提供方包依赖 `dsh-web`、Cordis 及其凭据和配置函数。它们拥有凭证、端点、协议格式映射、解析和 `WebError` 转换,使用平台 `fetch`。每个提供方注入共享服务并注册后端;只有 `dsh-web` 拥有 `ctx.web` 键。提供方私有的协议形状不会产生对 `ctx.llm` 或 Cordis HTTP 服务的依赖。 `@deepseek-ai/dsh-tool-web` 依赖 `@deepseek-ai/dsh-web`、`@deepseek-ai/dsh-tools`、`@deepseek-ai/dsh-system-prompt` 和 Cordis。它从不导入具体的提供方包。 @@ -78,16 +74,22 @@ flowchart LR `ctx.web` 是一个提供方注册表加上一个带提供方选择的执行 API。注册表部分与 `LlmRuntime` 保持接近:每种能力类别一个 `Map`,`registerSearchProvider`/`registerFetchProvider` 方法返回 disposer,重复 id 抛出 `WebError`,执行时解析在选定提供方缺失或不可用时抛出异常。权威签名见 `packages/web/web/src/types.ts`;seam 的形状: ```ts -import type { WebFetchRequest, WebFetchResult, WebSearchRequest, WebSearchResult } from '@deepseek-ai/dsh-web' +import type { + WebFetchRequest, WebFetchResult, WebProviderInfo, WebProviderList, WebSearchRequest, WebSearchResult, +} from '@deepseek-ai/dsh-web' interface WebSearchProvider { readonly id: string + readonly title?: string + readonly description?: WebProviderInfo['description'] available(): boolean search(request: WebSearchRequest, signal?: AbortSignal): Promise } interface WebFetchProvider { readonly id: string + readonly title?: string + readonly description?: WebProviderInfo['description'] available(): boolean fetch(request: WebFetchRequest, signal?: AbortSignal): Promise } @@ -95,6 +97,7 @@ interface WebFetchProvider { interface WebRuntime { registerSearchProvider(provider: WebSearchProvider): () => void registerFetchProvider(provider: WebFetchProvider): () => void + listProviders(): WebProviderList search(request: WebSearchRequest, signal?: AbortSignal): Promise fetch(request: WebFetchRequest, signal?: AbortSignal): Promise @@ -131,18 +134,15 @@ interface WebRuntime { - id: web name: '@deepseek-ai/dsh-web' config: - searchProvider: exa + searchProvider: deepseek-search-api fetchProvider: http -- id: web-search-exa - name: '@deepseek-ai/dsh-web-search-exa' - -- id: web-search-perplexity - name: '@deepseek-ai/dsh-web-search-perplexity' - - id: web-search-deepseek name: '@deepseek-ai/dsh-web-search-deepseek' +- id: web-deepseek-api + name: '@deepseek-ai/dsh-web-deepseek-api' + - id: web-fetch-http name: '@deepseek-ai/dsh-web-fetch-http' @@ -150,9 +150,9 @@ interface WebRuntime { name: '@deepseek-ai/dsh-tool-web' ``` -运维覆盖走同一条显式选择路径:`DSH_WEB_SEARCH_PROVIDER=perplexity` 等同于配置 `searchProvider: perplexity`,而非 `dsh-tool-web` 内部的隐式优先级链。 +运维覆盖走同一条显式选择路径:`DSH_WEB_SEARCH_PROVIDER=deepseek-search-api` 等同于配置 `searchProvider: deepseek-search-api`,而非 `dsh-tool-web` 内部的隐式优先级链。 -每次搜索或抓取在执行时读取配置的 id,因此保存的设置变更无需重新挂载 `dsh-web` 即对下一次操作选择另一个提供方。网页搜索设置页的开关借此把两种能力同时切换到 [DeepSeek Search 与 Fetch API](../feature/2026-10-15-deepseek-search-fetch-api.zh.md)。 +每次搜索或抓取在执行时读取配置的 id,因此保存的设置变更无需重新挂载 `dsh-web` 即可让下一次操作选用新提供方。「网页搜索与获取」设置页的 Search 和 Fetch 下拉框借此分别切换各能力的提供方(包括 [DeepSeek Search 与 Fetch API](../feature/2026-10-15-deepseek-search-fetch-api.zh.md));[Web 提供方选择决策](2026-10-29-web-provider-selection.zh.md)负责内置默认值。 `ctx.web.search()` 和 `ctx.web.fetch()` 在执行时按上述选择规则解析提供方。如果选定的能力不可用,它们抛出带有结构化代码的 `WebError`,如 `WEB_PROVIDER_UNAVAILABLE`、`WEB_PROVIDER_CONFIGURED_MISSING`、`WEB_PROVIDER_CONFIGURED_UNAVAILABLE` 或 `WEB_PROVIDER_AMBIGUOUS`。如果未显式配置提供方且不存在可用提供方,执行错误是通用的 `WEB_PROVIDER_UNAVAILABLE` 情况;刻意不提供对每个不可用提供方的诊断汇总。 @@ -168,8 +168,8 @@ interface WebRuntime { - `dsh-tool-web` 拥有该值并将其放在 `WebSearchRequest.maxResults` 上。 - `ctx.web` 将请求原样传递给选定的提供方。 -- 当提供方的 API 支持结果数量控制时(Exa 的 `numResults`),提供方在请求层应用 `maxResults`,作为成本/延迟优化。 -- `ctx.web` 在结果上强制执行上限:如果提供方返回的 source 数量超过 `maxResults`——因为其 API 没有结果数量控制(Perplexity)或忽略了提示——seam 将 `sources[]` 截断到 `maxResults` 并在返回前将 `WebSearchResult.truncated` 设为 `true`。这使上限成为面向模型层可以依赖的单一跨提供方保证,而非每个提供方都必须记得遵守的东西。 +- 当提供方的 API 支持结果数量控制时(DeepSeek Search API 的 `count`),提供方在请求层应用 `maxResults`,作为成本/延迟优化。 +- `ctx.web` 在结果上强制执行上限:如果提供方返回的 source 数量超过 `maxResults`——因为其 API 没有结果数量控制或忽略了提示——seam 将 `sources[]` 截断到 `maxResults` 并在返回前将 `WebSearchResult.truncated` 设为 `true`。这使上限成为面向模型层可以依赖的单一跨提供方保证,而非每个提供方都必须记得遵守的东西。 seam 请求不携带提供方特有的控制——没有 Perplexity 模型选择、搜索时效性、域名过滤器、Exa `livecrawl`、Exa `type`、区域提示、生成式回答预算或搜索深度。只有当某个字段具有提供方无关的语义,且工具 schema 和选定的提供方都能诚实地遵守时,才会添加。 @@ -190,13 +190,14 @@ interface WebSearchSource { readonly url: string readonly title?: string readonly snippet?: string + readonly summary?: string readonly publishedAt?: string } ``` -`content` 是可选的提供方生成的回答文本、搜索上下文或摘要。`sources[]` 是可移植的引用结构。source 必有 URL;title、snippet 和 `publishedAt` 可选,因为并非每个提供方都返回它们。`title` 不是必填:Perplexity 风格的引用可能只提供 URL,强制适配器编造标题会让 seam 说谎。`dsh-tool-web` 渲染 `title ?? hostname(url)` 风格的回退标签用于展示。`publishedAt` 是可选的发布/抓取时间戳,为 ISO-8601 字符串——Exa 在每条结果上以 `publishedDate` 返回它,Perplexity 在搜索结果上返回 `date`,因此它是真实的提供方数据而非派生值;seam 以字符串形式传递,日期解析留给消费方。 +`content` 是可选的提供方生成的回答文本、搜索上下文或摘要。`sources[]` 是可移植的引用结构。source 必有 URL;title、snippet、summary 和 `publishedAt` 可选,因为并非每个提供方都返回它们。`snippet` 是用于展示的短摘录,`summary` 是供模型阅读的较长摘录,其长度由消费方限制。`title` 不是必填:Perplexity 风格的引用可能只提供 URL,强制适配器编造标题会让 seam 说谎。`dsh-tool-web` 渲染 `title ?? hostname(url)` 风格的回退标签用于展示。`publishedAt` 是可选的发布/抓取时间戳,为 ISO-8601 字符串——DeepSeek Search API 在每条结果上以可为 null 的 `date` 返回它,Exa 以 `publishedDate` 返回,Perplexity 以 `date` 返回,因此它是真实的提供方数据而非派生值;seam 以字符串形式传递,日期解析留给消费方。 -Exa 搜索将提供方扁平 `results[]` 的每一项映射为 `WebSearchSource`:`url` ← `url`、`title` ← `title`、`snippet` ← 第一个 `highlights[]` 条目(没有 highlight 的条目没有可移植的 snippet,被丢弃)、`publishedAt` ← `publishedDate`。Exa 不返回提供方生成的回答,因此 `content` 省略。Perplexity 搜索将 `choices[0].message.content` 映射为 `content`,并优先使用结构化的顶层 `search_results[]` 作为 `sources[]`——`url` ← `url`、`title` ← `title`、`snippet` ← `snippet`(常为空)、`publishedAt` ← `date`——仅在 `search_results` 缺失时回退到纯 URL 的 `citations[]` 数组(这些 source 只有 `url`)。如果提供方返回的结构化字段少于 seam 支持的,适配器省略那些可选字段。 +DeepSeek Search API 提供方将响应 `results[]` 的每一项映射为 `WebSearchSource`:`url` ← `url`、`title` ← `title`、`snippet` ← `snippet`、`summary` ← `summary`、`publishedAt` ← `date`,空值省略。它不返回提供方生成的回答,因此 `content` 省略。如果提供方返回的结构化字段少于 seam 支持的,适配器省略那些可选字段。 完整页面获取仍是 `web_fetch(url)` 的职责。搜索 snippet 是发现上下文,不是获取到的页面正文。 @@ -283,7 +284,7 @@ fetch 提供方的资源控制: ## 测试 -每一层在自己的边界处固定:`dsh-web` 中的注册/选择/截断/abort 约定与 `WebError` 码;每个提供方基于录制的 fixture(测试前置数据)的请求/响应映射(Perplexity fixture 包含纯 URL 引用,以保持可选 source 字段的诚实性),加上每个真实提供方的自跳过带密钥冒烟测试;`web-fetch-http` 中的真实本地 HTTP 行为;`dsh-tool-web` 中通过真实工具注册表的启用驱动注册、结构化执行错误和结果格式化。一个真实 Loader 冒烟测试守护两种导出形状([事故复盘(postmortem) 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.zh.md)):`dsh-web` 是默认导出的服务,而提供方和 `tool-web` 是命名空间插件,误加 `export default` 会丢失 `inject`。 +每一层在自己的边界处固定:`dsh-web` 中的注册/选择/截断/abort 约定与 `WebError` 码;每个提供方基于录制的 fixture(测试前置数据)的请求/响应映射,加上每个真实提供方的自跳过带密钥冒烟测试;`web-fetch-http` 中的真实本地 HTTP 行为;`dsh-tool-web` 中通过真实工具注册表的启用驱动注册、结构化执行错误和结果格式化。一个真实 Loader 冒烟测试守护两种导出形状([事故复盘(postmortem) 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.zh.md)):`dsh-web` 是默认导出的服务,而提供方和 `tool-web` 是命名空间插件,误加 `export default` 会丢失 `inject`。 ## 曾考虑的替代方案 @@ -293,7 +294,7 @@ fetch 提供方的资源控制: ### 将提供方调度直接放在 `dsh-tool-web` 中 -这类似 OpenCode 的本地 web 搜索:一个稳定的 `websearch` 工具在内部调度到 Exa 或 Parallel。对于小型产品路径可以接受,但作为 harness 基础是错误的。工具包将拥有提供方选择、凭证、请求映射、传输、响应解析和展示,使得在不将 Exa 和 Perplexity 的差异烘焙进工具 schema 的情况下难以添加它们。 +这类似 OpenCode 的本地 web 搜索:一个稳定的 `websearch` 工具在内部调度到 Exa 或 Parallel。对于小型产品路径可以接受,但作为 harness 基础是错误的。工具包将拥有提供方选择、凭证、请求映射、传输、响应解析和展示,使得在不将各提供方格式差异烘焙进工具 schema 的情况下难以添加格式不同的提供方。 ### 将搜索和 fetch 拆为两个 seam(`dsh-search`、`dsh-fetch`) @@ -322,9 +323,9 @@ fetch 提供方的资源控制: ## 后果 -**搜索 schema 刻意精简。** Exa 和 Perplexity 都暴露了有用的提供方特有控制;只有当某个控制能以提供方无关的方式定义、且工具注册和提供方执行都能诚实遵守时,才会添加。 +**搜索 schema 刻意精简。** 各搜索 API 暴露了有用的提供方特有控制,例如 Exa 的 `livecrawl`、Perplexity 的搜索时效性和 DeepSeek Search API 的 `localization`;只有当某个控制能以提供方无关的方式定义、且工具注册和提供方执行都能诚实遵守时,才会添加。 -**Perplexity 引用可能稀疏。** 一条引用可能只有 URL。将 `title` 和 `snippet` 设为可选使 seam 保持诚实,但意味着 `tool-web` 需要渲染回退标签。 +**引用可能稀疏。** 一条 Perplexity 风格的引用可能只有 URL。将 `title` 和 `snippet` 设为可选使 seam 保持诚实,但意味着 `tool-web` 需要渲染回退标签。 **稳定的工具注册将配置错误推迟到执行时。** 当产品启用了 web 访问时,保持工具可见是正确的;但期望 web 搜索可用的产品应用应当明确暴露结构化的 `WEB_PROVIDER_CONFIGURED_MISSING`/`WEB_PROVIDER_CONFIGURED_UNAVAILABLE`/`WEB_PROVIDER_AMBIGUOUS` 失败,避免用户直到模型调用工具后才发现配置问题。 @@ -340,7 +341,7 @@ fetch 提供方的资源控制: - `pdf` `WebFetchBody` 类别:`http` 提供方将可文本提取的 PDF 解码(尽力而为、有上限、`truncated`)为 `{ kind: 'pdf'; content; pageCount? }` 分支,`tool-web` 渲染它。这是 fetch 而非 `web_extract`——PDF 获取是具体的 HTTP 200 加确定性的本地解码,不是提供方侧对非 HTTP 资源的提取。添加它是跨 `dsh-web`(声明分支)、提供方(解码 + 将「二进制拒绝」收窄为「拒绝二进制,但可文本提取的 PDF 除外」;需要 OCR 的扫描/图片 PDF 不在范围内)和 `tool-web`(渲染)的协调变更。封闭的 `WebFetchBody` 联合类型使消费方在新分支被处理之前编译失败。 - 提供方支撑的提取作为独立的 `web_extract` 能力,而非静默扩展 `web_fetch`。 -- `query` 和 `maxResults` 之外的提供方无关搜索控制,待 Exa 和 Perplexity 都能诚实遵守时再添加。 +- `query` 和 `maxResults` 之外的提供方无关搜索控制,待已注册的提供方都能诚实遵守时再添加。 ## 开放问题 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index ceccd5ead5..a41dd3bd98 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml @@ -21,8 +21,8 @@ en: 0a4240d88cca240a zh: 8f5f1527caf571cd /agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/decision/the-loading-flow-end-to-end: - en: 15fb56befd328339 - zh: 0ffb10ada7e52805 + en: dd05aa1ee608c414 + zh: 2863ced0086e3122 /agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/decision/live-graph-reconciliation: en: 37e2fa1fe5cb2b95 zh: 80e513db87709cb4 @@ -33,8 +33,8 @@ en: 94ca0d09e6fd1e3a zh: d5949a2bdec795ba /agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/consequences: - en: 16e1f2467c559315 - zh: a714ec6ff2722cf1 + en: 624efdd136029771 + zh: 7d60207147e631fe /agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/alternatives-considered: en: b8f036f50cff7cd8 zh: 6d623c4a1f4314b1 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index 28d718586b..15c0a39295 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -67,8 +67,8 @@ Why is the roster yml rows and not a scan? Because which plugins compose into a 1. The kernel mounts the vendored Loader and injects the module system as `internal` before any entry exists. Ordering matters: `tree.import`'s bare-import fallback must never run in a browser. 2. It creates every graph row uniformly. Importing the modules row returns the memoized bootstrap exports, whose `apply()` reads that tree's `Loader.internal` and provides the same instance as `ctx.modules`; rows that require that service remain PENDING until then, so the modules row needs no special creation position. Render assembly is an ordinary host-graph row provided by `dsh-client-ui-renderer`; the kernel appends no assembly pseudo-entry. 3. Graph order governs synchronous factory availability; Cordis activation remains independent and proceeds through service waiting. -4. `settled` = every entry created + `loader.await()` quiescent + an all-ACTIVE sweep. The sweep lists each import-failed, FAILED, or PENDING fiber with its missing services. It exists because cordis inject waits have no timeout — the sweep is the fail-loud floor. -5. The framework-free loading page projects real fiber states via `internal/status`. After the sweep, the kernel calls `ctx.uiRenderer.mount(container)` and replaces the page with the real UI in one pass. +4. `settled` = every entry created + `loader.await()` quiescent + the boot audit. The audit lists each import-failed, FAILED, or PENDING fiber with its activation error or missing services. It exists because cordis inject waits have no timeout. An inactive entry on the browser required list rejects startup; other inactive entries produce one console warning ([startup policy](2026-09-09-consumer-owned-startup-strictness.md)). +5. The framework-free loading page projects real fiber states via `internal/status`. After the audit accepts the tree, the kernel calls `ctx.uiRenderer.mount(container)` and replaces the page with the real UI in one pass. ### Live graph reconciliation @@ -104,7 +104,7 @@ The current package inventory and build forms live in the [client shell layering One governance implementation runs on both sides of the wire; the browser-specific layer is one module system plus one reload plugin. Dynamic packages have one artifact form, so the purity check covers them all. Cordis dependencies, module requests, and the boot tier live with their owners — the manifests — while the composing app holds only the roster. Host graph validation and recursive request arrival keep synchronous factory dependencies explicit. Browser-native script loading preserves the standard mapping among plugin network resources, generated bundles, and TypeScript/TSX sources, while the module system keeps two replaceable carrier hooks: `loadBundle` for bundle bytes and `resolveAsset` for package-asset references. -Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows guide factory arrival but service availability remains the activation authority, so a mismatch appears at the settled sweep; the static UI libraries keep direct value exports; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch. The Host retains bundle snapshots and lazy response plans for the current graph and one prior startup generation. Script and map bodies are cached after their first `GET`, so memory grows with bundle snapshots and requested response bodies. Each materialized response stays fixed at its URL; a prior-generation map first requested after a rebuild reads the current map file. +Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows guide factory arrival but service availability remains the activation authority, so a mismatch appears at the boot audit; the static UI libraries keep direct value exports; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch. The Host retains bundle snapshots and lazy response plans for the current graph and one prior startup generation. Script and map bodies are cached after their first `GET`, so memory grows with bundle snapshots and requested response bodies. Each materialized response stays fixed at its URL; a prior-generation map first requested after a rebuild reads the current map file. Roster: it lives in the web bundle's config tree (`packages/bundle/web-app/cordis.patch.yml`); `mountWebPlugins` and the `CLIENT_PACKAGES` constant are gone, and recomposing a deployment means swapping the yml/overlay. The graph composer lives in the `dsh-client-modules` node half, while the parser-preloaded client face bootstraps the browser module table. The webserver remains a plain route-registration plugin; `/api/*` binding, browser authentication, RPC envelopes, and exact Fetch routes belong to the Connection node half, while Remote dispatch belongs to API Gateway and the dev bundle watch plus SSE channel belongs to the hmr node half. diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md index 1bf50a15ba..95333823eb 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md @@ -67,8 +67,8 @@ Host 会快照每个已构建插件入口 bundle,并把每个调度阶段的 1. 内核挂载 vendored Loader,在任何 entry 存在之前就把模块系统注入为 `internal`。顺序有讲究:`tree.import` 的裸 import 兜底分支在浏览器里绝不能跑到。 2. 它统一创建每个 graph row。Import modules row 会返回记忆化的 bootstrap exports,其 `apply()` 读取该树的 `Loader.internal`,把同一个实例提供为 `ctx.modules`;需要该 service 的 row 会保持 PENDING 直至此时,因此 modules row 无需特殊创建位置。渲染组装是由 `dsh-client-ui-renderer` 提供的普通 host graph row;内核不追加组装伪 entry。 3. Graph 顺序治理同步 factory 可用性;Cordis 激活与之独立,仍经服务等待推进。 -4. `settled` = 每个 entry 已创建 + `loader.await()` 完全停稳 + 一次全 ACTIVE 扫描。扫描列出每个 import 失败、FAILED 或 PENDING 的 fiber 及其缺失的服务。它存在的理由:cordis 的 inject 等待没有超时——这次扫描就是大声失败的兜底线。 -5. 不依赖框架的 loading 页经 `internal/status` 投影真实 fiber 状态。检查完成后,内核调用 `ctx.uiRenderer.mount(container)`,一次切换到真实 UI。 +4. `settled` = 每个 entry 已创建 + `loader.await()` 完全停稳 + 启动审计。审计列出每个 import 失败、FAILED 或 PENDING 的 fiber 及其激活错误或缺失的服务。它存在的理由:cordis 的 inject 等待没有超时。浏览器必需名单中的 entry 未激活会拒绝启动;其他未激活 entry 输出一条控制台警告([启动策略](2026-09-09-consumer-owned-startup-strictness.zh.md))。 +5. 不依赖框架的 loading 页经 `internal/status` 投影真实 fiber 状态。审计接受插件树后,内核调用 `ctx.uiRenderer.mount(container)`,一次切换到真实 UI。 ### 动态图对账 @@ -104,7 +104,7 @@ Bootstrap 替换会在失效或卸载前被拒绝:模块系统保留其初始 Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。动态包只有一种产物形态,因此纯度检查覆盖全部动态包。Cordis 依赖、模块请求与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册。Host graph 校验与递归请求到达使同步 factory 依赖保持显式。浏览器原生 script 装载保留插件网络资源、生成 bundle 与 TypeScript/TSX 源码之间的标准映射,模块系统只保留两个可替换的载体钩子:负责 bundle 字节的 `loadBundle` 和负责包资源引用的 `resolveAsset`。 -接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在 settled 扫描时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 为当前图及上一代启动图保留 bundle 快照与惰性响应计划。脚本和 map body 在首次 `GET` 后缓存,因此内存随 bundle 快照和已请求的响应 body 增长。每份已物化响应在其 URL 下保持固定;若上一代 map 在重建后才首次被请求,则会读取当前 map 文件。 +接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在启动审计时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 为当前图及上一代启动图保留 bundle 快照与惰性响应计划。脚本和 map body 在首次 `GET` 后缓存,因此内存随 bundle 快照和已请求的响应 body 增长。每份已物化响应在其 URL 下保持固定;若上一代 map 在重建后才首次被请求,则会读取当前 map 文件。 名册位于 web 组合包的配置树(`packages/bundle/web-app/cordis.patch.yml`);`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于替换 yml/overlay。Graph 组合器位于 `dsh-client-modules` node 半,由 parser 预载的 Client face 则自举浏览器模块表。Webserver 继续作为朴素路由注册插件;`/api/*` 绑定、浏览器认证、RPC envelope 与精确 Fetch 路由属于 Connection node 半,Remote 分发属于 API Gateway,开发期 bundle 监视与 SSE 通道属于 HMR node 半。 diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml index bdd096c69d..ef8926e7d2 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml @@ -12,8 +12,8 @@ en: aea80ece13f2905b zh: a880bc1a7839cf12 /agent-note-one-ordering-for-configuration-sources-and-what-a-discovered-file-may-not-decide/consequences: - en: 640b7ee4e8c57ed0 - zh: 1c17b3fba16861a8 + en: 41ebe22077560090 + zh: da3475e4c196c3f9 /agent-note-one-ordering-for-configuration-sources-and-what-a-discovered-file-may-not-decide/alternatives-considered: en: ac43499cfe7b8495 zh: e5ab599e7d3ed753 diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md index ee4ea2a5b9..aeb9a25081 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md @@ -58,7 +58,6 @@ The line is that these take effect with no user action, before any turn, outside - A `.env` holding `DSH_*`, `PATH`, `BROWSER`, or — in the invoking directory — a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break. - Composition is no longer overridable by a stale shell endpoint. It is still overridable by a user's stored `settings.yaml`, which is the settings seam's layering and not something this note changes; the product CLI offers no flag above it, so a deployment that must win against stored settings owns its own bin or loader tree. - Not solved: the layers are still materialized into `process.env`, so ordinary project variables continue to reach child processes under the subprocess scrub. Bootstrap variables cannot come from a file at all; the environment package records the remaining subprocess reach as a limitation. -- Exa and Perplexity still capture their key at load time rather than through the credential seam. They no longer read raw `process.env` — they resolve through the trusted layers — but converting them to per-request credential resolution is separate work. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md index 024c93d3d1..36c3dda624 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md @@ -59,7 +59,6 @@ inherited process environment (read-only, wins) - 含 `DSH_*`、`PATH`、`BROWSER` 或(在调用目录中)proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。 - composition 不再会被陈旧的 shell endpoint 覆盖。但它仍然会被用户已存的 `settings.yaml` 覆盖,这是 settings seam 的分层方式,本 Note 不改变它;产品 CLI 没有高于它的标志,因此需要压过已存 settings 的部署方要自带 bin 或 loader 配置树。 - 未解决的:各层仍然会被物化进 `process.env`,因此普通项目变量继续按子进程清洗规则抵达子进程。bootstrap 变量完全不能来自文件;环境包将其余变量仍可抵达子进程这一点记录为一项限制。 -- Exa 与 Perplexity 仍在加载时捕获密钥,而不是经凭据 seam。它们不再读裸 `process.env`——改为经受信层解析——但把它们改造成按请求解析凭据是另一件事。 ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml index f7082b3940..954230cc2a 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml @@ -9,8 +9,8 @@ en: 4548e7fbdbee5b5a zh: 0078762ea92fa180 /agent-note-consumer-owned-startup-strictness/decision: - en: 1f11e04162e19ad1 - zh: d102e300eba9a2ea + en: 62e93742af5bed66 + zh: 3944d685b6503a2c /agent-note-consumer-owned-startup-strictness/alternatives-considered: en: 1b67e062213184cd zh: 91cc8a015de2bfe4 @@ -18,5 +18,5 @@ en: 283dec1b336c540d zh: 2ed358079072cfff /agent-note-consumer-owned-startup-strictness/testing: - en: c38221069ce98fd3 - zh: 9a2f91f539eba4d4 + en: cee14c22297e5471 + zh: a68748cc36f4a574 diff --git a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md index cf17823e72..be4e748975 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md +++ b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md @@ -20,6 +20,8 @@ The audit runs only during initial application boot. Later config HMR remains be This policy governs [Web host boot](../../../../packages/boot/app-boot/README.md), including its [client plugin roster](2026-07-23-client-plugin-loading-model.md). [Per-session presets](2026-09-18-declarative-agent-presets.md) own a separate strict subtree audit. +The browser boot audit applies the same policy to the client plugin tree with its own global list in [`boot-client.ts`](../../../../packages/client/web/src/boot-client.ts): the module system, Session control, the renderer, the application frame, the Session view, and its sidebar. Browser entries are named by package, so the list holds package names; a name absent from the boot graph has no effect, and neither list is derived from the other. The list is the minimum fatal set: an entry that a listed entry waits on blocks startup too. Feature plugins, including credential masking, stay optional. A failed entry reports its activation error. Optional failures appear in one console warning, and in Settings → Plugins → Plugin list only after a later graph synchronization reconciles them. Renaming a listed package requires updating the list and its tests. + ## Alternatives considered - **Add transactional and best-effort modes to vendored Loader.** Rejected because strictness belongs to the application or resource owner, while a Loader group contains unrelated plugins. A mode would also expand the vendor patch and leave callers to select a policy at every group. @@ -36,6 +38,8 @@ The compact terminal report keeps the failing plugins visible; a separate file r App-boot unit tests cover absent and disabled required ids, optional import failure, config evaluation failure, synchronous and asynchronous `apply()` failure, pending dependencies, and required failure teardown. Unit expectations pin diagnostic grouping, preservation of original error objects and import logs, exporter cleanup, complete diagnostic values, private file creation, concurrent report names, and failed-write fallback. The built Web-profile acceptance asserts a single port-conflict stack without Node wrapper output, verifies the saved diagnostic file and its stderr fallback, serves the full UI with optional failures and exits nonzero without readiness when the required HTTP port is occupied or `modules` or `connection` cannot activate. +Browser audit unit tests (`boot-client.client.spec.ts`, `boot.client.spec.ts`) cover required and optional import, creation, activation, and pending failures, carrier presentation, and required names absent from the boot graph. Test carriers are stricter than the product: `TestClient` requires every roster entry to activate unless a case passes `allowInactiveEntries`, and the browser e2e lanes treat a `web boot: warning` as a failure with no opt-out. + The [Web process matrix](../../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) independently exercises optional and required failures at startup and after native patch-file edits. Authenticated HTTP requests and plugin lifecycle files distinguish a usable application from a surviving process. These keyless process checks complement the [controlled-delivery unit tests](../../../../packages/boot/app-boot/tests/user-patches.spec.ts): unit tests isolate reconciliation failures, while the process tests also require the shipped launcher, native watcher, and bounded shutdown to work together. The matrix enables Chokidar's `awaitWriteFinish` to acknowledge stable file contents before each reload; otherwise its short change-event suppression window can discard the next test edit. Native events remain required, and assertions wait for observed activation or failure rather than a fixed settling sleep. This is explicit test configuration, not evidence for the default watcher timing. diff --git a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md index c8bda5cb20..4287b4aad2 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md @@ -20,6 +20,8 @@ Required id 为 `agent-loop`、`webserver`、`modules`、`connection`、`headles 该策略适用于 [Web host 启动](../../../../packages/boot/app-boot/README.zh.md),包括其 [client 插件名册](2026-07-23-client-plugin-loading-model.zh.md)。[按会话的 preset](2026-09-18-declarative-agent-presets.zh.md)持有独立的严格子树审计。 +浏览器启动审计以同一策略审计客户端插件树,使用 [`boot-client.ts`](../../../../packages/client/web/src/boot-client.ts) 中独立的全局名单:模块系统、Session 控制、渲染器、应用框架、Session 视图及其侧栏。浏览器 entry 以包名命名,因此名单写包名;启动图中不存在的名字不产生影响,两份名单互不推导。名单是最小致命集合:已列出 entry 所等待的 entry 同样会阻止启动。功能插件(包括凭据遮挡)保持可选。失败 entry 报告其激活错误。可选失败出现在一条控制台警告中;只有后续图同步调和之后,才会出现在「设置 → 插件 → 插件列表」。重命名名单中的包需要同步更新名单及其测试。 + ## 考虑过的替代方案 - **给 vendored Loader 增加 transactional 与 best-effort mode。** 拒绝,因为严格语义属于应用或资源 owner,而一个 Loader group 包含互不相关的 plugin。Mode 还会扩大 vendor patch,并要求 caller 为每个 group 选择 policy。 @@ -36,6 +38,8 @@ Required id 为 `agent-loop`、`webserver`、`modules`、`connection`、`headles App-boot 单元测试覆盖缺失和禁用的 required id、optional import failure、config evaluation failure、同步和异步 `apply()` failure、pending dependency,以及 required failure teardown。单元预期输出固定诊断分组、原始错误对象与导入日志的保留、exporter 清理、完整诊断值、私有文件创建、并发报告命名以及写入失败回退行为。构建后的 Web-profile acceptance 断言端口冲突堆栈只输出一次且不包含 Node 包装输出,验证已保存的诊断文件及其 stderr 回退,并会在 optional failure 存在时继续提供完整 UI,并在 required HTTP port 被占用或 `modules`、`connection` 无法激活时以非零码退出,且不报告就绪。 +浏览器审计单元测试(`boot-client.client.spec.ts`、`boot.client.spec.ts`)覆盖必需与可选 entry 的导入、创建、激活与 pending 失败、carrier 呈现,以及启动图中不存在的必需名称。测试载体比产品更严格:除非用例传入 `allowInactiveEntries`,`TestClient` 要求每个 roster entry 都激活;浏览器 e2e 把 `web boot: warning` 视为失败,且不提供放宽。 + [Web 进程矩阵](../../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)分别验证启动时和原生补丁文件修改后的 optional 与 required 失败。经过认证的 HTTP 请求和插件生命周期文件区分可用应用与仅存活的进程。这些无需密钥的进程检查与[受控事件投递单元测试](../../../../packages/boot/app-boot/tests/user-patches.spec.ts)互补:单元测试隔离配置协调失败,进程测试还要求随附启动器、原生监听器和有界关闭流程协同工作。 矩阵启用 Chokidar 的 `awaitWriteFinish`,在每次重载前确认文件内容已稳定;否则它的短暂 change 事件抑制窗口可能丢弃下一次测试编辑。测试仍然依赖原生事件,并等待观察到激活或失败,而不是固定时长的休眠。这是显式测试配置,不能证明默认监听器的时序行为。 diff --git a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml index d7f60ead90..a34df7ba86 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml @@ -15,8 +15,8 @@ en: 3aef37bb4f8a8e53 zh: a7b477324d486ab3 /agent-note-sandboxed-node-execution-for-ptc/decision/control-and-lifetime: - en: 2c7a694b04951772 - zh: 133b95b0477b127f + en: a8897bae00f5f674 + zh: 2c959353c5a885a4 /agent-note-sandboxed-node-execution-for-ptc/decision/resource-limits: en: 3f1d8a30f6d48243 zh: faa7c01d7c959893 diff --git a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md index 1259a617ad..91cbbf8159 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md +++ b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md @@ -28,7 +28,7 @@ The [experimental Python provider](../../../../packages/experimental/ptc-runtime The subprocess owner supplies a dedicated inherited binary control channel, separate from program stdout/stderr and launcher lifecycle IPC. The host bounds frames, queued writes, pending calls and outstanding argument bytes, then validates call identity and the binding allowlist before dispatch. Model code can write to that channel, so its bytes remain untrusted. -Program completion, timeout, cancellation and protocol failure all close execution through the managed process owner. Result selection stops the execution timer; cleanup then waits for the direct outcome and managed-range quiescence. The PTC bridge separately aborts and drains nested tool dispatches before its outer tool result settles. A denial or transport failure never automatically replays a program whose effects may already have occurred. +For temporary executions, program completion, timeout, cancellation and protocol failure all close execution through the managed process owner. Result selection stops the execution timer; cleanup then waits for the direct outcome and managed-range quiescence. The PTC bridge separately aborts and drains nested tool dispatches before its outer tool result settles. A denial or transport failure never automatically replays a program whose effects may already have occurred. ### Resource limits diff --git a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md index 0074fdf578..849737b6dc 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md @@ -28,7 +28,7 @@ Host 在子进程启动时保留 `ELECTRON_RUN_AS_NODE`;bootstrap 在求值前 子进程所有者提供专用的继承式二进制控制通道,与程序 stdout/stderr 及 launcher 生命周期 IPC 分开。Host 限制帧、排队写入、待处理调用和未完成参数字节,然后在分派前验证调用身份与绑定允许列表。模型代码可以写入该通道,因此其中字节仍不可信。 -程序完成、超时、取消与协议失败都通过受管进程所有者关闭执行。选择结果后停止执行计时器;清理随后等待直接结果与受管范围停稳。PTC 桥接在外层工具结果结算前,另行取消并排空嵌套工具分派。拒绝或传输失败绝不自动重放可能已经产生副作用的程序。 +对于临时执行,程序完成、超时、取消与协议失败都通过受管进程所有者关闭执行。选择结果后停止执行计时器;清理随后等待直接结果与受管范围停稳。PTC 桥接在外层工具结果结算前,另行取消并排空嵌套工具分派。拒绝或传输失败绝不自动重放可能已经产生副作用的程序。 ### 资源限制 diff --git a/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.i18n.yaml index 0fb431067d..10be2a7053 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.i18n.yaml @@ -9,8 +9,8 @@ en: 2abacff687661d28 zh: 2d7ba3ae27dba888 /agent-note-keep-live-configuration-in-the-owning-profile/decision: - en: 66c3884425cfb90a - zh: 0fe6a1f1a576470b + en: f9b1dd3d158457a9 + zh: 06ca76fcbbc76cda /agent-note-keep-live-configuration-in-the-owning-profile/alternatives-considered: en: 4089464ad1fb60bd zh: 3168e9e4a205145d diff --git a/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.md b/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.md index 65cc8f60b1..aabe6f5c43 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.md +++ b/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.md @@ -14,7 +14,7 @@ A plugin declares every configurable value in its Cordis Config. Fields that can Form writes target the active profile. Home patches and command-line overlays remain higher-priority deployment inputs; an edit they would shadow fails before persistence. Two plugin instances are addressed by their distinct profile entry ids. Nested Includes retain independent ownership and are not writable through the profile form. -Cordis config patches replace complete entry configs. An edit preserves ordinary fields and unedited secrets, including raw configuration expressions. A field reset restores the currently inherited value; resetting the complete entry removes its config overrides so later bundle changes are inherited. Other edits store the entry's complete config in the profile row: the ordinary fields as composed at write time plus every volatile field. Later bundle changes to those fields do not reach that profile until the entry's config override is removed. In the shipped bundles this pins `permission.presets` after a default-preset change, `agent-presets` after a preset choice, `agent-loop.agents` after a parallelism edit, and `web-search-deepseek.apiKeyEnv` after a Web Search edit, and the client marks every volatile field of such a row as overridden, not only the edited one. Narrowing a write to the edited fields needs merge semantics for patch `config`, which Include does not provide. +Cordis config patches replace complete entry configs. An edit preserves ordinary fields and unedited secrets, including raw configuration expressions. A field reset restores the currently inherited value; resetting the complete entry removes its config overrides so later bundle changes are inherited. Other edits store the entry's complete config in the profile row: the ordinary fields as composed at write time plus every volatile field. Later bundle changes to those fields do not reach that profile until the entry's config override is removed. In the shipped bundles this pins `permission.presets` after a default-preset change, `agent-presets` after a preset choice, `agent-loop.agents` after a parallelism edit, and `web-search-deepseek.apiKeyEnv` after a legacy Messages search edit on the Web search and fetch page, and the client marks every volatile field of such a row as overridden, not only the edited one. Narrowing a write to the edited fields needs merge semantics for patch `config`, which Include does not provide. The removed `$DSH_HOME/settings.yaml` is imported once into the active profile when the Loader has settled every entry after Settings starts: section ids are entry ids, with `ui-developer-tools` → `ui-settings`, `ui-onboarding` → `ui-settings-general`, and `shell` → the platform's shell executor entry. The file is renamed to `settings.yaml.imported` before the first write, so the import never repeats; a rejected section is logged and stays in the renamed file. diff --git a/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.zh.md b/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.zh.md index 9ce0952aab..f808941c35 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.zh.md @@ -14,7 +14,7 @@ Status: implemented 表单写入当前 profile。home patch 和命令行 overlay 仍是优先级更高的部署输入;如果它们会遮蔽某次修改,编辑器会在持久化前拒绝写入。两个插件实例通过各自不同的 profile entry id 寻址。嵌套 Include 保留独立所有权,不能通过该 profile 表单写入。 -Cordis config patch 替换完整 entry config。编辑会保留普通字段、未编辑的秘密值和原始配置表达式。字段重置恢复当前继承值;整个 entry 重置后会移除其 config override,从而继承后续 bundle 变化。其他编辑会把该 entry 的完整 config 存入 profile 行:写入时组合出的普通字段加上全部 volatile 字段。此后 bundle 对这些字段的修改在该 entry 的 config override 被移除之前不会到达该 profile。在已发布的 bundle 中,修改默认权限预设会钉住 `permission.presets`,选择预设会钉住 `agent-presets`,编辑并行度会钉住 `agent-loop.agents`,编辑网页搜索会钉住 `web-search-deepseek.apiKeyEnv`;客户端会把这类行的每个 volatile 字段都标为已覆盖,而不只是被编辑的那个。要把写入收窄到被编辑字段需要 patch `config` 的合并语义,Include 不提供。 +Cordis config patch 替换完整 entry config。编辑会保留普通字段、未编辑的秘密值和原始配置表达式。字段重置恢复当前继承值;整个 entry 重置后会移除其 config override,从而继承后续 bundle 变化。其他编辑会把该 entry 的完整 config 存入 profile 行:写入时组合出的普通字段加上全部 volatile 字段。此后 bundle 对这些字段的修改在该 entry 的 config override 被移除之前不会到达该 profile。在已发布的 bundle 中,修改默认权限预设会钉住 `permission.presets`,选择预设会钉住 `agent-presets`,编辑并行度会钉住 `agent-loop.agents`,在「网页搜索与获取」页编辑旧版 Messages 搜索会钉住 `web-search-deepseek.apiKeyEnv`;客户端会把这类行的每个 volatile 字段都标为已覆盖,而不只是被编辑的那个。要把写入收窄到被编辑字段需要 patch `config` 的合并语义,Include 不提供。 被移除的 `$DSH_HOME/settings.yaml` 会在 Settings 启动后、Loader 完成所有条目加载时一次性导入当前 profile:section id 即 entry id,其中 `ui-developer-tools` → `ui-settings`、`ui-onboarding` → `ui-settings-general`、`shell` → 当前平台的 shell 执行器条目。文件在第一次写入前改名为 `settings.yaml.imported`,因此导入不会重复;被拒绝的 section 会记录日志并保留在改名后的文件中。 diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml index c0587791fd..e4acbc9423 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml @@ -9,11 +9,11 @@ en: 97fe58a13135b4a1 zh: 7490373a8d941da5 /agent-note-active-tool-headers-and-stateful-request-projection/decision: - en: 0c40a9283650c643 - zh: 91a18d752d09a9ce + en: 24138152d511bf12 + zh: 95516d7566b6db7c /agent-note-active-tool-headers-and-stateful-request-projection/alternatives-considered: - en: b78785f2a603eba8 - zh: b8c268228da7e2e7 + en: 311db1f781e386e7 + zh: 7e3312544154a158 /agent-note-active-tool-headers-and-stateful-request-projection/consequences: - en: 60d7bf28fa5a8fd1 - zh: a17b222e7aca01e2 + en: 8a64dba42c01f647 + zh: 9e7ba7a5bd855031 diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md index a40b85601c..8a8976aacb 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md @@ -14,9 +14,9 @@ Plugin and MCP tools change during conversations. Providers differ in whether th `Session.toolHistory()` caches a capability-independent fold of committed headers and developer messages. First access restores inherited events; later reads consume only unseen events. Immutable snapshots carry initial declarations and ordered update identities with additions resolved through their historical headers. Explicit series starts and changed retained definitions rebuild declarations. Restoring a retained name with an unchanged definition continues the series: the projection keeps the declaration unchanged and lets the recorded addition re-offer the tool after its earlier removal, so a removal never disables a declared tool and a loading mode never rewrites an earlier declaration. -Loop and compaction requests carry this snapshot as `GenerateOptions.toolHistory`. At adapter dispatch, `projectToolUpdates` uses the prepared route's `toolUpdate`. Unsupported routes receive active definitions without `deferLoading` or developer messages. Supported routes defer historical additions; `in-history` retains removed definitions and removal blocks, while `addition-only` omits both. Only current-series update identities survive. Missing history or a request prefix omitting recorded updates uses current declarations without developer updates. Explicit deferred loading remains available on capable routes. +Loop and compaction requests carry this snapshot as `GenerateOptions.toolHistory`. At adapter dispatch, `projectToolUpdates` uses the prepared route's `toolUpdate`. Unsupported routes receive active definitions without `deferLoading` or developer messages. Supported routes defer historical additions; `in-history` retains removed definitions and removal blocks, while `addition-only` omits both. On addition-only routes, removing any tool rebuilds the route's baseline after that update: the request declarations change even when the removed tool was activated later, so retaining superseded activation blocks cannot preserve the full conversation prefix. Adding tools when no declarations exist also rebuilds the baseline. Deferred declarations count as existing tools. The first native tool changes the empty declaration prefix; deferring it provides no cache advantage over declaring it immediately. Previously activated tools become immediate; explicitly deferred declarations with no activation remain deferred. Subsequent additions remain deferred until their recorded activation. Superseded tool blocks are omitted without discarding other content in those messages. The Session fold and durable events remain capability-independent. Missing history or a request prefix omitting recorded updates uses current declarations without developer updates. -DeepSeek translates projected updates into system-role `tool_addition` and `tool_removal` blocks and `defer_loading` declarations, sending the tool-changes beta header when update blocks occur. The default `deepseek-flash` entry declares `addition-only`; pi-ai declares no mode. Chat and Trajectory display developer messages as context nodes, using localized tool-change notices: single changes show the tool name; multiple changes show counts and expandable name lists. +DeepSeek translates projected updates into system-role `tool_addition` and `tool_removal` blocks and `defer_loading` declarations, sending the tool-changes beta header when update blocks occur. The default `deepseek-flash` entry declares `addition-only`; pi-ai selects `in-history` for compatible Anthropic routes and `addition-only` for compatible Chat Completions and Responses routes. Chat and Trajectory display developer messages as context nodes, using localized tool-change notices: single changes show the tool name; multiple changes show counts and expandable name lists. ## Alternatives considered @@ -26,6 +26,8 @@ DeepSeek translates projected updates into system-role `tool_addition` and `tool **Scan the complete log on every request.** The cached fold consumes new events and supplies stable snapshots to ordinary and auxiliary calls without adding arbitrary synchronous history readers. +**Rebuild the baseline on every removal.** Deferred tools also have request declarations; deleting a tool changes those declarations regardless of its activation position. Addition-only routes cannot express that deletion in history. The rebuild therefore also covers removal-only updates and removals of later-activated tools. A past reset cannot force every future addition to become immediate: each reset establishes a new baseline from which deferred additions resume. + **Reject same-name schema changes during tool registration.** This restriction is not enforced. Immediate failure for a plugin registering an incompatible tool requires comparing the new registration with historical definitions of tools called on each affected current surface, while excluding calls removed by compaction. That registration-time historical and scope-aware check is deferred because of its implementation complexity. Same-name schema changes remain allowed; they rebuild declarations and invalidate prefix reuse even on `in-history` routes. ## Consequences @@ -34,4 +36,6 @@ Tool lists and logical update events are capability-independent; only outgoing d The authored [headless prompt/tool scenario](../../../../snapshots/session/dynamic-tool-prompt-updates/snapshot.yml) pins an appended prompt beside a deferred tool addition and verifies that the first request's messages stay unchanged. It shares the stable-prompt scenario's tool lifecycle and schema expectations. +The [DeepSeek replacement scenario](../../../../snapshots/session/deepseek-deferred-tools/snapshot.yml) checks the first additions to an empty set, a rebuilt baseline, a subsequent deferred addition, and removal of that later-activated tool through Messages serialization. Explicit deferred-only requests remain unchanged under the [DeepSeek failure policy](../simplification/2026-11-02-deepseek-deferred-tool-failures.md). These checks establish request construction, not measured cache savings from rebuilding. + The [real DeepSeek SDK test](../../../../apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts) measures the first request after a native tool addition with both unchanged and simultaneously updated instructions. Unique user history prevents a shared system-prompt cache hit from satisfying the preceding-input assertion. Removal checks tool availability without requiring cache retention; each case disables retries. diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md index 372dcb27af..e9f48c8a02 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md @@ -14,9 +14,9 @@ Status: implemented `Session.toolHistory()` 缓存对已提交请求头和 developer 消息的归约,不依赖模型能力。首次访问恢复继承事件;后续读取仅处理新增事件。不可变快照携带初始声明和有序更新标识,添加定义通过历史请求头解析。显式新序列和保留定义变化会重建声明。以未变化的定义恢复保留名称时序列继续:投影保持声明不变,由记录的添加在先前移除之后重新提供该工具,因此移除不会禁用已声明的工具,加载模式也不会改写先前声明。 -循环和压缩请求通过 `GenerateOptions.toolHistory` 携带此快照。适配器分发时,`projectToolUpdates` 使用已准备路由的 `toolUpdate`。不支持的路由接收有效定义,不携带 `deferLoading` 或 developer 消息。支持的路由延迟历史添加;`in-history` 保留已移除定义和移除块,`addition-only` 则省略两者。仅保留当前序列的更新标识。缺少历史或请求前缀遗漏已记录更新时,使用当前声明且不发送 developer 更新。显式延迟加载在支持的路由上仍然可用。 +循环和压缩请求通过 `GenerateOptions.toolHistory` 携带此快照。适配器分发时,`projectToolUpdates` 使用已准备路由的 `toolUpdate`。不支持的路由接收有效定义,不携带 `deferLoading` 或 developer 消息。支持的路由延迟历史添加;`in-history` 保留已移除定义和移除块,`addition-only` 则省略两者。在 addition-only 路由移除任何工具时,使用该次更新后的状态重建路由基线:即使被移除工具是后来激活的,请求声明也已发生变化,保留已被取代的激活块无法保留完整对话前缀。不存在任何声明时添加工具,也会重建基线。延迟声明也计入已有工具。首个原生工具会改变空声明前缀;将其延迟加载并不比直接声明具有缓存优势。此前已激活工具变为立即加载;没有激活记录的显式延迟声明仍保持延迟。后续添加在记录的激活位置前保持延迟加载。已被取代的工具块会被省略,但不会丢弃这些消息中的其他内容。Session 归约和持久事件仍不依赖模型能力。缺少历史或请求前缀遗漏已记录更新时,使用当前声明且不发送 developer 更新。 -DeepSeek 把投影后的更新转换为 system 角色的 `tool_addition` 和 `tool_removal` 块以及 `defer_loading` 声明,在存在更新块时发送工具变更 beta 请求头。默认 `deepseek-flash` 条目声明 `addition-only`;pi-ai 不声明模式。Chat 和 Trajectory 将 developer 消息显示为上下文节点,工具变更使用本地化通知:单项变更显示工具名,多项变更显示数量及可展开的名称列表。 +DeepSeek 把投影后的更新转换为 system 角色的 `tool_addition` 和 `tool_removal` 块以及 `defer_loading` 声明,在存在更新块时发送工具变更 beta 请求头。默认 `deepseek-flash` 条目声明 `addition-only`;pi-ai 为兼容的 Anthropic 路由选择 `in-history`,为兼容的 Chat Completions 和 Responses 路由选择 `addition-only`。Chat 和 Trajectory 将 developer 消息显示为上下文节点,工具变更使用本地化通知:单项变更显示工具名,多项变更显示数量及可展开的名称列表。 ## Alternatives considered @@ -26,6 +26,8 @@ DeepSeek 把投影后的更新转换为 system 角色的 `tool_addition` 和 `to **每次请求扫描完整日志。** 缓存归约处理新事件,为普通和辅助调用提供稳定快照,不增加任意同步历史读取接口。 +**每次移除都重建基线。** 延迟工具也具有请求声明;删除工具会改变这些声明,与其激活位置无关。addition-only 路由无法通过历史表达删除。因此,仅移除的更新和移除后来激活的工具也会重建基线。一次历史重置不能让所有后续添加都变为立即加载:每次重置确立新基线,后续添加继续采用延迟加载。 + **在工具注册时拒绝同名 schema 变化。** 当前不执行此限制。要让注册不兼容工具的插件立即收到异常,需要将新注册与各受影响当前 surface 中已调用工具的历史定义比较,同时排除已被压缩移除的调用。由于实现复杂,注册时结合历史和作用域的检查留待后续实现。同名 schema 变化仍被允许;它们会重建声明,即使在 `in-history` 路由上也会使前缀复用失效。 ## Consequences @@ -34,4 +36,6 @@ DeepSeek 把投影后的更新转换为 system 角色的 `tool_addition` 和 `to 手写的[无界面提示词/工具场景](../../../../snapshots/session/dynamic-tool-prompt-updates/snapshot.yml)钉住追加提示词与延迟工具添加同时发生的行为,并验证首次请求的消息保持不变。它复用稳定提示词场景的工具生命周期和 schema 期望值。 +[DeepSeek 替换场景](../../../../snapshots/session/deepseek-deferred-tools/snapshot.yml)通过 Messages 序列化验证向空工具集首次添加工具、重建后的基线、后续延迟添加,以及移除该后来激活的工具。根据 [DeepSeek 失败策略](../simplification/2026-11-02-deepseek-deferred-tool-failures.zh.md),显式全延迟请求保持原样。这些检查验证请求构造,不代表已经测量重建带来的缓存收益。 + [真实 DeepSeek SDK 测试](../../../../apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts) 分别在指令不变和指令同时更新时测量原生工具新增后的首次请求。独有的用户历史确保仅命中共享系统提示词缓存无法满足前序输入断言。移除场景检查工具可用性,不要求保留缓存;每个用例均禁用重试。 diff --git a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.i18n.yaml index bbac97a7ad..09d97dbe74 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.i18n.yaml @@ -36,8 +36,8 @@ en: 21a74f9031f86a92 zh: c6abd0a5dfbf56a8 /agent-note-definition-owned-grouping-with-generic-node-group-assembly/verification: - en: b42409d029fc0fde - zh: 0f8ab7d206cab772 + en: ff829b9a2772df95 + zh: df0e6524d00034df /agent-note-definition-owned-grouping-with-generic-node-group-assembly/consequences: - en: 96f1d81b28534661 - zh: 8cb1c7a0f000c335 + en: 236ddec90acc6ca1 + zh: d4807f635e217766 diff --git a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md index 39a31ae902..7a41965408 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md +++ b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md @@ -159,7 +159,7 @@ Business behavior is verified through keyed-update regressions and recorded Web | Evidence | Required observation | |---|---| | Keyed notifications and React updates | Content growth affects the owning Node and affected Group; unchanged historical rows and unrelated Turns receive no extra notifications. Mode changes preserve keys and member parents. | -| Recorded Web replay | Current accessible titles, group disclosure, hidden ordinary Context, trigger notices, and footer placement match the committed expected output. | +| Recorded Web replay | Current accessible titles, group disclosure, Context rows, trigger notices, and footer placement match the committed expected output. | [Recorded Web scenarios](../../../../apps/web/tests/steering.e2e.ts) cover online steering, reconnect handoff, and grouped presentation through the shipped Web profile. These behavior checks do not establish a quantified latency or memory improvement. @@ -171,4 +171,4 @@ Business behavior is verified through keyed-update regressions and recorded Web - Stable mounting does not eliminate layout, paint, or retained-memory costs. No measured latency or optimal-performance claim is made. - Real group splits, merges, first-member changes, and pagination repairs can change identity. The mode-switch guarantee does not prohibit those legitimate changes. - Revealing hidden parts, selection/copy, interruption notices, group disclosure, and outer Turn interaction belong to Chat's business adaptation; generic reference tests alone do not establish those behaviors. -- Turn status/duration replaces the old tool/message-count title, so status, accessible names, and empty-process Turns require explicit presentation coverage. Hiding ordinary Context retains non-human waking notices and original Session/Trajectory inspection; footer placement depends on the real Turn end rather than group membership. +- Turn status/duration replaces the old tool/message-count title, so status, accessible names, and empty-process Turns require explicit presentation coverage. Non-human waking notices remain independent of Context rows, and Session/Trajectory inspection is unchanged; footer placement depends on the real Turn end rather than group membership. diff --git a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.zh.md b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.zh.md index b469ad12ca..288b8b5234 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.zh.md @@ -159,7 +159,7 @@ React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派 | 证据 | 必须观察到的结果 | |---|---| | 按键通知与 React 更新 | 正文增长影响所属 Node 和相关 Group;未变化历史行及其他 Turn 没有额外通知。模式变化保留 key 与成员父级。 | -| 已录制的 Web 回放 | 当前可访问标题、组开合、普通 Context 隐藏、触发通知及页脚位置与提交的预期输出一致。 | +| 已录制的 Web 回放 | 当前可访问标题、组开合、Context 行、触发通知及页脚位置与提交的预期输出一致。 | [已录制的 Web 场景](../../../../apps/web/tests/steering.e2e.ts)通过正式 Web profile 覆盖在线 steering、重连接续与分组展示。这些行为检查不构成量化延迟或内存改善的证明。 @@ -171,4 +171,4 @@ React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派 - 稳定挂载不消除布局、绘制或保留内存的成本,不宣称实测延迟或最优性能。 - 真实组拆并、首成员变化及分页修正可以改变身份,模式切换保证不禁止这些正常变化。 - 隐藏部分的揭示、选择复制、中断提示、组开合与外层 Turn 联动属于 Chat 业务适配,仅凭通用引用测试不能确认这些行为。 -- 整轮状态与用时替代旧工具/消息计数标题,因此状态、可访问名称和空过程轮次需要明确的展示覆盖。隐藏普通 Context 时保留非人工唤醒通知及原始 Session/Trajectory 查看能力;页脚定位依赖真实 Turn 结束,不依赖分组成员。 +- 整轮状态与用时替代旧工具/消息计数标题,因此状态、可访问名称和空过程轮次需要明确的展示覆盖。非人工唤醒通知独立于 Context 行,Session/Trajectory 查看能力不变;页脚定位依赖真实 Turn 结束,不依赖分组成员。 diff --git a/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.i18n.yaml index 9ed59785ea..ee3f5d9048 100644 --- a/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.i18n.yaml @@ -9,8 +9,8 @@ en: e42db40b879fdbf4 zh: 6017da9c4da17835 /agent-note-node-ptc-execution-modes-and-in-memory-state/decision: - en: 7b5808a31493ac1e - zh: 8d9c698c41dbf18e + en: 411054071757d5fe + zh: e683ef7d80f33fc6 /agent-note-node-ptc-execution-modes-and-in-memory-state/alternatives-considered: en: 396157a9fc293198 zh: a061f05b4423e26f diff --git a/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.md b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.md index fac34c9f7f..c311f397dc 100644 --- a/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.md +++ b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.md @@ -18,7 +18,7 @@ One npm package and one plugin entry keep existing installation and loading path The Session records submitted programs, nested tool calls and returned output. It does not serialize the REPL heap or rerun earlier programs after restart, resume or fork: replaying automation could repeat external side effects. A missing variable is a normal program error, and the model-visible instructions require recreating variables and reacquiring resources. No environment identifier or new durable Session event is required because the provider exposes no unlogged model input. -Cancellation, deadline expiry, protocol or output-limit failure, owner disposal and process exit discard the environment. Ordinary exceptions retain earlier mutations. A retained function uses the active evaluation's host bindings; asynchronous work from an earlier evaluation cannot acquire a later evaluation's tool authority or output sink. +Protocol or output-limit failure, owner disposal and process exit discard the environment. The [interruption decision](2026-10-29-retained-ptc-interruption.md) owns timeout and cancellation recovery with retained state. Ordinary exceptions retain earlier mutations. A retained function uses the active evaluation's host bindings; asynchronous work from an earlier evaluation cannot acquire a later evaluation's tool authority or output sink. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.zh.md b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.zh.md index 93959fe07a..f91dfff11b 100644 --- a/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.zh.md @@ -18,7 +18,7 @@ Status: implemented Session 记录提交的程序、嵌套工具调用和返回输出。它不序列化 REPL 堆,也不在重启、恢复或派生后重新执行旧程序,因为重放自动化可能重复外部操作。缺失变量产生普通程序错误,模型指引要求重新创建变量并获取资源。提供方没有引入日志之外的模型输入,因此不需要环境标识或新的持久 Session 事件。 -取消、截止时间到期、协议失败、输出超限、所有者销毁和进程退出都会丢弃环境。普通异常保留此前发生的修改。保留的函数使用当前执行的宿主绑定;较早执行中的异步任务不能获取后续执行的工具权限或向其输出。 +协议失败、输出超限、所有者销毁和进程退出都会丢弃环境。[中断决策](2026-10-29-retained-ptc-interruption.zh.md)负责保留状态的超时和取消恢复。普通异常保留此前发生的修改。保留的函数使用当前执行的宿主绑定;较早执行中的异步任务不能获取后续执行的工具权限或向其输出。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.i18n.yaml index 4a2fe2ba9d..badb03541e 100644 --- a/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.i18n.yaml @@ -6,14 +6,14 @@ en: 9163af91ea0e030d zh: 92e44043b999d32f /agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/problem: - en: 6fac3c73a4415c83 - zh: f65b61cc1705348b + en: 32ba2b2b42e8deb5 + zh: 6a6532561f81b8e2 /agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/decision: - en: 458416ed7e47f644 - zh: 8b4dd820228e9dfd + en: 08a54b37e157da29 + zh: 65f633055d9df242 /agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/alternatives-considered: - en: b731254983be656d - zh: 5dee2b09f7faa537 + en: 6da221f6b821ea44 + zh: b15774011abed7ed /agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/consequences: - en: 798faeaa0b406eac - zh: 97bbfdaebc84b1d4 + en: c90a122c1f33bb83 + zh: 0175ca0f599d086b diff --git a/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.md b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.md index c3e74990d7..2399e3f1fc 100644 --- a/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.md +++ b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.md @@ -6,17 +6,17 @@ English | [中文](2026-09-27-web-search-offered-by-provider-readiness.zh.md) ## Problem -`dsh-tool-web` registers `web_search` whenever search is enabled, and the [web capability seam decision](2026-06-24-web-capability-seam.md) keeps it visible while the selected provider cannot run. The shipped search provider, `dsh-web-search-deepseek`, authenticates a search with the DeepSeek account token only for a model request on the `deepseek-account` route; every other route needs a configured API key. A Session on an API-key route without a search key was therefore offered a tool whose every call failed with `WEB_PROVIDER_CREDENTIAL_MISSING`. Sessions in one Host can use different routes, a Session can switch routes between requests, and the Web app registers `web_search` once per agent preset, so no registration decision matches every request. The Search and Fetch APIs of `dsh-web-deepseek-api`, which the Web search settings switch selects together, need that credential for both operations, so the same Session was also offered a `web_fetch` whose every call failed. +`dsh-tool-web` registers `web_search` whenever search is enabled, and the [web capability seam decision](2026-06-24-web-capability-seam.md) keeps it visible while the selected provider cannot run. The DeepSeek web providers authenticate an operation with the DeepSeek account token only for a model request on the `deepseek-account` route; every other route needs a configured API key. Without a readiness check, a Session on an API-key route without a key is offered a tool whose every call fails with `WEB_PROVIDER_CREDENTIAL_MISSING`. Sessions in one Host can use different routes, a Session can switch routes between requests, and the Web app registers `web_search` once per agent preset, so no registration decision matches every request. The Fetch API of `dsh-web-deepseek-api`, which the user selects separately from the search provider ([provider selection](2026-10-29-web-provider-selection.md)), needs the same credential, so such a Session with it selected would also be offered a `web_fetch` whose every call fails. ## Decision Each role of the web capability seam owns one part of a per-step check: - **Service Definition.** `WebSearchProvider` and `WebFetchProvider` have an optional `ready(llmProvider)` that answers whether an operation made for a model request on that chat route has a credential to send. It checks configuration only and makes no network calls. `ctx.web.searchReady(llmProvider?)` and `ctx.web.fetchReady(llmProvider?)` resolve the provider with the same rules as `search()` and `fetch()` and return its answer. A provider without `ready()` counts as ready, and a selection failure answers true because the operation reports that failure as a structured `WebError`. -- **Service Provider.** `dsh-web-search-deepseek` answers true when the route is `deepseek-account` and `ctx.deepseekAccount.resolveToken()` returns a token for the configured search endpoint, when the section carries a literal `apiKey`, or when `ctx.credentials.describe()` reports the `apiKeyEnv` reference configured (without that service, when the launching environment carries it). It checks credentials in the order `search()` resolves them, and a failed account-token or credential-store read answers false, because `search()` fails on that read instead of trying the next credential. The Search and Fetch API providers of `dsh-web-deepseek-api` share one credential and answer the same way for their API base; their transport repeats these checks, so neither package depends on the other. The providers name no tool or prompt section. +- **Service Provider.** Each DeepSeek provider checks its credentials in the order its operation resolves them. `dsh-web-search-deepseek` checks, on the `deepseek-account` route, whether `ctx.deepseekAccount.resolveToken()` returns a token for its Messages endpoint, and then its literal `apiKey` or its `apiKeyEnv` reference. The Search and Fetch API providers of `dsh-web-deepseek-api` resolve the same plan as the operation ([credential resolution](2026-10-29-web-provider-selection.md)): a capability with its own YAML fields checks only the key under its `*ApiKeyEnv`; otherwise, on the account route with the account model route mounted, a token for that route's `/v1/tools` base counts, and then the key of the `deepseek-official` route, or `DEEPSEEK_API_KEY` without that route. A key counts when `ctx.credentials.describe()` reports its reference configured (without that service, when the launching environment carries it); the [shared credential helpers](../../../../packages/llm/llm-deepseek/src/credential.ts) perform these presence checks. A failed account-token or credential-store read answers false, because the operation fails on that read instead of trying the next credential. The providers name no tool or prompt section. - **Consumer.** `dsh-tool-web` registers `web_search` and `web_fetch` with [step activation conditions](../../../../packages/core/tools/README.md#conditional-tools) that ask `ctx.web.searchReady()` and `ctx.web.fetchReady()` about the route of the step's model selection. The agent loop resolves conditions before it assembles the step, so the declared tools, the `tool:web_search` and `tool:web_fetch` prompt sections, PTC program bindings, and dispatch all read the same admitted set. -The route decides only whether the account token is checked before the API key, which happens on the `deepseek-account` route; an API key counts on every route, so a conversation on another provider's model is offered the DeepSeek web tools whenever a DeepSeek key is configured. The providers do not compare the chat route's endpoint with their own: each operation sends the configured key, `DEEPSEEK_API_KEY` by default, to the configured endpoint, even when the official route chats through a gateway. +The route decides only whether the account token is checked before the API key, which happens on the `deepseek-account` route; an API key counts on every route, so a conversation on another provider's model is offered the DeepSeek web tools whenever a DeepSeek key is configured. A Search or Fetch API capability with its own YAML fields ignores the route and is ready exactly when its own key is configured. Only presence decides: no API key value is read and no endpoint is contacted, so a wrong key or an endpoint that does not serve the native `web_search_20250305` server tool keeps the tool admitted. A step that does not admit a web tool also refuses calls to it as `UNKNOWN_TOOL`, so a model that repeats an earlier call reaches no provider; an operation made outside a model step resolves its credential when it runs and fails with `WEB_PROVIDER_CREDENTIAL_MISSING` when there is none. @@ -24,7 +24,7 @@ The request header records the assembled tools, so the agent loop announces each ## Alternatives considered -**Let the DeepSeek provider remove `web_search` in its own assemble listener.** The seam stays unchanged, but the provider then names another package's tool, prompt section, and PTC transport, and it removes the tool even when the deployment selects another search provider, such as Exa, whose searches would succeed. +**Let the DeepSeek provider remove `web_search` in its own assemble listener.** The seam stays unchanged, but the provider then names another package's tool, prompt section, and PTC transport, and it removes the tool even when the deployment selects another search provider, such as one a plugin registers, whose searches would succeed. **Extend `available()`.** It is synchronous and takes no request, while this check reads an asynchronous credential store and depends on the request's route. A route-dependent `available()` would also make provider selection depend on the conversation. @@ -36,9 +36,9 @@ The request header records the assembled tools, so the agent loop announces each **Offer search only on the DeepSeek account route.** Account tokens are issued only for the official endpoint, so a gateway key could never reach DeepSeek. But every shipped profile defaults to the `deepseek-official` route, so search would be off by default, including the official API-key case. -**Withhold the tools from other providers' conversations unless search has a key of its own.** A search from another model's conversation bills the DeepSeek key, but DeepSeek search is one of the services that key pays for, and a user who configured it expects search to work whatever model chats. The Web search settings page saves its key under `apiKeyEnv`, `DEEPSEEK_API_KEY` by default, which the chat route also reads, so a key entered there could never serve another model; a `routes` setting that opens them would be one more thing a user has to find. +**Withhold the tools from other providers' conversations unless search has a key of its own.** A search from another model's conversation bills the DeepSeek key, but DeepSeek search is one of the services that key pays for, and a user who configured it expects search to work whatever model chats. The Search and Fetch APIs send the chat route's own key, and the settings page saves the legacy Messages search key under `apiKeyEnv`, `DEEPSEEK_API_KEY` by default, which the chat route also reads, so withholding them would leave a configured key unable to serve another model; a `routes` setting that opens them would be one more thing a user has to find. -**Compare the official chat route's endpoint with the search endpoint.** Withholding the tools or the key when the two endpoints differ in origin keeps a gateway's key away from DeepSeek, but the providers would have to find the chat route's configuration themselves. Taking the first active `dsh-llm-deepseek-api-key` Loader row answers for an unrelated instance when a composition isolates several, which disables a correct route or lets a gateway key through; only the route's owner knows which endpoint serves a request, and an endpoint field in `dsh-llm` would reach the browser through the Remote `listProviders()`. The check would also withhold search from users who chat through a proxy or development endpoint with a key the official search endpoint accepts. Without it, a gateway user's search fails at the configured endpoint. +**Compare the official chat route's endpoint with the web endpoint and withhold the tools when they differ.** It would keep a gateway's key away from DeepSeek, but it would also withhold web tools from users who chat through a proxy or development endpoint with a key the web endpoint accepts. The Search and Fetch APIs instead derive their endpoint from the origin of the route whose key they send, so the key and endpoint come from one route; legacy Messages search keeps its own endpoint, where a gateway user's search fails. **Keep the tool offered and fail at execution.** The model spends a call on a search that cannot authenticate, and the failure repeats on every attempt. @@ -46,6 +46,6 @@ The request header records the assembled tools, so the agent loop announces each ## Consequences -`dsh-web` gains public members: the optional `ready()` of both provider interfaces, `WebRuntime.searchReady()`, and `WebRuntime.fetchReady()`. Each readiness method is the smallest query its one consumer needs, which is how the [observation-surface decision](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) lets a status query return; neither reports availability or selection failures. The Exa, Perplexity, and HTTP fetch providers implement no `ready()`, so their behavior is unchanged, and `web_fetch` stays admitted whenever the switch leaves HTTP fetch selected. A step that does not admit a web tool leaves it out of PTC program bindings too. An endpoint that cannot serve the native server tool keeps the tool admitted and fails each call. Removing a tool on an unchanged route changes the declared tools and misses the prompt cache once; a model switch already invalidates it. Searches and fetches from other providers' conversations bill the configured DeepSeek key, and a deployment whose official route chats through a gateway sends the gateway's key to the configured endpoint, the official one by default, where each operation fails unless that endpoint accepts the key. The DeepSeek web packages read no Loader rows and depend on no LLM package. +`dsh-web` gains public members: the optional `ready()` of both provider interfaces, `WebRuntime.searchReady()`, and `WebRuntime.fetchReady()`. Each readiness method is the smallest query its one consumer needs, which is how the [observation-surface decision](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) lets a status query return; neither reports availability or selection failures. The HTTP fetch provider implements no `ready()`, so `web_fetch` stays admitted whenever HTTP fetch is selected. A step that does not admit a web tool leaves it out of PTC program bindings too. An endpoint that cannot serve the native server tool keeps the tool admitted and fails each call. Removing a tool on an unchanged route changes the declared tools and misses the prompt cache once; a model switch already invalidates it. Searches and fetches from other providers' conversations bill the configured DeepSeek key. A deployment whose official route chats through a gateway sends the gateway's key to that gateway's origin with `/v1/tools` for the Search and Fetch APIs, and to the configured Messages endpoint, the official one by default, for legacy Messages search, where each search fails unless that endpoint accepts the key. The Search and Fetch APIs read the DeepSeek model routes' Loader entries through the model provider directory. -Unit tests pin each role: `packages/web/web/tests/web.spec.ts` covers which provider `searchReady()` and `fetchReady()` ask and their answer on selection failure, `packages/web/web-search-deepseek/tests/deepseek.spec.ts` and `packages/web/web-deepseek-api/tests/readiness.spec.ts` cover each credential case of `ready()` on the account route and on other routes, and `packages/web/tool-web/tests/tool-web.spec.ts` resolves step admission of both tools through the real prompt, tool, and executor services, including the `UNKNOWN_TOOL` refusal of a call in a step that withheld the tool. In real compositions, the shipped headless keyless smoke test and the `web-search-unconfigured` recorded-session snapshot, whose search key references are unset, pin the withheld search tool. Snapshot replays supply a placeholder DeepSeek key, so their tool lists do not depend on the runner's environment; with it, the `pi-ai-conversation-updates` conversation on a pi-ai route keeps `web_search`. +Unit tests pin each role: [web.spec.ts](../../../../packages/web/web/tests/web.spec.ts) covers which provider `searchReady()` and `fetchReady()` ask and their answer on selection failure, [deepseek.spec.ts](../../../../packages/web/web-search-deepseek/tests/deepseek.spec.ts) and [credential-resolution.spec.ts](../../../../packages/web/web-deepseek-api/tests/credential-resolution.spec.ts) cover each credential case of `ready()` on the account route and on other routes, including readiness that matches each operation, and [tool-web.spec.ts](../../../../packages/web/tool-web/tests/tool-web.spec.ts) resolves step admission of both tools through the real prompt, tool, and executor services, including the `UNKNOWN_TOOL` refusal of a call in a step that withheld the tool. In real compositions, the shipped headless keyless smoke test and the `web-search-unconfigured` recorded-session snapshot, whose search key references are unset, pin the withheld search tool. Snapshot replays supply a placeholder DeepSeek key, so their tool lists do not depend on the runner's environment; with it, the `pi-ai-conversation-updates` conversation on a pi-ai route keeps `web_search`. diff --git a/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.zh.md b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.zh.md index 9c4b0f6948..61f8acb6f8 100644 --- a/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.zh.md @@ -6,17 +6,17 @@ Status: implemented ## Problem -`dsh-tool-web` 在启用搜索时总会注册 `web_search`,而 [web 能力 seam 决策](2026-06-24-web-capability-seam.zh.md)让它在选中的提供方无法运行时仍然可见。发行版的搜索提供方 `dsh-web-search-deepseek` 只对 `deepseek-account` 路由上的模型请求使用 DeepSeek 账号 token 鉴权;其他路由都需要已配置的 API 密钥。因此,API 密钥路由上没有搜索密钥的会话会获得一个每次调用都以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败的工具。同一 Host 中的会话可以使用不同路由,一个会话可以在请求之间切换路由,而 Web 应用按 agent preset 只注册一次 `web_search`,因此任何注册层面的决定都无法与每个请求相符。Web 搜索设置开关会同时选中 `dsh-web-deepseek-api` 的 Search 与 Fetch API,两个操作都需要这份凭据,因此同一会话还会获得一个每次调用都失败的 `web_fetch`。 +`dsh-tool-web` 在启用搜索时总会注册 `web_search`,而 [web 能力 seam 决策](2026-06-24-web-capability-seam.zh.md)让它在选中的提供方无法运行时仍然可见。DeepSeek Web 提供方只对 `deepseek-account` 路由上的模型请求使用 DeepSeek 账号 token 为操作鉴权;其他路由都需要已配置的 API 密钥。没有就绪检查时,API 密钥路由上没有密钥的会话会获得一个每次调用都以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败的工具。同一 Host 中的会话可以使用不同路由,一个会话可以在请求之间切换路由,而 Web 应用按 agent preset 只注册一次 `web_search`,因此任何注册层面的决定都无法与每个请求相符。`dsh-web-deepseek-api` 的 Fetch API 由用户与搜索提供方分别选择([提供方选择](2026-10-29-web-provider-selection.zh.md)),它需要同一份凭据,因此这样的会话选中它时,还会获得一个每次调用都失败的 `web_fetch`。 ## Decision web 能力 seam 的每个角色各负责逐步骤检查的一部分: - **Service Definition。** `WebSearchProvider` 与 `WebFetchProvider` 有一个可选的 `ready(llmProvider)`,回答为该对话路由上的模型请求发起的操作是否有可发送的凭据。它只检查配置,不发起网络调用。`ctx.web.searchReady(llmProvider?)` 与 `ctx.web.fetchReady(llmProvider?)` 按与 `search()`、`fetch()` 相同的规则解析提供方并返回其回答。没有 `ready()` 的提供方视为就绪;选择失败时回答 true,因为对应操作会把该失败报告为结构化 `WebError`。 -- **Service Provider。** `dsh-web-search-deepseek` 在以下任一情况下回答 true:路由为 `deepseek-account` 且 `ctx.deepseekAccount.resolveToken()` 为配置的搜索端点返回 token;配置段带有字面 `apiKey`;或 `ctx.credentials.describe()` 报告 `apiKeyEnv` 引用已配置(没有该服务时,启动环境中存在该变量)。它按 `search()` 解析凭据的顺序检查凭据,读取账号 token 或凭据存储失败时回答 false,因为 `search()` 会在该次读取上失败,而不会尝试下一种凭据。`dsh-web-deepseek-api` 的 Search 与 Fetch API 提供方共用一份凭据,并对其 API 基础地址以同样方式回答;它们的传输层重复了这些检查,因此两个包互不依赖。提供方不点名任何工具或提示词段落。 +- **Service Provider。** 每个 DeepSeek 提供方都按其操作解析凭据的顺序检查凭据。`dsh-web-search-deepseek` 在 `deepseek-account` 路由上检查 `ctx.deepseekAccount.resolveToken()` 是否为其 Messages 端点返回 token,然后检查其字面 `apiKey` 或 `apiKeyEnv` 引用。`dsh-web-deepseek-api` 的 Search 与 Fetch API 提供方解析与操作相同的方案([凭据解析](2026-10-29-web-provider-selection.zh.md)):在 YAML 中设置了单独字段的能力只检查其 `*ApiKeyEnv` 下的密钥;否则,在账号路由上且账号模型路由已挂载时,该路由 `/v1/tools` 基础地址的 token 算数,然后是 `deepseek-official` 路由的密钥,没有该路由时为 `DEEPSEEK_API_KEY`。`ctx.credentials.describe()` 报告其引用已配置时(没有该服务时,启动环境中存在该变量),密钥即算存在;这些存在性检查由[共享凭据函数](../../../../packages/llm/llm-deepseek/src/credential.ts)执行。读取账号 token 或凭据存储失败时回答 false,因为操作会在该次读取上失败,而不会尝试下一种凭据。提供方不点名任何工具或提示词段落。 - **Consumer。** `dsh-tool-web` 注册 `web_search` 与 `web_fetch` 时各带一个[步骤激活条件](../../../../packages/core/tools/README.zh.md#conditional-tools),以该步骤模型选择的路由询问 `ctx.web.searchReady()` 与 `ctx.web.fetchReady()`。agent loop 在组装该步骤之前解析各条件,因此声明的工具、`tool:web_search` 与 `tool:web_fetch` 提示词段落、PTC 程序绑定与派发都读取同一个准入集合。 -路由只决定是否在 API 密钥之前检查账号 token,这只发生在 `deepseek-account` 路由上;API 密钥在任何路由上都算数,因此只要配置了 DeepSeek 密钥,使用其他提供方模型的对话也会获得 DeepSeek Web 工具。提供方不比较聊天路由的地址与自己的端点:即使官方路由经网关聊天,每次操作也把配置的密钥(默认 `DEEPSEEK_API_KEY`)发往配置的端点。 +路由只决定是否先检查账号 token 再检查 API 密钥,这发生在 `deepseek-account` 路由上;API 密钥在每条路由上都算数,因此只要配置了 DeepSeek 密钥,使用其他提供方模型的对话也会获得 DeepSeek Web 工具。在 YAML 中设置了单独字段的 Search 或 Fetch API 能力不看路由,恰在其自身密钥已配置时就绪。 只看凭据是否存在:不读取 API 密钥的值,也不访问任何端点,因此错误的密钥,或不提供原生 `web_search_20250305` 服务器工具的端点,仍会让该工具保持准入。未准入某个 Web 工具的步骤也会以 `UNKNOWN_TOOL` 拒绝对它的调用,因此模型重复先前的调用也到达不了任何提供方;在模型步骤之外发起的操作在运行时解析凭据,没有凭据时以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败。 @@ -24,7 +24,7 @@ web 能力 seam 的每个角色各负责逐步骤检查的一部分: ## Alternatives considered -**让 DeepSeek 提供方在自己的 assemble 监听器中移除 `web_search`。** seam 保持不变,但提供方因此要点名另一个包的工具、提示词段落与 PTC 传输工具;而且即使部署选择了另一个搜索成功的提供方(例如 Exa),它也会移除该工具。 +**让 DeepSeek 提供方在自己的 assemble 监听器中移除 `web_search`。** seam 保持不变,但提供方因此要点名另一个包的工具、提示词段落与 PTC 传输工具;而且即使部署选择了另一个搜索成功的提供方(例如插件注册的提供方),它也会移除该工具。 **扩展 `available()`。** 它是同步的且不接收请求,而这项检查要读取异步凭据存储,并取决于请求的路由。依赖路由的 `available()` 还会让提供方选择取决于对话。 @@ -36,9 +36,9 @@ web 能力 seam 的每个角色各负责逐步骤检查的一部分: **只在 DeepSeek 账号路由上提供搜索。** 账号 token 只对官方端点签发,因此网关的密钥不可能到达 DeepSeek。但所有发行 profile 的默认路由都是 `deepseek-official`,搜索会因此默认关闭,连官方 API 密钥的情况也一并关闭。 -**除非搜索有自己的密钥,否则不向其他提供方的对话提供这些工具。** 其他模型对话中的搜索会由 DeepSeek 密钥计费,但 DeepSeek 搜索本就是该密钥付费使用的服务之一,配置了它的用户也期望无论用哪个模型对话,搜索都能用。Web 搜索设置页把密钥存在 `apiKeyEnv` 指向的引用下,默认是聊天路由也在读取的 `DEEPSEEK_API_KEY`,因此在那里输入的密钥永远无法服务其他模型;再加一个开放这些路由的 `routes` 配置项,只会让用户多找一样东西。 +**除非搜索有自己的密钥,否则不向其他提供方的对话提供这些工具。** 其他模型对话中的搜索会由 DeepSeek 密钥计费,但 DeepSeek 搜索本就是该密钥付费使用的服务之一,配置了它的用户也期望无论用哪个模型对话,搜索都能用。Search 与 Fetch API 发送聊天路由自己的密钥,设置页把旧版 Messages 搜索的密钥存在 `apiKeyEnv` 指向的引用下,默认是聊天路由也在读取的 `DEEPSEEK_API_KEY`,因此撤下工具会让已配置的密钥无法服务其他模型;再加一个开放这些路由的 `routes` 配置项,只会让用户多找一样东西。 -**比较官方聊天路由的端点与搜索端点。** 两个端点不同源时撤下工具或不发送密钥,能让网关的密钥远离 DeepSeek,但提供方得自己找出聊天路由的配置。取第一条激活的 `dsh-llm-deepseek-api-key` Loader 行时,如果组合隔离了多个实例,回答的可能是无关的实例,从而关掉正确的路由,或放过网关的密钥;只有路由的所有者知道哪个端点服务某个请求,而在 `dsh-llm` 加地址字段会让它经 Remote 方法 `listProviders()` 进入浏览器。这项检查还会让经代理或开发端点对话、而密钥能被官方搜索端点接受的用户失去搜索。不做这项检查时,网关用户的搜索会在配置的端点上失败。 +**比较官方聊天路由的端点与 Web 端点,二者不同时撤下工具。** 这能让网关的密钥远离 DeepSeek,但也会让经代理或开发端点对话、而密钥能被 Web 端点接受的用户失去网页工具。Search 与 Fetch API 改为从其所发送密钥的路由的 origin 推导端点,因此密钥与端点来自同一条路由;旧版 Messages 搜索保留自己的端点,网关用户的搜索会在那里失败。 **保持工具可见,在执行时失败。** 模型会把一次调用花在无法鉴权的搜索上,并且每次尝试都会重复失败。 @@ -46,6 +46,6 @@ web 能力 seam 的每个角色各负责逐步骤检查的一部分: ## Consequences -`dsh-web` 增加以下公共成员:两个提供方接口上可选的 `ready()`、`WebRuntime.searchReady()` 与 `WebRuntime.fetchReady()`。每个就绪方法都是其唯一消费方所需的最小查询,这正是[观测接口决策](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md)允许状态查询回归的方式;它们既不报告可用性,也不报告选择失败。Exa、Perplexity 与 HTTP 抓取提供方没有实现 `ready()`,其行为不变;只要开关让 HTTP 抓取保持选中,`web_fetch` 就始终准入。未准入某个 Web 工具的步骤,其 PTC 程序绑定中也没有它。无法提供原生服务器工具的端点仍会让工具保持准入,并使每次调用失败。在路由不变时移除工具会改变声明的工具,并使提示词缓存失效一次;模型切换本身已经使缓存失效。其他提供方对话中的搜索与抓取由配置的 DeepSeek 密钥计费;官方路由经网关对话的部署会把网关的密钥发往配置的端点(默认是官方端点),除非该端点接受这个密钥,否则每次操作都会失败。DeepSeek Web 包不读取 Loader 的行,也不依赖任何 LLM 包。 +`dsh-web` 增加以下公共成员:两个提供方接口上可选的 `ready()`、`WebRuntime.searchReady()` 与 `WebRuntime.fetchReady()`。每个就绪方法都是其唯一消费方所需的最小查询,这正是[观测接口决策](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md)允许状态查询回归的方式;它们既不报告可用性,也不报告选择失败。HTTP 抓取提供方没有实现 `ready()`,因此只要选中 HTTP 抓取,`web_fetch` 就始终准入。未准入某个 Web 工具的步骤,其 PTC 程序绑定中也没有它。无法提供原生服务器工具的端点仍会让工具保持准入,并使每次调用失败。在路由不变时移除工具会改变声明的工具,并使提示词缓存失效一次;模型切换本身已经使缓存失效。其他提供方对话中的搜索与抓取由配置的 DeepSeek 密钥计费。官方路由经网关对话的部署,对 Search 与 Fetch API 会把网关的密钥发往该网关 origin 加 `/v1/tools`,对旧版 Messages 搜索则发往配置的 Messages 端点(默认是官方端点),除非该端点接受这个密钥,否则每次搜索都会失败。Search 与 Fetch API 通过模型提供方目录读取 DeepSeek 模型路由的 Loader 条目。 -单元测试分别固定每个角色:`packages/web/web/tests/web.spec.ts` 覆盖 `searchReady()` 与 `fetchReady()` 询问哪个提供方以及选择失败时的回答,`packages/web/web-search-deepseek/tests/deepseek.spec.ts` 与 `packages/web/web-deepseek-api/tests/readiness.spec.ts` 覆盖 `ready()` 在账号路由与其他路由上的每种凭据情况,`packages/web/tool-web/tests/tool-web.spec.ts` 通过真实的提示词、工具与执行器服务解析两个工具的步骤准入,包括在撤下该工具的步骤中调用它时以 `UNKNOWN_TOOL` 拒绝。在真实组合中,发行版 headless 无密钥冒烟测试与 `web-search-unconfigured` 录制会话快照的搜索密钥引用都未设置,二者固定了搜索工具的撤下行为。快照回放提供占位 DeepSeek 密钥,因此其工具列表不依赖运行环境;借助该密钥,`pi-ai-conversation-updates` 中 pi-ai 路由上的对话保留 `web_search`。 +单元测试分别固定每个角色:[web.spec.ts](../../../../packages/web/web/tests/web.spec.ts) 覆盖 `searchReady()` 与 `fetchReady()` 询问哪个提供方以及选择失败时的回答,[deepseek.spec.ts](../../../../packages/web/web-search-deepseek/tests/deepseek.spec.ts) 与 [credential-resolution.spec.ts](../../../../packages/web/web-deepseek-api/tests/credential-resolution.spec.ts) 覆盖 `ready()` 在账号路由与其他路由上的每种凭据情况,包括就绪判断与每次操作一致,[tool-web.spec.ts](../../../../packages/web/tool-web/tests/tool-web.spec.ts) 通过真实的提示词、工具与执行器服务解析两个工具的步骤准入,包括在撤下该工具的步骤中调用它时以 `UNKNOWN_TOOL` 拒绝。在真实组合中,发行版 headless 无密钥冒烟测试与 `web-search-unconfigured` 录制会话快照的搜索密钥引用都未设置,二者固定了搜索工具的撤下行为。快照回放提供占位 DeepSeek 密钥,因此其工具列表不依赖运行环境;借助该密钥,`pi-ai-conversation-updates` 中 pi-ai 路由上的对话保留 `web_search`。 diff --git a/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.i18n.yaml index b22fc768ec..3bdacb079c 100644 --- a/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.i18n.yaml @@ -9,14 +9,14 @@ en: 8112c1c059d1cf10 zh: 852d23c56a93a555 /agent-note-preserve-web-and-desktop-plugin-identities-across-experimental-consolidation/decision: - en: 90d74300f045bf6e - zh: 088e88944478ca2a + en: 3cf1a8f986cea871 + zh: 3105cf2608108ecd /agent-note-preserve-web-and-desktop-plugin-identities-across-experimental-consolidation/deferred-naming-cleanup: - en: 4c428f65ef611d07 - zh: 1fdc5467b0203d08 + en: 7f59c9d14a36e133 + zh: 2e7829c59b4fcd24 /agent-note-preserve-web-and-desktop-plugin-identities-across-experimental-consolidation/alternatives-considered: en: 65af55b5775c9bc5 zh: 2ea75b3fd33ac0c2 /agent-note-preserve-web-and-desktop-plugin-identities-across-experimental-consolidation/consequences: - en: e8e96b0354f1a3c6 - zh: ad1bfe3600e14ff1 + en: 08613c24e7c552ab + zh: 6f2509d3e61cb2c7 diff --git a/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.md b/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.md index c39f289a39..a6b66bc1fb 100644 --- a/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.md +++ b/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.md @@ -10,7 +10,7 @@ Moving a capability into the experimental directory changes its ownership and su ## Decision -The moved packages retain the published names declared by the [experimental naming policy](../../../../scripts/experimental-package-policy.ts). That typed directory-to-name map is the sole maintained inventory of naming exceptions. Package manifests, consumers, installation checks, and bundler checks agree with it; a missing, moved, or duplicated exception is rejected. [Experimental status](../../../../packages/experimental/README.md#status) remains independent of spelling, Official discovery, and explicit selection. +The moved packages retain the published names declared by the [experimental naming policy](../../../../scripts/experimental-package-policy.ts). That typed directory-to-name map is the sole maintained inventory of unprefixed experimental names; under the [unprefixed naming decision](2026-11-04-unprefixed-experimental-package-names.md), every new experimental package joins it. Package manifests, consumers, installation checks, and bundler checks agree with it; a missing, moved, or duplicated entry is rejected. [Experimental status](../../../../packages/experimental/README.md#status) remains independent of spelling, Official discovery, and explicit selection. Existing badge, Ralph, and native delegation row IDs remain valid Host row selectors. New isolation groups and newly introduced capabilities retain their new IDs. Later user layers still replace configuration or disable rows. Selecting an optional bundle is still explicit: a patch targeting an absent row does not select its bundle. @@ -20,10 +20,9 @@ Compatibility protection covers Web and Desktop profiles, including existing nat | Retained identity | Unconstrained cleanup | Why it is deferred | |---|---|---| -| Published names in the naming-exception map | Give every experimental package the `@deepseek-ai/dsh-experimental-` prefix | Existing dependencies, imports, module declarations, and profile files use the established names | | Existing badge, Ralph, and native-tool row IDs | Give optional contributions consistently distinct `optional-*` selectors | Existing user overrides use those IDs; the ownership move does not require a new selector | -These naming changes are deferred, not prerequisites for experimental classification. A later cleanup requires an explicit release and migration decision, an inventory of affected Web/Desktop configurations, and installed-profile upgrade evidence. Directory moves or pre-stable API status alone do not retire these exceptions. The package list stays in the typed policy rather than being copied into this note or package READMEs. +These naming changes are deferred, not prerequisites for experimental classification. A later cleanup requires an explicit release and migration decision, an inventory of affected Web/Desktop configurations, and installed-profile upgrade evidence. Directory moves or pre-stable API status alone do not retire these row IDs. The package list stays in the typed policy rather than being copied into this note or package READMEs. ## Alternatives considered @@ -35,6 +34,6 @@ These naming changes are deferred, not prerequisites for experimental classifica ## Consequences -The repository carries a bounded naming-exception map and tests that classify retained names through direct imports, aliases, transitive dependencies, and packed or bundled artifacts. An unprefixed name does not grant admission to the default product. Existing row identities have Web/Desktop composition coverage for user overrides and disabled state. No general alias or automatic profile migration mechanism is introduced. +The repository carries a typed map of unprefixed experimental names and tests that classify retained names through direct imports, aliases, transitive dependencies, and packed or bundled artifacts. An unprefixed name does not grant admission to the default product. Existing row identities have Web/Desktop composition coverage for user overrides and disabled state. No general alias or automatic profile migration mechanism is introduced. The [classification decision](../process/2026-10-05-product-package-classification.md), [optional-composition decision](2026-09-21-experimental-capabilities-as-optional-bundles.md), and [on-demand discovery decision](2026-10-05-official-on-demand-bundles.md) retain their separate responsibilities. Released Session generations and persisted hook event names remain protected independently of this profile compatibility scope. diff --git a/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.zh.md b/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.zh.md index 4b58743cb5..6a34639ec6 100644 --- a/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -迁移的包保留[实验包命名策略](../../../../scripts/experimental-package-policy.ts)声明的已发布名称。这个带类型的目录到包名映射是命名例外唯一手工维护的清单。包清单、消费者、安装检查和打包检查与其保持一致;缺失、迁移到其他位置或重复归属的例外会被拒绝。[实验状态](../../../../packages/experimental/README.zh.md#status)仍独立于名称拼写、Official 发现和显式选择。 +迁移的包保留[实验包命名策略](../../../../scripts/experimental-package-policy.ts)声明的已发布名称。这个带类型的目录到包名映射是不带前缀的实验包名唯一手工维护的清单;按照[不带前缀命名决策](2026-11-04-unprefixed-experimental-package-names.zh.md),每个新实验包都加入其中。包清单、消费者、安装检查和打包检查与其保持一致;缺失、迁移到其他位置或重复归属的条目会被拒绝。[实验状态](../../../../packages/experimental/README.zh.md#status)仍独立于名称拼写、Official 发现和显式选择。 既有徽章、Ralph 和原生委派行 ID 作为 Host 行选择器保持有效。新增隔离组和新引入的能力保留各自的新 ID。后续用户层仍可替换配置或禁用行。可选 bundle 仍须显式选择:指向缺失行的 patch 不会自动选择其 bundle。 @@ -20,10 +20,9 @@ Status: implemented | 保留的身份 | 不受兼容性约束时的清理 | 暂缓原因 | |---|---|---| -| 命名例外映射中的已发布名称 | 为每个实验包使用 `@deepseek-ai/dsh-experimental-` 前缀 | 既有依赖、导入、模块声明和 profile 文件使用原有名称 | | 既有徽章、Ralph 和原生工具行 ID | 为可选贡献统一使用独立的 `optional-*` 选择器 | 既有用户覆盖使用这些 ID;归属迁移不要求新的选择器 | -这些命名变更被暂缓,不是实验分类的前提。后续清理需要明确的发布与迁移决策、受影响 Web/Desktop 配置清单,以及已安装 profile 的升级验证。仅目录迁移或公共 API 处于稳定前阶段,不足以删除这些例外。包清单保留在带类型的策略中,不复制到本记录或各包 README。 +这些命名变更被暂缓,不是实验分类的前提。后续清理需要明确的发布与迁移决策、受影响 Web/Desktop 配置清单,以及已安装 profile 的升级验证。仅目录迁移或公共 API 处于稳定前阶段,不足以删除这些行 ID。包清单保留在带类型的策略中,不复制到本记录或各包 README。 ## 考虑过的替代方案 @@ -35,6 +34,6 @@ Status: implemented ## 后果 -仓库维护范围明确的命名例外映射,并测试通过直接导入、别名、传递依赖及打包产物识别这些保留名称。没有实验前缀的名称不因此获准进入默认产品。既有行身份具有覆盖用户配置和禁用状态的 Web/Desktop 组合测试。不引入通用别名或自动 profile 迁移机制。 +仓库维护带类型的不带前缀实验包名映射,并测试通过直接导入、别名、传递依赖及打包产物识别这些保留名称。没有实验前缀的名称不因此获准进入默认产品。既有行身份具有覆盖用户配置和禁用状态的 Web/Desktop 组合测试。不引入通用别名或自动 profile 迁移机制。 [分类决策](../process/2026-10-05-product-package-classification.zh.md)、[可选组合决策](2026-09-21-experimental-capabilities-as-optional-bundles.zh.md)和[按需发现决策](2026-10-05-official-on-demand-bundles.zh.md)继续承担各自独立的职责。已发布 Session 代际和持久化 hook 事件名的保护独立于此 profile 兼容范围。 diff --git a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.i18n.yaml index afcfe07ab3..979033451f 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.i18n.yaml @@ -9,11 +9,11 @@ en: 47ba548b869be190 zh: c2e5038a56fe741c /agent-note-asynchronous-projection-registration-and-retained-readers/decision: - en: be0d17bfbd1b59d4 - zh: 7a23bccdf95c21ff + en: a6f9ae8a53af5e4b + zh: 0aece791574ef060 /agent-note-asynchronous-projection-registration-and-retained-readers/alternatives-considered: - en: e7c66403fab991d2 - zh: 1580ec29116c7d42 + en: 12e0f5fc9638acc6 + zh: 24213550da27b70b /agent-note-asynchronous-projection-registration-and-retained-readers/consequences: - en: 31ce6f02dd3bf39d - zh: bcd29b2146f689b5 + en: f5c294608d87844f + zh: 24488e439a04d81a diff --git a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.md b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.md index 813b1dffd1..9d19c4e94b 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.md +++ b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.md @@ -12,7 +12,7 @@ Host readers coupled to registry key lookup lose access when a plugin unloads, a `SessionProjectionRegistry.register` returns an awaitable registration for a typed handle. Domain owners await it during activation before exposing dependent operations. Function plugins retain their namespace exports and use asynchronous `apply`; services use their initialization hook. Each domain service keeps its definitions and handles internal and exposes methods returning business data. Consumers depend on that service rather than registering its definitions or receiving projection readers. This supersedes the optional first-access acquisition and disposer-return details in [the mandatory-reader decision](2026-08-19-session-projection-mandatory-seam.md), while retaining its mandatory-state and cropped-client-view rules. -A handle owns one reference to a shared definition and its per-Session cells. Its disposer releases global participation exactly once; the final release removes the unit from live drives, snapshots, and checkpoints. Its reader remains usable after release, folding unseen events with the original definition. A later registration at a different state version does not replace an older handle's definition. Registration checks owner liveness after its asynchronous handoff and rejects if the owner unloaded before receiving the handle. +A handle owns one reference to a shared definition and its per-Session cells. Its disposer releases global participation exactly once; the final release removes the unit from live drives and snapshots. Checkpoints keep each Session's last cell of the released unit until its key registers again: the registry no longer advances that cell, while a retained handle read still advances it forward, so a writer that runs after the owner unloads still stores the state already folded. Under the Cordis rc.11 teardown order, Web-layer plugins release their units before the Sessions they describe detach, and the final projection-cache write would otherwise drop those rows. Stored rows can therefore lag the log; the cache promises only possibly stale rows. Its reader remains usable after release, folding unseen events with the original definition. A later registration at a different state version does not replace an older handle's definition. Registration checks owner liveness after its asynchronous handoff and rejects if the owner unloaded before receiving the handle. AgentRegistry owns turn and step state and exposes `turnBoundaryOf(session)`. AgentLoop acquires its inbox handle once before publishing its factory, reads the last turn before creating an Agent scope, and passes an inbox callback plus that turn number into synchronous Agent construction. The callback retains its handle through cleanup after the loop fiber removes global registrations. This preserves the existing creation/setup sequence without per-Agent registration or an extra initialization state. @@ -20,6 +20,10 @@ Plugins compiled against the synchronous API keep running during migration. The ## Alternatives considered +**Detach owned Sessions before unregistering.** Other Web-layer plugins unload in earlier fibers under the rc.11 teardown order, and plugin reload or disable reaches the same timing, so ordering one owner cannot keep every row. + +**Checkpoint a released unit only while its cell is at the Session cursor.** A Session with a running turn at shutdown appends teardown events after its units are released, so its rows would be dropped again. + **Keep registry-level lookup and delay unregistering.** This couples unrelated plugin disposal order to every reader's final access. Retained readers make cleanup independent of that order. **Return a synchronous handle.** This preserves callers' immediate reads but commits the acquisition API to synchronous initialization. Awaited acquisition leaves that timing explicit while state reads remain synchronous after readiness. @@ -36,4 +40,4 @@ Plugins compiled against the synchronous API keep running during migration. The ## Consequences -Projection owners must migrate acquisition and retain handles; other consumers use the owning services. Released handles retain their definitions and weakly keyed cells for as long as the caller keeps them; they do not restore global wire participation. Registration-reader tests cover post-release folding, version replacement, unrelated-unit isolation, repeated disposal, concurrent owner disposal, and owner unload during acquisition. Agent lifecycle tests cover cleanup failures, scope-minting races, and background child shutdown. Compatibility tests and a real Loader subprocess verify the deprecated calls. +Projection owners must migrate acquisition and retain handles; other consumers use the owning services. Released handles retain their definitions and weakly keyed cells for as long as the caller keeps them; they do not restore global wire participation. The registry also retains a released predecessor, with its definition closures and weakly keyed cells, until the same key registers again, so a key that a reloaded plugin renames or stops registering keeps the old definition referenced for the process lifetime. Registration-reader tests cover post-release folding, version replacement, unrelated-unit isolation, repeated disposal, concurrent owner disposal, and owner unload during acquisition. Agent lifecycle tests cover cleanup failures, scope-minting races, and background child shutdown. Compatibility tests and a real Loader subprocess verify the deprecated calls. diff --git a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.zh.md b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.zh.md index a80721d82d..56879ebc99 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.zh.md @@ -12,7 +12,7 @@ Status: implemented `SessionProjectionRegistry.register` 返回可等待的注册结果,用于获取类型化句柄。领域所有者在激活期间等待它,再提供依赖操作。函数插件保留命名空间导出并使用异步 `apply`;服务使用初始化钩子。领域服务在内部持有定义和句柄,通过方法返回业务数据。消费方依赖该服务,无需注册其定义或接收投影读取句柄。这取代了[必需读取方决策](2026-08-19-session-projection-mandatory-seam.zh.md)中的可选首次访问获取方式与返回释放函数的细节,同时保留必需状态和裁剪 Client 视图的规则。 -句柄拥有共享定义及其逐 Session 单元的一次引用。其释放函数恰好一次地解除全局参与;最后一次释放会从实时推进、快照和检查点中移除单元。读取句柄在释放后仍可使用原定义折叠未读取事件。后续注册的其他状态版本不会替换旧句柄的定义。注册在异步交接后检查 owner 是否存活;如果 owner 在收到句柄前卸载,注册拒绝。 +句柄拥有共享定义及其逐 Session 单元的一次引用。其释放函数恰好一次地解除全局参与;最后一次释放会从实时推进和快照中移除单元。检查点仍保留每个 Session 中已释放单元的最后一个 cell,直到它的 key 再次注册:注册表不再推进这个 cell,但已保留的 handle 读取时仍会将其向前推进,因此 owner 卸载后才运行的写入方仍会存下已经折叠出的状态。按 Cordis rc.11 的卸载顺序,Web 层插件会在它们所描述的 Session detach 之前释放单元,否则 projection cache 的最终写入会丢掉这些行。因此存储的行可能落后于日志;cache 只承诺行可能过时。读取句柄在释放后仍可使用原定义折叠未读取事件。后续注册的其他状态版本不会替换旧句柄的定义。注册在异步交接后检查 owner 是否存活;如果 owner 在收到句柄前卸载,注册拒绝。 AgentRegistry 持有轮次和步骤状态,并提供 `turnBoundaryOf(session)`。AgentLoop 在发布工厂前只获取一次 inbox 句柄,在创建 Agent 作用域前读取最后轮次,并将 inbox 回调与该轮次编号传入同步 Agent 构造过程。loop fiber 移除全局注册后,回调仍在清理期间保留其句柄。这保留既有创建与 setup 顺序,无需逐 Agent 注册或增加初始化状态。 @@ -20,6 +20,10 @@ AgentRegistry 持有轮次和步骤状态,并提供 `turnBoundaryOf(session)` ## Alternatives considered +**卸载前先 detach 自己拥有的 Session。** 按 rc.11 的卸载顺序,其他 Web 层插件在更早卸载的 fiber 里,插件重载或禁用也会碰上同样的时机,调整单个 owner 的顺序保不住所有行。 + +**只在已释放单元的 cell 位于 Session 游标时才写入检查点。** 关停时正在运行一轮的会话会在单元释放后追加 teardown 事件,因此它的这些行会再次被丢掉。 + **保留注册表级查询并延迟注销。** 这使无关插件的释放顺序依赖每个读取方的最后一次访问。保留读取句柄使清理不再依赖该顺序。 **返回同步句柄。** 这保留消费者的立即读取,却将获取 API 固定为同步初始化。等待获取过程使时序显式,同时就绪后的状态读取仍同步。 @@ -36,4 +40,4 @@ AgentRegistry 持有轮次和步骤状态,并提供 `turnBoundaryOf(session)` ## Consequences -投影所有者必须迁移获取方式并保留句柄;其他消费方使用所属服务。只要调用方持有已释放句柄,其定义和弱键单元就仍存在;句柄不会恢复全局 wire 参与。注册读取测试覆盖释放后折叠、版本替换、无关单元隔离、重复释放、并发 owner 释放以及获取期间 owner 卸载。Agent 生命周期测试覆盖清理失败、作用域创建竞态与后台子 Agent 停止。兼容测试与真实 Loader 子进程验证这些已弃用调用。 +投影所有者必须迁移获取方式并保留句柄;其他消费方使用所属服务。只要调用方持有已释放句柄,其定义和弱键单元就仍存在;句柄不会恢复全局 wire 参与。注册表自身也会保留已释放的前任及其定义闭包和弱键单元,直到同一 key 再次注册;因此重载后改名或不再注册的 key 会在进程生命周期内一直引用旧定义。注册读取测试覆盖释放后折叠、版本替换、无关单元隔离、重复释放、并发 owner 释放以及获取期间 owner 卸载。Agent 生命周期测试覆盖清理失败、作用域创建竞态与后台子 Agent 停止。兼容测试与真实 Loader 子进程验证这些已弃用调用。 diff --git a/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.i18n.yaml index 954a507a27..998bf5a661 100644 --- a/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.i18n.yaml @@ -9,8 +9,8 @@ en: 9f84c9bd85b6aace zh: 8ebb10962c219624 /agent-note-serve-session-list-rows-from-a-summary-key-set/decision: - en: 6e8f43d7a53c35fd - zh: a6e2bccc20474515 + en: aada3ca481120bff + zh: 1fa45a276a47e921 /agent-note-serve-session-list-rows-from-a-summary-key-set/alternatives-considered: en: ce8320f1032018ed zh: 4c5fad5e605bf524 diff --git a/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.md b/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.md index 89e7b2c37d..6f66a06299 100644 --- a/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.md +++ b/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.md @@ -13,10 +13,10 @@ A Session-list row carries every registered client view. Four of those views are The Session summary is every registered client view except the keys whose `SessionProjectionMap` entry carries the summary-exclusion mark. - [`packages/session/session-projection/src/types.ts`](../../../../packages/session/session-projection/src/types.ts) declares the type-only mark `SessionProjectionSummaryExcluded` over a unique symbol that is never exported and never present at runtime. `SessionProjectionViewValue` strips it distributively, so a nullable entry keeps its `null`; `SessionProjectionSummaryExcludedKey` tests the non-nullish part; `SessionProjectionSummaryValues` is every view value minus those keys. -- Each owning package wraps only its own map entry: `turnOutline` (session-turn-outline), `sessionStats` (session-stats), `todos` (tool-todo), and `imageLimits` (session-controller's boot-constant attachment limits, which every Session shares). The todo entry is `SessionProjectionSummaryExcluded | null`, because an intersection with `null` would absorb the null member. +- Each owning package wraps only its own map entry: `turnOutline` (session-turn-outline), `sessionStats` (session-stats), `todos` (tool-todo), `imageLimits` (session-controller's boot-constant attachment limits, which every Session shares), and `contextPressure` and `contextBreakdown` (token-meter). The todo entry is `SessionProjectionSummaryExcluded | null`, because an intersection with `null` would absorb the null member. - Registration carries the same decision as a plain boolean: `wire.summary` says whether a Session summary includes the unit, and omitting it means `true`. A key whose map entry carries the mark declares `false` so the runtime key set and the client view type agree. The mark itself has no runtime representation — `wire.view` and `viewSchema` are typed with `SessionProjectionViewValue`, which strips it, so the erase in `register` only widens the state type. - [`packages/session/session-projection/src/index.ts`](../../../../packages/session/session-projection/src/index.ts) exposes `summaryKeys(): ReadonlySet`, derived from the live registrations and rebuilt only when a new key registers or a key's last registrant disposes. `cachedSnapshot`, `viewCheckpoint`, and `snapshot` accept `ProjectionKeySelection` (`readonly Key[] | ReadonlySet`) and use a passed set as-is. -- [`packages/session/session-projection-cache/src/index.ts`](../../../../packages/session/session-projection-cache/src/index.ts) forwards that selection unchanged, and [`packages/api/session-controller/src/list.ts`](../../../../packages/api/session-controller/src/list.ts) passes `summaryKeys()` to the live and cold cached faces. `cachedPredecessorTitle` keeps its title-only read. +- [`packages/session/session-projection-cache/src/index.ts`](../../../../packages/session/session-projection-cache/src/index.ts) forwards that selection unchanged, and [`packages/api/session-controller/src/list.ts`](../../../../packages/api/session-controller/src/list.ts) passes `summaryKeys()` to the live registry's `snapshot` and to the cold cache read. `cachedPredecessorTitle` keeps its title-only read. - Client types only: `SessionSummary.projectionValues` narrows to `Partial`, while `SessionProjectionSnapshot.values` keeps every view value. No wire field, function, or UI name changes, and an opened Session still receives every key through `session/follow`, the control baseline, and `session.projections`. Estimated effect: with logs whose outlines dominate the projection payload, the four keys are estimated at roughly two thirds of a Session-list response for a few hundred Sessions — the outline alone near 57%, the figures and todo list near 5% together, and the shared image limits near 5% — and at roughly 85% of the same response after transport compression, where the outline's unique text barely compresses while the remaining keys do. diff --git a/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.zh.md b/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.zh.md index 75c753c345..d3212fe6ce 100644 --- a/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.zh.md @@ -13,10 +13,10 @@ Session 列表行携带每个已注册的客户端视图。其中四个视图从 Session 摘要就是每个已注册客户端视图,减去 `SessionProjectionMap` 条目带摘要排除标记的键。 - [`packages/session/session-projection/src/types.ts`](../../../../packages/session/session-projection/src/types.ts) 声明类型层标记 `SessionProjectionSummaryExcluded`,它基于一个从不导出、运行时从不存在的 unique symbol。`SessionProjectionViewValue` 以分发方式去掉标记,可空条目因此保留 `null`;`SessionProjectionSummaryExcludedKey` 取非空部分判断;`SessionProjectionSummaryValues` 是全部视图值减去这些键。 -- 每个 owning package 只包装自己的 map 条目:`turnOutline`(session-turn-outline)、`sessionStats`(session-stats)、`todos`(tool-todo),以及 `imageLimits`(session-controller 的启动常量附件上限,每个 Session 共享)。todo 条目写作 `SessionProjectionSummaryExcluded | null`,因为与 `null` 求交会吞掉 null 成员。 +- 每个 owning package 只包装自己的 map 条目:`turnOutline`(session-turn-outline)、`sessionStats`(session-stats)、`todos`(tool-todo)、`imageLimits`(session-controller 的启动常量附件上限,每个 Session 共享),以及 `contextPressure` 和 `contextBreakdown`(token-meter)。todo 条目写作 `SessionProjectionSummaryExcluded | null`,因为与 `null` 求交会吞掉 null 成员。 - 注册用普通布尔值承载同一决定:`wire.summary` 表示 Session 摘要是否包含该单元,省略即 `true`。map 条目带标记的 key 声明 `false`,让运行时键集合与客户端视图类型一致。标记本身没有运行时表示 —— `wire.view` 与 `viewSchema` 的签名已用 `SessionProjectionViewValue` 去标记,因此 `register` 里的擦除只是放宽状态类型。 - [`packages/session/session-projection/src/index.ts`](../../../../packages/session/session-projection/src/index.ts) 提供 `summaryKeys(): ReadonlySet`,从实时注册派生,只有新键注册或某键最后一个注册者注销时才重建。`cachedSnapshot`、`viewCheckpoint`、`snapshot` 接受 `ProjectionKeySelection`(`readonly Key[] | ReadonlySet`),传入 Set 时直接使用。 -- [`packages/session/session-projection-cache/src/index.ts`](../../../../packages/session/session-projection-cache/src/index.ts) 原样转发该选择,[`packages/api/session-controller/src/list.ts`](../../../../packages/api/session-controller/src/list.ts) 把 `summaryKeys()` 传给 live 与 cold 两个缓存读取面。`cachedPredecessorTitle` 保持只读 title。 +- [`packages/session/session-projection-cache/src/index.ts`](../../../../packages/session/session-projection-cache/src/index.ts) 原样转发该选择,[`packages/api/session-controller/src/list.ts`](../../../../packages/api/session-controller/src/list.ts) 把 `summaryKeys()` 传给实时注册表的 `snapshot` 与冷会话的缓存读取。`cachedPredecessorTitle` 保持只读 title。 - 客户端只有类型变化:`SessionSummary.projectionValues` 收窄为 `Partial`,而 `SessionProjectionSnapshot.values` 保留全部视图值。wire 字段、函数名与 UI 名称均不变;打开的 Session 仍通过 `session/follow`、控制基线与 `session.projections` 收到每个键。 估算效果:当 outline 在投影负载中占主导时,四个键估计占几百个 Session 时列表响应的约三分之二 —— outline 单项约 57%,统计与 todo 合计约 5%,共享的图片上限约 5% —— 占传输压缩后同一响应的约 85%,因为 outline 的唯一文本几乎无法压缩,而其余键可以。 diff --git a/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.i18n.yaml index afbff73943..056ad6e589 100644 --- a/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.i18n.yaml @@ -9,11 +9,11 @@ en: db7a81ab452fdd0f zh: a14fdcdb8ff4cd47 /agent-note-team-availability-is-selected-per-session/decision: - en: f4e4ba7be0eda796 - zh: 410649e86ff5ca72 + en: d1174db5157b91ee + zh: ceb6dc5a89a0a8d6 /agent-note-team-availability-is-selected-per-session/alternatives-considered: - en: cd3cc1f3fab84f80 - zh: 6d7792742d62f9fe + en: d50ed45e8f902281 + zh: c01d9be4b6e5df06 /agent-note-team-availability-is-selected-per-session/consequences: en: b56a6a1c8ad1aa7a zh: 43bdd8a5bccce8bf diff --git a/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.md b/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.md index 2449e1e4d0..63fb659556 100644 --- a/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.md +++ b/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.md @@ -14,12 +14,16 @@ The base bundle loads the Team service and task tools, and the Web bundle loads The model and reasoning menu owns the user’s selection. The root conversation’s Subagent panel displays members above shared tasks. Disabling Team keeps the journal and children; re-enabling restores the board. Historical task boards without a saved selection stay enabled. Profile loading removes the retired optional bundle selection, retaining row overrides. +Headless deployments may set `agent-team.defaultEnabled` to save an initial selection for a new root during startup creation. Saved selections take precedence, and resumed Sessions retain their mode even when no selection exists. Ordinary forks initialize independently; subagents share their live root’s selection. Creation identifies subagents by their Session origin because activation ownership is registered only after creation listeners settle. Task access still requires the live runtime owner. + This supersedes the explicit-composition choice in [Agent Teams](../feature/2026-08-05-agent-teams.md). Its durability, ownership, and lifecycle decisions remain active. [Optional bundles](../process/2026-09-15-shipped-optional-bundles.md) still governs other experimental features. ## Alternatives considered **Keep both switches.** Capability installation remains a prerequisite that the conversation control cannot explain or resolve; users must manage shared profile state for a Session decision. +**Apply the deployment default on resume or at read time.** Changing a profile would change an existing conversation’s tools without a saved selection. Persisting the default once at creation keeps subsequent capability decisions reconstructable from the Session log. + **Hide the plugin card without loading Team.** New profiles would have no way to obtain the capability. Loading experimental packages in the default profile would violate product isolation; moving them to their owning product groups makes the delivered support explicit. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.zh.md b/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.zh.md index 5b8baec113..e5ef16a331 100644 --- a/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.zh.md @@ -14,12 +14,16 @@ Status: implemented 模型与推理等级菜单承载用户选择。根会话的 Subagent 面板在成员下方显示共享任务。关闭 Team 保留日志与子智能体;重新开启恢复任务板。没有保存选择的历史任务板保持开启。profile 加载时移除已停用的可选 bundle 选择,保留配置行覆盖。 +无界面部署可设置 `agent-team.defaultEnabled`,在新根会话的启动创建阶段保存初始选择。已保存的选择优先,恢复的 Session 即使没有保存选择也保留原模式。普通分叉独立初始化;子智能体共享当前运行时根的选择。创建阶段通过 Session origin 识别子智能体,因为激活归属在创建监听器结束后才登记。任务访问仍要求当前运行时归属有效。 + 本决策替代 [Agent Teams](../feature/2026-08-05-agent-teams.zh.md) 中显式选择组合的部分;其持久性、归属与生命周期决策继续有效。[可选 bundle](../process/2026-09-15-shipped-optional-bundles.zh.md)仍约束其他实验性功能。 ## Alternatives considered **保留两个开关。** 安装能力仍是会话控件无法解释或解决的前置条件;用户必须为 Session 决策管理共享 profile 状态。 +**在恢复或读取时应用部署默认值。** 修改 profile 将在没有保存选择的情况下改变现有会话的工具。在创建时一次性持久化默认值,可让后续能力选择从 Session 日志重建。 + **隐藏插件卡片但不加载 Team。** 新 profile 将无法获得该能力。默认 profile 加载实验性包会违反产品隔离;将相关包移入所属产品组可明确随产品提供的支持。 ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-10-29-retained-ptc-interruption.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-29-retained-ptc-interruption.i18n.yaml new file mode 100644 index 0000000000..8333c9eff2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-29-retained-ptc-interruption.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-29-retained-ptc-interruption.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-29-retained-ptc-interruption.md +/agent-note-retained-ptc-interruption: + en: 482adc6f938bc494 + zh: 2bcc4856df4fb39e +/agent-note-retained-ptc-interruption/problem: + en: 1d2ac1280f14aa80 + zh: c5875c914cf23d73 +/agent-note-retained-ptc-interruption/decision: + en: 77f44a7ce832934c + zh: 4744934c60ca6f3d +/agent-note-retained-ptc-interruption/alternatives-considered: + en: 8c57872ffaf660b2 + zh: 4765cbf561ea5dc9 +/agent-note-retained-ptc-interruption/consequences: + en: f679322d48a740e9 + zh: 4a255df6e019a32c diff --git a/.agents/notes/implemented/architecture/2026-10-29-retained-ptc-interruption.md b/.agents/notes/implemented/architecture/2026-10-29-retained-ptc-interruption.md new file mode 100644 index 0000000000..a1376e985e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-29-retained-ptc-interruption.md @@ -0,0 +1,23 @@ +# Agent Note: Retained PTC interruption + +Status: implemented + +English | [中文](2026-10-29-retained-ptc-interruption.zh.md) + +## Problem + +Terminating a retained Node process on every timeout or cancellation loses unrelated application objects and imported modules. A control handler on the evaluation thread cannot stop a synchronous loop. The [in-memory execution decision](2026-09-23-best-effort-persistent-ptc.md) still owns disposable state and Session behavior; this decision replaces its timeout and cancellation cleanup policy. + +## Decision + +The host revokes execution authority immediately and requests cooperative stop over the existing control channel. The runtime rejects pending bindings; the original `run()` response confirms that evaluation has settled. Revocation is a one-way notification. The ordered pipe and single active evaluation determine notification ownership; RPC requests associate replies with callers. Acknowledgment permits reuse of the VM and retained objects; earlier mutations remain. Callers await acknowledgment or fallback process cleanup before starting another evaluation. + +The host bounds acknowledgment with `stopGraceMs`. Missing or failed acknowledgment terminates the managed process range and awaits cleanup. Results retain the original timeout or cancellation kind and report whether variables survived. Confirmed termination allows a fresh environment on the next call; failed cleanup blocks reuse until host release succeeds. Explicit reset, disposal and policy invalidation terminate immediately. Reset uses the existing release path and exposes no additional model tool. + +## Alternatives considered + +Unconditional termination unnecessarily discards state during ordinary asynchronous waits. Cooperative stop alone cannot bound synchronous loops. Inspector termination is unsuitable as a state-preserving default: local probes on Node 24.9 and 26.6 crashed native async-context handling when terminating an awaited VM loop with an enabled async hook; Node 24 REPL also crashed without an explicit user hook. A successful Inspector command does not establish that the runtime remains reusable. Any future forced in-process interruption must demonstrate safe recovery with the supported Node versions and async hooks. + +## Consequences + +Canceled tools throw normally, allowing user `catch` and `finally` handlers to run. A blocked handler or unresolved user Promise prevents acknowledgment and triggers fallback termination. Timers, arbitrary user Promises, I/O callbacks and user-created Workers or subprocesses have no per-evaluation cancellation guarantee and can still mutate retained state after acknowledgment. Acknowledgment confirms revocation of managed execution, not cancellation of all Node activity. Fallback termination loses all state in that process; neither path rolls back external effects. Host reset remains available when retained resources cannot be trusted. diff --git a/.agents/notes/implemented/architecture/2026-10-29-retained-ptc-interruption.zh.md b/.agents/notes/implemented/architecture/2026-10-29-retained-ptc-interruption.zh.md new file mode 100644 index 0000000000..257752abf5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-29-retained-ptc-interruption.zh.md @@ -0,0 +1,23 @@ +# Agent Note: 保留状态的 PTC 中断 + +Status: implemented + +[English](2026-10-29-retained-ptc-interruption.md) | 中文 + +## 问题 + +每次超时或取消都终止保留的 Node 进程,会丢失无关的应用对象与导入模块。执行线程上的控制处理器无法停止同步死循环。[内存执行决策](2026-09-23-best-effort-persistent-ptc.zh.md)仍负责可丢弃状态与 Session 行为;本决策替代其中的超时与取消清理策略。 + +## 决策 + +宿主立即撤销本次执行的权限,并通过现有控制通道请求协作式停止。运行时拒绝未完成的绑定,由原 `run()` 响应确认该次执行已结束。撤销是单向通知。有序管道和唯一的活动执行确定通知归属;RPC 请求将回复关联到调用方。收到确认后可复用 VM 和保留对象;此前发生的修改继续存在。调用方等待确认或兜底进程清理完成后,才能开始下一次执行。 + +宿主通过 `stopGraceMs` 限制确认等待时间。未收到确认或确认失败时,终止受管进程范围并等待清理。结果保留原来的超时或取消类型,并报告变量是否保留。确认终止后,下一次调用可以创建新环境;清理失败则阻止复用,直到宿主释放成功。显式重置、销毁和策略失效立即终止进程。重置复用现有释放路径,不向模型增加工具。 + +## 考虑过的替代方案 + +无条件终止会在普通异步等待期间丢弃本可保留的状态。仅靠协作式停止无法限制同步死循环。Inspector 终止不适合作为保留状态的默认机制:本地探针在 Node 24.9 和 26.6 启用 async hook 后,终止 await 后的 VM 死循环会导致原生异步上下文处理崩溃;Node 24 REPL 在未显式启用用户 hook 时也会崩溃。Inspector 命令成功不能说明运行时仍可复用。未来若引入进程内强制中断,必须验证支持的 Node 版本及 async hooks 下能安全恢复。 + +## 影响 + +取消的工具正常抛错,允许用户的 `catch` 和 `finally` 运行。处理器阻塞或用户 Promise 无法完成时,不能确认停止,将触发兜底终止。定时器、任意用户 Promise、I/O 回调及用户创建的 Worker 或子进程不承诺按执行取消,收到确认后仍可能修改保留状态。确认表示受管执行已撤销,不表示所有 Node 活动均已取消。兜底终止会丢失该进程中的全部状态;两条路径均不回滚外部效果。保留资源不可信时,仍可使用宿主重置。 diff --git a/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.i18n.yaml new file mode 100644 index 0000000000..f54423c675 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.i18n.yaml @@ -0,0 +1,37 @@ +# Bilingual-pair consistency record for 2026-10-29-web-provider-selection.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-29-web-provider-selection.md +/agent-note-web-provider-selection-independent-menus-over-registered-providers: + en: 2a2fa1def8a81625 + zh: 317d8b115f34e677 +/agent-note-web-provider-selection-independent-menus-over-registered-providers/problem: + en: 3f5dacf86015cebb + zh: cf0d5bceb9941554 +/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision: + en: 0682ebef17f6a5e7 + zh: 6ea399459262eb0c +/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision/registered-providers-are-observable-availability-is-not: + en: 38ce796dea91fa35 + zh: d312a9cd29fd5cc9 +/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision/independent-selection: + en: 9d68ffca5c8be681 + zh: 36bb661f98de0ebb +/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision/credential-resolution: + en: 32f59c35d71a4832 + zh: e9288313bb18b78d +/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision/defaults-and-stored-selections: + en: 5fcd3319fbceed1d + zh: 20771646b9c36fa6 +/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision/legacy-messages-search: + en: 2c7c84f8fa1e8745 + zh: 5d0244e4f17ba12a +/agent-note-web-provider-selection-independent-menus-over-registered-providers/alternatives-considered: + en: 0c227e270894cd71 + zh: 114484a3e69f5c0a +/agent-note-web-provider-selection-independent-menus-over-registered-providers/consequences: + en: 50f046bf81a7783d + zh: 5c0e27bdcaf9f12e +/agent-note-web-provider-selection-independent-menus-over-registered-providers/verification: + en: 26ade0695094e9bc + zh: 7b265c43b1886da5 diff --git a/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.md b/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.md new file mode 100644 index 0000000000..1d37c118ec --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.md @@ -0,0 +1,85 @@ +# Agent Note: Web provider selection - independent menus over registered providers + +Status: implemented + +English | [中文](2026-10-29-web-provider-selection.zh.md) + +## Problem + +The web service selects search and fetch providers through two independent fields, `web.searchProvider` and `web.fetchProvider`. A settings control that writes both at once ties fetch billing and target access policy to the search choice, and a control that offers only the DeepSeek choices it knows cannot select a provider that another plugin registers. A picker over registered providers needs the Host to report them, and the [web capability seam](2026-06-24-web-capability-seam.md) has no availability observation for it to reuse. Messages search starts an auxiliary model request for each query, whereas the [Search API](../feature/2026-10-15-deepseek-search-fetch-api.md) returns structured sources directly. The Search and Fetch APIs also need a DeepSeek credential, and a credential setting of their own makes every user reconcile it with the model provider they chat with. + +## Decision + +Users choose search and fetch providers independently from the Host's registered provider lists. The shipped composition selects the Search API for search and local HTTP fetch for fetch, and the DeepSeek Search and Fetch APIs follow the conversation's DeepSeek credential. + +### Registered providers are observable; availability is not + +`WebSearchProvider` and `WebFetchProvider` carry an optional `title`, the provider's display name, and an optional localized `description` of what the provider suits and what it costs or cannot do. `ctx.web.listProviders()` returns `{ search, fetch }`, each a registration-ordered list of `{ id, title?, description? }` entries, and the `web` Remote namespace exposes it as `web/listProviders`. Every registration and disposal emits the payload-free `web/providers-changed` event after the registry changes; the web service contains listener failures, so an observer cannot veto a registry change. [dsh-api-remotes](../../../../packages/api/remotes/README.md) mounts the namespace and forwards the event, and Clients re-read the list after each notification and reconnect. + +The lists include providers whose `available()` answers false. A picker shows what can be pinned, including a provider whose credential the user has not configured yet, whereas availability depends on per-request facts such as the Session's model route and credential state. Availability therefore stays an execution result: `search()` and `fetch()` resolve the configured provider at call time and throw the structured `WebError`, as the seam note records. The tool layer still never enumerates providers. + +### Independent selection + +The Web search and fetch page writes `web.searchProvider` from its Search menu and `web.fetchProvider` from its Fetch menu. Each menu lists every registered provider for its capability by `title` (or `id` when `title` is absent), so plugin providers appear without page changes, and a selected id that the Host does not register stays visible as such. Official providers register no `title`; the page names them from its own locale dictionary and lists them first in a fixed order: the Search API (`deepseek-search-api`) followed by Messages search (`deepseek-official`) for search, and local HTTP fetch (`http`) followed by the Fetch API (`deepseek-fetch-api`) for fetch. Other providers follow in Host registration order. Selecting the deployment's composed provider clears the stored override instead of storing a duplicate value. The [settings page README](../../../../packages/client/ui-settings-web-search/README.md) owns the page's controls. + +Every registered provider is listed; a provider has no flag that keeps it out of the menus. This note supersedes the [Search and Fetch API decision](../feature/2026-10-15-deepseek-search-fetch-api.md)'s rejection of listing every registered provider in a settings dropdown. Independent menus must offer providers that other plugins register, and the display metadata that listing needs is only the optional `title` and `description`. The description lives on the registration rather than in the page's dictionaries so that official and plugin providers explain themselves through one field, and a new provider needs no page change to describe its trade-offs. The rejected generic editor still stands: the page edits only the DeepSeek providers' settings, and other providers keep their own settings pages. + +The Search and Fetch APIs read one `web-deepseek-api` namespace, and each resolves its own credential and endpoint, as the next section records. Messages search reads `web-search-deepseek` for its independent endpoint and key reference. + +### Credential resolution + +Search and Fetch follow the DeepSeek credential of the conversation, and the settings page shows no credential control for them. The conversation's route is the `provider` of the initiating Session's latest request context, which account sign-out also reads; `ready()` uses the route of the model request being prepared, so readiness and execution resolve the same plan. + +On the `deepseek-account` route, with an active, valid `@deepseek-ai/dsh-llm-deepseek-account` Loader entry at the entry root that the model provider directory lists, the operation sends only the account token that `ctx.deepseekAccount` releases for the dispatch endpoint under that route's origin with `/v1/tools`; an HTTP 401 invalidates that token. Every other operation, including one in an account conversation without a sign-in or a token, sends the key under the `apiKeyEnv` of the active `@deepseek-ai/dsh-llm-deepseek-api-key` entry for `deepseek-official`, resolved with that package's own resolver (including `DEEPSEEK_BASE_URL`), to the origin of that route's base URL with `/v1/tools`; without that entry, `DEEPSEEK_API_KEY` goes to `https://api.deepseek.com/v1/tools`. Sending the API key in a signed-out account conversation keeps web tools available there. A user with only `DEEPSEEK_API_KEY`, including every headless, SDK, and ACP run, therefore searches and fetches by default, and a gateway or internal deployment set through the model provider's base URL serves web access at the same origin. A missing key makes the capability not ready, and an operation fails with `WEB_PROVIDER_CREDENTIAL_MISSING` and guidance naming the Models page, the reference, and the account sign-in. A mounted route entry whose settings its resolver rejects is not treated as absent: an operation that needs that route is not ready and fails with `WEB_PROVIDER_MISCONFIGURED` without reading a credential or sending the request, so a broken gateway setting never redirects requests to the official endpoint. + +A capability whose YAML sets a non-empty `searchBaseURL` or `searchApiKeyEnv` (`fetchBaseURL` or `fetchApiKeyEnv` for Fetch) instead always sends the key under its `*ApiKeyEnv` (`DEEPSEEK_API_KEY` when unset) to its `*BaseURL` (the official base when unset) and never the account token, so an intranet or third-party endpoint receives only the key chosen for it. Each capability has its own fields because a deployment can serve search and fetch from different gateways. These fields are YAML-only, and an empty value counts as unset. Stored values of fields outside the schema, including `baseURL`, `apiKeyEnv`, and `apiKey`, load without schema errors and have no effect. + +The [credential helpers](../../../../packages/llm/llm-deepseek/src/credential.ts) of `@deepseek-ai/dsh-llm-deepseek` read and validate API keys, check key presence, and release account tokens for the DeepSeek model routes, Messages search, and the Search and Fetch APIs, so every DeepSeek request reads the credential store the same way; the account model route, Messages search, and the Search and Fetch APIs also invalidate the token that an HTTP 401 rejects. Shipped profiles need no per-profile credential override, because the API-key path works wherever `DEEPSEEK_API_KEY` or the DeepSeek API-key route has a key. + +### Defaults and stored selections + +The [base composition](../../../../packages/bundle/base/cordis.patch.yml) sets `searchProvider: deepseek-search-api` and `fetchProvider: http`. Search defaults to the Search API because it returns sources without Harness starting an auxiliary model request. Fetch defaults to local HTTP fetch because it has no per-request API charge, applies Harness's own URL validation and resource limits, and reaches intranet and local addresses. The Fetch API remains one menu choice away. + +A stored selection, whether in the settings document or in a deployment's own composition, wins over the shipped default, and an upgrade does not rewrite it. A selection is never replaced at execution: when the selected provider is missing, unavailable, or fails, the operation fails with its structured error and does not fall back to another search or fetch provider. A fallback would send the query and credential to an endpoint the user did not choose and change billing without notice. + +### Legacy Messages search + +The DeepSeek Messages search provider remains available as **DeepSeek (legacy)**. A stored `web.searchProvider: deepseek-official` (or `DSH_WEB_SEARCH_PROVIDER=deepseek-official`) selects it, so users whose gateways serve Messages search but not `/v1/tools` can retain their working provider. + +The Messages provider owns and writes `web/deepseek-search-llm-request`. The declaration and payload retain compatibility with released Sessions; the Search and Fetch API package neither declares nor writes this auxiliary event. + +## Alternatives considered + +**Keep one switch for both APIs.** It lets Fetch API adoption need no configuration edit, but it ties fetch billing and target access policy to the search choice, and it cannot express a provider that another plugin registers. + +**Filter the lists by availability, or add availability to each entry.** A filtered list hides a provider while its credential is missing, right when the user needs to choose and configure it. Availability also depends on the request's model route and credential state, so a listed boolean could disagree with execution. The archived [observation-surface decision](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) allows only the smallest signal a consumer reads; the menus read registration only. + +**Keep a fixed provider catalog in the Client.** The Client would show ids the Host does not register as normal choices and would never show a provider that a plugin registers at runtime. + +**Carry the provider list in the change event.** A payload-free event leaves one read path for initial load, notifications, and reconnects, so the Client never has to order an event payload against a concurrent read. + +**Keep display names in the Client or in a separate Host registry.** The registering plugin knows its provider's name; a separate map would let a provider and its label register or dispose independently. + +**Fall back to another provider when the selected one fails.** A fallback hides gateway and credential misconfiguration, sends the query to a second service, and changes what the request costs without the user choosing it. + +**Remove Messages search and let a stored `deepseek-official` select the Search API.** Gateways can support Messages search without Search API routes. Retaining the legacy provider preserves that choice instead of changing its protocol and billing. + +**Default fetch to the Fetch API as well.** It adds a per-request charge to every fetch and delegates page retrieval and target access policy to the service, which fetches from its own side rather than from the user's machine. + +**Choose a credential source per capability on the settings page**, from a menu listing the configured DeepSeek model providers plus a custom key and base URL, without fallback between sources. The visible setting would state the whole rule, but the configuration surface is too large for users to understand: each capability adds a credential menu that the user must keep in step with the model provider they chat with, and a signed-in user chatting on the account route gets no web tools until they change it. Following the conversation's credential needs no setting, and the YAML-only own fields cover endpoints that differ from the model provider. + +**Share one custom key and base URL between Search and Fetch.** It cannot express a deployment that serves the two APIs from different gateways. + +**Fail an account conversation without a sign-in instead of sending the API key.** It would keep the account route from ever billing the API key, but signed-out account conversations would lose web tools even when a DeepSeek API key is configured. + +**Send the account token whenever the user is signed in, whatever the conversation's route.** It would bill web access to the account during conversations on the API-key route or on another provider, which the user did not choose for them. + +## Consequences + +Users without a stored search selection search through the Search API: their queries go to the Search endpoint the conversation's DeepSeek credential selects, cost a Search API request each, and send up to 128 characters of the latest human prompt as `ori_question` unless `sendOriginalQuestion` is off. Deployments whose gateway has no `/v1/tools` routes get search failures until they select Messages search or set `searchBaseURL` to a base that serves them. A stored `deepseek-official` selection uses Messages search. Users without a stored fetch selection fetch through local HTTP fetch. A signed-in user without an API key has DeepSeek web search only in conversations on the account route; the [upgrade guide](../../../../docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.md) lists each affected setup. + +A plugin provider, including one written in creator mode, appears in the matching menu as soon as it registers, under its `title`. The menus can offer a provider that cannot run for the current request; the failure appears when a search or fetch runs. Each registry change costs one forwarded event and one list read in each Client that observes it. + +## Verification + +[web.spec.ts](../../../../packages/web/web/tests/web.spec.ts) covers list contents and order, titles, events after registration and disposal, no event for a rejected duplicate, and contained listener failures. [deepseek.spec.ts](../../../../packages/web/web-search-deepseek/tests/deepseek.spec.ts) covers the independent Messages provider, and [credential-resolution.spec.ts](../../../../packages/web/web-deepseek-api/tests/credential-resolution.spec.ts) covers, for both capabilities, the account token on the account route and its invalidation after HTTP 401, the API-key fallback without a sign-in, the route key and endpoint in official, non-DeepSeek, and Session-less conversations, `DEEPSEEK_API_KEY` and the official base without that route, YAML-only own fields that never send the token, independent capabilities, readiness matching each operation, and ignored removed fields; [composition.spec.ts](../../../../packages/web/web-deepseek-api/tests/composition.spec.ts) boots the real model routes through the Loader. [remote-events.host.spec.ts](../../../../packages/api/remotes/tests/remote-events.host.spec.ts) covers event forwarding, and [base.spec.ts](../../../../packages/bundle/base/tests/base.spec.ts) pins the shipped defaults. The settings page's controller and card specs and the [plugin configuration browser scenario](../../../../apps/web/tests/plugin-config.e2e.ts) cover menu contents, independent Search and Fetch selection, the absence of credential controls for the DeepSeek APIs, and the settings each provider keeps. diff --git a/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.zh.md b/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.zh.md new file mode 100644 index 0000000000..7b9b5a75da --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.zh.md @@ -0,0 +1,85 @@ +# Agent Note: Web 提供方选择——基于已注册提供方的独立下拉框 + +Status: implemented + +[English](2026-10-29-web-provider-selection.md) | 中文 + +## 问题 + +Web 服务通过 `web.searchProvider` 和 `web.fetchProvider` 两个独立字段分别选择搜索与抓取提供方。用一个设置控件同时写入两者,会把抓取的计费和目标访问策略绑在搜索选择上;只提供已知 DeepSeek 选项的控件,也无法选用其他插件注册的提供方。基于已注册提供方的选择器需要 Host 报告这些提供方,而 [Web 能力 seam](2026-06-24-web-capability-seam.zh.md) 没有可供复用的可用性观察接口。Messages 搜索每次查询都会发起一次辅助模型请求,而 [Search API](../feature/2026-10-15-deepseek-search-fetch-api.zh.md) 能直接返回结构化来源。Search 与 Fetch API 还需要 DeepSeek 凭据;为它们单独设置凭据,会让每个用户都要把它与自己对话所用的模型提供方对齐。 + +## 决策 + +用户从 Host 已注册的提供方列表中分别选择搜索和抓取提供方。内置组合将搜索设为 Search API,将抓取设为本地 HTTP 抓取;DeepSeek Search 与 Fetch API 跟随会话的 DeepSeek 凭据。 + +### 已注册的提供方可观察,可用性不可观察 + +`WebSearchProvider` 和 `WebFetchProvider` 带有可选的 `title`(提供方的显示名称)和可选的多语言 `description`(说明该提供方适合什么、有什么代价或做不到什么)。`ctx.web.listProviders()` 返回 `{ search, fetch }`,二者均为按注册顺序排列的 `{ id, title?, description? }` 列表,`web` Remote namespace 将其暴露为 `web/listProviders`。每次注册和注销都会在注册表变更后触发无载荷的 `web/providers-changed` 事件;Web 服务会隔离监听器异常,因此观察者无法阻断注册表变更。[dsh-api-remotes](../../../../packages/api/remotes/README.zh.md) 挂载该 namespace 并转发该事件,Client 在收到通知和重连后重新读取列表。 + +列表包含 `available()` 返回 false 的提供方。选择器展示的是可固定选用的项,包括用户尚未配置凭据的提供方;而可用性取决于会话的模型路由、凭据状态等单次请求条件。因此可用性仍是执行期结果:`search()` 和 `fetch()` 在调用时解析所配置的提供方并抛出结构化 `WebError`,与 seam 记录一致。工具层仍从不枚举提供方。 + +### 分别选择 + +「网页搜索与获取」页由 Search 下拉框写入 `web.searchProvider`,由 Fetch 下拉框写入 `web.fetchProvider`。每个下拉框以 `title`(缺省时用 `id`)列出该能力的所有已注册提供方,因此插件提供方无需修改页面即可出现,且 Host 未注册的已选 id 仍会照常显示。官方提供方不注册 `title`,由页面用自己的语言字典命名,并按固定顺序排在最前:搜索依次为 Search API(`deepseek-search-api`)与 Messages 搜索(`deepseek-official`),抓取依次为本地 HTTP 抓取(`http`)和 Fetch API(`deepseek-fetch-api`)。其他提供方按 Host 注册顺序排列。选择部署组合的默认提供方会清除已保存的覆盖值,而非冗余保存一份副本。[设置页 README](../../../../packages/client/ui-settings-web-search/README.zh.md) 负责页面控件说明。 + +所有已注册的提供方都会列出;提供方没有可以把自己排除在下拉框之外的标志。本 Note 取代了 [Search 与 Fetch API 决策](../feature/2026-10-15-deepseek-search-fetch-api.zh.md)对「在设置页用下拉框列出所有已注册的提供方」的否决。独立的下拉框必须提供其他插件注册的提供方,而列出提供方所需的展示元数据只是可选的 `title` 和 `description`。说明放在注册信息里而不是页面词典里,这样官方与插件提供方都通过同一个字段说明自己,新提供方无需修改页面即可说明其取舍。被否决的通用编辑表单仍然成立:该页面只编辑 DeepSeek 提供方的设置,其他提供方保留各自的设置页。 + +Search 与 Fetch API 读取同一个 `web-deepseek-api` namespace,并各自解析自己的凭据和端点,见下一节。Messages 搜索从 `web-search-deepseek` 读取独立的端点和密钥引用。 + +### 凭据解析 + +Search 与 Fetch 跟随会话的 DeepSeek 凭据,设置页不为它们显示凭据控件。会话的路由是发起会话最近一次请求上下文的 `provider`,账号退出登录读取的也是它;`ready()` 使用正在准备的模型请求的路由,因此就绪判断与执行解析出相同的方案。 + +在 `deepseek-account` 路由上,若模型提供方目录列出了入口根部活动且有效的 `@deepseek-ai/dsh-llm-deepseek-account` Loader 条目,操作只发送 `ctx.deepseekAccount` 为该路由 origin 加 `/v1/tools` 下的请求端点给出的账号令牌;HTTP 401 会作废该令牌。其他所有操作,包括账号会话中没有登录或没有令牌的操作,都把 `deepseek-official` 的活动 `@deepseek-ai/dsh-llm-deepseek-api-key` 条目的 `apiKeyEnv` 下的密钥(用该包自己的解析函数解析,包括 `DEEPSEEK_BASE_URL`)发往该路由基础地址的 origin 加 `/v1/tools`;没有该条目时,把 `DEEPSEEK_API_KEY` 发往 `https://api.deepseek.com/v1/tools`。未登录的账号会话发送 API 密钥,网页工具因此在其中仍然可用。因此只有 `DEEPSEEK_API_KEY` 的用户(包括所有 headless、SDK 与 ACP 运行)默认即可搜索和抓取,通过模型提供方基础地址设置的网关或内部部署也在同一 origin 上提供 Web 访问。缺少密钥时,该能力未就绪,操作以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败,指引信息点名模型页面、该引用和账号登录。已挂载、但其解析函数拒绝其设置的路由条目不视为不存在:需要该路由的操作未就绪,并以 `WEB_PROVIDER_MISCONFIGURED` 失败,不读取凭据、不发送请求,因此网关设置出错时请求不会被改发到官方端点。 + +在 YAML 中设置了非空 `searchBaseURL` 或 `searchApiKeyEnv`(Fetch 为 `fetchBaseURL` 或 `fetchApiKeyEnv`)的能力,则始终把其 `*ApiKeyEnv`(未设置时为 `DEEPSEEK_API_KEY`)下的密钥发往其 `*BaseURL`(未设置时为官方基础地址),从不发送账号令牌,因此内网或第三方端点只收到为它选择的密钥。每个能力有自己的字段,因为一个部署可以由不同网关提供搜索和抓取。这些字段只能在 YAML 中设置,空值视为未设置。schema 之外字段的已保存值,包括 `baseURL`、`apiKeyEnv` 和 `apiKey`,可以加载而不产生 schema 错误,且不生效。 + +`@deepseek-ai/dsh-llm-deepseek` 的[凭据函数](../../../../packages/llm/llm-deepseek/src/credential.ts)为 DeepSeek 模型路由、Messages 搜索以及 Search 与 Fetch API 读取并校验 API 密钥、检查密钥是否存在、取得账号令牌,因此每个 DeepSeek 请求都以同一方式读取凭据库;账号模型路由、Messages 搜索以及 Search 与 Fetch API 还会作废被 HTTP 401 拒绝的令牌。内置配置不需要按 profile 覆盖凭据,因为只要 `DEEPSEEK_API_KEY` 或 DeepSeek API 密钥路由有密钥,API 密钥路径就能工作。 + +### 默认值与已保存的选择 + +[base 组合](../../../../packages/bundle/base/cordis.patch.yml)设置 `searchProvider: deepseek-search-api` 和 `fetchProvider: http`。搜索默认使用 Search API,因为它返回来源时无需 Harness 发起辅助模型请求。抓取默认使用本地 HTTP 抓取,因为它没有按次的 API 费用,执行 Harness 自身的 URL 校验和资源限制,并且可以访问内网和本地地址。Fetch API 仍可在下拉框中直接选择。 + +已保存的选择——无论位于设置文档还是部署自身的组合中——优先于内置默认值,升级不会改写它。执行时从不替换选择:所选提供方缺失、不可用或失败时,操作以结构化错误失败,不会回退到其他搜索或抓取提供方。回退会把查询和凭据发往用户未选择的端点,并在不提示的情况下改变计费。 + +### 旧版 Messages 搜索 + +DeepSeek Messages 搜索提供方仍以 **DeepSeek(旧版)**提供。已保存的 `web.searchProvider: deepseek-official`(或 `DSH_WEB_SEARCH_PROVIDER=deepseek-official`)会选中它,因此网关支持 Messages 搜索但不提供 `/v1/tools` 的用户可以保留可用的提供方。 + +Messages 提供方负责声明并写入 `web/deepseek-search-llm-request`。声明和载荷保持与已发布会话兼容;Search 与 Fetch API 包既不声明也不写入这一辅助事件。 + +## 考虑过的替代方案 + +**保留一个同时切换两个 API 的开关。** 它让使用 Fetch API 无需修改配置,但把抓取的计费和目标访问策略绑在搜索选择上,也无法表达其他插件注册的提供方。 + +**按可用性过滤列表,或在每个条目中加入可用性。** 过滤后的列表恰好会在凭据缺失时隐藏提供方,而这正是用户需要选择并配置它的时候。可用性还取决于请求的模型路由和凭据状态,列表中的布尔值可能与实际执行结果不一致。已归档的[观察面决策](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md)只允许引入消费方实际读取的最小信号;下拉框只读取注册情况。 + +**在 Client 中维护固定的提供方目录。** Client 会把 Host 未注册的 id 当作正常选项展示,且永远无法展示插件在运行时注册的提供方。 + +**在变更事件中携带提供方列表。** 无载荷事件让初次加载、变更通知和断线重连共用同一条读取路径,Client 无需为事件载荷与并发读取结果排序。 + +**把显示名称放在 Client 或单独的 Host 注册表中。** 注册插件最清楚自身提供方的名称;拆成独立映射会让提供方与其标签各自独立注册或注销。 + +**所选提供方失败时回退到其他提供方。** 回退会掩盖网关和凭据的配置错误,将查询发往第二个服务,并在未经用户选择的情况下改变请求的费用。 + +**移除 Messages 搜索,让已保存的 `deepseek-official` 选中 Search API。** 网关可能支持 Messages 搜索,却没有 Search API 路由。保留旧版提供方可以保留这一选择,而不改变其协议和计费。 + +**抓取也默认使用 Fetch API。** 它会给每次抓取增加按次费用,并把页面抓取和目标访问策略交给服务端,由服务端而不是用户的机器发起抓取。 + +**在设置页为每个能力选择一种凭据来源**,下拉框列出已配置的 DeepSeek 模型提供方,外加自定义密钥和基础地址,来源之间不回退。这样可见的设置就能说明全部规则,但配置面过大,用户难以理解:每个能力都多出一个凭据下拉框,用户必须让它与自己对话所用的模型提供方保持一致;在账号路由上对话的已登录用户在修改设置前没有网页工具。跟随会话的凭据不需要任何设置,与模型提供方不同的端点由只能在 YAML 中设置的单独字段覆盖。 + +**Search 与 Fetch 共用一个自定义密钥和基础地址。** 它无法表达由不同网关分别提供两个 API 的部署。 + +**账号会话未登录时直接失败,而不是发送 API 密钥。** 这样账号路由永远不会消耗 API 密钥,但即使配置了 DeepSeek API 密钥,未登录的账号会话也会失去网页工具。 + +**只要用户已登录就发送账号令牌,不论会话的路由。** 这会让在 API 密钥路由或其他提供方上进行的会话把 Web 访问记到账号上,而用户并没有为这些会话这样选择。 + +## 影响 + +没有已保存搜索选择的用户通过 Search API 搜索:查询发往会话的 DeepSeek 凭据所选的 Search 端点,每次计为一次 Search API 请求,并且除非关闭 `sendOriginalQuestion`,还会把最近一条用户提示的至多 128 个字符作为 `ori_question` 发送。网关没有 `/v1/tools` 路由的部署会遇到搜索失败,直到选择 Messages 搜索,或把 `searchBaseURL` 设为提供这些路由的基础地址。已保存的 `deepseek-official` 选择使用 Messages 搜索。没有已保存抓取选择的用户通过本地 HTTP 抓取。已登录但没有 API 密钥的用户只在账号路由上的会话中可以使用 DeepSeek 网页搜索;[升级指南](../../../../docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.zh.md)列出了每种受影响的配置。 + +插件提供方(包括在创作模式下编写的提供方)一经注册,就会以其 `title` 出现在对应的下拉框中。下拉框可能提供在当前请求中无法运行的提供方;失败会在执行搜索或抓取时出现。每次注册表变更会在每个观察它的 Client 中产生一次转发事件和一次列表读取。 + +## 验证 + +[web.spec.ts](../../../../packages/web/web/tests/web.spec.ts) 覆盖列表内容与顺序、标题、注册与撤销后的事件、重复注册被拒绝时不触发事件,以及监听器失败的隔离。[deepseek.spec.ts](../../../../packages/web/web-search-deepseek/tests/deepseek.spec.ts) 覆盖独立的 Messages 提供方,[credential-resolution.spec.ts](../../../../packages/web/web-deepseek-api/tests/credential-resolution.spec.ts) 针对两个能力覆盖账号路由上的账号令牌及其在 HTTP 401 后被作废、未登录时回退到 API 密钥、官方、非 DeepSeek 和会话之外的操作使用路由密钥与端点、没有该路由时使用 `DEEPSEEK_API_KEY` 与官方基础地址、从不发送令牌的 YAML 单独字段、两个能力相互独立、就绪判断与每次操作一致,以及被忽略的已移除字段;[composition.spec.ts](../../../../packages/web/web-deepseek-api/tests/composition.spec.ts) 通过 Loader 启动真实的模型路由。[remote-events.host.spec.ts](../../../../packages/api/remotes/tests/remote-events.host.spec.ts) 覆盖事件转发,[base.spec.ts](../../../../packages/bundle/base/tests/base.spec.ts) 固定内置默认值。设置页的 controller 与 card 测试以及[插件配置浏览器场景](../../../../apps/web/tests/plugin-config.e2e.ts)覆盖下拉框内容、Search 与 Fetch 的分别选择、DeepSeek API 不显示凭据控件,以及各提供方保留的设置。 diff --git a/.agents/notes/implemented/architecture/2026-11-04-unprefixed-experimental-package-names.i18n.yaml b/.agents/notes/implemented/architecture/2026-11-04-unprefixed-experimental-package-names.i18n.yaml new file mode 100644 index 0000000000..1674bd7b72 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-11-04-unprefixed-experimental-package-names.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-11-04-unprefixed-experimental-package-names.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-11-04-unprefixed-experimental-package-names.md +/agent-note-name-new-experimental-packages-without-the-experimental-prefix: + en: b84bbfa015f35105 + zh: dff2c32694314996 +/agent-note-name-new-experimental-packages-without-the-experimental-prefix/problem: + en: 84638e34c7b1d291 + zh: 0c3928b3424db016 +/agent-note-name-new-experimental-packages-without-the-experimental-prefix/decision: + en: 0f13619da3b05747 + zh: fac563c2a8b61270 +/agent-note-name-new-experimental-packages-without-the-experimental-prefix/alternatives-considered: + en: 1c6a08a813dcb3a7 + zh: 16bf31184377c80b +/agent-note-name-new-experimental-packages-without-the-experimental-prefix/consequences: + en: 2b536c42672e8850 + zh: f6a551964855308e diff --git a/.agents/notes/implemented/architecture/2026-11-04-unprefixed-experimental-package-names.md b/.agents/notes/implemented/architecture/2026-11-04-unprefixed-experimental-package-names.md new file mode 100644 index 0000000000..e6d48e0cc1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-11-04-unprefixed-experimental-package-names.md @@ -0,0 +1,29 @@ +# Agent Note: Name new experimental packages without the experimental prefix + +Status: implemented + +English | [中文](2026-11-04-unprefixed-experimental-package-names.zh.md) + +## Problem + +An experimental package named `@deepseek-ai/dsh-experimental-` must be renamed when it moves to a product group. The rename changes every import, dependency entry, module declaration, and profile file that names the package, so promotion carries a migration that is unrelated to the capability's behavior. + +## Decision + +New experimental packages use `@deepseek-ai/dsh-` and never the `experimental` prefix. Each one is declared in `EXPERIMENTAL_PACKAGE_NAME_EXCEPTIONS` in the [experimental package policy](../../../../scripts/experimental-package-policy.ts), because isolation checks and plugin-record ownership recognize an unprefixed experimental name only through that map. Promotion moves the package and removes its map entry; its npm name stays the same. + +Existing packages with the prefix are listed in `LEGACY_PREFIXED_EXPERIMENTAL_PACKAGE_DIRECTORIES`. `pnpm run constraints` rejects the prefix in any other directory and rejects a listed directory that no longer holds a prefixed package, so the list can only shrink. A legacy package is renamed when it is promoted; renaming it earlier needs the release and migration evidence that the [plugin identity decision](2026-10-06-web-desktop-plugin-identity-compatibility.md) requires. + +This decision replaces the deferred cleanup that the plugin identity decision recorded, which gave every experimental package the prefix. The [package rules](../../../../packages/experimental/AGENTS.md) state the naming rule for authors. + +## Alternatives considered + +**Keep the prefix as the default.** The name shows experimental status, but each promotion then renames the package and migrates its consumers and installed profiles. + +**Rename the legacy packages now.** One rename removes the legacy list, but it changes published names that existing dependencies and profiles use, without a promotion that needs the change. + +**Document the rule without a gate.** A new package copied from a legacy one would keep the prefix with every check passing. + +## Consequences + +The unprefixed-name map grows with every new experimental package; the legacy list only shrinks. Directory placement and the map, not the npm name, identify an unprefixed package as experimental, and an unprefixed name does not grant admission to the default product. diff --git a/.agents/notes/implemented/architecture/2026-11-04-unprefixed-experimental-package-names.zh.md b/.agents/notes/implemented/architecture/2026-11-04-unprefixed-experimental-package-names.zh.md new file mode 100644 index 0000000000..f0ea98f897 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-11-04-unprefixed-experimental-package-names.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 新实验包命名不使用 experimental 前缀 + +Status: implemented + +[English](2026-11-04-unprefixed-experimental-package-names.md) | 中文 + +## 问题 + +名为 `@deepseek-ai/dsh-experimental-` 的实验包迁入产品分组时必须重命名。重命名会改变引用该包名的每个导入、依赖项、模块声明和 profile 文件,因此晋升会附带一次与能力行为无关的迁移。 + +## 决策 + +新实验包使用 `@deepseek-ai/dsh-`,不使用 `experimental` 前缀。每个新实验包都在[实验包命名策略](../../../../scripts/experimental-package-policy.ts)的 `EXPERIMENTAL_PACKAGE_NAME_EXCEPTIONS` 中声明,因为隔离检查和插件记录归属只通过这个映射识别不带前缀的实验包名。晋升时移动该包并删除其映射条目;其 npm 名称保持不变。 + +带前缀的既有包列在 `LEGACY_PREFIXED_EXPERIMENTAL_PACKAGE_DIRECTORIES` 中。`pnpm run constraints` 拒绝其他目录中出现该前缀,也拒绝已列出但不再包含带前缀包的目录,因此该清单只能缩减。遗留包在晋升时重命名;提前重命名需要[插件身份决策](2026-10-06-web-desktop-plugin-identity-compatibility.zh.md)要求的发布与迁移依据。 + +本决策取代插件身份决策记录的暂缓清理,该清理为每个实验包加上前缀。[包规则](../../../../packages/experimental/AGENTS.md)向作者说明命名规则。 + +## 考虑过的替代方案 + +**默认保留前缀。** 名称能体现实验状态,但每次晋升都要重命名该包,并迁移其消费方和已安装 profile。 + +**现在重命名遗留包。** 一次重命名即可删除遗留清单,但会在没有晋升需要的情况下改变既有依赖和 profile 使用的已发布名称。 + +**只记录规则,不设门禁。** 从遗留包复制的新包会保留前缀,而所有检查仍会通过。 + +## 后果 + +不带前缀包名映射随每个新实验包增长;遗留清单只会缩减。识别不带前缀的包是否为实验包依据的是目录位置和该映射,而不是 npm 名称;不带前缀的名称也不因此获准进入默认产品。 diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml b/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml index 9c1ddab7b7..43557a7492 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml @@ -18,11 +18,11 @@ en: 4cd76a34f4712e1a zh: 303ef72a18644d7c /agent-note-ptc-mode-the-model-writes-typescript-against-the-tool-registry/decision/the-run-code-tool-and-the-dispatch-bridge: - en: 6a0e2698b082978e - zh: fbb03503520b30e9 + en: c3d079330748cd24 + zh: 4e2cc41d00227780 /agent-note-ptc-mode-the-model-writes-typescript-against-the-tool-registry/decision/observability-tool-ptc-dispatch: - en: b8e4cf9433c7ee18 - zh: e5f4e8d7d03d3a7b + en: 5423da23d4e8d1d9 + zh: f1198d4f224f3fe6 /agent-note-ptc-mode-the-model-writes-typescript-against-the-tool-registry/decision/the-ptc-runtime-seam: en: 4c423f4e9cef6055 zh: 5c23a3637eb420c0 diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.md b/.agents/notes/implemented/feature/2026-06-15-ptc.md index e99eccd65b..8e12a2db3e 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.md +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.md @@ -56,13 +56,15 @@ Under `'ptc'` the registry owns `run_code` as a reserved presentation transport **Concurrency is bounded, not serialized.** Each run owns a dispatch queue that starts calls strictly in submission order and classifies each one through `registry.executionMode`, the same fail-closed `isConcurrencySafe` contract the native loop uses. Consecutive parallel-classified calls overlap up to `maxParallelSubCalls` (default 10; `1` restores serial dispatch); an exclusive call drains the pool and runs alone. Settlement abandons queued calls that have not started. This note shipped the serialized placeholder; the [live-parallel reference](../../../../packages/core/tools/README.md) owns the scheduler that replaced it. +**Tool-call allowance.** Counting attempts before validation and queuing bounds loops that repeatedly fail or enqueue work. The allowance scales with the resolved timeout and permits bursts; it does not enforce a rolling window. Exhaustion uses the run-scoped abort path so a program cannot catch a binding failure and keep running. Active calls are canceled, queued calls are abandoned, and settlement waits for cleanup. Completed effects remain; a new run receives a fresh allowance. The [executor README](../../../../packages/core/tool-executor-ptc/README.md#use-this-package) owns configuration and failure reporting. + **Presentation.** `run_code`'s render intent is decided here per the [render-intent Agent Note](../architecture/2026-07-02-tool-render-intent-union.md): `presentCall` creates a `generic` card with `kind: 'execute'`, the program text as its title, and the same program text as `rawInput`; `run_code` intentionally declares no `presentResult`, so the TUI and host/client runtime (Web) complete that card through their generic raw-content fallback using the final durable `tool/result.content`, including captured logs plus the returned value, failure, or post-policy spill preview. This is not a `terminal` card: that card's semantics are "a shell command in a working directory", which a program is not. See the [result-card completeness note](../../archived/bug-fix/2026-07-20-code-mode-result-card-completeness.md). ### Observability: `tool/ptc-dispatch` Each sub-dispatch appends a log-only `tool/ptc-dispatch-start` event at pool entry and a `tool/ptc-dispatch` settle event containing parent and child call ids, tool identity, normalized arguments, and the complete rendered `content`/`isError` outcome. It remains outside model history but available to persistence and UIs. Appends occur inside the open `run_code` turn. Direct executions without an agent still run but cannot log the event. -New sub-calls use `:ptc:` ids, numbered in submission order. All call ids are opaque to consumers: migration preserves every historical id byte-for-byte, including `:code:` substrings, so dispatch pairs, spill references, and other correlations remain intact. The bridge attributes forwarded image context to `{ kind: 'plugin', plugin: 'tools-ptc' }`. +New sub-calls use `:ptc:` ids, numbered by tool-call attempt. Invalid arguments consume a number without a dispatch event, so logged ids may have gaps. All call ids are opaque to consumers: migration preserves every historical id byte-for-byte, including `:code:` substrings, so dispatch pairs, spill references, and other correlations remain intact. The bridge attributes forwarded image context to `{ kind: 'plugin', plugin: 'tools-ptc' }`. The [V2-to-V3 PTC specification](../../../../packages/session/session-format-v2-to-v3/README.md#ptc-vocabulary) owns exact historical tag and attribution conversion; [native V3 admission](../../../../packages/session/session-format-v2-to-v3/README.md#native-v3-admission) owns predecessor-tag refusal. These are not runtime aliases: an opaque extension must not acquire PTC lifecycle meaning merely through a version change. diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md b/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md index 71efafa7f2..813a795888 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md @@ -56,13 +56,15 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 **并发是有界的,而非被序列化。** 每次 run 拥有一个分发队列,严格按提交顺序启动调用,并通过 `registry.executionMode` 对每个调用分类——与原生循环所用的 fail-closed `isConcurrencySafe` 约定相同。连续的 parallel 类调用最多重叠 `maxParallelSubCalls` 个(默认 10;设为 `1` 恢复串行分发);exclusive 类调用会排空池并单独运行。结算时放弃尚未开始的排队调用。本 note 交付的是被序列化的占位实现;取代它的调度器由[实时并行 参考](../../../../packages/core/tools/README.zh.md) 负责。 +**工具调用额度。** 在验证和排队之前对尝试计数,可以限制重复失败或持续入队的循环。额度随最终超时时间缩放,允许集中调用,不实施滑动窗口限制。额度耗尽时使用运行级取消路径,使程序无法捕获绑定错误后继续运行。活动调用被取消,排队调用被放弃,结算会等待清理完成。已完成的操作仍然生效;新运行获得完整额度。[执行器 README](../../../../packages/core/tool-executor-ptc/README.zh.md#use-this-package) 负责配置与失败报告。 + **呈现。** `run_code` 的 render intent 按[呈现意图 Agent Note](../architecture/2026-07-02-tool-render-intent-union.zh.md)在此决定:`presentCall` 创建一个 `generic` 卡片,`kind: 'execute'`,以程序文本作为标题,并将同一程序文本作为 `rawInput`;`run_code` 有意不声明 `presentResult`,因此 TUI 和宿主/客户端运行时(Web)会通过通用原始内容回退机制,使用最终持久化的 `tool/result.content` 补全该卡片,其中包括捕获的日志,以及返回值、失败信息或 post-policy spill 预览。这不是 `terminal` 卡片:该卡片的语义是「工作目录中的 shell 命令」,程序不是。参见[结果卡片完整性说明](../../archived/bug-fix/2026-07-20-code-mode-result-card-completeness.md)。 ### 可观测性:`tool/ptc-dispatch` 每次子分发在进入分发池时追加一个仅日志的 `tool/ptc-dispatch-start` 事件,并以一个 `tool/ptc-dispatch` 结算事件收尾,后者包含父子 call id、工具标识、规范化参数以及完整渲染后的 `content`/`isError` 结果。它不进入模型历史,但可供持久化和 UI 使用。追加发生在开放的 `run_code` 轮次内。没有 agent 的直接执行仍然运行,但无法记录该事件。 -新子调用使用 `:ptc:` 标识,按提交顺序编号。消费者将所有 call id 视为不透明值:迁移逐字节保留每个历史标识,包括 `:code:` 子串,因此分发事件对、spill 引用及其他关联保持完整。桥接层将转发图片上下文的来源标记为 `{ kind: 'plugin', plugin: 'tools-ptc' }`。 +新子调用使用 `:ptc:` 标识,按工具调用尝试编号。无效参数会占用编号而不产生分发事件,因此日志中的编号可能有空缺。消费者将所有 call id 视为不透明值:迁移逐字节保留每个历史标识,包括 `:code:` 子串,因此分发事件对、spill 引用及其他关联保持完整。桥接层将转发图片上下文的来源标记为 `{ kind: 'plugin', plugin: 'tools-ptc' }`。 [V2 到 V3 PTC 规范](../../../../packages/session/session-format-v2-to-v3/README.zh.md#ptc-vocabulary)负责精确的历史标签与归属转换;[原生 V3 准入](../../../../packages/session/session-format-v2-to-v3/README.zh.md#native-v3-admission)负责前代标签拒绝。这些不是运行时别名:不透明扩展不能仅因版本变化就获得 PTC 生命周期含义。 diff --git a/.agents/notes/implemented/feature/2026-08-31-mcp-manager-plugin.i18n.yaml b/.agents/notes/implemented/feature/2026-08-31-mcp-manager-plugin.i18n.yaml new file mode 100644 index 0000000000..acba92a4e3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-31-mcp-manager-plugin.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-08-31-mcp-manager-plugin.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-31-mcp-manager-plugin.md +/agent-note-settings-driven-mcp-server-manager: + en: 56c40f7db118247c + zh: 3b3c596983cd48e1 +/agent-note-settings-driven-mcp-server-manager/problem: + en: 7115cbaefaa11d2d + zh: 23aff8797a651a36 +/agent-note-settings-driven-mcp-server-manager/decision: + en: 39cf3ae7e8a5799f + zh: 7a26503da73e238f +/agent-note-settings-driven-mcp-server-manager/alternatives-considered: + en: daad4acd963926cc + zh: 152f3d35a4fe3309 +/agent-note-settings-driven-mcp-server-manager/testing: + en: cbfd30400093ade5 + zh: 018ad32e25c6c7fc +/agent-note-settings-driven-mcp-server-manager/consequences: + en: 2a11ba309122335a + zh: 663670e2bf3c5a37 diff --git a/.agents/notes/implemented/feature/2026-08-31-mcp-manager-plugin.md b/.agents/notes/implemented/feature/2026-08-31-mcp-manager-plugin.md new file mode 100644 index 0000000000..653be051a6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-31-mcp-manager-plugin.md @@ -0,0 +1,63 @@ +# Agent Note: Settings-driven MCP server manager + +Status: implemented + +English | [中文](2026-08-31-mcp-manager-plugin.zh.md) + +## Problem + +One MCP server was one `dsh-mcp-client` row in `cordis.yml`. That is right for composition-pinned servers and wrong for the product goal of managing servers from a Web surface: adding, editing, testing, enabling, disabling, deleting, and recovering servers at runtime, with connection status visible per server. The client plugin exposed no status beyond log lines, activation inherited an unbounded SDK startup wait, and there was no way to test a configuration without mounting it. A survey of six peer agent products (Claude Code, Codex, OpenCode, Kimi Code, DeepSeek-Reasonix, Pi) converged on the same shape: a config registry, a management facade, and a connection-state machine with a status-to-action mapping, secrets never round-tripping to the surface. + +Installed application plugins also bring their own servers. Each must add its server without editing the user's configuration or depending on another application, and disabling or uninstalling the plugin must withdraw the server's tools. + +## Decision + +`packages/mcp/mcp-manager` is a service plugin (`ctx.mcpManager`) that owns the volatile `servers` configuration and reconciles a fleet of ordinary `dsh-mcp-client` child instances; `packages/mcp/mcp-client` owns connection supervision, status, probing, and per-attempt startup budgets. + +**Configuration is volatile Config, and CRUD needs no new API.** Settings projects the manager entry’s Config under its Loader id; ConfigEditor owns profile persistence, inheritance, and revision checks. Settings edits retain inherited servers, and resetting an inherited entry restores its base value. Direct profile patches replace the complete `servers` map. Loader commits the reference before notifying the manager’s existing reconcile queue. + +**Plugins register servers as effects.** `register(name, entry)` adds one server for the calling plugin's lifetime and returns the effect disposer; the entry takes every configured field except `enabled` and passes the same validation. The plugin manager's existing enable, disable, and uninstall operations therefore decide when a plugin's server exists, and the registration's disposal awaits the instance's unmount so the tools are gone when the plugin's disposal settles. Registered and configured names share one namespace: a registration refuses a configured name, and a settings write refuses a registered name. Registrations stay out of Settings. + +**A flat entry schema, validated cross-field.** The entry is flat with a `transport` discriminant, and write-time validation enforces the per-transport requirements and rejections (missing `command`/`url`, cross-transport fields, malformed templates) at the write — a schema-valid-but-unserviceable entry is never stored, and refusals name the offending field where a union's shape error would not. + +**Children are plugin instances, not library calls.** Each enabled entry mounts `dsh-mcp-client` under the manager's fiber via `ctx.plugin()`. That reuses the tested activation/disposal semantics, makes `serverName` reservations collide correctly with statically composed instances (surfaced as a `failed`/`load` status with the actionable message), and lets manager disposal unwind every instance and its tools through ordinary fiber teardown. + +**Whole-entry identity; `enabled` controls mounting.** A changed entry rebuilds the child instance; disabling or deleting it disposes the child. Reconciliation and `restart` serialize on one operation tail. Probes run independently, and configuration changes cancel affected probes before queued cleanup waits for them. The manager retains a failed cleanup record and refuses a replacement until the Host restarts, preventing overlapping server lifetimes. + +**Status belongs to the child Fiber.** The supervisor publishes a cell read through `connectionStatusOf(fiber)`. It survives reloads of that Fiber and remains readable after disposal, which detaches observers. Fiber identity distinguishes instances across registration scopes without identity tokens in status payloads. The manager aggregates cells into `list()` and the `mcp-manager/status` event, absorbing equal statuses and remaining silent after disposal. Removed entries with incomplete cleanup remain listed with `configured: false`. The first reconcile waits for the manager’s own Fiber activation because strict service reads require an active Fiber. + +**Templates keep secrets out of configuration.** Stored env and header values remain readable; a value that must stay hidden belongs in a template. The manager passes raw templates into the child Fiber. Opt-in mcp-client expansion resolves `${credential:REF}` from the current provider, whose local implementation also reads the inherited environment, so there is no separate environment scheme. Missing values leave non-strict clients waiting for credentials; referenced credential notifications can recover them. A changed value replaces the connection only after cleanup succeeds, while a same-value notification preserves the connection and retry budget. Diagnostics, tool-call failures, and `isError` results keep upstream text with every resolved value replaced by `[redacted]`, so the model can act on a server's error without seeing a secret the instance sent. + +**mcp-client budgets and probe.** `startupTimeoutMs` is optional for standalone clients; the manager explicitly supplies a 30-second default. `probeServer(ctx, config, options)` connects, reads server identity, lists tool names, and closes without reserving a name or registering tools. Its own budget and configurable listing caps are independent of runtime startup. Probe success and transport closure are separate results, so successful discovery cannot hide incomplete cleanup. + +**The manager is its own Remote surface.** `McpServerManager` is a Typert Remote service: `watch` streams the complete server list on subscription and after every status or entry change, and `restart` and `probe` call the Host operations. Configuration writes remain on the Settings Remote, and the manager exposes no write method, so a browser cannot define a server command. + +OAuth is deliberately out of scope: the credential-record and authorization seams plus the MCP SDK's `OAuthClientProvider` are the intended home, and a `needs-auth` status state is reserved for that change. + +## Alternatives considered + +**Manage each server as a separate Loader entry.** Rejected: the manager needs one server list with status, restart, and probe. Settings and ConfigEditor already edit its dictionary with inheritance, validation, and revision checks; the runtime child instances need no separate persisted entries. + +**Call `startConnection()` as a library instead of mounting child plugins.** Rejected: it forks the lifecycle from statically composed instances, loses the reservation collision check, and re-implements activation/disposal quiescence the plugin already proves under test. + +**Let plugins write their entry into the manager's Settings namespace.** Rejected: bundle patches replace the whole `servers` map, so two plugins cannot each ship an entry; a plugin must discover the manager's namespace and revision; a settings write returns before reconciliation, so the plugin restarts the entry to wait and mounts it twice; and uninstalling the plugin leaves an enabled entry whose tools stay live. + +**Forward `mcp-manager/status` to clients instead of streaming lists.** Rejected: the Remote event carrier forwards only `emit` events, which do not contain listener failures, and a client would read `list()` after each event to see removals and authorization state. + +**Broadcast per-instance status as a Cordis event from mcp-client.** Rejected: preset-scoped instances can share names, requiring identity tokens in event payloads. A Fiber-owned handle identifies the instance directly; the manager supplies the single aggregated event. + +**Poll status from the management surface.** Rejected: the Remote plane already forwards Host events; peers that shipped polling (Reasonix) or lacked a status event (OpenCode v1) treat it as a defect to fix. + +**A capability seam (Definition/Provider/Consumer) for "MCP management".** Rejected: one implementation and no independently evolving roles; a seam without a second provider is structure without an owner. + +## Testing + +mcp-client unit tests cover status transitions, independent startup and probe budgets, template binding and value redaction, same-Fiber reloads, final readable status, observer containment, cancellation, and separate close and cleanup outcomes. Manager tests use real Cordis, profile-backed Settings, and an in-memory credential provider to cover configuration changes, plugin registrations and their name collisions, credential rotation, probe cancellation, the `watch` stream, failed-cleanup retention, and deletion or disposal during pending work. Real composition tests verify inherited servers, profile patches, persisted settings edits, and write-time validation. Keyless e2e exercises real stdio and HTTP servers, including probes and crash recovery. Both packages meet the per-file 100% coverage gate. + +## Consequences + +- Management operations use Settings for configured add/edit/delete/enable/disable, registrations for plugin-owned servers, `list()`, `watch`, and one event for status, and `probe` for testing. Reconnect and `restart` recover connection failures; incomplete cleanup requires a Host restart. +- Liveness relies on transport closure and request failures; a Streamable HTTP server that stalls without closing is noticed when a request fails. +- A rebuilt server’s tools remain unavailable until the replacement completes its initial sync. Replacement waits for confirmed transport closure and successful local cleanup. +- The manager governs the global plane only; preset-scoped and static instances stay outside its list. +- `startupTimeoutMs` bounds startup attempts, while notification-driven re-sync requests still ride the SDK's request default — recorded as an mcp-client limitation. diff --git a/.agents/notes/implemented/feature/2026-08-31-mcp-manager-plugin.zh.md b/.agents/notes/implemented/feature/2026-08-31-mcp-manager-plugin.zh.md new file mode 100644 index 0000000000..875e6d3bd9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-31-mcp-manager-plugin.zh.md @@ -0,0 +1,63 @@ +# Agent Note: Settings-driven MCP server manager + +Status: implemented + +[English](2026-08-31-mcp-manager-plugin.md) | 中文 + +## 问题 + +一台 MCP 服务器就是 `cordis.yml` 里的一条 `dsh-mcp-client` 行。这对组合钉死的服务器是对的,但与产品目标相悖:要从 Web 界面在运行时添加、修改、测试、启用、停用、删除并恢复服务器,且每台服务器的连接状态可见。客户端插件除日志外没有任何状态出口,激活继承了无界的 SDK 启动等待,也没有任何"不挂载就测试一份配置"的途径。对六个同类 agent 产品(Claude Code、Codex、OpenCode、Kimi Code、DeepSeek-Reasonix、Pi)的调研收敛到同一形态:配置注册表、管理门面、带"状态到动作"映射的连接状态机,密钥绝不回读到界面。 + +已安装的应用插件也会带来自己的服务器。每个插件都必须在不编辑用户配置、不依赖其他应用的前提下加入服务器,停用或卸载插件时还必须撤回该服务器的工具。 + +## 决策 + +`packages/mcp/mcp-manager` 是一个服务插件(`ctx.mcpManager`),拥有 volatile `servers` 配置并对一支普通 `dsh-mcp-client` 子实例编队做对账;`packages/mcp/mcp-client` 拥有连接监督、状态、探测与逐次启动预算。 + +**配置由 volatile Config 提供,CRUD 不需要新 API。** Settings 按管理器条目的 Loader id 展示其 Config;ConfigEditor 拥有 profile 持久化、继承和 revision 检查。Settings 编辑保留继承的服务器,重置继承条目会恢复其基层值。直接编写 profile patch 会替换完整 `servers` 映射。Loader 先提交引用,再通知管理器现有的协调队列。 + +**插件以副作用注册服务器。** `register(name, entry)` 为调用方插件的生命周期加入一台服务器,并返回该副作用的释放函数;条目接受除 `enabled` 外的全部配置字段,并通过相同校验。因此插件管理器已有的启用、停用和卸载操作决定插件的服务器何时存在;注册的释放会等待实例卸载,插件的释放完成时工具已撤回。注册名与配置名共用一个命名空间:注册拒绝已配置的名字,设置写入拒绝已注册的名字。注册不进入 Settings。 + +**扁平条目 schema,跨字段校验。** 条目是带 `transport` 判别字段的扁平结构,写入期校验在写入处强制逐传输要求与拒绝(缺 `command`/`url`、跨传输字段、模板写错)——schema 合法但不可执行的条目永远不会被存下,且拒绝会点名出错字段,union 的形状错误做不到。 + +**子实例是插件实例,不是库调用。** 每个启用条目经 `ctx.plugin()` 在管理器 fiber 下挂载 `dsh-mcp-client`。这复用了经过测试的激活/销毁语义,让 `serverName` 预留与静态组合实例正确冲突(以带可操作消息的 `failed`/`load` 状态浮出),并让管理器销毁经普通 fiber 收卷解除每个实例及其工具。 + +**整条目决定实例;`enabled` 控制挂载。** 条目变化会重建子实例,停用或删除条目会销毁子实例。对账与 `restart` 在同一操作队列中串行执行。探测独立运行,配置变更先取消受影响的探测,再由队列中的清理操作等待它们结束。管理器保留清理失败记录,并在 Host 重启前拒绝替换,防止服务器生命周期重叠。 + +**状态属于子 Fiber。** 监督器发布经 `connectionStatusOf(fiber)` 读取的状态单元。同一 Fiber 重载时保留该单元,最终销毁时移除观察者,但单元仍可读取。Fiber 身份直接区分不同注册作用域中的实例,无需在状态载荷中加入身份令牌。管理器聚合为 `list()` 和 `mcp-manager/status` 事件,吸收相等状态并在销毁后停止发出事件。配置已删除但清理未完成的条目仍以 `configured: false` 列出。首次对账等待管理器自身 Fiber 激活,因为严格服务读取要求 Fiber 已激活。 + +**模板让密钥不进入配置。** 存储的 env 和 header 值仍可读,必须隐藏的值应使用模板。管理器把原始模板传入子 Fiber。mcp-client 显式启用模板展开后,从当前 provider 解析 `${credential:REF}`;其本地实现也读取继承的环境变量,因此没有单独的环境变量模板。缺失值使非严格客户端等待凭据,相关凭据通知可恢复连接。值变化时,连接只有在清理成功后才会替换;同值通知保留当前连接和重试预算。诊断、工具调用失败与 `isError` 结果保留上游文本,其中每个已解析的值都替换为 `[redacted]`,模型因此能依据服务器的错误行动,又看不到实例发送过的密钥。 + +**mcp-client 预算与探测。** 独立客户端的 `startupTimeoutMs` 可省略,管理器明确提供 30 秒默认值。`probeServer(ctx, config, options)` 连接、读取服务器身份、列出工具名并关闭,不预留名称,也不注册工具。探测有独立于运行期启动的预算和可配置的列表上限。探测成功与传输关闭分别报告,因此发现成功不会掩盖清理未完成。 + +**管理器自身即 Remote 面。** `McpServerManager` 是 Typert Remote 服务:`watch` 在订阅时以及每次状态或条目变化后推送完整服务器列表,`restart` 与 `probe` 调用宿主操作。配置写入仍走 Settings Remote,管理器不提供写入方法,因此浏览器无法定义服务器命令。 + +OAuth 刻意不在本次范围内:凭据记录与 authorization seam 加 MCP SDK 的 `OAuthClientProvider` 是预期归宿,`needs-auth` 状态位为那次变更保留。 + +## 曾考虑的替代方案 + +**把每台服务器作为独立 Loader 条目管理。** 否决:管理器需要包含状态、restart 与 probe 的统一服务器列表。Settings 与 ConfigEditor 已经为其字典提供继承、校验和 revision 检查;运行期子实例不需要独立持久化条目。 + +**把 `startConnection()` 当库调用而不挂子插件。** 否决:这会让生命周期与静态组合实例分叉,丢掉预留冲突检查,还要重造插件已在测试下证明的激活/销毁静默。 + +**让插件把条目写进管理器的 Settings 命名空间。** 否决:bundle patch 会替换整个 `servers` 映射,两个插件无法各自带一个条目;插件需要反查管理器的命名空间与 revision;设置写入在对账前返回,插件只能重启条目来等待,导致重复挂载;卸载插件后还会留下仍启用的条目,其工具继续生效。 + +**把 `mcp-manager/status` 转发给客户端,而不是推送列表。** 否决:Remote 事件载体只转发 `emit` 事件,而 `emit` 不隔离监听器失败;客户端还需要在每次事件后读取 `list()` 才能看到移除与授权状态。 + +**让 mcp-client 以 Cordis 事件广播逐实例状态。** 否决:preset 作用域实例可以重名,迫使事件载荷携带身份令牌。Fiber 所有的句柄直接识别实例,管理器提供唯一的聚合事件。 + +**让管理界面轮询状态。** 否决:Remote 面已转发宿主事件;发货了轮询的同类产品(Reasonix)或缺状态事件的(OpenCode v1)都把它当作待修缺陷。 + +**为「MCP 管理」开 capability seam(Definition/Provider/Consumer)。** 否决:只有一个实现、没有独立演化的角色;没有第二个 provider 的 seam 是无主的结构。 + +## 测试 + +mcp-client 单测覆盖状态迁移、独立的启动与探测预算、模板绑定与值替换、同一 Fiber 重载、销毁后可读状态、观察者异常遏制、取消,以及独立的关闭和清理结果。管理器测试使用真实 Cordis、profile 支持的 Settings 和内存 credential provider,覆盖配置变化、插件注册及其名字冲突、凭据轮换、探测取消、`watch` 流、清理失败保留,以及待完成工作期间的删除或销毁。真实组合测试验证继承服务器、profile patch、持久化设置编辑和写入期校验。无密钥 e2e 验证真实 stdio 和 HTTP 服务器,包括探测与崩溃恢复。两个包均满足逐文件 100% 覆盖率门禁。 + +## 后果 + +- 管理操作通过 Settings 对配置条目增/改/删/启/停,通过注册加入插件拥有的服务器,通过 `list()`、`watch` 和一条事件获取状态,通过 `probe` 测试。重连和 `restart` 恢复连接失败;清理未完成时需要重启 Host。 +- 存活判断依赖传输关闭与请求失败;停滞但不关闭的 Streamable HTTP 服务器要等到请求失败时才会被发现。 +- 被重建服务器的工具在替换连接完成首次同步前不可用。替换会等待传输确认关闭且本地清理成功。 +- 管理器只治理全局面;preset 作用域与静态实例不在其列表内。 +- `startupTimeoutMs` 只为启动尝试设界,通知驱动的重同步请求仍走 SDK 请求默认值——已记录为 mcp-client 的限制。 diff --git a/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.i18n.yaml b/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.i18n.yaml index fc9f88a600..75d71a51fc 100644 --- a/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.i18n.yaml @@ -19,7 +19,7 @@ zh: 993378f78f5a4120 /agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/two-driving-tiers: en: 0b9303333bf482ae - zh: 6dfc68ad9e2924c3 + zh: 8a2ffe660bbe8730 /agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/default-visibility: en: 754705367c350b31 zh: e10b6541458c4a45 diff --git a/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.zh.md b/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.zh.md index 871eb3fe91..c5437202b5 100644 --- a/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.zh.md +++ b/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.zh.md @@ -32,7 +32,7 @@ owner 在 DSH 里为某一个会话创建分享。分享发布到 Liveshare 服 每个分享带一个 `ViewerSessionMode`,只有两个值,且两档都会驱动共享会话: -- **view** —— 读者通过普通 prompt 路径驱动共享会话的 loop,在授予工作区的只读沙箱里运行,因此会话的工具绝不写文件。读取与检索面(`read`、`read_image`、`glob`、`grep`、`present`)、无本地文件副作用的工具(`todo_write`、`skill`、`web_search`、`web_fetch`)都限制在授权范围内:工具运行前检查其声明的每个路径,无法检查的调用一律拒绝。 +- **view** —— 读者通过普通 prompt 路径驱动共享会话的 loop,在授予工作区的只读沙箱里运行,因此会话的工具绝不写文件。读取与检索面(`read`、`read_image`、`glob`、`grep`、`present`)、无本地文件副作用的工具(`todo_write`、`skill`、`web_search`、`web_fetch`)都限制在授权范围内:工具运行前检查其声明的每个路径,无法检查的调用一律拒绝。两档也都准入 PTC 传输工具 `run_code`:程序运行在所有者自己的常驻文件策略下而不是分享档位下,因此可以触碰授权范围之外的文件,而其嵌套调用仍保留同样的守卫与路径检查;分享对话框会就此向所有者提示。 - **write** —— 读者在受宿主写入上限约束的可写沙箱里驱动同一个 loop,沙箱也会修改授予工作区内的文件。 沙箱安装在会话上,但只约束分享查看者发起的轮次:`agent/pre-step` 记录一个已准入轮次的进入输入是否带有 share-viewer principal,守卫与替代定义对该轮次的每次执行读取这一结论。所有者自己发起的轮次保留会话自己的工具;所有者在查看者轮次中追加的输入仍留在该受限轮次内。该标记只存在于 Host 内存,按轮次重算,不写入会话日志。所有分享停止或全部过期后执行层被释放,会话恢复自己的工具。 diff --git a/.agents/notes/implemented/feature/2026-10-09-forge-panel.i18n.yaml b/.agents/notes/implemented/feature/2026-10-09-forge-panel.i18n.yaml index 53d2b74f3a..f779b7be73 100644 --- a/.agents/notes/implemented/feature/2026-10-09-forge-panel.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-10-09-forge-panel.i18n.yaml @@ -9,8 +9,8 @@ en: 344bcc7e9825bbc8 zh: c1d0f4b54f5b3786 /agent-note-change-request-panel-and-worktree-sessions/decision: - en: 6a0dc836205863ba - zh: 3d4feb0385953dda + en: 66bde24e7c601eb6 + zh: 5091591f3dc1ac85 /agent-note-change-request-panel-and-worktree-sessions/deferred: en: 32ef8af85f4d2f66 zh: b82b67f94346399e @@ -18,5 +18,5 @@ en: e471e16b6b6450ca zh: 5fbc9242edc8d112 /agent-note-change-request-panel-and-worktree-sessions/consequences: - en: 81790c448f0a7d3b - zh: ec63ccb2ad315ff8 + en: 0b785a2204963c4f + zh: be4bd8db40b801a1 diff --git a/.agents/notes/implemented/feature/2026-10-09-forge-panel.md b/.agents/notes/implemented/feature/2026-10-09-forge-panel.md index 1285ba6cdf..2b852ad824 100644 --- a/.agents/notes/implemented/feature/2026-10-09-forge-panel.md +++ b/.agents/notes/implemented/feature/2026-10-09-forge-panel.md @@ -10,7 +10,9 @@ English | [中文](2026-10-09-forge-panel.zh.md) ## Decision -[`dsh-experimental-client-ui-forge`](../../../../packages/experimental/client-ui-forge/README.md) is a two-face package. Its Host half, the `forgePanel` Remote, assembles everything the panel shows for one Session into one streamed state; the forge registry and the binding service keep only their domain operations. Its Client half is a right-sidebar tab, a Session header button that shows or hides it, a New Session option, a creation card above the composer, a toast, and the [Session list icons and hover card section](2026-10-20-forge-session-list.md). Every surface calls a bound item a change request, the forge registry's term, whatever its code host calls it. The [forge bundle](../../../../packages/experimental/forge-bundle/README.md) ships the panel, the forge registry, the GitHub provider, the bindings, and an isolated worktree service as one default-disabled Plugins page entry. +[`dsh-experimental-client-ui-forge`](../../../../packages/experimental/client-ui-forge/README.md) is a two-face package. Its Host half, the `forgePanel` Remote, assembles everything the panel shows for one Session into one streamed state; the forge registry and the binding service keep only their domain operations. Its Client half is a right-sidebar tab, a Session header button that shows or hides it, a New Session option, a creation card above the composer, a toast, and the [Session list icons and hover card section](2026-10-20-forge-session-list.md). Every surface calls a bound item a change request, the forge registry's term, whatever its code host calls it. The [forge bundle](../../../../packages/experimental/forge-bundle/README.md) ships the panel, the forge registry, the GitHub provider, the bindings, the worktree model tool, and an isolated worktree service for the panel as one default-disabled Plugins page entry. + +Review and worktree controls serve one code-change workflow. One Plugins entry lets users enable that workflow without having to distinguish the underlying package split. Packages and the two worktree services remain independent so each owns its before-Session or in-conversation creation flow. The Remote inspects the checkout through the Session's subprocess provider under its [resolved permissions and query restrictions](../bug-fix/2026-10-28-forge-git-session-permissions.md), so it reads what the Session's commands see. Inspection reads the top-level, Git, and common Git directories, the branch or the abbreviated HEAD commit, the upstream, the remotes, and the size of the branch's changes against its fork point. It runs when a Session is followed, after each `working-directory/change`, `tool/result`, and `sandbox/mode` event, and on refresh, one at a time with one trailing rerun. @@ -89,4 +91,4 @@ The changes view offers no scope for one commit or for the last turn yet; both w ## Consequences -The panel shows current status only while it is open; checks that finish while nothing is mounted produce no notification. Changes made outside the Session appear after Refresh or the next tool result. Every inspection spawns up to twelve short Git processes in the Session's execution world with one remote, and two more for each further remote it tries for a fork point. A worktree exists only for a Session that requested it before its first step, and a failed or cancelled attempt can leave its branch or checkout behind. A message that enters while the Agent runs a turn or other maintenance gets no hold, so its worktree is created within the first turn and a removed worktree is not checked. While a message waits, the Agent stays idle in maintenance: the composer offers no stop, and callers waiting for the Agent to become idle wait as well. Restoring loses changes that were never committed. A base branch that requires a merge queue cannot merge from the panel. With Git Worktrees also enabled, two independent worktree services exist; the model's tool never sees the panel's service. A local or change request diff over `maxDiffBytes`, or one the host refuses, shows no files. Expanding unchanged lines of a change request reads each file from the code host once. +The panel shows current status only while it is open; checks that finish while nothing is mounted produce no notification. Changes made outside the Session appear after Refresh or the next tool result. Every inspection spawns up to twelve short Git processes in the Session's execution world with one remote, and two more for each further remote it tries for a fork point. A worktree exists only for a Session that requested it before its first step, and a failed or cancelled attempt can leave its branch or checkout behind. A message that enters while the Agent runs a turn or other maintenance gets no hold, so its worktree is created within the first turn and a removed worktree is not checked. While a message waits, the Agent stays idle in maintenance: the composer offers no stop, and callers waiting for the Agent to become idle wait as well. Restoring loses changes that were never committed. A base branch that requires a merge queue cannot merge from the panel. The bundle mounts two independent worktree services; the model's tool never sees the panel's service. A local or change request diff over `maxDiffBytes`, or one the host refuses, shows no files. Expanding unchanged lines of a change request reads each file from the code host once. diff --git a/.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md b/.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md index 432f2b10a8..660bd81d38 100644 --- a/.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md +++ b/.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md @@ -10,7 +10,9 @@ Status: implemented ## Decision -[`dsh-experimental-client-ui-forge`](../../../../packages/experimental/client-ui-forge/README.zh.md) 是一个双端包。宿主端的 `forgePanel` Remote 把面板为一个 Session 展示的全部内容组装成一条流式状态;forge 注册表与绑定服务只保留各自的领域操作。客户端包括一个右侧边栏标签页、一个显示或隐藏它的 Session 标题栏按钮、一个新会话选项、一张位于输入框上方的创建卡片、一个 toast,以及[会话列表中的图标与悬浮卡片部分](2026-10-20-forge-session-list.zh.md)。所有界面都把已绑定的条目称为变更请求(forge 注册表的术语),不随代码托管平台的叫法变化。[forge 套件](../../../../packages/experimental/forge-bundle/README.zh.md)把面板、forge 注册表、GitHub 提供方、绑定服务与一个隔离的 worktree 服务作为插件页中默认禁用的一个条目发布。 +[`dsh-experimental-client-ui-forge`](../../../../packages/experimental/client-ui-forge/README.zh.md) 是一个双端包。宿主端的 `forgePanel` Remote 把面板为一个 Session 展示的全部内容组装成一条流式状态;forge 注册表与绑定服务只保留各自的领域操作。客户端包括一个右侧边栏标签页、一个显示或隐藏它的 Session 标题栏按钮、一个新会话选项、一张位于输入框上方的创建卡片、一个 toast,以及[会话列表中的图标与悬浮卡片部分](2026-10-20-forge-session-list.zh.md)。所有界面都把已绑定的条目称为变更请求(forge 注册表的术语),不随代码托管平台的叫法变化。[forge 套件](../../../../packages/experimental/forge-bundle/README.zh.md)把面板、forge 注册表、GitHub 提供方、绑定服务、工作树模型工具与面板使用的一个隔离 worktree 服务作为插件页中默认禁用的一个条目发布。 + +评审与工作树控件服务于同一个代码修改流程。统一插件入口让用户可以一次启用这些能力,无需根据底层包的拆分判断应该开启哪张卡片。包与两套工作树服务仍保持独立,分别负责会话开始前和对话中创建工作树的流程。 Remote 通过 Session 的子进程提供方,按其[解析出的权限与查询限制](../bug-fix/2026-10-28-forge-git-session-permissions.zh.md)检查检出,因此它读到的与 Session 命令看到的相同。检查读取顶层目录、Git 目录与公共 Git 目录、分支或缩写的 HEAD 提交、上游与远端,以及分支相对于其分叉点的变更规模。它在 Session 被关注时、每个 `working-directory/change`、`tool/result` 与 `sandbox/mode` 事件之后以及刷新时运行,一次只运行一个,最多再追加一次。 @@ -89,4 +91,4 @@ Session 所在的 worktree 可能在 Session 之外被删除。每次检查都 ## Consequences -面板只在打开时显示当前状态;没有任何界面挂载时检查结束不会有通知。Session 之外的改动要等到刷新或下一个工具结果才会出现。只有一个远端时,每次检查最多在 Session 的执行环境中启动十二个短暂的 Git 进程;为寻找分叉点每多尝试一个远端,就多启动两个。只有在第一步之前提出请求的 Session 才会有 worktree,失败或被取消的尝试可能留下它的分支或检出。Agent 正在运行一轮或其他维护任务时进入的消息不会被暂留,因此它的 worktree 在第一轮之内创建,已删除的 worktree 也不会被检查。消息等待期间,Agent 以空闲状态处于维护任务中:输入框不提供停止,等待 Agent 空闲的调用方也会一并等待。恢复会丢失从未提交的修改。要求合并队列的基准分支无法从面板合并。同时启用 Git Worktrees 时会存在两个互不影响的 worktree 服务;模型的工具看不到面板的服务。本地或变更请求的 diff 超过 `maxDiffBytes`,或被托管平台拒绝时,不显示任何文件。展开变更请求中未变更的行时,每个文件只从托管平台读取一次。 +面板只在打开时显示当前状态;没有任何界面挂载时检查结束不会有通知。Session 之外的改动要等到刷新或下一个工具结果才会出现。只有一个远端时,每次检查最多在 Session 的执行环境中启动十二个短暂的 Git 进程;为寻找分叉点每多尝试一个远端,就多启动两个。只有在第一步之前提出请求的 Session 才会有 worktree,失败或被取消的尝试可能留下它的分支或检出。Agent 正在运行一轮或其他维护任务时进入的消息不会被暂留,因此它的 worktree 在第一轮之内创建,已删除的 worktree 也不会被检查。消息等待期间,Agent 以空闲状态处于维护任务中:输入框不提供停止,等待 Agent 空闲的调用方也会一并等待。恢复会丢失从未提交的修改。要求合并队列的基准分支无法从面板合并。组合包挂载两个互不影响的 worktree 服务;模型的工具看不到面板的服务。本地或变更请求的 diff 超过 `maxDiffBytes`,或被托管平台拒绝时,不显示任何文件。展开变更请求中未变更的行时,每个文件只从托管平台读取一次。 diff --git a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.i18n.yaml b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.i18n.yaml index dea1cb8a9f..98c3a9541a 100644 --- a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.i18n.yaml @@ -9,11 +9,11 @@ en: d1aad580a4db9872 zh: 1eb3bac59353291d /agent-note-direct-deepseek-search-and-fetch-apis/decision: - en: e70efbb3aa9d53b6 - zh: 1d25eae2461346df + en: 43298ccae4f7a6d4 + zh: eb7ef46b6f290c15 /agent-note-direct-deepseek-search-and-fetch-apis/decision/protocol-selection-and-deployment: - en: ec653b59d62ed95f - zh: 0d675616059930ba + en: 90e5031d0d2fa9e4 + zh: a9c0229b20555d9e /agent-note-direct-deepseek-search-and-fetch-apis/decision/search-results-and-model-input: en: ad7b3e4fbe528cf2 zh: 8ab8779bfdaaa4fa @@ -21,17 +21,17 @@ en: 7566e400f9ab3a51 zh: 118b2e84d0030480 /agent-note-direct-deepseek-search-and-fetch-apis/decision/default-migration: - en: d5a8e36b545f1c04 - zh: d741fd0789401c62 -/agent-note-direct-deepseek-search-and-fetch-apis/decision/supplementary-messages-fix: - en: 738e32b488bbc1a1 - zh: f33afe570f62599a + en: cb2f0910955b6730 + zh: 7df057048acd7858 +/agent-note-direct-deepseek-search-and-fetch-apis/decision/legacy-messages-search: + en: 0dcc97d8105fab60 + zh: d80d40ae3a0c601d /agent-note-direct-deepseek-search-and-fetch-apis/alternatives-considered: - en: 641cbfefc76f2f95 - zh: e3e718473eace85f + en: 907e0b8424943d43 + zh: f45e583e7cb4eb63 /agent-note-direct-deepseek-search-and-fetch-apis/consequences: - en: a55da02f4d3c06c0 - zh: 3c1a624f03f34f28 + en: 7ec21c5b1124e5f8 + zh: af769dbdb4face9f /agent-note-direct-deepseek-search-and-fetch-apis/verification-and-remaining-api-adoption: - en: 4c84e7bd035b52a7 - zh: 4e5687bda0a47706 + en: 595a35ce727a7c82 + zh: 10b2197663198015 diff --git a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.md b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.md index 4369faee79..ee8b912b2e 100644 --- a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.md +++ b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.md @@ -10,15 +10,15 @@ DeepSeek web retrieval through Messages requires an auxiliary model request for ## Decision -The [DeepSeek Search and Fetch guide](https://api-docs.deepseek.com/zh-cn/guides/search), checked on 2026-10-16, defines the upstream API. The [web-deepseek-api plugin](../../../../packages/web/web-deepseek-api/README.md) implements it behind the existing `web_search` and `web_fetch` tools, and [web-search-deepseek](../../../../packages/web/web-search-deepseek/README.md) keeps Messages search. The [web capability decision](../architecture/2026-06-24-web-capability-seam.md) continues to own provider selection, tool responsibilities, and the fetch result fields. +The [DeepSeek Search and Fetch guide](https://api-docs.deepseek.com/zh-cn/guides/search), checked on 2026-10-16, defines the upstream API. The [web-deepseek-api plugin](../../../../packages/web/web-deepseek-api/README.md) implements it behind the existing `web_search` and `web_fetch` tools. The [web capability decision](../architecture/2026-06-24-web-capability-seam.md) continues to own provider selection, tool responsibilities, and the fetch result fields, and the [web provider selection decision](../architecture/2026-10-29-web-provider-selection.md) owns how users choose each provider and which providers the shipped composition selects. ### Protocol selection and deployment -The APIs register as `deepseek-search-api` and `deepseek-fetch-api`; Messages search keeps `deepseek-official`. The setting **Use the new Web Search API** (**启用新版 Web 搜索 API**) on the Web search page writes `web.searchProvider` and `web.fetchProvider` together: on selects both APIs, and off restores Messages search with local HTTP fetch, the shipped selection. The switch defaults off and takes effect after saving, because the web service reads both selections for each operation. Messages remains the default until the Search API launch is announced, so existing users keep working search without configuration. Each operation resolves one settings snapshot, including its endpoint and credentials. Both APIs read one credential and one API base, because the guide serves them from one base and the switch selects them together. +The APIs register as `deepseek-search-api` and `deepseek-fetch-api`. Messages search remains separately registered as `deepseek-official`. The web service selects each provider through `web.searchProvider` and `web.fetchProvider`, which the Search and Fetch menus on the Web search and fetch page set independently; the shipped composition selects the Search API and local HTTP fetch, as the [selection decision](../architecture/2026-10-29-web-provider-selection.md) records. A saved selection takes effect on the next operation because the web service reads both selections per operation. Each operation resolves one settings snapshot, including its endpoint and credential. Both APIs read the `web-deepseek-api` namespace, and each capability resolves its own endpoint and credential. -The two protocols retain independent API bases. Messages keeps `https://api.deepseek.com/anthropic/v1` and appends `/messages`. Direct retrieval uses `web-deepseek-api.baseURL`, which defaults to the guide's public base `https://api.deepseek.com/v1/tools`, and appends `/search` or `/fetch`; the verified development base is `http://api-dev.deepseek.com:8031/api/v1/tools`. These prefixes are different and are never inferred from the hostname, so another deployment enters its complete base. The switch is already the explicit opt-in, so the documented public deployment serves as its default. A blank or unparseable base leaves both providers unavailable. +Direct retrieval appends `/search` or `/fetch` to an API base. A capability without its own YAML endpoint fields uses the origin of the conversation's DeepSeek model route base URL with `/v1/tools`, which for the public deployment is the guide's `https://api.deepseek.com/v1/tools`; the [selection decision](../architecture/2026-10-29-web-provider-selection.md) owns that derivation and the credential each operation sends. The verified development base is `http://api-dev.deepseek.com:8031/api/v1/tools`; its prefix differs and is never inferred from the hostname, so such a deployment sets the complete base in `searchBaseURL` or `fetchBaseURL`. An unparseable base leaves that capability's provider unavailable. -The guide specifies API-key Bearer authentication, and requests send only the documented authorization and JSON content type. A Session on the DeepSeek account route sends its account token instead, as Messages search does; the public Search and Fetch routes accepted it on 2026-10-16, so account-only users can enable direct retrieval. An Anthropic-compatible gateway checked the same day served Messages web search and had no `/v1/tools` routes; such deployments keep Messages search. +The guide specifies API-key Bearer authentication, and requests send only the documented authorization and JSON content type. A conversation on the `deepseek-account` route sends the account token instead; the public Search and Fetch routes accepted it on 2026-10-16, so account-only users have direct retrieval. An Anthropic-compatible gateway checked the same day served Messages web search and had no `/v1/tools` routes; such deployments can select Messages search or set the capability's own base URL to one that serves `/v1/tools`. ### Search results and model input @@ -36,45 +36,41 @@ The HTTP provider retains its own URL validation and resource limits. Selecting ### Default migration -Changing the default to the Search API is a separate change after the launch is announced. API keys and account tokens both reach the public routes; deployments behind a gateway without `/v1/tools` routes need an explicit `web.searchProvider: deepseek-official` before the default changes, and a stored Messages selection keeps Messages. +The shipped composition selects `deepseek-search-api`; the [selection decision](../architecture/2026-10-29-web-provider-selection.md) owns that default and its no-fallback rule. API keys and account tokens both reach the public routes. Deployments behind a gateway without `/v1/tools` routes set `searchBaseURL` to a base that serves them. A stored `web.searchProvider: deepseek-official` selects Messages search, and any other stored selection keeps its provider. -### Supplementary Messages fix +### Legacy Messages search -The retained Messages path explicitly sends `thinking: { type: 'disabled' }` and records that same body before dispatch. This avoids reasoning work in an auxiliary request whose generated answer is discarded; it does not implement or control the new retrieval API. `tool_choice` remains omitted. The [persistence acknowledgement](../../../../docs/persistence-changes/2026-10-13-deepseek-search-thinking.md) owns compatibility for the optional logged field. +The independent `dsh-web-search-deepseek` provider sends Messages search requests when `deepseek-official` is selected, as the [selection decision](../architecture/2026-10-29-web-provider-selection.md) records. Its `web/deepseek-search-llm-request` Session event type, including the optional `thinking: { type: 'disabled' }` body field that the [persistence acknowledgement](../../../../docs/persistence-changes/2026-10-13-deepseek-search-thinking.md) covers, stays declared and written in `dsh-web-search-deepseek`, so released Sessions that contain the event still open. Messages search sends that field because Harness discards the generated answer and the auxiliary request needs no reasoning. ## Alternatives considered -**Keep Messages as the only search implementation.** It preserves auxiliary generation and response extraction even when callers need only sources. Direct Search removes that Harness model request while retaining Messages as an explicit mode. - -**Make the Search API the default now.** The launch is not announced, and some gateways expose only Messages; their users would lose working search. +**Keep Messages as the only search implementation.** It preserves auxiliary generation and response extraction even when callers need only sources. Direct Search removes that Harness model request. **Reuse the Messages address or infer a route prefix.** The protocols and deployed prefixes differ. Separate bases preserve existing configurations and avoid sending a valid request to the wrong route. -**Require an explicit Search API base.** Enabling the switch is already an explicit choice, and an empty default would make every user copy the documented public address. Other deployments still override it. +**Require an explicit Search API base.** The Search API is the shipped search default, and an empty default would leave it unavailable until every user enters an address that the DeepSeek model route already determines. Other deployments still set their own base. -**Keep the APIs inside the Messages provider behind a provider-local switch.** A switch inside `web-search-deepseek` cannot change fetch routing, so turning it on left `web_fetch` on HTTP unless the user also edited `web.fetchProvider`; it also tied the APIs' lifecycle to Messages search, which the default migration may retire. Separate registrations let the web service's existing selection express the switch, and each package can change or retire alone. +**Keep the APIs inside the Messages provider behind a provider-local switch.** A switch inside the Messages provider cannot change fetch routing, so `web_fetch` would stay on HTTP unless the user also edited `web.fetchProvider`; it also ties the APIs' lifecycle to Messages search. Separate registrations let the web service's existing selection choose each provider, without coupling either API to the Messages provider. -**Share one transport between Messages search and the new APIs.** Both resolve credentials through the same seams, `ctx.credentials` and `ctx.deepseekAccount`, so a shared module would save only the dispatch and failure-mapping code. It would make the Messages package depend on the new API package and tie their releases, while the default migration may retire Messages search. Each package keeps its own transport until then. +**Share one transport between Messages search and the new APIs.** A shared web transport would couple different request formats and failure mappings. Credential values instead come from the shared [`llm-deepseek` credential helpers](../../../../packages/llm/llm-deepseek/src/credential.ts); Messages search and the Search/Fetch API transports remain independent. -**Split Search and Fetch into separate packages.** The guide serves both APIs from one base with one credential, and the switch selects them together; separate packages would duplicate the settings a user edits once. Each operation lives in its own module, so either API can change without the other. +**Split Search and Fetch into separate packages.** The guide serves both APIs from one base with one credential; separate packages would duplicate settings that address one deployment. Independent selection does not require separate packages, because each API registers its own provider id. Each operation lives in its own module, so either API can change without the other. -**List every registered provider in a settings dropdown.** A generic provider form derives editors from Config schemas and needs host-side display metadata on the provider interfaces. The page needs only the DeepSeek choice, and other providers keep their own settings pages. +**List every registered provider in a settings dropdown.** A generic provider form derives editors from Config schemas and needs host-side display metadata on the provider interfaces. This rejection stands for the generic form: the page edits only the DeepSeek providers' settings, and other providers keep their own settings pages. The [selection decision](../architecture/2026-10-29-web-provider-selection.md) supersedes it for the provider menus, which list every registered provider by its `title`. **Let the model supply `ori_question` as a tool argument.** The API defines the field as the user's original question, which the latest human prompt already records. A tool argument would change the model-facing schema shared by every search provider and invite paraphrase. -**Keep HTTP fetch when the switch turns on.** The switch adopts the new DeepSeek retrieval APIs, and leaving fetch on HTTP made Fetch API adoption depend on a hand-edited `web.fetchProvider`. HTTP fetch, with its own access policy, remains the selection whenever the switch is off, and configuration can still select either fetch provider alone. - **Add an extraction tool or widen the fetch result.** The API retrieves one concrete web page as Markdown, which fits the existing fetch operation. Its success already implies a retrieved page, and the Markdown body carries the page heading, so widening the result would change every fetch consumer for little model-visible gain. ## Consequences -Retrieval no longer requires auxiliary model tokens in direct mode, but its results still consume the main model's context. One Messages web search through a gateway on 2026-10-16 consumed 8,154 input and 828 output tokens for a single server-side search. The guide prices Search and Fetch independently at CNY 5 per 1,000 successful requests with results; result count does not change a request's price. Multiple queries remain multiple requests. The stated Claude Code and Codex promotion does not establish a Harness exemption. Search API mode also sends up to 128 characters of the user's prompt to the search service with each query unless `sendOriginalQuestion` is off. +Direct retrieval requires no auxiliary model tokens, but its results still consume the main model's context. One Messages web search through a gateway on 2026-10-16 consumed 8,154 input and 828 output tokens for a single server-side search. The guide prices Search and Fetch independently at CNY 5 per 1,000 successful requests with results; result count does not change a request's price. Multiple queries remain multiple requests. The stated Claude Code and Codex promotion does not establish a Harness exemption. Search API mode also sends up to 128 characters of the user's prompt to the search service with each query unless `sendOriginalQuestion` is off. -Search and Fetch each have an account-wide 5 QPS limit, independent of model concurrency and shared across that account's API keys. Fetch also has a target-domain limit. The current tool can issue four queries concurrently and fails the batch if one query fails. The provider has no shared rate scheduler, automatic retries, or fallback; other Sessions or processes can exhaust the same account allowance. +Search and Fetch each have an account-wide 5 QPS limit, independent of model concurrency and shared across that account's API keys. Fetch also has a target-domain limit. The tool accepts up to `searchMaxQueries` queries per call, 10 by default, and fails the batch if one query fails. The Search provider sends at most `searchRequestsPerSecond` requests, 5 by default, in any one-second window of one plugin instance, so the second five queries of a full batch start one second after the first. The pacing is not shared across processes, and the provider has no automatic retries or fallback; other processes or clients can exhaust the same account allowance. ## Verification and remaining API adoption -Provider tests cover request mapping, empty results, malformed responses, independent and default bases, account-token authentication, cancellation, and blank configuration. Settings tests and the [plugin configuration browser scenario](../../../../apps/web/tests/plugin-config.e2e.ts) cover staged saves, the default base, and switching both selections. The [web search replay](../../../../apps/web/tests/web-search-round.e2e.ts) and [Fetch Session replay](../../../../snapshots/session/deepseek-fetch/snapshot.yml) pin model-visible results. On 2026-10-16 the [real provider tests](../../../../packages/web/web-deepseek-api/tests/web-deepseek-api.e2e.ts) passed Search and Fetch against the public base, and a headless round on the shipped profile returned eight sources and fetched Markdown without an auxiliary request event. The account-token, gateway, and status observations above come from manual probes the same day. +Provider tests cover request mapping, empty results, malformed responses, independent and derived bases, account-token authentication and its invalidation after HTTP 401, cancellation, and blank configuration. Settings tests and the [plugin configuration browser scenario](../../../../apps/web/tests/plugin-config.e2e.ts) cover staged saves, the result count, and independent Search and Fetch selection. The [web search replay](../../../../apps/web/tests/web-search-round.e2e.ts) and [Fetch Session replay](../../../../snapshots/session/deepseek-fetch/snapshot.yml) pin model-visible results. On 2026-10-16 the [real provider tests](../../../../packages/web/web-deepseek-api/tests/web-deepseek-api.e2e.ts) passed Search and Fetch against the public base, and a headless round on the shipped profile returned eight sources and fetched Markdown without an auxiliary request event. The account-token, gateway, and status observations above come from manual probes the same day. The following capabilities remain outside the implemented integration: diff --git a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.zh.md b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.zh.md index 0b736cb445..c7493c8e23 100644 --- a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.zh.md +++ b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.zh.md @@ -10,15 +10,15 @@ Status: implemented ## 决策 -2026-10-16 核对的 [DeepSeek Search 与 Fetch 指南](https://api-docs.deepseek.com/zh-cn/guides/search)定义上游 API。[web-deepseek-api 插件](../../../../packages/web/web-deepseek-api/README.zh.md)通过现有 `web_search` 和 `web_fetch` 工具接入它,[web-search-deepseek](../../../../packages/web/web-search-deepseek/README.zh.md) 保留 Messages 搜索。[Web 能力决策](../architecture/2026-06-24-web-capability-seam.zh.md)继续负责提供方选择、工具职责和抓取结果字段。 +2026-10-16 核对的 [DeepSeek Search 与 Fetch 指南](https://api-docs.deepseek.com/zh-cn/guides/search)定义上游 API。[web-deepseek-api 插件](../../../../packages/web/web-deepseek-api/README.zh.md)通过现有 `web_search` 和 `web_fetch` 工具接入它。[Web 能力决策](../architecture/2026-06-24-web-capability-seam.zh.md)继续负责提供方选择、工具职责和抓取结果字段;[Web 提供方选择决策](../architecture/2026-10-29-web-provider-selection.zh.md)负责用户如何选择各个提供方,以及内置组合选择哪些提供方。 ### 协议选择与部署 -两个 API 注册为 `deepseek-search-api` 和 `deepseek-fetch-api`;Messages 搜索保留 `deepseek-official`。网页搜索页上的设置 **Use the new Web Search API**(**启用新版 Web 搜索 API**)同时写入 `web.searchProvider` 和 `web.fetchProvider`:开启时选择两个 API,关闭时恢复内置选择,即 Messages 搜索与本地 HTTP 抓取。开关默认关闭,保存后生效,因为 Web 服务在每次操作时读取两项选择。在 Search API 正式发布公告之前,Messages 仍是默认值,已有用户无需配置即可继续使用搜索。每次操作解析一份设置快照,包含端点和凭据。两个 API 读取同一份凭据和同一个 API 基础地址,因为指南从同一基础地址提供它们,开关也同时选择它们。 +两个 API 注册为 `deepseek-search-api` 和 `deepseek-fetch-api`;Messages 搜索仍独立注册为 `deepseek-official`。Web 服务通过 `web.searchProvider` 和 `web.fetchProvider` 选择各个提供方,「网页搜索与获取」页上的 Search 和 Fetch 下拉框分别设置这两项;内置组合选择 Search API 与本地 HTTP 抓取,见[选择决策](../architecture/2026-10-29-web-provider-selection.zh.md)。已保存的选择从下一次操作起生效,因为 Web 服务在每次操作时读取两项选择。每次操作解析一份包含端点和凭据的设置快照。两个 API 读取 `web-deepseek-api` namespace,每个能力各自解析端点与凭据。 -两种协议分别保存 API 基础地址。Messages 保留 `https://api.deepseek.com/anthropic/v1`,追加 `/messages`。直接检索使用 `web-deepseek-api.baseURL`,默认值为指南中的公网地址 `https://api.deepseek.com/v1/tools`,追加 `/search` 或 `/fetch`;已验证的开发地址为 `http://api-dev.deepseek.com:8031/api/v1/tools`。两者前缀不同,程序不会根据主机名推断,其他部署需填写完整基础地址。开关本身已是显式启用,因此以文档中的公网部署作为默认值。基础地址为空白或无法解析时,两个提供方都不可用。 +直接检索在 API 基础地址后追加 `/search` 或 `/fetch`。没有在 YAML 中单独设置端点字段的能力,使用会话的 DeepSeek 模型路由基础地址的 origin 加 `/v1/tools`,公网部署即指南中的 `https://api.deepseek.com/v1/tools`;该推导及每次操作发送的凭据由[选择决策](../architecture/2026-10-29-web-provider-selection.zh.md)负责。已验证的开发地址为 `http://api-dev.deepseek.com:8031/api/v1/tools`;其前缀不同,程序不会根据主机名推断,此类部署需在 `searchBaseURL` 或 `fetchBaseURL` 中填写完整基础地址。基础地址无法解析时,该能力的提供方不可用。 -指南规定使用 API 密钥进行 Bearer 鉴权,请求只发送文档规定的鉴权与 JSON 内容类型请求头。DeepSeek 账号路由上的会话改为发送其账号 token,与 Messages 搜索一致;2026-10-16 公网 Search 与 Fetch 路由已接受该 token,因此只登录账号的用户也可以启用直接检索。同日检查的一个 Anthropic 兼容网关提供 Messages 网页搜索,但没有 `/v1/tools` 路由;此类部署继续使用 Messages 搜索。 +指南规定使用 API 密钥进行 Bearer 鉴权,请求只发送文档规定的鉴权与 JSON 内容类型请求头。`deepseek-account` 路由上的会话改为发送账号 token;2026-10-16 公网 Search 与 Fetch 路由已接受该 token,因此只登录账号的用户也能使用直接检索。同日检查的一个 Anthropic 兼容网关提供 Messages 网页搜索,但没有 `/v1/tools` 路由;此类部署可以选择 Messages 搜索,或把该能力的单独基础地址设为提供 `/v1/tools` 的地址。 ### 搜索结果与模型输入 @@ -36,45 +36,41 @@ HTTP 提供方继续负责自身的 URL 校验和资源限制。选择 DeepSeek ### 默认值迁移 -将默认值改为 Search API 是发布公告之后的另一项变更。API 密钥和账号 token 都能访问公网路由;位于没有 `/v1/tools` 路由的网关之后的部署,需要在默认值变更前显式设置 `web.searchProvider: deepseek-official`,已保存的 Messages 选择仍使用 Messages。 +内置组合选择 `deepseek-search-api`;[选择决策](../architecture/2026-10-29-web-provider-selection.zh.md)负责这项默认值及其不回退规则。API 密钥和账号 token 都能访问公网路由。位于没有 `/v1/tools` 路由的网关之后的部署,需把 `searchBaseURL` 设为提供这些路由的基础地址。已保存的 `web.searchProvider: deepseek-official` 会选中 Messages 搜索,其他已保存的选择保持其提供方。 -### 附带的 Messages 修复 +### 旧版 Messages 搜索 -保留的 Messages 路径显式发送 `thinking: { type: 'disabled' }`,并在发送前记录同一请求体。这避免了辅助请求在生成回答被丢弃的情况下执行思考;它不实现或控制新的检索 API。`tool_choice` 仍不传递。[持久化确认记录](../../../../docs/persistence-changes/2026-10-13-deepseek-search-thinking.zh.md)负责日志中可选字段的兼容性。 +选中 `deepseek-official` 时,独立的 `dsh-web-search-deepseek` 提供方发送 Messages 搜索请求,见[选择决策](../architecture/2026-10-29-web-provider-selection.zh.md)。它的 `web/deepseek-search-llm-request` 会话事件类型(包括[持久化确认记录](../../../../docs/persistence-changes/2026-10-13-deepseek-search-thinking.zh.md)覆盖的可选请求体字段 `thinking: { type: 'disabled' }`)仍由 `dsh-web-search-deepseek` 声明并写入,因此包含该事件的已发布会话仍能打开。Messages 搜索发送该字段,是因为 Harness 丢弃生成的回答,辅助请求无需推理。 ## 考虑过的替代方案 -**仅保留 Messages 搜索实现。** 即使调用方只需要来源,这也保留了辅助生成和响应提取。直接 Search 去掉这次 Harness 模型请求,同时保留 Messages 供显式选择。 - -**立即将 Search API 设为默认值。** 尚未发布公告,部分网关也只提供 Messages;这些用户会失去可用的搜索。 +**仅保留 Messages 搜索实现。** 即使调用方只需要来源,这也保留了辅助生成和响应提取。直接 Search 去掉这次 Harness 模型请求。 **复用 Messages 地址或推断路由前缀。** 协议与部署前缀不同。分别保存基础地址可保留现有配置,避免把有效请求发往错误路由。 -**要求显式填写 Search API 基础地址。** 开启开关已是显式选择,空默认值会迫使每位用户复制文档中的公网地址。其他部署仍可覆盖它。 +**要求显式填写 Search API 基础地址。** Search API 是内置的搜索默认值,空默认值会让它在每位用户填写一个 DeepSeek 模型路由已经确定的地址之前都不可用。其他部署仍可设置自己的基础地址。 -**把两个 API 放在 Messages 提供方内部,由提供方自己的开关切换。** `web-search-deepseek` 内的开关无法改变抓取路由,因此开启后除非用户另外修改 `web.fetchProvider`,`web_fetch` 仍走 HTTP;它还把两个 API 的生命周期绑在 Messages 搜索上,而默认值迁移后 Messages 搜索可能被移除。独立注册让 Web 服务现有的选择来表达这个开关,每个包都可以单独修改或移除。 +**把两个 API 放在 Messages 提供方内部,由提供方自己的开关切换。** Messages 提供方内的开关无法改变抓取路由,因此除非用户另外修改 `web.fetchProvider`,`web_fetch` 仍会走 HTTP;它还会把两个 API 的生命周期绑在 Messages 搜索上。独立注册让 Web 服务通过现有的提供方选择机制选择各个提供方,不会把任一 API 与 Messages 提供方绑定。 -**让 Messages 搜索与新 API 共用一套传输层。** 两者都通过同样的 seam,即 `ctx.credentials` 和 `ctx.deepseekAccount` 解析凭据,共享模块只能省下请求派发与失败映射代码。它会让 Messages 包依赖新 API 包、两者必须一起发版,而默认值迁移后 Messages 搜索可能被移除。在此之前,两个包各自保留传输层。 +**让 Messages 搜索与新 API 共用一套传输层。** 共享 Web 传输层会耦合不同的请求格式和失败映射。凭据值改为通过共享的 [`llm-deepseek` 凭据函数](../../../../packages/llm/llm-deepseek/src/credential.ts)获取;Messages 搜索与 Search/Fetch API 传输层保持独立。 -**把 Search 和 Fetch 拆成两个包。** 指南从同一基础地址、以同一份凭据提供两个 API,开关也同时选择它们;拆成两个包会让用户只需编辑一次的设置重复出现。每种操作位于独立模块,任一 API 都可以单独修改。 +**把 Search 和 Fetch 拆成两个包。** 指南从同一基础地址、以同一份凭据提供两个 API;拆成两个包会让指向同一部署的设置重复出现。分别选择不需要拆包,因为每个 API 注册自己的提供方 id。每种操作位于独立模块,任一 API 都可以单独修改。 -**在设置页用下拉框列出所有已注册的提供方。** 通用提供方表单从 Config schema 推导编辑控件,还需要在提供方接口上增加宿主侧展示元数据。该页面只需要 DeepSeek 的选择,其他提供方保留各自的设置页。 +**在设置页用下拉框列出所有已注册的提供方。** 通用提供方表单从 Config schema 推导编辑控件,还需要在提供方接口上增加宿主侧展示元数据。这一否决对通用表单仍然成立:该页面只编辑 DeepSeek 提供方的设置,其他提供方保留各自的设置页。对于提供方下拉框,[选择决策](../architecture/2026-10-29-web-provider-selection.zh.md)取代了这一否决,下拉框按 `title` 列出所有已注册的提供方。 **让模型以工具参数提供 `ori_question`。** API 将该字段定义为用户的原始问题,而最近一条用户提示已记录了这段文本。工具参数会改变所有搜索提供方共用的面向模型的 schema,并引入改写。 -**开启开关时保留 HTTP 抓取。** 该开关用于启用 DeepSeek 新的检索 API,保留 HTTP 抓取会让使用 Fetch API 依赖手动修改 `web.fetchProvider`。开关关闭时,抓取仍选择带有自身访问策略的 HTTP 提供方,配置中也仍可单独选择任一抓取提供方。 - **新增提取工具或扩展抓取结果。** API 以 Markdown 获取一个具体网页,符合现有抓取操作。其成功本身即表示已获取页面,Markdown 正文也带有页面标题,因此扩展结果会改动每个抓取消费方,而模型可见的收益很小。 ## 影响 -直接模式中的检索不再需要辅助模型 token,但结果仍消耗主模型上下文。2026-10-16 通过网关进行的一次 Messages 网页搜索,为一次服务端搜索消耗了 8,154 个输入 token 和 828 个输出 token。指南对 Search 和 Fetch 分别按每 1,000 次成功且有结果的请求收取 5 元;结果条数不影响单次价格。多个查询仍对应多次请求。文档中的 Claude Code 与 Codex 优惠不能作为 Harness 免收费用的依据。除非关闭 `sendOriginalQuestion`,Search API 模式还会随每个查询把用户提示的至多 128 个字符发送给搜索服务。 +直接检索不需要辅助模型 token,但结果仍消耗主模型上下文。2026-10-16 通过网关进行的一次 Messages 网页搜索,为一次服务端搜索消耗了 8,154 个输入 token 和 828 个输出 token。指南对 Search 和 Fetch 分别按每 1,000 次成功且有结果的请求收取 5 元;结果条数不影响单次价格。多个查询仍对应多次请求。文档中的 Claude Code 与 Codex 优惠不能作为 Harness 免收费用的依据。除非关闭 `sendOriginalQuestion`,Search API 模式还会随每个查询把用户提示的至多 128 个字符发送给搜索服务。 -Search 和 Fetch 各有账号级 5 QPS 限制,独立于模型并发,同一账号的 API 密钥共享额度。Fetch 还有目标域名限制。当前工具可并发发送四个查询,任一查询失败会使整批失败。提供方没有共享限速调度器、自动重试或回退;其他会话或进程也会消耗同一账号的额度。 +Search 和 Fetch 各有账号级 5 QPS 限制,独立于模型并发,同一账号的 API 密钥共享额度。Fetch 还有目标域名限制。工具每次调用最多接受 `searchMaxQueries` 个查询,默认 10 个;任一查询失败会使整批失败。Search 提供方在单个插件实例的任意一秒窗口内最多发送 `searchRequestsPerSecond` 个请求,默认 5 个,因此满批次的后五个查询比前五个晚一秒开始。该限速不跨进程共享,提供方也没有自动重试或回退;其他进程或客户端也会消耗同一账号的额度。 ## 验证与尚未接入的 API 能力 -提供方测试覆盖请求映射、空结果、格式错误的响应、独立地址与默认地址、账号 token 鉴权、取消与空白配置。设置测试及[插件配置浏览器场景](../../../../apps/web/tests/plugin-config.e2e.ts)覆盖暂存后保存、默认地址和同时切换两项选择。[网页搜索回放](../../../../apps/web/tests/web-search-round.e2e.ts)与 [Fetch Session 回放](../../../../snapshots/session/deepseek-fetch/snapshot.yml)固定模型可见结果。2026-10-16,[真实提供方测试](../../../../packages/web/web-deepseek-api/tests/web-deepseek-api.e2e.ts)在公网地址通过了 Search 与 Fetch 验证;在内置配置上运行的一轮 headless 对话返回了八个来源并抓取到 Markdown,且没有辅助请求事件。上文的账号 token、网关和状态码观察来自同日的手动探测。 +提供方测试覆盖请求映射、空结果、格式错误的响应、独立地址与推导地址、账号 token 鉴权及其在 HTTP 401 后被作废、取消与空白配置。设置测试及[插件配置浏览器场景](../../../../apps/web/tests/plugin-config.e2e.ts)覆盖暂存后保存、结果数,以及分别选择 Search 与 Fetch。[网页搜索回放](../../../../apps/web/tests/web-search-round.e2e.ts)与 [Fetch Session 回放](../../../../snapshots/session/deepseek-fetch/snapshot.yml)固定模型可见结果。2026-10-16,[真实提供方测试](../../../../packages/web/web-deepseek-api/tests/web-deepseek-api.e2e.ts)在公网地址通过了 Search 与 Fetch 验证;在内置配置上运行的一轮 headless 对话返回了八个来源并抓取到 Markdown,且没有辅助请求事件。上文的账号 token、网关和状态码观察来自同日的手动探测。 以下能力尚未包含在已实现的接入中: diff --git a/.agents/notes/implemented/feature/2026-11-02-liveshare-plugin-method-catalog.i18n.yaml b/.agents/notes/implemented/feature/2026-11-02-liveshare-plugin-method-catalog.i18n.yaml new file mode 100644 index 0000000000..f4bb734afe --- /dev/null +++ b/.agents/notes/implemented/feature/2026-11-02-liveshare-plugin-method-catalog.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-11-02-liveshare-plugin-method-catalog.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-11-02-liveshare-plugin-method-catalog.md +/agent-note-liveshare-plugin-method-catalog: + en: 1daaf32c7ea8d3cd + zh: 70a94350263d22c9 +/agent-note-liveshare-plugin-method-catalog/problem: + en: 2122ac5fdd75f900 + zh: dc135aaa0d1dddc0 +/agent-note-liveshare-plugin-method-catalog/decision: + en: 6e0b897ba6460894 + zh: f05866ccde01a5af +/agent-note-liveshare-plugin-method-catalog/alternatives-considered: + en: 57896311cada7804 + zh: 01fbd1cbd05353ef +/agent-note-liveshare-plugin-method-catalog/consequences: + en: e2069a5f9801f767 + zh: 0698f0b9e1e49201 diff --git a/.agents/notes/implemented/feature/2026-11-02-liveshare-plugin-method-catalog.md b/.agents/notes/implemented/feature/2026-11-02-liveshare-plugin-method-catalog.md new file mode 100644 index 0000000000..699c33bbe5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-11-02-liveshare-plugin-method-catalog.md @@ -0,0 +1,23 @@ +# Agent Note: LiveShare plugin method catalog + +Status: implemented + +English | [中文](2026-11-02-liveshare-plugin-method-catalog.zh.md) + +## Problem + +LiveShare used to expose plugin endpoints through a declaration API. The API let a plugin name arbitrary endpoints, so a catalog mistake could widen a viewer's reach without a corresponding Gateway check. The replacement needs one source of truth that says which plugin methods a share may expose. + +## Decision + +The catalog is enumerated from the Typert local registry at runtime. One entry is emitted per bundle, using the bundle's localized title when available and the component package title otherwise. Endpoint descriptions come from the registered Remote descriptors. A share stores only method-level policies: the Host intersects each policy with the catalog before the Gateway dispatches a call. + +The catalog excludes the DSH core bundles. Their methods remain governed by the existing Session and Workspace sharing roles, not by plugin permissions. The core bundle names are derived from `PROFILE_TEMPLATES`, so the exclusion follows the shipped profile composition instead of a second hand-maintained list. LiveShare's own bundle is excluded because it is the sharing implementation, not a capability a share can grant itself. + +## Alternatives considered + +Keeping the declaration API would leave two sources of truth: a plugin-declared endpoint and the Gateway's own classification. A hand-maintained allowlist in LiveShare would drift from the shipped profile templates. Deriving the exclusion from the actual profile templates keeps the catalog aligned with composition without a second list. + +## Consequences + +A method that is not in the Typert local registry cannot be selected or called through plugin permissions. A plugin that uses an owner-only exact route is absent from the catalog; the viewer sees an `owner-route-not-shared` notice when it calls that route. Adding a shareable method is therefore a plugin-side registration decision, not a LiveShare catalog edit. diff --git a/.agents/notes/implemented/feature/2026-11-02-liveshare-plugin-method-catalog.zh.md b/.agents/notes/implemented/feature/2026-11-02-liveshare-plugin-method-catalog.zh.md new file mode 100644 index 0000000000..4f0f639dc4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-11-02-liveshare-plugin-method-catalog.zh.md @@ -0,0 +1,23 @@ +# Agent Note:LiveShare 插件方法目录 + +Status: implemented + +[English](2026-11-02-liveshare-plugin-method-catalog.md) | 中文 + +## 问题 + +LiveShare 过去通过声明 API 暴露插件端点。该 API 允许插件声明任意端点,因此目录写错时,viewer 的可达范围会在没有对应 Gateway 校验的情况下被扩大。替代方案需要一个唯一事实来源,明确一条分享可以暴露哪些插件方法。 + +## 决定 + +目录在运行时从 Typert 本地注册表枚举。每个 bundle 生成一行,优先使用 bundle 的本地化标题,没有时使用组件包标题;端点说明来自已注册的 Remote descriptor。分享记录只保存方法级策略,Host 在 Gateway 派发前把策略与目录取交集。 + +目录排除 DSH 核心 bundle。它们的方法仍由原有的 Session 和 Workspace 分享角色控制,而不是插件权限。核心 bundle 名从 `PROFILE_TEMPLATES` 派生,使排除规则跟随实际下发的 profile 组合,而不是再维护一份手写名单。LiveShare 自身的 bundle 也被排除,因为它是分享实现,不是分享可以授予自己的能力。 + +## 备选方案 + +保留声明 API 会留下两个事实来源:插件声明的端点和 Gateway 自己的分类。由 LiveShare 维护手写白名单又会与实际下发的 profile 模板漂移。从真实 profile 模板派生排除规则,让目录跟随组合而不需要第二份名单。 + +## 影响 + +不在 Typert 本地注册表中的方法无法被插件权限选中或调用。使用 owner-only exact route 的插件不会出现在目录中;viewer 调用该路由时会看到 `owner-route-not-shared` 提示。因此新增可分享方法是插件侧的注册决定,而不是修改 LiveShare 目录。 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 8c2c549e30..73329cbb21 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -9,8 +9,8 @@ en: 3e229ecf646af866 zh: d358507b1e1d381f /agent-note-wine-and-native-windows-ci/decision: - en: 1125b44a170f70fb - zh: 37838b9da2003651 + en: 9038773113cf3553 + zh: 67875d393093c105 /agent-note-wine-and-native-windows-ci/alternatives-considered: en: 0e9a322cfc38d313 zh: f7bdeb86ba4fea1a diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index cb14645f13..c7c8be3f26 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -30,7 +30,7 @@ Native watchers use `canonicalizeWatchPath()` to realpath the deepest existing a Windows durable JSONL paths keep drive roots in native spelling and apply the extended-length namespace only to descendants and staging paths. The ACP teardown ladder uses real Node children, proves graceful and forced tiers with host-appropriate outcomes, and avoids claiming POSIX signal delivery on Windows. Executable fixtures provide `.cmd` shims and `PATHEXT` where the product accepts a bare command. Repository-cache helpers live inside the selected Git subpath so their declared `file:` dependencies expose command shims identically on Windows. The bundled installer exports pnpm's own workspace-ignore configuration, retains `PNPM_HOME` for pnpm data while removing that directory from lifecycle `PATH`, and prioritizes `.CMD` in `PATHEXT`; nested Git-package installation therefore cannot rejoin the enclosing workspace or let an inherited Windows pnpm executable outrank the transaction-owned wrapper. -Post-boot profile watcher setup proceeds only while the root fiber and Loader are both live. A concurrent setup error is contained only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud. The [process-shutdown controller](../../../../apps/cli/src/process-shutdown.ts) lets a successful one-shot completion drain Node's remaining handles after root disposal, while teardown failure, deadline, and signal escalation retain forced exit. The vendored Include serializes debounced writes, retries only transient access or busy failures with bounded backoff, and observes every timer rejection. A terminal persistence failure remains on the queue and is rethrown to the teardown owner, while successful teardown drains the latest write. +Post-boot profile watcher setup proceeds only while the root fiber and Loader are both live. A concurrent setup error is contained only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud. The [process-shutdown controller](../../../../apps/cli/src/process-shutdown.ts) lets a successful one-shot completion drain Node's remaining handles after root disposal, while teardown failure, deadline, and signal escalation retain forced exit. The vendored Include journals runtime changes and writes them through an atomic rename, retrying only transient access or busy failures with bounded backoff. `flush()` and `stop()` reject while changes remain unsaved, and a later successful flush clears the previous error. Shiki disables lazy TextMate-regex compilation and warms each boot grammar before user content enters the unchanged per-line tokenization budget, so scheduler contention cannot publish a partial highlighted stream. The Codex real-product fixture is pinned to stable 0.153.4 schemas and selects an actually advertised command tool and argument shape, preserving the provider-owned protocol while proving unattended rejection and whole-tree exit on each host. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index b999ac8ef8..12ffd61a2c 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -30,7 +30,7 @@ Wine 在 Linux 内核与区分大小写的 ext4 之上采用 hoisted 依赖布 Windows 的持久 JSONL 路径会保留驱动器根目录的原生写法,并仅对后代路径与暂存路径应用扩展长度命名空间。ACP(Agent Client Protocol)拆卸阶梯使用真实 Node 子进程,以符合宿主语义的结果证明优雅终止与强制终止两个层级,并避免声称 Windows 会交付 POSIX 信号。产品接受裸命令时,可执行 fixture 会提供 `.cmd` 包装脚本与 `PATHEXT`。repository-cache 辅助包位于所选 Git 子路径内,因此它们声明的 `file:` 依赖会在 Windows 上以相同方式暴露命令包装脚本。随附的安装器会导出 pnpm 自有的 workspace-ignore 配置,保留 `PNPM_HOME` 作为 pnpm 数据配置,同时从生命周期 `PATH` 中移除该目录,并在 `PATHEXT` 中优先选择 `.CMD`;因此,嵌套 Git 包安装既不会重新加入外层 workspace,也不会让继承的 Windows pnpm 可执行文件抢在事务持有的 wrapper 之前。 -启动后,只有根 fiber 与 Loader 均处于活跃状态时,系统才会继续设置 profile watcher。只有当同一次调用所记录的信号已取得关闭流程所有权时,系统才会隔离并发设置错误;无关 HMR 故障仍会响亮失败。[进程关闭控制器](../../../../apps/cli/src/process-shutdown.ts)会在根级 dispose 成功后让单次任务的正常完成流程排空 Node 剩余句柄,同时让拆卸失败、截止时间到期和信号升级继续强制退出。vendored Include 会串行化防抖写入,只对瞬时访问或忙碌故障执行有界退避重试,并确保每个由计时器触发的拒绝都得到观察。持久化最终失败后,该故障会保留在队列中,并重新抛给拆卸责任方;成功拆卸则会排空最新写入。 +启动后,只有根 fiber 与 Loader 均处于活跃状态时,系统才会继续设置 profile watcher。只有当同一次调用所记录的信号已取得关闭流程所有权时,系统才会隔离并发设置错误;无关 HMR 故障仍会响亮失败。[进程关闭控制器](../../../../apps/cli/src/process-shutdown.ts)会在根级 dispose 成功后让单次任务的正常完成流程排空 Node 剩余句柄,同时让拆卸失败、截止时间到期和信号升级继续强制退出。vendored Include 通过 journal 记录运行时变更,并以原子 rename 写入,只对瞬时访问或忙碌故障执行有界退避重试。仍有未保存变更时,`flush()` 和 `stop()` 会拒绝;之后一次成功的 flush 会清除先前的错误。 Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持不变的逐行 tokenization(词元化)预算前预热每种启动语法,从而避免调度器争用发布不完整的高亮流。Codex 真实产品 fixture 固定使用稳定版 0.153.4 schema,并选择实际提供的命令工具与对应参数形态;这样既保留由提供方负责的协议,也能在每种宿主上证明无人值守拒绝和整棵进程树退出。 diff --git a/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.i18n.yaml index f00222dc57..fc8bf04b3d 100644 --- a/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.i18n.yaml @@ -9,11 +9,11 @@ en: d4ab65a39cf4fd7c zh: 62144be9b4fd7956 /agent-note-keep-loader-mutations-non-transactional/decision: - en: 9b661159de63d421 - zh: 15697f468a90edb9 + en: 83268169e9436127 + zh: ce740df03e9fe24a /agent-note-keep-loader-mutations-non-transactional/alternatives-considered: - en: 72b871064c7ae315 - zh: a09e5168f8124c4c + en: 645db43a48b9cfc1 + zh: d03fabd1e82dadbd /agent-note-keep-loader-mutations-non-transactional/consequences: en: 100f687b9d498308 zh: 1c4bc2d989e43ec7 diff --git a/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md index d37539cb3e..58ba37bd3c 100644 --- a/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md +++ b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md @@ -12,19 +12,19 @@ Transactional config reload preserves an old plugin generation after a failed ed Revert the five commits in #932, resolving package moves and retaining independent later behavior. The reported merge commit belongs to the larger #936 dependency chain; reverting its first-parent diff would remove unrelated repository-plugin support. The [vendor ledger](../../../../vendor/README.md#local-modifications) records every retained source change against the unchanged pins. -Loader changes entry options eagerly. EntryGroup starts siblings concurrently and logs application failures; EntryTree waits for outstanding work without rejecting failed fibers. Neither restores a previous plugin or configuration. Include retains parse validation and patch reapplication, but plugin failures can leave a partially applied tree. +Loader changes entry options eagerly. EntryGroup starts siblings concurrently and logs application failures; EntryTree waits for outstanding work without rejecting failed fibers. Neither restores a previous plugin or configuration. Include keeps the running tree after an invalid file, but plugin failures can leave a partially applied tree. Application consumers own their completion checks. The CLI waits for its fallback HMR service before installing live patch watchers. The directory chooser checks the entries it mounts. The chooser and browser package runner capture the first fiber-disposal result before removing the entry, then await it before reporting teardown complete. Preset mounting waits for its subtree and reports import, activation, and missing-service failures. [App boot](../../../../packages/boot/app-boot/README.md) owns exact patch-file watching, activation audits, and partial-context cleanup. Web startup audits activation before printing a URL or opening the browser. These adaptations preserve existing consumer behavior after the reverse patch. -Fiber, Entry, and isolate keep their upstream update return behavior. App boot observes discarded restart promises through the existing `internal/update` waterfall and waits for fibers before auditing a patch reload. The detached import-completion observer handles both fiber outcomes; the fiber still retains its failure for an explicit audit. Durable Include writes drain before and after child removal so a later teardown write cannot erase an earlier terminal write failure. +Fiber, Entry, and isolate keep their upstream update return behavior. App boot observes discarded restart promises through the existing `internal/update` waterfall and waits for fibers before auditing a patch reload. The detached import-completion observer handles both fiber outcomes; the fiber still retains its failure for an explicit audit. Include persistence follows the journal adaptation in the vendor ledger: `flush()` and `stop()` reject while changes remain unsaved. -Two #932-specific vendor changes remain: awaited initial-file creation and forced rereading in Include, and Schemastery conditional exports. Restoring the pre-#932 debounced write/read sequence reproduces `ENOENT` in the missing-file initialization test. Keeping these two lines preserves the existing `initial` option without an application-side file writer or a second YAML serializer. Removing Schemastery exports reproduces `ERR_REQUIRE_ESM_RACE_CONDITION` while the Web preset suite boots: Node falls back to the CJS entry during concurrent ESM imports. The HMR injection decorators, conditional patch cloning, and update return values use the pre-#932 behavior. Explicit `workspace:` dependencies make the #932 workspace-link switch and dedicated lockfile check unnecessary. +One #932-specific vendor change remains: Schemastery conditional exports. Include 1.1.1 writes the `initial` file before reading it, so missing-file initialization no longer needs a local change. Removing Schemastery exports reproduces `ERR_REQUIRE_ESM_RACE_CONDITION` while the Web preset suite boots: Node falls back to the CJS entry during concurrent ESM imports. The HMR injection decorators and update return values use the pre-#932 behavior; patch cloning follows upstream Include. Explicit `workspace:` dependencies make the #932 workspace-link switch and dedicated lockfile check unnecessary. ## Alternatives considered **Keep transactional Loader updates.** They provide automatic recovery from a rejected plugin candidate, but retain the vendored lifecycle machinery being removed. Parse failures can be contained without plugin rollback. -**Restore every vendored file verbatim.** This would also remove lazy injected config evaluation, conditional disabled entries, lifecycle disposal fixes, durable writes, and module-loader compatibility. Those changes have independent consumers and remain recorded in the vendor ledger. +**Restore every vendored file verbatim.** This would also remove lazy injected config evaluation, conditional disabled entries, lifecycle disposal fixes, and module-loader compatibility. Those changes have independent consumers and remain recorded in the vendor ledger. **Move generic rollback into app boot.** This would retain the same candidate-generation and restoration obligations under another owner. Applications instead report failures and allow a later valid edit to recover. diff --git a/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.zh.md b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.zh.md index 57ad3bb687..9ecadde422 100644 --- a/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.zh.md +++ b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.zh.md @@ -12,19 +12,19 @@ Status: implemented 撤销 #932 中的五个提交,解决包移动冲突并保留后续独立行为。记录的合并提交属于更大的 #936 依赖链;撤销其第一父提交差异还会删除无关的仓库插件支持。[Vendor 修改记录](../../../../vendor/README.md#local-modifications) 按不变的固定来源记录每项保留的源码更改。 -Loader 立即更改条目选项。EntryGroup 并发启动同级条目并记录应用失败;EntryTree 等待未完成的工作,但不因失败的 fiber 而拒绝。两者均不恢复旧插件或配置。Include 保留解析校验和 patch 重应用,但插件失败可能留下部分应用的配置树。 +Loader 立即更改条目选项。EntryGroup 并发启动同级条目并记录应用失败;EntryTree 等待未完成的工作,但不因失败的 fiber 而拒绝。两者均不恢复旧插件或配置。Include 在文件无效时保留正在运行的配置树,但插件失败可能留下部分应用的配置树。 应用消费者负责完成检查。CLI 在安装实时 patch 监视器前等待其回退 HMR 服务。目录选择器检查其挂载的条目。选择器和浏览器包运行器在移除条目前取得首次 fiber 释放的结果,并等待该结果后才报告拆卸完成。预设挂载等待其子树,并报告导入、激活和缺少服务的失败。[应用启动](../../../../packages/boot/app-boot/README.zh.md) 负责精确 patch 文件监视、激活检查和部分上下文清理。Web 启动在打印 URL 或打开浏览器前检查激活状态。这些适配维持应用反向补丁后已有的消费者行为。 -Fiber、Entry 和 isolate 保持上游的更新返回行为。App boot 通过现有的 `internal/update` waterfall 观察被丢弃的重启 promise,并在检查 patch 重载前等待 fiber。游离的导入完成观察器处理 fiber 的两种结果;fiber 仍保留失败信息供显式检查。Include 的持久写入在删除子条目前后均排空,防止后续拆卸写入掩盖更早的终止性写入失败。 +Fiber、Entry 和 isolate 保持上游的更新返回行为。App boot 通过现有的 `internal/update` waterfall 观察被丢弃的重启 promise,并在检查 patch 重载前等待 fiber。游离的导入完成观察器处理 fiber 的两种结果;fiber 仍保留失败信息供显式检查。Include 的持久化遵循 vendor 修改记录中的 journal 适配:仍有未保存变更时,`flush()` 和 `stop()` 会拒绝。 -保留两项 #932 专属 vendor 改动:Include 中等待初始文件创建并强制重新读取,以及 Schemastery 条件导出。恢复 #932 前的防抖写入和读取顺序,会在缺失文件初始化测试中复现 `ENOENT`。保留这两行可以维持已有 `initial` 选项,而无需在应用侧增加文件写入器或另一份 YAML 序列化逻辑。移除 Schemastery exports 后,Web preset 测试在启动时复现 `ERR_REQUIRE_ESM_RACE_CONDITION`:并发 ESM 导入使 Node 回退到 CJS 入口。HMR 注入装饰器、条件 patch 克隆及更新返回值采用 #932 前的行为。显式 `workspace:` 依赖使 #932 的 workspace 链接开关与专用锁文件检查不再必要。 +保留一项 #932 专属 vendor 改动:Schemastery 条件导出。Include 1.1.1 会先写入 `initial` 文件再读取,因此缺失文件的初始化不再需要本地改动。移除 Schemastery exports 后,Web preset 测试在启动时复现 `ERR_REQUIRE_ESM_RACE_CONDITION`:并发 ESM 导入使 Node 回退到 CJS 入口。HMR 注入装饰器及更新返回值采用 #932 前的行为;patch 克隆遵循上游 Include。显式 `workspace:` 依赖使 #932 的 workspace 链接开关与专用锁文件检查不再必要。 ## 考虑过的替代方案 **保留事务化 Loader 更新。** 它们能从被拒绝的插件候选自动恢复,但会保留本次删除的 vendor 生命周期机制。解析失败可以独立于插件回滚进行处理。 -**逐字恢复所有 vendor 文件。** 这还会删除延迟注入配置求值、条件禁用条目、生命周期释放修复、持久写入和模块加载器兼容性。这些更改具有独立消费者,并继续记录在 vendor 修改记录中。 +**逐字恢复所有 vendor 文件。** 这还会删除延迟注入配置求值、条件禁用条目、生命周期释放修复和模块加载器兼容性。这些更改具有独立消费者,并继续记录在 vendor 修改记录中。 **将通用回滚移入应用启动。** 这会在另一归属下保留相同的候选代次与恢复义务。应用改为报告失败,并允许后续有效编辑恢复。 diff --git a/.agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.i18n.yaml b/.agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.i18n.yaml new file mode 100644 index 0000000000..e4e2d36818 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-11-02-deepseek-deferred-tool-failures.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.md +/agent-note-expose-deepseek-deferred-only-request-failures: + en: 1c4af8b4e2ec0e46 + zh: fcb806d33973025e +/agent-note-expose-deepseek-deferred-only-request-failures/problem: + en: c974406f21854541 + zh: 701cd64cabdfee79 +/agent-note-expose-deepseek-deferred-only-request-failures/decision: + en: 6878caaaebd91a41 + zh: 2ebd5da04dfb1ac6 +/agent-note-expose-deepseek-deferred-only-request-failures/alternatives-considered: + en: d10efff97379cfbb + zh: 572b124ddbb2d86d +/agent-note-expose-deepseek-deferred-only-request-failures/consequences: + en: d60323c595b076f4 + zh: 1bf1068424777b7e diff --git a/.agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.md b/.agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.md new file mode 100644 index 0000000000..78bc8d4a5c --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.md @@ -0,0 +1,25 @@ +# Agent Note: Expose DeepSeek deferred-only request failures + +Status: implemented + +English | [中文](2026-11-02-deepseek-deferred-tool-failures.zh.md) + +## Problem + +A synthetic immediate tool can make an all-deferred DeepSeek request acceptable while hiding an invalid tool-update projection. It also changes the request prefix and advertises a tool with no executor. + +## Decision + +The DeepSeek adapter sends deferred declarations unchanged and propagates provider rejection. This replaces the placeholder accommodation described by the [tool-update decision](../architecture/2026-09-20-dynamic-tool-updates.md), whose shared history projection remains authoritative. + +Ordinary addition-only histories start with native tools or an empty set. Removals and first additions to an empty set rebuild native declarations; later deferred additions retain a native baseline. Explicit deferred-only registrations, direct adapter calls, and custom in-history routes can still produce all-deferred requests. Those configurations receive no adapter workaround. + +## Alternatives considered + +**Keep a synthetic immediate tool.** It conceals unexpected deferred-only requests and cannot preserve a prefix that lacked that tool. Automatically activating a real tool instead changes its declared availability. + +**Reject locally.** Provider validation owns acceptance; another local restriction would duplicate it. Reconsider accommodation only for a supported workflow that requires deferred-only requests and cannot retain a native declaration. + +## Consequences + +Deferred-only compatibility is deliberately given up so request errors remain visible. Serializer and HTTP tests verify exact declarations and propagation of a provider rejection. The all-deferred keyless snapshot verifies request construction only; replay success does not establish provider acceptance. Replacement and empty-set scenarios verify the supported addition-only path. diff --git a/.agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.zh.md b/.agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.zh.md new file mode 100644 index 0000000000..f075aff3ad --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 显露 DeepSeek 全延迟请求失败 + +Status: implemented + +[English](2026-11-02-deepseek-deferred-tool-failures.md) | 中文 + +## Problem + +合成的立即加载工具可使 DeepSeek 接受全延迟请求,却掩盖无效的工具更新投影。它还会改变请求前缀,并声明一个没有执行器的工具。 + +## Decision + +DeepSeek 适配器原样发送延迟声明,并传递提供方的拒绝错误。这取代了[工具更新决策](../architecture/2026-09-20-dynamic-tool-updates.zh.md)中描述的占位工具兼容机制;其中的共享历史投影仍是权威依据。 + +普通 addition-only 历史以原生工具或空集开始。移除工具或首次向空集添加工具会重建原生声明;后续延迟添加保留原生基线。显式全延迟注册、直接调用适配器和自定义 in-history 路由仍可产生全延迟请求。适配器不为这些配置提供变通处理。 + +## Alternatives considered + +**保留合成的立即加载工具。** 它会掩盖意外的全延迟请求,也无法保留原本没有该工具的前缀。改为自动激活真实工具则会改变其声明的可用性。 + +**在本地拒绝。** 是否接受请求由提供方验证决定;额外的本地限制会重复该验证。只有受支持工作流确实需要全延迟请求,且无法保留原生声明时,才重新考虑兼容处理。 + +## Consequences + +有意放弃全延迟兼容性,使请求错误保持可见。序列化和 HTTP 测试验证精确声明以及提供方拒绝错误的传递。全延迟无密钥快照仅验证请求构造;回放成功不代表提供方接受。替换和空集场景验证受支持的 addition-only 路径。 diff --git a/.agents/skills/dsh-client-ui-ux/SKILL.md b/.agents/skills/dsh-client-ui-ux/SKILL.md index 0b8b72de99..4455609b71 100644 --- a/.agents/skills/dsh-client-ui-ux/SKILL.md +++ b/.agents/skills/dsh-client-ui-ux/SKILL.md @@ -1,76 +1,118 @@ --- name: dsh-client-ui-ux -description: Design and review DeepSeek Harness client UI changes — visual token discipline, reuse-before-adding, feedback surfaces (toast vs in-place notice vs empty state), overlay and menu safety, platform window adaptation, loading states, and error copy placement. Use when adding or changing product-user-visible GUI behavior in packages/client, or when reviewing such a PR. +description: "Design and review DSH user-visible UI: alignment, spacing, icons, visual foundation, geometry and interaction. Use before UI changes in client, experimental and app packages; verify affected rendered results before handoff." --- -# DeepSeek Harness Client UI/UX +# DeepSeek Harness UI development -This skill is guidance, not a complete checklist. It covers judgment calls that lint, typecheck, and the i18n gate cannot make: where feedback appears, what gets reused, and how surfaces behave at window edges and across platforms. Copy quality itself follows [dsh-prose-standard](../dsh-prose-standard/SKILL.md); locale ownership follows the [locale-owned copy decision](../../notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md); styling ownership and token rules live in [docs/web-styling.md](../../../docs/web-styling.md) and the [ui-primitives component catalog](../../../packages/client/ui-primitives/README.md#component-catalog) — this skill adds judgment on top of those rules, not a second copy of them. +Use this skill as the entry point for UI design and review. It owns the workflow, visual foundation, geometry and interaction guidance; alignment, spacing and icon details live in its references below. Load only the references relevant to the task; examples explain judgment, not coverage. -## Architecture prerequisites + +## UI change workflow -Component structure, state flow, and effect usage follow the architecture rules; read them before redesigning a component: +Use applicable design rules for UI changes, including visible loading, error, disabled, expanded and focus states. Choose effort by impact and regression risk; ordinary development does not require a full design audit. -- [packages/client/AGENTS.md](../../../packages/client/AGENTS.md) — Slot and props discipline, reactive read, export, ctx, and layering rules. -- [Client component rules](../../../docs/defensive-patterns/client-components.md) — those requirements stated with the defects they prevent. +| Change | Required inspection | Additional records | +|---|---|---| +| Local copy, icon reuse, color or spacing | Inspect the actual page, affected neighbors and relevant states; run applicable tests. Apply the minimum checks below. | Briefly report results and material gaps. No separate plan, axis drawing, scene inventory, source snapshot or screenshot report is required by default. | +| Page layout, shared components or global styles | Identify affected relationships and consumers; inspect distinct affected layout, content and state variants in assembled pages. Reuse evidence only for justified equivalents. | Briefly state scope. Preserve the starting version and comparable images when requested or needed to explain broad changes or assess regression risk. | +| Explicit comprehensive review | Cover the agreed regions and relevant states; distinguish inspected results, defects and uninspected scope. | Use the [review cookbook](../../../docs/cookbook/reviewing-ui-design.md) for coverage records, findings and supporting evidence. A comparison request alone does not require a product-wide audit. | -## Visual foundation +### Minimum checks for affected UI -- **Use existing tokens for color, radius, and spacing.** Before introducing a literal value, search the target page and its nearest established sibling (the Plugins page is the usual reference for full-page surfaces) for the token or value already in use, and adopt it. The same applies to whole treatments — toasts, menus, in-message artifact previews, sidebar tab layout, colors, clearance rules, page padding: reuse the established component or rule wherever one exists instead of inventing a parallel one. -- **Verify every color in both light and dark mode.** An ink/background pair must stay readable under both themes before it ships. Dimmed tones are the standing counter-example: they suit decorative surfaces, but on text they routinely fall below readability in one theme — never put a dimmed color on text without checking both. -- **Font weight tops out at 500 in feature CSS you add or change.** Do not use 600+ for emphasis; differentiate with size or ink instead. The markdown typography tokens owned by ui-theme are the standing exception. -- **Pick font sizes from the theme typography variables when an existing role matches, otherwise from the page's existing scale.** Parallel content shares one size; never invent a size for a single element, and keep the number of distinct sizes on a page small (13px is the established row/detail size). When the page itself has no precedent, borrow the size and spacing ratios of the most similar existing page or element. -- **Icons, text, and neighbouring elements stay proportionate and aligned.** Every element lines up with its siblings along a deliberate axis (baseline, vertical center, or shared edge), and an icon's size stays in proportion to the text beside it; never ship an element that aligns to nothing or visibly outweighs its neighbours. +These checks apply even to small changes; optional documentation does not make inspection optional. Open the actual page and inspect the changed element with its directly affected neighbors, rather than judging CSS or an isolated component alone. + +- **Color or opacity:** inspect light and dark themes, including affected foreground/background and state combinations. +- **Layout, spacing, typography or sizing:** inspect a constrained container and long or wrapping content; check both sides of a changed breakpoint, alignment, clipping and access to actions. +- **Interaction:** exercise the changed path and affected focus, disabled, loading, error, expanded and overlapping states. Check only states the change can affect, not every possible combination. +- **Shared components or global styles:** trace affected consumers and inspect distinct assembled layouts and states; one isolated example cannot establish coverage. + +If a required scene is unavailable, state what was not checked, why and how it can be verified; a labeled production-component fixture can provide partial evidence, not an actual-page pass. Do not claim verification complete while required checks remain missing. Run the original applicable tests; source inspection and passing tests alone do not establish the rendered result. + +Read unchanged owners once; consult examples for uncertain judgments. Inspect relevant states and actual usage sizes, not a fixed screenshot count or every width × theme × state combination. Batch related edits and reuse a current application instance and valid evidence. Documentation-only changes and refactors with evidence of unchanged rendering need no new UI captures; state that basis. + +When a before/after comparison is needed, preserve the actual starting implementation, including uncommitted work, and use comparable capture conditions. Never recreate a missing before image and present it as an original. Protect existing work regardless of whether extra evidence is needed. + +Before handoff, review or push, check only new cumulative interactions, missing required checks and invalidated evidence; do not repeat completed verification at each stage. Report results and material gaps accurately. Do not claim full coverage or visual acceptance when required scenes or checks are missing. + +These effort levels change only the additional design-review procedure. Existing [client test requirements](../../../packages/client/AGENTS.md), [GUI PR GIF requirements](../record-browser-gif/SKILL.md#every-gui-pull-request-includes-a-gif), and [designer/product approval](#design-review-escalation) still apply, including to local changes when their original triggers are met. + +## Select the rule owners + +For layout changes, read the alignment and spacing references. Read the icon reference when selecting, drawing or reviewing artwork, or when adding, changing or reviewing icon buttons, icon-only actions or informational icons, including reuse without artwork changes. Other tasks use the relevant sections below. Do not load all references by default. Existing responsive and official-artwork skills retain their scopes; numeric scales stay with their implementation owners. + +| Decision needed before implementation | Section or reference | +|---|---| +| Shared axes, text starts, centering, tree indentation, complete icon boxes | [Alignment](references/alignment.md) | +| Semantic groups, inner/outer gaps, repeated rhythm, section separation | [Spacing and grouping](references/spacing.md) | +| Interface icon selection and artwork; icon buttons and icon-only actions, tooltips, informational hover content, accessible labels and focus | [Icons](references/icons.md) | +| Color, typography and existing treatments | [Visual foundation](#visual-foundation); [Web styling](../../../docs/web-styling.md) owns tokens/materials | +| Component geometry, nested contours and state fills | [Geometry decisions](#component-geometry); [radius standard](../../../docs/ui-radius.md) owns values and exceptions | +| Feedback lifetime, loading, overlays, scrolling and platform clearance | [Interaction and feedback](#interaction-and-feedback); [error handling](../dsh-error-handling/SKILL.md) owns failure guarantees | +| Responsive layout rules, mobile-specific interactions, and truncation/ellipsis rules for components like text and buttons | [Responsive UI](../dsh-responsive-ui/SKILL.md) | +| Official plugin artwork and feature previews | [Official artwork](../dsh-official-plugin-artwork/SKILL.md) | +| Copy, locale ownership and component semantics | [Prose standard](../dsh-prose-standard/SKILL.md), [locale ownership](../../notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md), [primitives catalog](../../../packages/client/ui-primitives/README.md#component-catalog) | +| Density, information hierarchy, combined control states and component choice | [Principle reference](../../../docs/ui-design-review-cases.md#density) sections 3–6 | ## Reuse before adding -- **Extend an existing component, container, or interaction before creating a new one.** A new capability that fits an existing surface (an extra menu item, a new prop on an existing primitive) beats a parallel element stacked beside it. -- **Icons come from the existing icon library.** Pick the semantically closest existing icon; a new glyph requires designer approval before it lands — when reviewing a PR that adds one, suggest requesting a designer review. -- **Right-sidebar content registers a slot in the existing sidebar**, never a separate sidebar. Every sidebar tab declares a tab icon matching its meaning. -- **Icon-only actions whose meaning is not obvious get a Tooltip.** Informational content the pointer must rest on or select uses HoverCard instead. Prefer these shared primitives over ad-hoc title attributes. +Extend existing components, containers and interactions when the capability fits. Register right-sidebar content through a slot in the existing sidebar; do not create a separate sidebar. Follow [icon reuse](references/icons.md#reuse-existing-icons-first). Before restructuring, read [client instructions](../../../packages/client/AGENTS.md) and [client defensive patterns](../../../docs/defensive-patterns/client-components.md) for component/state ownership and layering. -## Feedback surfaces +## Visual foundation -Apply [dsh-error-handling](../dsh-error-handling/SKILL.md#localized-actionable-visible-reports) to failure reports. Choose the surface by the lifetime of the message relative to the surface that produced it: +- Reuse tokens, values and complete treatments from the target page or nearest established sibling before adding literals; Plugins is the usual full-page reference. +- Verify colors in light and dark themes; keep ink/background pairs readable, especially dimmed text. +- Feature CSS you add or change uses **font weight ≤500**; emphasize with size or ink. ui-theme markdown typography tokens are the exception. +- Use theme typography roles, then the page's scale, then a comparable page's size/spacing ratios. Do not invent a size for a single element. Peers share sizes; keep the scale small (13px is the established row/detail size). +- Apply [alignment](references/alignment.md) and the [same-foreground rule](references/icons.md#placement-and-use) to icon/text relationships, including value chevrons and search prompts. -- **Transient operation outcomes use the app-wide Toast primitive**, not in-place notices, and the toast's state and rendering live in a host that outlives the reporting surface (a `shell.overlay` entry, as RowActionToast does) — a toast rendered by the panel itself unmounts with that panel. A deletion can close the panel or tab that requested it; only a toast held outside that surface survives the close. Report success and failure; do not report still-pending states. -- **A failed operation keeps the data visible.** A failed deletion keeps its row; the toast announces the failure. Never blank content to show an error for a transient operation. -- **In-place notices are for states tied to the surface itself**: a query failure with its Retry action, field validation on the form that owns the field. A populated panel keeps a compact notice beside its retained content; only an empty panel centers a full error state. -- **Error copy is plain language, and short.** No internal jargon or technical nouns unless an error code must be exposed for debugging. Prefer one clear sentence; anything explainable in two sentences must not grow into paragraphs. A Chinese notice of at most two sentences omits the trailing 句号(。). The notice must not break the existing layout — reserve its space or overlay it; never shift neighbouring elements. +## Component geometry -## Loading states +Choose component role and size variant, then its container relationship. Compare height, radius, curve, inset and material together; equal radii alone do not establish coherence, and wrapping/expansion alone does not justify larger radii. [Radius standards](../../../docs/ui-radius.md#radius-scale) own values and exceptions. -- Lists use their skeleton; every other page-level load centers a bare spinner. Never place a spinner in a corner of the page. -- One loading treatment per page/component — concurrent regions must not each flash their own style. -- No loading text unless the context needs it (narrow sidebars almost never do). +- **Inset:** coordinate inner/outer curves and inset. Radius subtraction is exact for concentric circular arcs; for superellipses, subtraction is only a nominal sizing rule, so inspect diagonal whitespace as well as side gaps. +- **Flush or adjoining:** the enclosure owns exposed corners; internal joins stay square. Hover fills and pseudo-elements follow that contour without clipping menus or focus. +- **Independent child card/group:** use its own role/size tier, not the dialog radius or subtraction of all parent padding. +- **Circles/capsules:** preserve shape, including avatar container and image, using the theme's round-curve exception; ordinary controls use the shared curve. -## Overlays and menus +Keep fills, strokes and overlays geometrically stable across hover, pressed, selected, loading and disabled states. Reuse [card/elevation materials](../../../docs/ui-radius.md#settings-cards): shadows already include neutral hairlines; retain semantic state-colored borders without doubling neutral edges. -Every menu, popover, and tooltip must be verified for all three before merge: +Compare related controls, including small selection controls, at actual size in affected themes/states and relevant long-label/narrow layouts. Static checks reject unsupported declarations; rendered comparison judges fit. Consult [geometry cases](../../../docs/ui-design-review-cases.md#geometry) when needed. Apply to scoped changes, not bulk restyling. -- **Dismissable**: outside click (and Escape where focusable) closes it. -- **Viewport-fitting**: it slides or flips to stay inside the window with an edge margin, and flips only into a side that actually fits (no oscillation when neither side fits). -- **Unclipped**: it escapes `overflow` ancestors and containing blocks (portal to body where needed) and is never covered by neighbouring elements. +## Interaction and feedback -Scrollbars stay inside their container: use the shared scrollbar styles rather than component-specific selectors, and verify the bar neither overflows the container nor gets clipped by its rounded corners or edges — keep a small inset instead of letting it sit flush against the container edge. +### Feedback surfaces -## Responsive layouts +Follow [error handling](../dsh-error-handling/SKILL.md#localized-actionable-visible-reports). Choose feedback by message lifetime: -Use [dsh-responsive-ui](../dsh-responsive-ui/SKILL.md) for responsive layouts, mobile and touch detection, text truncation, title wrapping, icon-only controls, and focus during width changes. +- Transient success/failure uses the app-wide Toast, with state and rendering in a host that survives the reporting panel/tab, such as a `shell.overlay` entry. A deletion may close its source panel; a Toast owned by that panel would disappear with it. Do not toast pending states. +- Failed operations retain visible data; failed deletion keeps the row. +- Surface-owned failures use in-place notices: query failure with Retry or field validation. Keep a compact notice beside populated content; only empty panels center a full error state. +- Use short plain-language errors, preferably one sentence, without internal jargon except necessary diagnostic codes. Chinese notices of at most two sentences omit the trailing 句号(。). Reserve space or overlay notices so neighbors do not shift. -## Platform window adaptation +### Loading states -- Any new header element or full page must account for macOS and Windows window chrome. On macOS the titlebar clearance is `--dsh-frame-top-clearance`, consumed by Menu, Modal, and overlay primitives as their top safe margin — **never change the global variable to fix one page**. Add a scoped `[data-platform='darwin']` override on the page's own inset instead, and assert the override in the page's stylesheet spec. -- State the intended total offset (clearance + page inset) when reviewing, so two pages aligning to "the same top spacing" agree on the sum, not just one addend. +Lists use skeletons; other page-level loads center a bare spinner. Keep one loading treatment per page/component, including concurrent regions. Add loading text only when context requires it. -## Spacing review +### Overlays and menus -- Review every changed region for gaps: nothing sits flush against its neighbour without an intentional gap, and adjacent icons and elements keep the region's established spacing. -- Prefer symmetry for parallel elements; an unexplained one-off gap or offset usually signals a missed shared value. -- **For complex pages and containers, establish shared horizontal and vertical alignment axes before adjusting local spacing.** Consider hover, selected, and expanded/collapsed states, as well as the overall relationship with adjacent panels and other page elements. Choose from the following references according to layout similarity: - - **Sidebar:** align corresponding text starts and leading icons across vertically stacked rows. Align hover and selected row backgrounds with the buttons above the list to maintain visual balance. - - **Plugins page:** keep content left-aligned along a shared edge. List-row hover backgrounds may extend beyond that edge while the content stays in place. - - **Conversation and right-sidebar headers:** align the first header rows across both panels and match their heights. Apply the same rule to the second rows, and align corresponding underlines and dividers on shared horizontal baselines. +Before merge, verify every menu, popover and tooltip closes on outside click (and Escape when focusable), fits the viewport with an edge margin, and escapes ancestor clipping, containing blocks and neighbor coverage, using a portal to body when needed. Flip only into a side that fits; never oscillate when neither fits. + +Use shared scrollbar styles and a small inset; bars must stay inside containers without being clipped by rounded corners or edges. + +### Platform window adaptation + +New headers/pages account for macOS and Windows chrome. Menu, Modal and overlay primitives consume `--dsh-frame-top-clearance`; fix local macOS insets with scoped `[data-platform='darwin']` overrides and stylesheet assertions, not global-variable changes. Review the total clearance + page inset. + +## Check the rules together + +Inspect the composition, then affected internal/cross-group relationships, distant peers and expanded content. Match both endpoints of axes; compare complete icon boxes before optical adjustments. Preserve popup/focus visibility, field feedback, narrow-layout actions and drafts. Check affected theme materials/platform backdrops. Full-interface claims cover all regions; fixtures/crops establish only what they render. + +## Maintain one owner per rule + +Update the owning section or reference within this skill; do not expose its design specialties as separate skills. Keep examples in the [case reference](../../../docs/ui-design-review-cases.md); AGENTS and PR workflows link here instead of duplicating rules. + +Rule changes state applicability, decisions, exceptions and observable verification; update a distinguishing case and remove superseded statements elsewhere. Declare migration versus future-edit scope without implying existing UI conforms. Automate only mechanically decidable checks. Local review tools consume repository rules and retain source/rule revisions rather than maintaining another standards copy. ## Design review escalation diff --git a/.agents/skills/dsh-client-ui-ux/references/alignment.md b/.agents/skills/dsh-client-ui-ux/references/alignment.md new file mode 100644 index 0000000000..029e7768c7 --- /dev/null +++ b/.agents/skills/dsh-client-ui-ux/references/alignment.md @@ -0,0 +1,32 @@ +# UI alignment + +Apply within the [UI development loop](../SKILL.md#required-review-loop). Align comparable peers, including slots for optional icons and actions. + +## Layout structure first, visual calibration second + +Distinguish click target, painted button, complete icon box and drawn contour. Intentional icon whitespace belongs to the layout box; do not remove it merely because painted pixels miss a guide. Enlarging a target must not move that box. Check containers, slots, columns, gaps and unintended overrides before optical corrections. Neither matching CSS boxes nor aligning every painted contour alone establishes balance. + +A glyph inconsistent across uses belongs to its icon owner. A composition-specific optical correction needs a scoped justification and comparison with peers. Preserve intended hierarchy: a child icon may align with parent text, but DOM nesting alone does not determine indentation. When comparing a bare icon with a bordered control, extra hit padding is not a shared visible edge. + +Inline SVG `vertical-align: middle` follows font baseline/x-height, not visible text center. Check font, size, line height and icon box across CJK, Latin, monospace and wrapping. Do not make multiline links unwrappable just to center an icon. + +## Choose both the object and the axis + +Name the aligned object and reference container. Equal padding need not align text starts; equal SVG dimensions need not align visible centers. For a close control beside footer actions, explicitly choose the control edge or complete icon-box edge; do not switch between them while measuring. + +| Alignment | Use it for | Judge the actual relationship | Common mistake | +|---|---|---|---| +| Left edge | Navigation labels, list titles, field labels, reading content | Same-role text starts on one deliberate axis, including across groups | Every component has its own plausible but different inset | +| Horizontal center | A standalone primary button or short empty-state stack | Center the icon-plus-label or full stack in the named container | Center the label alone and attach an icon outside it | +| Vertical center | A single-line icon/label row or peer toolbar controls | Visible centers balance; the whole clickable unit keeps a stable height | Equal CSS heights hide different SVG whitespace or line boxes | +| Baseline / first line | Mixed text sizes, multiline labels with leading icons | Text baselines agree; an icon belongs to the intended first line | Center an icon beside the entire multiline paragraph | +| Two edges | Title/action rows and left/right composer groups | Left group follows the content edge; right group follows the trailing edge | Distribute every icon across the available width | +| Repeated columns | Action clusters, timestamps, file rows | Corresponding slots share centers or edges across rows | Only the last icon aligns; preceding icons drift | + +For Chinese/English navigation, identify leading icon centers, text starts and trailing controls by role. Headings, avatars and expandable search may differ. Continue axes only where the composition establishes them, including shared plugin reading edges even when hover paint extends beyond them, and conversation/right-sidebar header heights and baselines. + +For a trigger and its menu, or search field and result rows, compare the complete text inset: outer padding, borders, leading slot and gap. Different icon sizes can retain one text start. For adjacent statistics, check font family, size, weight and line height together before moving the icon. + +Center a button within its own bounds, a welcome group within the main pane, and an empty state within the content remaining below headings/filters. A fixed top margin is not centering. When a short window cannot fit the group, preserve scroll access with a compact top-aligned layout instead of clipping. + +Normalize peer control sizes and slots before any necessary small optical offsets; verify comparable uses. Preserve usable, non-overlapping hit targets and do not hide inconsistent parent layout with transforms. Transparent hit padding can exceed the painted icon slot; check neighboring target edges after tightening a cluster. Check [spacing](spacing.md) without moving the chosen axes; consult [cases](../../../../docs/ui-design-review-cases.md#alignment) for uncertain judgments. diff --git a/.agents/skills/dsh-client-ui-ux/references/icons.md b/.agents/skills/dsh-client-ui-ux/references/icons.md new file mode 100644 index 0000000000..5d9bbefb19 --- /dev/null +++ b/.agents/skills/dsh-client-ui-ux/references/icons.md @@ -0,0 +1,60 @@ +# UI icons + +Apply within the [UI development loop](../SKILL.md#required-review-loop), in the affected page or modal context. + +## Reuse existing icons first + +Search the library and comparable UI for the intended meaning and supported variants. Reuse the shared component; do not copy its SVG into a feature or create a near-duplicate. Size, color and placement mismatches belong to the variant or consumer layout, not a new drawing. + +Draw only when existing artwork cannot express the meaning or the user requests a distinct design. Before drawing, explain the closest alternatives and the unmet meaning or requested design difference. New glyphs require designer approval before landing; requested drafts can be prepared locally. Do not redraw a shared glyph to fix one consumer. + +The [primitives icon contract](../../../../packages/client/ui-primitives/README.md#controls-and-icons) and icon source own available variants, stroke scaling and menu sizes; [official artwork](../../dsh-official-plugin-artwork/SKILL.md) owns package artwork. + +## System style + +- Use minimal, geometric, functional glyphs with familiar meanings. Match peers in contour, endings, joins, weight, whitespace and complexity; avoid decorative, playful or illustration-like detail. A supplied reference guides the requested meaning and distinctive form within this system. +- Use simple geometry and restrained rounding. Around 1px at a 16px design size is a starting point for softened corners, not a rule for every turn. Preserve useful sharp corners. +- Default to monochrome outlines inheriting `currentColor`, without gradients, shadows, textures or 3D effects. Preserve intentional fills, status dots, selected marks and knockouts. File, site, plugin and brand artwork retain their own palettes and roles. +- Balance structure and whitespace without requiring symmetry; avoid accidental tilt or a top-heavy silhouette. Preserve meaningful direction. Use gaps, overlap, nesting and broken obscured lines to clarify complex meanings; avoid accidental tangencies, dark knots and disappearing gaps at actual size. Add contour detail only when it improves recognition; do not force every subject into a closed outline. + +## Optical size and clarity before implementation + +Use the owning control's supported size and weight. [Shared menu sizes and stroke scaling](../../../../packages/client/ui-primitives/README.md#controls-and-icons) belong to primitives; [permission triggers](../../../../packages/client/ui-permission-presets/README.md) and [settings navigation](../../../../packages/client/ui-settings-general/README.md) belong to their feature packages. A reviewed proposal for a larger or heavier trigger is not a statement that every existing consumer already uses it. Compare at the owning control's size, weight, colors and label, using an outlined circle and rectangular control as references. Open silhouettes may need more extent than dense fills; supporting chevrons, handles and dots keep their roles. Do not normalize by ink area or fill every view box. + +A shared view-box change affects every consumer at its own CSS size. Inspect both compact menus and larger icon-only controls; a clearer small chevron can become oversized in a floating button. Adjust a consumer’s supported icon size when needed while retaining its button base and hit target. + +For softness, check CSS dimensions and ancestor transforms, view box and drawing bounds, stroke versus filled contour, device-pixel placement, then color/opacity. Non-16 view boxes are valid; compare identical artwork before attributing sharpness to inline SVG versus sprites. + +Use the [implemented stroke behavior](../../../../packages/client/ui-primitives/README.md#controls-and-icons): source stroke widths scale with the SVG viewport, and filled outlines scale as geometry. Do not add non-scaling strokes or convert filled contours during an ordinary placement or reuse fix. A change to that rendering contract belongs to a separately scoped library change with consumer validation; retain intentional silhouettes and knockouts. + +Check straight-stroke edges at actual DPR after all scaling: center and width jointly determine coverage. Integer source coordinates alone do not guarantee crisp edges. Round positioned overlays in device pixels only when layout places otherwise crisp artwork between pixels; preserve anchoring and viewport containment. Keep antialiasing on curves/diagonals; do not globally round paths or use `crispEdges` to hide placement errors. Partial coverage is legitimate for 1.3px strokes. + +## Draw a new icon + +Establish meaning, placement, actual sizes, peers and states; ask only when missing information changes the design. Produce editable SVG; shared line icons need Regular and Medium variants sharing construction; intentional fill-only variants may share geometry. Tune silhouette, gaps, center and weight at intended size using component/theme conventions, not consumer-specific stroke or scale overrides. + +Deliver editable artwork and a rendered peer comparison, including its label or control when relevant. Explain the semantic choice and unresolved alternatives. Keep drafts local; when implementing, register reusable components and update their documentation. Distinguish review-ready proposals from approved glyphs. + +## Normalize user-supplied artwork + +Before changing supplied artwork, establish its meaning, recognizable features and intended use. Preserve the original and recognizable form. Show an existing equivalent as a reference when available, but do not substitute it for the drawing the user asked to normalize. Separate technical fixes (canvas, scale, stroke, color inheritance, placement) from silhouette or metaphor changes; identify any required simplification. + +Apply the same system style and verification criteria as new artwork. Inspect SVG paths, transforms, fills, strokes and references. Reconstruct raster references without tracing rasterization or compression artifacts; identify interpreted details and ask about unrecoverable defining features. Deliver editable SVG and original/normalized/peer comparisons under identical conditions at intended size. Keep artwork changes separate from consumer layout fixes. + +## Placement and use + +Distinguish click target, visible button base, icon slot and painted glyph. Equal targets or SVG dimensions do not imply equal optical size; compare silhouette, stroke, whitespace and center beside the label. Fix containers and unintended CSS overrides first. Correct shared artwork only when the imbalance follows it across uses. + +An icon and its associated text for one action, value or input prompt must share foreground color, effective opacity and state changes. This includes submenu value chevrons and search placeholders; inherit the shared role instead of assigning an independent icon token. Typed text and independent clear actions may differ; status, warning and brand roles retain semantic colors. Check computed color and ancestor opacity in both themes. Apply the rule to changed consumers; updating it does not certify untouched UI. Compare native file icons and folder outlines deliberately; do not normalize logos, status dots or decorative illustrations to toolbar icons. + +Every right-sidebar tab needs a meaningful icon. [Alignment](alignment.md) owns positioning and [spacing](spacing.md) owns gaps. + +## Icon controls and interaction + +Use the shared Tooltip for unclear icon-only actions and HoverCard for informational content requiring hover or selection; prefer these primitives over ad-hoc `title` attributes. Preserve accessible labels and focus. [Responsive UI](../../dsh-responsive-ui/SKILL.md) owns mobile tooltips and label collapse. + +## Verification and handoff + +For ordinary icon reuse or placement changes, inspect the affected control at its actual size and relevant states; no fixed size/DPR matrix or separate raster report is required. For new or changed artwork, strokes or rendering, compare affected sizes and weights with peers; check DPR 1 and 2 when stroke scaling or pixel placement can change, and both themes when color or optical weight is affected. Keep before/after captures when needed for the change or requested deliverable. Judge recognition, clipping, center, gaps and neighboring text at actual size; enlarged views diagnose pixels only. + +After a set-wide change, trace affected families, consumers and weights, including unchanged artwork whose rendering can change. Use unchanged peers for comparison. An explicitly requested full-set review inventories and compares every glyph under matching conditions. Consolidate duplicate artwork only when necessary for the current fix; record unrelated duplicates separately instead of expanding the task. Consult [cases](../../../../docs/ui-design-review-cases.md#icon-optics) for uncertain judgments. diff --git a/.agents/skills/dsh-client-ui-ux/references/spacing.md b/.agents/skills/dsh-client-ui-ux/references/spacing.md new file mode 100644 index 0000000000..b2d173692f --- /dev/null +++ b/.agents/skills/dsh-client-ui-ux/references/spacing.md @@ -0,0 +1,19 @@ +# UI spacing and grouping + +Apply within the [UI development loop](../SKILL.md#required-review-loop). Distance should explain which text, input and action belong together. + +### Identify the group before choosing the gap + +Group elements by one object, action or repeated unit, not DOM siblings, background or React component. State that relationship before positioning. Check each nesting level independently; larger outer gaps do not require larger inner gaps. + +For example, a title and description introduce one section; three conversation actions form one operation group; member cards form a section separate from tasks. In a model form, checkbox and label form an option, options form a field, fields form a model, and models form a list. Check each relationship rather than applying one gap everywhere. + +Without an explicit divider, comparable neighboring groups need visibly greater separation between groups than within them. Equality is only a lower bound against reversal. Repeated compositions share gaps, padding and alignment; label length does not justify unrelated spacing. + +When wrapping, metadata, subgroups or independent actions increase complexity, increase enclosing separation enough to retain grouping. A compact icon/label pair and a section of multiline cards need different spacing levels. Do not derive gaps from card area or multiply every margin. Dividers and distinct surfaces may reinforce separation, but headings must remain closer to their own content than to preceding groups. + +Judge actual whitespace at normal size, including padding, line-height, glyph whitespace and borders. CSS margin ratios alone do not establish grouping; explanatory labels need one unambiguous owner. For example, declared 16px/8px margins can still leave a heading closer to the preceding content once line-height and container padding are included; compare the rendered gaps rather than adopting that pair as a rule. + +When matching two visible gaps, name both pairs of endpoints and include each container’s padding, border and text line box. Equal visible gaps may require unequal margins. A control’s painted edge and a text glyph’s edge are different endpoints; keep that choice stable through review. + +Reuse page spacing and [style tokens](../../../../docs/web-styling.md), preserve [alignment](alignment.md) axes, and consult [cases](../../../../docs/ui-design-review-cases.md#grouping) for uncertain judgments. This skill defines no numeric scale. diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index e5cf8c079f..2be16611a8 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -39,6 +39,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo — ## Manual checks +- **UI review coverage:** apply the [UI change workflow](../dsh-client-ui-ux/SKILL.md#required-review-loop) to the cumulative visible diff. Verify its [minimum checks](../dsh-client-ui-ux/SKILL.md#minimum-checks-for-affected-ui) for the changed properties and consumers. Missing required inspection remains a gap; missing optional reports does not. Do not demand full-audit records for ordinary changes. Recheck only new interactions or invalidated evidence. Existing tests, GUI GIFs and designer/product approval remain required under their original triggers. - **Intent and interface contracts:** trace both sides of every changed interface. Confirm the implementation matches the PR and any Agent Note, including errors, cancellation, ownership, and disposal. - **Error handling:** apply [dsh-error-handling](../dsh-error-handling/SKILL.md) to changed failure paths, including their state guarantees and final user-visible reports. Reject catch-and-continue unless the state owner establishes that subsequent work's preconditions hold, including after recovery. - **Lifecycle and concurrency:** for async setup, callbacks, processes, or teardown, apply [defensive-patterns/general.md](../../../docs/defensive-patterns/general.md). Check races before publication, cancellation during awaits, independent error reporting, callback containment, ownership before reentry, complete detach cleanup, and quiescent disposal. diff --git a/.agents/skills/dsh-official-plugin-artwork/SKILL.md b/.agents/skills/dsh-official-plugin-artwork/SKILL.md index 809409c8b5..fce1964d39 100644 --- a/.agents/skills/dsh-official-plugin-artwork/SKILL.md +++ b/.agents/skills/dsh-official-plugin-artwork/SKILL.md @@ -7,6 +7,8 @@ description: Draw and review official DeepSeek Harness plugin icons and optional Draw recognizable artwork that belongs beside the existing official icons. Official and official experimental plugins share this visual language; the UI label communicates experimental status. This guidance does not replace designer approval. +When reviewing the containing page, use the [UI review baseline](../../../docs/ui-design-review-cases.md#review-baseline) to combine artwork balance with its tile, label, neighboring cards and theme material. Keep package artwork distinct from action icons: coherent optical weight does not require equal drawing bounds, monochrome fills or identical geometry across those roles. + ## Optional feature previews Before drawing a larger image for the top of an official bundle's detail document, read and follow [adding a plugin feature preview](../../../docs/cookbook/adding-a-plugin-preview.md). It owns optionality, image count, design canvas, position, language variants, file location, and acceptance. The guide also owns the embedded-raster exception for SVG previews; small icons remain geometry-only. The icon dimensions below do not apply to previews. Inspect the real feature and preserve its produced assets. @@ -14,7 +16,7 @@ Before drawing a larger image for the top of an official bundle's detail documen ## Establish the subject - Inspect the actual feature, its output, and its owning manifest before drawing. A badge-producing skill may need a tag, not a medal. Reuse designer-supplied assets; do not invent a brand mark or change product names, identifiers, or unrelated icons without approval. -- Read the [existing artwork](../../../packages/client/ui-primitives/src/plugin-artwork.tsx) and the nearest relevant package SVGs. Borrow one coherent treatment, not every reference's effects. Action icons remain with [dsh-client-ui-ux](../dsh-client-ui-ux/SKILL.md#reuse-before-adding). +- Read the [existing artwork](../../../packages/client/ui-primitives/src/plugin-artwork.tsx) and the nearest relevant package SVGs. Borrow one coherent treatment, not every reference's effects. Action icons remain with [the UI design entry](../dsh-client-ui-ux/SKILL.md#select-the-rule-owners). - Check every affected entry, list, detail, and component row. Bundle artwork and a component's name and icon can have separate metadata owners; changing one does not update the other. ## Draw and refine diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md index 3d55a9dece..e10d03cecc 100644 --- a/.agents/skills/dsh-pre-push-checks/SKILL.md +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -33,6 +33,7 @@ When the outgoing change adds or changes a resource-owning or asynchronous test, - **Package or script behavior:** run the owning Vitest file or focused test name. Add adjacent package tests when a shared contract changes; leave repository-wide coverage to CI unless the change is genuinely cross-cutting or the user requests it. - **Remote mock typing:** unbuilt `any` is an explicit local fallback, not strict evidence. Run `pnpm run typecheck` before handing off Remote/mock changes; rebuild missing, stale, or partial generated declarations before diagnosing remaining errors. Keep the exception in the [test proxy](../../../packages/test-support/remote-mock/README.md#remote-proxy), never in production Remote types, ambient flags, or copied signatures. - **Documentation, Agent Notes, catalogs, or doc-linked comments:** run `pnpm run doc-sync`; run full lint when the documentation workflow requires it. +- **User-visible UI:** use the [UI change workflow](../dsh-client-ui-ux/SKILL.md#required-review-loop) and its [minimum checks](../dsh-client-ui-ux/SKILL.md#minimum-checks-for-affected-ui) to verify required inspection and new cumulative effects. Disclose missing checks; optional paperwork is not a substitute for inspecting the result. Reuse valid evidence; ordinary changes do not need a separate full-audit report at push time. Keep existing test, GUI GIF and designer/product approval requirements. Tests alone do not establish visual acceptance. - **Model-, editor-, CLI-, or terminal-visible output:** run the focused keyless snapshot or real runnable-example scenario that owns the output. - **Expected-output placement:** a test whose selected recorded Session generation is replay input and expected persisted output belongs under top-level `snapshots/`, with `snapshot.yml` naming its shipped `dsh` profile and composition/header pin. Canonical parent files are `session[.vN].jsonl`, children are `session.[.vN].jsonl`, and the harness selects the highest generation per role. ARIA, geometry, generator, CLI, and unit expectations without that Session round trip stay beside their owning test under `tests/expected/`; do not place them in `snapshots/` or give them a `*.snapshot.ts` owner. Use the owning `test:expected`, `test:web`, or `test` lane. - **Profile and configuration placement:** cross-package behavior of a shipped `dsh` profile belongs under `apps/cli/tests/profiles/`; a package-specific Loader composition belongs under that package's `tests/fixtures/`. User-facing optional overlays live under `apps/cli/config/examples/` and pair with a guide under `docs/user/`. diff --git a/.agents/skills/dsh-responsive-ui/SKILL.md b/.agents/skills/dsh-responsive-ui/SKILL.md index 73e0a8f944..44b2fbe977 100644 --- a/.agents/skills/dsh-responsive-ui/SKILL.md +++ b/.agents/skills/dsh-responsive-ui/SKILL.md @@ -5,6 +5,8 @@ description: Use when designing, implementing, or reviewing responsive client la # Responsive client UI +For visual reviews, select the [impact scope](../../../docs/cookbook/reviewing-ui-design.md#review-scope), then apply these behavioral requirements and the [UI review baseline](../../../docs/ui-design-review-cases.md#review-baseline). Compare affected wrapping and layout relationships in the whole page or modal's context while preserving drafts, focus and action access. Inspect all regions for an explicit full-interface review. Examples do not define coverage; fitting the viewport alone is insufficient. + ## 1. Minimum viewport Use a viewport width of 390 CSS px as the primary acceptance minimum. There must be no unintended overlap, clipping, page-wide horizontal scrolling, or inaccessible primary actions. Below 390px, layout requirements may be relaxed. diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index a4ed098609..47ba28681e 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -73,6 +73,12 @@ jobs: - name: Run blocking Windows builds shell: pwsh run: pnpm run check:ci:windows-blocking + - name: Verify Desktop updater dependency versions + shell: pwsh + run: | + node --test apps/desktop/tests/updates/electron-updater/versions.test.mjs + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + node apps/desktop/tests/updates/electron-updater/check-versions.mjs - name: Parse Windows runner supervision scripts shell: pwsh run: | diff --git a/.github/workflows/macos-intel-package.yml b/.github/workflows/macos-intel-package.yml index 4f60c1b2a7..32f0a34e4b 100644 --- a/.github/workflows/macos-intel-package.yml +++ b/.github/workflows/macos-intel-package.yml @@ -15,9 +15,9 @@ on: default: test required: true update_channel: - description: '升级通道:nightly 或 latest(须为稳定版本);自动更新不会切换通道' + description: '升级通道:nightly 或 stable(须为稳定版本);默认通道由此打包输入确定' type: choice - options: [nightly, latest] + options: [nightly, stable] default: nightly required: true version_mode: diff --git a/.github/workflows/macos-package.yml b/.github/workflows/macos-package.yml index eff4ebe10d..9a525d318a 100644 --- a/.github/workflows/macos-package.yml +++ b/.github/workflows/macos-package.yml @@ -15,9 +15,9 @@ on: default: test required: true update_channel: - description: '升级通道:nightly 或 latest(须为稳定版本);自动更新不会切换通道' + description: '升级通道:nightly 或 stable(须为稳定版本);默认通道由此打包输入确定' type: choice - options: [nightly, latest] + options: [nightly, stable] default: nightly required: true version_mode: diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index 29229605de..76a4b68f10 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -15,9 +15,9 @@ on: default: test required: true update_channel: - description: '升级通道:nightly 或 latest(须为稳定版本);自动更新不会切换通道' + description: '升级通道:nightly 或 stable(须为稳定版本);默认通道由此打包输入确定' type: choice - options: [nightly, latest] + options: [nightly, stable] default: nightly required: true version_mode: diff --git a/.gitignore b/.gitignore index 4d52487a06..51b7cf53dd 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ apps/web/dist/ .artifacts/ .dsh-build/ .dsh-report/ +.ui-review/ .playwright-mcp/ .orig .worktrees/ diff --git a/AGENTS.md b/AGENTS.md index db164319da..278a2be722 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -157,6 +157,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and - **Create Agent Notes only for durable decision rationale;** mechanical/local edits are exempt, including local UI changes ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)). - **Official plugin artwork:** use [dsh-official-plugin-artwork](.agents/skills/dsh-official-plugin-artwork/SKILL.md) for official and official experimental plugin icons; obtain designer approval before landing new glyphs. - **Client UI copy is locale-owned.** Route product text through typed dictionaries and `t` or localized primitive props; `verify-client-ui-i18n` rejects hardcoded copy ([decision](.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). English copy, including plugin titles, uses sentence case ([rule](.agents/skills/dsh-prose-standard/SKILL.md#required-coverage-by-prose-location)). +- **UI changes:** use [dsh-client-ui-ux](.agents/skills/dsh-client-ui-ux/SKILL.md) as the design and review entry point; load its references only as needed. - **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible change updates a keyless recorded-session snapshot; [snapshot ownership](snapshots/AGENTS.md) reserves the top-level tree for session-driven cases and keeps other expected output owner-local. Fixtures replay on macOS/Linux; fix fixtures, not normalizers. - **Design each tool's UI presentation up front.** Host presenters stay pure; Web cards derive from raw events and persisted result metadata ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for capability seams, lifecycle paths, and transcript output; include missing snapshot-harness support in the same change. diff --git a/apps/cli/config/examples/team-mode.yml b/apps/cli/config/examples/team-mode.yml new file mode 100644 index 0000000000..5204952bd1 --- /dev/null +++ b/apps/cli/config/examples/team-mode.yml @@ -0,0 +1,3 @@ +- id: agent-team + config: + defaultEnabled: true diff --git a/apps/cli/package.json b/apps/cli/package.json index 3df78de1fb..5bddabe27e 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh", "description": "dsh CLI: profile launch, plugin management, and configuration inspection", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index ffd79b8daa..2c33416553 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -3,7 +3,7 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md /dsh-cli-behavior-reference: - en: 5a4f43756dae900e + en: abffba1bcc44698c zh: d58c479481cfa5fe /dsh-cli-behavior-reference/profile-boot: en: b474a6fc3c98837d @@ -24,8 +24,8 @@ en: 149c5571acd51baf zh: 300e3c2425273a92 /dsh-cli-behavior-reference/shared-deployment-behavior: - en: f40edcf903a51f3f - zh: 8a9a915451047c8b + en: f02219118dcec531 + zh: fe2977628d7340da /dsh-cli-behavior-reference/source-execution: - en: 05c2b23790f7b296 - zh: 51f809020fb230a1 + en: 0fd6c72ba9a7eb3b + zh: 011c0ac4008ee480 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index a375450048..dc84a267d8 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -6,6 +6,8 @@ This reference defines the profile, plugin-management, and config-dump command m The [generated command help](../../../docs/cli-help.md) contains complete launcher, plugin-management, and shipped-profile help. Run `pnpm run gen-cli-help` to refresh it; `pnpm run verify-cli-help` checks it against the current CLI. + + ## Profile boot `dsh ` abbreviates `dsh --profile ` and boots the profile at `$DSH_HOME/profiles/`. The shorthand name must immediately follow `dsh`; `plugin` remains the plugin-management command, so boot a profile with that name using `dsh --profile plugin`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch ` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. The final YAML composition controls whether `dsh-hmr` watches configuration; without HMR, changes require restart. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit. @@ -134,7 +136,7 @@ Web, headless, and SDK inherit native tool execution from the base bundle; use a ## Shared deployment behavior -The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search` and `web_fetch`, the HTTP fetch provider, default-on DeepSeek session-log upload, and feedback-gated OTel upload for all users. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`. Enabled fetch calls run in every sandbox and approval mode without per-call confirmation and can reach loopback and private-network hosts. The Web app disables the base tool row and exposes the same tools through its `cordis`, `ptc`, and `standard` agent presets. +The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search` and `web_fetch`, the HTTP fetch provider, default-on DeepSeek session-log upload, and feedback-gated OTel upload for all users. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. The DeepSeek Search and Fetch APIs follow the session's DeepSeek credentials; custom endpoints and keys can only be set in YAML through `web-deepseek-api.searchBaseURL` / `searchApiKeyEnv` and `fetchBaseURL` / `fetchApiKeyEnv` ([authentication](../../../packages/web/web-deepseek-api/README.md#authentication)). Legacy Messages search reads `DEEPSEEK_SEARCH_BASE_URL`. Enabled fetch calls run in every sandbox and approval mode without per-call confirmation and can reach loopback and private-network hosts. The Web app disables the base tool row and exposes the same tools through its `cordis`, `ptc`, and `standard` agent presets. Feedback is recorded in the Session log without starting model work. The [DeepSeek session-log contributor](../../../packages/session/session-log-deepseek/README.md) sends complete unaccepted log suffixes with subsequent DeepSeek requests by default, including requests sent through configured gateways; turn off **Settings → General → Upload Session Log when using the official model API** in Web, or set its `enabled` configuration to `false` to opt out. [OTel session upload](../../../packages/session/session-telemetry-otel/README.md) applies to all users and providers, including `deepseek-official`, without requiring a request header. The base defaults to `FEEDBACK_ONLY`: new own text feedback, message ratings, edits, and withdrawals release the complete canonical prefix through that event, including stored context; later records wait for the next explicit feedback. Inherited parent feedback does not authorize a fork. Requests, restoration, mount, and HMR do not trigger capture. SDK batching may finish an authorized upload without further interaction or model work. `DSH_TELEMETRY_MODE=DISABLED` disables OTel delivery; `FULL` is rejected, and any non-empty `DSH_TELEMETRY_DISABLED` disables its row. `DSH_TELEMETRY_OTLP_URL` selects the collector. Handoff is best-effort, not collector acceptance; no durable outbox or retry guarantee is provided. These OTel settings do not enable or disable the DeepSeek contribution. Neither path changes model input, but exports can include message text, tool arguments and results, and workspace paths. @@ -143,4 +145,4 @@ Install external plugin bundles through `dsh plugin --profile add ## Source execution -From the repository root, run `pnpm run build` separately after a fresh checkout and whenever artifacts need updating, then use `pnpm dsh `. The `package.json` script launches `apps/cli/src/bin.ts` with `node --import tsx/esm` without building and forwards every argument. Missing Typert host artifacts fail profile boot through module-resolution errors without a build instruction. Once those host artifacts exist, missing frontend or client-plugin bundles fail at startup with an instruction to run `pnpm run build`. The launcher does not check freshness, so existing stale bundles can run older browser code until rebuilt. The process inherits the launch environment, and `runProfile` resolves the outbound proxy from that snapshot before any entry mounts, so `HTTP_PROXY`/`HTTPS_PROXY` (and a proxy declared in a `.env` layer) apply without any further flag. The source entry explicitly selects linked profile resolution so tsx workspace imports and profile plugins share module identities. The installed form launches the built `apps/cli/lib/bin.js` with runtime profile resolution without rebuilding the repository. +From the repository root, run `pnpm run build` separately after a fresh checkout and whenever artifacts need updating, then use `pnpm dsh `. The `package.json` script launches `apps/cli/src/bin.ts` with `node --import tsx/esm` without building and forwards every argument. Missing Typert host artifacts fail profile boot through module-resolution errors without a build instruction. Once those host artifacts exist, missing frontend or client-plugin bundles fail at startup with an instruction to run `pnpm run build`. The launcher does not check freshness, so existing stale bundles can run older browser code until rebuilt. The process inherits the launch environment, and `runProfile` resolves the outbound proxy from that snapshot before any entry mounts, so `HTTP_PROXY`/`HTTPS_PROXY` (and a proxy declared in a `.env` layer) apply without any further flag. The source entry explicitly selects linked profile resolution so tsx workspace imports and profile plugins share module identities. On Windows, the installed command expands 8.3 paths before loading profile dependencies. The installed form launches the built `apps/cli/lib/bin.js` with runtime profile resolution without rebuilding the repository. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 642fa18bc5..2de7755983 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -136,7 +136,7 @@ Web、headless 与 SDK 继承 base bundle 的原生工具执行;使用 [PTC ov ## 共享部署行为 -基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和 `web_fetch`、HTTP fetch 提供方,默认开启的 DeepSeek 会话日志上传,以及面向所有用户的反馈门控 OTel 上传。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`。已启用的抓取调用会在所有 sandbox 与审批模式下执行,无需逐次确认,并且可以访问 loopback 与内网主机。Web app 会禁用 base 工具配置项,再通过 `cordis`、`ptc` 与 `standard` agent preset 暴露相同工具。 +基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和 `web_fetch`、HTTP fetch 提供方,默认开启的 DeepSeek 会话日志上传,以及面向所有用户的反馈门控 OTel 上传。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。DeepSeek Search 与 Fetch API 跟随会话的 DeepSeek 凭据;自定义 endpoint 和密钥只能在 YAML 中通过 `web-deepseek-api.searchBaseURL` / `searchApiKeyEnv` 和 `fetchBaseURL` / `fetchApiKeyEnv` 设置([鉴权](../../../packages/web/web-deepseek-api/README.zh.md#authentication))。旧版 Messages 搜索读取 `DEEPSEEK_SEARCH_BASE_URL`。已启用的抓取调用会在所有 sandbox 与审批模式下执行,无需逐次确认,并且可以访问 loopback 与内网主机。Web app 会禁用 base 工具配置项,再通过 `cordis`、`ptc` 与 `standard` agent preset 暴露相同工具。 反馈记录在会话日志中,不会启动模型工作。[DeepSeek 会话日志贡献器](../../../packages/session/session-log-deepseek/README.zh.md)默认随后续 DeepSeek 请求发送尚未确认接收的完整日志后缀,包括经已配置网关发送的请求;可在 Web 的**设置 → 通用 → 在使用官方模型 API 时上传 Session Log**中关闭上传,或将其 `enabled` 配置设为 `false`。[OTel 会话上传](../../../packages/session/session-telemetry-otel/README.zh.md)适用于所有用户和提供方,包括 `deepseek-official`,无需请求头。基础配置默认使用 `FEEDBACK_ONLY`:新的自身文本反馈、消息评分、编辑与撤回会释放截至该事件的完整规范日志前缀,包含存储的上下文;后续记录等待下一次显式反馈。继承的父级反馈不构成 fork 的授权。请求、恢复、挂载和 HMR 不触发捕获。SDK 批处理可完成已授权上传,无需进一步交互或模型工作。`DSH_TELEMETRY_MODE=DISABLED` 禁止 OTel 投递;`FULL` 被拒绝,任何非空的 `DSH_TELEMETRY_DISABLED` 都会禁用其配置行。`DSH_TELEMETRY_OTLP_URL` 选择采集端。交接尽力而为,不代表采集端接受;不提供持久化 outbox 或重试保证。这些 OTel 设置不会开启或关闭 DeepSeek 贡献。两条路径都不改变模型输入,但导出可能包含消息文本、工具参数和结果,以及工作区路径。 @@ -145,4 +145,4 @@ Web、headless 与 SDK 继承 base bundle 的原生工具执行;使用 [PTC ov ## 源码执行 -请在仓库根目录中,于全新 checkout 之后及产物需要更新时单独运行 `pnpm run build`,然后使用 `pnpm dsh `。`package.json` 中的脚本不会构建,而是通过 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`,并转发所有参数。Typert Host 产物缺失时,profile 启动会因不含构建指引的模块解析错误而失败。这些 Host 产物存在后,如果前端或 Client plugin 组合包缺失,启动会失败并提示运行 `pnpm run build`。启动器不会检查产物是否为最新,因此已有的陈旧组合包可能继续运行旧版浏览器代码,直至重新构建。该进程会继承启动环境,且 `runProfile` 会在任何 entry 挂载之前从该快照解析出站代理,因此 `HTTP_PROXY`/`HTTPS_PROXY`(以及写在 `.env` 层中的代理)无需任何额外开关即可生效。源码入口显式选择链接式 profile 解析,使 tsx workspace 导入与 profile 插件共享模块身份。安装形式会使用 runtime profile 解析直接启动构建后的 `apps/cli/lib/bin.js`,不会重新构建仓库。 +请在仓库根目录中,于全新 checkout 之后及产物需要更新时单独运行 `pnpm run build`,然后使用 `pnpm dsh `。`package.json` 中的脚本不会构建,而是通过 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`,并转发所有参数。Typert Host 产物缺失时,profile 启动会因不含构建指引的模块解析错误而失败。这些 Host 产物存在后,如果前端或 Client plugin 组合包缺失,启动会失败并提示运行 `pnpm run build`。启动器不会检查产物是否为最新,因此已有的陈旧组合包可能继续运行旧版浏览器代码,直至重新构建。该进程会继承启动环境,且 `runProfile` 会在任何 entry 挂载之前从该快照解析出站代理,因此 `HTTP_PROXY`/`HTTPS_PROXY`(以及写在 `.env` 层中的代理)无需任何额外开关即可生效。源码入口显式选择链接式 profile 解析,使 tsx workspace 导入与 profile 插件共享模块身份。在 Windows 上,安装版命令在加载 profile 依赖前展开 8.3 短路径。安装形式会使用 runtime profile 解析直接启动构建后的 `apps/cli/lib/bin.js`,不会重新构建仓库。 diff --git a/apps/cli/src/installed-bin.ts b/apps/cli/src/installed-bin.ts new file mode 100644 index 0000000000..a9e9b2ae7b --- /dev/null +++ b/apps/cli/src/installed-bin.ts @@ -0,0 +1,28 @@ +#!/usr/bin/env node +/** + * Installed CLI entry for disk launchers and embedded runtimes. + * @module @deepseek-ai/dsh/installed-bin + */ +import { realpathSync } from 'node:fs' +import { pathToFileURL } from 'node:url' +import type { RunCliOptions } from './bin.ts' + +/** + * Run the CLI in the importing carrier's module tree. + * @param options - Installation-owned runtime options. + * @returns Settlement of the selected command. + */ +export async function runCli(options?: RunCliOptions): Promise { + const cli = await import('./bin.ts') + await cli.runCli(options) +} + +if (import.meta.main) { + // Windows ESM can retain an 8.3 spelling while profile resolution expands it. + const entry = process.platform === 'win32' ? pathToFileURL(realpathSync.native(import.meta.filename)).href : import.meta.url + if (entry === import.meta.url) await runCli() + else { + const canonical = await import(entry) as typeof import('./installed-bin.ts') + await canonical.runCli() + } +} diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index a97d158697..1c8765d268 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -344,6 +344,20 @@ function startStartupProfile(fixture: StartupFixture, args: readonly string[]) { describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => { testProfileResolution('lib') + it.runIf(process.platform === 'win32')('resolves the installed entry before loading CLI dependencies', async () => { + const home = mkdtempSync(join(tmpdir(), 'dsh-cli-alias-')) + try { + const alias = join(home, 'launcher') + symlinkSync(join(repoRoot, 'apps/cli/lib'), alias, 'junction') + const { stdout } = await execa(process.execPath, [ + '--preserve-symlinks-main', join(alias, 'bin.js'), '--version', + ], { timeout: SPAWN_TIMEOUT_MS, env: { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1' } }) + expect(stdout).toBe(cliVersion) + } finally { + rmSync(home, { recursive: true, force: true }) + } + }, SPAWN_TIMEOUT_MS + 5_000) + it('requires a profile and rejects removed flags', async () => { const bare = await runBuiltBin() expect(bare.code).toBe(1) diff --git a/apps/cli/tests/profile-mcp.spec.ts b/apps/cli/tests/profile-mcp.spec.ts index b082d8316b..4fbc3de396 100644 --- a/apps/cli/tests/profile-mcp.spec.ts +++ b/apps/cli/tests/profile-mcp.spec.ts @@ -1,4 +1,4 @@ -/** MCP resource ownership across the resolved shipped profile templates. */ +/** MCP resource and server-manager ownership across the resolved shipped profile templates. */ import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -41,4 +41,17 @@ describe('shipped MCP resource composition', () => { rmSync(home, { recursive: true, force: true }) } }) + + it.each(Object.keys(PROFILE_TEMPLATES))('%s mounts the server manager only with the Web application', (name) => { + const home = mkdtempSync(join(tmpdir(), 'dsh-profile-mcp-')) + try { + const profile = loadProfile('dsh', name, installAnchor, home) + const rows = composeEntries([...profile.layers.map(layer => layer.patches), profile.patches]) + expect(rows.filter(row => row.name === '@deepseek-ai/dsh-mcp-manager')).toEqual(name === 'web' + ? [{ id: 'mcp-manager', name: '@deepseek-ai/dsh-mcp-manager', inject: ['credentials'] }] + : []) + } finally { + rmSync(home, { recursive: true, force: true }) + } + }) }) diff --git a/apps/cli/tests/profiles/headless/subagent-inheritance-snapshot.patch.yml b/apps/cli/tests/profiles/headless/subagent-inheritance-snapshot.patch.yml index 7511e92167..83547c6412 100644 --- a/apps/cli/tests/profiles/headless/subagent-inheritance-snapshot.patch.yml +++ b/apps/cli/tests/profiles/headless/subagent-inheritance-snapshot.patch.yml @@ -40,3 +40,8 @@ - id: session-log-deepseek config: enabled: false + +# The recorded tool catalog includes the optional patch tool. +- id: tool-fs + config: + applyPatch: true diff --git a/apps/cli/tests/profiles/headless/tests/fixtures/cli.patch.yml b/apps/cli/tests/profiles/headless/tests/fixtures/cli.patch.yml index aa22a2d2f0..5b1f799910 100644 --- a/apps/cli/tests/profiles/headless/tests/fixtures/cli.patch.yml +++ b/apps/cli/tests/profiles/headless/tests/fixtures/cli.patch.yml @@ -42,8 +42,14 @@ mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write' workspaceRoot: !!js process.cwd() -# A search key reference no runner sets: the turn runs as a deployment without -# web search credentials, whatever the launching environment exports. +# Own key references no runner sets on the DeepSeek Search and Fetch APIs: the +# turn runs as a deployment without web search credentials, whatever +# DEEPSEEK_API_KEY the launching environment exports. +- id: web-deepseek-api + config: + searchApiKeyEnv: DSH_KEYLESS_SMOKE_SEARCH_KEY + fetchApiKeyEnv: DSH_KEYLESS_SMOKE_FETCH_KEY + - id: web-search-deepseek config: apiKeyEnv: DSH_KEYLESS_SMOKE_SEARCH_KEY diff --git a/apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts index 0272ff8752..3710ec082a 100644 --- a/apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts @@ -61,7 +61,7 @@ describe('headless-agent keyless smoke', () => { expect(String(result?.['output'])).toContain('CLI_TOOL_ROUND_TRIP') expect(persistedHeader).toMatchObject({ type: 'session' }) expect(persistedToolNames).toEqual(expect.arrayContaining(['read', 'write', 'edit', 'web_fetch'])) - // Search has no key in this composition, so the model request does not offer it. + // Neither DeepSeek search provider has a key in this composition, so the model request does not offer search. expect(persistedToolNames).not.toContain('web_search') expect(persistedToolNames).not.toContain('str_replace_editor') }, PRODUCTION_PROFILE_TEST_TIMEOUT_MS) diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index a232e82874..81c9eb56ea 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -57,6 +57,7 @@ describe('Python SDK dsh profile keyless smoke', () => { { label: 'reports max-token turns with mapping enabled through env', envValue: 'true', editorEnabled: false }, { label: 'reports max-token turns with mapping disabled through env', envValue: 'false', editorEnabled: false }, { label: 'allows an explicit patch to enable str_replace_editor', envValue: undefined, editorEnabled: true }, + { label: 'allows an explicit patch to enable apply_patch', envValue: undefined, editorEnabled: false, patchEnabled: true }, { label: 'uses the PTC executor overlay despite the retired environment selector', envValue: undefined, editorEnabled: false, ptc: true }, // The shipped sdk profile mounts no observation policy: an edit of a file the model never read lands on disk. { label: 'edits a file the model never read through the shipped profile', envValue: undefined, editorEnabled: false, unreadEdit: true }, @@ -64,11 +65,14 @@ describe('Python SDK dsh profile keyless smoke', () => { const { envValue, editorEnabled } = scenario const ptc = 'ptc' in scenario && scenario.ptc const unreadEdit = 'unreadEdit' in scenario && scenario.unreadEdit + const patchEnabled = 'patchEnabled' in scenario && scenario.patchEnabled const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-runtime-smoke-')) const unreadFile = join(root, 'notes.txt') if (unreadEdit) await writeFile(unreadFile, 'alpha\n') const editorPatch = join(root, 'editor.patch.yml') + const fsPatch = join(root, 'fs.patch.yml') + if (patchEnabled) await writeFile(fsPatch, '- {id: tool-fs, config: {applyPatch: true}}\n') if (editorEnabled) await writeFile(editorPatch, [ '- insert:', ' - id: tool-str-replace-editor', @@ -103,6 +107,7 @@ describe('Python SDK dsh profile keyless smoke', () => { '--profile', 'sdk', ...(editorEnabled ? ['--patch', editorPatch] : []), + ...(patchEnabled ? ['--patch', fsPatch] : []), ...(ptc ? ['--patch', join(repoRoot, 'apps/cli/config/examples/ptc.yml')] : []), ], { cwd: repoRoot, @@ -189,16 +194,16 @@ describe('Python SDK dsh profile keyless smoke', () => { const toolNames = tools.map(tool => tool.name) expect(modelRequests[0]?.output_config).toEqual({ effort: 'max' }) expect(modelRequests[0]?.max_tokens).toBe(1234) - const expectedTools = ['read', 'write', 'edit', 'apply_patch', 'web_fetch', 'web_search', 'subagent', 'subagent_fork', + const expectedTools = ['read', 'write', 'edit', 'web_fetch', 'web_search', 'subagent', 'subagent_fork', 'present', 'ask_user_question', 'schedule_create', 'schedule_edit', 'schedule_list', 'schedule_view', 'plugin_manager'] const requestText = JSON.stringify(modelRequests[0]) if (ptc) { expect(toolNames).toEqual(['run_code']) - // The PTC overlay keeps the pre-apply_patch tool set, as the Web ptc preset does. - for (const tool of expectedTools.filter(tool => tool !== 'apply_patch')) expect(requestText.includes(`${tool}:`), tool).toBe(true) + for (const tool of expectedTools) expect(requestText.includes(`${tool}:`), tool).toBe(true) expect(requestText.includes('apply_patch:')).toBe(false) } else { expect(toolNames).toEqual(expect.arrayContaining(expectedTools)) + expect(toolNames.includes('apply_patch')).toBe(patchEnabled) expect(toolNames.includes('str_replace_editor')).toBe(editorEnabled) expect(toolNames).not.toContain('list_subagent_models') } diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 792187acae..ff5fd57cae 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -300,7 +300,7 @@ describe('the shipped Web composition', () => { // excluded for the reason the TUI composition e2e excludes them — they // depend on ripgrep being present on the machine. expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([ - 'apply_patch', 'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode', + 'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'list_subagent_models', 'plugin_manager', 'present', 'read', 'read_image', 'schedule_create', 'schedule_edit', 'schedule_list', 'schedule_view', 'send_message', 'skill', diff --git a/apps/cli/tsdown.config.ts b/apps/cli/tsdown.config.ts index e85aadc6e8..11437c24af 100644 --- a/apps/cli/tsdown.config.ts +++ b/apps/cli/tsdown.config.ts @@ -5,7 +5,7 @@ import { defineConfig } from 'tsdown' * Declarations come from `tsc -b` (dts: false), matching every package. */ export default defineConfig({ - entry: ['lib/types/bin.js', 'lib/types/profile-boot.js'], + entry: { bin: 'lib/types/installed-bin.js', 'profile-boot': 'lib/types/profile-boot.js' }, outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/apps/desktop-host/package.json b/apps/desktop-host/package.json index 93469b15f2..9e4d9cc0f4 100644 --- a/apps/desktop-host/package.json +++ b/apps/desktop-host/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-desktop-host", "description": "Private Node-mode host process for the Electron desktop application", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "private": true, "license": "MIT", "type": "module", diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 899796c03a..081d44f9f8 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -60,8 +60,8 @@ en: 67bf0a18faa7aa76 zh: 782e14ce62bb939f /deepseek-harness-desktop/updates/local-desktop-settings: - en: 2e57457c44c85b5f - zh: 5b8510a3dc4c6c9d + en: 01461be5b81e8609 + zh: 58e818df821a3aa2 /deepseek-harness-desktop/updates/mandatory-update-policy: en: 182a9c6705213e13 zh: 681762f83490f4c6 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index bf36a7b9fe..335991f722 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -302,7 +302,17 @@ On first launch, including after an upgrade, Desktop creates this document if it } ``` -Existing documents are never overwritten; an omitted `updates` object or field resolves to `false`. Set the field to `true` to permit test authentication dialogs, then fully quit and restart the application. Set it back to `false` and restart to suppress them again. Creation, read, or validation failures use the default `false` without blocking startup. Desktop attempts to warn through the Electron console with the file path; unavailable logging does not block startup. Correct the file and restart to apply an explicit setting. Unknown fields are preserved. Installation updates retain this user-data file; Windows uninstallation removes it under the [uninstall rules](#windows-uninstall-rules). +Startup loading never overwrites existing documents; an omitted `updates` object or field resolves to `false`. Set the field to `true` to permit test authentication dialogs, then fully quit and restart the application. Set it back to `false` and restart to suppress them again. Creation, read, or validation failures use the default `false` without blocking startup. Desktop attempts to warn through the Electron console with the file path; unavailable logging does not block startup. Correct the file and restart to apply an explicit setting. Unknown fields are preserved. Installation updates retain this user-data file; Windows uninstallation removes it under the [uninstall rules](#windows-uninstall-rules). + +In Desktop **Settings → General → Update channel**, **Stable** selects `stable` and **Development** selects `nightly`. The main process saves `updates.channel` in this same file and applies the choice immediately through IPC; no Host settings service is involved. An omitted channel uses the build-injected package default. External edits and deletion are read only on the next launch; no file watcher runs. The authentication-popup setting remains startup-only. UI writes retain unrelated fields from the latest valid document; external editors must not write concurrently with a UI save. + +During a UI switch, the selector shows the pending choice with a loading indicator. Failure restores the actual runtime selection and shows a short toast; ordinary save failures keep the current channel and update target accessible. Invalid startup settings or failure to retire the previous update suspends update operations; without a mandatory policy block, the application remains usable. Startup settings errors show a non-blocking ten-second toast with a details action. The update indicator retains the suspension reason independently of download progress; open it for recovery guidance and a button to reveal the settings file. Fix the file, then choose **Reload settings** in the error details or restart; this explicit reload applies only update settings and never rewrites the file. The reload button shows a busy indicator for at least half a second and rejects repeated clicks. Invalid files keep updates paused. Selecting a valid channel in Settings can also recover an invalid channel; malformed JSON must be repaired first. A known mandatory-update block remains enforced; its configuration-error action opens the same file-location and reload dialog. Successful channel changes trigger a fresh mandatory-policy check, waiting for an older channel’s in-flight check if necessary. Successful switching retires pending checks, downloads and installation eligibility before checking the new feed, including when both feeds advertise the same version. Switching never downgrades or installs automatically. Installation preparation rejects UI switches; once `quitAndInstall()` hands off, file edits cannot cancel that installation. + +The shared settings storage distinguishes missing files, read failures, malformed JSON, invalid document structure and invalid individual fields. Popup and channel fields are validated independently with Zod; an invalid popup field does not invalidate a valid channel, and vice versa. Reads never create files; startup initialization is explicit and shares the writer lock with saves. A save preserves unknown and unrelated invalid fields. Write diagnostics distinguish no write, uncertain completion and a committed write followed by lock-cleanup failure; the last case is not a rollback. + +Once installation eligibility is revoked, obsolete-file removal failures do not block switching. They are flushed to `update-cleanup.jsonl` in Electron’s logs directory with the time, operation, path and filesystem error; inability to write that log falls back to stderr. Later download failures are reported independently and are not attributed to an earlier cleanup warning. Structured filesystem errors distinguish insufficient space, denied writes and busy files; the error dialog retains the current error code and supplied file paths in its details. Errors without structured evidence keep the generic download-failure guidance. + +Updater adapter compatibility and platform version records are documented in [Electron updater compatibility](tests/updates/electron-updater/README.md). diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 6241072116..82ccf994a4 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -302,7 +302,17 @@ Electron 主进程在启动时读取一次 `app.getPath('userData')/desktop/sett } ``` -已有文件不会被覆盖;缺少 `updates` 对象或该字段时均按 `false` 处理。将字段设为 `true` 后完全退出并重启应用,即可允许测试鉴权弹窗。改回 `false` 并重启即可再次禁止。创建、读取或校验失败时使用默认值 `false`,不阻止启动。Desktop 尝试在 Electron 控制台记录包含文件路径的警告;日志不可用也不阻止启动。修正文件并重启后才会应用显式设置。未知字段会保留。安装更新保留此用户数据文件;Windows 卸载按[卸载规则](#windows-uninstall-rules)删除它。 +启动读取不会覆盖已有文件;缺少 `updates` 对象或该字段时均按 `false` 处理。将字段设为 `true` 后完全退出并重启应用,即可允许测试鉴权弹窗。改回 `false` 并重启即可再次禁止。创建、读取或校验失败时使用默认值 `false`,不阻止启动。Desktop 尝试在 Electron 控制台记录包含文件路径的警告;日志不可用也不阻止启动。修正文件并重启后才会应用显式设置。未知字段会保留。安装更新保留此用户数据文件;Windows 卸载按[卸载规则](#windows-uninstall-rules)删除它。 + +在桌面版**设置 → 通用设置 → 更新渠道**中,**正式版**选择 `stable`,**开发版**选择 `nightly`。主进程在同一文件中保存 `updates.channel`,并通过 IPC 立即应用选择,不依赖 Host 设置服务。省略 channel 时使用打包注入的包内默认值。外部编辑和删除仅在下次启动读取,不运行文件监听器。鉴权弹窗设置仍须重启生效。UI 写入保留最新有效文件中的其他字段;外部编辑器不能与 UI 保存同时写入。 + +UI 切换期间,选择器显示待应用选项和加载指示;失败时恢复实际运行渠道,并显示简短 toast。普通保存失败不会阻止使用当前渠道及旧更新目标。启动配置无效或无法撤销旧更新安装资格会暂停更新操作;没有强更策略阻塞时,主应用仍可使用。启动配置错误会显示持续十秒、可查看详情的非阻塞提示。更新入口独立于下载进度保留暂停原因;打开后可查看恢复指引、配置路径并在系统文件管理器中定位文件。修正文件后,可在错误详情中选择**重新读取配置**或重启;显式重读只应用更新配置,不改写文件。重读按钮至少显示半秒加载指示,期间不接受重复点击;文件仍无效时继续暂停更新。在设置中选择有效渠道也可恢复无效渠道;JSON 损坏须先修复文件。已知强更阻塞继续生效;强更页面的配置异常入口可打开同一文件定位和重读对话框。渠道切换成功后立即重新检查强更策略;如旧渠道的检查仍在进行,则等待其完成后检查新渠道。切换成功时先收尾检查、下载并撤销安装资格,再检查新渠道;两个渠道版本号相同也遵循该规则。切换不降级,也不自动安装。安装准备期间拒绝 UI 切换;调用 `quitAndInstall()` 交接后,编辑文件不能取消本次安装。 + +共享设置存储区分文件缺失、读取失败、JSON 损坏、文档结构无效和单项字段无效。鉴权开关与渠道字段分别通过 Zod 校验;鉴权开关无效不会使有效渠道失效,反之亦然。读取不创建文件;启动初始化是显式操作,并与保存共用写锁。保存保留未知字段和无关的无效字段。写入诊断区分未写入、完成情况不确定,以及写入已提交但释放锁失败;最后一种情况不表示文件已回滚。 + +撤销安装资格后,旧文件删除失败不阻止渠道切换。失败时间、操作、路径及文件系统错误会刷新写入 Electron 日志目录的 `update-cleanup.jsonl`;日志本身无法写入时回退到标准错误输出。后续下载失败独立报告,不归因于之前的清理警告。有结构化文件系统错误时,区分空间不足、无法写入和文件占用;错误弹窗详情保留当次错误码及已提供的文件路径。没有结构化依据时保留通用下载失败提示。 + +更新适配层兼容性和平台版本记录见 [Electron updater 兼容性](tests/updates/electron-updater/README.zh.md)。 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 40bdfefe27..3f95b9aa8b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-desktop", "description": "Electron desktop shell for a bundled dsh runtime and external plugins", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "private": true, "license": "MIT", "author": { @@ -15,6 +15,7 @@ "bundle": "tsdown --config-loader native", "dev": "tsx scripts/dev.ts", "start": "tsx scripts/dev.ts --skip-build", + "test:updates:channels": "pnpm run build && node scripts/test-update-channels.mjs", "test:updates:local": "pnpm run build && node scripts/test-local-updater.mjs", "prepare:runtime": "tsx scripts/prepare-runtime.ts", "render:tray-icon": "tsx scripts/render-tray-icon.ts", diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index 49412773f7..40f26fd202 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -18,8 +18,8 @@ en: 0b35c7471c09b27a zh: cc4a382b99fc3210 /desktop-packaging/release-versions: - en: 3f38ab26ee195b39 - zh: f00eddafa8f3b9ca + en: 8cc17b1e5ae3e422 + zh: 7de3e4f1e76be3e3 /desktop-packaging/configuration-and-local-builds: en: 5b4f0f80a117f202 zh: ff05be196c181087 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index 502a95e2be..54cb16cb29 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -63,7 +63,7 @@ pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.202609 A production release publishes the product version and takes no `--build-version`. Uploading one tags the packaged commit as `desktop-v` once the artifacts are public; a build from a modified checkout is not tagged, and a tagging failure prints the command to run by hand rather than failing a completed upload. Test and local builds are deliberately left untagged, and every artifact records `dshBuildCommit` and `dshBuildDirty` in its manifest so a build handed over directly remains traceable. -The installer embeds `DSH_DESKTOP_UPDATE_CHANNEL` (`nightly` by default, or `latest`) independently of version and deployment. Automatic updates remain on that channel even for a stable version; manually installing the other channel switches it. Latest packages reject prerelease versions. A stable product can produce a nightly `-test..` build, but that version ranks below the stable base and cannot upgrade an already installed higher version. Keep automatic downgrade disabled; stable corrective releases require a product version bump. CI version selection and publication are documented in [CI publication](../publishing/ci/README.md#package-and-publish). +The installer embeds `DSH_DESKTOP_UPDATE_CHANNEL` (`nightly` by default, or `stable`) independently of version and deployment. This value supplies the default channel; the saved selection in [Desktop settings](../README.md#local-desktop-settings) takes precedence, including after installing another package. Stable-channel packages reject prerelease versions. A stable product can produce a nightly `-test..` build, but that version ranks below the stable base and cannot upgrade an already installed higher version. Keep automatic downgrade disabled; stable corrective releases require a product version bump. CI version selection and publication are documented in [CI publication](../publishing/ci/README.md#package-and-publish). diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 7f43a0c2e5..7c76afdb50 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -63,7 +63,7 @@ pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.202609 production 发布使用产品版本本身,不传 `--build-version`。其上传成功后会把打包所用 commit 打成 `desktop-v<版本>` 标签;来自有改动工作区的构建不打标签,打标签失败也只打印手工命令,不会让已完成的上传变成失败。test 与本地构建有意不留标签,而所有产物的清单都记录 `dshBuildCommit` 与 `dshBuildDirty`,直接分发的构建同样可溯源。 -安装包独立于版本和发布环境内置 `DSH_DESKTOP_UPDATE_CHANNEL`(默认 `nightly`,也可选择 `latest`)。自动更新始终保留该通道,即使安装的是稳定版本;手动安装另一通道的包才会切换。latest 包拒绝预发布版本。稳定产品可生成 nightly 的 `-test..` 构建,但该版本低于稳定基准,不能升级已安装的更高版本。保持自动降级关闭;稳定修复版需要提升产品版本。CI 版本选择与发布见 [CI 发布](../publishing/ci/README.zh.md#package-and-publish)。 +安装包独立于版本和发布环境内置 `DSH_DESKTOP_UPDATE_CHANNEL`(默认 `nightly`,也可选择 `stable`)。该值提供默认渠道;[桌面设置](../README.zh.md#local-desktop-settings)中保存的选择优先,包括安装另一个包之后。stable 包拒绝预发布版本。稳定产品可生成 nightly 的 `-test..` 构建,但该版本低于稳定基准,不能升级已安装的更高版本。保持自动降级关闭;稳定修复版需要提升产品版本。CI 版本选择与发布见 [CI 发布](../publishing/ci/README.zh.md#package-and-publish)。 diff --git a/apps/desktop/publishing/README.i18n.yaml b/apps/desktop/publishing/README.i18n.yaml index 99b4b44aef..e74d7c1ff0 100644 --- a/apps/desktop/publishing/README.i18n.yaml +++ b/apps/desktop/publishing/README.i18n.yaml @@ -21,8 +21,8 @@ en: eed7c4ed0d4e2758 zh: 026fe6076e11018e /desktop-publication/publish-installers-and-update-feeds: - en: 3c3e3a09573c4386 - zh: c851ae6a94f2284a + en: 4723059d6f16b25b + zh: 54e5afad8fff8eb6 /desktop-publication/fixed-installer-downloads: en: ce8296f8f1ee881e zh: 749ab63bd6ed4248 diff --git a/apps/desktop/publishing/README.md b/apps/desktop/publishing/README.md index b9bb98af2b..1584a106e8 100644 --- a/apps/desktop/publishing/README.md +++ b/apps/desktop/publishing/README.md @@ -59,9 +59,9 @@ pnpm run package:desktop:mac:arm64 pnpm run upload:mac:arm64 ``` -The target dotenv file for internal-test packaging explicitly sets `DSH_DESKTOP_AUTO_UPDATE_ENV=test` and `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`; uploads use `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` and separate test credentials. Both test and production use the fixed Nightly channel; deployment selection does not enable channel switching. +The target dotenv file for internal-test packaging explicitly sets `DSH_DESKTOP_AUTO_UPDATE_ENV=test` and `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`; uploads use `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` and separate test credentials. Deployment and update channel are independent; see [package channel selection](../packaging/README.md#release-versions). -Early internal-test packages use the `test` deployment. Select `production` explicitly only for a production release; changing upload credentials does not retarget an existing package. Packaging requires no COS credentials, disables electron-builder publishing, strips COS credentials from child processes, and records completion only after signing and notarization succeed. Upload validates this record, deployment, target, shared version, filenames, sizes, and SHA-512 before reading credentials. Packages and blockmaps upload before YAML; historical objects are retained. Every release publishes `nightly.yml` or `nightly-mac.yml`; stable releases also publish `latest.yml` or `latest-mac.yml` pointing at the same artifacts. Published YAML uses absolute binary URLs. The uploader leaves Cache-Control unset, including the empty header the COS SDK would otherwise add: deployment infrastructure owns cache policy, with uncached feeds and separately configurable binary caching. Serialize publication per target and verify public artifacts and feed contents before release qualification. +Early internal-test packages use the `test` deployment. Select `production` explicitly only for a production release; changing upload credentials does not retarget an existing package. Packaging requires no COS credentials, disables electron-builder publishing, strips COS credentials from child processes, and records completion only after signing and notarization succeed. Upload validates this record, deployment, target, shared version, filenames, sizes, and SHA-512 before reading credentials. Packages and blockmaps upload before YAML; historical objects are retained. Each package publishes only its selected channel: `nightly.yml` / `nightly-mac.yml` or `stable.yml` / `stable-mac.yml`. Published YAML uses absolute binary URLs. The uploader leaves Cache-Control unset, including the empty header the COS SDK would otherwise add: deployment infrastructure owns cache policy, with uncached feeds and separately configurable binary caching. Serialize publication per target and verify public artifacts and feed contents before release qualification. Windows NSIS uploads require the generated, nonempty `.exe.blockmap` beside the installer. The blockmap is uploaded before channel YAML; NSIS installer metadata does not require the embedded `blockMapSize` used by the separate web-installer format. File-plan tests use the pinned builder's blockmap generator, not a hand-authored embedded-map field. diff --git a/apps/desktop/publishing/README.zh.md b/apps/desktop/publishing/README.zh.md index 0f64fb31ee..b19afbb68f 100644 --- a/apps/desktop/publishing/README.zh.md +++ b/apps/desktop/publishing/README.zh.md @@ -59,9 +59,9 @@ pnpm run package:desktop:mac:arm64 pnpm run upload:mac:arm64 ``` -内测打包在目标 `.env` 中显式设置 `DSH_DESKTOP_AUTO_UPDATE_ENV=test` 和 `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`;上传使用 `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` 及独立测试凭据。test 和 production 都使用固定 Nightly 通道,部署选择不提供通道切换。 +内测打包在目标 `.env` 中显式设置 `DSH_DESKTOP_AUTO_UPDATE_ENV=test` 和 `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`;上传使用 `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` 及独立测试凭据。部署与更新渠道相互独立;参见[安装包渠道选择](../packaging/README.zh.md#release-versions)。 -前期内测包使用 `test` 部署。只有正式发布才显式选择 `production`;更换上传凭据不会改变已有安装包的更新目标。打包不需要 COS 凭据,会禁用 electron-builder 发布、移除子进程的 COS 凭据,并且仅在签名与公证成功后记录完成状态。上传在读取凭据前验证该记录、部署、目标、共同版本号、文件名、大小与 SHA-512。安装包和 blockmap 先于 YAML 上传;历史对象继续保留。每个版本发布 `nightly.yml` 或 `nightly-mac.yml`;稳定版本还发布指向相同产物的 `latest.yml` 或 `latest-mac.yml`。发布的 YAML 使用安装包绝对 URL。上传器不设置 Cache-Control,包括 COS SDK 否则会添加的空头部:缓存策略由部署基础设施负责,清单不缓存,安装包缓存单独配置。同一目标应串行发布,并在发布验收前验证公网产物与清单内容。 +前期内测包使用 `test` 部署。只有正式发布才显式选择 `production`;更换上传凭据不会改变已有安装包的更新目标。打包不需要 COS 凭据,会禁用 electron-builder 发布、移除子进程的 COS 凭据,并且仅在签名与公证成功后记录完成状态。上传在读取凭据前验证该记录、部署、目标、共同版本号、文件名、大小与 SHA-512。安装包和 blockmap 先于 YAML 上传;历史对象继续保留。每个包仅发布自身选定渠道的清单:`nightly.yml` / `nightly-mac.yml` 或 `stable.yml` / `stable-mac.yml`。发布的 YAML 使用安装包绝对 URL。上传器不设置 Cache-Control,包括 COS SDK 否则会添加的空头部:缓存策略由部署基础设施负责,清单不缓存,安装包缓存单独配置。同一目标应串行发布,并在发布验收前验证公网产物与清单内容。 Windows NSIS 上传要求安装包旁存在生成的非空 `.exe.blockmap`。blockmap 先于通道 YAML 上传;NSIS 安装包元数据不要求另一种 web-installer 格式使用的内嵌 `blockMapSize`。文件清单测试使用固定版本构建器的 blockmap 生成器,而不是手工编造内嵌映射字段。 diff --git a/apps/desktop/publishing/ci/README.i18n.yaml b/apps/desktop/publishing/ci/README.i18n.yaml index 14ef478ed7..e01a4e7c0c 100644 --- a/apps/desktop/publishing/ci/README.i18n.yaml +++ b/apps/desktop/publishing/ci/README.i18n.yaml @@ -24,11 +24,11 @@ en: 0f43f2e193e95043 zh: 1c1efb062c06f7b8 /desktop-ci-publication/package-and-publish: - en: f7c12d343891e547 - zh: da596ee1366c2046 + en: ee8d79ac75482502 + zh: 1d986a76860ed262 /desktop-ci-publication/recovery-and-records: - en: b62a7484929f9941 - zh: c9dd06c77098e6ef + en: 4ff6f0673d057ca8 + zh: 7171d060dd86db9a /desktop-ci-publication/restore-a-channel: en: d82e2ccf70cce397 zh: d93dcb77cc4b463e diff --git a/apps/desktop/publishing/ci/README.md b/apps/desktop/publishing/ci/README.md index 5009bf87e7..4c224d2a7b 100644 --- a/apps/desktop/publishing/ci/README.md +++ b/apps/desktop/publishing/ci/README.md @@ -70,7 +70,7 @@ Environment approval gates this workflow, not arbitrary code running under the s For publication to either test or production, choose Windows `signing=signed` or leave macOS **Skip notarization** unchecked. Unsigned and unnotarized packages are only for internal debugging; they cannot publish or provide automatic updates. Before installing dependencies, the hosted routing job rejects debugging artifacts identified by the selected run and attempt. Missing artifact metadata does not establish eligibility: the original machine still validates its retained manifest and signatures. A debugging-package rejection requires a new signed/notarized package and its run ID / attempt; retrying publication cannot qualify the existing bytes. -Select the platform's `Package Desktop` workflow, preset, source, channel and version mode. `source_ref` accepts repository branch names (for example `feature/desktop-update`) or `dsh-v*` tags for test; production accepts only `master` or `dsh-v*` tags. Names matching `dsh-v` followed by a digit and letters, digits, dots or hyphens are treated as tags, even if a branch has that name. Tags must match the selected product version. We recommend dispatching the packaging workflow from `master` and enter the source separately; raw commit SHAs, PR refs and fork repositories are not supported. The selected source must contain the CI adapters, so older sources fail before dependency installation. Only select trusted branches: their build code runs on the signing machine. This is an operating convention, not enforced workflow-origin or credential isolation. Publication uses the recorded commit and requires it to remain in the selected branch history; normal branch advances are allowed, deletion or a diverging force-push rejects. Tag publication requires the tag to still resolve to the recorded commit. Dispatch the publisher from `master`; its hosted routing job rejects other refs before scheduling the original machine. The packaged source commit is recorded separately. `version_mode=default` selects product for production and auto for test; product preserves the selected checkout version, auto appends date/run/attempt, and manual requires `build_version`. A latest package requires a stable version, so test/latest needs product or a stable manual version. The optional `test_release_id` is the test URL prefix: blank retains the machine default, successive upgrade tests on that platform need the same value, and defaults should be rotated periodically. It is not a GitHub run ID. See [packaging configuration](../../packaging/README.md#configuration-and-local-builds) for its validation and rotation limits. +Select the platform's `Package Desktop` workflow, preset, source, channel and version mode. `source_ref` accepts repository branch names (for example `feature/desktop-update`) or `dsh-v*` tags for test; production accepts only `master` or `dsh-v*` tags. Names matching `dsh-v` followed by a digit and letters, digits, dots or hyphens are treated as tags, even if a branch has that name. Tags must match the selected product version. We recommend dispatching the packaging workflow from `master` and enter the source separately; raw commit SHAs, PR refs and fork repositories are not supported. The selected source must contain the CI adapters, so older sources fail before dependency installation. Only select trusted branches: their build code runs on the signing machine. This is an operating convention, not enforced workflow-origin or credential isolation. Publication uses the recorded commit and requires it to remain in the selected branch history; normal branch advances are allowed, deletion or a diverging force-push rejects. Tag publication requires the tag to still resolve to the recorded commit. Dispatch the publisher from `master`; its hosted routing job rejects other refs before scheduling the original machine. The packaged source commit is recorded separately. `version_mode=default` selects product for production and auto for test; product preserves the selected checkout version, auto appends date/run/attempt, and manual requires `build_version`. A stable-channel package requires a stable version, so test/stable needs product or a stable manual version. The optional `test_release_id` is the test URL prefix: blank retains the machine default, successive upgrade tests on that platform need the same value, and defaults should be rotated periodically. It is not a GitHub run ID. See [packaging configuration](../../packaging/README.md#configuration-and-local-builds) for its validation and rotation limits. Packaging only retains files. After success, open **Publish Desktop · COS → Run workflow** on `master`, enter `package_run_id` from the packaging run URL, and optionally enter `package_run_attempt`. Blank selects the latest attempt, which must have succeeded; it never falls back to an older success. An explicit attempt selects that exact successful build. Windows must be signed; macOS must finish notarization and stapling. GitHub job evidence selects the original runner; its local manifest supplies the preset, version, source and channel before approval. No installer or request artifact is downloaded. Retained packages with the old `publishRequested=false` flag remain eligible; missing or changed local files stop publication. @@ -80,7 +80,7 @@ Before installing dependencies on the original runner, preparation and the appro ## Recovery and records -After resolving a failure, re-run the failed publishing job to keep its prepared package attempt, or start a new publication with the explicit original package run and attempt. Re-running all jobs with a blank attempt resolves the latest package attempt again and requires a new approval. Do not rebuild solely to retry an upload. Missing objects are uploaded, identical objects are skipped, and conflicting same-name bytes stop the operation. Unknown write outcomes are reconciled by reading remote bytes. A newer channel version prevents a stale upload from replacing it. Each package updates only its embedded channel: nightly writes `nightly.yml` / `nightly-mac.yml`, latest writes `latest.yml` / `latest-mac.yml`. Latest binaries use a `latest/` subdirectory under the target binary prefix, separate from existing nightly objects. Publishing the same source/version to both channels requires two packages; an existing release tag is reused only for the same source commit. +After resolving a failure, re-run the failed publishing job to keep its prepared package attempt, or start a new publication with the explicit original package run and attempt. Re-running all jobs with a blank attempt resolves the latest package attempt again and requires a new approval. Do not rebuild solely to retry an upload. Missing objects are uploaded, identical objects are skipped, and conflicting same-name bytes stop the operation. Unknown write outcomes are reconciled by reading remote bytes. A newer channel version prevents a stale upload from replacing it. Each package updates only its embedded channel: nightly writes `nightly.yml` / `nightly-mac.yml`, stable writes `stable.yml` / `stable-mac.yml`. Stable-channel binaries use a `stable/` subdirectory under the target binary prefix, separate from existing nightly objects. Publishing the same source/version to both channels requires two packages; an existing release tag is reused only for the same source commit. The sibling `upload-records/` directory retains an operation identity, newline-delimited events and terminal result. Feed-write intents retain both previous and requested YAML. Failures record their stage, safe error classification and diagnostic; COS failures also identify `GetObject` or `PutObject`, plus a validated HTTP status and request ID when available. Access-denied reports name the permission to inspect. Raw provider responses and credentials are excluded. Audit initialization becomes visible only after its files are complete; abandoned `.initializing-*` directories are not scanned for completed publication. Writes and readback are recorded before proceeding; missing terminal results mean interruption, not success. Actions also keeps its operation record as a diagnostic artifact for 30 days. These local records are not tamper-proof. COS readback verifies storage bytes, not public CDN behavior; public download acceptance is separate. diff --git a/apps/desktop/publishing/ci/README.zh.md b/apps/desktop/publishing/ci/README.zh.md index 151032eed9..ae61bde4e9 100644 --- a/apps/desktop/publishing/ci/README.zh.md +++ b/apps/desktop/publishing/ci/README.zh.md @@ -70,7 +70,7 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任 发布到 test 或 production 都必须在 Windows 选择 `signing=signed`,或在 macOS 不勾选**跳过公证**。无签名、无公证的包仅限内部调试,不能发布或用于自动更新。托管路由 job 会在安装依赖前,依据所选 run 和 attempt 对应的 artifact 名称拒绝已标明的调试包。缺少 artifact 元数据不代表允许发布:原机器仍会验证留存清单和签名。遇到调试包拒绝提示时,需重新完成签名或公证,再使用新包的 run ID / attempt 发布;重跑发布不能使现有产物获得发布资格。 -选择对应平台的 `Package Desktop` 工作流、preset、源码、通道和版本模式。test 的 `source_ref` 接受仓库内分支名(如 `feature/desktop-update`)或 `dsh-v*` tag;production 仅接受 `master` 或 `dsh-v*` tag。符合 `dsh-v` 后接数字、其余为字母、数字、点或连字符的名称按 tag 处理,即使存在同名分支。tag 必须与所选产品版本一致。建议从 `master` 启动打包工作流,再单独填写打包源码;不支持直接填写 commit SHA、PR ref 或 fork 仓库。源码必须包含 CI 适配脚本,旧源码会在安装依赖前失败。仅选择可信分支:其构建代码将在签名机器上执行。这是操作约定,不提供工作流来源强制限制或凭据隔离。发布使用记录的 commit,并要求该提交仍在所选分支历史中;正常追加提交不影响发布,删除分支或导致历史分叉的强推会拒绝。tag 发布要求 tag 仍指向记录的提交。从 `master` 发起发布;托管路由 job 会在派发到原机器前拒绝其他 ref。实际打包源码提交单独记录。`version_mode=default` 对 production 选择 product,对 test 选择 auto;product 保留所选源码版本,auto 追加日期/run/attempt,manual 要求填写 `build_version`。latest 包要求稳定版本,因此 test/latest 需选择 product 或稳定的手填版本。可选 `test_release_id` 是测试 URL 前缀:留空沿用机器默认值,同一平台连续升级测试需保持相同值,并应定期轮换默认值。它不是 GitHub run ID。格式校验和轮换限制见[打包配置](../../packaging/README.zh.md#configuration-and-local-builds)。 +选择对应平台的 `Package Desktop` 工作流、preset、源码、通道和版本模式。test 的 `source_ref` 接受仓库内分支名(如 `feature/desktop-update`)或 `dsh-v*` tag;production 仅接受 `master` 或 `dsh-v*` tag。符合 `dsh-v` 后接数字、其余为字母、数字、点或连字符的名称按 tag 处理,即使存在同名分支。tag 必须与所选产品版本一致。建议从 `master` 启动打包工作流,再单独填写打包源码;不支持直接填写 commit SHA、PR ref 或 fork 仓库。源码必须包含 CI 适配脚本,旧源码会在安装依赖前失败。仅选择可信分支:其构建代码将在签名机器上执行。这是操作约定,不提供工作流来源强制限制或凭据隔离。发布使用记录的 commit,并要求该提交仍在所选分支历史中;正常追加提交不影响发布,删除分支或导致历史分叉的强推会拒绝。tag 发布要求 tag 仍指向记录的提交。从 `master` 发起发布;托管路由 job 会在派发到原机器前拒绝其他 ref。实际打包源码提交单独记录。`version_mode=default` 对 production 选择 product,对 test 选择 auto;product 保留所选源码版本,auto 追加日期/run/attempt,manual 要求填写 `build_version`。stable 包要求稳定版本,因此 test/stable 需选择 product 或稳定的手填版本。可选 `test_release_id` 是测试 URL 前缀:留空沿用机器默认值,同一平台连续升级测试需保持相同值,并应定期轮换默认值。它不是 GitHub run ID。格式校验和轮换限制见[打包配置](../../packaging/README.zh.md#configuration-and-local-builds)。 打包只保留文件。成功后,在 `master` 上打开 **Publish Desktop · COS → Run workflow**,填写打包运行 URL 中的 `package_run_id`,并可填写 `package_run_attempt`。留空选择最新尝试,且该尝试必须成功,不会回退到较早的成功尝试;显式填写则选择对应的成功构建。Windows 必须完成签名,macOS 必须完成公证和 stapling。GitHub job 证据确定原 runner,本机清单在审批前提供 preset、版本、源码和通道。不会下载安装包或请求 artifact。旧清单的 `publishRequested=false` 不影响发布资格;本机文件缺失或变化会停止发布。 @@ -80,7 +80,7 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任 ## 恢复与记录 -解决故障后重跑失败的发布 job,以保持准备阶段选定的打包尝试;也可显式填写原打包运行和尝试,发起新的发布。尝试留空时重跑所有 job 会重新解析最新打包尝试,并需要重新审批。不要仅为重试上传而重新打包。缺失对象会上传,相同对象跳过,同名但字节不同的对象会停止操作。写入结果不明时通过读取远端字节核对。频道已有更高版本时,旧上传不能替换它。每个包只更新自身内置通道:nightly 写入 `nightly.yml` / `nightly-mac.yml`,latest 写入 `latest.yml` / `latest-mac.yml`。latest 二进制位于目标二进制前缀下的 `latest/` 子目录,与已有 nightly 对象分离。同一源码和版本发布到两个通道需要分别打包;已有发布 tag 仅在源码提交相同时复用。 +解决故障后重跑失败的发布 job,以保持准备阶段选定的打包尝试;也可显式填写原打包运行和尝试,发起新的发布。尝试留空时重跑所有 job 会重新解析最新打包尝试,并需要重新审批。不要仅为重试上传而重新打包。缺失对象会上传,相同对象跳过,同名但字节不同的对象会停止操作。写入结果不明时通过读取远端字节核对。频道已有更高版本时,旧上传不能替换它。每个包只更新自身内置通道:nightly 写入 `nightly.yml` / `nightly-mac.yml`,stable 写入 `stable.yml` / `stable-mac.yml`。stable 二进制位于目标二进制前缀下的 `stable/` 子目录,与已有 nightly 对象分离。同一源码和版本发布到两个通道需要分别打包;已有发布 tag 仅在源码提交相同时复用。 同级 `upload-records/` 目录保留操作标识、逐行事件和最终结果。频道写入意图同时记录先前和目标 YAML。失败记录阶段、安全错误分类与诊断;COS 失败还标明 `GetObject` 或 `PutObject`,并在可用时记录经过校验的 HTTP 状态码和 request ID。访问拒绝提示会指出需检查的权限。不保存原始服务响应或凭据。审计初始化在文件完整后才可见;遗留的 `.initializing-*` 目录不参与已完成发布的扫描。写入和回读在继续操作前落盘;没有最终结果代表中断,不代表成功。Actions 还将操作记录保存为诊断 artifact,保留 30 天。这些本地记录不防篡改。COS 回读验证存储字节,不代表公开 CDN 行为;公开下载需单独验收。 diff --git a/apps/desktop/renderer/mandatory-update.js b/apps/desktop/renderer/mandatory-update.js index eed28f7718..27b98bc350 100644 --- a/apps/desktop/renderer/mandatory-update.js +++ b/apps/desktop/renderer/mandatory-update.js @@ -14,6 +14,7 @@ function render(view) { const { locale: { id, messages }, policy, update, confirmation, navigation } = view const confirming = confirmation !== undefined const failed = update.phase === 'error' + const configurationError = failed && update.failedOperation === 'configuration' const ready = update.phase === 'ready' || (failed && update.failedOperation === 'install') const downloadable = update.phase === 'available' || (failed && update.failedOperation === 'download') const authenticationRequired = policy.error === 'authentication-required' @@ -45,13 +46,14 @@ function render(view) { detail = view.restart === 'stopping-tasks' ? messages.mandatoryStopping : messages.mandatoryRestarting status = ''; primary = ''; action = '' } - const error = localError ?? view.error ?? (authenticationRequired ? messages.policyLoginRequired : failed + const error = localError ?? view.error ?? (configurationError ? messages.updateConfigurationRead + : authenticationRequired ? messages.policyLoginRequired : failed ? update.failedOperation === 'download' ? messages.mandatoryDownloadFailed : update.failedOperation === 'install' ? preparationMessages[update.preparationFailure] ?? messages.mandatoryInstallFailed : messages.mandatoryUnavailable : update.phase === 'idle' && !policy.checking ? messages.mandatoryNoRelease : undefined) - const technicalDetails = failed ? update.technicalDetails ?? update.message ?? '' : '' + const technicalDetails = failed && !configurationError ? update.technicalDetails ?? update.message ?? '' : '' document.documentElement.lang = id document.title = messages.mandatoryTitle element('title').textContent = title @@ -73,7 +75,8 @@ function render(view) { element('update').disabled = busy && !confirming element('later').textContent = messages.updateLater element('later').hidden = !confirmation?.active - element('refresh').textContent = authenticationRequired ? messages.policyLogin : messages.mandatoryRefresh + element('refresh').textContent = configurationError ? messages.updateConfigurationDetails + : authenticationRequired ? messages.policyLogin : messages.mandatoryRefresh element('refresh').hidden = confirming || view.restart !== undefined || (!authenticationRequired && (!!primary || !fallback)) element('refresh').disabled = busy || policy.checking element('page').textContent = navigation ? messages.mandatoryReopen : messages.mandatoryPage diff --git a/apps/desktop/renderer/update-dialog.css b/apps/desktop/renderer/update-dialog.css index dd50411d46..583339a238 100644 --- a/apps/desktop/renderer/update-dialog.css +++ b/apps/desktop/renderer/update-dialog.css @@ -35,3 +35,8 @@ body.update-dialog.visible { opacity: 1; } .primary:hover:not(:disabled) { background: var(--dsw-alias-button-primary-hover); } .secondary:hover:not(:disabled), .close:hover { background: var(--dsw-alias-interactive-bg-hover); } } + +button[aria-busy="true"] { opacity: 1; display: flex; align-items: center; justify-content: center; gap: 8px; } +button[aria-busy="true"]::before { content: ""; width: 14px; height: 14px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: reload-spin 800ms linear infinite; } +@keyframes reload-spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { button[aria-busy="true"]::before { animation: none; } } diff --git a/apps/desktop/renderer/update-dialog.js b/apps/desktop/renderer/update-dialog.js index d03eadadf7..44102bc012 100644 --- a/apps/desktop/renderer/update-dialog.js +++ b/apps/desktop/renderer/update-dialog.js @@ -3,7 +3,7 @@ const api = window.dshUpdateDialog let view let responding = false function respond(index) { - if (responding || view === undefined) return + if (responding || view === undefined || view.busyButton !== undefined) return responding = true void api.respond(view.revision, index).catch(() => { responding = false }) } @@ -11,7 +11,8 @@ document.getElementById('close').addEventListener('click', () => { respond(view. document.addEventListener('keydown', event => { if (event.key === 'Escape' && view !== undefined) { event.preventDefault(); respond(view.cancelId) } if (event.key !== 'Tab') return - const controls = [...document.querySelectorAll('button, details:not([hidden]) > summary, details[open]:not([hidden]) > pre')] + const controls = [...document.querySelectorAll('button:not(:disabled), details:not([hidden]) > summary, details[open]:not([hidden]) > pre')] + if (controls.length === 0) { event.preventDefault(); return } const current = controls.indexOf(document.activeElement) const next = current < 0 ? (event.shiftKey ? controls.length - 1 : 0) : (current + (event.shiftKey ? -1 : 1) + controls.length) % controls.length @@ -32,6 +33,7 @@ function render(state) { document.getElementById('title').textContent = state.message document.getElementById('detail').textContent = state.detail document.getElementById('detail').hidden = state.detail === '' + document.getElementById('close').disabled = state.busyButton !== undefined document.getElementById('close').setAttribute('aria-label', state.closeLabel) document.getElementById('technical-details').hidden = state.technicalDetails === '' document.getElementById('technical-details-label').textContent = state.technicalDetailsLabel @@ -42,6 +44,8 @@ function render(state) { const button = document.createElement('button') button.type = 'button' button.textContent = label + button.disabled = state.busyButton !== undefined + if (state.busyButton === index) button.setAttribute('aria-busy', 'true') button.className = index === 0 ? 'primary' : 'secondary' button.addEventListener('click', () => { respond(index) }) document.getElementById('actions').append(button) diff --git a/apps/desktop/scripts/desktop-auto-update-environment.d.mts b/apps/desktop/scripts/desktop-auto-update-environment.d.mts index 4d42b3dd1e..98a6309d14 100644 --- a/apps/desktop/scripts/desktop-auto-update-environment.d.mts +++ b/apps/desktop/scripts/desktop-auto-update-environment.d.mts @@ -17,7 +17,7 @@ export function desktopLatestInstallerFilename(target: DesktopAutoUpdateTarget): /** Public updater URL and object prefixes for one release target. */ export interface DesktopAutoUpdateConfig { readonly environment: DesktopAutoUpdateEnvironment - readonly channel: 'nightly' | 'latest' + readonly channel: 'nightly' | 'stable' readonly target: DesktopAutoUpdateTarget readonly origin: string readonly publicUrl: string @@ -68,7 +68,7 @@ export function desktopBuildRecordFilename(target: DesktopAutoUpdateTarget): str export function desktopUpdateMetadataFilename( version: string, platform: NodeJS.Platform, - channel?: 'nightly' | 'latest', + channel?: 'nightly' | 'stable', ): string /** @@ -104,7 +104,7 @@ export function resolveDesktopUploadConfig( * @param env Packaging settings. * @returns Validated channel. */ -export function resolveDesktopUpdateChannel(env: NodeJS.ProcessEnv): 'nightly' | 'latest' +export function resolveDesktopUpdateChannel(env: NodeJS.ProcessEnv): 'nightly' | 'stable' /** * Read a required nonempty deployment setting. diff --git a/apps/desktop/scripts/desktop-auto-update-environment.mjs b/apps/desktop/scripts/desktop-auto-update-environment.mjs index 835beadd6d..33cff177a2 100644 --- a/apps/desktop/scripts/desktop-auto-update-environment.mjs +++ b/apps/desktop/scripts/desktop-auto-update-environment.mjs @@ -8,11 +8,11 @@ export const DESKTOP_AUTO_UPDATE_ENV = 'DSH_DESKTOP_AUTO_UPDATE_ENV' /** * Resolve the channel embedded in the installer; omitted local settings retain nightly. * @param {NodeJS.ProcessEnv} env Packaging or upload settings. - * @returns {'nightly' | 'latest'} Validated channel. + * @returns {'nightly' | 'stable'} Validated channel. */ export function resolveDesktopUpdateChannel(env) { const channel = env.DSH_DESKTOP_UPDATE_CHANNEL || 'nightly' - if (channel !== 'nightly' && channel !== 'latest') throw new Error('desktop auto-update: invalid update channel') + if (channel !== 'nightly' && channel !== 'stable') throw new Error('desktop auto-update: invalid update channel') return channel } @@ -90,7 +90,7 @@ export function desktopBuildRecordFilename(target) { * Return the electron-builder channel metadata filename for an application version. * @param {string} version - Desktop semantic version. * @param {NodeJS.Platform} platform - Target platform. - * @param {'nightly' | 'latest'} channel - Selected channel; omitted means nightly. + * @param {'nightly' | 'stable'} channel - Selected channel; omitted means nightly. * @returns {string} Channel metadata filename emitted for the target. */ export function desktopUpdateMetadataFilename(version, platform, channel = 'nightly') { @@ -148,7 +148,7 @@ function httpsOrigin(value, name) { * @param {NodeJS.ProcessEnv} env - Packaging or upload environment. * @param {NodeJS.Platform} platform - Target Node.js platform. * @param {string} arch - Target Node.js architecture. - * @returns {{ environment: 'test' | 'production', channel: 'nightly' | 'latest', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string }} Resolved updater configuration. + * @returns {{ environment: 'test' | 'production', channel: 'nightly' | 'stable', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string }} Resolved updater configuration. * @throws {Error} When the test deployment lacks a valid HTTPS origin or a 32-character lowercase hexadecimal release ID. */ export function resolveDesktopAutoUpdateConfig(env, platform, arch) { @@ -177,7 +177,7 @@ export function resolveDesktopAutoUpdateConfig(env, platform, arch) { target, origin, keyPrefix, - binaryKeyPrefix: `${releasePrefix}/bin/${target}${channel === 'latest' ? '/latest' : ''}`, + binaryKeyPrefix: `${releasePrefix}/bin/${target}${channel === 'stable' ? '/stable' : ''}`, publicUrl: `${origin}/${keyPrefix}/`, } } @@ -187,7 +187,7 @@ export function resolveDesktopAutoUpdateConfig(env, platform, arch) { * @param {NodeJS.ProcessEnv} env - Upload environment. * @param {NodeJS.Platform} platform - Target Node.js platform. * @param {string} arch - Target Node.js architecture. - * @returns {{ environment: 'test' | 'production', channel: 'nightly' | 'latest', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string, bucket: string, secretIdEnvName: string, secretKeyEnvName: string }} Resolved upload configuration. + * @returns {{ environment: 'test' | 'production', channel: 'nightly' | 'stable', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string, bucket: string, secretIdEnvName: string, secretKeyEnvName: string }} Resolved upload configuration. * @throws {Error} When the selected deployment lacks a bucket or valid updater configuration. */ export function resolveDesktopUploadConfig(env, platform, arch) { diff --git a/apps/desktop/scripts/desktop-ci-version.mjs b/apps/desktop/scripts/desktop-ci-version.mjs index 8fef629287..3822b8329f 100644 --- a/apps/desktop/scripts/desktop-ci-version.mjs +++ b/apps/desktop/scripts/desktop-ci-version.mjs @@ -24,7 +24,7 @@ export function desktopCiVersion(environment, productVersion, now = new Date()) } candidate = `${desktopBuildVersionPrefix(productVersion)}${date}.${environment.GITHUB_RUN_NUMBER}.${environment.GITHUB_RUN_ATTEMPT}` } - if (environment.DSH_DESKTOP_UPDATE_CHANNEL === 'latest' && candidate.includes('-')) throw new Error('Latest channel requires a stable version; select product or manual stable version') + if (environment.DSH_DESKTOP_UPDATE_CHANNEL === 'stable' && candidate.includes('-')) throw new Error('Stable channel requires a stable version; select product or manual stable version') try { const resolved = validateDesktopBuildVersion(candidate, productVersion) return resolved diff --git a/apps/desktop/scripts/desktop-publish-route.ts b/apps/desktop/scripts/desktop-publish-route.ts index 9b6fedfa42..5c8d7a9ef3 100644 --- a/apps/desktop/scripts/desktop-publish-route.ts +++ b/apps/desktop/scripts/desktop-publish-route.ts @@ -12,7 +12,7 @@ const runSchema = z.object({ const requestSchema = z.object({ source: z.object({ repository: z.string(), runId: z.string(), attempt: z.string(), commit: z.string().regex(/^[a-f0-9]{40}$/u), workflowCommit: z.string(), sourceRef: z.string().min(1), - channel: z.enum(['nightly', 'latest']), + channel: z.enum(['nightly', 'stable']), runnerName: z.string(), target: z.enum(['mac-arm64', 'mac-x64', 'win-x64']), preset: z.enum(['test', 'production']), qualification: z.enum(['signed', 'notarized', 'unsigned', 'unnotarized']), }).refine(source => source.preset === 'test' || /^(?:master|dsh-v[0-9][0-9A-Za-z.-]*)$/u.test(source.sourceRef), @@ -25,7 +25,7 @@ export interface DesktopPublishRoute { readonly attempt: string readonly commit: string readonly sourceRef: string - readonly channel: 'nightly' | 'latest' + readonly channel: 'nightly' | 'stable' readonly runnerName: string readonly runnerLabel: string readonly target: 'mac-arm64' | 'mac-x64' | 'win-x64' diff --git a/apps/desktop/scripts/desktop-release-store.ts b/apps/desktop/scripts/desktop-release-store.ts index c080272e1d..bb3345aa74 100644 --- a/apps/desktop/scripts/desktop-release-store.ts +++ b/apps/desktop/scripts/desktop-release-store.ts @@ -14,7 +14,7 @@ const sourceSchema = z.strictObject({ commit: z.string().regex(/^[a-f0-9]{40}$/u), workflowCommit: z.string().regex(/^[a-f0-9]{40}$/u), sourceRef: z.string().min(1), - channel: z.enum(['nightly', 'latest']), + channel: z.enum(['nightly', 'stable']), runnerName: z.string().min(1), // Storage accepts every Desktop target; CI routing separately limits currently provisioned platforms. target: z.enum(['mac-arm64', 'mac-x64', 'win-x64']), diff --git a/apps/desktop/scripts/desktop-upload-plan.ts b/apps/desktop/scripts/desktop-upload-plan.ts index 1461444fc6..1299dfdae8 100644 --- a/apps/desktop/scripts/desktop-upload-plan.ts +++ b/apps/desktop/scripts/desktop-upload-plan.ts @@ -219,7 +219,7 @@ export async function createDesktopUploadPlan( throw new Error(`desktop upload: ${targetName} package completion record for ${buildVersion} does not match the ${update.environment} update destination`) } - if (update.channel === 'latest' && buildVersion.includes('-')) throw new Error('desktop upload: latest requires a stable version') + if (update.channel === 'stable' && buildVersion.includes('-')) throw new Error('desktop upload: stable requires a stable version') const metadataFilename = desktopUpdateMetadataFilename(buildVersion, target.platform, update.channel) const metadataPath = join(artifactsRoot, metadataFilename) let metadataValue: unknown diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index 0e12399d89..c2563a0edf 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -105,7 +105,7 @@ export function createElectronBuilderConfig( // the artifact names, the update feed, and the installed app.getVersion() the updater compares against. const productVersion = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8')).version const buildVersion = resolveDesktopBuildVersion(env, productVersion) - if (update?.channel === 'latest' && buildVersion.includes('-')) throw new Error('desktop package: latest requires a stable version') + if (update?.channel === 'stable' && buildVersion.includes('-')) throw new Error('desktop package: stable requires a stable version') const packaged = resolveDesktopBuildCommit(env) return { appId, diff --git a/apps/desktop/scripts/macos-app-update-config.d.mts b/apps/desktop/scripts/macos-app-update-config.d.mts index ce18266c48..926762b9ec 100644 --- a/apps/desktop/scripts/macos-app-update-config.d.mts +++ b/apps/desktop/scripts/macos-app-update-config.d.mts @@ -1,14 +1,14 @@ /** Resolved fields required to embed a macOS updater feed. */ export interface MacOSAppUpdateFeed { readonly publicUrl: string - readonly channel?: 'nightly' | 'latest' + readonly channel?: 'nightly' | 'stable' } /** Packaged electron-updater configuration for macOS. */ export interface MacOSAppUpdateConfig { readonly provider: 'generic' readonly url: string - readonly channel: 'nightly' | 'latest' + readonly channel: 'nightly' | 'stable' readonly updaterCacheDirName: string } diff --git a/apps/desktop/scripts/macos-app-update-config.mjs b/apps/desktop/scripts/macos-app-update-config.mjs index d6b4132c71..bdb400bcde 100644 --- a/apps/desktop/scripts/macos-app-update-config.mjs +++ b/apps/desktop/scripts/macos-app-update-config.mjs @@ -24,24 +24,24 @@ function nonEmptyString(value, label) { /** * Resolve the one generic macOS feed from the final electron-builder configuration. * @param {unknown} publish - Final electron-builder publish setting. - * @returns {{ publicUrl: string, channel?: 'nightly' | 'latest' }} Resolved feed used by the packaged App. + * @returns {{ publicUrl: string, channel?: 'nightly' | 'stable' }} Resolved feed used by the packaged App. */ export function resolveMacOSAppUpdateFeed(publish) { if (!Array.isArray(publish) || publish.length !== 1) { throw new Error('desktop macOS update config: publish must contain exactly one provider') } const provider = object(publish[0], 'publish provider') - if (provider.provider !== 'generic' || !['nightly', 'latest'].includes(provider.channel)) { - throw new Error('desktop macOS update config: publish provider must be generic with nightly or latest channel') + if (provider.provider !== 'generic' || !['nightly', 'stable'].includes(provider.channel)) { + throw new Error('desktop macOS update config: publish provider must be generic with nightly or stable channel') } return { publicUrl: nonEmptyString(provider.url, 'publish provider URL'), channel: resolveDesktopUpdateChannel({ DSH_DESKTOP_UPDATE_CHANNEL: provider.channel }) } } /** * Create the electron-updater configuration embedded before code signing. - * @param {{ publicUrl: string, channel?: 'nightly' | 'latest' }} update - Resolved update feed. + * @param {{ publicUrl: string, channel?: 'nightly' | 'stable' }} update - Resolved update feed. * @param {string} updaterCacheDirName - electron-builder application cache directory. - * @returns {{ provider: 'generic', url: string, channel: 'nightly' | 'latest', updaterCacheDirName: string }} Packaged updater fields. + * @returns {{ provider: 'generic', url: string, channel: 'nightly' | 'stable', updaterCacheDirName: string }} Packaged updater fields. */ export function createMacOSAppUpdateConfig(update, updaterCacheDirName) { return { @@ -55,7 +55,7 @@ export function createMacOSAppUpdateConfig(update, updaterCacheDirName) { /** * Write the updater configuration into an assembled App before signing. * @param {string} resourcesDir - App Contents/Resources directory. - * @param {{ publicUrl: string, channel?: 'nightly' | 'latest' }} update - Resolved update feed. + * @param {{ publicUrl: string, channel?: 'nightly' | 'stable' }} update - Resolved update feed. * @param {string} updaterCacheDirName - electron-builder application cache directory. * @returns {Promise} Resolves after the configuration is durable. */ @@ -67,7 +67,7 @@ export async function writeMacOSAppUpdateConfig(resourcesDir, update, updaterCac /** * Verify the updater configuration inside an assembled macOS App. * @param {string} appPath - Application bundle path. - * @param {{ publicUrl: string, channel?: 'nightly' | 'latest' }} update - Expected update feed. + * @param {{ publicUrl: string, channel?: 'nightly' | 'stable' }} update - Expected update feed. * @param {string | undefined} updaterCacheDirName - Exact cache directory when known. * @returns {Promise} Resolves when the packaged configuration matches the release destination. */ diff --git a/apps/desktop/scripts/publish-desktop-ci.ts b/apps/desktop/scripts/publish-desktop-ci.ts index f9348ec2eb..19dcea3f58 100644 --- a/apps/desktop/scripts/publish-desktop-ci.ts +++ b/apps/desktop/scripts/publish-desktop-ci.ts @@ -17,7 +17,7 @@ import { publishDesktopTag } from './desktop-publish-tag.ts' import { desktopPublishQualificationError } from './desktop-publish-qualification.mjs' const routeSchema = z.object({ runId: z.string(), attempt: z.string(), commit: z.string(), runnerName: z.string(), - sourceRef: z.string(), channel: z.enum(['nightly', 'latest']), + sourceRef: z.string(), channel: z.enum(['nightly', 'stable']), target: z.enum(['mac-arm64', 'mac-x64', 'win-x64']), preset: z.enum(['test', 'production']) }) /** diff --git a/apps/desktop/scripts/test-update-channels.mjs b/apps/desktop/scripts/test-update-channels.mjs new file mode 100644 index 0000000000..22934e9c8c --- /dev/null +++ b/apps/desktop/scripts/test-update-channels.mjs @@ -0,0 +1,29 @@ +/** Qualify built channel transitions with Electron and the pinned updater, without installing. */ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawn } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import electron from 'electron' +import { recordVerifiedVersions } from '../tests/updates/electron-updater/versions.mjs' + +if (!['win32', 'darwin'].includes(process.platform)) throw new Error('Qualification requires Windows or macOS') +if (process.argv.slice(2).some(arg => arg !== '--record')) throw new Error('Usage: test-update-channels.mjs [--record]') + +const root = await mkdtemp(join(tmpdir(), 'desktop-channel-')) +const environment = { ...process.env, DSH_CHANNEL_TEST_ROOT: root } +delete environment.ELECTRON_RUN_AS_NODE +const child = spawn(electron, [fileURLToPath(new URL('../tests/updates/electron-updater/qualification.mjs', import.meta.url))], { + env: environment, stdio: 'inherit', windowsHide: true, +}) +let timedOut = false +const deadline = setTimeout(() => { timedOut = true; child.kill() }, 30_000) +try { + const code = await new Promise((resolve, reject) => { + child.once('error', reject) + child.once('close', (code, signal) => signal ? reject(new Error(`Channel qualification ended: timedOut=${timedOut}, signal=${signal}`)) : resolve(code)) + }) + if (timedOut) throw new Error('Channel qualification timed out after 30000 ms') + if (code !== 0) throw new Error(`Channel qualification failed: ${code}`) + if (process.argv.includes('--record')) await recordVerifiedVersions() +} finally { clearTimeout(deadline); await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }) } diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index a3c5f5d935..83ebe7415a 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -1,6 +1,8 @@ import type { DesktopFileActions } from '@deepseek-ai/dsh-client-ui-file-actions/types' /** Typed preload operations exposed only by the Electron shell. */ +export type { DesktopChannelState, DesktopChannelChangeResult } from '@deepseek-ai/dsh-client-ui-settings-general/types' +import type { DesktopChannelBridge, DesktopChannelState } from '@deepseek-ai/dsh-client-ui-settings-general/types' import type { DesktopKeyboardApi, DesktopShortcutsApi } from '@deepseek-ai/dsh-client-shortcuts/protocol' import type { IpcMainInvokeEvent } from 'electron' import type { DesktopBrowserBridge } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types' @@ -32,6 +34,9 @@ export const DESKTOP_IPC = { deviceInfo: 'dsh-desktop:device-info', localeBootstrap: 'dsh-desktop:locale-bootstrap', localeChanged: 'dsh-desktop:locale-changed', + updatesChannelGet: 'dsh-desktop:updates-channel-get', + updatesChannelSet: 'dsh-desktop:updates-channel-set', + updatesChannelChanged: 'dsh-desktop:updates-channel-changed', updatesStatus: 'dsh-desktop:updates-status', updatesOpen: 'dsh-desktop:updates-open', updatesPresentation: 'dsh-desktop:updates-presentation', @@ -51,17 +56,23 @@ export interface DesktopUpdateState { /** Main-owned diagnostics without subprocess output or credentials; hidden until expanded. */ readonly technicalDetails?: string readonly percent?: number - readonly failedOperation?: 'check' | 'download' | 'install' + readonly failedOperation?: 'check' | 'download' | 'install' | 'configuration' /** Main-owned preparation cause; UI wording is selected by the active locale. */ readonly preparationFailure?: DesktopUpdatePreparationFailureKind + /** Current filesystem failure, independent of previous cache cleanup attempts. */ + readonly downloadFailure?: 'space' | 'permission' | 'busy' } /** Classified failure copy selected by the Web locale without exposing raw updater diagnostics. */ export type DesktopUpdateFailureKind = + | 'configuration' | 'check' | 'check-network' | 'download' | 'download-network' + | 'download-space' + | 'download-permission' + | 'download-busy' | 'install' | 'install-network' | 'stop-failed' @@ -75,6 +86,7 @@ export interface DesktopUpdatePresentation { readonly version?: string readonly percent?: number readonly failure?: DesktopUpdateFailureKind + readonly configurationError?: DesktopChannelState['error'] } /** Product documents cannot supply update versions, package URLs, or installation authorization. */ @@ -91,6 +103,7 @@ export interface DshDesktopProductApi { */ deviceInfo(): Promise readonly updates: { + readonly channels: DesktopChannelBridge status(): Promise open(): Promise subscribe(listener: (state: DesktopUpdatePresentation) => void): () => void diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index f5d30b82ba..bddf39b9f8 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -106,9 +106,20 @@ export const en = { reloadPageMenu: 'Reload Page', toggleDevToolsMenu: 'Developer tools', restartAppHostMenu: 'Restart App and Host', + updateConfigurationFailed: 'Update settings could not be applied. Fix the configuration file and restart, or select an update channel in Settings.', + updateConfigurationRead: 'Settings file error; updates are paused', + updateConfigurationFormat: 'Settings file error; updates are paused', + updateConfigurationChannel: 'Settings file error; updates are paused', + updateConfigurationCleanup: 'Updates paused: cannot discard previous update; restart the app', + updateReloadSettings: 'Reload settings', + updateConfigurationDetails: 'Review settings', + updateShowSettingsFile: 'Show settings file', updateCheckFailedTitle: 'Update Check Failed', updateCheckFailed: 'Could not check for updates. Please try again later.', updateDownloadFailed: 'Could not download the update. Please try again.', + updateDownloadSpaceFailed: 'Not enough disk space to download the update. Free up space, then retry.', + updateDownloadPermissionFailed: 'Cannot write the update files. Check the folder permissions and retry.', + updateDownloadBusyFailed: 'An update file is in use. Close the program using it, then retry.', updateInstallFailed: 'Could not install the update. Please try again later.', updateCheckNetworkFailed: 'Could not check for updates. Check your connection and try again.', updateDownloadNetworkFailed: 'Could not download the update. Check your connection and try again.', @@ -286,9 +297,20 @@ export const zh = { reloadPageMenu: '刷新页面', toggleDevToolsMenu: '开发者工具', restartAppHostMenu: '重启应用与 Host', + updateConfigurationFailed: '更新配置无法应用。请修正配置文件并重启,或在设置中选择更新渠道。', + updateConfigurationRead: '配置文件异常,更新已暂停', + updateConfigurationFormat: '配置文件异常,更新已暂停', + updateConfigurationChannel: '配置文件异常,更新已暂停', + updateConfigurationCleanup: '更新已暂停:无法清理旧更新,请重启应用', + updateReloadSettings: '重新读取配置', + updateConfigurationDetails: '查看配置', + updateShowSettingsFile: '显示配置文件', updateCheckFailedTitle: '更新检查失败', updateCheckFailed: '检查更新失败,请稍后重试。', updateDownloadFailed: '下载更新失败,请重试。', + updateDownloadSpaceFailed: '磁盘空间不足,无法下载更新。请释放空间后重试。', + updateDownloadPermissionFailed: '无法写入更新文件,请检查目录权限后重试。', + updateDownloadBusyFailed: '更新文件正被占用,请关闭占用它的程序后重试。', updateInstallFailed: '安装更新失败,请稍后重试。', updateCheckNetworkFailed: '检查更新失败,请检查网络连接后重试。', updateDownloadNetworkFailed: '下载更新失败,请检查网络连接后重试。', @@ -464,9 +486,20 @@ export const ja = { reloadPageMenu: 'ページを再読み込み', toggleDevToolsMenu: '開発者ツール', restartAppHostMenu: 'アプリとホストを再起動', + updateConfigurationFailed: '更新設定を適用できませんでした。設定ファイルを修正して再起動するか、設定で更新チャンネルを選択してください。', + updateConfigurationRead: '設定ファイルに問題があるため、更新を停止しています', + updateConfigurationFormat: '設定ファイルに問題があるため、更新を停止しています', + updateConfigurationChannel: '設定ファイルに問題があるため、更新を停止しています', + updateConfigurationCleanup: '更新を停止中:以前の更新を削除できません。アプリを再起動してください', + updateReloadSettings: '設定を再読み込み', + updateConfigurationDetails: '設定を確認', + updateShowSettingsFile: '設定ファイルを表示', updateCheckFailedTitle: 'アップデートの確認に失敗しました', updateCheckFailed: 'アップデートを確認できませんでした。後でもう一度お試しください。', updateDownloadFailed: 'アップデートをダウンロードできませんでした。もう一度お試しください。', + updateDownloadSpaceFailed: '更新のダウンロードに必要なディスク容量が不足しています。空き容量を増やして再試行してください。', + updateDownloadPermissionFailed: '更新ファイルを書き込めません。フォルダーのアクセス権を確認して再試行してください。', + updateDownloadBusyFailed: '更新ファイルが使用中です。使用しているプログラムを閉じて再試行してください。', updateInstallFailed: 'アップデートをインストールできませんでした。後でもう一度お試しください。', updateCheckNetworkFailed: 'アップデートを確認できませんでした。接続を確認してもう一度お試しください。', updateDownloadNetworkFailed: 'アップデートをダウンロードできませんでした。接続を確認してもう一度お試しください。', @@ -642,9 +675,20 @@ export const es = { reloadPageMenu: 'Recargar página', toggleDevToolsMenu: 'Herramientas de desarrollo', restartAppHostMenu: 'Reiniciar la aplicación y el host', + updateConfigurationFailed: 'No se pudo aplicar la configuración de actualización. Corrige el archivo y reinicia, o selecciona un canal en Ajustes.', + updateConfigurationRead: 'Error en el archivo de ajustes; actualizaciones pausadas', + updateConfigurationFormat: 'Error en el archivo de ajustes; actualizaciones pausadas', + updateConfigurationChannel: 'Error en el archivo de ajustes; actualizaciones pausadas', + updateConfigurationCleanup: 'Actualizaciones pausadas: no se pudo descartar la actualización anterior; reinicia la aplicación', + updateReloadSettings: 'Volver a cargar ajustes', + updateConfigurationDetails: 'Revisar ajustes', + updateShowSettingsFile: 'Mostrar archivo de ajustes', updateCheckFailedTitle: 'Error en la comprobación de actualización', updateCheckFailed: 'No se pudo buscar actualizaciones. Inténtelo de nuevo más tarde.', updateDownloadFailed: 'No se pudo descargar la actualización. Por favor inténtalo de nuevo.', + updateDownloadSpaceFailed: 'No hay espacio suficiente para descargar la actualización. Libera espacio y vuelve a intentarlo.', + updateDownloadPermissionFailed: 'No se pueden escribir los archivos de actualización. Revisa los permisos de la carpeta y vuelve a intentarlo.', + updateDownloadBusyFailed: 'Un archivo de actualización está en uso. Cierra el programa que lo usa y vuelve a intentarlo.', updateInstallFailed: 'No se pudo instalar la actualización. Inténtelo de nuevo más tarde.', updateCheckNetworkFailed: 'No se pudo buscar actualizaciones. Comprueba tu conexión y vuelve a intentarlo.', updateDownloadNetworkFailed: 'No se pudo descargar la actualización. Comprueba tu conexión y vuelve a intentarlo.', @@ -820,9 +864,20 @@ export const fr = { reloadPageMenu: 'Recharger la page', toggleDevToolsMenu: 'Outils de développement', restartAppHostMenu: "Redémarrez l'application et l'hôte", + updateConfigurationFailed: 'Impossible d’appliquer les paramètres de mise à jour. Corrigez le fichier et redémarrez, ou sélectionnez un canal dans les paramètres.', + updateConfigurationRead: 'Erreur du fichier de paramètres ; mises à jour suspendues', + updateConfigurationFormat: 'Erreur du fichier de paramètres ; mises à jour suspendues', + updateConfigurationChannel: 'Erreur du fichier de paramètres ; mises à jour suspendues', + updateConfigurationCleanup: 'Mises à jour suspendues : suppression de la mise à jour précédente impossible ; redémarrez l’application', + updateReloadSettings: 'Recharger les paramètres', + updateConfigurationDetails: 'Vérifier les paramètres', + updateShowSettingsFile: 'Afficher le fichier de paramètres', updateCheckFailedTitle: 'Échec de la vérification de la mise à jour', updateCheckFailed: 'Impossible de vérifier les mises à jour. Veuillez réessayer plus tard.', updateDownloadFailed: 'Impossible de télécharger la mise à jour. Veuillez réessayer.', + updateDownloadSpaceFailed: 'Espace disque insuffisant pour télécharger la mise à jour. Libérez de l’espace, puis réessayez.', + updateDownloadPermissionFailed: 'Impossible d’écrire les fichiers de mise à jour. Vérifiez les autorisations du dossier, puis réessayez.', + updateDownloadBusyFailed: 'Un fichier de mise à jour est utilisé. Fermez le programme qui l’utilise, puis réessayez.', updateInstallFailed: "Impossible d'installer la mise à jour. Veuillez réessayer plus tard.", updateCheckNetworkFailed: 'Impossible de vérifier les mises à jour. Vérifiez votre connexion et réessayez.', updateDownloadNetworkFailed: 'Impossible de télécharger la mise à jour. Vérifiez votre connexion et réessayez.', @@ -998,9 +1053,20 @@ export const de = { reloadPageMenu: 'Seite neu laden', toggleDevToolsMenu: 'Entwicklertools', restartAppHostMenu: 'Anwendung und Host neu starten', + updateConfigurationFailed: 'Die Update-Einstellungen konnten nicht angewendet werden. Korrigiere die Datei und starte neu, oder wähle in den Einstellungen einen Update-Kanal.', + updateConfigurationRead: 'Fehler in der Einstellungsdatei; Updates pausiert', + updateConfigurationFormat: 'Fehler in der Einstellungsdatei; Updates pausiert', + updateConfigurationChannel: 'Fehler in der Einstellungsdatei; Updates pausiert', + updateConfigurationCleanup: 'Updates pausiert: vorheriges Update nicht verwerfbar; App neu starten', + updateReloadSettings: 'Einstellungen neu laden', + updateConfigurationDetails: 'Einstellungen prüfen', + updateShowSettingsFile: 'Einstellungsdatei anzeigen', updateCheckFailedTitle: 'Update-Überprüfung fehlgeschlagen', updateCheckFailed: 'Es konnte nicht nach Updates gesucht werden. Bitte versuchen Sie es später noch einmal.', updateDownloadFailed: 'Das Update konnte nicht heruntergeladen werden. Bitte versuchen Sie es erneut.', + updateDownloadSpaceFailed: 'Nicht genügend Speicherplatz für das Update. Geben Sie Speicherplatz frei und versuchen Sie es erneut.', + updateDownloadPermissionFailed: 'Update-Dateien können nicht geschrieben werden. Prüfen Sie die Ordnerberechtigungen und versuchen Sie es erneut.', + updateDownloadBusyFailed: 'Eine Update-Datei wird verwendet. Schließen Sie das verwendende Programm und versuchen Sie es erneut.', updateInstallFailed: 'Das Update konnte nicht installiert werden. Bitte versuchen Sie es später noch einmal.', updateCheckNetworkFailed: 'Es konnte nicht nach Updates gesucht werden. Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.', updateDownloadNetworkFailed: 'Das Update konnte nicht heruntergeladen werden. Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.', @@ -1176,9 +1242,20 @@ export const ru = { reloadPageMenu: 'Обновить страницу', toggleDevToolsMenu: 'Инструменты разработчика', restartAppHostMenu: 'Перезапустите приложение и хост.', + updateConfigurationFailed: 'Не удалось применить настройки обновления. Исправьте файл и перезапустите приложение или выберите канал в настройках.', + updateConfigurationRead: 'Ошибка файла настроек; обновления приостановлены', + updateConfigurationFormat: 'Ошибка файла настроек; обновления приостановлены', + updateConfigurationChannel: 'Ошибка файла настроек; обновления приостановлены', + updateConfigurationCleanup: 'Обновления приостановлены: не удалось удалить предыдущее обновление; перезапустите приложение', + updateReloadSettings: 'Перечитать настройки', + updateConfigurationDetails: 'Проверить настройки', + updateShowSettingsFile: 'Показать файл настроек', updateCheckFailedTitle: 'Проверка обновления не удалась', updateCheckFailed: 'Не удалось проверить наличие обновлений. Пожалуйста, повторите попытку позже.', updateDownloadFailed: 'Не удалось загрузить обновление. Пожалуйста, попробуйте еще раз.', + updateDownloadSpaceFailed: 'Недостаточно места для загрузки обновления. Освободите место и повторите попытку.', + updateDownloadPermissionFailed: 'Не удалось записать файлы обновления. Проверьте права доступа к папке и повторите попытку.', + updateDownloadBusyFailed: 'Файл обновления занят. Закройте использующую его программу и повторите попытку.', updateInstallFailed: 'Не удалось установить обновление. Пожалуйста, повторите попытку позже.', updateCheckNetworkFailed: 'Не удалось проверить наличие обновлений. Проверьте подключение и повторите попытку.', updateDownloadNetworkFailed: 'Не удалось загрузить обновление. Проверьте подключение и повторите попытку.', @@ -1354,9 +1431,20 @@ export const pt = { reloadPageMenu: 'Recarregar página', toggleDevToolsMenu: 'Ferramentas do desenvolvedor', restartAppHostMenu: 'Reinicie o aplicativo e o host', + updateConfigurationFailed: 'Não foi possível aplicar as configurações de atualização. Corrija o arquivo e reinicie, ou selecione um canal nas configurações.', + updateConfigurationRead: 'Erro no arquivo de configurações; atualizações pausadas', + updateConfigurationFormat: 'Erro no arquivo de configurações; atualizações pausadas', + updateConfigurationChannel: 'Erro no arquivo de configurações; atualizações pausadas', + updateConfigurationCleanup: 'Atualizações pausadas: não foi possível descartar a atualização anterior; reinicie o aplicativo', + updateReloadSettings: 'Recarregar configurações', + updateConfigurationDetails: 'Verificar configurações', + updateShowSettingsFile: 'Mostrar arquivo de configurações', updateCheckFailedTitle: 'Falha na verificação de atualização', updateCheckFailed: 'Não foi possível verificar se há atualizações. Por favor, tente novamente mais tarde.', updateDownloadFailed: 'Não foi possível baixar a atualização. Por favor, tente novamente.', + updateDownloadSpaceFailed: 'Não há espaço suficiente para baixar a atualização. Libere espaço e tente novamente.', + updateDownloadPermissionFailed: 'Não foi possível gravar os arquivos de atualização. Verifique as permissões da pasta e tente novamente.', + updateDownloadBusyFailed: 'Um arquivo de atualização está em uso. Feche o programa que o utiliza e tente novamente.', updateInstallFailed: 'Não foi possível instalar a atualização. Por favor, tente novamente mais tarde.', updateCheckNetworkFailed: 'Não foi possível verificar se há atualizações. Verifique sua conexão e tente novamente.', updateDownloadNetworkFailed: 'Não foi possível baixar a atualização. Verifique sua conexão e tente novamente.', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 4c223c3c09..1a2488fe9d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,8 +1,12 @@ -import { installFileActions } from './file-actions.ts' -import type { ProductEventMap, ProductEvent } from '@deepseek-ai/dsh-client-product-analytics/types' -import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts' /** Electron shell: desktop project ownership, custom protocol, windows, and lifecycle. */ +import { installFileActions } from './file-actions.ts' +import { configurationFailure, channelSaveFailure, configurationMessage } from './update-configuration.ts' +import type { DesktopChannelState, DesktopChannelChangeResult } from '@deepseek-ai/dsh-client-ui-settings-general/types' +import { DesktopUpdateSettings } from './update-settings.ts' +import { isDesktopUpdateChannel } from './settings-file.ts' +import type { ProductEventMap, ProductEvent } from '@deepseek-ai/dsh-client-product-analytics/types' +import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts' import { access, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -365,7 +369,7 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi } async function main(): Promise { - const desktopSettings = readDesktopSettings(app.getPath('userData')) + const desktopSettings = await readDesktopSettings(app.getPath('userData')) void pruneCrashReports(app.getPath('logs')) const journalDirectory = process.env.DSH_DESKTOP_UPDATE_JOURNAL_DIR const updateJournal = journalDirectory === undefined ? undefined : new DesktopUpdateJournal(journalDirectory, app.getVersion()) @@ -421,6 +425,9 @@ async function main(): Promise { let updateStoppedHost = false let updateStopFailure: DesktopHostUncleanExitError | undefined let updateState: DesktopUpdateState = { phase: 'idle' } + let channelState: DesktopChannelState | undefined + const updatePresentation = (state: DesktopUpdateState) => ({ ...presentDesktopUpdate(state), + ...(channelState?.error === undefined ? {} : { configurationError: channelState.error }) }) const systemLanguages = app.getPreferredSystemLanguages() let locale = windowsLanguage === undefined ? resolveDesktopStartupLocale(null, systemLanguages) : resolveDesktopLocale(windowsLanguage) @@ -442,11 +449,11 @@ async function main(): Promise { const updateOverlays = new DesktopUpdateOverlays() const updateDialog = new DesktopUpdateDialog(fileURLToPath(new URL('./preload-update-dialog.cjs', import.meta.url)), () => locale, updateOverlays) const isMandatory = (): boolean => mandatoryPolicy?.state.blocking === true - const ordinaryMessageBox = async (options: UpdateDialogOptions): Promise => { + const ordinaryMessageBox = async (options: UpdateDialogOptions, + parent = currentDialogWindow()): Promise => { const controller = new AbortController() ordinaryDialogs.add(controller) try { - const parent = currentDialogWindow() if (parent === undefined) return { response: options.cancelId ?? 0, checkboxChecked: false } return await updateDialog.show(parent, { ...options, signal: controller.signal }) } @@ -614,9 +621,9 @@ async function main(): Promise { updateState = state mandatoryUI?.sync() for (const window of BrowserWindow.getAllWindows()) { - window.webContents.send(DESKTOP_IPC.updatesPresentation, presentDesktopUpdate(state)) + window.webContents.send(DESKTOP_IPC.updatesPresentation, updatePresentation(state)) } - if (state.phase === 'error' && state.failedOperation !== 'check') { + if (state.phase === 'error' && state.failedOperation !== 'check' && state.failedOperation !== 'configuration') { if (state.failedOperation === 'install' && fileDragStopped && !quitting) { fileDragCleanups.add(installFileDrag()) fileDragStopped = false @@ -678,7 +685,13 @@ async function main(): Promise { } const manifest: unknown = JSON.parse(await readFile(join(app.getAppPath(), 'package.json'), 'utf8')) - const updateChannel = desktopUpdateChannel(manifest) + const packagedChannel = desktopUpdateChannel(manifest) + const channelSettings = new DesktopUpdateSettings(app.getPath('userData'), packagedChannel) + const initialChannel = await channelSettings.load() + channelState = { channel: initialChannel.kind === 'ready' ? initialChannel.channel : null, + packagedChannel, changing: false, + ...(initialChannel.kind === 'error' ? { error: configurationFailure(initialChannel.error) } : {}) } + const updateChannel = initialChannel.kind === 'ready' ? initialChannel.channel : packagedChannel const updates = new DesktopUpdateCoordinator( publishUpdate, async () => { @@ -739,12 +752,22 @@ async function main(): Promise { updateChannel, ) + if (initialChannel.kind === 'error') updates.suspendConfiguration(initialChannel.error.path) + const publishChannel = (state: DesktopChannelState): DesktopChannelState => { + channelState = state + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.send(DESKTOP_IPC.updatesChannelChanged, state) + window.webContents.send(DESKTOP_IPC.updatesPresentation, updatePresentation(updates.state)) + } + return state + } + const updateSchedule = new DesktopUpdateSchedule(updates, resolveDesktopUpdateScheduleConfig(process.env)) - const downloadUpdate = async (version: string): Promise => { + const downloadUpdate = async (version: string, revision = updates.revision): Promise => { void track('desktop_upgrade_click', {}) updateJournal?.action('download-requested') - const state = await updates.download(version) + const state = await updates.download(version, revision) if (state.phase !== 'ready' || quitting) return state // Only a completed user-driven download opens this prompt; cancelling installation does not reopen it. // A confirmation on a hidden window would go unseen, so it waits for the next show; the mandatory @@ -752,7 +775,7 @@ async function main(): Promise { if (!isMandatory()) await windowShown() // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- A quit can begin while the show is awaited. if (quitting) return state - return updates.install(version) + return updates.install(version, revision) } const windowShown = (): Promise => new Promise((resolve) => { const window = currentDialogWindow() @@ -869,9 +892,39 @@ async function main(): Promise { windowsLanguage = locale.id refreshApplicationMenu() }) + ipcMain.handle(DESKTOP_IPC.updatesChannelGet, (event) => { + assertProductSender(event) + return channelState + }) + let channelRequests = 0 + ipcMain.handle(DESKTOP_IPC.updatesChannelSet, async (event, channel: unknown): Promise => { + assertProductSender(event) + if (!isDesktopUpdateChannel(channel)) throw new Error('desktop update: unsupported channel') + if (channelState === undefined) throw new Error('desktop update: settings are not initialized') + channelRequests++ + publishChannel({ ...channelState, changing: true }) + try { + const kind = await updates.switchChannel(channel, async () => { + try { await channelSettings.save(channel) } catch (error) { + if (channelSaveFailure(error) === 'uncertain') { + updates.suspendConfiguration('desktop update: settings save outcome is unknown') + publishChannel({ channel: updates.selectedChannel, packagedChannel, changing: true, error: 'read' }) + } + throw error + } + }) + if (kind === 'applied') void mandatoryPolicy?.check('channel-change', true).catch((error: unknown) => { console.error(error) }) + return { kind, state: publishChannel({ channel: updates.selectedChannel, packagedChannel, changing: --channelRequests > 0 }) } + } catch (error) { + console.error('desktop update: channel change failed', error) + const state = publishChannel({ ...channelState, changing: --channelRequests > 0, + ...(updates.configurationFailure === 'cleanup' ? { error: 'cleanup' as const } : {}) }) + return { kind: 'failed', state, reason: channelSaveFailure(error) } + } + }) ipcMain.handle(DESKTOP_IPC.updatesStatus, (event) => { assertProductSender(event) - return presentDesktopUpdate(updates.state) + return updatePresentation(updates.state) }) ipcMain.handle(DESKTOP_IPC.deviceInfo, (event) => { assertProductSender(event) @@ -897,9 +950,52 @@ async function main(): Promise { await openUpdatePrompt() }) + let configurationPrompt: Promise | undefined + const showConfigurationError = (): Promise => { + configurationPrompt ??= Promise.resolve().then(async () => { + while (channelState?.error !== undefined) { + const result = await ordinaryMessageBox({ type: 'error', title: locale.messages.updateFailedTitle, + message: configurationMessage(channelState.error, locale.messages), + ...{ detail: channelSettings.path, + buttons: [locale.messages.updateReloadSettings, locale.messages.updateShowSettingsFile, locale.messages.updateClose], + defaultId: 0, cancelId: 2, + keepOpenAction: { index: 1, run: () => { shell.showItemInFolder(channelSettings.path) } }, + action: { index: 0, run: async () => { + if (channelRequests > 0 || channelState === undefined) return false + channelRequests++ + publishChannel({ ...channelState, changing: true }) + try { + const loaded = await channelSettings.load() + if (loaded.kind === 'error') { + publishChannel({ ...channelState, changing: true, error: configurationFailure(loaded.error) }) + return false + } + await updates.switchChannel(loaded.channel, async () => {}) + publishChannel({ channel: updates.selectedChannel, packagedChannel, changing: true }) + return true + } catch (error) { + console.error('desktop update: settings reload failed', error) + publishChannel({ ...channelState, + ...(updates.configurationFailure === 'cleanup' ? { error: 'cleanup' as const } : {}) }) + return false + } finally { + publishChannel({ ...channelState, changing: --channelRequests > 0 }) + } + } } }, + }, mandatoryUI?.confirmationWindow ?? currentDialogWindow()) + if (result.response !== 0) return + } + }).finally(() => { configurationPrompt = undefined }) + return configurationPrompt + } + let promptOperation: Promise | undefined let policyAuthenticationQueued = false + /** Present check results only while their captured channel revision remains current. */ const openUpdatePrompt = (manual = false): Promise => { + if (channelState?.error !== undefined) { + return showConfigurationError() + } if (authenticationOperation !== undefined) { policyAuth?.focus(); updateDialog.focus() } @@ -917,6 +1013,7 @@ async function main(): Promise { let progress: Promise | undefined try { let state = updates.state + const revision = updates.revision if (manual || state.phase === 'idle' || (state.phase === 'error' && state.failedOperation === 'check')) { controller = new AbortController() ordinaryDialogs.add(controller) @@ -928,6 +1025,7 @@ async function main(): Promise { } state = await updateSchedule.check(true) } + if (revision !== updates.revision) return if (isMandatory()) { mandatoryUI?.focus(); return } if (state.phase === 'error' && state.failedOperation === 'check') { await showUpdateFailure(state); return } if (state.phase === 'idle') { @@ -939,7 +1037,7 @@ async function main(): Promise { if (state.phase === 'ready' || (state.phase === 'error' && state.failedOperation === 'install')) { if (state.version !== undefined) { failedOperation = 'install' - await showUpdateFailure(await updates.install(state.version)) + await showUpdateFailure(await updates.install(state.version, revision)) } return } @@ -954,7 +1052,7 @@ async function main(): Promise { if (!isMandatory() && state.version !== undefined) { controller?.abort() failedOperation = 'download' - await showUpdateFailure(await downloadUpdate(state.version)) + await showUpdateFailure(await downloadUpdate(state.version, revision)) } } finally { controller?.abort() @@ -1411,7 +1509,7 @@ async function main(): Promise { let wasBlocking = false mandatoryPolicy = new DesktopMandatoryUpdatePolicy(policyConfig, { platform: process.platform as 'win32' | 'darwin', arch: process.arch as 'x64' | 'arm64', - channel: updateChannel, + get channel() { return updates.selectedChannel }, bundledDshVersion: app.isPackaged ? readDesktopRuntime(resources.dsh).release.version : app.getVersion(), }, (state) => { if (state.error !== 'authentication-required') policyAuthenticationQueued = false @@ -1429,7 +1527,10 @@ async function main(): Promise { preload: fileURLToPath(new URL('./preload-mandatory.cjs', import.meta.url)), locale, allowedPageOrigins: policyConfig.allowedPageOrigins, parent: () => mainWindow, policy: () => policy.state, update: () => updates.state, - refresh: async () => { await Promise.all([checkPolicyManually(), updateSchedule.check(true)]) }, + refresh: async () => { + if (channelState?.error !== undefined) await showConfigurationError() + if (channelState?.error === undefined) await Promise.all([checkPolicyManually(), updateSchedule.check(true)]) + }, download: downloadUpdate, install: version => updates.install(version), }) void mandatoryPolicy.check('launch').then((state) => { diff --git a/apps/desktop/src/mandatory-update-policy.ts b/apps/desktop/src/mandatory-update-policy.ts index 92168a7ea3..6c65da3a68 100644 --- a/apps/desktop/src/mandatory-update-policy.ts +++ b/apps/desktop/src/mandatory-update-policy.ts @@ -8,7 +8,7 @@ export interface DesktopPolicyIdentity { readonly platform: 'win32' | 'darwin' readonly bundledDshVersion: string readonly arch: 'x64' | 'arm64' - readonly channel: 'nightly' | 'latest' + readonly channel: 'nightly' | 'stable' } /** Validated deployment choices; test authentication is explicitly enabled, never inferred from a redirect. */ @@ -129,6 +129,7 @@ function parsePolicy(body: unknown, ok: boolean, config: DesktopPolicyConfig): D export class DesktopMandatoryUpdatePolicy { private current: DesktopPolicyState = { blocking: false, checking: false } private pending: Promise | undefined + private pendingChannel: DesktopPolicyIdentity['channel'] | undefined private controller: AbortController | undefined private timer: ReturnType | undefined private disposed = false @@ -137,7 +138,7 @@ export class DesktopMandatoryUpdatePolicy { /** * @param config - Resolved deployment settings. - * @param identity - Installed software identity, fixed for this application build. + * @param identity - Installed software identity; a channel getter may follow an explicit user selection. * @param publish - Receives policy changes without controlling downloads or existing tasks. * @param request - Anonymous Fetch or the dedicated test-authentication Session transport. * @param client - UI build version, language, and UTC offset, sampled for every check. @@ -156,10 +157,10 @@ export class DesktopMandatoryUpdatePolicy { } /** Platform headers for one check; the calling UI's language and UTC offset are read now. */ - private requestHeaders(): Record { + private requestHeaders(channel: DesktopPolicyIdentity['channel']): Record { return { ...platformClientHeaders(this.identity.platform, this.client()), - 'x-client-arch': this.identity.arch, 'x-client-update-channel': this.identity.channel, + 'x-client-arch': this.identity.arch, 'x-client-update-channel': channel, 'x-client-bundled-dsh-version': this.identity.bundledDshVersion, } } @@ -175,9 +176,12 @@ export class DesktopMandatoryUpdatePolicy { */ check(scenario: string, manual = false): Promise { if (this.disposed) return Promise.reject(new Error('desktop policy: disposed')) - if (this.pending !== undefined) return this.pending + if (this.pending !== undefined) return this.pendingChannel === this.identity.channel + ? this.pending : this.pending.then(() => this.check(scenario, true)) if (!manual && Date.now() < this.nextCheck) return Promise.resolve(this.current) clearTimeout(this.timer) + const channel = this.identity.channel + this.pendingChannel = channel this.pending = Promise.resolve().then(async () => { if (this.disposed) return this.current const controller = new AbortController() @@ -187,7 +191,7 @@ export class DesktopMandatoryUpdatePolicy { try { const url = new URL('/api/v0/check_client_update', this.config.origin) url.searchParams.set('scenario', scenario) - const response = await this.request(url, { headers: this.requestHeaders(), signal: controller.signal, + const response = await this.request(url, { headers: this.requestHeaders(channel), signal: controller.signal, credentials: this.config.authentication === 'feishu-test' ? 'include' : 'omit', cache: 'no-store', redirect: 'error' }) const body: unknown = await response.json() if (this.config.authentication === 'feishu-test' && response.status === 401 diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index 26266b4f1c..6b4e989614 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -1,6 +1,7 @@ import type { FileSaveResult } from '@deepseek-ai/dsh-client-ui-file-actions/types' /** Origin-scoped boot, native directory selection, host paths of picked files, and update presentation with native confirmation actions. */ +import type { DesktopChannelState, DesktopChannelChangeResult } from '@deepseek-ai/dsh-client-ui-settings-general/types' import type { DesktopShortcutInput, ShortcutConfigSnapshot, ShortcutSaveResult } from '@deepseek-ai/dsh-client-shortcuts/protocol' import { contextBridge, ipcRenderer, webUtils } from 'electron' import { DESKTOP_IPC, SCHEME, type DshDesktopProductApi, type DesktopUpdatePresentation } from './ipc.ts' @@ -65,6 +66,15 @@ function createProductApi(): DshDesktopProductApi { }, }, updates: { + channels: { + get: () => ipcRenderer.invoke(DESKTOP_IPC.updatesChannelGet) as Promise, + set: channel => ipcRenderer.invoke(DESKTOP_IPC.updatesChannelSet, channel) as Promise, + subscribe(listener) { + const handle = (_event: Electron.IpcRendererEvent, state: DesktopChannelState): void => { listener(state) } + ipcRenderer.on(DESKTOP_IPC.updatesChannelChanged, handle) + return () => { ipcRenderer.off(DESKTOP_IPC.updatesChannelChanged, handle) } + }, + }, status: () => ipcRenderer.invoke(DESKTOP_IPC.updatesStatus) as Promise, open: () => ipcRenderer.invoke(DESKTOP_IPC.updatesOpen) as Promise, subscribe(listener) { diff --git a/apps/desktop/src/settings-file.ts b/apps/desktop/src/settings-file.ts new file mode 100644 index 0000000000..023e2e29a6 --- /dev/null +++ b/apps/desktop/src/settings-file.ts @@ -0,0 +1,166 @@ +/** Shared shell settings storage; reads have no side effects and fields fail independently. */ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' +import { z } from 'zod' + +const preferencesSchema = z.object({ + allowTestAuthPopupWindow: z.boolean(), + channel: z.enum(['nightly', 'stable']), +}) +const documentSchema = z.looseObject({ updates: z.looseObject({}).optional() }) + +/** Known field values before consumers supply defaults for absent fields. */ +export type DesktopUpdatePreferences = z.infer +/** Unknown root and updates fields survive a read-modify-write operation. */ +export type DesktopSettingsDocument = z.infer +/** Handling-relevant failure details; validation failures never include document contents. */ +export type DesktopSettingsProblem = + | { kind: 'read' } + | { kind: 'invalid'; reason: 'json' | 'structure' } + | { kind: 'invalid'; reason: 'field'; field: keyof DesktopUpdatePreferences } + | { kind: 'write'; operation: 'initialize' | 'save'; effect: 'unchanged' | 'unknown' | 'written' } + +/** A storage or validation failure; business consumers decide fallback or suspension. */ +export class DesktopSettingsFileError extends Error { + /** + * @param problem - Stable failure classification and mutation outcome, when applicable. + * @param path - Settings file location, never file contents. + * @param options - Original filesystem error, when available. + */ + constructor(readonly problem: DesktopSettingsProblem, readonly path: string, options?: ErrorOptions) { + super(`desktop settings: ${problem.kind}: ${path}`, options) + } +} + +/** Absence permits a caller default; an invalid explicit field does not. */ +export type DesktopSettingsField = + | { kind: 'absent' } + | { kind: 'valid'; value: T } + | { kind: 'invalid'; error: DesktopSettingsFileError } + +/** File state and independent field results; a missing file is never implicitly created. */ +export type DesktopSettingsRead = + | { kind: 'missing' } + | { kind: 'error'; error: DesktopSettingsFileError } + | { + kind: 'ready' + document: DesktopSettingsDocument + fields: { [K in keyof DesktopUpdatePreferences]: DesktopSettingsField } + } + +function field(schema: z.ZodType, value: unknown, name: keyof DesktopUpdatePreferences, path: string): DesktopSettingsField { + if (value === undefined) return { kind: 'absent' } + const result = schema.safeParse(value) + return result.success ? { kind: 'valid', value: result.data } + : { kind: 'invalid', error: new DesktopSettingsFileError({ kind: 'invalid', reason: 'field', field: name }, path) } +} + +/** + * Validate a channel at the IPC boundary using the same schema as file loading. + * @param value - Untrusted input. + * @returns Whether the input is a supported channel. + */ +export function isDesktopUpdateChannel(value: unknown): value is DesktopUpdatePreferences['channel'] { + return preferencesSchema.shape.channel.safeParse(value).success +} + +/** + * Resolve the document shared by shell preferences and update channels. + * @param userData - Electron userData, independent of Host and DSH_HOME. + * @returns Settings file path without filesystem changes. + */ +function desktopSettingsPath(userData: string): string { + return join(userData, 'desktop', 'settings.json') +} + +/** File operations do not watch, cache, choose defaults or apply runtime preferences. */ +export class DesktopSettingsFile { + readonly path: string + + /** @param userData - Electron userData, independent of Host and DSH_HOME. */ + constructor(userData: string) { this.path = desktopSettingsPath(userData) } + + /** @returns Fresh file and field states; performs no creation or repair. */ + async read(): Promise { + let raw: string + try { raw = await readFile(this.path, 'utf8') } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { kind: 'missing' } + return { kind: 'error', error: new DesktopSettingsFileError({ kind: 'read' }, this.path, { cause: error }) } + } + let value: unknown + try { value = JSON.parse(raw) } catch (_error) { + // SyntaxError can quote user content; diagnostics expose only the file and failure category. + return { kind: 'error', error: new DesktopSettingsFileError({ kind: 'invalid', reason: 'json' }, this.path) } + } + return this.inspect(value) + } + + private inspect(value: unknown): DesktopSettingsRead { + const result = documentSchema.safeParse(value) + if (!result.success) return { kind: 'error', error: new DesktopSettingsFileError({ kind: 'invalid', reason: 'structure' }, this.path) } + const document = result.data + return { kind: 'ready', document, fields: { + allowTestAuthPopupWindow: field(preferencesSchema.shape.allowTestAuthPopupWindow, + document.updates?.allowTestAuthPopupWindow, 'allowTestAuthPopupWindow', this.path), + channel: field(preferencesSchema.shape.channel, document.updates?.channel, 'channel', this.path), + } } + } + + /** + * Explicit startup initialization; existing files, including invalid ones, are never replaced. + * @returns Current file state after exclusive creation if missing, or a structured failure. + */ + async initialize(): Promise { + const current = await this.read() + if (current.kind !== 'missing') return current + try { return await this.mutate({ kind: 'initialize' }) } catch (error) { + if (!(error instanceof DesktopSettingsFileError)) throw error + return { kind: 'error', error } + } + } + + /** + * Patch known fields under the shared writer lock, preserving unknown and unrelated invalid fields. + * Malformed JSON/structure is rejected; a missing document starts empty. External editors must + * not write concurrently. No runtime settings change here. Write errors distinguish unchanged, + * uncertain write completion, and a committed write whose lock cleanup failed; no rollback is attempted. + * @param updates - Typed fields to replace; omitted keys remain unchanged. + * @returns After atomic replacement and lock release; otherwise throws DesktopSettingsFileError. + */ + async saveUpdates(updates: Partial): Promise { + const result = await this.mutate({ kind: 'save', updates }) + if (result.kind === 'error') throw result.error + } + + private async mutate(request: { kind: 'initialize' } | { kind: 'save'; updates: Partial }): Promise { + let effect: 'unchanged' | 'unknown' | 'written' = 'unchanged' + try { + await mkdir(dirname(this.path), { recursive: true, mode: 0o700 }) + return await withFileLock(this.path, async () => { + const current = await this.read() + if (current.kind === 'error') throw current.error + if (request.kind === 'initialize' && current.kind !== 'missing') return current + const document = current.kind === 'ready' ? current.document : {} + const updates = request.kind === 'initialize' ? { allowTestAuthPopupWindow: false } : request.updates + const next = { ...document, updates: { ...document.updates, ...updates } } + const raw = `${JSON.stringify(next, null, 2)}\n` + effect = 'unknown' + if (request.kind === 'initialize') { + try { await writeFile(this.path, raw, { flag: 'wx', mode: 0o600 }) } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error + effect = 'unchanged' + return this.read() + } + } else { + await writeFileAtomic(this.path, raw, { mode: 0o600 }) + } + effect = 'written' + return this.inspect(next) + }) + } catch (error) { + if (error instanceof DesktopSettingsFileError) throw error + throw new DesktopSettingsFileError({ kind: 'write', operation: request.kind, effect }, this.path, { cause: error }) + } + } +} diff --git a/apps/desktop/src/settings.ts b/apps/desktop/src/settings.ts index a4bb67b3bd..55521bbc41 100644 --- a/apps/desktop/src/settings.ts +++ b/apps/desktop/src/settings.ts @@ -1,57 +1,26 @@ -/** - * Local Electron shell settings, loaded once per process independently of the Host. - * Ownership: .agents/notes/implemented/architecture/2026-10-08-desktop-shell-configuration.md. - */ -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' +/** Startup policy for optional Electron shell preferences, independent of the Host. */ +import { DesktopSettingsFile, type DesktopUpdatePreferences } from './settings-file.ts' -/** Resolved shell preferences; absent fields use the defaults without rewriting existing files. */ +/** Resolved popup preference; channel failures do not disable a valid popup preference. */ export interface DesktopSettings { - readonly updates: { - readonly allowTestAuthPopupWindow: boolean - } -} - -function record(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) + readonly updates: Pick } /** - * Read shell settings, creating the default document exclusively when missing. - * @param userData - Electron's userData directory, independent of DSH_HOME. - * @returns Validated settings, or defaults when reading, creating or validating fails; changes require a restart. + * Initialize the shared document explicitly at startup, then resolve the popup preference. + * @param userData - Electron userData, independent of DSH_HOME. + * @returns Validated popup preference, or false with a diagnostic on failure; changes require restart. */ -export function readDesktopSettings(userData: string): DesktopSettings { - const directory = join(userData, 'desktop') - const path = join(directory, 'settings.json') - const defaults: DesktopSettings = { updates: { allowTestAuthPopupWindow: false } } - try { - let raw: string - try { raw = readFileSync(path, 'utf8') } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error - mkdirSync(directory, { recursive: true, mode: 0o700 }) - try { writeFileSync(path, `${JSON.stringify(defaults, null, 2)}\n`, { flag: 'wx', mode: 0o600 }) } catch (error) { - // An existing document belongs to the user, including one created during initialization. - if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error - } - raw = readFileSync(path, 'utf8') +export async function readDesktopSettings(userData: string): Promise { + const file = new DesktopSettingsFile(userData) + const result = await file.initialize() + const preference = result.kind === 'ready' ? result.fields.allowTestAuthPopupWindow : undefined + if (preference?.kind === 'valid') return { updates: { allowTestAuthPopupWindow: preference.value } } + const error = result.kind === 'error' ? result.error : preference?.kind === 'invalid' ? preference.error : undefined + if (error) { + try { console.warn(`desktop settings: ${file.path}: using defaults because settings could not be loaded`, error) } catch (_logError) { + // Optional popup settings remain disabled even when diagnostic output is unavailable. } - const value: unknown = JSON.parse(raw) - if (!record(value) || (value.updates !== undefined && !record(value.updates))) { - throw new Error('expected a JSON object with an optional updates object') - } - const allow = value.updates?.allowTestAuthPopupWindow - if (allow !== undefined && typeof allow !== 'boolean') { - throw new Error('updates.allowTestAuthPopupWindow must be true or false') - } - return { updates: { allowTestAuthPopupWindow: allow ?? false } } - } catch (error) { - try { - console.warn(`desktop settings: ${path}: using defaults because settings could not be loaded`, error) - } catch (_logError) { - // Diagnostic output is best effort; its failure must not make optional settings fatal. - // TODO: Persist settings diagnostics when console output is unavailable, without blocking startup. - } - return defaults } + return { updates: { allowTestAuthPopupWindow: false } } } diff --git a/apps/desktop/src/update-channel.ts b/apps/desktop/src/update-channel.ts index ac767ea642..85f5e10da1 100644 --- a/apps/desktop/src/update-channel.ts +++ b/apps/desktop/src/update-channel.ts @@ -1,7 +1,8 @@ /** Resolve the update channel sealed into the installed application manifest. */ +import type { DesktopUpdatePreferences } from './settings-file.ts' /** Installer-owned channel, independent of release version and upload environment. */ -export type DesktopUpdateChannel = 'nightly' | 'latest' +export type DesktopUpdateChannel = DesktopUpdatePreferences['channel'] /** * Read the packaged channel; older manifests retain their existing nightly behavior. @@ -12,6 +13,6 @@ export function desktopUpdateChannel(manifest: unknown): DesktopUpdateChannel { if (typeof manifest !== 'object' || manifest === null) throw new Error('desktop update: invalid application manifest') if (!('dshDesktopUpdateChannel' in manifest)) return 'nightly' const channel = manifest.dshDesktopUpdateChannel - if (channel !== 'nightly' && channel !== 'latest') throw new Error('desktop update: invalid packaged channel') + if (channel !== 'nightly' && channel !== 'stable') throw new Error('desktop update: invalid packaged channel') return channel } diff --git a/apps/desktop/src/update-cleanup-log.ts b/apps/desktop/src/update-cleanup-log.ts new file mode 100644 index 0000000000..6171849873 --- /dev/null +++ b/apps/desktop/src/update-cleanup-log.ts @@ -0,0 +1,29 @@ +/** Durable, best-effort diagnostics for obsolete update files that could not be removed. */ +import { appendFileSync, mkdirSync } from 'node:fs' +import { join } from 'node:path' + +/** Filesystem operation on a retired target; not evidence that a future download will fail. */ +export interface UpdateCleanupFailure { + readonly operation: 'list' | 'remove' + readonly path: string + readonly error: unknown +} + +/** + * Flush one cleanup warning to disk; diagnostic failure falls back to stderr without reviving a target. + * @param directory - Shell-owned log directory, outside the update cache. + * @param failure - Failed cleanup operation; no update URL or settings contents. + */ +export function recordUpdateCleanupFailure(directory: string, failure: UpdateCleanupFailure): void { + const error = failure.error + const entry = { time: new Date().toISOString(), event: 'update-cache-cleanup-failed', + operation: failure.operation, path: failure.path, + code: error instanceof Error && 'code' in error ? String(error.code) : null, + reason: error instanceof Error ? error.message : String(error) } + try { + mkdirSync(directory, { recursive: true }) + appendFileSync(join(directory, 'update-cleanup.jsonl'), `${JSON.stringify(entry)}\n`, { mode: 0o600, flush: true }) + } catch (logError) { + console.error('desktop update: could not persist cleanup warning', entry, logError) + } +} diff --git a/apps/desktop/src/update-configuration.ts b/apps/desktop/src/update-configuration.ts new file mode 100644 index 0000000000..7e732a4a6a --- /dev/null +++ b/apps/desktop/src/update-configuration.ts @@ -0,0 +1,45 @@ +/** Safe user-facing classifications for shell update settings failures. */ +import type { DesktopChannelChangeResult, DesktopChannelState } from '@deepseek-ai/dsh-client-ui-settings-general/types' +import { DesktopSettingsFileError } from './settings-file.ts' +import type { DesktopMessages } from './locale.ts' + +/** + * Classify an unreadable startup selection without exposing file contents. + * @param error - Validated storage failure. + * @returns The persistent reason updates are suspended. + */ +export function configurationFailure(error: DesktopSettingsFileError): NonNullable { + const problem = error.problem + if (problem.kind !== 'invalid') return 'read' + return problem.reason === 'field' && problem.field === 'channel' ? 'channel' : 'format' +} + +/** + * Reduce an operation failure to a short, trustworthy toast reason. + * @param error - Failed save or channel transition. + * @returns Safe detail for the initiating settings view. + */ +export function channelSaveFailure(error: unknown): Extract['reason'] { + if (!(error instanceof DesktopSettingsFileError)) return 'apply' + if (error.problem.kind === 'write' && error.problem.effect === 'unknown') return 'uncertain' + if (error.problem.kind === 'invalid') return 'format' + const cause = error.cause + if (cause instanceof Error && 'code' in cause && (cause.code === 'EACCES' || cause.code === 'EPERM')) return 'permission' + return 'save' +} + +/** + * Explain an actual suspension and its recovery action. + * @param reason - Persistent updater configuration failure. + * @param messages - Current desktop locale. + * @returns Localized dialog message. + */ +export function configurationMessage(reason: NonNullable, messages: DesktopMessages): string { + const key = { + read: 'updateConfigurationRead', + format: 'updateConfigurationFormat', + channel: 'updateConfigurationChannel', + cleanup: 'updateConfigurationCleanup', + } as const + return messages[key[reason]] +} diff --git a/apps/desktop/src/update-coordinator.ts b/apps/desktop/src/update-coordinator.ts index 3b83cea08f..29cf2d88f3 100644 --- a/apps/desktop/src/update-coordinator.ts +++ b/apps/desktop/src/update-coordinator.ts @@ -3,33 +3,41 @@ import { existsSync } from 'node:fs' import { join } from 'node:path' import { app } from 'electron' -import electronUpdater, { type AppUpdater, type ProgressInfo, type UpdateInfo } from 'electron-updater' +import electronUpdater, { type AppUpdater, type ProgressInfo, type UpdateInfo, type UpdateCheckResult } from 'electron-updater' import { gt, valid } from 'semver' import type { DesktopUpdateState } from './ipc.ts' -import { DesktopUpdateHttpExecutor } from './update-http-executor.ts' -import { DesktopUpdatePreparationError } from './update-error.ts' +import { DesktopUpdateHttpExecutor } from './updates/electron-updater/http-executor.ts' +import { DesktopUpdatePreparationError, downloadFailureDetails } from './update-error.ts' +import type { DesktopUpdateChannel } from './update-channel.ts' +import { selectUpdateChannel } from './updates/electron-updater/channel.ts' +import { discardDesktopUpdate } from './updates/electron-updater/discard.ts' const { autoUpdater } = electronUpdater /** Owns one updater target until its download and installation settle. */ export class DesktopUpdateCoordinator { private current: DesktopUpdateState = { phase: 'idle' } - private candidate: string | undefined - private downloaded = false + private release: { kind: 'none' } | { kind: 'checked' | 'downloaded' | 'handed-off'; version: string } = { kind: 'none' } private disposed = false + private generation = 0 + private switching = 0 + private downloadGeneration = 0 + private switchTail: Promise = Promise.resolve() + private cancellation: UpdateCheckResult['cancellationToken'] + private configurationError: 'startup' | 'cleanup' | undefined private checkOperation: Promise | undefined private downloadOperation: Promise | undefined private installOperation: Promise | undefined private readonly onProgress = (progress: ProgressInfo): void => { - if (this.downloadOperation === undefined || this.downloaded) return + if (this.downloadGeneration !== this.generation || this.downloadOperation === undefined || this.downloaded) return const percent = Math.min(100, Math.max(0, progress.percent)) this.setState({ phase: percent >= 100 ? 'verifying' : 'downloading', ...this.target(), percent }) } private readonly onDownloaded = (info: UpdateInfo): void => { - if (this.downloadOperation === undefined || info.version !== this.candidate) return - this.downloaded = true + if (this.downloadGeneration !== this.generation || this.downloadOperation === undefined || info.version !== this.candidate) return + this.release = { kind: 'downloaded', version: info.version } } private readonly onError = (error: Error): void => { @@ -46,6 +54,7 @@ export class DesktopUpdateCoordinator { * @param enabled - Whether this process has a packaged update source. * @param currentVersion - Actual installed application version. * @param channel - Installed package channel; omitted test/local callers retain nightly. + * @param discard - Clears settled library targets and platform resources before another channel is used. * @param downloadResult - Once per completed download attempt, including platform preparation failures. */ constructor( @@ -55,7 +64,8 @@ export class DesktopUpdateCoordinator { private readonly enabled: () => boolean = () => app.isPackaged && existsSync(join(process.resourcesPath, 'app-update.yml')), private readonly currentVersion: () => string = () => app.getVersion(), private readonly downloadResult?: (success: boolean, reason?: string) => void, - private readonly channel: 'nightly' | 'latest' = 'nightly', + private channel: DesktopUpdateChannel = 'nightly', + private readonly discard: (updater: AppUpdater) => Promise = discardDesktopUpdate, ) { if (updater === autoUpdater) { // electron-updater omits this internal transport property from its public declarations. @@ -68,10 +78,7 @@ export class DesktopUpdateCoordinator { } this.updater.autoDownload = false this.updater.autoInstallOnAppQuit = false - this.updater.channel = channel - this.updater.allowPrerelease = channel === 'nightly' - // Selecting a channel can enable downgrade in electron-updater. - this.updater.allowDowngrade = false + selectUpdateChannel(this.updater, channel) this.updater.on('download-progress', this.onProgress) this.updater.on('update-downloaded', this.onDownloaded) this.updater.on('error', this.onError) @@ -80,6 +87,66 @@ export class DesktopUpdateCoordinator { /** Latest observable state; complete download identity remains main-process-owned. */ get state(): DesktopUpdateState { return this.current } + /** Reason ordinary updates are suspended, independently of the last operation result. */ + get configurationFailure(): 'startup' | 'cleanup' | undefined { return this.configurationError } + + /** Selected runtime channel, including while a new feed is being checked. */ + get selectedChannel(): DesktopUpdateChannel { return this.channel } + + /** Revision captured by confirmations so channel changes invalidate old user actions. */ + get revision(): number { return this.generation } + + /** + * Suspend ordinary updates when settings or previous-target cleanup are invalid. + * @param detail - File path and safe diagnostic, without document contents. + * @param reason - Startup validation or retirement of a previous target. + */ + suspendConfiguration(detail: string, reason: 'startup' | 'cleanup' = 'startup'): void { + this.configurationError = reason + this.setState({ phase: 'error', failedOperation: 'configuration', message: detail }) + } + + /** + * Persist a UI choice, retire the old target, and check the selected feed. + * Accepted choices serialize; installation cannot start while any choice is pending. + * Persistence failure leaves the previous channel and target intact. Cleanup failure + * suspends updates until another explicit switch successfully retires the target. + * @param channel - Validated UI choice. + * @param persist - Atomic settings save; rejects before a choice is applied on failure. + * @returns Whether the selection changed; healthy repeated choices preserve the current operation. + */ + switchChannel(channel: DesktopUpdateChannel, persist: () => Promise): Promise<'applied' | 'unchanged'> { + this.assertLive() + if (this.installOperation !== undefined || this.current.phase === 'installing' || this.release.kind === 'handed-off') { + return Promise.reject(new Error('desktop update: installation is in progress')) + } + this.switching++ + const operation = this.switchTail.then(async () => { + this.assertLive() + if (channel === this.channel && this.configurationError === undefined) return 'unchanged' as const + await persist() + this.generation++ + this.cancellation?.cancel() + await Promise.allSettled([this.checkOperation, this.downloadOperation]) + this.assertLive() + this.release = { kind: 'none' } + this.cancellation = undefined + try { await this.discard(this.updater) } catch (error) { + this.suspendConfiguration('desktop update: could not retire the previous update', 'cleanup') + throw error + } + this.channel = channel + selectUpdateChannel(this.updater, channel) + this.configurationError = undefined + this.setState({ phase: 'checking' }) + const result = await this.doCheck() + if (result.phase === 'error') this.setState(result) + return 'applied' as const + }).finally(() => { this.switching-- }) + this.switchTail = operation.then(() => {}).catch(() => { /* The caller reports this selection's failure; later choices may retry. */ }) + return operation + } + /** * Check metadata without downloading, joining any current check. * @param manual - Whether a failed check must remain visible in the status indicator. @@ -87,7 +154,8 @@ export class DesktopUpdateCoordinator { */ async check(manual = false): Promise { this.assertLive() - if (this.downloadOperation !== undefined || this.installOperation !== undefined || this.downloaded) return this.current + if (this.configurationError !== undefined || this.switching > 0 + || this.downloadOperation !== undefined || this.installOperation !== undefined || this.downloaded) return this.current if (!manual && this.current.phase === 'error' && this.current.failedOperation === 'download') return this.current this.checkOperation ??= Promise.resolve().then(() => this.doCheck()) .finally(() => { this.checkOperation = undefined }) @@ -98,24 +166,31 @@ export class DesktopUpdateCoordinator { /** * @param version - Version shown in the user's download confirmation. + * @param revision - Revision captured by the download confirmation; omitted uses the current target. * @returns Download readiness or failure, without authorizing installation. */ - async download(version: string): Promise { + async download(version: string, revision = this.generation): Promise { this.assertLive() + if (revision !== this.generation || this.configurationError !== undefined || this.switching > 0) return this.current if (this.downloaded || this.installOperation !== undefined) return this.current + const generation = this.generation + this.downloadGeneration = generation this.downloadOperation ??= Promise.resolve().then(async () => { await this.checkOperation + if (generation !== this.generation) return this.current this.assertLive() if (this.candidate === undefined) throw new Error('desktop update: no checked update is available') if (version !== this.candidate) throw new Error('desktop update: download confirmation is stale') this.setState({ phase: 'downloading', version, percent: 0 }) try { - await this.updater.downloadUpdate() + await this.updater.downloadUpdate(this.cancellation) + if (generation !== this.generation || this.disposed) return this.current if (!this.downloaded) throw new Error('desktop update: platform preparation did not report readiness') this.downloadResult?.(true) return this.setState({ phase: 'ready', version }) } catch (error) { - this.downloaded = false + if (generation !== this.generation || this.disposed) return this.current + this.release = { kind: 'checked', version } this.downloadResult?.(false, error instanceof DesktopUpdatePreparationError ? error.kind : 'download_failed') return this.setState(this.failure(error, 'download')) } @@ -126,16 +201,20 @@ export class DesktopUpdateCoordinator { /** * Install a prepared target after a separate user confirmation. * @param version - Exact version displayed in the confirmation, never a renderer-selected URL. + * @param revision - Revision captured when the confirmation began; omitted uses the current target. * @returns Installation handoff or a recoverable preparation error. */ - async install(version: string): Promise { + async install(version: string, revision = this.generation): Promise { this.assertLive() + if (revision !== this.generation) return this.current + if (this.configurationError !== undefined || this.switching > 0) throw new Error('desktop update: channel selection is not ready') if (!this.downloaded || this.downloadOperation !== undefined || version !== this.candidate) throw new Error('desktop update: confirmed target is not ready') this.installOperation ??= Promise.resolve().then(async () => { this.setState({ phase: 'installing', version }) try { if (!await this.beforeRestart()) return this.setState({ phase: 'ready', version }) this.assertLive() + this.release = { kind: 'handed-off', version } this.updater.quitAndInstall(true, true) return this.current } catch (error) { @@ -152,10 +231,14 @@ export class DesktopUpdateCoordinator { this.updater.off('download-progress', this.onProgress) this.updater.off('update-downloaded', this.onDownloaded) // Pending updater promises can still emit EventEmitter errors during shutdown. - void Promise.allSettled([this.checkOperation, this.downloadOperation, this.installOperation]) + void Promise.allSettled([this.checkOperation, this.downloadOperation, this.installOperation, this.switchTail]) .then(() => { this.updater.off('error', this.onError) }) } + private get candidate(): string | undefined { return this.release.kind === 'none' ? undefined : this.release.version } + + private get downloaded(): boolean { return this.release.kind === 'downloaded' || this.release.kind === 'handed-off' } + private assertLive(): void { if (this.disposed) throw new Error('desktop update: coordinator is disposed') } @@ -171,6 +254,7 @@ export class DesktopUpdateCoordinator { private failure(error: unknown, failedOperation: 'check' | 'download' | 'install'): DesktopUpdateState { return { phase: 'error', ...this.target(), failedOperation, message: error instanceof Error ? error.message : String(error), + ...(failedOperation === 'download' ? downloadFailureDetails(error) : {}), ...(error instanceof DesktopUpdatePreparationError ? { preparationFailure: error.kind, ...(error.technicalDetails === undefined ? {} : { technicalDetails: error.technicalDetails }), @@ -182,17 +266,22 @@ export class DesktopUpdateCoordinator { } private async doCheck(): Promise { + const generation = this.generation try { this.assertLive() if (!this.enabled()) throw new Error('desktop update: this application has no packaged update source') const result = await this.updater.checkForUpdates() + if (generation !== this.generation || this.disposed) return this.current if (result === null) throw new Error('desktop update: no check result was returned') + this.cancellation = result.cancellationToken const version = result.updateInfo.version if (valid(version) === null) throw new Error('desktop update: feed version is invalid') - if (this.channel === 'latest' && version.includes('-')) throw new Error('desktop update: latest feed contains a prerelease') - this.candidate = result.isUpdateAvailable && gt(version, this.currentVersion()) ? version : undefined + if (this.channel === 'stable' && version.includes('-')) throw new Error('desktop update: stable feed contains a prerelease') + this.release = result.isUpdateAvailable && gt(version, this.currentVersion()) + ? { kind: 'checked', version } : { kind: 'none' } return this.setState(this.candidate === undefined ? { phase: 'idle' } : { phase: 'available', version }) } catch (error) { + if (generation !== this.generation || this.disposed) return this.current return this.failure(error, 'check') } } diff --git a/apps/desktop/src/update-dialog.ts b/apps/desktop/src/update-dialog.ts index e7a7916028..65efdd80cd 100644 --- a/apps/desktop/src/update-dialog.ts +++ b/apps/desktop/src/update-dialog.ts @@ -8,6 +8,8 @@ export const UPDATE_DIALOG_IPC = { status: 'dsh-update-dialog:status', changed: /** Text and choices supplied by the main process, never by product documents. */ export interface UpdateDialogView { + /** Button running a main-owned action; responses are disabled until it settles. */ + readonly busyButton?: number /** Identifies the displayed choices; a response from an older prompt is rejected. */ readonly revision: number readonly locale: string @@ -24,6 +26,10 @@ export interface UpdateDialogView { /** Electron message options with separately expandable, main-owned diagnostics. */ export interface UpdateDialogOptions extends MessageBoxOptions { readonly technicalDetails?: string + /** Synchronous auxiliary action that leaves the current prompt and choices open. */ + readonly keepOpenAction?: { index: number; run: () => void } + /** Main-owned action kept in this dialog; false keeps the prompt open for correction. */ + readonly action?: { index: number; run: () => Promise } } /** The document can select only a displayed response index. */ @@ -43,7 +49,11 @@ export class DesktopUpdateDialog { private view: DesktopUpdateOverlay | undefined private parent: BrowserWindow | undefined private closing: ReturnType | undefined - private active: { view: UpdateDialogView; finish: (index: number, retain?: boolean) => void } | undefined + private active: { + view: UpdateDialogView + finish: (index: number, retain?: boolean) => void + respond: (index: number) => Promise + } | undefined /** Focus the current explanation or confirmation without replacing it or granting permission. */ focus(): void { @@ -71,7 +81,8 @@ export class DesktopUpdateDialog { || (index !== active.view.cancelId && (index < 0 || index >= active.view.buttons.length))) { throw new Error('desktop update: invalid dialog response') } - active.finish(index) + if (active.view.busyButton !== undefined) return + return active.respond(index) }) } @@ -95,7 +106,7 @@ export class DesktopUpdateDialog { const surface = existing ?? this.overlays.create(parent, this.preload) this.view = surface this.parent = parent - const view: UpdateDialogView = { revision: ++this.revision, locale: locale.id, title: options.title ?? '', message: options.message, + let view: UpdateDialogView = { revision: ++this.revision, locale: locale.id, title: options.title ?? '', message: options.message, detail: options.detail ?? '', buttons, cancelId, closeLabel: locale.messages.updateClose, technicalDetails: options.technicalDetails ?? '', technicalDetailsLabel: locale.messages.updateTechnicalDetails } return new Promise((resolve) => { @@ -110,7 +121,35 @@ export class DesktopUpdateDialog { } resolve({ response, checkboxChecked: false }) } - this.active = { view, finish } + const respond = async (index: number): Promise => { + const publish = (busy: boolean): void => { + const { busyButton: _busy, ...rest } = view + view = { ...rest, revision: ++this.revision, ...(busy ? { busyButton: index } : {}) } + if (this.active !== undefined) this.active.view = view + surface.webContents.send(UPDATE_DIALOG_IPC.changed, view) + } + if (options.keepOpenAction?.index === index) { + options.keepOpenAction.run() + publish(false) + return + } + if (options.action?.index !== index) { finish(index); return } + publish(true) + // Keep a fast local read perceptible without delaying the operation itself. + const held = new Promise((resolveHold) => { setTimeout(resolveHold, 500) }) + try { + const close = await options.action.run() + await held + if (this.active?.view !== view) return + if (close) finish(index) + else publish(false) + } catch (error) { + await held + if (this.active?.view === view) publish(false) + throw error + } + } + this.active = { view, finish, respond } options.signal?.addEventListener('abort', abort, { once: true }) if (existing !== undefined) { surface.webContents.send(UPDATE_DIALOG_IPC.changed, view); return } const failed = (): void => { if (this.view === surface) { this.cancel(); this.close() } } diff --git a/apps/desktop/src/update-error.ts b/apps/desktop/src/update-error.ts index adac72738b..a2a9f23c5e 100644 --- a/apps/desktop/src/update-error.ts +++ b/apps/desktop/src/update-error.ts @@ -1,5 +1,5 @@ /** Localized update preparation failures with separately displayed diagnostics. */ -import type { DesktopUpdatePreparationFailureKind } from './ipc.ts' +import type { DesktopUpdatePreparationFailureKind, DesktopUpdateState } from './ipc.ts' /** Only explicitly safe main-process facts may be exposed as technical details. */ export class DesktopUpdatePreparationError extends Error { @@ -10,3 +10,20 @@ export class DesktopUpdatePreparationError extends Error { */ constructor(readonly kind: DesktopUpdatePreparationFailureKind, message: string, readonly technicalDetails?: string) { super(message) } } + +/** + * Preserve current download diagnostics; never infer a cause from an older cleanup failure. + * @param error - Rejected updater download or filesystem error. + * @returns Structured filesystem cause when supplied by the failing operation. + */ +export function downloadFailureDetails(error: unknown): Pick { + if (!(error instanceof Error) || !('code' in error) || typeof error.code !== 'string') return {} + const code = error.code + const kind = code === 'ENOSPC' ? 'space' : ['EACCES', 'EPERM', 'EROFS'].includes(code) ? 'permission' : code === 'EBUSY' ? 'busy' : undefined + const path = 'path' in error && typeof error.path === 'string' ? error.path : undefined + const destination = 'dest' in error && typeof error.dest === 'string' ? error.dest : undefined + return { + ...(kind === undefined ? {} : { downloadFailure: kind }), + technicalDetails: [code, error.message, path, destination].filter(value => value !== undefined).join('\n'), + } +} diff --git a/apps/desktop/src/update-presentation.ts b/apps/desktop/src/update-presentation.ts index 670b6eb4b6..4fa061f33a 100644 --- a/apps/desktop/src/update-presentation.ts +++ b/apps/desktop/src/update-presentation.ts @@ -10,7 +10,9 @@ const NETWORK_FAILURE = /\b(?:ERR_CONNECTION_CLOSED|ERR_CONNECTION_RESET|ERR_INT * @returns Stable presentation kind without raw diagnostics. */ function desktopUpdateFailureKind(state: DesktopUpdateState): DesktopUpdateFailureKind { + if (state.failedOperation === 'configuration') return 'configuration' if (state.failedOperation === 'install' && state.preparationFailure !== undefined) return state.preparationFailure + if (state.failedOperation === 'download' && state.downloadFailure !== undefined) return `download-${state.downloadFailure}` const operation = state.failedOperation ?? 'install' return NETWORK_FAILURE.test(state.message ?? '') ? `${operation}-network` : operation } @@ -24,9 +26,13 @@ function desktopUpdateFailureKind(state: DesktopUpdateState): DesktopUpdateFailu export function desktopUpdateErrorSummary(state: DesktopUpdateState, messages: DesktopMessages): string { const kind = desktopUpdateFailureKind(state) const summaries: Readonly> = { + configuration: messages.updateConfigurationFailed, check: messages.updateCheckFailed, 'check-network': messages.updateCheckNetworkFailed, download: messages.updateDownloadFailed, + 'download-space': messages.updateDownloadSpaceFailed, + 'download-permission': messages.updateDownloadPermissionFailed, + 'download-busy': messages.updateDownloadBusyFailed, 'download-network': messages.updateDownloadNetworkFailed, install: messages.updateInstallFailed, 'install-network': messages.updateInstallNetworkFailed, diff --git a/apps/desktop/src/update-settings.ts b/apps/desktop/src/update-settings.ts new file mode 100644 index 0000000000..bad7f6ee2b --- /dev/null +++ b/apps/desktop/src/update-settings.ts @@ -0,0 +1,52 @@ +/** Channel-specific default and failure policy over the shared shell settings document. */ +import { DesktopSettingsFile, DesktopSettingsFileError } from './settings-file.ts' +import { appendFileSync } from 'node:fs' +import type { DesktopUpdateChannel } from './update-channel.ts' + +/** Invalid explicit channels suspend ordinary updates; only absence uses the package default. */ +export type DesktopUpdateSettingsResult = + | { kind: 'ready'; channel: DesktopUpdateChannel } + | { kind: 'error'; error: DesktopSettingsFileError } + +/** No file watching; loading and explicit UI saves share shell storage and validation. */ +export class DesktopUpdateSettings { + private readonly file: DesktopSettingsFile + readonly path: string + + /** + * @param userData - Electron userData, independent of Host and DSH_HOME. + * @param packagedChannel - Default selected by the installer build input. + */ + constructor(userData: string, private readonly packagedChannel: DesktopUpdateChannel) { + this.file = new DesktopSettingsFile(userData) + this.path = this.file.path + } + + /** @returns Startup channel or an error requiring suspension; never creates a missing file. */ + async load(): Promise { + const result = await this.file.read() + if (result.kind === 'error') return result + if (result.kind === 'missing') return { kind: 'ready', channel: this.packagedChannel } + const channel = result.fields.channel + if (channel.kind === 'invalid') return { kind: 'error', error: channel.error } + return { kind: 'ready', channel: channel.kind === 'valid' ? channel.value : this.packagedChannel } + } + + /** + * Save a selection while preserving other fields; never changes the runtime selection itself. + * @param channel - Validated UI selection. + * @returns After a confirmed write, including logged lock-release failures. + * Uncertain writes reject without retry; diagnostics are appended to settings.json.save.jsonl. + */ + async save(channel: DesktopUpdateChannel): Promise { + try { await this.file.saveUpdates({ channel }) } catch (error) { + if (!(error instanceof DesktopSettingsFileError) || error.problem.kind !== 'write') throw error + const entry = { time: new Date().toISOString(), event: 'settings-save-failed', effect: error.problem.effect, + path: this.path, reason: error.cause instanceof Error ? error.cause.message : String(error.cause) } + try { appendFileSync(`${this.path}.save.jsonl`, `${JSON.stringify(entry)}\n`, { mode: 0o600, flush: true }) } catch (logError) { + console.error('desktop settings: could not persist save warning', entry, logError) + } + if (error.problem.effect !== 'written') throw error + } + } +} diff --git a/apps/desktop/src/updates/electron-updater/channel.ts b/apps/desktop/src/updates/electron-updater/channel.ts new file mode 100644 index 0000000000..06f07627b2 --- /dev/null +++ b/apps/desktop/src/updates/electron-updater/channel.ts @@ -0,0 +1,14 @@ +/** Channel policy applied after electron-updater's channel setter. */ +import type { AppUpdater } from 'electron-updater' + +/** + * Select a feed without permitting downgrades or prereleases on stable. + * @param updater - Updater whose prior operations have settled. + * @param channel - Selected distribution channel. + */ +export function selectUpdateChannel(updater: AppUpdater, channel: 'nightly' | 'stable'): void { + updater.channel = channel + // The library channel setter enables downgrades. + updater.allowDowngrade = false + updater.allowPrerelease = channel === 'nightly' +} diff --git a/apps/desktop/src/updates/electron-updater/discard.ts b/apps/desktop/src/updates/electron-updater/discard.ts new file mode 100644 index 0000000000..f066141c42 --- /dev/null +++ b/apps/desktop/src/updates/electron-updater/discard.ts @@ -0,0 +1,62 @@ +/** Retires electron-updater 6.x targets only after owned checks and downloads settle. */ +import type { Server } from 'node:http' +import { readdir, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { app } from 'electron' +import { recordUpdateCleanupFailure } from '../../update-cleanup-log.ts' +import type { AppUpdater } from 'electron-updater' + +/** + * Revoke the library's pending target and await macOS's local feed server closure. + * This adapter depends on private electron-updater 6.x fields, covered by real-library tests. + * autoInstallOnAppQuit must be false and quitAndInstall must not have been called. + * @param updater - Settled updater owned by the Desktop coordinator. + * @returns After old in-memory targets and the local feed server are retired; residual files only produce durable warnings. + */ +export async function discardDesktopUpdate(updater: AppUpdater): Promise { + // The dependency omits these private fields from its public integration declarations. + const internal = updater as object as { + updateInfoAndProvider: unknown + downloadedUpdateHelper?: { + _file: string | null + _packageFile: string | null + versionInfo: unknown + fileInfo: unknown + _downloadedFileInfo: unknown + cacheDirForPendingUpdate: string + } | null + server?: Server | null + squirrelDownloadedUpdate?: boolean + } + if (internal.squirrelDownloadedUpdate === true) throw new Error('desktop update: native installation already staged') + const server = internal.server + if (server?.listening) { + await new Promise((resolve, reject) => { + server.close((error) => { if (error === undefined) resolve(); else reject(error) }) + }) + } + internal.server = null + internal.updateInfoAndProvider = null + const helper = internal.downloadedUpdateHelper + if (helper == null) return + // Equivalent to the pinned helper's memory reset; its clear() silently swallows filesystem failures. + helper._file = null + helper._packageFile = null + helper.versionInfo = null + helper.fileInfo = null + helper._downloadedFileInfo = null + const directory = helper.cacheDirForPendingUpdate + let names: string[] + try { names = await readdir(directory) } catch (error) { + if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) { + recordUpdateCleanupFailure(app.getPath('logs'), { operation: 'list', path: directory, error }) + } + return + } + for (const name of names) { + const path = join(directory, name) + try { await rm(path, { recursive: true, force: true }) } catch (error) { + recordUpdateCleanupFailure(app.getPath('logs'), { operation: 'remove', path, error }) + } + } +} diff --git a/apps/desktop/src/update-http-executor.ts b/apps/desktop/src/updates/electron-updater/http-executor.ts similarity index 100% rename from apps/desktop/src/update-http-executor.ts rename to apps/desktop/src/updates/electron-updater/http-executor.ts diff --git a/apps/desktop/tests/README.i18n.yaml b/apps/desktop/tests/README.i18n.yaml index 97e9812489..58cfb79e5b 100644 --- a/apps/desktop/tests/README.i18n.yaml +++ b/apps/desktop/tests/README.i18n.yaml @@ -21,8 +21,8 @@ en: 6f4de4503748f3e2 zh: 648370375f16e463 /desktop-local-verification/manual-walkthrough: - en: c4b4ee8763d8464a - zh: ee2159bc08966592 + en: 1f333416e5a3fef1 + zh: fe0f30418db59cb0 /desktop-local-verification/evidence: en: dcb2473264c825d4 zh: cebb2832efa15974 diff --git a/apps/desktop/tests/README.md b/apps/desktop/tests/README.md index cda4533699..9c165af2e0 100644 --- a/apps/desktop/tests/README.md +++ b/apps/desktop/tests/README.md @@ -65,6 +65,8 @@ With Host, client, and Desktop artifacts built, run `node --import tsx apps/desk +After building Desktop, `node apps/desktop/scripts/test-update-channels.mjs` checks the pinned Electron updater against loopback feeds and downloaded inert files. It verifies channel filenames, downgrade prohibition, rollout eligibility, and retirement of actual download caches and stale confirmations after channel switching; on macOS it also closes the updater-created local handoff server. Windows pull-request CI runs this check after the blocking build. No installer, Host or remote publication runs. This qualification does not replace signed installed-app testing on Windows and macOS. + ## Evidence The local command builds Desktop and executes Electron 44 with the actual HTTP updater, policy client, sandboxed preload, and mandatory renderer. It records each scenario and preserves its report under `.desktop-build/qualification/local-updater-*`. The installer call, external browser, and clipboard are observation substitutes; downloaded bytes are not an executable installer. diff --git a/apps/desktop/tests/README.zh.md b/apps/desktop/tests/README.zh.md index 6cca69e8ac..3c69df8a56 100644 --- a/apps/desktop/tests/README.zh.md +++ b/apps/desktop/tests/README.zh.md @@ -65,6 +65,8 @@ Host、客户端与 Desktop 产物构建完成后,在 Windows 仓库根目录 +构建 Desktop 后,`node apps/desktop/scripts/test-update-channels.mjs` 使用本地回环清单和下载的无执行能力文件检查固定版本的 Electron updater。它验证渠道文件名、禁止降级、灰度命中,以及切换渠道后实际下载缓存和过期确认的撤销,在 macOS 上还关闭 updater 创建的本地交接服务。Windows pull request CI 在阻塞性构建后运行此检查。不运行安装器、Host 或远程发布。此验证不能替代 Windows 和 macOS 的签名安装应用测试。 + ## 验证证据 本地命令构建 Desktop,并在 Electron 44 中运行真实 HTTP updater、策略客户端、沙箱预加载和强更页面。它记录每个场景,并把报告保存在 `.desktop-build/qualification/local-updater-*`。安装器调用、外部浏览器和剪贴板均替换为观测记录;下载字节不是可执行安装器。 diff --git a/apps/desktop/tests/desktop-auto-update-environment.spec.ts b/apps/desktop/tests/desktop-auto-update-environment.spec.ts index 9fbe629c89..4e2e7f9c70 100644 --- a/apps/desktop/tests/desktop-auto-update-environment.spec.ts +++ b/apps/desktop/tests/desktop-auto-update-environment.spec.ts @@ -119,3 +119,14 @@ describe('desktop auto-update environment', () => { expect(() => desktopUpdateMetadataFilename('1.2.3', 'linux')).toThrow(/unsupported metadata platform/u) }) }) + + +it.each([['darwin', 'arm64', 'mac-arm64', 'stable-mac.yml'], ['darwin', 'x64', 'mac-x64', 'stable-mac.yml'], ['win32', 'x64', 'win-x64', 'stable.yml']] as const) +('routes stable metadata and binaries separately for %s %s', (platform, arch, target, filename) => { + const config = resolveDesktopAutoUpdateConfig({ DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', DSH_DESKTOP_UPDATE_CHANNEL: 'stable' }, platform, arch) + expect(config.channel).toBe('stable') + expect(config.keyPrefix).toBe(`dsh-desk/feeds/${target}`) + expect(config.binaryKeyPrefix).toBe(`dsh-desk/bin/${target}/stable`) + expect(desktopUpdateMetadataFilename('1.0.0', platform, config.channel)).toBe(filename) + expect(() => resolveDesktopAutoUpdateConfig({ DSH_DESKTOP_UPDATE_CHANNEL: 'latest' }, platform, arch)).toThrow(/channel/u) +}) diff --git a/apps/desktop/tests/desktop-publication-summary.spec.ts b/apps/desktop/tests/desktop-publication-summary.spec.ts index 5267d29034..bfb5beb483 100644 --- a/apps/desktop/tests/desktop-publication-summary.spec.ts +++ b/apps/desktop/tests/desktop-publication-summary.spec.ts @@ -6,16 +6,16 @@ import type { DesktopUploadPlan } from '../scripts/desktop-upload-plan.ts' const source: DesktopRetainedSource = { repository: 'owner/repo', runId: '20', attempt: '3', commit: 'a'.repeat(40), workflowCommit: 'b'.repeat(40), - sourceRef: 'dsh-v1.0.0', channel: 'latest', runnerName: 'windows-publisher', target: 'win-x64', + sourceRef: 'dsh-v1.0.0', channel: 'stable', runnerName: 'windows-publisher', target: 'win-x64', preset: 'production', productVersion: '1.0.0', version: '1.0.0', testReleaseId: null, qualification: 'signed', } const plan: DesktopUploadPlan = { environment: 'production', target: 'win-x64', version: '1.0.0', bucket: 'production-1234567890', publicUrl: 'https://download.example.com/dsh-desk/feeds/win-x64/', secretIdEnvName: 'SECRET_ID', secretKeyEnvName: 'SECRET_KEY', artifacts: [ - { path: '/not-read/installer.exe', filename: 'deepseek-harness-1.0.0-win-x64.exe', key: 'dsh-desk/bin/win-x64/latest/deepseek-harness-1.0.0-win-x64.exe', + { path: '/not-read/installer.exe', filename: 'deepseek-harness-1.0.0-win-x64.exe', key: 'dsh-desk/bin/win-x64/stable/deepseek-harness-1.0.0-win-x64.exe', contents: 'installer bytes', contentType: 'application/octet-stream', channelMetadata: false }, - { path: '/not-read/latest.yml', filename: 'latest.yml', key: 'dsh-desk/feeds/win-x64/latest.yml', + { path: '/not-read/stable.yml', filename: 'stable.yml', key: 'dsh-desk/feeds/win-x64/stable.yml', contents: 'version: 1.0.0\n', contentType: 'application/yaml', channelMetadata: true }, ], } @@ -23,8 +23,8 @@ const plan: DesktopUploadPlan = { it('shows production destinations and tag after approval with upload-byte fingerprints', async () => { const summary = await desktopPublicationSummary(source, plan, 'c'.repeat(64)) await expect(summary).toMatchFileSnapshot('./expected/publication-production.md') - expect(summary).toContain('/latest/deepseek-harness-1.0.0-win-x64.exe') - expect(summary).toContain('https://download.example.com/dsh-desk/feeds/win-x64/latest.yml') + expect(summary).toContain('/stable/deepseek-harness-1.0.0-win-x64.exe') + expect(summary).toContain('https://download.example.com/dsh-desk/feeds/win-x64/stable.yml') expect(summary).toContain(createHash('sha256').update('version: 1.0.0\n').digest('hex')) expect(summary).not.toContain('测试 release ID |') expect(summary).not.toContain('SECRET_KEY') diff --git a/apps/desktop/tests/desktop-rollback.spec.ts b/apps/desktop/tests/desktop-rollback.spec.ts index 2bf2151626..f7912ea09b 100644 --- a/apps/desktop/tests/desktop-rollback.spec.ts +++ b/apps/desktop/tests/desktop-rollback.spec.ts @@ -55,17 +55,17 @@ it.each(['missing-file', 'changed-file', 'changed-channel'] as const)('rejects % expect(f.writes).toEqual([]) }) -it('restores nightly when latest already matches and resumes a partially restored pair', async () => { - for (const latestAlreadyMatches of [true, false]) { +it('restores nightly when stable already matches and resumes a partially restored pair', async () => { + for (const stableAlreadyMatches of [true, false]) { const f = fixture() const nightly = f.plan.artifacts[1]! - const plan = { ...f.plan, artifacts: [...f.plan.artifacts, { ...nightly, key: 'feed/latest.yml', filename: 'latest.yml' }] } - f.objects.set('feed/latest.yml', latestAlreadyMatches ? nightly.contents! : 'version: 2.0.0\n') - if (!latestAlreadyMatches) { + const plan = { ...f.plan, artifacts: [...f.plan.artifacts, { ...nightly, key: 'feed/stable.yml', filename: 'stable.yml' }] } + f.objects.set('feed/stable.yml', stableAlreadyMatches ? nightly.contents! : 'version: 2.0.0\n') + if (!stableAlreadyMatches) { const put = f.operations.transport.put.bind(f.operations.transport) const interrupted = { ...f.operations, transport: { ...f.operations.transport, async put(...args: Parameters) { - if (args[0].key === 'feed/latest.yml') throw new Error('fixture interruption') + if (args[0].key === 'feed/stable.yml') throw new Error('fixture interruption') return put(...args) }, } } @@ -73,7 +73,7 @@ it('restores nightly when latest already matches and resumes a partially restore } await expect(rollbackDesktopChannel(plan, '2.0.0', true, f.operations)).resolves.toMatchObject({ status: 'complete' }) expect(f.objects.get('feed/nightly.yml')).toBe(nightly.contents) - expect(f.objects.get('feed/latest.yml')).toBe(nightly.contents) + expect(f.objects.get('feed/stable.yml')).toBe(nightly.contents) expect(f.writes.filter(key => key === 'feed/nightly.yml')).toHaveLength(1) } }) diff --git a/apps/desktop/tests/desktop-upload-plan.spec.ts b/apps/desktop/tests/desktop-upload-plan.spec.ts index def8cb19bf..d213484167 100644 --- a/apps/desktop/tests/desktop-upload-plan.spec.ts +++ b/apps/desktop/tests/desktop-upload-plan.spec.ts @@ -38,7 +38,7 @@ async function fixture( target: DesktopPackageTargetName, version = '1.2.3', environment: 'test' | 'production' = 'test', - channel: 'nightly' | 'latest' = 'nightly', + channel: 'nightly' | 'stable' = 'nightly', ): Promise { const root = await mkdtemp(join(tmpdir(), 'dsh-desktop-upload-')) temporaryDirectories.push(root) @@ -175,14 +175,14 @@ describe('desktop upload plan', () => { .rejects.toThrow(failure === 'checksum' ? /SHA-512/u : /completion record/u) }) - it.each(['test', 'production'] as const)('isolates stable latest packages from nightly in %s', async (environment) => { + it.each(['test', 'production'] as const)('isolates stable-channel packages from nightly in %s', async (environment) => { const nightly = await createDesktopUploadPlan('win-x64', await fixture('win-x64', '1.2.3', environment, 'nightly')) - const latest = await createDesktopUploadPlan('win-x64', await fixture('win-x64', '1.2.3', environment, 'latest')) - expect(latest.artifacts.filter(file => file.channelMetadata).map(file => file.filename)).toEqual(['latest.yml']) + const stable = await createDesktopUploadPlan('win-x64', await fixture('win-x64', '1.2.3', environment, 'stable')) + expect(stable.artifacts.filter(file => file.channelMetadata).map(file => file.filename)).toEqual(['stable.yml']) expect(nightly.artifacts.filter(file => file.channelMetadata).map(file => file.filename)).toEqual(['nightly.yml']) - expect(latest.artifacts.every(file => !nightly.artifacts.some(other => other.key === file.key))).toBe(true) - const prerelease = await fixture('win-x64', '1.2.3-alpha.1', environment, 'latest') - await expect(createDesktopUploadPlan('win-x64', prerelease)).rejects.toThrow(/latest requires a stable version/u) + expect(stable.artifacts.every(file => !nightly.artifacts.some(other => other.key === file.key))).toBe(true) + const prerelease = await fixture('win-x64', '1.2.3-alpha.1', environment, 'stable') + await expect(createDesktopUploadPlan('win-x64', prerelease)).rejects.toThrow(/stable requires a stable version/u) }) it('publishes fixed feeds referencing versioned binaries without overriding CDN cache policy', async () => { diff --git a/apps/desktop/tests/expected/publication-production.md b/apps/desktop/tests/expected/publication-production.md index 9041d1b3d9..a3b93ccb87 100644 --- a/apps/desktop/tests/expected/publication-production.md +++ b/apps/desktop/tests/expected/publication-production.md @@ -7,10 +7,10 @@ | --- | --- | | 发布环境 | production | | 版本号 | 1.0.0 | -| 升级通道 | latest | +| 升级通道 | stable | | 平台 | win-x64 | -| 下载地址预览 | https://download.example.com/dsh-desk/bin/win-x64/latest/deepseek-harness-1.0.0-win-x64.exe
发布后的目标地址,审批前不保证可下载。 | -| 升级 YAML | https://download.example.com/dsh-desk/feeds/win-x64/latest.yml
本次发布将更新此文件;审批前可能不存在或仍指向旧版本。 | +| 下载地址预览 | https://download.example.com/dsh-desk/bin/win-x64/stable/deepseek-harness-1.0.0-win-x64.exe
发布后的目标地址,审批前不保证可下载。 | +| 升级 YAML | https://download.example.com/dsh-desk/feeds/win-x64/stable.yml
本次发布将更新此文件;审批前可能不存在或仍指向旧版本。 | | 打包来源 | 查看打包运行 #20 · 第 3 次尝试
源码:dsh-v1.0.0 @ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
打包机器:windows-publisher | | 签名与公证 | EXE 签名校验通过 | | Git tag | desktop-v1.0.0 | @@ -33,8 +33,8 @@ COS 存储桶:production-1234567890 | 文件 | 大小 | SHA-256 | | --- | --- | --- | -| deepseek-harness-1.0.0-win-x64.exe | 15 字节 | e34210a6de4f653edf588301431c3d69a633638cbf587345cc50a7fed9f38f4c | -| latest.yml | 15 字节 | 8850e6075dda0080c526269a4de29848c32c2a65e3d81f4a64ed8ce53d4b47c1 | +| deepseek-harness-1.0.0-win-x64.exe | 15 字节 | e34210a6de4f653edf588301431c3d69a633638cbf587345cc50a7fed9f38f4c | +| stable.yml | 15 字节 | 8850e6075dda0080c526269a4de29848c32c2a65e3d81f4a64ed8ce53d4b47c1 | diff --git a/apps/desktop/tests/fixtures/local-updater.mjs b/apps/desktop/tests/fixtures/local-updater.mjs index fb01c13f04..b8afce9ece 100644 --- a/apps/desktop/tests/fixtures/local-updater.mjs +++ b/apps/desktop/tests/fixtures/local-updater.mjs @@ -16,7 +16,7 @@ import { createUpdateServer } from './update-server.mjs' import { DesktopMandatoryUpdatePolicy, resolveDesktopPolicyConfig } from '../../lib/types/mandatory-update-policy.js' import { DesktopMandatoryUpdateWindow } from '../../lib/types/mandatory-update-window.js' import { DesktopUpdatePreparationError } from '../../lib/types/update-error.js' -import { DesktopUpdateHttpExecutor } from '../../lib/types/update-http-executor.js' +import { DesktopUpdateHttpExecutor } from '../../lib/types/updates/electron-updater/http-executor.js' import { DesktopUpdateOverlays } from '../../lib/types/update-overlay.js' import { qualifyUpdateDialogs } from './update-dialogs.mjs' diff --git a/apps/desktop/tests/fixtures/signed-updates.mjs b/apps/desktop/tests/fixtures/signed-updates.mjs index 8b50cad678..30ef854a4a 100644 --- a/apps/desktop/tests/fixtures/signed-updates.mjs +++ b/apps/desktop/tests/fixtures/signed-updates.mjs @@ -5,7 +5,7 @@ import { join } from 'node:path' import { app } from 'electron' import updaterModule from 'electron-updater' import { DesktopUpdateCoordinator } from '../../lib/types/update-coordinator.js' -import { DesktopUpdateHttpExecutor } from '../../lib/types/update-http-executor.js' +import { DesktopUpdateHttpExecutor } from '../../lib/types/updates/electron-updater/http-executor.js' import { resolveWindowsUpdatePublisher } from '../../scripts/windows-sign.mjs' import { artifactDigest, createArtifactUpdateServer } from './artifact-update-server.mjs' diff --git a/apps/desktop/tests/fixtures/workspace-updates.mjs b/apps/desktop/tests/fixtures/workspace-updates.mjs index 95cf7a5194..3ad9bae9b8 100644 --- a/apps/desktop/tests/fixtures/workspace-updates.mjs +++ b/apps/desktop/tests/fixtures/workspace-updates.mjs @@ -10,7 +10,7 @@ import { app, BrowserWindow, Menu, webContents } from 'electron' import updaterModule from 'electron-updater' import { createUpdateServer } from './update-server.mjs' import { fixture } from './workspace-update-adapters.mjs' -import { DesktopUpdateHttpExecutor } from '../../lib/types/update-http-executor.js' +import { DesktopUpdateHttpExecutor } from '../../lib/types/updates/electron-updater/http-executor.js' import { desktopUpdateReadyConfirmation, resolveDesktopLocale } from '../../lib/types/locale.js' const root = process.env.DSH_WORKSPACE_UPDATE_ROOT diff --git a/apps/desktop/tests/locale.spec.ts b/apps/desktop/tests/locale.spec.ts index b53a02e8fb..d1de8206f7 100644 --- a/apps/desktop/tests/locale.spec.ts +++ b/apps/desktop/tests/locale.spec.ts @@ -3,6 +3,12 @@ import { describe, expect, it } from 'vitest' import { en, formatDesktopMessage, resolveDesktopLocale, resolveDesktopStartupLocale, ja, zh, es, fr, de, ru, pt } from '../src/locale.ts' describe('desktop locale dictionaries', () => { + it('uses the same concise Chinese configuration error in the toast and details', () => { + for (const message of [zh.updateConfigurationRead, zh.updateConfigurationFormat, zh.updateConfigurationChannel]) { + expect(message).toBe('配置文件异常,更新已暂停') + } + }) + it('ships baseline and Japanese keys and falls back for unsupported languages', () => { expect(Object.keys(zh)).toEqual(Object.keys(en)) expect(Object.keys(ja)).toEqual(Object.keys(en)) diff --git a/apps/desktop/tests/macos-app-update-config.spec.ts b/apps/desktop/tests/macos-app-update-config.spec.ts index bba859fa7c..23e255e48a 100644 --- a/apps/desktop/tests/macos-app-update-config.spec.ts +++ b/apps/desktop/tests/macos-app-update-config.spec.ts @@ -66,10 +66,10 @@ describe('macOS packaged updater configuration', () => { }) -it('seals latest into the macOS updater file and rejects a different expected channel', async () => { +it('seals stable into the macOS updater file and rejects a different expected channel', async () => { const paths = await fixture() - const latest = resolveMacOSAppUpdateFeed([{ provider: 'generic', url: update.publicUrl, channel: 'latest' }]) - await writeMacOSAppUpdateConfig(paths.resourcesDir, latest, 'fixture-updater') - await expect(verifyMacOSAppUpdateConfig(paths.appPath, latest)).resolves.toBeUndefined() + const stable = resolveMacOSAppUpdateFeed([{ provider: 'generic', url: update.publicUrl, channel: 'stable' }]) + await writeMacOSAppUpdateConfig(paths.resourcesDir, stable, 'fixture-updater') + await expect(verifyMacOSAppUpdateConfig(paths.appPath, stable)).resolves.toBeUndefined() await expect(verifyMacOSAppUpdateConfig(paths.appPath, update)).rejects.toThrow(/does not match/u) }) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 924808eb0d..cf3fd803c0 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -72,8 +72,8 @@ const harness = await vi.hoisted(async () => { }) const loginShell = vi.fn((base: NodeJS.ProcessEnv, _config: unknown, _options: { signal?: AbortSignal }) => readLoginShell(base)) const updateCheck = vi.fn(async (_manual?: boolean): Promise => updateState) - const updateDownload = vi.fn(async (_version: string): Promise => updateState) - const updateInstall = vi.fn(async (_version: string): Promise => updateState) + const updateDownload = vi.fn(async (_version: string, _revision?: number): Promise => updateState) + const updateInstall = vi.fn(async (_version: string, _revision?: number): Promise => updateState) const popup = vi.fn<(options: { window: FakeWindow; x?: number; y?: number; callback?: () => void }) => void>() const menuBuilder = vi.fn<(template: MenuItemConstructorOptions[]) => { popup: typeof popup }>(() => ({ popup })) const menu = Object.assign(menuBuilder, { buildFromTemplate: menuBuilder, setApplicationMenu: vi.fn() }) @@ -243,6 +243,7 @@ const harness = await vi.hoisted(async () => { set publishUpdate(value: (state: DesktopUpdateState) => DesktopUpdateState) { publishUpdate = value }, dialog: { showOpenDialog: vi.fn(), showErrorBox: vi.fn(), showMessageBox: vi.fn() }, openExternal: vi.fn(async () => {}), + showItemInFolder: vi.fn(), protocolHandle: vi.fn<(scheme: string, handler: (request: Request) => Response | Promise) => void>(), applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }), disableAllPlugins: vi.fn(async () => { @@ -308,7 +309,7 @@ vi.mock('electron', () => ({ BrowserWindow: harness.FakeWindow, WebContentsView: harness.FakeView, dialog: harness.dialog, - shell: { openExternal: harness.openExternal }, + shell: { openExternal: harness.openExternal, showItemInFolder: harness.showItemInFolder }, nativeTheme: harness.nativeTheme, ipcMain: { on: harness.ipcOn, @@ -370,10 +371,28 @@ vi.mock('../src/update-dialog.ts', () => ({ DesktopUpdateDialog: class { dispose() {} } })) vi.mock('../src/update-coordinator.ts', () => ({ DesktopUpdateCoordinator: class { - constructor(publish: (state: DesktopUpdateState) => DesktopUpdateState, beforeRestart: () => Promise) { + selectedChannel: 'nightly' | 'stable' = 'nightly' + revision = 0 + configurationFailure: 'startup' | 'cleanup' | undefined + constructor(publish: (state: DesktopUpdateState) => DesktopUpdateState, beforeRestart: () => Promise, + _updater: unknown, _enabled: unknown, _version: unknown, _downloadResult: unknown, channel: 'nightly' | 'stable') { + this.selectedChannel = channel harness.prepareUpdate = beforeRestart harness.publishUpdate = publish } + async switchChannel(channel: 'nightly' | 'stable', persist: () => Promise) { + await persist() + this.selectedChannel = channel + this.configurationFailure = undefined + this.revision++ + harness.updateState = { phase: 'idle' } + return 'applied' as const + } + suspendConfiguration(detail: string, reason: 'startup' | 'cleanup' = 'startup') { + this.configurationFailure = reason + harness.updateState = { phase: 'error', failedOperation: 'configuration', message: detail } + harness.publishUpdate(harness.updateState) + } get state() { return harness.updateState } readonly check = harness.updateCheck readonly download = harness.updateDownload @@ -493,6 +512,219 @@ describe('desktop main startup', () => { }) }) + it('persists a channel through product IPC and ignores external edits during the process', async () => { + configureTestAuthPopup(true) + await readyForUpdate() + expect(await invoke(DESKTOP_IPC.updatesChannelGet, 'app')).toMatchObject({ channel: 'nightly', changing: false }) + expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable')).toEqual({ kind: 'applied', state: { channel: 'stable', packagedChannel: 'nightly', changing: false } }) + const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json') + expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual({ updates: { allowTestAuthPopupWindow: true, channel: 'stable' } }) + writeFileSync(path, JSON.stringify({ updates: { channel: 'nightly' } })) + expect(await invoke(DESKTOP_IPC.updatesChannelGet, 'app')).toMatchObject({ channel: 'stable' }) + expect(harness.windows[0]!.webContents.send).toHaveBeenCalledWith(DESKTOP_IPC.updatesChannelChanged, + { channel: 'stable', packagedChannel: 'nightly', changing: false }) + await expect(invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'alpha')).rejects.toThrow('unsupported channel') + expect(await invoke(DESKTOP_IPC.updatesChannelGet, 'app')).toMatchObject({ channel: 'stable' }) + }) + + it('reports a corrupt document without replacing it or pretending a UI save succeeded', async () => { + await readyForUpdate() + const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json') + writeFileSync(path, '{broken') + expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable')).toMatchObject({ kind: 'failed', reason: 'format', state: { channel: 'nightly', changing: false } }) + expect(readFileSync(path, 'utf8')).toBe('{broken') + expect(await invoke(DESKTOP_IPC.updatesStatus, 'app')).not.toHaveProperty('configurationError') + }) + + it('applies a committed selection even when storage lock release fails', async () => { + await readyForUpdate() + const { DesktopSettingsFile, DesktopSettingsFileError } = await import('../src/settings-file.ts') + const save = vi.spyOn(DesktopSettingsFile.prototype, 'saveUpdates').mockRejectedValueOnce( + new DesktopSettingsFileError({ kind: 'write', operation: 'save', effect: 'written' }, '/settings.json')) + try { + expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable')).toMatchObject({ + kind: 'applied', state: { channel: 'stable', changing: false }, + }) + expect(save).toHaveBeenCalledTimes(1) + } finally { save.mockRestore() } + }) + + it('suspends updates after an uncertain save and leaves recovery explicit', async () => { + await readyForUpdate() + harness.updateState = { phase: 'ready', version: '1.0.1' } + const { DesktopUpdateSettings } = await import('../src/update-settings.ts') + const { DesktopSettingsFileError } = await import('../src/settings-file.ts') + const save = vi.spyOn(DesktopUpdateSettings.prototype, 'save').mockRejectedValueOnce( + new DesktopSettingsFileError({ kind: 'write', operation: 'save', effect: 'unknown' }, '/settings.json')) + try { + expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable')).toMatchObject({ + kind: 'failed', reason: 'uncertain', state: { channel: 'nightly', changing: false, error: 'read' }, + }) + expect(harness.updateState).toMatchObject({ phase: 'error', failedOperation: 'configuration' }) + expect(harness.updateInstall).not.toHaveBeenCalled() + expect(save).toHaveBeenCalledTimes(1) + const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json') + const disk = JSON.stringify({ updates: { channel: 'stable' }, keep: true }) + writeFileSync(path, disk) + harness.dialog.showMessageBox.mockImplementationOnce(async (options: import('../src/update-dialog.ts').UpdateDialogOptions) => { + expect(await options.action!.run()).toBe(true) + return { response: 0 } + }) + await invoke(DESKTOP_IPC.updatesOpen, 'app') + const recovered = await invoke(DESKTOP_IPC.updatesChannelGet, 'app') + expect(recovered).toMatchObject({ channel: 'stable', changing: false }) + expect(recovered).not.toHaveProperty('error') + expect(readFileSync(path, 'utf8')).toBe(disk) + expect(save).toHaveBeenCalledTimes(1) + } finally { save.mockRestore() } + }) + + it('keeps the previous ready update installable after a channel save fails', async () => { + await readyForUpdate() + harness.updateState = { phase: 'ready', version: '1.0.1' } + writeFileSync(join(harness.app.getPath('userData'), 'desktop', 'settings.json'), '{broken') + await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable') + await invoke(DESKTOP_IPC.updatesOpen, 'app') + expect(harness.updateInstall).toHaveBeenCalledWith('1.0.1', 0) + }) + + it('keeps startup channel errors visible and recovers after a valid UI selection', async () => { + configureTestAuthPopup(false) + const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json') + writeFileSync(path, JSON.stringify({ updates: { channel: 'unknown' } })) + await readyForUpdate() + expect(await invoke(DESKTOP_IPC.updatesChannelGet, 'app')).toMatchObject({ channel: null, error: 'channel' }) + expect(harness.hosts).toHaveLength(1) + expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable')).toEqual({ kind: 'applied', state: { channel: 'stable', packagedChannel: 'nightly', changing: false } }) + }) + + it('keeps committed settings after apply failure and recovers through explicit reload', async () => { + await readyForUpdate() + const { DesktopUpdateCoordinator } = await import('../src/update-coordinator.ts') + vi.spyOn(DesktopUpdateCoordinator.prototype, 'switchChannel').mockImplementationOnce(async function (this: InstanceType, _channel, persist) { + await persist() + this.suspendConfiguration('cache retirement failed', 'cleanup') + throw new Error('cache retirement failed') + }) + expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable')).toMatchObject({ + kind: 'failed', reason: 'apply', state: { channel: 'nightly', error: 'cleanup' }, + }) + expect(await invoke(DESKTOP_IPC.updatesStatus, 'app')).toMatchObject({ configurationError: 'cleanup' }) + await invoke(DESKTOP_IPC.updatesOpen, 'app') + const options = harness.dialog.showMessageBox.mock.lastCall![0] as import('../src/update-dialog.ts').UpdateDialogOptions + expect(options.message).toBe(en.updateConfigurationCleanup) + const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json') + expect(options.detail).toBe(path) + const saved = readFileSync(path, 'utf8') + expect(JSON.parse(saved)).toMatchObject({ updates: { channel: 'stable' } }) + expect(await options.action!.run()).toBe(true) + expect(await invoke(DESKTOP_IPC.updatesChannelGet, 'app')).toMatchObject({ channel: 'stable', changing: false }) + expect(await invoke(DESKTOP_IPC.updatesChannelGet, 'app')).not.toHaveProperty('error') + expect(readFileSync(path, 'utf8')).toBe(saved) + }) + + it('recovers invalid settings from the mandatory block without bypassing that block', async () => { + configureTestAuthPopup(false) + const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json') + writeFileSync(path, '{broken') + harness.embeddedPolicy = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'] } + vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => Response.json({ code: 40005, + data: { show_content: { title: 'Update', detail: 'Required' }, desktop_app_link: 'https://downloads.example.com/' } }))) + await readyForUpdate() + await harness.policyBlocked.promise + const modal = harness.windows[0]! + const owned = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame } + harness.dialog.showMessageBox.mockImplementationOnce(async (options: import('../src/update-dialog.ts').UpdateDialogOptions) => { + options.keepOpenAction!.run() + return { response: 2 } + }) + await harness.handlers.get(MANDATORY_IPC.action)!(owned, 'refresh') + expect(harness.showItemInFolder).toHaveBeenCalledWith(path) + expect(harness.updateDownload).not.toHaveBeenCalled() + harness.dialog.showMessageBox.mockImplementationOnce(async (options: import('../src/update-dialog.ts').UpdateDialogOptions) => { + expect(options.detail).toBe(path) + expect(await options.action!.run()).toBe(false) + writeFileSync(path, JSON.stringify({ updates: { channel: 'stable' } })) + expect(await options.action!.run()).toBe(true) + return { response: 0 } + }) + await harness.handlers.get(MANDATORY_IPC.action)!(owned, 'refresh') + expect(await invoke(DESKTOP_IPC.updatesChannelGet, 'app')).toMatchObject({ channel: 'stable' }) + expect(harness.handlers.get(MANDATORY_IPC.status)!(owned)).toMatchObject({ policy: { blocking: true } }) + expect(harness.updateDownload).not.toHaveBeenCalled() + }) + + it('checks mandatory policy immediately after a successful channel switch', async () => { + harness.embeddedPolicy = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'] } + const checked = Promise.withResolvers() + const request = vi.fn().mockImplementation(async (url, options) => { + if (url instanceof URL && url.searchParams.get('scenario') === 'channel-change') { + expect(new Headers(options?.headers).get('x-client-update-channel')).toBe('stable') + checked.resolve(undefined) + } + return Response.json({ code: 0, data: { biz_code: 0 } }) + }) + vi.stubGlobal('fetch', request) + await readyForUpdate() + expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable')).toMatchObject({ kind: 'applied' }) + await checked.promise + }) + + it('offers the actual settings file for malformed JSON without suggesting channel reselection', async () => { + configureTestAuthPopup(false) + const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json') + writeFileSync(path, '{broken') + await readyForUpdate() + harness.dialog.showMessageBox.mockImplementationOnce(async (options: import('../src/update-dialog.ts').UpdateDialogOptions) => { + options.keepOpenAction!.run() + return { response: 2 } + }) + await invoke(DESKTOP_IPC.updatesOpen, 'app') + expect(harness.dialog.showMessageBox.mock.lastCall![0]).toMatchObject({ + message: en.updateConfigurationFormat, detail: path, + buttons: [en.updateReloadSettings, en.updateShowSettingsFile, en.updateClose], cancelId: 2, + }) + expect(en.updateConfigurationFormat).not.toContain('select') + expect(harness.showItemInFolder).toHaveBeenCalledWith(path) + expect(readFileSync(path, 'utf8')).toBe('{broken') + }) + + it('rereads a repaired file without writing it or restarting the shell', async () => { + configureTestAuthPopup(false) + const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json') + writeFileSync(path, '{broken') + await readyForUpdate() + harness.dialog.showMessageBox.mockImplementationOnce(async (options: import('../src/update-dialog.ts').UpdateDialogOptions) => { + expect(await options.action!.run()).toBe(false) + expect(await invoke(DESKTOP_IPC.updatesChannelGet, 'app')).toMatchObject({ error: 'format', changing: false }) + const repaired = JSON.stringify({ updates: { channel: 'stable' }, retained: 'keep' }) + writeFileSync(path, repaired) + expect(await options.action!.run()).toBe(true) + expect(readFileSync(path, 'utf8')).toBe(repaired) + return { response: 0 } + }) + await invoke(DESKTOP_IPC.updatesOpen, 'app') + const state = await invoke(DESKTOP_IPC.updatesChannelGet, 'app') + expect(state).toMatchObject({ channel: 'stable', changing: false }) + expect(state).not.toHaveProperty('error') + }) + + it('shows the invalid startup field and file path until a valid channel is applied', async () => { + configureTestAuthPopup(false) + const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json') + writeFileSync(path, JSON.stringify({ updates: { channel: 'unknown' } })) + await readyForUpdate() + await invoke(DESKTOP_IPC.updatesOpen, 'app') + expect(harness.dialog.showMessageBox.mock.lastCall![0]).toMatchObject({ + message: en.updateConfigurationChannel, detail: path, + }) + writeFileSync(path, '{broken') + expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable')).toMatchObject({ + kind: 'failed', reason: 'format', state: { channel: null, error: 'channel' }, + }) + expect(await invoke(DESKTOP_IPC.updatesStatus, 'app')).toMatchObject({ configurationError: 'channel' }) + }) + it('starts the Host with authentication popups disabled when local settings are invalid', async () => { configureTestAuthPopup(false) const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json') @@ -822,7 +1054,7 @@ describe('desktop main startup', () => { await vi.advanceTimersByTimeAsync(0) harness.updateState = { phase: 'available', version: '1.0.1-nightly.1' } await invoke(DESKTOP_IPC.updatesOpen, 'app') - expect(harness.updateDownload).toHaveBeenCalledWith('1.0.1-nightly.1') + expect(harness.updateDownload).toHaveBeenCalledWith('1.0.1-nightly.1', 0) expect(testAuth.login).not.toHaveBeenCalled() }) @@ -881,7 +1113,7 @@ describe('desktop main startup', () => { it('accepts product IPC only from the current application top frame in the owned main window', async () => { await readyForUpdate() const sender = harness.windows[0]!.webContents - for (const channel of [DESKTOP_IPC.updatesStatus, DESKTOP_IPC.deviceInfo]) { + for (const channel of [DESKTOP_IPC.updatesStatus, DESKTOP_IPC.updatesChannelGet, DESKTOP_IPC.deviceInfo]) { const handler = harness.handlers.get(channel)! expect(() => handler({ sender, senderFrame: sender.mainFrame })).not.toThrow() for (const event of [ @@ -1577,12 +1809,12 @@ describe('desktop main startup', () => { window.visible = false const opened = invoke(DESKTOP_IPC.updatesOpen, 'app') as Promise await vi.advanceTimersByTimeAsync(0) - expect(harness.updateDownload).toHaveBeenCalledWith('1.0.1') + expect(harness.updateDownload).toHaveBeenCalledWith('1.0.1', 0) expect(harness.updateInstall).not.toHaveBeenCalled() window.visible = true window.emit('show') await opened - expect(harness.updateInstall).toHaveBeenCalledWith('1.0.1') + expect(harness.updateInstall).toHaveBeenCalledWith('1.0.1', 0) }) it('waits for Platform view storage cleanup before an ordinary quit completes', async () => { @@ -1777,6 +2009,28 @@ describe('desktop main startup', () => { })) }) + it.each([ + { phase: 'available', version: '1.0.3' }, + { phase: 'idle' }, + { phase: 'error', failedOperation: 'check', message: 'Old feed unavailable' }, + ] satisfies DesktopUpdateState[])('discards a manual check result after switching channel: $phase', async (result) => { + await readyForUpdate() + const checked = Promise.withResolvers() + const requested = Promise.withResolvers() + harness.updateCheck.mockImplementationOnce(() => { requested.resolve(undefined); return checked.promise }) + harness.dialog.showMessageBox.mockResolvedValue({ response: 0 }) + const action = applicationMenuItems().find(item => item.label === 'Check for Updates…') + Reflect.apply(action!.click!, undefined, []) + const prompt = Promise.resolve(invoke(DESKTOP_IPC.updatesOpen, 'app')) + await requested.promise + await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable') + checked.resolve(result) + await prompt + expect(harness.dialog.showMessageBox).toHaveBeenCalledTimes(1) + expect(harness.updateDownload).not.toHaveBeenCalled() + expect(harness.updateInstall).not.toHaveBeenCalled() + }) + it('reports a manual check failure without offering a download', async () => { await readyForUpdate() harness.updateCheck.mockResolvedValueOnce({ phase: 'error', failedOperation: 'check', message: 'Feed unavailable' }) @@ -1809,7 +2063,7 @@ describe('desktop main startup', () => { await invoke(DESKTOP_IPC.updatesOpen, 'app') const messages = harness.dialog.showMessageBox.mock.calls.map(call => (call.at(-1) as { message: string }).message) expect(messages).not.toContain(en.mandatoryUnavailable) - expect(harness.updateDownload).toHaveBeenCalledExactlyOnceWith('1.0.1-nightly.1') + expect(harness.updateDownload).toHaveBeenCalledExactlyOnceWith('1.0.1-nightly.1', 0) }) it('does not wait for a pending policy request before proceeding with an ordinary update', async () => { @@ -1823,7 +2077,7 @@ describe('desktop main startup', () => { await invoke(DESKTOP_IPC.updatesOpen, 'app') }) try { - await vi.waitFor(() => { expect(harness.updateDownload).toHaveBeenCalledWith('1.0.1-nightly.1') }) + await vi.waitFor(() => { expect(harness.updateDownload).toHaveBeenCalledWith('1.0.1-nightly.1', 0) }) } finally { policy.resolve(Response.json({ code: 500 }, { status: 503 })) await operation @@ -1910,11 +2164,11 @@ describe('desktop main startup', () => { await started.promise const repeated = invoke(DESKTOP_IPC.updatesOpen, 'app') expect(harness.dialog.showMessageBox).not.toHaveBeenCalled() - expect(harness.updateDownload).toHaveBeenCalledExactlyOnceWith('1.0.1-nightly.1') + expect(harness.updateDownload).toHaveBeenCalledExactlyOnceWith('1.0.1-nightly.1', 0) expect(harness.updateInstall).not.toHaveBeenCalled() downloading.resolve({ phase: 'ready', version: '1.0.1-nightly.1' }) await Promise.all([action, repeated]) - expect(harness.updateInstall).toHaveBeenCalledExactlyOnceWith('1.0.1-nightly.1') + expect(harness.updateInstall).toHaveBeenCalledExactlyOnceWith('1.0.1-nightly.1', 0) await harness.updateCheck() expect(harness.updateInstall).toHaveBeenCalledOnce() }) diff --git a/apps/desktop/tests/mandatory-update-policy.spec.ts b/apps/desktop/tests/mandatory-update-policy.spec.ts index a04353810d..7f6066d7aa 100644 --- a/apps/desktop/tests/mandatory-update-policy.spec.ts +++ b/apps/desktop/tests/mandatory-update-policy.spec.ts @@ -77,6 +77,24 @@ describe('mandatory update policy', () => { expect(headers[1]).toMatchObject({ 'x-client-locale': 'zh_CN', 'x-client-timezone-offset': '28800' }) }) + it('checks the selected channel after an old-channel request finishes', async () => { + let channel: 'nightly' | 'stable' = 'nightly' + const old = Promise.withResolvers() + const request = vi.fn().mockReturnValueOnce(old.promise).mockResolvedValue(Response.json(clear)) + const policy = new DesktopMandatoryUpdatePolicy(resolveDesktopPolicyConfig(deployment)!, + { ...identity, get channel() { return channel } }, () => {}, request, () => client) + instances.push(policy) + const first = policy.check('launch') + channel = 'stable' + const second = policy.check('channel-change', true) + old.resolve(Response.json(force)) + await first + expect(await second).toMatchObject({ blocking: false }) + expect(request).toHaveBeenCalledTimes(2) + expect(request.mock.calls.map(([, options]) => new Headers(options?.headers).get('x-client-update-channel'))) + .toEqual(['nightly', 'stable']) + }) + it('coalesces manual checks and honors the interval on foreground/resume', async () => { const { policy, request } = fixture() const pending = Promise.withResolvers() diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts index 8001ae6fc2..fa3d2386f0 100644 --- a/apps/desktop/tests/preload-app.spec.ts +++ b/apps/desktop/tests/preload-app.spec.ts @@ -292,3 +292,22 @@ const imageProtocol = (value: import('../src/ipc.ts').DshDesktopProductApi['file const imageProtocolBack = (value: DesktopFileDrag): import('../src/ipc.ts').DshDesktopProductApi['fileDrag'] => value void imageProtocol void imageProtocolBack + + +it('exposes channel choices and scoped subscriptions without exposing updater objects', async () => { + vi.stubGlobal('location', new URL('dsh-app://app/index.html')) + await import('../src/preload-app.ts') + const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshDesktop')?.[1] as DshDesktopProductApi + await api.updates.channels.get() + await api.updates.channels.set('stable') + expect(electron.ipcRenderer.invoke.mock.calls).toEqual([[DESKTOP_IPC.updatesChannelGet], [DESKTOP_IPC.updatesChannelSet, 'stable']]) + const listener = vi.fn() + const dispose = api.updates.channels.subscribe(listener) + const handler = electron.ipcRenderer.on.mock.calls.find(([channel]) => channel === DESKTOP_IPC.updatesChannelChanged)?.[1] as + (event: unknown, state: unknown) => void + const state = { channel: 'stable', packagedChannel: 'nightly', changing: false } + handler({}, state) + expect(listener).toHaveBeenCalledWith(state) + dispose() + expect(electron.ipcRenderer.off).toHaveBeenCalledWith(DESKTOP_IPC.updatesChannelChanged, handler) +}) diff --git a/apps/desktop/tests/settings-file.spec.ts b/apps/desktop/tests/settings-file.spec.ts new file mode 100644 index 0000000000..57efe2c4b9 --- /dev/null +++ b/apps/desktop/tests/settings-file.spec.ts @@ -0,0 +1,222 @@ +import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises' +import * as fs from 'node:fs/promises' +import * as atomic from '@deepseek-ai/dsh-atomic-write' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { afterEach, expect, it, onTestFinished, vi } from 'vitest' +import { DesktopSettingsFile, isDesktopUpdateChannel } from '../src/settings-file.ts' +import { readDesktopSettings } from '../src/settings.ts' +import { DesktopUpdateSettings } from '../src/update-settings.ts' + +vi.mock('node:fs/promises', async original => ({ ...await original() })) +vi.mock('@deepseek-ai/dsh-atomic-write', async original => ({ ...await original() })) +afterEach(() => { vi.restoreAllMocks() }) + +async function fixture(raw?: string) { + const root = await mkdtemp(join(tmpdir(), 'desktop-settings-file-')) + onTestFinished(() => rm(root, { recursive: true, force: true })) + const file = new DesktopSettingsFile(root) + if (raw !== undefined) { + await mkdir(dirname(file.path)) + await writeFile(file.path, raw) + } + return { root, file } +} + +it('distinguishes missing files from absent fields without creating a file during reads', async () => { + const { file } = await fixture() + expect(await file.read()).toEqual({ kind: 'missing' }) + await expect(readFile(file.path)).rejects.toMatchObject({ code: 'ENOENT' }) + await file.saveUpdates({}) + expect(await file.read()).toMatchObject({ kind: 'ready', fields: { + channel: { kind: 'absent' }, allowTestAuthPopupWindow: { kind: 'absent' }, + } }) +}) + +it.each([ + ['{secret-value', 'json'], ['null', 'structure'], ['[]', 'structure'], + ['{"updates":null}', 'structure'], ['{"updates":[]}', 'structure'], ['{"updates":true}', 'structure'], +])('classifies malformed documents and refuses to replace them: %s', async (raw, reason) => { + const { file } = await fixture(raw) + const result = await file.initialize() + expect(result).toMatchObject({ kind: 'error', error: { problem: { kind: 'invalid', reason }, path: file.path } }) + expect(JSON.stringify(result)).not.toContain('secret-value') + await expect(file.saveUpdates({ channel: 'stable' })).rejects.toMatchObject({ problem: { kind: 'invalid', reason } }) + expect(await readFile(file.path, 'utf8')).toBe(raw) +}) + +it('keeps an invalid channel independent from the popup preference and repairs only the selected field', async () => { + const { root, file } = await fixture('{"other":[1],"updates":{"channel":"alpha","allowTestAuthPopupWindow":true,"future":{"x":2}}}') + expect(await file.read()).toMatchObject({ kind: 'ready', fields: { + channel: { kind: 'invalid', error: { problem: { kind: 'invalid', reason: 'field', field: 'channel' } } }, + allowTestAuthPopupWindow: { kind: 'valid', value: true }, + } }) + expect((await readDesktopSettings(root)).updates.allowTestAuthPopupWindow).toBe(true) + expect(await new DesktopUpdateSettings(root, 'stable').load()).toMatchObject({ kind: 'error' }) + await file.saveUpdates({ channel: 'nightly' }) + expect(JSON.parse(await readFile(file.path, 'utf8'))).toEqual({ other: [1], updates: { + channel: 'nightly', allowTestAuthPopupWindow: true, future: { x: 2 }, + } }) +}) + +it('keeps a valid channel usable when the popup preference is invalid', async () => { + const { root, file } = await fixture('{"updates":{"channel":"nightly","allowTestAuthPopupWindow":"bad"}}') + vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect((await readDesktopSettings(root)).updates.allowTestAuthPopupWindow).toBe(false) + expect(await new DesktopUpdateSettings(root, 'stable').load()).toEqual({ kind: 'ready', channel: 'nightly' }) + await file.saveUpdates({ channel: 'stable' }) + expect(JSON.parse(await readFile(file.path, 'utf8'))).toEqual({ updates: { channel: 'stable', allowTestAuthPopupWindow: 'bad' } }) + await file.saveUpdates({ allowTestAuthPopupWindow: true }) + expect(await file.read()).toMatchObject({ kind: 'ready', fields: { + allowTestAuthPopupWindow: { kind: 'valid', value: true }, channel: { kind: 'valid', value: 'stable' }, + } }) +}) + +it('classifies unreadable files without attempting initialization', async () => { + const { file } = await fixture() + await mkdir(file.path, { recursive: true }) + expect(await file.initialize()).toMatchObject({ kind: 'error', error: { problem: { kind: 'read' } } }) + expect((await fs.stat(file.path)).isDirectory()).toBe(true) +}) + +it('serializes initializers and patches from separate storage instances without losing fields', async () => { + const { root, file } = await fixture() + const other = new DesktopSettingsFile(root) + await Promise.all([file.initialize(), other.initialize()]) + await Promise.all([file.saveUpdates({ channel: 'stable' }), other.saveUpdates({ allowTestAuthPopupWindow: true })]) + expect(JSON.parse(await readFile(file.path, 'utf8'))).toEqual({ updates: { channel: 'stable', allowTestAuthPopupWindow: true } }) + await expect(fs.access(`${file.path}.lock`)).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +it('preserves a file created by an external writer during exclusive initialization', async () => { + const { file } = await fixture() + const original = fs.writeFile + const external = '{"updates":{"channel":"nightly"},"external":true}' + vi.spyOn(fs, 'writeFile').mockImplementation(async (path, contents, options) => { + if (path === file.path) await original(path, external) + return original(path, contents, options) + }) + expect(await file.initialize()).toMatchObject({ kind: 'ready', fields: { channel: { kind: 'valid', value: 'nightly' } } }) + expect(await readFile(file.path, 'utf8')).toBe(external) +}) + +it('reports preparation failure as unchanged and does not create a file', async () => { + const { file } = await fixture() + vi.spyOn(fs, 'mkdir').mockRejectedValue(Object.assign(new Error('denied'), { code: 'EACCES' })) + await expect(file.saveUpdates({ channel: 'stable' })).rejects.toMatchObject({ problem: { + kind: 'write', operation: 'save', effect: 'unchanged', + } }) + await expect(readFile(file.path)).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +it('reports uncertain write completion without reporting a successful save', async () => { + const { file } = await fixture('{}') + vi.spyOn(atomic, 'writeFileAtomic').mockRejectedValue(new Error('write failed')) + await expect(file.saveUpdates({ channel: 'stable' })).rejects.toMatchObject({ problem: { + kind: 'write', operation: 'save', effect: 'unknown', + } }) + expect(await readFile(file.path, 'utf8')).toBe('{}') + await expect(fs.access(`${file.path}.lock`)).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +it('reports a committed write when lock cleanup fails, without claiming rollback', async () => { + const { file } = await fixture('{}') + const original = fs.rm + vi.spyOn(fs, 'rm').mockImplementation(async (path, options) => { + if (path === `${file.path}.lock`) throw new Error('lock cleanup failed') + return original(path, options) + }) + await expect(file.saveUpdates({ channel: 'stable' })).rejects.toMatchObject({ problem: { + kind: 'write', operation: 'save', effect: 'written', + } }) + expect(JSON.parse(await readFile(file.path, 'utf8'))).toEqual({ updates: { channel: 'stable' } }) +}) + +it.each(['nightly', 'stable'])('accepts the same channel at the IPC and file boundaries: %s', async (channel) => { + const { file } = await fixture(JSON.stringify({ updates: { channel } })) + expect(isDesktopUpdateChannel(channel)).toBe(true) + expect(await file.read()).toMatchObject({ kind: 'ready', fields: { channel: { kind: 'valid', value: channel } } }) +}) + +it.each([null, false, 1, 'alpha', ''])('rejects an explicit invalid channel without treating it as absent: %s', async (channel) => { + const { file } = await fixture(JSON.stringify({ updates: { channel } })) + expect(isDesktopUpdateChannel(channel)).toBe(false) + expect(await file.read()).toMatchObject({ kind: 'ready', fields: { channel: { kind: 'invalid' } } }) +}) + +it('reports a partial initialization write as uncertain and preserves it for diagnosis', async () => { + const { file } = await fixture() + const original = fs.writeFile + vi.spyOn(fs, 'writeFile').mockImplementation(async (path, contents, options) => { + if (path !== file.path) return original(path, contents, options) + await original(path, '{', options) + throw new Error('disk write interrupted') + }) + expect(await file.initialize()).toMatchObject({ kind: 'error', error: { problem: { + kind: 'write', operation: 'initialize', effect: 'unknown', + } } }) + expect(await readFile(file.path, 'utf8')).toBe('{') + expect(await file.initialize()).toMatchObject({ kind: 'error', error: { problem: { kind: 'invalid', reason: 'json' } } }) +}) + +it('rereads fields after acquiring the writer lock instead of saving a stale snapshot', async () => { + const { file } = await fixture('{"updates":{"channel":"stable"}}') + const started = Promise.withResolvers() + const release = Promise.withResolvers() + const holder = atomic.withFileLock(file.path, async () => { + started.resolve(undefined) + await release.promise + await writeFile(file.path, '{"updates":{"channel":"stable","future":3},"other":4}') + }) + await started.promise + const attempting = Promise.withResolvers() + const originalLock = atomic.withFileLock + vi.spyOn(atomic, 'withFileLock').mockImplementation((path: string, operation: () => Promise, options?: atomic.FileLockOptions): Promise => { + attempting.resolve(undefined) + return originalLock(path, operation, options) + }) + const save = file.saveUpdates({ channel: 'nightly' }) + onTestFinished(async () => { release.resolve(undefined); await Promise.allSettled([holder, save]) }) + await attempting.promise + release.resolve(undefined) + const outcomes = await Promise.allSettled([holder, save]) + expect(outcomes.map(result => result.status)).toEqual(['fulfilled', 'fulfilled']) + expect(JSON.parse(await readFile(file.path, 'utf8'))).toEqual({ updates: { channel: 'nightly', future: 3 }, other: 4 }) +}) + +it('accepts the new channel and persists a warning after a real write with failed lock removal', async () => { + const { root, file } = await fixture('{"updates":{"channel":"nightly"},"keep":true}') + const settings = new DesktopUpdateSettings(root, 'nightly') + const original = fs.rm + vi.spyOn(fs, 'rm').mockImplementation(async (path, options) => { + if (path === `${file.path}.lock`) throw new Error('injected lock removal failure') + return original(path, options) + }) + await expect(settings.save('stable')).resolves.toBeUndefined() + expect(JSON.parse(await readFile(file.path, 'utf8'))).toEqual({ updates: { channel: 'stable' }, keep: true }) + expect(await settings.load()).toEqual({ kind: 'ready', channel: 'stable' }) + expect(JSON.parse(await readFile(`${file.path}.save.jsonl`, 'utf8'))).toMatchObject({ + effect: 'written', reason: 'injected lock removal failure', + }) + await expect(fs.access(`${file.path}.lock`)).resolves.toBeUndefined() +}) + +it.each([false, true])('leaves an uncertain write for explicit recovery, committed=%s', async (committed) => { + const { root, file } = await fixture('{"updates":{"channel":"nightly"},"keep":true}') + const settings = new DesktopUpdateSettings(root, 'nightly') + const original = atomic.writeFileAtomic + const write = vi.spyOn(atomic, 'writeFileAtomic').mockImplementationOnce(async (...args) => { + if (committed) await original(...args) + throw new Error('injected uncertain completion') + }) + const read = vi.spyOn(DesktopSettingsFile.prototype, 'read') + await expect(settings.save('stable')).rejects.toMatchObject({ problem: { effect: 'unknown' } }) + expect(write).toHaveBeenCalledTimes(1) + expect(read).toHaveBeenCalledTimes(1) + expect(JSON.parse(await readFile(file.path, 'utf8'))).toEqual({ + updates: { channel: committed ? 'stable' : 'nightly' }, keep: true, + }) + expect(JSON.parse(await readFile(`${file.path}.save.jsonl`, 'utf8'))).toMatchObject({ effect: 'unknown' }) + await expect(fs.access(`${file.path}.lock`)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await settings.load()).toEqual({ kind: 'ready', channel: committed ? 'stable' : 'nightly' }) +}) diff --git a/apps/desktop/tests/settings.spec.ts b/apps/desktop/tests/settings.spec.ts index b285bb98e9..0a0fec8c37 100644 --- a/apps/desktop/tests/settings.spec.ts +++ b/apps/desktop/tests/settings.spec.ts @@ -1,11 +1,12 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import * as fs from 'node:fs' +import * as fsp from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, expect, it, onTestFinished, vi } from 'vitest' import { readDesktopSettings } from '../src/settings.ts' -vi.mock('node:fs', async importOriginal => ({ ...await importOriginal() })) +vi.mock('node:fs/promises', async importOriginal => ({ ...await importOriginal() })) beforeEach(() => { vi.spyOn(console, 'warn').mockImplementation(() => {}) }) afterEach(() => { vi.restoreAllMocks() }) @@ -18,66 +19,66 @@ function fixture() { return { userData, path: join(directory, 'settings.json') } } -it('creates a discoverable default file on first launch without touching other preferences', () => { +it('creates a discoverable default file on first launch without touching other preferences', async () => { const { userData, path } = fixture() rmSync(join(userData, 'desktop'), { recursive: true }) writeFileSync(join(userData, 'keybindings.json'), 'existing shortcuts\n') - expect(readDesktopSettings(userData)).toEqual({ updates: { allowTestAuthPopupWindow: false } }) + expect(await readDesktopSettings(userData)).toEqual({ updates: { allowTestAuthPopupWindow: false } }) expect(readFileSync(path, 'utf8')).toBe('{\n "updates": {\n "allowTestAuthPopupWindow": false\n }\n}\n') expect(readFileSync(join(userData, 'keybindings.json'), 'utf8')).toBe('existing shortcuts\n') }) -it.each(['{}', '{"updates": {}}', '{"other": 1, "updates": {"other": true}}'])('defaults an older document without rewriting it: %s', (raw) => { +it.each(['{}', '{"updates": {}}', '{"other": 1, "updates": {"other": true}}'])('defaults an older document without rewriting it: %s', async (raw) => { const { userData, path } = fixture() writeFileSync(path, raw) - expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(false) + expect((await readDesktopSettings(userData)).updates.allowTestAuthPopupWindow).toBe(false) expect(readFileSync(path, 'utf8')).toBe(raw) }) -it('loads explicit opt-in and opt-out on the next read without changing an earlier process snapshot', () => { +it('loads explicit opt-in and opt-out on the next read without changing an earlier process snapshot', async () => { const { userData, path } = fixture() for (const allow of [true, false]) { const raw = JSON.stringify({ updates: { allowTestAuthPopupWindow: allow }, custom: 'preserve' }) writeFileSync(path, raw) - expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(allow) + expect((await readDesktopSettings(userData)).updates.allowTestAuthPopupWindow).toBe(allow) expect(readFileSync(path, 'utf8')).toBe(raw) } - const previous = readDesktopSettings(userData) + const previous = await readDesktopSettings(userData) writeFileSync(path, '{"updates":{"allowTestAuthPopupWindow":true}}') expect(previous.updates.allowTestAuthPopupWindow).toBe(false) - expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(true) + expect((await readDesktopSettings(userData)).updates.allowTestAuthPopupWindow).toBe(true) }) it.each(['', '{', 'null', '[]', '{"updates":null}', '{"updates":[]}', '{"updates":{"allowTestAuthPopupWindow":"false"}}', '{"updates":{"allowTestAuthPopupWindow":1}}', - '{"updates":{"allowTestAuthPopupWindow":null}}'])('reports invalid settings with their path and preserves the original: %s', (raw) => { + '{"updates":{"allowTestAuthPopupWindow":null}}'])('reports invalid settings with their path and preserves the original: %s', async (raw) => { const { userData, path } = fixture() writeFileSync(path, raw) - expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(false) + expect((await readDesktopSettings(userData)).updates.allowTestAuthPopupWindow).toBe(false) expect(console.warn).toHaveBeenCalledWith(expect.stringContaining(path), expect.any(Error)) expect(readFileSync(path, 'utf8')).toBe(raw) }) -it('reports filesystem errors instead of replacing unreadable settings', () => { +it('reports filesystem errors instead of replacing unreadable settings', async () => { const { userData, path } = fixture() mkdirSync(path) - expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(false) + expect((await readDesktopSettings(userData)).updates.allowTestAuthPopupWindow).toBe(false) expect(console.warn).toHaveBeenCalledWith(expect.stringContaining(path), expect.any(Error)) expect(fs.statSync(path).isDirectory()).toBe(true) }) -it.each(['mkdirSync', 'writeFileSync'] as const)('continues with disabled popups when %s fails', (operation) => { +it.each(['mkdir', 'writeFile'] as const)('continues with disabled popups when %s fails', async (operation) => { const { userData, path } = fixture() const failure = Object.assign(new Error('settings creation denied'), { code: 'EACCES' }) - vi.spyOn(fs, operation).mockImplementation(() => { throw failure }) - expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(false) - expect(console.warn).toHaveBeenCalledWith(expect.stringContaining(path), failure) + vi.spyOn(fsp, operation).mockRejectedValue(failure) + expect((await readDesktopSettings(userData)).updates.allowTestAuthPopupWindow).toBe(false) + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining(path), expect.objectContaining({ cause: failure })) }) -it('continues with defaults even when warning output fails', () => { +it('continues with defaults even when warning output fails', async () => { const { userData, path } = fixture() writeFileSync(path, '{') vi.mocked(console.warn).mockImplementation(() => { throw new Error('output unavailable') }) - expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(false) + expect((await readDesktopSettings(userData)).updates.allowTestAuthPopupWindow).toBe(false) expect(readFileSync(path, 'utf8')).toBe('{') }) diff --git a/apps/desktop/tests/update-channel.spec.ts b/apps/desktop/tests/update-channel.spec.ts index 8ea7bb98a4..191cd8e5d9 100644 --- a/apps/desktop/tests/update-channel.spec.ts +++ b/apps/desktop/tests/update-channel.spec.ts @@ -3,8 +3,9 @@ import { desktopUpdateChannel } from '../src/update-channel.ts' it('reads the installed channel independently from the current version', () => { expect(desktopUpdateChannel({ version: '1.0.0', dshDesktopUpdateChannel: 'nightly' })).toBe('nightly') - expect(desktopUpdateChannel({ version: '1.0.0', dshDesktopUpdateChannel: 'latest' })).toBe('latest') + expect(desktopUpdateChannel({ version: '1.0.0', dshDesktopUpdateChannel: 'stable' })).toBe('stable') expect(desktopUpdateChannel({ version: '1.0.0' })).toBe('nightly') + expect(() => desktopUpdateChannel({ dshDesktopUpdateChannel: 'latest' })).toThrow(/channel/u) expect(() => desktopUpdateChannel({ dshDesktopUpdateChannel: 'beta' })).toThrow(/channel/u) expect(() => desktopUpdateChannel(null)).toThrow(/manifest/u) }) diff --git a/apps/desktop/tests/update-configuration.spec.ts b/apps/desktop/tests/update-configuration.spec.ts new file mode 100644 index 0000000000..799d396bc1 --- /dev/null +++ b/apps/desktop/tests/update-configuration.spec.ts @@ -0,0 +1,42 @@ +import { expect, it } from 'vitest' +import { channelSaveFailure, configurationFailure, configurationMessage } from '../src/update-configuration.ts' +import { DesktopSettingsFileError } from '../src/settings-file.ts' +import { en, zh } from '../src/locale.ts' + +it('keeps startup suspensions distinct from transient save failures', () => { + const json = new DesktopSettingsFileError({ kind: 'invalid', reason: 'json' }, '/settings.json') + const structure = new DesktopSettingsFileError({ kind: 'invalid', reason: 'structure' }, '/settings.json') + const channel = new DesktopSettingsFileError({ kind: 'invalid', reason: 'field', field: 'channel' }, '/settings.json') + const popup = new DesktopSettingsFileError({ kind: 'invalid', reason: 'field', field: 'allowTestAuthPopupWindow' }, '/settings.json') + expect(configurationFailure(json)).toBe('format') + expect(configurationFailure(structure)).toBe('format') + expect(configurationFailure(channel)).toBe('channel') + expect(configurationFailure(popup)).toBe('format') + expect(configurationFailure(new DesktopSettingsFileError({ kind: 'read' }, '/settings.json'))).toBe('read') + expect(channelSaveFailure(json)).toBe('format') + expect(channelSaveFailure(new Error('native apply failed'))).toBe('apply') +}) + +it('only exposes a permission hint for a known filesystem permission failure', () => { + for (const code of ['EACCES', 'EPERM', 'EIO']) { + const cause = Object.assign(new Error('private filesystem detail'), { code }) + const error = new DesktopSettingsFileError({ kind: 'write', operation: 'save', effect: 'unchanged' }, '/private/path', { cause }) + expect(channelSaveFailure(error)).toBe(code === 'EIO' ? 'save' : 'permission') + } + expect(channelSaveFailure(new DesktopSettingsFileError({ kind: 'read' }, '/private/path'))).toBe('save') + expect(channelSaveFailure(new DesktopSettingsFileError({ kind: 'read' }, '/private/path', { cause: 'private detail' }))).toBe('save') +}) + +it('keeps settings errors concise and cleanup recovery distinct', () => { + expect(configurationMessage('read', en)).toBe('Settings file error; updates are paused') + expect(configurationMessage('format', en)).toBe('Settings file error; updates are paused') + expect(configurationMessage('channel', zh)).toBe('配置文件异常,更新已暂停') + expect(configurationMessage('cleanup', zh)).toContain('请重启应用') + expect(configurationMessage('cleanup', en)).not.toContain('settings file') +}) + +it('reports an uncertain write separately even when its cause is permission denied', () => { + const error = new DesktopSettingsFileError({ kind: 'write', operation: 'save', effect: 'unknown' }, '/settings.json', + { cause: Object.assign(new Error('denied'), { code: 'EACCES' }) }) + expect(channelSaveFailure(error)).toBe('uncertain') +}) diff --git a/apps/desktop/tests/update-coordinator.spec.ts b/apps/desktop/tests/update-coordinator.spec.ts index 3aef0bc60f..75521799f4 100644 --- a/apps/desktop/tests/update-coordinator.spec.ts +++ b/apps/desktop/tests/update-coordinator.spec.ts @@ -39,7 +39,7 @@ describe('desktop release metadata', () => { nodeVersion: '24.17.0', pnpmVersion: '11.7.0', } - expect(() => parseDesktopRelease({ ...base, version: 'latest' })).toThrow(/invalid desktop release metadata/u) + expect(() => parseDesktopRelease({ ...base, version: 'stable' })).toThrow(/invalid desktop release metadata/u) expect(() => parseDesktopRelease({ ...base, hostProtocolVersion: 999 })).toThrow(/invalid desktop release metadata/u) }) }) @@ -47,7 +47,7 @@ describe('desktop release metadata', () => { const coordinators: InstanceType[] = [] afterEach(() => { for (const item of coordinators.splice(0)) item.dispose() }) -function fixture(channel: 'nightly' | 'latest' = 'nightly', version = '1.1.0-alpha.1') { +function fixture(channel: 'nightly' | 'stable' = 'nightly', version = '1.1.0-alpha.1', discard?: (updater: AppUpdater) => Promise) { const events = new EventEmitter() const checkForUpdates = vi.fn(async () => ({ isUpdateAvailable: true, @@ -66,7 +66,7 @@ function fixture(channel: 'nightly' | 'latest' = 'nightly', version = '1.1.0-alp const updater = Object.assign(events, { checkForUpdates, downloadUpdate, quitAndInstall }) as unknown as AppUpdater const coordinator = new DesktopUpdateCoordinator( (state) => { states.push(state); return state }, - beforeRestart, updater, () => true, () => version, downloadResult, channel, + beforeRestart, updater, () => true, () => version, downloadResult, channel, discard, ) coordinators.push(coordinator) return { coordinator, updater, events, states, checkForUpdates, downloadUpdate, quitAndInstall, beforeRestart, downloadResult } @@ -244,16 +244,198 @@ it.each([new DesktopUpdatePreparationError('stop-failed', 'private diagnostic'), }) -it('keeps a stable-version nightly installation on nightly and rejects prereleases on latest', async () => { +it('keeps a stable-version nightly installation on nightly and rejects prereleases on stable', async () => { const nightly = fixture('nightly', '1.0.0') expect(nightly.updater.channel).toBe('nightly') expect(nightly.updater.allowPrerelease).toBe(true) expect((await nightly.coordinator.check()).phase).toBe('available') expect(nightly.updater.channel).toBe('nightly') - const latest = fixture('latest', '1.0.0') - expect(latest.updater.channel).toBe('latest') - expect(latest.updater.allowPrerelease).toBe(false) - expect(latest.updater.allowDowngrade).toBe(false) - expect((await latest.coordinator.check()).phase).toBe('error') - expect(latest.downloadUpdate).not.toHaveBeenCalled() + const stable = fixture('stable', '1.0.0') + expect(stable.updater.channel).toBe('stable') + expect(stable.updater.allowPrerelease).toBe(false) + expect(stable.updater.allowDowngrade).toBe(false) + expect((await stable.coordinator.check()).phase).toBe('error') + expect(stable.downloadUpdate).not.toHaveBeenCalled() +}) + + +it('retires a downloaded target and rejects its old confirmation after switching', async () => { + const f = fixture() + await f.coordinator.check() + const revision = f.coordinator.revision + await f.coordinator.download('1.1.0-rc.2') + const save = vi.fn(async () => {}) + f.checkForUpdates.mockResolvedValueOnce({ isUpdateAvailable: true, updateInfo: { version: '1.2.0' } }) + await f.coordinator.switchChannel('stable', save) + expect(save).toHaveBeenCalledOnce() + expect(f.coordinator.selectedChannel).toBe('stable') + expect(f.updater.allowDowngrade).toBe(false) + expect(f.coordinator.state).toMatchObject({ phase: 'available', version: '1.2.0' }) + await f.coordinator.install('1.1.0-rc.2', revision) + expect(f.quitAndInstall).not.toHaveBeenCalled() +}) + +it('leaves the current target intact if saving the choice fails', async () => { + const f = fixture() + await f.coordinator.check() + await f.coordinator.download('1.1.0-rc.2') + await expect(f.coordinator.switchChannel('stable', () => Promise.reject(new Error('read only')))).rejects.toThrow('read only') + expect(f.coordinator.selectedChannel).toBe('nightly') + expect(f.coordinator.state.phase).toBe('ready') + await f.coordinator.install('1.1.0-rc.2') + expect(f.quitAndInstall).toHaveBeenCalledOnce() +}) + +it('discards a check result that belongs to the previous channel', async () => { + const f = fixture() + const started = Promise.withResolvers() + const checked = Promise.withResolvers<{ isUpdateAvailable: boolean; updateInfo: { version: string } }>() + f.checkForUpdates.mockImplementationOnce(() => { started.resolve(undefined); return checked.promise }) + const checking = f.coordinator.check() + await started.promise + const saved = Promise.withResolvers() + const switching = f.coordinator.switchChannel('stable', async () => { saved.resolve(undefined) }) + await saved.promise + f.checkForUpdates.mockResolvedValueOnce({ isUpdateAvailable: true, updateInfo: { version: '1.2.0' } }) + checked.resolve({ isUpdateAvailable: true, updateInfo: { version: '9.0.0-alpha.1' } }) + await Promise.all([checking, switching]) + expect(f.states.some(state => state.version === '9.0.0-alpha.1')).toBe(false) + expect(f.coordinator.state).toMatchObject({ phase: 'available', version: '1.2.0' }) +}) + +it('does not check or install while startup channel configuration is invalid', async () => { + const f = fixture() + f.coordinator.suspendConfiguration('/settings.json') + expect(f.coordinator.configurationFailure).toBe('startup') + await f.coordinator.check(true) + await f.coordinator.download('1.1.0-rc.2') + expect(f.checkForUpdates).not.toHaveBeenCalled() + expect(f.downloadUpdate).not.toHaveBeenCalled() + expect(f.coordinator.state).toMatchObject({ phase: 'error', failedOperation: 'configuration' }) + await f.coordinator.switchChannel('nightly', async () => {}) + expect(f.coordinator.state.phase).toBe('available') + expect(f.coordinator.configurationFailure).toBeUndefined() +}) + + +it('waits for a cancelled download before checking the new channel and ignores late progress', async () => { + const f = fixture() + const entered = Promise.withResolvers() + const finished = Promise.withResolvers() + f.downloadUpdate.mockImplementationOnce(async () => { entered.resolve(undefined); return finished.promise }) + await f.coordinator.check() + const downloading = f.coordinator.download('1.1.0-rc.2') + await entered.promise + const persisted = Promise.withResolvers() + const switching = f.coordinator.switchChannel('stable', async () => { persisted.resolve(undefined) }) + await persisted.promise + await Promise.resolve() + const count = f.states.length + f.events.emit('download-progress', { percent: 99 }) + f.events.emit('update-downloaded', { version: '1.1.0-rc.2' }) + expect(f.states).toHaveLength(count) + expect(f.checkForUpdates).toHaveBeenCalledTimes(1) + f.checkForUpdates.mockResolvedValueOnce({ isUpdateAvailable: true, updateInfo: { version: '1.2.0' } }) + finished.resolve([]) + await Promise.all([downloading, switching]) + expect(f.coordinator.state).toEqual({ phase: 'available', version: '1.2.0' }) + expect(f.downloadResult).not.toHaveBeenCalled() + expect(f.updater.allowDowngrade).toBe(false) +}) + +it('rejects a channel switch during installation preparation without persisting it', async () => { + const f = fixture() + await f.coordinator.check() + await f.coordinator.download('1.1.0-rc.2') + const preparation = Promise.withResolvers() + f.beforeRestart.mockReturnValueOnce(preparation.promise) + const installing = f.coordinator.install('1.1.0-rc.2') + const save = vi.fn(async () => {}) + await expect(f.coordinator.switchChannel('stable', save)).rejects.toThrow('installation is in progress') + expect(save).not.toHaveBeenCalled() + preparation.resolve(false) + await installing + expect(f.coordinator.state.phase).toBe('ready') +}) + + +it('does not accept a channel switch after installer handoff even if a later error is reported', async () => { + const f = fixture() + await f.coordinator.check() + await f.coordinator.download('1.1.0-rc.2') + await f.coordinator.install('1.1.0-rc.2') + f.events.emit('error', new Error('native updater failed after handoff')) + expect(f.coordinator.state.phase).toBe('error') + const save = vi.fn(async () => {}) + await expect(f.coordinator.switchChannel('stable', save)).rejects.toThrow('installation is in progress') + expect(save).not.toHaveBeenCalled() +}) + +it('suspends updates if retiring a downloaded target fails and recovers on a later switch', async () => { + const discard = vi.fn().mockRejectedValueOnce(new Error('native target busy')).mockResolvedValueOnce(undefined) + const f = fixture('nightly', '1.1.0-alpha.1', discard) + await f.coordinator.check() + await f.coordinator.download('1.1.0-rc.2') + await expect(f.coordinator.switchChannel('stable', async () => {})).rejects.toThrow('native target busy') + expect(f.coordinator.configurationFailure).toBe('cleanup') + expect(f.coordinator.state).toMatchObject({ phase: 'error', failedOperation: 'configuration' }) + const checks = f.checkForUpdates.mock.calls.length + await f.coordinator.check(true) + await expect(f.coordinator.install('1.1.0-rc.2')).rejects.toThrow('channel selection is not ready') + expect(f.checkForUpdates).toHaveBeenCalledTimes(checks) + expect(f.quitAndInstall).not.toHaveBeenCalled() + await f.coordinator.switchChannel('stable', async () => {}) + expect(f.coordinator.configurationFailure).toBeUndefined() +}) + + +it.each(['idle', 'downloading', 'ready'] as const)('preserves %s when the current channel is selected again', async (phase) => { + const f = fixture() + const save = vi.fn(async () => {}) + const pending = Promise.withResolvers() + const started = Promise.withResolvers() + let downloading: Promise | undefined + if (phase !== 'idle') { + await f.coordinator.check() + if (phase === 'downloading') f.downloadUpdate.mockImplementationOnce(() => { started.resolve(undefined); return pending.promise }) + downloading = f.coordinator.download('1.1.0-rc.2') + if (phase === 'ready') await downloading + else await started.promise + } + const before = f.coordinator.state + const revision = f.coordinator.revision + const checks = f.checkForUpdates.mock.calls.length + try { + expect(await f.coordinator.switchChannel('nightly', save)).toBe('unchanged') + expect(save).not.toHaveBeenCalled() + expect(f.coordinator.state).toBe(before) + expect(f.coordinator.revision).toBe(revision) + expect(f.checkForUpdates).toHaveBeenCalledTimes(checks) + } finally { + pending.resolve(['verified-package']) + await downloading + } +}) + +it.each([ + ['ENOSPC', 'space'], ['EACCES', 'permission'], ['EPERM', 'permission'], ['EROFS', 'permission'], ['EBUSY', 'busy'], +] as const)('reports the current %s download failure without attributing it to cleanup', async (code, kind) => { + const f = fixture() + await f.coordinator.check() + f.downloadUpdate.mockRejectedValueOnce(Object.assign(new Error('write failed'), { + code, path: '/cache/new.zip', dest: '/cache/pending/new.zip', + })) + const state = await f.coordinator.download('1.1.0-rc.2') + expect(state).toMatchObject({ phase: 'error', failedOperation: 'download', downloadFailure: kind, + technicalDetails: `${code}\nwrite failed\n/cache/new.zip\n/cache/pending/new.zip` }) + await expect(f.coordinator.install('1.1.0-rc.2')).rejects.toThrow('not ready') + expect((await f.coordinator.download('1.1.0-rc.2')).phase).toBe('ready') + expect(f.coordinator.state.downloadFailure).toBeUndefined() +}) + +it('does not diagnose a filesystem cause from error wording alone', async () => { + const f = fixture() + await f.coordinator.check() + f.downloadUpdate.mockRejectedValueOnce(new Error('ENOSPC or EACCES in a remote response')) + expect((await f.coordinator.download('1.1.0-rc.2')).downloadFailure).toBeUndefined() }) diff --git a/apps/desktop/tests/update-dialog.spec.ts b/apps/desktop/tests/update-dialog.spec.ts index 1c43e37037..29cbf832ab 100644 --- a/apps/desktop/tests/update-dialog.spec.ts +++ b/apps/desktop/tests/update-dialog.spec.ts @@ -214,3 +214,56 @@ it('reads the current locale and keeps technical diagnostics separate from ordin await pending } }) + + +it('keeps a reload action visible for at least half a second and prevents repeat responses', async () => { + vi.useFakeTimers() + const f = setup() + const run = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true) + const pending = f.show(undefined, { action: { index: 0, run } }) + const first = f.invoke(UPDATE_DIALOG_IPC.respond, 0) + expect(f.invoke(UPDATE_DIALOG_IPC.status)).toMatchObject({ busyButton: 0 }) + f.invoke(UPDATE_DIALOG_IPC.respond, 0) + expect(run).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(499) + expect(f.invoke(UPDATE_DIALOG_IPC.status)).toMatchObject({ busyButton: 0 }) + await vi.advanceTimersByTimeAsync(1) + await first + expect(dialogs!.isOpen).toBe(true) + expect(f.invoke(UPDATE_DIALOG_IPC.status)).not.toHaveProperty('busyButton') + const second = f.invoke(UPDATE_DIALOG_IPC.respond, 0) + await vi.advanceTimersByTimeAsync(500) + await second + expect(await pending).toMatchObject({ response: 0 }) +}) + +it('does not revive a busy prompt after cancellation', async () => { + vi.useFakeTimers() + const f = setup() + const action = Promise.withResolvers() + const pending = f.show(undefined, { action: { index: 0, run: () => action.promise } }) + const responding = f.invoke(UPDATE_DIALOG_IPC.respond, 0) + dialogs!.cancel() + action.resolve(false) + await vi.advanceTimersByTimeAsync(500) + await responding + expect(await pending).toMatchObject({ response: 1 }) + expect(dialogs!.isOpen).toBe(false) +}) + +it('keeps the same dialog open after revealing a settings file', async () => { + const f = setup() + const reveal = vi.fn() + const pending = f.show(undefined, { buttons: ['Reload', 'Show file', 'Close'], cancelId: 2, + keepOpenAction: { index: 1, run: reveal } }) + const before = f.invoke(UPDATE_DIALOG_IPC.status) as { revision: number } + await f.invoke(UPDATE_DIALOG_IPC.respond, 1) + expect(reveal).toHaveBeenCalledOnce() + expect((f.invoke(UPDATE_DIALOG_IPC.status) as { revision: number }).revision).toBe(before.revision + 1) + expect(f.invoke(UPDATE_DIALOG_IPC.status)).not.toEqual(before) + await f.invoke(UPDATE_DIALOG_IPC.respond, 1) + expect(reveal).toHaveBeenCalledTimes(2) + expect(dialogs!.isOpen).toBe(true) + await f.invoke(UPDATE_DIALOG_IPC.respond, 2) + expect(await pending).toMatchObject({ response: 2 }) +}) diff --git a/apps/desktop/tests/update-discard.spec.ts b/apps/desktop/tests/update-discard.spec.ts new file mode 100644 index 0000000000..17a4b54aec --- /dev/null +++ b/apps/desktop/tests/update-discard.spec.ts @@ -0,0 +1,62 @@ +import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises' +import * as fs from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import type { AppUpdater } from 'electron-updater' +import { discardDesktopUpdate } from '../src/updates/electron-updater/discard.ts' +import { recordUpdateCleanupFailure } from '../src/update-cleanup-log.ts' + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, rm: vi.fn(actual.rm) } +}) + +const environment = vi.hoisted(() => ({ logs: '' })) +vi.mock('electron', () => ({ app: { getPath: () => environment.logs } })) +const roots: string[] = [] +afterEach(async () => { + vi.restoreAllMocks() + for (const root of roots.splice(0)) await fs.rm(root, { recursive: true, force: true }) +}) + +it('retires the target despite a removal failure and persists its exact filesystem warning', async () => { + const root = await mkdtemp(join(tmpdir(), 'update-discard-')) + roots.push(root) + const pending = join(root, 'pending') + environment.logs = join(root, 'logs') + await mkdir(pending) + const file = join(pending, 'old.zip') + await writeFile(file, 'obsolete') + const helper = { _file: file, _packageFile: file, versionInfo: {}, fileInfo: {}, + _downloadedFileInfo: {}, cacheDirForPendingUpdate: pending } + const internal = { updateInfoAndProvider: {}, downloadedUpdateHelper: helper } + const failure = Object.assign(new Error('file is locked'), { code: 'EPERM' }) + vi.mocked(fs.rm).mockRejectedValueOnce(failure) + await discardDesktopUpdate(internal as object as AppUpdater) + expect(internal.updateInfoAndProvider).toBeNull() + expect(helper).toMatchObject({ _file: null, _packageFile: null, versionInfo: null, fileInfo: null, _downloadedFileInfo: null }) + expect(await readFile(file, 'utf8')).toBe('obsolete') + const lines = (await readFile(join(environment.logs, 'update-cleanup.jsonl'), 'utf8')).trim().split('\n') + expect(lines).toHaveLength(1) + expect(lines[0]).toMatch(/"time":"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z"/u) + expect(JSON.parse(lines[0]!)).toMatchObject({ event: 'update-cache-cleanup-failed', operation: 'remove', path: file, + code: 'EPERM', reason: 'file is locked' }) +}) + +it('does not retire a target that the native installer already owns', async () => { + const internal = { squirrelDownloadedUpdate: true, updateInfoAndProvider: {} } + await expect(discardDesktopUpdate(internal as object as AppUpdater)).rejects.toThrow('native installation already staged') + expect(internal.updateInfoAndProvider).not.toBeNull() +}) + +it('reports log-write failure separately without restoring a retired target', async () => { + const root = await mkdtemp(join(tmpdir(), 'update-log-')) + roots.push(root) + const file = join(root, 'not-a-directory') + await writeFile(file, 'sentinel') + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + recordUpdateCleanupFailure(file, { operation: 'list', path: '/retired/cache', error: new Error('unreadable') }) + expect(error).toHaveBeenCalledWith('desktop update: could not persist cleanup warning', + expect.objectContaining({ operation: 'list', path: '/retired/cache' }), expect.any(Error)) +}) diff --git a/apps/desktop/tests/update-error-renderer.spec.ts b/apps/desktop/tests/update-error-renderer.spec.ts index b2778c3b15..f13c58a588 100644 --- a/apps/desktop/tests/update-error-renderer.spec.ts +++ b/apps/desktop/tests/update-error-renderer.spec.ts @@ -259,3 +259,43 @@ it.each(['en', 'zh-CN'])('shows fullscreen recovery guidance directly in the man expect((p.element('technical-details') as HTMLDetailsElement).open).toBe(false) expect(action).not.toHaveBeenCalled() }) + +it('offers configuration recovery from a mandatory update error', async () => { + const p = page('mandatory-update') + const locale = resolveDesktopLocale('zh-CN') + const state: MandatoryUpdateView = { locale, deferred: false, policy: { blocking: true, checking: false }, + update: { phase: 'error', failedOperation: 'configuration', message: 'settings parse failure', technicalDetails: 'diagnostic' } } + const action = vi.fn(async () => {}) + const api: MandatoryUpdateApi = { status: async () => state, action, subscribe: () => () => {} } + Object.defineProperty(p.dom.window, 'dshMandatoryUpdate', { value: api }) + p.run() + await expect.poll(() => p.element('refresh').textContent).toBe(locale.messages.updateConfigurationDetails) + expect(p.element('refresh').hidden).toBe(false) + expect(p.element('error').textContent).toBe(locale.messages.updateConfigurationRead) + expect(p.element('technical-details').hidden).toBe(true) + expect(p.element('technical-details-content').textContent).toBe('') + expect(p.element('update').hidden).toBe(true) + p.element('refresh').click() + expect(action).toHaveBeenCalledWith('refresh', undefined, undefined) +}) + +it('accepts another response after a keep-open action publishes its next revision', async () => { + const p = page('update-dialog') + const locale = resolveDesktopLocale('zh-CN') + let state: UpdateDialogView = { revision: 1, locale: locale.id, title: 'Settings', message: 'Settings error', detail: '', + buttons: ['Reload', 'Show file', 'Close'], cancelId: 2, closeLabel: 'Close', technicalDetails: '', technicalDetailsLabel: '' } + let publish!: (view: UpdateDialogView | null) => void + const respond = vi.fn(async (_revision: number, index: number) => { + if (index === 1) { state = { ...state, revision: state.revision + 1 }; publish(state) } + else publish(null) + }) + const api: UpdateDialogApi = { status: async () => state, respond, subscribe: (listener) => { publish = listener; return () => {} } } + Object.defineProperty(p.dom.window, 'dshUpdateDialog', { value: api }) + p.run() + await expect.poll(() => p.element('actions').children.length).toBe(3) + p.element('actions').querySelectorAll('button')[1]!.click() + p.element('actions').querySelectorAll('button')[1]!.click() + p.element('actions').querySelectorAll('button')[2]!.click() + expect(respond.mock.calls).toEqual([[1, 1], [2, 1], [3, 2]]) + expect(p.document.body.classList.contains('visible')).toBe(false) +}) diff --git a/apps/desktop/tests/update-http-executor.spec.ts b/apps/desktop/tests/update-http-executor.spec.ts index 6fb46d0f74..c5a760359a 100644 --- a/apps/desktop/tests/update-http-executor.spec.ts +++ b/apps/desktop/tests/update-http-executor.spec.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ClientRequest } from 'electron' -import { DesktopUpdateHttpExecutor } from '../src/update-http-executor.ts' +import { DesktopUpdateHttpExecutor } from '../src/updates/electron-updater/http-executor.ts' beforeEach(() => { vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() }) diff --git a/apps/desktop/tests/update-presentation.spec.ts b/apps/desktop/tests/update-presentation.spec.ts index 9c53a9ebec..a9ad2501d3 100644 --- a/apps/desktop/tests/update-presentation.spec.ts +++ b/apps/desktop/tests/update-presentation.spec.ts @@ -44,3 +44,18 @@ it('keeps raw diagnostics out of error tooltips in both locales', () => { technicalDetails: 'private diagnostic' }, messages)).toBe(messages.updateInstallFailed) } }) + +it('offers actionable filesystem recovery without exposing paths in the indicator', () => { + for (const messages of [en, zh]) { + for (const [downloadFailure, expected] of [ + ['space', messages.updateDownloadSpaceFailed], + ['permission', messages.updateDownloadPermissionFailed], + ['busy', messages.updateDownloadBusyFailed], + ] as const) { + const state: DesktopUpdateState = { phase: 'error', failedOperation: 'download', downloadFailure, + technicalDetails: '/private/cache/new.zip', message: 'write failed' } + expect(desktopUpdateErrorSummary(state, messages)).toBe(expected) + expect(presentDesktopUpdate(state)).toEqual({ phase: 'error', failure: `download-${downloadFailure}` }) + } + } +}) diff --git a/apps/desktop/tests/update-settings.spec.ts b/apps/desktop/tests/update-settings.spec.ts new file mode 100644 index 0000000000..ae8145296f --- /dev/null +++ b/apps/desktop/tests/update-settings.spec.ts @@ -0,0 +1,81 @@ +import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it, onTestFinished, vi } from 'vitest' +import { readDesktopSettings } from '../src/settings.ts' +import { DesktopUpdateSettings } from '../src/update-settings.ts' +import { DesktopSettingsFile, DesktopSettingsFileError } from '../src/settings-file.ts' + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'desktop-channel-')) + onTestFinished(() => rm(root, { recursive: true, force: true })) + await mkdir(join(root, 'desktop')) + return new DesktopUpdateSettings(root, 'stable') +} + +it('uses the packaged channel for missing settings without recreating the document', async () => { + const settings = await fixture() + expect(await settings.load()).toEqual({ kind: 'ready', channel: 'stable' }) + await expect(readFile(settings.path)).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +it('preserves other fields during explicit saves and only rereads external edits on load', async () => { + const settings = await fixture() + await writeFile(settings.path, JSON.stringify({ updates: { allowTestAuthPopupWindow: true }, other: 4 })) + const startup = await settings.load() + await settings.save('nightly') + expect(startup).toEqual({ kind: 'ready', channel: 'stable' }) + expect(JSON.parse(await readFile(settings.path, 'utf8'))).toEqual({ updates: { allowTestAuthPopupWindow: true, channel: 'nightly' }, other: 4 }) + expect(await settings.load()).toEqual({ kind: 'ready', channel: 'nightly' }) + await writeFile(settings.path, '{}') + expect(await settings.load()).toEqual({ kind: 'ready', channel: 'stable' }) +}) + +it.each(['{', 'null', '{"updates":[]}', '{"updates":{"channel":"alpha"}}', '{"updates":{"channel":null}}'])('suspends updates for invalid channel settings: %s', async (raw) => { + const settings = await fixture() + await writeFile(settings.path, raw) + expect(await settings.load()).toMatchObject({ kind: 'error', error: { problem: { kind: 'invalid' }, path: settings.path } }) + expect(await readFile(settings.path, 'utf8')).toBe(raw) +}) + +it('refuses to replace malformed user JSON during a UI save', async () => { + const settings = await fixture() + await writeFile(settings.path, '{') + await expect(settings.save('nightly')).rejects.toBeInstanceOf(DesktopSettingsFileError) + expect(await readFile(settings.path, 'utf8')).toBe('{') +}) + +it('reports unreadable configuration instead of silently choosing a default', async () => { + const settings = await fixture() + await mkdir(settings.path) + expect(await settings.load()).toMatchObject({ kind: 'error', error: { problem: { kind: 'read' } } }) +}) + +it('updates the document initialized by shell settings without losing the popup preference', async () => { + const root = await mkdtemp(join(tmpdir(), 'desktop-shared-settings-')) + onTestFinished(() => rm(root, { recursive: true, force: true })) + expect((await readDesktopSettings(root)).updates.allowTestAuthPopupWindow).toBe(false) + const settings = new DesktopUpdateSettings(root, 'stable') + await settings.save('nightly') + expect(JSON.parse(await readFile(settings.path, 'utf8'))).toEqual({ + updates: { allowTestAuthPopupWindow: false, channel: 'nightly' }, + }) + expect((await readDesktopSettings(root)).updates.allowTestAuthPopupWindow).toBe(false) + expect(await settings.load()).toEqual({ kind: 'ready', channel: 'nightly' }) +}) + +// Storage has already classified the write; the channel consumer must not retry it. +it.each(['written', 'unknown', 'unchanged'] as const)('handles the %s write outcome without retrying', async (effect) => { + const settings = await fixture() + const failure = new DesktopSettingsFileError({ kind: 'write', operation: 'save', effect }, settings.path, + { cause: new Error('injected storage failure') }) + const save = vi.spyOn(DesktopSettingsFile.prototype, 'saveUpdates').mockRejectedValueOnce(failure) + const read = vi.spyOn(DesktopSettingsFile.prototype, 'read') + try { + if (effect === 'written') await expect(settings.save('nightly')).resolves.toBeUndefined() + else await expect(settings.save('nightly')).rejects.toBe(failure) + expect(save).toHaveBeenCalledTimes(1) + expect(read).not.toHaveBeenCalled() + expect(JSON.parse(await readFile(`${settings.path}.save.jsonl`, 'utf8'))).toMatchObject({ effect }) + } finally { save.mockRestore(); read.mockRestore() } +}) diff --git a/apps/desktop/tests/updates/electron-updater/README.i18n.yaml b/apps/desktop/tests/updates/electron-updater/README.i18n.yaml new file mode 100644 index 0000000000..8966a2eb27 --- /dev/null +++ b/apps/desktop/tests/updates/electron-updater/README.i18n.yaml @@ -0,0 +1,10 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write apps/desktop/tests/updates/electron-updater/README.md +/: + en: ff2d052de11d825a + zh: aa36e8c136562d5a +/electron-updater-compatibility: + en: c28a2b4667f4ee10 + zh: 51ffd9e5e08a39e4 diff --git a/apps/desktop/tests/updates/electron-updater/README.md b/apps/desktop/tests/updates/electron-updater/README.md new file mode 100644 index 0000000000..c8e9a1334f --- /dev/null +++ b/apps/desktop/tests/updates/electron-updater/README.md @@ -0,0 +1,23 @@ +--- +description: "Platform version records and local qualification for the Electron updater adapter." +--- + +# Electron updater compatibility + +English | [中文](README.zh.md) + +The adapter in `src/updates/electron-updater/` owns library channel policy, cache retirement and Electron HTTP handling. This qualification calls that adapter directly; coordinator, configuration and UI regressions belong to ordinary tests. + +Every PR runs a Node-only version check in Windows CI. `verified-versions.json` records the installed Electron, electron-updater and its builder-util-runtime versions separately for Windows and macOS. Missing records, `null` (pending qualification), and version mismatches all fail the check. Git retains prior records. No Electron process or network access is required by this check. + +After changing those dependencies, the adapter, or its qualification scripts, run on each affected native platform from the repository root: + +```sh +pnpm install --frozen-lockfile +pnpm --filter @deepseek-ai/dsh-desktop exec install-electron +pnpm --filter @deepseek-ai/dsh-desktop run test:updates:channels --record +``` + +Workspace dependencies must already be built (`pnpm run build:artifacts` on a fresh checkout). The final command rebuilds Desktop, runs Electron without opening a window, and records only the current platform after successful exit. Commit the JSON change with the dependency or adapter change. Omit `--record` to test without changing records. The lightweight check compares versions only; unchanged versions do not certify modified adapter code. + +The fixture uses a private temporary directory and loopback HTTP feed with inert payloads. It checks channel filenames, downgrade rejection, staging eligibility, downloaded-cache retirement, rejection of obsolete cache by a new updater instance, and macOS proxy closure. It never executes an installer or contacts a production feed. Dependency installation can require external network access. A 30-second watchdog bounds Electron startup, tests and exit; timeout or failure never updates records. Signed installation and application restart remain separate qualification work. diff --git a/apps/desktop/tests/updates/electron-updater/README.zh.md b/apps/desktop/tests/updates/electron-updater/README.zh.md new file mode 100644 index 0000000000..d00544eae5 --- /dev/null +++ b/apps/desktop/tests/updates/electron-updater/README.zh.md @@ -0,0 +1,23 @@ +--- +description: "Electron updater 适配层的平台版本记录与本地验证。" +--- + +# Electron updater 兼容性 + +[English](README.md) | 中文 + +`src/updates/electron-updater/` 中的适配层负责库的渠道策略、缓存撤销和 Electron HTTP 处理。本验证直接调用该适配层;协调器、配置和 UI 回归由常规测试负责。 + +每个 PR 在 Windows CI 中运行纯 Node 版本检查。`verified-versions.json` 分别记录 Windows 和 macOS 已验证的实际 Electron、electron-updater 及其 builder-util-runtime 版本。缺少记录、`null`(待验证)或版本不匹配都会使检查失败。Git 保留历史记录。此检查不启动 Electron,也不访问网络。 + +修改这些依赖、适配层或验证脚本后,在每个受影响的原生平台上从仓库根目录运行: + +```sh +pnpm install --frozen-lockfile +pnpm --filter @deepseek-ai/dsh-desktop exec install-electron +pnpm --filter @deepseek-ai/dsh-desktop run test:updates:channels --record +``` + +需要先构建 workspace 依赖(新检出目录运行 `pnpm run build:artifacts`)。最后一条命令重新构建 Desktop,启动不打开窗口的 Electron,并且只在成功退出后记录当前平台。将 JSON 改动与依赖或适配层改动一起提交。不带 `--record` 可只测试、不改记录。轻量检查只比较版本;版本未变不代表修改后的适配层已经通过验证。 + +fixture 使用独立临时目录、回环 HTTP 服务和惰性载荷,检查渠道清单文件名、降级拒绝、灰度资格、下载缓存撤销、新 updater 实例拒绝旧缓存,以及 macOS 代理关闭。它不执行安装器,也不访问生产更新源。安装依赖可能需要外网。30 秒看门狗覆盖 Electron 启动、测试和退出;超时或失败不会更新记录。签名安装和应用重启另行验证。 diff --git a/apps/desktop/tests/updates/electron-updater/check-versions.mjs b/apps/desktop/tests/updates/electron-updater/check-versions.mjs new file mode 100644 index 0000000000..6769497a96 --- /dev/null +++ b/apps/desktop/tests/updates/electron-updater/check-versions.mjs @@ -0,0 +1,9 @@ +/** Check every supported platform without starting Electron or making network requests. */ +import { installedVersions, readVerifiedVersions, versionFailures } from './versions.mjs' + +const installed = installedVersions() +const failures = versionFailures(await readVerifiedVersions(), installed) +if (failures.length) { + throw new Error(`Unverified Electron updater dependencies for: ${failures.join(', ')}. Installed: ${JSON.stringify(installed)}. On each listed platform run pnpm --filter @deepseek-ai/dsh-desktop run test:updates:channels --record, then commit verified-versions.json.`) +} +console.log('PASS: Electron updater dependency versions match all platform records') diff --git a/apps/desktop/tests/updates/electron-updater/qualification.mjs b/apps/desktop/tests/updates/electron-updater/qualification.mjs new file mode 100644 index 0000000000..c8243d5480 --- /dev/null +++ b/apps/desktop/tests/updates/electron-updater/qualification.mjs @@ -0,0 +1,132 @@ +/** Real updater channel/cache qualification with inert files; no installer is executed. */ +import assert from 'node:assert/strict' +import { createServer } from 'node:http' +import { once } from 'node:events' +import { writeFile, readFile, access } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { join } from 'node:path' +import { app } from 'electron' +import updaterModule from 'electron-updater' +import { selectUpdateChannel } from '../../../lib/types/updates/electron-updater/channel.js' +import { discardDesktopUpdate } from '../../../lib/types/updates/electron-updater/discard.js' +import { DesktopUpdateHttpExecutor } from '../../../lib/types/updates/electron-updater/http-executor.js' + +async function main() { + await app.whenReady() + const root = process.env.DSH_CHANNEL_TEST_ROOT + assert.ok(root) + const requests = [] + let percentage + let version = '2.0.0' + let payload = Buffer.from('inert channel-switch qualification payload') + let sha512 = createHash('sha512').update(payload).digest('base64') + const server = createServer((request, response) => { + const path = request.url.split('?')[0] + requests.push(path) + if (path === '/inert.exe' || path === '/inert.zip') { + response.writeHead(200, { 'Content-Length': payload.length }) + response.end(payload) + return + } + response.end(JSON.stringify({ version, stagingPercentage: percentage, + files: ['inert.exe', 'inert.zip'].map(url => ({ url, sha512, size: payload.length })) })) + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const origin = `http://127.0.0.1:${server.address().port}/` + const forbidden = () => { throw new Error('Installer execution is forbidden') } + let ownedUpdater + try { + const config = join(root, 'app-update.yml') + await writeFile(config, 'updaterCacheDirName: cache\n') + const adapter = { version: '1.0.0', name: 'channel-test', isPackaged: true, + appUpdateConfigPath: config, userDataPath: root, baseCachePath: root, + whenReady: async () => {}, quit: forbidden, relaunch: forbidden, onQuit: forbidden } + const Target = process.platform === 'darwin' ? updaterModule.MacUpdater : updaterModule.NsisUpdater + const target = new Target(undefined, adapter) + ownedUpdater = target + target.logger = null + target.autoDownload = false + target.autoInstallOnAppQuit = false + target.disableDifferentialDownload = true + target.httpExecutor = new DesktopUpdateHttpExecutor(5000) + target.setFeedURL({ provider: 'generic', url: origin }) + target.quitAndInstall = forbidden + target.on('error', () => {}) // Awaited check/download promises report failures. + selectUpdateChannel(target, 'nightly') + assert.ok((await target.checkForUpdates()).updateInfo) + assert.equal(requests.at(-1), `/nightly${process.platform === 'darwin' ? '-mac' : ''}.yml`) + selectUpdateChannel(target, 'stable') + await target.checkForUpdates() + assert.equal(requests.at(-1), `/stable${process.platform === 'darwin' ? '-mac' : ''}.yml`) + assert.equal(target.allowDowngrade, false) + assert.equal(target.allowPrerelease, false) + version = '0.9.0' + await discardDesktopUpdate(target) + await target.checkForUpdates() + assert.equal(target.updateInfoAndProvider, null) + version = '2.0.0' + percentage = 0 + await discardDesktopUpdate(target) + await target.checkForUpdates() + assert.equal(target.updateInfoAndProvider, null) + percentage = 100 + selectUpdateChannel(target, 'nightly') + await target.checkForUpdates() + assert.ok(target.updateInfoAndProvider) + assert.equal(target.allowDowngrade, false) + assert.equal(await target.isUserWithinRollout({ version: '2.0.0', stagingPercentage: 50 }), + await target.isUserWithinRollout({ version: '2.0.0', stagingPercentage: 50 })) + await target.downloadUpdate() + const helper = target.downloadedUpdateHelper + const file = helper.file + assert.ok(file) + assert.deepEqual(await readFile(file), payload) + const pending = helper.cacheDirForPendingUpdate + const cachedInfo = await readFile(join(pending, 'update-info.json')) + const oldPayload = Buffer.from(payload) + const proxy = target.server + if (process.platform === 'darwin') assert.equal(proxy.listening, true) + await discardDesktopUpdate(target) + selectUpdateChannel(target, 'stable') + if (proxy) assert.equal(proxy.listening, false) + assert.equal(helper.file, null) + assert.equal(helper.packageFile, null) + assert.equal(target.updateInfoAndProvider, null) + await assert.rejects(access(file), { code: 'ENOENT' }) + await assert.rejects(target.downloadUpdate()) + // A later process can encounter files that a previous process could not remove. + await writeFile(file, oldPayload) + await writeFile(join(pending, 'update-info.json'), cachedInfo) + version = '2.1.0' + payload = Buffer.from('different release after obsolete cache survived') + sha512 = createHash('sha512').update(payload).digest('base64') + const recovered = new Target(undefined, adapter) + ownedUpdater = recovered + recovered.logger = null + recovered.autoDownload = false + recovered.autoInstallOnAppQuit = false + recovered.disableDifferentialDownload = true + recovered.httpExecutor = new DesktopUpdateHttpExecutor(5000) + recovered.setFeedURL({ provider: 'generic', url: origin }) + recovered.quitAndInstall = forbidden + recovered.on('error', () => {}) + selectUpdateChannel(recovered, 'stable') + await recovered.checkForUpdates() + await recovered.downloadUpdate() + assert.deepEqual(await readFile(recovered.downloadedUpdateHelper.file), payload) + assert.equal(recovered.downloadedUpdateHelper.versionInfo.version, '2.1.0') + console.log('PASS: obsolete cache rejected by a new updater instance; current payload verified') + console.log('PASS: real provider channel switching, rollout eligibility, real downloads, native platform cache and proxy retirement') + } finally { + try { + if (ownedUpdater) await discardDesktopUpdate(ownedUpdater) + } finally { + server.closeAllConnections() + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) + } + + } + +} +void main().then(() => app.exit(0), error => { console.error(error); app.exit(1) }) diff --git a/apps/desktop/tests/updates/electron-updater/verified-versions.json b/apps/desktop/tests/updates/electron-updater/verified-versions.json new file mode 100644 index 0000000000..dfe3835ffd --- /dev/null +++ b/apps/desktop/tests/updates/electron-updater/verified-versions.json @@ -0,0 +1,12 @@ +{ + "windows": { + "electron": "44.7.0", + "electron-updater": "6.8.9", + "builder-util-runtime": "9.7.0" + }, + "macos": { + "electron": "44.7.0", + "electron-updater": "6.8.9", + "builder-util-runtime": "9.7.0" + } +} diff --git a/apps/desktop/tests/updates/electron-updater/versions.mjs b/apps/desktop/tests/updates/electron-updater/versions.mjs new file mode 100644 index 0000000000..726a6956f5 --- /dev/null +++ b/apps/desktop/tests/updates/electron-updater/versions.mjs @@ -0,0 +1,53 @@ +/** Installed dependency versions and platform-specific qualification records. */ +import { createRequire } from 'node:module' +import { readFile, writeFile, rename, rm } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' + +const require = createRequire(import.meta.url) +const recordUrl = new URL('./verified-versions.json', import.meta.url) + +/** @returns The actual installed versions, without loading the Electron binary. */ +export function installedVersions() { + const updaterRequire = createRequire(require.resolve('electron-updater/package.json')) + return { + electron: require('electron/package.json').version, + 'electron-updater': require('electron-updater/package.json').version, + 'builder-util-runtime': updaterRequire('builder-util-runtime/package.json').version, + } +} + +/** + * @param records - Parsed platform records. + * @param installed - Actual installed dependency versions. + * @returns Missing or mismatched platform records. + */ +export function versionFailures(records, installed) { + return ['windows', 'macos'].filter(platform => { + const record = records?.[platform] + return !record || Object.keys(record).length !== Object.keys(installed).length + || Object.entries(installed).some(([name, version]) => record[name] !== version) + }) +} + +/** @returns Persisted platform qualification records. */ +export async function readVerifiedVersions() { + return JSON.parse(await readFile(recordUrl, 'utf8')) +} + +/** + * Record only the current platform after successful native qualification. + * @returns After atomically replacing the record file. + */ +export async function recordVerifiedVersions() { + const platform = { win32: 'windows', darwin: 'macos' }[process.platform] + if (!platform) throw new Error('Qualification records require Windows or macOS') + const records = await readVerifiedVersions() + records[platform] = installedVersions() + const temporary = new URL(`./verified-versions.${randomUUID()}.tmp`, import.meta.url) + try { + await writeFile(temporary, `${JSON.stringify(records, null, 2)}\n`, { flag: 'wx' }) + await rename(temporary, recordUrl) + } finally { + await rm(temporary, { force: true }) + } +} diff --git a/apps/desktop/tests/updates/electron-updater/versions.test.mjs b/apps/desktop/tests/updates/electron-updater/versions.test.mjs new file mode 100644 index 0000000000..b2dc113fe5 --- /dev/null +++ b/apps/desktop/tests/updates/electron-updater/versions.test.mjs @@ -0,0 +1,18 @@ +/** Negative controls for the platform version gate. */ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { versionFailures, installedVersions } from './versions.mjs' + +test('accepts only matching complete records for both platforms', () => { + const installed = installedVersions() + assert.deepEqual(versionFailures({ windows: installed, macos: installed }, installed), []) + assert.deepEqual(versionFailures({ windows: installed, macos: null }, installed), ['macos']) + assert.deepEqual(versionFailures({}, installed), ['windows', 'macos']) + for (const name of Object.keys(installed)) { + assert.deepEqual(versionFailures({ windows: { ...installed, [name]: '0.0.0' }, macos: installed }, installed), ['windows']) + const missing = { ...installed } + delete missing[name] + assert.deepEqual(versionFailures({ windows: installed, macos: missing }, installed), ['macos']) + } + assert.deepEqual(versionFailures({ windows: { ...installed, obsolete: '1' }, macos: installed }, installed), ['windows']) +}) diff --git a/apps/desktop/tests/windows-runner-network-process.ps1 b/apps/desktop/tests/windows-runner-network-process.ps1 index 7b8fe0887b..b90c29cc95 100644 --- a/apps/desktop/tests/windows-runner-network-process.ps1 +++ b/apps/desktop/tests/windows-runner-network-process.ps1 @@ -45,13 +45,19 @@ Write-Output 'RUNNER-STDOUT' for ($iteration = 0; $iteration -lt 2; $iteration++) { $previous = [int](Get-Content (Join-Path $network 'child-pid')) $child = Get-Process -Id $previous - try { $child.Kill(); $child.WaitForExit() } finally { $child.Dispose() } - $deadline = [datetime]::UtcNow.AddSeconds(30) - do { - if ([datetime]::UtcNow -ge $deadline) { throw 'Network process was not recovered.' } - Start-Sleep -Milliseconds 50 - $next = Get-Content (Join-Path $network 'child-pid') -Raw - } while (-not $next -or [int]$next -eq $previous) + try { + # Retain the terminated process handle so Windows cannot recycle its PID before recovery is observed. + $null = $child.Handle + $child.Kill() + $child.WaitForExit() + $deadline = [datetime]::UtcNow.AddSeconds(30) + $next = $null + do { + if ([datetime]::UtcNow -ge $deadline) { throw "Network process was not recovered (iteration $iteration, previous PID $previous, observed PID $next)." } + Start-Sleep -Milliseconds 50 + $next = Get-Content (Join-Path $network 'child-pid') -Raw + } while (-not $next -or [int]$next -eq $previous) + } finally { $child.Dispose() } if ([int](Get-Content (Join-Path $PSScriptRoot 'listened')) -ne $PID) { throw 'Runner was replaced during recovery.' } } Write-Output 'RUNNER-SURVIVED-RECOVERY' diff --git a/apps/desktop/tsconfig.host.json b/apps/desktop/tsconfig.host.json index 7078ac8198..2701caf5a6 100644 --- a/apps/desktop/tsconfig.host.json +++ b/apps/desktop/tsconfig.host.json @@ -41,6 +41,9 @@ { "path": "../../packages/util/file-drag" }, + { + "path": "../../packages/client/ui-settings-general/tsconfig.host.json" + }, { "path": "../../packages/client/ui-file-actions" } diff --git a/apps/plugin-market-generator/package.json b/apps/plugin-market-generator/package.json index a426b81646..133e2d2931 100644 --- a/apps/plugin-market-generator/package.json +++ b/apps/plugin-market-generator/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plugin-market-generator", "description": "Reference generator of plugin market index files from a list of npm packages and Git repositories", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "private": true, "type": "module", "scripts": { diff --git a/apps/web/package.json b/apps/web/package.json index b011aa916d..8d48aff419 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-frontend", "description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/apps/web/tests/README.i18n.yaml b/apps/web/tests/README.i18n.yaml index e314a707ba..068cc2c07e 100644 --- a/apps/web/tests/README.i18n.yaml +++ b/apps/web/tests/README.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/web/tests/README.md /apps-web-browser-e2e: - en: 2deef0df72a6465c - zh: b24989ffb2d8586a + en: 88e00180b920a42e + zh: 8ddba0f4bbb4f1f6 /apps-web-browser-e2e/completion-observations: en: 8c139966983959cc zh: 54f09f9b7ac84cda diff --git a/apps/web/tests/README.md b/apps/web/tests/README.md index 969cf3a965..be2467e2d1 100644 --- a/apps/web/tests/README.md +++ b/apps/web/tests/README.md @@ -16,6 +16,8 @@ Ordinary scenarios begin with no registered Workspace or Session and a durable m Same-day message goldens use the shared `WEB_FIXTURE_TIME`: seeded transcripts pass it as `createdAt` and pin the browser calendar with `pinBrowserClock`; scenarios that submit live messages also use `pinHostClock` and restore it after teardown. These clocks advance with real elapsed time while keeping native timers intact, so midnight does not change a scenario's date labels. +Every scenario requires the whole client composition to activate. The product boot only warns about inactive entries outside its required list, but the `watchConsole` tripwire, the [real-host smoke](smoke-real.e2e.ts), and the [assembled jsdom boot](assembled-boot.ts) treat a `web boot: warning` as a failure. No scenario option relaxes this: an inactive plugin in a product composition is a defect to fix. + ## Completion observations State-sensitive cases use Workspace, admission, attachment, and model-stream barriers to separate visible intermediate states from completed operations. Model-picker persistence assertions wait for the saved default, independently of menu closure. Details close waits for frame transitions; archive verification assigns an explicit title to the seeded Session and follows that identity across reload. The [CI test reliability workflow](../../../.agents/skills/dsh-ci-test-reliability/SKILL.md#synchronize-on-state) owns these synchronization rules. diff --git a/apps/web/tests/README.zh.md b/apps/web/tests/README.zh.md index 29234f33c6..317725a2dc 100644 --- a/apps/web/tests/README.zh.md +++ b/apps/web/tests/README.zh.md @@ -16,6 +16,8 @@ pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps 当天消息的 golden 使用共享的 `WEB_FIXTURE_TIME`:预置会话将它传为 `createdAt`,并通过 `pinBrowserClock` 固定浏览器日期;提交实时消息的场景还使用 `pinHostClock`,在清理后恢复。固定后的时钟随真实耗时前进,保留原生定时器,因此跨午夜不会改变场景的日期标签。 +每个场景都要求整个客户端组合全部激活。产品启动只对必需名单外的未激活 entry 告警,但 `watchConsole` 绊线、[真实 Host 冒烟](smoke-real.e2e.ts)与[组装 jsdom 启动](assembled-boot.ts)把 `web boot: warning` 视为失败。没有任何场景选项放宽这一点:产品组合中出现未激活插件就是需要修复的缺陷。 + ## 完成状态观察 依赖状态的用例使用 Workspace、接纳、附件和模型流屏障,区分可见中间状态与已完成操作。模型选择器的持久化断言等待默认设置保存完成,不以菜单关闭作为完成信号。详情关闭等待框架过渡结束;归档验证为 seed Session 设置显式标题,并跨重载跟踪该身份。[CI 测试可靠性工作流](../../../.agents/skills/dsh-ci-test-reliability/SKILL.md#synchronize-on-state)负责这些同步规则。 diff --git a/apps/web/tests/additional-locales.e2e.ts b/apps/web/tests/additional-locales.e2e.ts index 007aab3e14..2cd04ce30a 100644 --- a/apps/web/tests/additional-locales.e2e.ts +++ b/apps/web/tests/additional-locales.e2e.ts @@ -59,3 +59,37 @@ it.each(languages)('selects %s from the browser, localizes metadata and persists expect(tripwire.warnings).toEqual([]) expect(tripwire.pageErrors).toEqual([]) }) + +const forgePreviews = [ + ['de', 'de-DE', 'Plugins', 'Eine Unterhaltung mit verknüpftem GitHub-Pull-Request, Prüfungen, Reviews, Codeunterschieden und einem separaten Arbeitsbaum'], + ['es', 'es-MX', 'Complementos', 'Una conversación vinculada a un pull request de GitHub, con comprobaciones, revisiones, diferencias de código y un árbol de trabajo aislado'], + ['fr', 'fr-CA', 'Plugins', 'Une conversation liée à une pull request GitHub, avec vérifications, revues, différences de code et un arbre de travail isolé'], + ['ja', 'ja-JP', 'プラグイン', 'GitHub のプルリクエストに関連付けられた会話、チェックとレビューの状況、コードの差分、独立したワークツリー'], + ['pt', 'pt-BR', 'Plug-ins', 'Uma conversa vinculada a um pull request do GitHub, com verificações, revisões, diferenças de código e uma árvore de trabalho isolada'], + ['ru', 'ru-RU', 'Плагины', 'Беседа, связанная с pull request GitHub: проверки, рецензии, различия в коде и отдельное рабочее дерево'], +] as const + +it.each(forgePreviews)('reads the Forge preview in %s before activation', async (id, tag, plugins, alt) => { + const resources: { scaffold?: WebScaffold; browser?: Browser } = {} + onTestFinished(async () => { + try { await resources.browser?.close() } finally { await resources.scaffold?.close() } + }) + const scaffold = await launchWebScaffold() + resources.scaffold = scaffold + const browser = await chromium.launch() + resources.browser = browser + const page = await browser.newPage({ locale: tag, viewport: { width: 1440, height: 960 } }) + const tripwire = watchConsole(page) + onTestFailed(() => saveFailureShot(page, `forge-preview-${id}`)) + await page.goto(scaffold.authenticatedUrl) + await expect.poll(() => page.locator('html').getAttribute('lang')).toBe(id) + await page.getByRole('button', { name: plugins, exact: true }).click() + const card = page.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-forge-bundle"]') + expect(await card.getByRole('switch').getAttribute('aria-checked')).toBe('false') + await card.getByRole('button').click() + const image = page.locator('[data-detail-document]').getByRole('img', { name: alt, exact: true }) + await image.waitFor() + await image.evaluate(async (node: HTMLImageElement) => { await node.decode() }) + expect(await image.evaluate((node: HTMLImageElement) => [node.naturalWidth, node.naturalHeight])).toEqual([640, 300]) + expect(tripwire.pageErrors).toEqual([]) +}) diff --git a/apps/web/tests/anchored-panels.e2e.ts b/apps/web/tests/anchored-panels.e2e.ts new file mode 100644 index 0000000000..5770d228c7 --- /dev/null +++ b/apps/web/tests/anchored-panels.e2e.ts @@ -0,0 +1,78 @@ +/** Production styles keep oversized floating panels aligned with their triggers. */ +import { readFile } from 'node:fs/promises' +import { chromium, webkit } from 'playwright' +import { expect, it, onTestFinished } from 'vitest' + +const PANELS = [ + { file: 'client/ui-primitives/src/Menu.module.css', classes: 'list portal scrollable', align: 'start' }, + { file: 'client/ui-primitives/src/Menu.module.css', classes: 'list portal scrollable', align: 'end' }, + { file: 'client/ui-schedule/src/client/TaskMenu.module.css', classes: 'list portal scrollable', align: 'start' }, + { file: 'client/ui-schedule/src/client/TaskMenu.module.css', classes: 'list portal scrollable alignEnd', align: 'end' }, + { file: 'client/ui-schedule/src/client/PickerPopover.module.css', classes: 'panel', align: 'start' }, + { file: 'client/ui-schedule/src/client/ScheduleCatalogAction.module.css', classes: 'menu', align: 'start' }, + { file: 'client/ui-subagent/src/client/SubagentHeaderLineage.module.css', classes: 'menu', align: 'start' }, + { file: 'client/ui-approval/src/client/ApprovalPanel.module.css', classes: 'warningPopover', align: 'end' }, + { file: 'client/ui-jobs/src/client/JobListAction.module.css', classes: 'menu', align: 'start' }, + { file: 'client/ui-conversation/src/client/skeleton/ContextMeter.module.css', classes: 'panel', align: 'start' }, + { file: 'client/ui-chat/src/client/chat/stat-dialog.module.css', classes: 'panel', align: 'start' }, + { file: 'client/ui-sidebar-documentpreview/src/client/office/FontNotice.module.css', classes: 'panel', align: 'start' }, + { file: 'client/ui-plugin-manager/src/client/PluginManagerPage.module.css', classes: 'registry', align: 'end' }, + { file: 'experimental/session-inspector/src/client/views/inspector.module.css', classes: 'typeFilterPanel', align: 'start' }, +] as const + +it.each([{ name: 'Chromium', engine: chromium }, { name: 'WebKit', engine: webkit }])( + 'retains horizontal anchors when tall panels exhaust both sides ($name)', async ({ engine }) => { + const browser = await engine.launch() + onTestFinished(() => browser.close()) + const page = await browser.newPage({ viewport: { width: 1400, height: 600 } }) + for (const panel of PANELS) { + const css = await readFile(new URL(`../../../packages/${panel.file}`, import.meta.url), 'utf8') + // Only content volume is synthetic; placement and viewport limits come from each owner. + await page.setContent(` +
+
Long list
+
`) + const card = page.locator('#panel') + const horizontalError = () => card.evaluate((node, align) => { + const bounds = node.getBoundingClientRect() + const trigger = document.querySelector('#trigger')!.getBoundingClientRect() + return Math.abs(align === 'end' ? bounds.right - trigger.right : bounds.left - trigger.left) + }, panel.align) + await expect.poll(horizontalError, { message: `${engine.name()}: ${panel.file} (${panel.align})` }).toBeLessThan(1) + const bounds = await card.boundingBox() + expect(bounds).not.toBeNull() + expect(bounds!.y).toBeGreaterThanOrEqual(12) + expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(588) + expect(await page.locator('#scroller').evaluate(node => node.scrollHeight > node.clientHeight)).toBe(true) + + // Each opening mounts a new portal, without a previous native fallback choice. + await page.locator('#content').evaluate((node) => { node.style.height = '60px' }) + await card.evaluate((node) => { node.replaceWith(node.cloneNode(true)) }) + await expect.poll(() => card.evaluate(node => node.getBoundingClientRect().height)).toBeLessThan(120) + await expect.poll(horizontalError).toBeLessThan(1) + const short = await card.boundingBox() + expect(short!.y + short!.height <= 286 || short!.y >= 314).toBe(true) + + const opensAbove = /(?:ApprovalPanel|ContextMeter|stat-dialog)/.test(panel.file) + await page.locator('#trigger').evaluate((node, top) => { node.style.top = `${top}px` }, opensAbove ? 24 : 548) + await card.evaluate((node) => { node.replaceWith(node.cloneNode(true)) }) + await expect.poll(() => card.evaluate((node, above) => { + const rect = node.getBoundingClientRect() + return above ? rect.top >= 52 : rect.bottom <= 548 + }, opensAbove), { message: `${engine.name()}: flipped ${panel.file}` }).toBe(true) + + await page.setViewportSize({ width: 390, height: 600 }) + await page.locator('#trigger').evaluate((node) => { node.style.left = '145px'; node.style.top = '286px' }) + await page.locator('#content').evaluate((node) => { node.style.height = '2000px' }) + await expect.poll(() => card.evaluate((node) => { + const rect = node.getBoundingClientRect() + return rect.left >= 12 && rect.right <= 378 && rect.top >= 12 && rect.bottom <= 588 + }), { message: `${engine.name()}: constrained ${panel.file}` }).toBe(true) + await page.setViewportSize({ width: 1400, height: 600 }) + } + }) diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index 42f006222d..88dc7e9f94 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -196,13 +196,16 @@ class EventSourceStub { const win = window as FixtureWindow let unmount: (() => Promise) | undefined let mountedRemote: RemoteMock | undefined +/** Boot audit warnings of the current test; the product boot tolerates inactive optional entries, this lane does not. */ +let bootWarnings: string[] = [] /** * Register the per-test jsdom setup and teardown the assembled boot needs: * English pinned before boot so role/text locators stay deterministic across * localized component migrations (the newEnglishPage e2e convention), the * observers, font events, and frame callbacks jsdom lacks, and a full reset of the document, - * the boot globals, and the injected plugin styles afterwards. + * the boot globals, and the injected plugin styles afterwards. A boot audit warning about an + * inactive entry fails the test at teardown: every assembled composition must activate completely. */ export function installAssembledBootEnv(): void { // jsdom implements no scroll geometry: the trigger menu reveals its @@ -219,7 +222,15 @@ export function installAssembledBootEnv(): void { }) } let fontsDescriptor: PropertyDescriptor | undefined + let restoreWarn: (() => void) | undefined beforeEach(() => { + bootWarnings = [] + const warn = console.warn.bind(console) + const spy = vi.spyOn(console, 'warn').mockImplementation((message?: unknown, ...rest: unknown[]) => { + if (typeof message === 'string' && message.startsWith('web boot: warning')) bootWarnings.push(message) + warn(message, ...rest) + }) + restoreWarn = () => { spy.mockRestore() } fontsDescriptor = Object.getOwnPropertyDescriptor(document, 'fonts') Object.defineProperty(document, 'fonts', { configurable: true, value: new EventTarget() }) localStorage.clear() @@ -267,6 +278,9 @@ export function installAssembledBootEnv(): void { vi.unstubAllGlobals() if (fontsDescriptor === undefined) Reflect.deleteProperty(document, 'fonts') else Object.defineProperty(document, 'fonts', fontsDescriptor) + restoreWarn?.() + restoreWarn = undefined + if (bootWarnings.length > 0) failures.push(new Error(`assembled boot: every entry must activate\n${bootWarnings.join('\n')}`)) if (failures.length > 0) throw new AggregateError(failures, 'assembled boot teardown failed') }) } diff --git a/apps/web/tests/context-menus.e2e.ts b/apps/web/tests/context-menus.e2e.ts index 29489b6f0b..9b2c2b650f 100644 --- a/apps/web/tests/context-menus.e2e.ts +++ b/apps/web/tests/context-menus.e2e.ts @@ -335,27 +335,46 @@ describe.each(['MacIntel', 'Win32', 'Linux x86_64'])('web context menus on %s', expect(await page.locator('[data-document-preview]').getByText('SECOND FILE', { exact: true }).count()).toBe(1) expect(tripwire.pageErrors).toEqual([]) }) - it('keeps context-menu rows reachable after browser zoom', async () => { + it('keeps context-menu layout size during browser zoom and scrolls overflowing rows', async () => { const cdp = await page.context().newCDPSession(page) try { await page.setViewportSize({ width: 1291, height: 480 }) await page.locator('[data-row-key="session:context-menu-first"]').click({ button: 'right' }) const menu = page.getByRole('menu') await menu.waitFor() - // Desktop Chromium caps page scale at three; the short window makes the rows overflow at that scale. + const unzoomed = await menu.evaluate((node) => { + const rect = node.getBoundingClientRect() + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height } + }) await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 3 }) await expect.poll(() => page.evaluate(() => window.visualViewport!.scale)).toBe(3) - await expect.poll(() => menu.evaluate((node) => { + await expect.poll(() => menu.evaluate((node, before) => { + const rect = node.getBoundingClientRect() + return Math.max(Math.abs(rect.x - before.x), Math.abs(rect.y - before.y), + Math.abs(rect.width - before.width), Math.abs(rect.height - before.height)) + }, unzoomed)).toBeLessThan(1) + expect(await menu.evaluate((node) => { const view = window.visualViewport! - const box = node.getBoundingClientRect() - return Math.min(box.left - view.offsetLeft, box.top - view.offsetTop, - view.offsetLeft + view.width - box.right, view.offsetTop + view.height - box.bottom) - })).toBeGreaterThanOrEqual(11.5) + const rect = node.getBoundingClientRect() + return rect.width > view.width || rect.height > view.height + })).toBe(true) + + // A smaller layout viewport, rather than page zoom alone, makes this list overflow. + await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 1 }) + await page.setViewportSize({ width: 1291, height: 180 }) const rows = menu.locator('[role="presentation"]').first() + await expect.poll(() => rows.evaluate(node => node.scrollHeight > node.clientHeight)).toBe(true) + await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 3 }) expect(await rows.evaluate((node) => { node.scrollTop = node.scrollHeight return node.scrollTop > 0 })).toBe(true) + await expect.poll(() => rows.evaluate((node) => { + const last = [...node.querySelectorAll('[role="menuitem"]')].at(-1)! + const row = last.getBoundingClientRect() + const viewport = node.getBoundingClientRect() + return row.top >= viewport.top - 1 && row.bottom <= viewport.bottom + 1 + })).toBe(true) } finally { await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 1 }) await cdp.detach() diff --git a/apps/web/tests/credential-editor.e2e.ts b/apps/web/tests/credential-editor.e2e.ts deleted file mode 100644 index b9ee283aa4..0000000000 --- a/apps/web/tests/credential-editor.e2e.ts +++ /dev/null @@ -1,235 +0,0 @@ -/** Saved credentials and endpoints reach the assembled editor and provider requests. */ -import { once } from 'node:events' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { createServer } from 'node:http' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { chromium } from 'playwright' -import { expect, it, onTestFinished } from 'vitest' -import { BlockAssembler } from '@deepseek-ai/dsh-llm' -import { credentialRef } from '@deepseek-ai/dsh-credentials' -import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' -import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, webSnapshotMode } from './scaffold.ts' -import { openSettings, ZH_BROWSER_LOCALE } from './support.ts' - -it.each([false, true])('edits saved DeepSeek keys and restores environment detection (environment present: %s)', async (hasEnvironment) => { - const scaffold = await launchWebScaffold({ - deepSeekMissingCredential: true, - extraOverlayPath: fileURLToPath(new URL('./fixtures/credential-editor.patch.yml', import.meta.url)), - openInAppEnvironment: createLaunchEnvironmentSnapshot([{ - source: 'process', values: hasEnvironment ? { DEEPSEEK_API_KEY: 'fixture-environment-key' } : {}, - }]), - }) - try { - const browser = await chromium.launch() - try { - const page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE }) - const ref = credentialRef('DEEPSEEK_API_KEY') - await scaffold.ctx.credentials.set(ref, 'fixture-saved-deepseek-key') - await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await openSettings(page, 'zh') - const dialog = page.getByRole('dialog', { name: '设置' }) - await dialog.getByRole('button', { name: '模型', exact: true }).click() - const edit = dialog.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)', exact: true }) - await edit.click() - const key = dialog.getByLabel('API 密钥', { exact: true }) - await expect.poll(() => key.inputValue()).toBe('fixture-saved-deepseek-key') - expect(await key.getAttribute('type')).toBe('password') - await compareOrRefreshGolden( - fileURLToPath(new URL('./expected/credential-editor/saved-key.expected.md', import.meta.url)), - await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode(), - ) - await dialog.getByRole('button', { name: '显示密钥', exact: true }).click() - expect(await key.getAttribute('type')).toBe('text') - await key.fill('fixture-cancelled-key') - await dialog.getByRole('button', { name: '取消', exact: true }).click() - expect(await scaffold.ctx.credentials.readStored(ref)).toBe('fixture-saved-deepseek-key') - await edit.click() - await expect.poll(() => key.inputValue()).toBe('fixture-saved-deepseek-key') - expect(await key.getAttribute('type')).toBe('password') - await key.fill('fixture-replacement-key') - await dialog.getByRole('button', { name: '保存', exact: true }).click() - await dialog.getByText('已保存 DeepSeek (deepseek-official)。', { exact: true }).waitFor() - expect(await scaffold.ctx.credentials.readStored(ref)).toBe('fixture-replacement-key') - await page.reload({ waitUntil: 'load' }) - await openSettings(page, 'zh') - await dialog.getByRole('button', { name: '模型', exact: true }).click() - await edit.click() - await expect.poll(() => key.inputValue()).toBe('fixture-replacement-key') - await key.fill('') - expect(await key.getAttribute('placeholder')).toBe('留空使用环境认证(DEEPSEEK_API_KEY)') - await dialog.getByText('环境凭据状态尚未确认', { exact: true }).waitFor() - expect(await scaffold.ctx.credentials.readStored(ref)).toBe('fixture-replacement-key') - await dialog.getByRole('button', { name: '保存', exact: true }).click() - await dialog.getByText('已保存 DeepSeek (deepseek-official)。', { exact: true }).waitFor() - expect(await scaffold.ctx.credentials.readStored(ref)).toBeUndefined() - // A missing credential opens the first-run settings card automatically; - // an environment credential keeps an ordinary row whose editor is closed. - if (hasEnvironment) await edit.click() - await expect.poll(() => key.inputValue()).toBe('') - expect(await key.getAttribute('placeholder')).toBe('留空使用环境认证(DEEPSEEK_API_KEY)') - const hint = hasEnvironment - ? '已检测到环境变量(DEEPSEEK_API_KEY)' - : '未检测到环境变量。请填写 API 密钥,或设置环境变量 DEEPSEEK_API_KEY。' - const detection = dialog.getByText(hint, { exact: true }) - await detection.waitFor() - const colors = await detection.evaluate((element, present) => { - const label = element as HTMLElement - const actual = getComputedStyle(label).color - const previous = label.style.color - label.style.color = present ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-warn-label)' - const expected = getComputedStyle(label).color - label.style.color = previous - return { actual, expected } - }, hasEnvironment) - expect(colors.actual).toBe(colors.expected) - expect(await dialog.getByRole('button', { name: '显示密钥', exact: true }).count()).toBe(0) - expect((await scaffold.ctx.credentials.resolve(ref))?.source).toBe(hasEnvironment ? 'env' : undefined) - await compareOrRefreshGolden( - fileURLToPath(new URL(`./expected/credential-editor/${hasEnvironment ? 'detected' : 'missing'}-environment.expected.md`, import.meta.url)), - await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode(), - ) - } finally { await browser.close() } - } finally { await scaffold.close() } -}) - -/** Local provider endpoint recording the destination path and authentication actually received. */ -async function connectionProbe(messages: boolean) { - const calls: { path: string; key: string | undefined }[] = [] - const server = createServer((request, response) => { - request.on('end', () => { - const key = messages ? request.headers['x-api-key'] : request.headers.authorization - calls.push({ path: request.url ?? '', key: typeof key === 'string' ? key : undefined }) - const events = messages ? [ - { type: 'message_start', message: { id: 'probe', type: 'message', role: 'assistant', model: 'credential-editor-model', content: [], usage: { input_tokens: 1, output_tokens: 0 } } }, - { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, - { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'connection-ok' } }, - { type: 'content_block_stop', index: 0 }, - { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 1 } }, - { type: 'message_stop' }, - ] : [ - { choices: [{ delta: { role: 'assistant', content: 'connection-ok' }, index: 0, finish_reason: null }] }, - { choices: [{ delta: {}, index: 0, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1 } }, - ] - response.writeHead(200, { 'content-type': 'text/event-stream' }) - response.end(events.map(event => `${'type' in event ? `event: ${event.type}\n` : ''}data: ${JSON.stringify(event)}\n\n`).join('') - + (messages ? '' : 'data: [DONE]\n\n')) - }) - request.resume() - }) - onTestFinished(async () => { - await new Promise((resolve) => { server.close(() => { resolve() }); server.closeAllConnections() }) - }) - server.listen(0, '127.0.0.1') - await once(server, 'listening') - const address = server.address() - if (address === null || typeof address === 'string') throw new Error('connection probe did not bind TCP') - return { url: `http://127.0.0.1:${String(address.port)}`, calls } -} - -it.each([ - { provider: 'deepseek-official', ref: 'DEEPSEEK_API_KEY', messages: true }, - { provider: 'openai', ref: 'OPENAI_API_KEY', messages: false }, - { provider: 'anthropic', ref: 'ANTHROPIC_API_KEY', messages: true }, - { provider: 'credential-fixture', ref: 'CREDENTIAL_FIXTURE_API_KEY', messages: false }, -])('uses the saved endpoint and key on the wire for $provider, then restores fallbacks', async ({ provider, ref, messages }) => { - const ambient = await connectionProbe(messages) - const saved = await connectionProbe(messages) - const root = await mkdtemp(join(tmpdir(), 'dsh-credential-routing-')) - onTestFinished(() => rm(root, { recursive: true, force: true })) - const overlay = join(root, 'connection.patch.yml') - const models = [{ id: 'credential-editor-model' }] - const baseSuffix = provider === 'anthropic' ? '' : '/v1' - const operation = provider === 'anthropic' ? '/v1/messages?beta=true' : messages ? '/messages' : '/chat/completions' - const ambientURL = `${ambient.url}/ambient${baseSuffix}` - await writeFile(overlay, JSON.stringify([ - { id: 'llm-deepseek', config: { models } }, - ...provider === 'deepseek-official' ? [] : [{ - id: 'llm-pi-ai', config: { providers: { [provider]: { - api: messages ? 'anthropic-messages' : 'openai-completions', - baseURL: ambientURL, models, - ...provider === 'credential-fixture' ? {} : { apiKeyEnv: ref }, - } } }, - }], - ])) - const scaffold = await launchWebScaffold({ - deepSeekMissingCredential: true, - extraOverlayPath: overlay, - openInAppEnvironment: createLaunchEnvironmentSnapshot([{ - source: 'process', values: { - [ref]: 'fixture-environment-key', - DEEPSEEK_BASE_URL: ambientURL, - OPENAI_BASE_URL: ambientURL, - ANTHROPIC_BASE_URL: ambientURL, - }, - }]), - }) - try { - const browser = await chromium.launch() - try { - const page = await browser.newPage({ locale: ZH_BROWSER_LOCALE }) - await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await openSettings(page, 'zh') - const dialog = page.getByRole('dialog', { name: '设置' }) - await dialog.getByRole('button', { name: '模型', exact: true }).click() - const edit = dialog.getByRole('button', { - name: `编辑 ${provider === 'deepseek-official' ? 'DeepSeek (deepseek-official)' : provider}`, exact: true, - }) - const key = dialog.getByLabel('API 密钥', { exact: true }) - const endpoint = dialog.getByLabel('API 地址', { exact: true }) - const savedURL = `${saved.url}/configured${baseSuffix}` - const generate = async (): Promise => { - const assembler = new BlockAssembler() - for await (const chunk of scaffold.ctx.llm.stream({ provider, model: 'credential-editor-model', messages: [] })) assembler.push(chunk) - expect(assembler.message({ provider, model: 'credential-editor-model' }).content).toContainEqual({ type: 'text', text: 'connection-ok' }) - } - await edit.click() - await key.fill('fixture-saved-key') - await dialog.getByText('自定义设置', { exact: true }).click() - await endpoint.fill(savedURL) - await dialog.getByRole('button', { name: '保存', exact: true }).click() - await expect.poll(() => key.count()).toBe(0) - await generate() - expect(saved.calls).toEqual([{ path: `/configured${baseSuffix}${operation}`, key: messages ? 'fixture-saved-key' : 'Bearer fixture-saved-key' }]) - expect(ambient.calls).toEqual([]) - await page.reload({ waitUntil: 'load' }) - await openSettings(page, 'zh') - await dialog.getByRole('button', { name: '模型', exact: true }).click() - await edit.click() - expect(await key.inputValue()).toBe('fixture-saved-key') - expect(await key.getAttribute('type')).toBe('password') - await dialog.getByText('自定义设置', { exact: true }).click() - expect(await endpoint.inputValue()).toBe(savedURL) - await generate() - expect(saved.calls).toHaveLength(2) - expect(saved.calls[1]).toEqual(saved.calls[0]) - expect(ambient.calls).toEqual([]) - await key.fill('') - await dialog.getByRole('button', { name: '保存', exact: true }).click() - await expect.poll(() => key.count()).toBe(0) - await generate() - expect(saved.calls).toHaveLength(3) - expect(saved.calls[2]).toEqual({ path: `/configured${baseSuffix}${operation}`, key: messages ? 'fixture-environment-key' : 'Bearer fixture-environment-key' }) - expect(ambient.calls).toEqual([]) - await edit.click() - await key.fill('fixture-saved-key') - await dialog.getByText('自定义设置', { exact: true }).click() - await endpoint.fill('') - await dialog.getByRole('button', { name: '保存', exact: true }).click() - await expect.poll(() => key.count()).toBe(0) - await generate() - expect(saved.calls).toHaveLength(3) - expect(ambient.calls).toEqual([{ path: `/ambient${baseSuffix}${operation}`, key: messages ? 'fixture-saved-key' : 'Bearer fixture-saved-key' }]) - await edit.click() - await key.fill('') - await dialog.getByRole('button', { name: '保存', exact: true }).click() - await expect.poll(() => key.count()).toBe(0) - await generate() - expect(saved.calls).toHaveLength(3) - expect(ambient.calls[1]).toEqual({ path: `/ambient${baseSuffix}${operation}`, key: messages ? 'fixture-environment-key' : 'Bearer fixture-environment-key' }) - expect(ambient.calls).toHaveLength(2) - expect(await scaffold.ctx.credentials.readStored(credentialRef(ref))).toBeUndefined() - } finally { await browser.close() } - } finally { await scaffold.close() } -}) diff --git a/apps/web/tests/cua-permissions.e2e.ts b/apps/web/tests/cua-permissions.e2e.ts index d4c872ac7b..52578a412e 100644 --- a/apps/web/tests/cua-permissions.e2e.ts +++ b/apps/web/tests/cua-permissions.e2e.ts @@ -70,6 +70,79 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr expect(await approval.getByText('/Applications/Fixture.app', { exact: true }).count()).toBe(0) await compareOrRefreshGolden(fileURLToPath(new URL('./expected/cua-permissions/application-approval.expected.md', import.meta.url)), await captureStableAria(page, '[data-approval-key]', scaffold.workspaceCwd), webSnapshotMode()) + const risks = approval.getByRole('button', { name: 'Risks', exact: true }) + const warning = page.getByRole('dialog', { name: 'Computer use risks' }) + await risks.hover() + await warning.waitFor() + expect(await warning.evaluate(element => element === document.activeElement)).toBe(false) + await warning.hover() + expect(await warning.count()).toBe(1) + await approval.locator('p').hover() + await warning.waitFor({ state: 'hidden' }) + await risks.hover() + await warning.waitFor() + await approval.getByRole('button', { name: 'Reject', exact: true }).focus() + await warning.waitFor({ state: 'hidden' }) + await approval.locator('p').hover() + await risks.hover() + await warning.waitFor() + await page.keyboard.press('ControlOrMeta+Slash') + const shortcuts = page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true }) + await shortcuts.waitFor() + await warning.waitFor({ state: 'hidden' }) + expect(await shortcuts.evaluate(element => element.contains(document.activeElement))).toBe(true) + await page.keyboard.press('Escape') + await shortcuts.waitFor({ state: 'hidden' }) + expect(await approval.count()).toBe(1) + await approval.locator('p').hover() + for (const selectText of [false, true]) { + await risks.hover() + await warning.waitFor() + const paragraph = warning.locator('p').first() + if (selectText) { + await paragraph.dblclick({ position: { x: 8, y: 10 } }) + expect(await page.evaluate(() => window.getSelection()?.toString().trim().length ?? 0)).toBeGreaterThan(0) + } else { + await paragraph.click() + } + expect(await warning.evaluate(element => element === document.activeElement)).toBe(true) + await approval.locator('p').hover() + await warning.waitFor({ state: 'hidden' }) + } + await page.keyboard.press('Tab') + await risks.focus() + await warning.waitFor() + expect(await risks.evaluate(element => element === document.activeElement)).toBe(true) + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/cua-permissions/application-risks.expected.md', import.meta.url)), + await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode()) + await page.keyboard.press('Escape') + await warning.waitFor({ state: 'hidden' }) + expect(await approval.count()).toBe(1) + for (const { width, height } of [{ width: 1000, height: 800 }, { width: 390, height: 800 }, { width: 390, height: 480 }]) { + await page.setViewportSize({ width, height }) + for (const colorScheme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme }) + await expect.poll(() => page.evaluate(() => document.body.hasAttribute('data-ds-dark-theme'))).toBe(colorScheme === 'dark') + await risks.dispatchEvent('pointerdown', { pointerType: 'touch' }) + await risks.click() + await warning.waitFor() + await expect.poll(async () => warning.evaluate((element) => { + const bounds = element.getBoundingClientRect() + return bounds.left >= 12 && bounds.top >= 12 && bounds.right <= innerWidth - 12 && bounds.bottom <= innerHeight - 12 + })).toBe(true) + expect(await warning.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + expect(await approval.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + if (webSnapshotMode() === 'refresh') { + const directory = fileURLToPath(new URL('../../../.artifacts', import.meta.url)) + await mkdir(directory, { recursive: true }) + await page.screenshot({ path: join(directory, `cua-popover-${width}-${height}-${colorScheme}.png`), animations: 'disabled' }) + } + await page.mouse.click(4, height / 2) + await warning.waitFor({ state: 'hidden' }) + } + } + await page.emulateMedia({ colorScheme: null }) + await page.setViewportSize({ width: 1680, height: 1000 }) await approval.getByRole('button', { name: 'Always allow', exact: true }).click() expect((await grant).results).toMatchObject([{ result: { isError: false } }]) await approval.waitFor({ state: 'hidden' }) diff --git a/apps/web/tests/default-workspace.e2e.ts b/apps/web/tests/default-workspace.e2e.ts index 51ddce2312..9930040918 100644 --- a/apps/web/tests/default-workspace.e2e.ts +++ b/apps/web/tests/default-workspace.e2e.ts @@ -2,7 +2,7 @@ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import { chromium } from 'playwright' +import { chromium, webkit } from 'playwright' import { describe, expect, it, onTestFinished } from 'vitest' import { assertFinalWorkspaceSnapshot, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, @@ -36,8 +36,8 @@ describe.skipIf(MODE === 'record')('web e2e: default Workspace', () => { await input.waitFor() // Language-neutral on disk, localized in the hero chip. The chip's // aria-label is its fixed call to action, so the label is its text. - await expect.poll(() => page.getByRole('button', { name: 'Choose workspace', exact: true }).textContent()) - .toBe('Default workspace') + await page.getByRole('button', { name: 'Choose workspace', exact: true }) + .getByText('Default workspace', { exact: true }).waitFor() expect(scaffold.ctx.workspaceRegistry.list()).toHaveLength(1) const initialSession = scaffold.ctx.sessions.list()[0]! expect(scaffold.ctx.sessions.list()).toHaveLength(1) @@ -70,6 +70,30 @@ describe.skipIf(MODE === 'record')('web e2e: default Workspace', () => { } }) + it.each([{ name: 'Chromium', engine: chromium }, { name: 'WebKit', engine: webkit }])( + 'opens the default Workspace chooser from its external trigger ($name)', async ({ engine }) => { + const scaffold = await launchWebScaffold({ firstUse: true }) + onTestFinished(() => scaffold.close()) + const browser = await engine.launch() + onTestFinished(() => browser.close()) + const page = await browser.newPage({ + viewport: { width: 390, height: 844 }, locale: 'en-US', timezoneId: 'Asia/Shanghai', + isMobile: true, hasTouch: true, + }) + await page.goto(scaffold.authenticatedUrl) + const trigger = page.getByRole('button', { name: 'Choose workspace', exact: true }) + await trigger.getByText('Default workspace', { exact: true }).waitFor() + await trigger.tap() + const menu = page.getByRole('menu').filter({ has: page.getByRole('menuitem', { name: 'Default workspace', exact: true }) }) + await menu.waitFor({ state: 'visible', timeout: 10_000 }) + expect(await trigger.getAttribute('aria-expanded')).toBe('true') + expect(await trigger.evaluate(node => node.style.anchorName)).toBe(await menu.evaluate(node => node.style.positionAnchor)) + await page.keyboard.press('Escape') + await menu.waitFor({ state: 'hidden' }) + expect(await trigger.getAttribute('aria-expanded')).toBe('false') + }, + ) + it('reports a startup directory conflict and opens the composed folder picker for recovery', async () => { const scaffold = await launchWebScaffold({ firstUse: true }) onTestFinished(() => scaffold.close()) diff --git a/apps/web/tests/desktop-channel.e2e.ts b/apps/web/tests/desktop-channel.e2e.ts new file mode 100644 index 0000000000..a1f3f7cfff --- /dev/null +++ b/apps/web/tests/desktop-channel.e2e.ts @@ -0,0 +1,78 @@ +/** Built settings UI uses the shell file API; external edits wait for a new shell startup. */ +import { readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import { expect, it } from 'vitest' +import { DesktopUpdateSettings } from '../../desktop/src/update-settings.ts' +import type { DesktopChannelState, DesktopChannelChangeResult } from '../../desktop/src/ipc.ts' +import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode } from './scaffold.ts' +import { openSettings } from './support.ts' + +it('persists a Desktop channel from Settings and keeps external edits startup-only', async () => { + const scaffold = await launchWebScaffold({}) + try { + const browser = await chromium.launch() + try { + const settings = new DesktopUpdateSettings(join(scaffold.harnessHome, 'shell'), 'nightly') + await settings.save('nightly') + let state: DesktopChannelState = { channel: 'nightly', packagedChannel: 'nightly', changing: false } + const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, locale: 'en-US' }) + const tripwire = watchConsole(page) + let detailOpens = 0 + await page.exposeFunction('openUpdateDetails', () => { detailOpens++ }) + await page.exposeFunction('readChannel', () => state) + await page.exposeFunction('setChannel', async (channel: 'nightly' | 'stable'): Promise => { + try { + await settings.save(channel) + state = { ...state, channel, error: undefined } + return { kind: 'applied', state } + } catch (_error) { return { kind: 'failed', state, reason: 'format' } } + }) + await page.addInitScript(() => { + const bridge = globalThis as typeof globalThis & { + readChannel(): Promise + openUpdateDetails(): Promise + setChannel(channel: 'nightly' | 'stable'): Promise + } + Object.assign(globalThis, { dshDesktop: { protocolVersion: 1, updates: { + status: async () => ({ phase: 'idle' }), open: () => bridge.openUpdateDetails(), subscribe: () => () => {}, + channels: { get: () => bridge.readChannel(), set: (channel: 'nightly' | 'stable') => bridge.setChannel(channel), + subscribe: () => () => {} }, + } } }) + }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await openSettings(page, 'en') + const row = page.getByRole('group', { name: 'Update channel', exact: true }) + await row.getByText('Development', { exact: true }).waitFor() + await row.getByRole('button', { name: 'Update channel', exact: true }).click() + await page.getByRole('menuitem', { name: 'Stable', exact: true }).click() + await row.getByText('Stable', { exact: true }).waitFor() + await page.getByText('Update channel changed', { exact: true }).waitFor() + expect(JSON.parse(await readFile(settings.path, 'utf8'))).toMatchObject({ updates: { channel: 'stable' } }) + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/desktop-channel/stable.expected.md', import.meta.url)), + await captureStableAria(page, '[role="group"][aria-label="Update channel"]', scaffold.workspaceCwd), webSnapshotMode()) + await writeFile(settings.path, JSON.stringify({ updates: { channel: 'nightly' } })) + expect(await row.getByText('Stable', { exact: true }).count()).toBe(1) + expect(await settings.load()).toEqual({ kind: 'ready', channel: 'nightly' }) + await writeFile(settings.path, '{broken') + await row.getByRole('button', { name: 'Update channel', exact: true }).click() + await page.getByRole('menuitem', { name: 'Development', exact: true }).click() + await page.getByText('Could not save: invalid settings file', { exact: true }).waitFor() + await row.getByText('Stable', { exact: true }).waitFor() + expect(await row.getByText('Stable', { exact: true }).count()).toBe(1) + expect(await readFile(settings.path, 'utf8')).toBe('{broken') + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/desktop-channel/save-error.expected.md', import.meta.url)), + await captureStableAria(page, '[role="group"][aria-label="Update channel"]', scaffold.workspaceCwd) + '\n' + + await captureStableAria(page, '[role="alert"]', scaffold.workspaceCwd), webSnapshotMode()) + state = { ...state, error: 'format' } + await page.reload({ waitUntil: 'load' }) + await page.getByRole('alert').filter({ hasText: 'Settings file error; updates are paused' }).waitFor() + await page.getByRole('button', { name: 'View details', exact: true }).click() + await expect.poll(() => detailOpens).toBe(1) + expect(await readFile(settings.path, 'utf8')).toBe('{broken') + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } finally { await browser.close() } + } finally { await scaffold.close() } +}) diff --git a/apps/web/tests/desktop-updates.e2e.ts b/apps/web/tests/desktop-updates.e2e.ts index 87903d8050..ac596249b3 100644 --- a/apps/web/tests/desktop-updates.e2e.ts +++ b/apps/web/tests/desktop-updates.e2e.ts @@ -5,7 +5,6 @@ import { fileURLToPath } from 'node:url' import { chromium } from 'playwright' import { describe, expect, it } from 'vitest' import { presentDesktopUpdate } from '../../desktop/src/update-presentation.ts' -import { en } from '../../desktop/src/locale.ts' import { launchWebScaffold, watchConsole } from './scaffold.ts' // Mirrors the preload's presentation-only API; importing Client projects would mix compiler faces. @@ -53,8 +52,8 @@ describe('web e2e: Desktop update workspace chrome', () => { const errorDetail = locale === 'zh-CN' ? '下载更新失败,请重试。' : 'Could not download the update. Please try again.' const readyLabel = locale === 'zh-CN' ? '安装并重启' : 'Install and Restart' const version = '0.1.5-nightly.20260911' - // The carrier classification deliberately uses English shell copy; Web copy follows its own locale. - const available = presentDesktopUpdate({ phase: 'available', version }, en) + // The carrier exposes semantic states; Web copy follows its own locale. + const available = presentDesktopUpdate({ phase: 'available', version }) const publish = async (state: Presentation) => page.evaluate((value) => { (window as FixtureWindow).updateFixture.publish(value) }, state) @@ -71,7 +70,7 @@ describe('web e2e: Desktop update workspace chrome', () => { await update.click() await expect.poll(opens).toBe(1) - const progress = presentDesktopUpdate({ phase: 'downloading', version, percent: 58 }, en) + const progress = presentDesktopUpdate({ phase: 'downloading', version, percent: 58 }) await publish(progress) const downloading = page.getByRole('button', { name: '58%', exact: true }) await downloading.waitFor() @@ -93,7 +92,7 @@ describe('web e2e: Desktop update workspace chrome', () => { const updateBackground = await badge.evaluate(element => getComputedStyle(element).backgroundColor) await page.screenshot({ path: join(evidence, 'collapsed.png') }) - const error = presentDesktopUpdate({ phase: 'error', version, failedOperation: 'download', message: 'HTTP 503' }, en) + const error = presentDesktopUpdate({ phase: 'error', version, failedOperation: 'download', message: 'HTTP 503' }) await publish(error) const errorBadge = toggle.getByRole('img', { name: retryLabel, exact: true }) await errorBadge.waitFor() @@ -121,7 +120,7 @@ describe('web e2e: Desktop update workspace chrome', () => { await retry.click() await expect.poll(opens).toBe(2) - await publish(presentDesktopUpdate({ phase: 'ready', version }, en)) + await publish(presentDesktopUpdate({ phase: 'ready', version })) const ready = page.getByRole('button', { name: readyLabel, exact: true }) await ready.waitFor() expect(await ready.getAttribute('aria-disabled')).toBe('false') diff --git a/apps/web/tests/expected/cordis-history/ui.expected.md b/apps/web/tests/expected/cordis-history/ui.expected.md index 2b9508ea33..32e5cba5bc 100644 --- a/apps/web/tests/expected/cordis-history/ui.expected.md +++ b/apps/web/tests/expected/cordis-history/ui.expected.md @@ -15,6 +15,7 @@ - status: Completed - button "Completed in {{duration}}" [expanded] - button "Called tools" [expanded] +- button "Context injection runtime-context" - button "Think I will inspect the current Session's dynamic Cordis Plugins before defining the snapshot Package." - button "Inspect plugins 0 dynamic plugins" - button "Think No dynamic Plugins are present, so I will define the requested Host and Client Package." diff --git a/apps/web/tests/expected/credential-editor/detected-environment.expected.md b/apps/web/tests/expected/credential-editor/detected-environment.expected.md deleted file mode 100644 index 829eeedd37..0000000000 --- a/apps/web/tests/expected/credential-editor/detected-environment.expected.md +++ /dev/null @@ -1,25 +0,0 @@ -- dialog "设置": - - text: 设置 - - navigation: - - button "通用设置" - - button "模型" - - button "内置插件" - - button "Agent 预设" - - button "打开配置文件" - - button "关闭" - - heading "模型" [level=2] - - paragraph: 填入各提供商的 API 密钥即可使用其模型。 - - list: - - listitem: - - text: DeepSeek - - img "API 密钥已配置" - - button "编辑 DeepSeek (deepseek-official)": 编辑 - - text: DeepSeek deepseek-official API 密钥 - - button "认证说明": "?" - - textbox "API 密钥": - - /placeholder: 留空使用环境认证(DEEPSEEK_API_KEY) - - paragraph: 已检测到环境变量(DEEPSEEK_API_KEY) - - group: 自定义设置 - - button "取消" - - button "保存" - - button "添加模型提供商" diff --git a/apps/web/tests/expected/credential-editor/missing-environment.expected.md b/apps/web/tests/expected/credential-editor/missing-environment.expected.md deleted file mode 100644 index 609ac50d0e..0000000000 --- a/apps/web/tests/expected/credential-editor/missing-environment.expected.md +++ /dev/null @@ -1,23 +0,0 @@ -- dialog "设置": - - text: 设置 - - navigation: - - button "通用设置" - - button "模型" - - button "内置插件" - - button "Agent 预设" - - button "打开配置文件" - - button "关闭" - - heading "模型" [level=2] - - paragraph: 填入各提供商的 API 密钥即可使用其模型。 - - status: 已保存 DeepSeek (deepseek-official)。 - - list: - - listitem: - - text: DeepSeek deepseek-official API 密钥 - - button "认证说明": "?" - - textbox "API 密钥": - - /placeholder: 留空使用环境认证(DEEPSEEK_API_KEY) - - paragraph: 未检测到环境变量。请填写 API 密钥,或设置环境变量 DEEPSEEK_API_KEY。 - - group: 自定义设置 - - button "取消" - - button "保存" - - button "添加模型提供商" diff --git a/apps/web/tests/expected/credential-editor/saved-key.expected.md b/apps/web/tests/expected/credential-editor/saved-key.expected.md deleted file mode 100644 index 2d4dde4639..0000000000 --- a/apps/web/tests/expected/credential-editor/saved-key.expected.md +++ /dev/null @@ -1,26 +0,0 @@ -- dialog "设置": - - text: 设置 - - navigation: - - button "通用设置" - - button "模型" - - button "内置插件" - - button "Agent 预设" - - button "打开配置文件" - - button "关闭" - - heading "模型" [level=2] - - paragraph: 填入各提供商的 API 密钥即可使用其模型。 - - list: - - listitem: - - text: DeepSeek - - img "API 密钥已配置" - - button "编辑 DeepSeek (deepseek-official)": 编辑 - - text: DeepSeek deepseek-official API 密钥 - - button "认证说明": "?" - - textbox "API 密钥": - - /placeholder: 留空使用环境认证(DEEPSEEK_API_KEY) - - text: fixture-saved-deepseek-key - - button "显示密钥" - - group: 自定义设置 - - button "取消" - - button "保存" - - button "添加模型提供商" diff --git a/apps/web/tests/expected/cua-permissions/application-approval.expected.md b/apps/web/tests/expected/cua-permissions/application-approval.expected.md index 74989b3ae3..6ef8b2863f 100644 --- a/apps/web/tests/expected/cua-permissions/application-approval.expected.md +++ b/apps/web/tests/expected/cua-permissions/application-approval.expected.md @@ -1,8 +1,9 @@ - text: Waiting for approval - group "Approval details": - strong: Fixture - - text: Allow computer use to launch, read, capture and control this application? - - paragraph: Allowing or denying applies to every version and copy sharing the same application identity. Session access and denial apply only to this conversation. + - text: Allow agent to control this application via computer use? + - button "Risks" + - paragraph: Manage app permissions for all sessions on the plugin page. - button "Reject" - button "Allow for this session" - button "Always allow" diff --git a/apps/web/tests/expected/cua-permissions/application-risks.expected.md b/apps/web/tests/expected/cua-permissions/application-risks.expected.md new file mode 100644 index 0000000000..f098f19080 --- /dev/null +++ b/apps/web/tests/expected/cua-permissions/application-risks.expected.md @@ -0,0 +1,5 @@ +- dialog "Computer use risks": + - heading "Computer use risks" [level=3] + - paragraph: The agent can access only apps you approve, and can read and control them in the background. + - paragraph: Online games, messaging apps, and other apps may restrict automation. Violating their rules could result in your account with that app being suspended or banned. + - paragraph: Proceed at your own risk. To stop automated control at any time, press Stop in DeepSeek Harness. diff --git a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md index d4fa1c31fb..08a0750772 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md @@ -17,9 +17,7 @@ - text: DeepSeek deepseek-official API 密钥 - button "认证说明": "?" - textbox "API 密钥": - - /placeholder: 留空使用环境认证(DEEPSEEK_API_KEY) - - text: sk-messages-onboarding - - button "显示密钥" + - /placeholder: 留空使用已保存凭据 - group: - text: 自定义设置 API 地址 - textbox "API 地址": diff --git a/apps/web/tests/expected/desktop-channel/save-error.expected.md b/apps/web/tests/expected/desktop-channel/save-error.expected.md new file mode 100644 index 0000000000..00b1838755 --- /dev/null +++ b/apps/web/tests/expected/desktop-channel/save-error.expected.md @@ -0,0 +1,4 @@ +- group "Update channel": + - text: Update channel Stable is suitable for everyday use. Development offers early access to new features but may be unstable. + - button "Update channel": Stable +- alert: "Could not save: invalid settings file" diff --git a/apps/web/tests/expected/desktop-channel/stable.expected.md b/apps/web/tests/expected/desktop-channel/stable.expected.md new file mode 100644 index 0000000000..4f93fab271 --- /dev/null +++ b/apps/web/tests/expected/desktop-channel/stable.expected.md @@ -0,0 +1,3 @@ +- group "Update channel": + - text: Update channel Stable is suitable for everyday use. Development offers early access to new features but may be unstable. + - button "Update channel": Stable diff --git a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md index 19a00db120..5d29a1bb88 100644 --- a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md @@ -21,7 +21,10 @@ - text: GitHub event received - time: {{clock}} - status: Completed -- button "Completed in {{duration}}" [disabled] +- button "Completed in {{duration}}" [expanded] +- button "Injected context" [expanded] +- button "Context injection runtime-context" +- button "Context injection time-context" - paragraph: "Review complete: no actionable findings." - button "Copy" - button "Good response" diff --git a/apps/web/tests/expected/github-ready-review/conversation.expected.md b/apps/web/tests/expected/github-ready-review/conversation.expected.md index 19a00db120..9b10e08416 100644 --- a/apps/web/tests/expected/github-ready-review/conversation.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation.expected.md @@ -21,7 +21,7 @@ - text: GitHub event received - time: {{clock}} - status: Completed -- button "Completed in {{duration}}" [disabled] +- button "Completed in {{duration}}" - paragraph: "Review complete: no actionable findings." - button "Copy" - button "Good response" diff --git a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md index 018ea8a13c..03900f109b 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md @@ -17,9 +17,7 @@ - text: DeepSeek deepseek-official API 密钥 - button "认证说明": "?" - textbox "API 密钥": - - /placeholder: 留空使用环境认证(DEEPSEEK_API_KEY) - - text: dsh_onboarding_fixture_key - - button "显示密钥" + - /placeholder: 留空使用已保存凭据 - group: - text: 自定义设置 API 地址 - textbox "API 地址": diff --git a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md index a5811b5d66..27ba737236 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md @@ -17,9 +17,7 @@ - text: DeepSeek deepseek-official API 密钥 - button "认证说明": "?" - textbox "API 密钥": - - /placeholder: 留空使用环境认证(DEEPSEEK_API_KEY) - - text: dsh_onboarding_fixture_key - - button "显示密钥" + - /placeholder: 留空使用已保存凭据 - group: - text: 自定义设置 API 地址 - textbox "API 地址": diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md index bdf3d7033a..0d4f6919d6 100644 --- a/apps/web/tests/expected/plugin-config/official.expected.md +++ b/apps/web/tests/expected/plugin-config/official.expected.md @@ -11,8 +11,8 @@ - button "查看 子智能体": 子智能体 - text: 设置子智能体的递归层级、数量和模型。 - listitem: - - button "查看 网页搜索": 网页搜索 - - text: 设置 DeepSeek 的搜索提供方。 + - button "查看 网页搜索与获取": 网页搜索与获取 + - text: 选择网页搜索与网页获取使用的提供方。 - listitem: - button "查看 Agent 循环": Agent 循环 - text: 控制 Agent 派发工具调用的方式。 @@ -39,7 +39,7 @@ - switch "启用 自动授权审查" - listitem: - button "查看 变更请求与工作树": 变更请求与工作树 - - text: 实验性 跟踪每个会话的变更请求及其检查与评审,新会话也可以在 Git 工作树中开始;GitHub 通过 gh 命令行访问。 + - text: 实验性 在会话侧栏集中跟踪关联的 GitHub PR:查看检查状态、评审意见与代码差异,并把修改建议交给智能体继续处理。也可查看本地改动,用独立工作树隔离任务。GitHub 功能需要本机安装 gh 并登录。 - status - switch "启用 变更请求与工作树" - heading "已安装插件" [level=3] diff --git a/apps/web/tests/expected/plugin-config/web-search.expected.md b/apps/web/tests/expected/plugin-config/web-search.expected.md index a1c912fcd4..7f725a72e5 100644 --- a/apps/web/tests/expected/plugin-config/web-search.expected.md +++ b/apps/web/tests/expected/plugin-config/web-search.expected.md @@ -1,24 +1,20 @@ - button "返回插件": 插件 -- heading "网页搜索" [level=3] -- paragraph: 设置 DeepSeek 的搜索提供方。 -- text: 启用新版 Web 搜索 API -- switch "启用新版 Web 搜索 API" [checked] -- paragraph: - - text: 默认关闭,搜索通过 DeepSeek Messages 完成。新版 Web 搜索 API 功能与计费详见 - - link "网页搜索与抓取指南": - - /url: https://api-docs.deepseek.com/zh-cn/guides/search -- text: API Key 未配置密钥;仅使用 DeepSeek 账号模型的对话可以通过默认接口地址搜索。 -- textbox "API Key" -- paragraph: 不写入设置文件。留空表示保持当前密钥。 -- text: Search / Fetch API 基础地址 已覆盖 -- button "恢复默认" -- textbox "Search / Fetch API 基础地址": - - /placeholder: "" - - text: https://search-api.test/api/v1/tools -- paragraph: 填写含 API 前缀的地址,例如 https://api.deepseek.com/v1/tools;程序追加 /search 或 /fetch,留空恢复默认。 -- text: 每次搜索结果数 -- textbox "每次搜索结果数": - - /placeholder: "" - - text: "10" -- paragraph: 每个查询返回的结果条数,须为 1–50 的整数,默认 10;不控制搜索调用次数。web_search 保留的来源数不超过其自身的结果上限。 +- heading "网页搜索与获取" [level=3] +- paragraph: 选择网页搜索与网页获取使用的提供方。 +- region "网页搜索提供方": + - heading "网页搜索提供方" [level=4] + - button "网页搜索提供方 DeepSeek Search API": DeepSeek Search API + - paragraph: 直接请求搜索接口返回标题、URL 与摘要,无需额外调用模型。使用当前会话的 DeepSeek 账号或 DeepSeek API Key,可能产生计费 + - paragraph: + - link "网页搜索与获取指南": + - /url: https://api-docs.deepseek.com/zh-cn/guides/search + - text: 每次搜索结果数 + - textbox "每次搜索结果数": + - /placeholder: "" + - text: "10" + - paragraph: 每个查询返回的结果条数,须为 1–50 的整数,默认 10;不控制搜索调用次数。web_search 保留的来源数不超过其自身的结果上限。 +- region "网页获取提供方": + - heading "网页获取提供方" [level=4] + - button "网页获取提供方 DeepSeek Fetch API": DeepSeek Fetch API + - paragraph: 由 DeepSeek 服务端抓取公网页面并返回 Markdown。使用当前会话的 DeepSeek 账号或 DeepSeek API Key,可能产生计费,且无法访问内网和本机 - button "保存" [disabled] diff --git a/apps/web/tests/expected/plugin-manager/forge-preview.expected.md b/apps/web/tests/expected/plugin-manager/forge-preview.expected.md new file mode 100644 index 0000000000..afcab828fc --- /dev/null +++ b/apps/web/tests/expected/plugin-manager/forge-preview.expected.md @@ -0,0 +1,54 @@ +- button "返回插件": 插件 +- status +- switch "启用 变更请求与工作树" +- heading "变更请求与工作树" [level=3] +- text: v{{version}} 实验性 +- paragraph: + - code: "@deepseek-ai/dsh-experimental-forge-bundle" +- paragraph: 在会话侧栏集中跟踪关联的 GitHub PR:查看检查状态、评审意见与代码差异,并把修改建议交给智能体继续处理。也可查看本地改动,用独立工作树隔离任务。GitHub 功能需要本机安装 gh 并登录。 +- region "详细介绍": + - paragraph: + - img "会话关联 GitHub PR,跟踪检查、评审与代码差异,并用工作树隔离任务改动" +- heading "包含的组件" [level=4] +- text: 共 7 个 · 7 已停用 +- list: + - listitem: + - text: 工作树服务 创建命名的 Git 工作树,并切换当前会话的工作目录。 + - code: worktree + - code: "@deepseek-ai/dsh-experimental-worktree" + - text: 已关闭 + - listitem: + - text: Git 工作树 要求在新分支上工作时,智能体会创建单独的 Git 工作树并在其中继续,你当前的工作树保持原样。 + - code: tool-worktree + - code: "@deepseek-ai/dsh-experimental-tool-worktree" + - text: 已关闭 + - listitem: + - text: 变更请求 通过一个与提供方无关的服务注册代码托管提供方,并轮询变更请求状态。 + - code: forge + - code: "@deepseek-ai/dsh-experimental-forge" + - text: 已关闭 + - listitem: + - text: GitHub 变更请求 通过宿主上的 GitHub CLI 为 forge 注册表读取 GitHub 变更请求。 + - code: forge-github + - code: "@deepseek-ai/dsh-experimental-forge-github" + - text: 已关闭 + - listitem: + - text: 会话变更请求 把变更请求绑定到创建、推送或跟踪它们的会话,并监听其状态。 + - code: forge-session + - code: "@deepseek-ai/dsh-experimental-forge-session" + - text: 已关闭 + - listitem: + - text: 工作树服务 创建命名的 Git 工作树,并切换当前会话的工作目录。 + - code: forge-worktree + - code: "@deepseek-ai/dsh-experimental-worktree" + - text: 已关闭 + - listitem: + - text: 变更请求面板 在侧栏标签页中显示会话的变更请求,并让新会话可以在工作树中开始。 + - code: forge-panel + - code: "@deepseek-ai/dsh-experimental-client-ui-forge" + - text: 已关闭 +- heading "来源信息" [level=4] +- term: 代码来源 +- definition: 内置 +- term: 当前版本 +- definition: {{version}} diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md index 8a7e68999a..b425c381c9 100644 --- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md +++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md @@ -11,8 +11,8 @@ - button "查看 子智能体": 子智能体 - text: 设置子智能体的递归层级、数量和模型。 - listitem: - - button "查看 网页搜索": 网页搜索 - - text: 设置 DeepSeek 的搜索提供方。 + - button "查看 网页搜索与获取": 网页搜索与获取 + - text: 选择网页搜索与网页获取使用的提供方。 - listitem: - button "查看 Agent 循环": Agent 循环 - text: 控制 Agent 派发工具调用的方式。 @@ -39,7 +39,7 @@ - switch "启用 自动授权审查" - listitem: - button "查看 变更请求与工作树": 变更请求与工作树 - - text: 实验性 跟踪每个会话的变更请求及其检查与评审,新会话也可以在 Git 工作树中开始;GitHub 通过 gh 命令行访问。 + - text: 实验性 在会话侧栏集中跟踪关联的 GitHub PR:查看检查状态、评审意见与代码差异,并把修改建议交给智能体继续处理。也可查看本地改动,用独立工作树隔离任务。GitHub 功能需要本机安装 gh 并登录。 - status - switch "启用 变更请求与工作树" - heading "已安装插件" [level=3] diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md index 1053a94651..aa7d31a43b 100644 --- a/apps/web/tests/expected/plugin-manager/manager.expected.md +++ b/apps/web/tests/expected/plugin-manager/manager.expected.md @@ -11,8 +11,8 @@ - button "查看 子智能体": 子智能体 - text: 设置子智能体的递归层级、数量和模型。 - listitem: - - button "查看 网页搜索": 网页搜索 - - text: 设置 DeepSeek 的搜索提供方。 + - button "查看 网页搜索与获取": 网页搜索与获取 + - text: 选择网页搜索与网页获取使用的提供方。 - listitem: - button "查看 Agent 循环": Agent 循环 - text: 控制 Agent 派发工具调用的方式。 @@ -39,7 +39,7 @@ - switch "启用 自动授权审查" - listitem: - button "查看 变更请求与工作树": 变更请求与工作树 - - text: 实验性 跟踪每个会话的变更请求及其检查与评审,新会话也可以在 Git 工作树中开始;GitHub 通过 gh 命令行访问。 + - text: 实验性 在会话侧栏集中跟踪关联的 GitHub PR:查看检查状态、评审意见与代码差异,并把修改建议交给智能体继续处理。也可查看本地改动,用独立工作树隔离任务。GitHub 功能需要本机安装 gh 并登录。 - status - switch "启用 变更请求与工作树" - heading "已安装插件" [level=3] diff --git a/apps/web/tests/expected/reference-composer/order.expected.md b/apps/web/tests/expected/reference-composer/order.expected.md index d5168f7914..e13baba024 100644 --- a/apps/web/tests/expected/reference-composer/order.expected.md +++ b/apps/web/tests/expected/reference-composer/order.expected.md @@ -9,7 +9,7 @@ - text: Research notes what changed? Referenced session · Research notes {{clock}} - button "Copy" - status: Completed -- button "Completed in {{duration}}" [disabled] +- button "Completed in {{duration}}" - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md index 8a79eb3a5e..6bd1adcfbd 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md @@ -12,7 +12,11 @@ - text: and confirm the fixture wiring {{clock}} - button "Copy" - status: Completed -- button "Completed in {{duration}}" [disabled] +- button "Completed in {{duration}}" [expanded] +- button "Injected context" [expanded] +- button "Context injection runtime-context" +- button "Context injection user-invoke-demo" +- button "Context injection time-context" - paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill. - button "Copy" - button "Good response" diff --git a/apps/web/tests/expected/skill-user-invoke/ui.expected.md b/apps/web/tests/expected/skill-user-invoke/ui.expected.md index 8a79eb3a5e..db8db2840a 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md @@ -12,7 +12,7 @@ - text: and confirm the fixture wiring {{clock}} - button "Copy" - status: Completed -- button "Completed in {{duration}}" [disabled] +- button "Completed in {{duration}}" - paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill. - button "Copy" - button "Good response" diff --git a/apps/web/tests/expected/steer-all/mid-steer.expected.md b/apps/web/tests/expected/steer-all/mid-steer.expected.md index 937bd71e43..a3a0ed7930 100644 --- a/apps/web/tests/expected/steer-all/mid-steer.expected.md +++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md @@ -9,6 +9,8 @@ - button "More conversation views" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" +- button "Context injection runtime-context" +- button "Context injection time-context" - button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that." - status: Deep diving - text: "Deep diving for {{duration}} ··· Interjection: include the word BANANA in your final reply." diff --git a/apps/web/tests/expected/steer-all/settled-expanded.expected.md b/apps/web/tests/expected/steer-all/settled-expanded.expected.md index ddf3d37380..10873c5f69 100644 --- a/apps/web/tests/expected/steer-all/settled-expanded.expected.md +++ b/apps/web/tests/expected/steer-all/settled-expanded.expected.md @@ -12,6 +12,8 @@ - status: Completed - button "Completed in {{duration}}" [disabled] - button "Asked questions" [expanded] +- button "Context injection runtime-context" +- button "Context injection time-context" - button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that." - button "Ask question 1/1 answered" - group "Questions and answers": diff --git a/apps/web/tests/fixtures/credential-editor.patch.yml b/apps/web/tests/fixtures/credential-editor.patch.yml deleted file mode 100644 index 911f60db22..0000000000 --- a/apps/web/tests/fixtures/credential-editor.patch.yml +++ /dev/null @@ -1,5 +0,0 @@ -- id: llm-deepseek - config: - baseURL: http://127.0.0.1 - models: - - id: credential-editor-model diff --git a/apps/web/tests/forge-panel.e2e.ts b/apps/web/tests/forge-panel.e2e.ts index 76da6c115e..31b32ac9af 100644 --- a/apps/web/tests/forge-panel.e2e.ts +++ b/apps/web/tests/forge-panel.e2e.ts @@ -447,6 +447,8 @@ it.skipIf(MODE === 'record')('automatically shows a numbered PR when gh JSON omi const { settled } = await sendPrompt(page, scaffold) const sessionId = await settled const events = await readPersistedEvents(scaffold, sessionId) + const request = events.find(event => event.type === 'request/header') + expect(request?.data.header.tools?.filter(tool => tool.name === 'create_worktree')).toHaveLength(1) const toolOutput = events.flatMap(event => event.type === 'tool/result' ? event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []).join('\n') expect(toolOutput).toContain('Add the pull request panel') @@ -1152,6 +1154,63 @@ it.skipIf(MODE === 'record')('uses horizontal arrows to navigate files while pre expect(tripwire.warnings).toEqual([]) }) +it.skipIf(MODE === 'record')('keeps tall branch and changed-file menus aligned with their triggers', async () => { + const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-tall-menus') + await Promise.all(Array.from({ length: 45 }, (_, index) => + git(project, 'update-ref', `refs/heads/worktree/long-menu-regression-${index}`, 'HEAD'))) + const option = page.locator('[data-forge-worktree]') + await option.getByRole('checkbox', { name: '新建工作树', exact: true }).check() + const base = option.getByRole('button', { name: '基于 main', exact: true }) + await base.click() + const menu = page.getByRole('menu') + await menu.getByRole('menuitem', { name: 'worktree/long-menu-regression-44', exact: true }).waitFor() + await expect.poll(async () => { + const trigger = await base.boundingBox() + const card = await menu.boundingBox() + return trigger === null || card === null ? Infinity : Math.abs(trigger.x + trigger.width - card.x - card.width) + }).toBeLessThan(1) + await page.keyboard.press('Escape') + expect(await base.getAttribute('aria-expanded')).toBe('false') + await option.getByRole('checkbox', { name: '新建工作树', exact: true }).uncheck() + + await git(project, 'update-ref', 'refs/remotes/origin/main', 'HEAD') + await git(project, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main') + await mkdir(join(project, 'src')) + await Promise.all(Array.from({ length: 60 }, (_, index) => + writeFile(join(project, 'src', `long-file-list-${index}.ts`), `export const value = ${index}\n`))) + await git(project, 'add', 'src') + await git(project, 'commit', '--quiet', '-m', 'long file list') + const { settled } = await sendPrompt(page, scaffold) + await settled + await page.locator('[data-forge-header]').click() + const panel = page.locator('[data-forge-panel]') + await panel.getByRole('button', { name: '刷新状态', exact: true }).click() + await panel.getByRole('button', { name: /^查看本会话的变更:60 个文件/ }).click() + const changes = page.locator('[data-forge-changes="local"]') + const files = changes.getByRole('button', { name: '全部 60 个文件', exact: true }) + await files.click() + await menu.getByRole('textbox', { name: '筛选文件…', exact: true }).waitFor() + await expect.poll(async () => { + const trigger = await files.boundingBox() + const card = await menu.boundingBox() + return trigger === null || card === null ? Infinity : Math.abs(trigger.x - card.x) + }).toBeLessThan(1) + await menu.getByRole('textbox', { name: '筛选文件…', exact: true }).fill('long-file-list-59') + expect(await menu.getByRole('menuitem', { name: 'src/long-file-list-59.ts', exact: true }).isVisible()).toBe(true) + await page.keyboard.press('Escape') + expect(await menu.count()).toBe(0) + await page.setViewportSize({ width: 390, height: 700 }) + await files.click() + await menu.getByRole('textbox', { name: '筛选文件…', exact: true }).waitFor() + await expect.poll(() => menu.evaluate((node) => { + const rect = node.getBoundingClientRect() + return rect.left >= 12 && rect.right <= 378 && rect.top >= 12 && rect.bottom <= 688 + })).toBe(true) + await page.keyboard.press('Escape') + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) +}) + it.skipIf(MODE === 'record')('keeps localized status columns separate and long file lists inside one vertical scroller', async () => { const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-layout') await page.locator('[data-forge-current-branch]').waitFor() diff --git a/apps/web/tests/jobs-menu-layering.e2e.ts b/apps/web/tests/jobs-menu-layering.e2e.ts index ca2f53e227..3a4eda2f70 100644 --- a/apps/web/tests/jobs-menu-layering.e2e.ts +++ b/apps/web/tests/jobs-menu-layering.e2e.ts @@ -47,13 +47,13 @@ function placement(page: Page) { [0.25, 0.5, 0.75].every(y => menu.contains(document.elementFromPoint( left + (right - left) * x, top + (bottom - top) * y, )))) - const expectedLeft = Math.min(Math.max(trigger.left, 12), innerWidth - box.width - 12) - const expectedTop = Math.min(Math.max(trigger.bottom + 5, 12), innerHeight - box.height - 12) + const verticalGap = Math.min(Math.abs(box.top - trigger.bottom - 5), Math.abs(trigger.top - box.bottom - 5)) + const horizontalAlignment = Math.min(Math.abs(box.left - trigger.left), Math.abs(box.right - trigger.right)) return { dockMode: dock.dataset.sidebarRightPanel, bodyMounted: menu.parentElement === document.body, fixed: getComputedStyle(menu).position === 'fixed', - anchored: Math.abs(box.left - expectedLeft) <= 1 && Math.abs(box.top - expectedTop) <= 1, + anchored: verticalGap <= 1 && horizontalAlignment <= 1, viewportBounded: box.left >= 11 && box.top >= 11 && box.right <= innerWidth - 11 && box.bottom <= innerHeight - 11, overlapsBrowser, diff --git a/apps/web/tests/menu-position.e2e.ts b/apps/web/tests/menu-position.e2e.ts new file mode 100644 index 0000000000..d702c622ea --- /dev/null +++ b/apps/web/tests/menu-position.e2e.ts @@ -0,0 +1,64 @@ +/** CSS menu candidates preserve layout-window margins through anchor alignment and flipping. */ +import { readFile } from 'node:fs/promises' +import { chromium, webkit, type Browser, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFinished } from 'vitest' + +const cases = [ + { name: 'end alignment near the left edge', side: 'bottom', align: 'end', x: 232, y: 100, width: 255, height: 100, left: 12, top: 132, clearance: 12 }, + { name: 'start alignment near the right edge', side: 'bottom', align: 'start', x: 570, y: 100, width: 255, height: 100, left: 333, top: 132, clearance: 12 }, + { name: 'above placement near the desktop titlebar', side: 'top', align: 'start', x: 120, y: 110, width: 160, height: 80, left: 120, top: 142, clearance: 60 }, + { name: 'below placement near the bottom edge', side: 'bottom', align: 'start', x: 120, y: 330, width: 160, height: 100, left: 120, top: 226, clearance: 12 }, + { name: 'right placement near the right edge', side: 'right', align: 'start', x: 540, y: 100, width: 160, height: 100, left: 376, top: 100, clearance: 12 }, + { name: 'window fallback retains horizontal anchoring when neither vertical side fits', side: 'bottom', align: 'start', x: 120, y: 180, width: 300, height: 320, left: 120, top: 60, clearance: 60 }, +] as const + +type Placement = (typeof cases)[number] + +async function showMenu(page: Page, css: string, placement: Placement): Promise { + await page.setContent(` + + `) +} + +describe.each([{ name: 'Chromium', engine: chromium }, { name: 'WebKit', engine: webkit }])('menu layout margins ($name)', ({ engine }) => { + let browser: Browser | undefined + let css: string + beforeAll(async () => { + css = await readFile(new URL('../../../packages/client/ui-primitives/src/Menu.module.css', import.meta.url), 'utf8') + browser = await engine.launch() + }) + afterAll(async () => { await browser?.close() }) + + it.each(cases)('$name', async (placement) => { + const page = await browser!.newPage({ viewport: { width: 600, height: 400 } }) + onTestFinished(() => page.close()) + await showMenu(page, css, placement) + await expect.poll(async () => { + const rect = await page.getByRole('menu').boundingBox() + if (rect === null) return null + return { + left: Math.round(rect.x), top: Math.round(rect.y), + bounded: rect.x >= 12 && rect.x + rect.width <= 588 + && rect.y >= placement.clearance && rect.y + rect.height <= 388, + } + }).toEqual({ left: placement.left, top: placement.top, bounded: true }) + }) + + it.runIf(engine === chromium)('keeps layout bounds while zoom allows visual-viewport overflow', async () => { + const page = await browser!.newPage({ viewport: { width: 600, height: 400 } }) + onTestFinished(() => page.close()) + await showMenu(page, css, cases[0]) + const menu = page.getByRole('menu') + const before = await menu.boundingBox() + const cdp = await page.context().newCDPSession(page) + try { + await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 3 }) + await expect.poll(() => page.evaluate(() => visualViewport!.scale)).toBe(3) + await expect.poll(() => menu.boundingBox()).toEqual(before) + expect(await menu.evaluate(node => node.getBoundingClientRect().width > visualViewport!.width)).toBe(true) + } finally { + await cdp.detach() + } + }) +}) diff --git a/apps/web/tests/model-menu-observer.e2e.ts b/apps/web/tests/model-menu-observer.e2e.ts index 3ec0b7dd45..7bd5720bc5 100644 --- a/apps/web/tests/model-menu-observer.e2e.ts +++ b/apps/web/tests/model-menu-observer.e2e.ts @@ -25,7 +25,7 @@ const PROVIDERS = [ ? { id: 'origin-large', name: 'Origin Large', reasoningEfforts: { off: null, minimal: 'minimal', low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max', } } - : { id: `${provider.prefix.toLowerCase()}-model-${index + 1}`, name: `${provider.prefix} Model ${index + 1}` }), + : { id: `${provider.prefix.toLowerCase()}-model-${index + 1}`, name: `${provider.prefix} Model ${index + 1}${index === 6 ? ' with a long name that exceeds the model card width' : ''}` }), })) const EXPECTED_GROUPS = [ { label: 'DeepSeek', rows: ['DeepSeek-V4-Flash', 'DeepSeek-V4-Flash-Vision-Exp'] }, @@ -289,23 +289,33 @@ describe.skipIf(webSnapshotMode() === 'record').each([ expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'origin-gateway', model: 'origin-large' }) expect(tripwire.pageErrors).toEqual([]) }) - it.runIf(engine === chromium)('keeps the complete model card inside the zoomed visual viewport', async () => { + it.runIf(engine === chromium)('keeps the model card at layout size when zooming beyond the visual viewport', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-model-menu-zoom')) const cdp = await page.context().newCDPSession(page) try { await page.setViewportSize({ width: 402, height: 874 }) await page.getByRole('button', { name: /^选择模型/ }).click() await page.getByRole('menuitem', { name: /^模型/ }).click() - await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 2.5 }) + const panel = page.getByRole('group', { name: '模型与推理等级', exact: true }) for (const height of [874, 500]) { + await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 1 }) await page.setViewportSize({ width: 402, height }) - await expect.poll(() => page.getByRole('group', { name: '模型与推理等级', exact: true }).evaluate((panel) => { - const viewport = window.visualViewport! - const rect = panel.getBoundingClientRect() - return Math.min(rect.left - viewport.offsetLeft, rect.top - viewport.offsetTop, - viewport.offsetLeft + viewport.width - rect.right, viewport.offsetTop + viewport.height - rect.bottom) - })).toBeGreaterThanOrEqual(11.5) - expect(await page.evaluate(() => window.visualViewport!.scale)).toBeGreaterThan(2) + await frames(page) + const unzoomed = await panel.evaluate((node) => { + const rect = node.getBoundingClientRect() + const anchor = document.querySelector(`[aria-controls="${node.id}"]`)!.getBoundingClientRect() + return { width: rect.width, height: rect.height, rightGap: rect.right - anchor.right, + bottomGap: anchor.top - rect.bottom } + }) + await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 2.5 }) + await expect.poll(() => page.evaluate(() => window.visualViewport!.scale)).toBe(2.5) + await expect.poll(() => panel.evaluate((node, before) => { + const rect = node.getBoundingClientRect() + const anchor = document.querySelector(`[aria-controls="${node.id}"]`)!.getBoundingClientRect() + return Math.max(Math.abs(rect.width - before.width), Math.abs(rect.height - before.height), + Math.abs(rect.right - anchor.right - before.rightGap), Math.abs(anchor.top - rect.bottom - before.bottomGap)) + }, unzoomed)).toBeLessThan(1) + expect(await panel.evaluate(node => node.getBoundingClientRect().width > window.visualViewport!.width)).toBe(true) const models = page.getByRole('menu', { name: '模型', exact: true }) const names = models.locator('[data-model-id]').locator('..') expect(await names.evaluateAll(labels => labels.every(label => @@ -388,36 +398,68 @@ describe.skipIf(webSnapshotMode() === 'record').each([ } }) - it.runIf(engine === chromium)('scrolls to and selects the last reasoning effort after zooming', async () => { + it.runIf(engine === chromium)('keeps reasoning efforts selectable after zooming with and without list overflow', async () => { const cdp = await page.context().newCDPSession(page) try { - await page.setViewportSize({ width: 402, height: 500 }) - await page.getByRole('button', { name: /^选择模型/ }).click() - await page.getByRole('menuitem', { name: /^推理等级/ }).click() - await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 2.5 }) - const menu = page.getByRole('menu', { name: '模型与推理等级', exact: true }) - const last = menu.getByRole('menuitemradio').last() - expect(await menu.getByRole('menuitemradio').count()).toBeGreaterThanOrEqual(7) - await expect.poll(() => last.evaluate((row) => { - const scroller = row.parentElement! - scroller.scrollTop = scroller.scrollHeight - return { overflow: getComputedStyle(scroller).overflowY, moved: scroller.scrollTop > 0 } - })).toEqual({ overflow: 'auto', moved: true }) - await expect.poll(() => last.evaluate((row) => { - const rect = row.getBoundingClientRect() - const viewport = row.parentElement!.getBoundingClientRect() - return rect.top >= viewport.top - 1 && rect.bottom <= viewport.bottom + 1 - })).toBe(true) - // Dispatch the touch at the now-visible row; locator.click would auto-scroll even hidden overflow. - const point = await last.evaluate((row) => { - const rect = row.getBoundingClientRect() - const viewport = window.visualViewport! - return { x: rect.left + rect.width / 2 - viewport.offsetLeft, - y: rect.top + rect.height / 2 - viewport.offsetTop } - }) - await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [point] }) - await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }) - await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection()).toMatchObject({ reasoningEffort: 'max' }) + for (const { height, scrolls } of [{ height: 500, scrolls: false }, { height: 350, scrolls: true }]) { + await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 1 }) + await page.setViewportSize({ width: 402, height }) + await page.getByRole('button', { name: /^选择模型/ }).click() + await page.getByRole('menuitem', { name: /^推理等级/ }).click() + const menu = page.getByRole('menu', { name: '模型与推理等级', exact: true }) + await menu.getByRole('menuitemradio').first().click() + await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection().reasoningEffort).toBeUndefined() + await page.getByRole('button', { name: /^选择模型/ }).click() + await page.getByRole('menuitem', { name: /^推理等级/ }).click() + const last = menu.getByRole('menuitemradio').last() + await expect.poll(() => last.getAttribute('aria-checked')).toBe('false') + await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 2.5 }) + await expect.poll(() => page.evaluate(() => window.visualViewport!.scale)).toBe(2.5) + expect(await menu.getByRole('menuitemradio').count()).toBeGreaterThanOrEqual(7) + await expect.poll(() => last.evaluate((row) => { + const scroller = row.parentElement! + scroller.scrollTop = scroller.scrollHeight + return { overflow: getComputedStyle(scroller).overflowY, + overflows: scroller.scrollHeight > scroller.clientHeight, moved: scroller.scrollTop > 0 } + })).toEqual({ overflow: 'auto', overflows: scrolls, moved: scrolls }) + await expect.poll(() => last.evaluate((row) => { + const rect = row.getBoundingClientRect() + const viewport = row.parentElement!.getBoundingClientRect() + return rect.top >= viewport.top - 1 && rect.bottom <= viewport.bottom + 1 + })).toBe(true) + const target = await last.evaluate((row) => { + const rect = row.getBoundingClientRect() + const viewport = window.visualViewport! + return { + left: Math.max(0, Math.min(window.innerWidth - viewport.width, rect.left + rect.width / 2 - viewport.width / 2)), + top: Math.max(0, Math.min(window.innerHeight - viewport.height, rect.top + rect.height / 2 - viewport.height / 2)), + offsetLeft: viewport.offsetLeft, offsetTop: viewport.offsetTop, + } + }) + // Pan from the page margin so wheel input cannot scroll the list instead. + await cdp.send('Input.synthesizeScrollGesture', { + x: 1, y: 1, xDistance: target.offsetLeft - target.left, yDistance: target.offsetTop - target.top, + gestureSourceType: 'mouse', speed: 800, + }) + await expect.poll(() => last.evaluate((row) => { + const rect = row.getBoundingClientRect() + const viewport = window.visualViewport! + const x = rect.left + rect.width / 2 - viewport.offsetLeft + const y = rect.top + rect.height / 2 - viewport.offsetTop + return x >= 0 && x < viewport.width && y >= 0 && y < viewport.height + })).toBe(true) + // Dispatch touch without locator auto-scrolling the last row into view. + const point = await last.evaluate((row) => { + const rect = row.getBoundingClientRect() + const viewport = window.visualViewport! + return { x: rect.left + rect.width / 2 - viewport.offsetLeft, + y: rect.top + rect.height / 2 - viewport.offsetTop } + }) + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [point] }) + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }) + await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection()).toMatchObject({ reasoningEffort: 'max' }) + await menu.waitFor({ state: 'detached' }) + } } finally { await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 1 }) await cdp.detach() diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 1b5a5dcbed..d36008959d 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -2,7 +2,7 @@ // wire — the one add card offers the dormant pi-ai catalog on its third-party // mode and the create form on its custom-API mode, a blank key saves a // reference-free profile for provider-native auth, and typing an API key later -// stores it under the derived reference (`MINIMAX_CN_API_KEY`) +// stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`) // while the settings document records only that reference. Each saved row // appears after route topology invalidation without presenting liveness as // provider status. The customized-settings fold writes its curated fields — @@ -154,7 +154,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await dialog.getByText('该 API 密钥格式错误,请检查。').waitFor({ timeout: 10_000 }) await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(false) - // An empty field leaves this new profile on native authentication. + // Clearing it restores submit: an empty field means "keep what is stored", + // never a refusal, or editing any other setting would demand the key. await key.fill('') await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(true) expect(await dialog.getByText('该 API 密钥格式错误,请检查。').count()).toBe(0) @@ -220,9 +221,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-customized')) const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click() - const savedKey = dialog.getByLabel('API 密钥', { exact: true }) - await expect.poll(() => savedKey.inputValue(), { timeout: 10_000 }).toBe('sk-e2e-minimax') - expect(await savedKey.getAttribute('type')).toBe('password') + await dialog.getByPlaceholder('留空使用已保存凭据', { exact: true }).waitFor({ timeout: 10_000 }) expect(await dialog.textContent()).not.toContain('API 密钥来源:') expect(await dialog.textContent()).not.toMatch(/oauth/i) await dialog.getByText('自定义设置').click() diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index 06e451032d..8706a7a4de 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -2,6 +2,7 @@ // credential is absent, both ordered steps share the shipped modal chrome, // and the inline key write lands in an isolated harness home without a reload // or model call. +import { randomBytes } from 'node:crypto' import { assertModelInputLayout } from './model-input-layout.ts' import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' @@ -32,7 +33,6 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup let page: Page let tripwire: ReturnType const browserConsole: string[] = [] - const secret = 'dsh_onboarding_fixture_key' beforeAll(async () => { scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, welcomeNoticePending: true }) @@ -50,7 +50,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await scaffold?.close() }) - it('stores a key and loads it masked only in an explicitly opened editor', async () => { + it('stores a key write-only and observes configured state without restarting', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-config')) const welcome = page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title }) await welcome.waitFor({ timeout: 15_000 }) @@ -86,6 +86,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup const initial = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(MISSING_EXPECTED, initial, MODE) + const secret = `dsh_onboarding_${randomBytes(12).toString('hex')}` await keyInput.fill(secret) await credentialStep.getByRole('button', { name: '保存并继续' }).click() await credentialStep.waitFor({ state: 'detached', timeout: 15_000 }) @@ -100,7 +101,8 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup const acknowledgedSettings = await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8') expect(acknowledgedSettings).toContain(`${WELCOME_NOTICE_ACK_FIELD}: ${WELCOME_NOTICE_VERSION}`) - // Opening the ordinary Models editor loads the saved key masked. + // The ordinary Models surface reuses the refreshed join and exposes the + // configured write-only placeholder without a reload. await openSettings(page, 'zh') const settings = page.getByRole('dialog', { name: '设置' }) await settings.waitFor({ timeout: 10_000 }) @@ -111,10 +113,9 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup const configuredInput = settings.getByLabel('API 密钥', { exact: true }) await configuredInput.waitFor({ timeout: 10_000 }) await expect.poll( - () => configuredInput.inputValue(), + () => configuredInput.getAttribute('placeholder'), { timeout: 10_000 }, - ).toBe(secret) - expect(await configuredInput.getAttribute('type')).toBe('password') + ).toBe('留空使用已保存凭据') const secondReloadWarnings = tripwire.warnings.length await page.reload({ waitUntil: 'load' }) @@ -254,9 +255,9 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await settings.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).check() await expect.poll( - () => settings.getByLabel('API 密钥', { exact: true }).inputValue(), + () => settings.getByLabel('API 密钥', { exact: true }).getAttribute('placeholder'), { timeout: 10_000 }, - ).toBe(secret) + ).toBe('留空使用已保存凭据') const modelEditor = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(MODELS_EXPECTED, modelEditor, MODE) await settings.getByRole('button', { name: '保存', exact: true }).click() diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index 8bba474e11..2b317f4ec3 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -9,6 +9,7 @@ import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' +import { HIDDEN_BUNDLES } from '@deepseek-ai/dsh-official-bundles' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { join } from 'node:path' import { @@ -34,10 +35,11 @@ describe('web e2e: plugin configuration pages', () => { beforeAll(async () => { // The live-client fixture is a bundle with a browser half; switched on // below, that half registers its row's configuration into the page. + // Legacy Messages search names an unset credential so the goldens show the + // unconfigured key whatever keys the host environment carries. scaffold = await launchWebScaffold({ extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), deepSeekSearch: { apiKeyEnv: UNUSED_KEY_ENV }, - deepSeekWebApi: { apiKeyEnv: UNUSED_KEY_ENV }, profile: { packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-live-client') }] }, }) browser = await chromium.launch() @@ -91,12 +93,12 @@ describe('web e2e: plugin configuration pages', () => { // Every page the shipped web composition exposes: the shell executor, the // agent loop, subagent selection, and the DeepSeek search provider. - await panel.getByRole('button', { name: '查看 网页搜索', exact: true }).waitFor({ timeout: 20_000 }) + await panel.getByRole('button', { name: '查看 网页搜索与获取', exact: true }).waitFor({ timeout: 20_000 }) const basic = panel.locator('[data-plugin-group="basic"]') expect(await basic.locator('[data-plugin-item]').count()).toBe(4) expect(await basic.locator(':scope > ul > li').evaluateAll(cards => cards.map(card => card.getAttribute('data-plugin-item')))) .toEqual(['subagent', 'web-search', 'agent-loop', 'shell']) - for (const title of ['子智能体', '网页搜索', 'Agent 循环', '终端']) { + for (const title of ['子智能体', '网页搜索与获取', 'Agent 循环', '终端']) { expect(await basic.getByRole('button', { name: `查看 ${title}`, exact: true }).count()).toBe(1) } // A card carries the one-liner; the fields wait for the page. @@ -115,17 +117,14 @@ describe('web e2e: plugin configuration pages', () => { // The More list omits the shipped bundles whose own rows do not run (the visibility rule lives in // ui-plugin-manager), so it holds fewer cards than the installed optional-bundle list. const bundles = await scaffold.ctx.pluginManager.listBundles() - const hiddenBundles = new Set([ - '@deepseek-ai/dsh-experimental-terminal-bundle', - '@deepseek-ai/dsh-experimental-liveshare', - ]) expect(await panel.locator('[data-plugin-group="more"] [data-plugin-package]').count()) - .toBe(bundles.filter(bundle => !hiddenBundles.has(bundle.name) + .toBe(bundles.filter(bundle => !HIDDEN_BUNDLES.has(bundle.name) + && bundle.name !== '@deepseek-ai/dsh-experimental-liveshare' && bundle.official && (bundle.listedByDefault !== false || bundle.enabled || bundle.installed)).length) expect(await basic.count()).toBe(0) expect(await extensions.count()).toBe(0) expect(await panel.locator('[data-plugin-group="bundles"]').count()).toBe(0) - await panel.getByRole('button', { name: '查看 Git 工作树', exact: true }).click() + await panel.getByRole('button', { name: '查看 开发者工具', exact: true }).click() await panel.locator('[data-plugin-detail]').waitFor() await panel.getByRole('button', { name: '返回 实验性插件', exact: true }).click() await panel.getByRole('heading', { name: '实验性插件', exact: true, level: 1 }).waitFor() @@ -144,63 +143,107 @@ describe('web e2e: plugin configuration pages', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('switches search and fetch to the Search and Fetch APIs together and preserves each API’s endpoint', async () => { + it('selects search and fetch providers independently and keeps each provider’s settings', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-search-api-settings')) const panel = await openPlugins() - await openPage(panel, '网页搜索') - const toggle = panel.getByRole('switch', { name: '启用新版 Web 搜索 API', exact: true }) - const endpoint = panel.locator('#plugin-config-web-search-endpoint') - const maxUses = panel.getByLabel('单次请求最多搜索次数', { exact: true }) - const save = panel.getByRole('button', { name: '保存', exact: true }) - expect(await toggle.getAttribute('aria-checked')).toBe('false') - expect(await panel.getByRole('link', { name: '网页搜索与抓取指南', exact: true }).getAttribute('href')) - .toBe('https://api-docs.deepseek.com/zh-cn/guides/search') - expect(await panel.getByLabel('Messages API 基础地址', { exact: true }).count()).toBe(1) - expect(await maxUses.inputValue()).toBe('5') - expect(await endpoint.inputValue()).toBe('') - expect(await save.isDisabled()).toBe(true) - await endpoint.fill('https://messages.test/anthropic/v1') - await save.click() - await expect.poll(() => save.isDisabled()).toBe(true) - await toggle.focus() - await page.keyboard.press('Space') - expect(await panel.getByLabel('Search / Fetch API 基础地址', { exact: true }).count()).toBe(1) - expect(await endpoint.inputValue()).toBe('https://api.deepseek.com/v1/tools') - expect(await maxUses.count()).toBe(0) + await openPage(panel, '网页搜索与获取') + // A menu's accessible name is its label followed by the selected entry, so the menus are found by id. + const searchMenu = panel.locator('#plugin-config-web-search-provider') + const fetchMenu = panel.locator('#plugin-config-web-fetch-provider') + const searchSection = panel.getByRole('region', { name: '网页搜索提供方', exact: true }) + const fetchSection = panel.getByRole('region', { name: '网页获取提供方', exact: true }) const count = panel.getByLabel('每次搜索结果数', { exact: true }) + const save = panel.getByRole('button', { name: '保存', exact: true }) + /** Pick one entry from a menu. */ + async function choose(menu: Locator, label: string): Promise { + await menu.click() + await page.getByRole('menuitem', { name: label, exact: true }).click() + } + /** Open a menu and read its entries in order, then close it. */ + async function entries(menu: Locator): Promise { + await menu.click() + const items = await page.getByRole('menuitem').allTextContents() + await page.keyboard.press('Escape') + await expect.poll(() => page.getByRole('menuitem').count()).toBe(0) + return items + } + // The shipped composition selects the DeepSeek Search API and local fetch, and the page writes nothing on mount. + expect(await searchMenu.textContent()).toBe('DeepSeek Search API') + expect(await fetchMenu.textContent()).toBe('本机直接获取') + expect(await panel.getByRole('button', { name: '网页搜索提供方 DeepSeek Search API', exact: true }).count()).toBe(1) + expect(await panel.getByRole('button', { name: '网页获取提供方 本机直接获取', exact: true }).count()).toBe(1) + expect(await entries(searchMenu)).toEqual(['DeepSeek Search API', 'DeepSeek(旧版)']) + expect(await entries(fetchMenu)).toEqual(['本机直接获取', 'DeepSeek Fetch API']) + expect(await settingsDocument()).not.toContain('searchProvider:') + // The page fixes the menu order itself; the Loader imports plugins concurrently, so the + // Host's registration order is not the YAML row order and only its membership is checked here. + const hostWeb = scaffold.ctx.get('web') as { listProviders(): Record<'search' | 'fetch', { id: string }[]> } | undefined + expect(hostWeb?.listProviders().search.map(provider => provider.id).sort()).toEqual(['deepseek-official', 'deepseek-search-api']) + expect(await panel.getByRole('link', { name: '网页搜索与获取指南', exact: true }).getAttribute('href')) + .toBe('https://api-docs.deepseek.com/zh-cn/guides/search') + // Each section shows the description its selected provider registered with the Host, in the active language. + expect(await searchSection.getByText('直接请求搜索接口返回标题、URL 与摘要,无需额外调用模型。使用当前会话的 DeepSeek 账号或 DeepSeek API Key,可能产生计费', { exact: true }).count()).toBe(1) + expect(await fetchSection.getByText('本机直接请求,免 API Key 与计费,可访问内网和本机。仅解码 HTML、JSON 等文本,不执行 JavaScript、不带登录态,不支持 PDF;也可以试试 DeepSeek Fetch API', { exact: true }).count()).toBe(1) + // The Fetch API suggestion is text, not an action. + expect(await fetchSection.getByRole('button').count()).toBe(1) + expect(await save.isDisabled()).toBe(true) + + // The Search panel holds only the result count. + expect(await searchSection.getByRole('textbox').count()).toBe(1) expect(await count.inputValue()).toBe('10') await count.fill('51') await panel.getByText('请填 1–50 的整数;留空表示使用默认值。', { exact: true }).waitFor() expect(await save.isDisabled()).toBe(true) - expect(await toggle.isDisabled()).toBe(true) + for (const menu of [searchMenu, fetchMenu]) expect(await menu.isDisabled()).toBe(true) await count.fill('10') - expect(await settingsDocument()).not.toContain('searchProvider: deepseek-search-api') - await endpoint.fill('https://search-api.test/api/v1/tools') + expect(await save.isDisabled()).toBe(true) + + // The Fetch API takes no settings here, so its section shows no panel. + await choose(fetchMenu, 'DeepSeek Fetch API') + expect(await fetchSection.getByText('由 DeepSeek 服务端抓取公网页面并返回 Markdown。使用当前会话的 DeepSeek 账号或 DeepSeek API Key,可能产生计费,且无法访问内网和本机', { exact: true }).count()).toBe(1) + expect(await fetchSection.getByRole('textbox').count()).toBe(0) + expect(await fetchSection.getByRole('button').count()).toBe(1) await save.click() await expect.poll(() => save.isDisabled()).toBe(true) - await expect.poll(settingsDocument).toContain('searchProvider: deepseek-search-api') await expect.poll(settingsDocument).toContain('fetchProvider: deepseek-fetch-api') - await expect.poll(settingsDocument).toContain('baseURL: https://search-api.test/api/v1/tools') + await page.reload({ waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await openPlugins() - await openPage(panel, '网页搜索') - expect(await toggle.getAttribute('aria-checked')).toBe('true') - expect(await endpoint.inputValue()).toBe('https://search-api.test/api/v1/tools') + await openPage(panel, '网页搜索与获取') + expect(await searchMenu.textContent()).toBe('DeepSeek Search API') + expect(await fetchMenu.textContent()).toBe('DeepSeek Fetch API') const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd) await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'web-search.expected.md'), snapshot, MODE) - await panel.getByRole('button', { name: '恢复默认', exact: true }).click() - expect(await endpoint.inputValue()).toBe('https://api.deepseek.com/v1/tools') + + await choose(fetchMenu, '本机直接获取') await save.click() await expect.poll(() => save.isDisabled()).toBe(true) - await expect.poll(settingsDocument).not.toContain('https://search-api.test/api/v1/tools') - await toggle.click() - expect(await endpoint.inputValue()).toBe('https://messages.test/anthropic/v1') - await save.click() - await expect.poll(() => save.isDisabled()).toBe(true) - await expect.poll(settingsDocument).not.toContain('searchProvider: deepseek-search-api') await expect.poll(settingsDocument).not.toContain('fetchProvider: deepseek-fetch-api') - expect(await settingsDocument()).toContain('baseURL: https://messages.test/anthropic/v1') + + await choose(searchMenu, 'DeepSeek(旧版)') + const messagesEndpoint = panel.locator('#plugin-config-web-search-endpoint') + expect(await panel.getByLabel('单次请求最多搜索次数', { exact: true }).inputValue()).toBe('5') + await messagesEndpoint.fill('https://messages.test/anthropic/v1') + await save.click() + await expect.poll(() => save.isDisabled()).toBe(true) + await expect.poll(settingsDocument).toContain('searchProvider: deepseek-official') + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await openPlugins() + await openPage(panel, '网页搜索与获取') + expect(await searchMenu.textContent()).toBe('DeepSeek(旧版)') + expect(await messagesEndpoint.inputValue()).toBe('https://messages.test/anthropic/v1') + expect(await entries(searchMenu)).toEqual(['DeepSeek Search API', 'DeepSeek(旧版)']) + expect(await save.isDisabled()).toBe(true) + expect(await settingsDocument()).toContain('searchProvider: deepseek-official') + await choose(searchMenu, 'DeepSeek Search API') + await count.fill('20') + await save.click() + await expect.poll(() => save.isDisabled()).toBe(true) + await expect.poll(settingsDocument).not.toContain('searchProvider:') + expect(await settingsDocument()).toContain('count: 20') + expect(await settingsDocument()).not.toMatch(/ApiKeyEnv:|searchBaseURL:|fetchBaseURL:/) expect(tripwire.pageErrors).toEqual([]) }) diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index 9bb7f1cfdd..66bd3cfb1d 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -9,6 +9,7 @@ import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { FiberState } from '@deepseek-ai/cordis' import { readProfileManifest } from '@deepseek-ai/dsh-app-boot' +import { HIDDEN_BUNDLES } from '@deepseek-ai/dsh-official-bundles' import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished, vi } from 'vitest' import { join } from 'node:path' import { @@ -852,7 +853,7 @@ describe('web e2e: plugin manager', () => { // the eligible installed optional-bundle list. expect(await panel.locator('[data-plugin-group="more"] [data-plugin-package="@deepseek-ai/dsh-experimental-liveshare"]').count()).toBe(0) expect(await panel.locator('[data-plugin-group="more"] [data-plugin-package]').count()) - .toBe(bundles.filter(bundle => bundle.name !== '@deepseek-ai/dsh-experimental-terminal-bundle' + .toBe(bundles.filter(bundle => !HIDDEN_BUNDLES.has(bundle.name) && bundle.name !== '@deepseek-ai/dsh-experimental-liveshare' && bundle.official && (bundle.listedByDefault !== false || bundle.enabled || bundle.installed)).length) // Computer Use ships with the installation and is listed only on the macOS Hosts it supports. @@ -878,7 +879,6 @@ describe('web e2e: plugin manager', () => { ['@deepseek-ai/dsh-personalized-recommendations', 1], ['@deepseek-ai/dsh-experimental-inspector-profile', 1], ...cuaSupported ? [['@deepseek-ai/dsh-cua-bundle', 1]] : [], - ['@deepseek-ai/dsh-experimental-tool-worktree', 1], ['@deepseek-ai/dsh-experimental-cot-translation-bundle', 1], ['@deepseek-ai/dsh-experimental-session-titles-bundle', 1], ['@deepseek-ai/dsh-experimental-badge-skill-bundle', 1], @@ -1065,6 +1065,51 @@ describe('web e2e: plugin manager', () => { expect(tripwire.pageErrors).toEqual([]) }) + it('introduces code review and worktrees with localized artwork before activation', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-forge-preview')) + const panel = await openPluginsPanel() + await panel.getByRole('button', { name: '查看 变更请求与工作树', exact: true }).click() + const detail = panel.locator('[data-plugin-detail]') + expect(await detail.getByRole('switch').getAttribute('aria-checked')).toBe('false') + const sources: (string | null)[] = [] + let english = false + try { + for (const [language, alt] of [ + ['zh', '会话关联 GitHub PR,跟踪检查、评审与代码差异,并用工作树隔离任务改动'], + ['en', 'A conversation linked to a GitHub pull request, with checks, reviews, code diffs, and an isolated worktree'], + ] as const) { + if (language === 'en') { + await setLanguage(language) + english = true + } + const document = detail.locator('[data-detail-document]') + const image = document.getByRole('img', { name: alt, exact: true }) + await image.waitFor() + await image.evaluate(async (node: HTMLImageElement) => { await node.decode() }) + expect(await image.evaluate((node: HTMLImageElement) => [node.naturalWidth, node.naturalHeight])).toEqual([640, 300]) + sources.push(await image.getAttribute('src')) + for (const width of [1680, 390]) { + await page.setViewportSize({ width, height: 1000 }) + expect(await image.evaluate((node: HTMLImageElement) => { + const bounds = node.getBoundingClientRect() + return bounds.width > 0 && bounds.left >= 0 && bounds.right <= window.innerWidth + && window.document.documentElement.scrollWidth <= window.innerWidth + })).toBe(true) + } + await page.setViewportSize({ width: 1680, height: 1000 }) + } + expect(sources[0]).not.toBe(sources[1]) + } finally { + await page.setViewportSize({ width: 1680, height: 1000 }) + if (english) await setLanguage('zh') + } + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'forge-preview.expected.md'), + await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd, { + replacements: [[badgeVersion, '{{version}}']], + }), MODE) + expect(tripwire.pageErrors).toEqual([]) + }) + it('localizes independent exports and falls back per field without activating the plugins', async () => { onTestFinished(closeSettings) onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-exports')) @@ -1121,7 +1166,7 @@ describe('web e2e: plugin manager', () => { // Schedule ships in the delivered composition, so no bundle card carries it in either language. expect(await panel.getByRole('button', { name: 'View Automation tasks', exact: true }).count()).toBe(0) // The official configuration pages follow the language too, from their own dictionary. - for (const title of ['Shell', 'Agent loop', 'Subagent', 'Web search']) { + for (const title of ['Shell', 'Agent loop', 'Subagent', 'Web search and fetch']) { await panel.getByRole('button', { name: `View ${title}`, exact: true }).waitFor() } } finally { @@ -1437,8 +1482,8 @@ describe('web e2e: plugin manager', () => { // on, so writing the patch file recomposes the running loader; each step first waits for that // recomposition and then re-reads the Host bundle list through the page's own Refresh. const patchFile = join(visibilityScaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml') - const declarationsRow = (): boolean => [...visibilityScaffold.ctx.loader.entries()] - .some(entry => entry.options.id === 'liveshare-viewer-declarations') + const shareHostRow = (): boolean => [...visibilityScaffold.ctx.loader.entries()] + .some(entry => entry.options.id === 'liveshare-share-host') const refresh = panel.getByRole('button', { name: '刷新', exact: true }) // Default install: the package ships, but no row it declares runs, so neither list offers it. @@ -1456,12 +1501,12 @@ describe('web e2e: plugin manager', () => { // selects no such bundle: the card joins the experimental list. await writeFile(patchFile, [ '- insert:', - ' - id: liveshare-viewer-declarations', - " name: '@deepseek-ai/dsh-experimental-liveshare/viewer-declarations'", + ' - id: liveshare-share-host', + " name: '@deepseek-ai/dsh-experimental-liveshare'", '', ].join('\n')) - await expect.poll(declarationsRow, { - timeout: 30_000, message: 'the profile patch did not compose the LiveShare declarations row', + await expect.poll(shareHostRow, { + timeout: 30_000, message: 'the profile patch did not compose the LiveShare share-host row', }).toBe(true) await refresh.click() await expect.poll(() => card.count(), { @@ -1470,8 +1515,8 @@ describe('web e2e: plugin manager', () => { // Removing the row stops it: the card leaves both lists again. await writeFile(patchFile, '[]\n') - await expect.poll(declarationsRow, { - timeout: 30_000, message: 'the profile patch did not remove the LiveShare declarations row', + await expect.poll(shareHostRow, { + timeout: 30_000, message: 'the profile patch did not remove the LiveShare share-host row', }).toBe(false) await refresh.click() await expect.poll(() => card.count(), { @@ -1494,7 +1539,7 @@ describe('web e2e: plugin manager', () => { await assertFixtureInventory(SNAPSHOT_DIR, [ 'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', 'icons.expected.md', 'loading.expected.md', 'refresh.expected.md', 'ime-enter.expected.md', 'header-responsive.expected.md', 'header-natural-motion.expected.md', 'preview.expected.md', - 'touch-tooltips.expected.md', + 'touch-tooltips.expected.md', 'forge-preview.expected.md', ]) }) }) diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts index a1e83e6bd0..1a9402ef87 100644 --- a/apps/web/tests/reference-composer.e2e.ts +++ b/apps/web/tests/reference-composer.e2e.ts @@ -392,7 +392,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through expect(tripwire.warnings).toEqual([]) }) - it('renders the direct message while retaining the following recall only in the log', async () => { + it('renders the direct message followed by its logged recall as a collapsed Context row', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-order')) const group = page.getByRole('treeitem', { name: /Ungrouped/ }) await group.waitFor({ timeout: 15_000 }) @@ -410,12 +410,19 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through expect(inputs.map(event => event.data.source.kind)).toEqual(['user', 'session-reference']) expect(inputs[0]?.seq).toBeLessThan(inputs[1]!.seq) expect(JSON.stringify(inputs[1]?.data)).toContain('snapshot') - expect(await page.locator('[data-chat-flow-kind="context"]').count()).toBe(0) + const recall = page.locator('[data-chat-flow-kind="context"]') + expect(await recall.count()).toBe(1) + expect(await recall.locator('[data-context-recall-icon]').count()).toBe(1) + expect(await recall.locator('[aria-expanded]').getAttribute('aria-expanded')).toBe('false') + // The recall follows the direct message, matching their logged order. + expect(await page.locator('[data-chat-flow-kind="user"], [data-chat-flow-kind="context"]') + .evaluateAll(rows => rows.map(row => row.getAttribute('data-chat-flow-kind')))).toEqual(['user', 'context']) const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(TARGET_SESSION_ID).join('{{targetId}}') await compareOrRefreshGolden(ORDER_EXPECTED, snapshot, MODE) expect(snapshot).toContain('Research notes what changed?') + // The recall row folds into the settled Turn process. expect(snapshot).not.toContain('Session recall Research notes') expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 92f307c095..2b173325e4 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -59,6 +59,7 @@ import { type NormalizeContext, } from '@deepseek-ai/dsh-session-snapshot' import type { Profile, ProfileContext } from '@deepseek-ai/dsh-app-boot' +import type { PresetDefinition } from '@deepseek-ai/dsh-agent-preset-registry' import { dshHomePath } from '@deepseek-ai/dsh-home-paths' import { LlmAdapter } from '@deepseek-ai/dsh-llm' import type { @@ -395,29 +396,25 @@ export interface LaunchOptions { welcomeNoticePending?: boolean /** Leave first-use Workspace initialization eligible; ordinary scenarios start after the default was removed. */ firstUse?: boolean - /** - * Patch the shipped DeepSeek search row to a deterministic endpoint and - * credential reference. Browser search scenarios keep the real provider and - * credentials seam while avoiding external search traffic and ambient keys. - */ + /** Pin the legacy Messages search endpoint and credential reference. */ deepSeekSearch?: { - /** Anthropic-compatible base URL; the provider appends `/messages`. */ + /** Anthropic-compatible base URL; `/messages` is appended. */ baseURL?: string - /** Credential reference resolved by the shipped search provider. */ + /** Credential reference read for each search. */ apiKeyEnv: string } /** - * Patch the shipped DeepSeek Search and Fetch API row to a deterministic - * endpoint and credential reference, optionally selecting both providers as - * the settings switch does. + * Give both sections of the shipped DeepSeek Search and Fetch API row their + * own credential reference and, when given, endpoint, so neither follows the + * conversation's DeepSeek credential. Browser search scenarios keep the real + * providers and credentials seam while avoiding external search traffic and + * ambient keys. */ deepSeekWebApi?: { - /** Search and Fetch API base URL; omitted uses the provider default. The providers append `/search` or `/fetch`. */ + /** Base URL of both sections; omitted uses the official base. The providers append `/search` or `/fetch`. */ baseURL?: string - /** Credential reference resolved by both providers. */ + /** Credential reference of both sections. */ apiKeyEnv: string - /** Select `deepseek-search-api` and `deepseek-fetch-api` in place of the shipped selection. */ - select?: boolean } /** Preset selection default and additional declarative definitions for this scenario. */ agentPresets?: { @@ -689,30 +686,42 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise ({ id: `preset-${config.id}`, name: '@deepseek-ai/dsh-agent-preset', config })) }, ], - ...options.deepSeekSearch === undefined - ? [] - : [{ - id: 'web-search-deepseek', - config: { - apiKeyEnv: options.deepSeekSearch.apiKeyEnv, - ...options.deepSeekSearch.baseURL === undefined ? {} : { baseURL: options.deepSeekSearch.baseURL }, - }, - }], ...options.deepSeekWebApi === undefined ? [] : [ { id: 'web-deepseek-api', config: { - apiKeyEnv: options.deepSeekWebApi.apiKeyEnv, - ...options.deepSeekWebApi.baseURL === undefined ? {} : { baseURL: options.deepSeekWebApi.baseURL }, + searchApiKeyEnv: options.deepSeekWebApi.apiKeyEnv, + fetchApiKeyEnv: options.deepSeekWebApi.apiKeyEnv, + ...options.deepSeekWebApi.baseURL === undefined ? {} : { + searchBaseURL: options.deepSeekWebApi.baseURL, fetchBaseURL: options.deepSeekWebApi.baseURL, + }, }, }, - ...options.deepSeekWebApi.select === true - ? [{ id: 'web', config: { searchProvider: 'deepseek-search-api', fetchProvider: 'deepseek-fetch-api' } }] - : [], ], + ...options.deepSeekSearch === undefined ? [] : [{ + id: 'web-search-deepseek', + config: { + apiKeyEnv: options.deepSeekSearch.apiKeyEnv, + ...options.deepSeekSearch.baseURL === undefined ? {} : { baseURL: options.deepSeekSearch.baseURL }, + }, + }], { id: 'llm-deepseek', disabled: mode !== 'record' && !maskDeepSeekCredential }, ] + // Recorded standard sessions pin the optional patch tool in their request headers. + if (options.replayFixture !== undefined || options.compareReplaySession === true) { + const config = composedRows.find(row => row.id === 'preset-standard')!.config as PresetDefinition + overlayPatches.push({ + id: 'preset-standard', + config: { + ...config, + plugins: config.plugins.map(plugin => plugin.id === 'tool-fs' + ? { ...plugin, config: { ...plugin.config, applyPatch: true } } + : plugin), + }, + }) + } + // Live fields use a shared deployment layer; process-specific ports and roots stay in CLI overlays. const formEntries = new Set(['agent-default-model', 'agent-preset-registry', 'llm-deepseek', 'llm-pi-ai', 'web', 'web-search-deepseek', 'web-deepseek-api', 'agent-loop', 'subagent', 'bash-sandbox', 'pwsh-sandbox', @@ -1761,10 +1770,12 @@ export async function assertFixtureInventory(dir: string, expected: string[]): P /** * Console tripwires: reconnect/gap-repair self-healing or a pageerror must - * fail the scenario, not mask a dead wire behind eventual consistency. + * fail the scenario, not mask a dead wire behind eventual consistency. A boot + * audit warning fails it too: the product boot tolerates inactive optional + * entries, but every e2e composition must activate completely. */ export interface WebConsoleTripwire { - /** Console warnings matching reconnect/gap-repair/discontinuity copy. */ + /** Console warnings matching reconnect/gap-repair/discontinuity copy or the boot audit's inactive-entry warning. */ warnings: string[] /** Uncaught page errors. */ pageErrors: string[] @@ -1780,7 +1791,7 @@ export function watchConsole(page: Page): WebConsoleTripwire { const pageErrors: string[] = [] page.on('console', (message) => { const text = message.text() - if (/connection lost|gap repair|discontinuous/i.test(text)) warnings.push(text) + if (/connection lost|gap repair|discontinuous|web boot: warning/i.test(text)) warnings.push(text) }) page.on('pageerror', (error) => { pageErrors.push(String(error)) }) return { warnings, pageErrors } diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 34cbaf4155..2b39f5e41e 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -920,6 +920,43 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('keeps a wide schedule catalog below its header trigger in a narrow viewport', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-catalog-anchor')) + try { + await page.getByRole('button', { name: 'Automation tasks', exact: true }).click() + const manager = page.getByTestId('task-manager-page') + await manager.getByRole('button', { name: 'Check exact cadence', exact: true }).click() + await manager.getByRole('button', { name: /^Linked session.*: open linked session$/ }).click() + await liveAgent(scaffold, CATALOG_SESSION_ID) + const collapse = page.getByRole('button', { name: 'Collapse sidebar', exact: true }) + if (await collapse.isVisible()) await collapse.click() + await page.setViewportSize({ width: 360, height: 586 }) + await page.evaluate(() => document.fonts.ready) + const trigger = page.getByRole('button', { name: '3 automation tasks', exact: true }) + await trigger.click() + const catalog = page.getByRole('list', { name: 'Enabled automation tasks', exact: true }) + await expect.poll(() => catalog.evaluate((node) => { + const anchor = document.querySelector('button[aria-label="3 automation tasks"]')!.getBoundingClientRect() + const rect = node.getBoundingClientRect() + return { + gap: Math.round(rect.top - anchor.bottom), + inside: rect.left >= 15.5 && rect.right <= innerWidth - 15.5 + && rect.top >= 15.5 && rect.bottom <= innerHeight - 15.5, + widerThanEitherSide: rect.width > innerWidth - 16 - anchor.left && rect.width > anchor.right - 16, + } + })).toEqual({ gap: 5, inside: true, widerThanEitherSide: true }) + expect(await catalog.getByRole('listitem').count()).toBe(3) + await page.keyboard.press('Escape') + await catalog.waitFor({ state: 'detached' }) + expect(await trigger.evaluate(node => node === document.activeElement)).toBe(true) + } finally { + await page.keyboard.press('Escape') + await page.setViewportSize({ width: 900, height: 900 }) + const expand = page.getByRole('button', { name: 'Open sidebar', exact: true }) + if (await expand.isVisible()) await expand.click() + } + }) + it('reads stored tasks without a live Session and deletes them through the catalog', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-catalog')) const layers = [ diff --git a/apps/web/tests/schedule-manual.e2e.ts b/apps/web/tests/schedule-manual.e2e.ts index 60ca701a4d..cde9828a5d 100644 --- a/apps/web/tests/schedule-manual.e2e.ts +++ b/apps/web/tests/schedule-manual.e2e.ts @@ -1,7 +1,151 @@ -/** Manual task creation and editing through the shipped Web composition. */ +/** Schedule creation, prompt prefill, and editing through the shipped Web composition. */ import { chromium } from 'playwright' import { expect, it } from 'vitest' +import type { ScheduleTiming } from '@deepseek-ai/dsh-schedule/client' import { launchWebScaffold, watchConsole } from './scaffold.ts' +import { writeComposerDraft } from './support.ts' + +it.each([ + { locale: 'en', panel: 'Automation tasks', create: 'New', menu: 'Choose a creation method', chat: 'Create in a session', + prompt: 'Please create an automation task based on the requirements.' }, + { locale: 'zh', panel: '自动化任务', create: '新建', menu: '选择创建方式', chat: '在会话中创建', + prompt: '请根据需求创建一个自动化任务。' }, +])('prefills an unsent Schedule creation draft in $locale through both conversation entries', async (copy) => { + const scaffold = await launchWebScaffold({ firstUse: true }) + let browser: Awaited> | undefined + try { + browser = await chromium.launch() + const page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: copy.locale }) + await page.addInitScript((locale) => { localStorage.setItem('dsh.locale', locale) }, copy.locale) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + const input = page.locator('[data-composer-input][contenteditable="true"]').first() + for (const { entry, draft } of [ + { entry: 'primary', draft: '' }, + { entry: 'primary', draft: 'Existing unsent draft' }, + { entry: 'menu', draft: '' }, + { entry: 'menu', draft: 'Existing unsent draft' }, + ]) { + await writeComposerDraft(page, input, draft) + await page.getByRole('button', { name: copy.panel, exact: true }).click() + const manager = page.getByTestId('task-manager-page') + if (entry === 'primary') await manager.getByRole('button', { name: copy.create, exact: true }).click() + else { + await manager.getByRole('button', { name: copy.menu, exact: true }).click() + await page.getByRole('menuitem', { name: copy.chat, exact: true }).click() + } + await manager.waitFor({ state: 'detached' }) + await input.getByText(copy.prompt, { exact: true }).waitFor() + expect(await input.evaluate(element => [...element.children].map(paragraph => paragraph.textContent).join('\n'))) + .toBe(draft === '' ? copy.prompt : `${draft}\n${copy.prompt}`) + const events = scaffold.ctx.agents.list().flatMap(agent => agent.session.snapshotEvents()) + expect(events.filter(event => event.type === 'user/message' || event.type === 'turn/start')).toEqual([]) + expect(await scaffold.ctx.schedule.catalog()).toEqual([]) + } + expect(tripwire.pageErrors).toEqual([]) + } finally { + try { await browser?.close() } finally { await scaffold.close() } + } +}) + +it('opens stored timing fields immediately without selecting another repeat option', async () => { + const scaffold = await launchWebScaffold({ firstUse: true }) + let browser: Awaited> | undefined + try { + const clock = new Date(Date.now() + 4 * 60 * 60_000).toISOString().slice(11, 16) + const choices = [ + { timing: { kind: 'after', after_seconds: 3600 }, repeat: '仅一次', field: '日期' }, + { timing: { kind: 'at', at: '2099-01-05T09:00:00.000Z' }, repeat: '仅一次', field: '日期' }, + { timing: { kind: 'every', every_seconds: 300 }, repeat: '每 N 分钟', field: '重复间隔' }, + { timing: { kind: 'daily', daily: { time: `${clock}:00`, time_zone: 'UTC' } }, repeat: '每天', field: '时间' }, + { timing: { kind: 'weekly', weekly: { time: `${clock}:45`, time_zone: 'UTC', weekdays: [1, 5] } }, + repeat: '每周', field: '星期' }, + ] satisfies { timing: ScheduleTiming; repeat: string; field: string }[] + const tasks = [] + for (const [index, choice] of choices.entries()) { + const task = await scaffold.ctx.schedule.create({ title: `Stored task ${index}`, prompt: 'Review the project', timing: choice.timing }) + await scaffold.ctx.schedule.edit({ id: task.id, action: 'update', patch: { active: false } }) + tasks.push({ ...choice, task }) + } + browser = await chromium.launch() + const page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: 'zh-CN', timezoneId: 'Asia/Shanghai' }) + await page.addInitScript(() => { localStorage.setItem('dsh.locale', 'zh') }) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.getByRole('button', { name: '自动化任务', exact: true }).click() + const manager = page.getByTestId('task-manager-page') + await manager.getByRole('button', { name: '全部', exact: true }).click() + for (const { task, repeat, field } of tasks) { + await manager.getByRole('button', { name: task.title, exact: true }).click() + const detail = manager.getByRole('complementary', { name: '任务详情', exact: true }) + expect(await detail.getByRole('button', { name: `重复 ${repeat}`, exact: true }).isVisible()).toBe(true) + const role = field === '星期' ? 'group' : field === '重复间隔' ? 'spinbutton' : 'button' + expect(await detail.getByRole(role, { name: field, exact: true }).isVisible()).toBe(true) + expect(await detail.getByRole('button', { name: '保存修改', exact: true }).isDisabled()).toBe(true) + expect(await scaffold.ctx.schedule.view({ id: task.id })).toMatchObject({ kind: task.kind, scheduledAt: task.scheduledAt }) + if (field === '日期') { + await detail.getByRole('button', { name: '日期', exact: true }).click() + await page.getByRole('dialog', { name: '日期', exact: true }).waitFor() + await page.keyboard.press('Escape') + } + } + expect(tripwire.pageErrors).toEqual([]) + } finally { + try { await browser?.close() } finally { await scaffold.close() } + } +}) + +it.each(['2026-03-07T16:00:00.000Z', '2026-03-08T16:00:00.000Z'])( + 'saves a stored weekly task on %s without a false timing error', async (now) => { + const scaffold = await launchWebScaffold({ firstUse: true }) + let browser: Awaited> | undefined + try { + const task = await scaffold.ctx.schedule.create({ title: 'Weekly review', prompt: 'Review the project', + timing: { kind: 'weekly', weekly: { time: '02:30:00', time_zone: 'America/New_York', weekdays: [5] } } }) + await scaffold.ctx.schedule.edit({ id: task.id, action: 'update', patch: { active: false } }) + browser = await chromium.launch() + const page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: 'zh-CN', timezoneId: 'America/New_York' }) + await page.addInitScript((now) => { + localStorage.setItem('dsh.locale', 'zh') + Date.now = () => Date.parse(now) + }, now) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.getByRole('button', { name: '自动化任务', exact: true }).click() + const manager = page.getByTestId('task-manager-page') + await manager.getByRole('button', { name: '全部', exact: true }).click() + await manager.getByRole('button', { name: task.title, exact: true }).click() + const detail = manager.getByRole('complementary', { name: '任务详情', exact: true }) + await detail.getByRole('group', { name: '星期', exact: true }).getByRole('button').nth(0).click() + expect(await detail.getByRole('status').count()).toBe(0) + const save = detail.getByRole('button', { name: '保存修改', exact: true }) + expect(await save.isEnabled()).toBe(true) + await save.click() + await expect.poll(async () => { + const record = await scaffold.ctx.schedule.view({ id: task.id }) + return 'code' in record ? undefined : record.kind + }).toBe('rrule') + const updated = await scaffold.ctx.schedule.view({ id: task.id }) + expect(updated).toMatchObject({ kind: 'rrule', status: 'paused', target: task.target }) + if ('code' in updated || updated.kind !== 'rrule') throw new Error('expected an edited weekly rule') + expect(updated.rrule).toContain('BYDAY=MO,FR') + + await page.reload({ waitUntil: 'load' }) + await page.getByRole('button', { name: '自动化任务', exact: true }).click() + await manager.getByRole('button', { name: '全部', exact: true }).click() + await manager.getByRole('button', { name: task.title, exact: true }).click() + await detail.getByRole('button', { name: '重复 每周', exact: true }).waitFor() + expect(await detail.getByRole('group', { name: '星期', exact: true }).getByRole('button') + .evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-pressed')))) + .toEqual(['true', 'false', 'false', 'false', 'true', 'false', 'false']) + expect(await detail.getByRole('status').count()).toBe(0) + expect(await save.isDisabled()).toBe(true) + expect(tripwire.pageErrors).toEqual([]) + } finally { + try { await browser?.close() } finally { await scaffold.close() } + } + }, +) it('creates from a paused empty state, selects the task, and saves an inline edit without starting an Agent', async () => { // First use registers the default Workspace, which a new task's directory starts with. @@ -178,7 +322,7 @@ it('creates and edits Custom Cron through the built UI while retaining its expli await detail.getByRole('textbox', { name: '自定义', exact: true }).fill(' ') expect(await detail.getByRole('button', { name: '保存修改', exact: true }).isDisabled()).toBe(true) - await detail.getByRole('status').getByText('请检查必填项和运行规则', { exact: true }).waitFor() + await detail.getByRole('status').getByText('请修改自定义规则、选择其他重复方式,或让智能体协助修改', { exact: true }).waitFor() expect(await scaffold.ctx.schedule.view({ id: created.id })).toEqual(created) await detail.getByRole('textbox', { name: '自定义', exact: true }).fill('0 10 * * 1-5') await detail.getByRole('button', { name: /^时区 / }).waitFor() diff --git a/apps/web/tests/schedule-pickers.e2e.ts b/apps/web/tests/schedule-pickers.e2e.ts index 2548f71777..cb0531c9da 100644 --- a/apps/web/tests/schedule-pickers.e2e.ts +++ b/apps/web/tests/schedule-pickers.e2e.ts @@ -1,5 +1,5 @@ /** Zoomed automation pickers truncate wide labels and keep vertical choices reachable. */ -import type { Browser, Locator, Page } from 'playwright' +import type { Browser, CDPSession, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import type {} from '@deepseek-ai/dsh-schedule' @@ -12,7 +12,7 @@ let browser: Browser beforeAll(async () => { scaffold = await launchWebScaffold() - // Two digits must overflow at 500% zoom; narrower system UI fonts can fit. + // Long zone labels use the same font on every host. await scaffold.ctx.settings.update('ui-theme', { textFontFamily: 'monospace' }) await scaffold.ctx.schedule.create({ title: TITLE, prompt: 'Inspect the deployment log', @@ -30,30 +30,87 @@ afterAll(async () => { } }) -/** Assert that the complete floating card stays inside the zoomed visual viewport. */ -async function expectContained(panel: Locator): Promise { - await expect.poll(() => panel.evaluate((node) => { - const view = window.visualViewport! +/** Pan the visual viewport to a row, then touch it without locator auto-scroll. */ +async function touchRow(cdp: CDPSession, row: Locator): Promise { + const target = await row.evaluate((node) => { const rect = node.getBoundingClientRect() - return Math.min(rect.left - view.offsetLeft, rect.top - view.offsetTop, - view.offsetLeft + view.width - rect.right, view.offsetTop + view.height - rect.bottom) - })).toBeGreaterThanOrEqual(11.5) + const view = window.visualViewport! + return { + left: Math.max(0, Math.min(window.innerWidth - view.width, rect.left + rect.width / 2 - view.width / 2)), + top: Math.max(0, Math.min(window.innerHeight - view.height, rect.top + rect.height / 2 - view.height / 2)), + offsetLeft: view.offsetLeft, offsetTop: view.offsetTop, + } + }) + await cdp.send('Input.synthesizeScrollGesture', { + x: 1, y: 1, xDistance: target.offsetLeft - target.left, yDistance: target.offsetTop - target.top, + gestureSourceType: 'mouse', speed: 800, + }) + await expect.poll(() => row.evaluate((node) => { + const rect = node.getBoundingClientRect() + const view = window.visualViewport! + const x = rect.left + rect.width / 2 - view.offsetLeft + const y = rect.top + rect.height / 2 - view.offsetTop + return x >= 0 && x < view.width && y >= 0 && y < view.height + })).toBe(true) + const point = await row.evaluate((node) => { + const rect = node.getBoundingClientRect() + return { x: rect.left + rect.width / 2 - window.visualViewport!.offsetLeft, + y: rect.top + rect.height / 2 - window.visualViewport!.offsetTop } + }) + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [point] }) + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }) } -/** Narrow labels ellipsize instead of increasing their row's width. */ -async function expectEllipsizedText(labels: Locator): Promise { +/** Labels retain ellipsis styling without increasing the list's width. */ +async function expectEllipsisStyle(labels: Locator): Promise { expect(await labels.evaluateAll(nodes => nodes.every((node) => { const style = getComputedStyle(node) return style.textOverflow === 'ellipsis' && style.overflowX === 'hidden' }))).toBe(true) - expect(await labels.evaluateAll(nodes => nodes.some(node => node.scrollWidth > node.clientWidth))).toBe(true) } describe.each([ - { locale: 'en-US', tasks: 'Automation tasks', time: 'Time', second: 'Second', zone: 'Time zone' }, - { locale: 'zh-CN', tasks: '自动化任务', time: '时间', second: '秒', zone: '时区' }, + { locale: 'en-US', tasks: 'Automation tasks', time: 'Time', second: 'Second', zone: 'Time zone', repeat: 'Repeat' }, + { locale: 'zh-CN', tasks: '自动化任务', time: '时间', second: '秒', zone: '时区', repeat: '重复' }, ])('automation pickers ($locale)', (copy) => { - it.each(['clock', 'zone'] as const)('ellipsizes the %s labels and keeps vertical choices reachable at 500% zoom', async (kind) => { + it('keeps the repeat menu inside the short layout viewport before and after zoom', async () => { + const context = await browser.newContext({ + viewport: { width: 360, height: 586 }, locale: copy.locale, timezoneId: 'Asia/Shanghai', + isMobile: true, hasTouch: true, + }) + const page = await context.newPage() + try { + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await revealNavigationDrawer(page, copy.locale === 'zh-CN' ? 'zh' : 'en') + await page.getByRole('button', { name: copy.tasks, exact: true }).click() + const detail = page.getByTestId('task-manager-page') + await detail.getByRole('button', { name: TITLE, exact: true }).click() + await page.evaluate(() => document.fonts.ready) + await detail.getByRole('button', { name: new RegExp(`^${copy.repeat}`) }).click() + const menu = page.getByRole('menu') + await expect.poll(() => menu.evaluate((node) => { + const rect = node.getBoundingClientRect() + return Math.min(rect.left, rect.top, innerWidth - rect.right, innerHeight - rect.bottom) + })).toBeGreaterThanOrEqual(11.5) + const unzoomed = await menu.boundingBox() + expect(unzoomed).not.toBeNull() + const cdp = await context.newCDPSession(page) + try { + await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 5 }) + await expect.poll(() => page.evaluate(() => visualViewport!.scale)).toBe(5) + await expect.poll(() => menu.boundingBox()).toEqual(unzoomed) + } finally { + await cdp.detach() + } + } catch (error) { + await saveFailureShot(page, `schedule-repeat-bounds-${copy.locale}`) + throw error + } finally { + await context.close() + } + }) + + it.each(['clock', 'zone'] as const)('keeps %s layout size, clipping and vertical choices reachable at 500% zoom', async (kind) => { const context = await browser.newContext({ viewport: { width: 390, height: 844 }, locale: copy.locale, timezoneId: 'Asia/Shanghai', isMobile: true, hasTouch: true, @@ -74,15 +131,28 @@ describe.each([ const panel = kind === 'clock' ? page.getByRole('dialog', { name: copy.time, exact: true }) : page.getByRole('menu') + const before = await panel.evaluate((node) => { + const rect = node.getBoundingClientRect() + return { width: rect.width, height: rect.height, left: rect.left, top: rect.top } + }) const cdp = await context.newCDPSession(page) await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 5 }) await expect.poll(() => page.evaluate(() => window.visualViewport!.scale)).toBe(5) - await expectContained(panel) + await expect.poll(() => panel.evaluate((node, original) => { + const rect = node.getBoundingClientRect() + return Math.max(Math.abs(rect.width - original.width), Math.abs(rect.height - original.height), + Math.abs(rect.left - original.left), Math.abs(rect.top - original.top)) + }, before)).toBeLessThan(1) + expect(await panel.evaluate(node => node.getBoundingClientRect().width > window.visualViewport!.width)).toBe(true) expect(await panel.evaluate(node => node.scrollWidth <= node.clientWidth)).toBe(true) if (kind === 'clock') { - await expectEllipsizedText(panel.locator('[role="option"] > span')) + await expectEllipsisStyle(panel.locator('[role="option"] > span')) const columns = panel.getByRole('listbox') + expect(await columns.evaluateAll(nodes => nodes.every((node) => { + const style = getComputedStyle(node) + return style.overscrollBehaviorX === 'auto' && style.overscrollBehaviorY === 'contain' + }))).toBe(true) expect(await columns.evaluateAll(nodes => nodes.every(node => node.scrollWidth <= node.clientWidth))).toBe(true) const seconds = panel.getByRole('listbox', { name: copy.second, exact: true }) @@ -97,10 +167,15 @@ describe.each([ return label.left >= column.left && label.right <= column.right && label.top >= column.top && label.bottom <= column.bottom })).toBe(true) - await last.click() + await touchRow(cdp, last) await expect.poll(() => trigger.textContent()).toMatch(/:59$/) } else { - await expectEllipsizedText(panel.locator('[role="menuitem"] > span')) + const labels = panel.locator('[role="menuitem"] > span') + await expectEllipsisStyle(labels) + // Long English names exercise overflow; translated labels may fit the layout-sized menu. + if (copy.locale === 'en-US') { + expect(await labels.evaluateAll(nodes => nodes.some(node => node.scrollWidth > node.clientWidth))).toBe(true) + } const viewport = panel.locator('[role="presentation"]').last() expect(await viewport.evaluate((node) => { node.scrollTop = node.scrollHeight @@ -113,7 +188,7 @@ describe.each([ return row.left >= viewport.left && row.right <= viewport.right && row.top >= viewport.top && row.bottom <= viewport.bottom })).toBe(true) - await last.click() + await touchRow(cdp, last) await panel.waitFor({ state: 'hidden' }) } expect(consoleWatch.pageErrors).toEqual([]) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index d8b767960c..9119cd3235 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -423,7 +423,10 @@ describe('web e2e: seeded history renders through cold resume', () => { await scaffold.ctx.sessions.flush(agent.session) expect(agent.session.snapshotEvents()).toContainEqual(injected) await waitForContextInClient(page, injected.seq) - expect(await page.locator('[data-chat-flow-kind="context"]').count()).toBe(0) + const contextRows = page.locator('[data-chat-flow-kind="context"]') + await expect.poll(() => contextRows.count()).toBe(1) + expect(await contextRows.getByRole('button', { name: /^Context injection/ }).getAttribute('aria-expanded')).toBe('false') + expect(await contextRows.locator('[data-context-injection-body]').count()).toBe(0) }, 60_000) it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => { @@ -479,7 +482,7 @@ describe('web e2e: seeded history renders through cold resume', () => { } }) - it.skipIf(MODE === 'record')('omits ordinary instructions from Chat while retaining their logged content', async () => { + it.skipIf(MODE === 'record')('shows ordinary instructions as one collapsed Context row that expands to their logged content', async () => { const session = scaffold.ctx.sessions.get(SessionId(SEED_ID)) if (session === undefined) throw new Error('seeded session is unavailable') const instructions = session.snapshotEvents().filter(event => event.type === 'user/message' @@ -487,7 +490,18 @@ describe('web e2e: seeded history renders through cold resume', () => { expect(instructions).toHaveLength(1) expect(instructions[0]?.data).toMatchObject({ source: { form: 'instructions', changes: [{ path: 'AGENTS.md' }] } }) expect(JSON.stringify(instructions[0]?.data)).toContain('Instruction 24: preserve the logged context contract.') - expect(await page.locator('[data-chat-flow-kind="context"], [data-context-injection-body]').count()).toBe(0) + const row = page.locator('[data-chat-flow-kind="context"]') + expect(await row.count()).toBe(1) + const disclosure = row.getByRole('button', { name: /^Context injection/ }) + expect(await disclosure.getAttribute('aria-expanded')).toBe('false') + await disclosure.click() + try { + await expect.poll(() => row.locator('[data-context-injection-body]').textContent()) + .toContain('Instruction 24: preserve the logged context contract.') + } finally { + await disclosure.click() + } + await expect.poll(() => row.locator('[data-context-injection-body]').count()).toBe(0) }) it.skipIf(MODE === 'record')('restores the active turn rail mark across Chat and Trajectory', async () => { @@ -797,9 +811,11 @@ describe('web e2e: seeded history renders through cold resume', () => { } }, 60_000) - it.skipIf(MODE === 'record')('keeps short injected context out of Chat without dropping the event', async () => { + it.skipIf(MODE === 'record')('shows short injected context as a collapsed Context row without dropping the event', async () => { const agent = scaffold.ctx.agents.get(SessionId(SEED_ID)) if (agent === undefined) throw new Error('seeded session did not attach an agent') + const contextRows = page.locator('[data-chat-flow-kind="context"]') + const before = await contextRows.count() const injected = agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Short injected context.' }], source: { kind: 'fixture' }, @@ -808,8 +824,16 @@ describe('web e2e: seeded history renders through cold resume', () => { await scaffold.ctx.sessions.flush(agent.session) expect(agent.session.snapshotEvents()).toContainEqual(injected) await waitForContextInClient(page, injected.seq) - expect(await page.locator('[data-chat-flow-kind="context"]').count()).toBe(0) - expect(await page.getByText('Short injected context.', { exact: true }).count()).toBe(0) + await expect.poll(() => contextRows.count()).toBe(before + 1) + const row = contextRows.last() + const disclosure = row.getByRole('button', { name: /^Context injection/ }) + expect(await disclosure.getAttribute('aria-expanded')).toBe('false') + await disclosure.click() + try { + await expect.poll(() => row.locator('[data-context-injection-body]').getByText('Short injected context.', { exact: true }).count()).toBe(1) + } finally { + await disclosure.click() + } }) it.skipIf(MODE === 'record')('restores the recorded file preview in its original tab after page reload', async () => { diff --git a/apps/web/tests/server-restart.e2e.ts b/apps/web/tests/server-restart.e2e.ts index 736165c2dc..d5b3c180cb 100644 --- a/apps/web/tests/server-restart.e2e.ts +++ b/apps/web/tests/server-restart.e2e.ts @@ -84,6 +84,24 @@ class RestartableServer { logs(): string { return this.output.replace(/([?&]token=)[A-Za-z0-9_-]+/gu, '$1') } } +type HostRpc = (endpoint: string, args: object) => Promise + +/** Authenticate against one server process and return its Host RPC caller. */ +async function hostRpc(url: string): Promise { + const authenticated = await fetch(url, { redirect: 'manual' }) + const cookie = authenticated.headers.get('set-cookie')?.split(';', 1)[0] + if (cookie === undefined) throw new Error('Server did not issue an authentication cookie') + return async (endpoint: string, args: object): Promise => { + const response = await fetch(new URL('/api/' + endpoint, url), { + method: 'POST', headers: { 'content-type': 'application/json', cookie }, + body: JSON.stringify({ type: 'client-request', rpcId: randomUUID(), method: endpoint, payload: { args } }), + }) + const result = await response.json() as { result: { ok: boolean; value: T; error?: { message: string } } } + if (!result.result.ok) throw new Error(`${endpoint}: ${String(result.result.error?.message)}`) + return result.result.value + } +} + it.each([false, true])('keeps the same revision, Session and page across a server restart (delayed startup: %s)', async (holdStartup) => { const world = await mkdtemp(join(tmpdir(), 'dsh-server-restart-')) onTestFinished(() => rm(world, { recursive: true, force: true })) @@ -118,18 +136,7 @@ it.each([false, true])('keeps the same revision, Session and page across a serve const url = await server.start(0) const port = Number(new URL(url).port) - const authenticated = await fetch(url, { redirect: 'manual' }) - const cookie = authenticated.headers.get('set-cookie')?.split(';', 1)[0] - if (cookie === undefined) throw new Error('Server did not issue an authentication cookie') - const rpc = async (endpoint: string, args: object): Promise => { - const response = await fetch(new URL('/api/' + endpoint, url), { - method: 'POST', headers: { 'content-type': 'application/json', cookie }, - body: JSON.stringify({ type: 'client-request', rpcId: randomUUID(), method: endpoint, payload: { args } }), - }) - const result = await response.json() as { result: { ok: boolean; value: T; error?: { message: string } } } - if (!result.result.ok) throw new Error(`${endpoint}: ${String(result.result.error?.message)}`) - return result.result.value - } + const rpc = await hostRpc(url) await rpc('workspace/create', { request: { path: workspacePath } }) const scenarioId = randomUUID() @@ -234,3 +241,30 @@ it.each([false, true])('keeps the same revision, Session and page across a serve expect(streamErrors).toEqual([]) expect(navigations).toBe(0) }) + +it('keeps a live blank Session blank across a graceful server restart', async () => { + const world = await mkdtemp(join(tmpdir(), 'dsh-server-restart-')) + onTestFinished(() => rm(world, { recursive: true, force: true })) + // The scenario runs no model turn, so the model endpoint is never contacted. + const server = new RestartableServer(world, 'http://127.0.0.1:9') + onTestFinished(() => server.stop()) + const workspacePath = join(world, 'workspace') + await mkdir(workspacePath) + onTestFailed(async () => { + await writeFile(join(REPO_ROOT, '.artifacts', 'server-restart-blank-' + randomUUID() + '.json'), JSON.stringify({ server: server.logs() }, null, 2)) + }) + + const before = await hostRpc(await server.start(0)) + const { workspace } = await before<{ workspace: { id: string } }>('workspace/create', { request: { path: workspacePath } }) + const { sessionId } = await before<{ sessionId: string }>('session/create', { request: { workspaceId: workspace.id } }) + const listed = async (rpc: HostRpc) => (await rpc<{ + items: { sessionId: string; blank: boolean; agentAvailable: boolean }[] + }>('session/list', { _request: {} })).items.find(item => item.sessionId === sessionId) + expect(await listed(before)).toMatchObject({ blank: true, agentAvailable: true }) + + // SIGTERM unloads the Web layer, including the list projection, before its live Session detaches; + // stopping right after creation also precedes the throttled checkpoint of the Session's setup events. + await server.stop() + const after = await hostRpc(await server.start(0)) + expect(await listed(after)).toMatchObject({ blank: true, agentAvailable: false }) +}) diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 48ca2546c6..1e0ae9dfad 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -473,7 +473,6 @@ function assertLeanChildRecord(agent: Agent): void { * Agent Note owns the rationale and its sources. */ const EXPECTED_TOOLS = [ - 'apply_patch', 'ask_user_question', 'bash', 'create_goal', diff --git a/apps/web/tests/sidebar-alignment.e2e.ts b/apps/web/tests/sidebar-alignment.e2e.ts index 82afdd29f2..ed5fced520 100644 --- a/apps/web/tests/sidebar-alignment.e2e.ts +++ b/apps/web/tests/sidebar-alignment.e2e.ts @@ -1,5 +1,7 @@ /** Browser geometry for sidebar controls, text clearance, and drag previews. */ -import { readFile } from 'node:fs/promises' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' @@ -179,6 +181,44 @@ describe('web e2e: sidebar search alignment and row action targets', () => { expect(tripwire.warnings).toEqual([]) }) + it.each(['Search sessions', 'View options', 'Add workspace'])('keeps the %s tooltip visible across its full bounds', async (name) => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-toolbar-tooltip')) + const trigger = page.getByRole('button', { name, exact: true }) + await trigger.hover() + const tooltip = page.getByRole('tooltip') + await tooltip.waitFor({ state: 'visible', timeout: 10_000 }) + const bounds = await tooltip.evaluate((element: HTMLElement) => { + const rect = element.getBoundingClientRect() + const sidebar = document.querySelector('[class*="sidebarCol"]')!.getBoundingClientRect() + const originalStyle = element.getAttribute('style') + try { + // Tooltips ignore pointer input; enabling it only for this read exposes ancestor clipping. + element.style.pointerEvents = 'auto' + const points = [ + [rect.left + 2, rect.top + rect.height / 2], + [rect.right - 2, rect.top + rect.height / 2], + [rect.left + rect.width / 2, rect.top + 2], + [rect.left + rect.width / 2, rect.bottom - 2], + ] + return { + outsideSidebar: rect.right > sidebar.right, + hits: points.map(([x, y]) => { + const hit = document.elementFromPoint(x!, y!) + return hit !== null && element.contains(hit) + }), + } + } finally { + if (originalStyle === null) element.removeAttribute('style') + else element.setAttribute('style', originalStyle) + } + }) + if (name === 'Add workspace') expect(bounds.outsideSidebar).toBe(true) + expect(bounds.hits).toEqual([true, true, true, true]) + await page.mouse.move(1000, 400) + await tooltip.waitFor({ state: 'detached' }) + expect(tripwire.pageErrors).toEqual([]) + }) + it('interpolates header spacing with the search expansion and collapse', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-search-transition')) const header = page.locator('[class*="sectionHeader"]').filter({ has: page.getByRole('button', { name: 'Search sessions', exact: true }) }) @@ -368,6 +408,55 @@ describe('web e2e: sidebar search alignment and row action targets', () => { expect(tripwire.pageErrors).toEqual([]) }) + it('spans the first root workspace with its insertion marker during a native drag', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-first-workspace-drop')) + const directory = await mkdtemp(join(tmpdir(), 'dsh-sidebar-drag-')) + try { + const extra = await scaffold.ctx.workspaceRegistry.create(directory, 'Sidebar drag workspace') + try { + const rows = page.locator('[role="tree"][aria-label="Sessions"] [data-row-key^="workspace:"]') + await expect.poll(() => rows.count()).toBe(2) + const before = await rows.evaluateAll(elements => elements.map(element => element.getAttribute('data-row-key'))) + await rows.last().hover() + const source = await rows.last().boundingBox() + const target = await rows.first().boundingBox() + if (source === null || target === null) throw new Error('Workspace drag rows have no layout boxes') + await page.mouse.move(source.x + 30, source.y + source.height / 2) + await page.mouse.down() + try { + await page.mouse.move(source.x + 40, source.y + source.height / 2, { steps: 3 }) + await page.mouse.move(target.x + 30, target.y + 2, { steps: 5 }) + await page.mouse.move(target.x + 31, target.y + 2) + const marker = page.locator('[class*="listTopDropIndicator"]') + await marker.waitFor({ state: 'attached', timeout: 10_000 }) + const geometry = await marker.evaluate((element) => { + const rect = element.getBoundingClientRect() + const tree = element.parentElement!.querySelector('[role="tree"]')!.getBoundingClientRect() + const first = element.parentElement!.querySelector('[data-row-key^="workspace:"]')!.getBoundingClientRect() + return { + width: rect.width, height: rect.height, + treeWidth: tree.width, leftInset: rect.left - first.left, + rightInset: first.right - rect.right, topOffset: rect.top - first.top, + } + }) + expect(geometry.width).toBeGreaterThan(0) + expect(geometry.width).toBe(geometry.treeWidth) + expect(geometry).toMatchObject({ height: 12, leftInset: 0, rightInset: 0, topOffset: -8 }) + } finally { + await page.mouse.up() + } + await expect.poll(() => rows.evaluateAll(elements => elements.map(element => element.getAttribute('data-row-key')))) + .toEqual(before.toReversed()) + } finally { + await scaffold.ctx.workspaceRegistry.delete(extra.id) + } + } finally { + await rm(directory, { recursive: true, force: true }) + } + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + it.each(['session', 'workspace'] as const)('retains %s row spacing in the native drag preview', async (kind) => { onTestFailed(() => saveFailureShot(page, `web-e2e-sidebar-${kind}-drag-preview`)) const row = kind === 'session' @@ -496,7 +585,7 @@ describe('web e2e: sidebar search alignment and row action targets', () => { await row.getByRole('button', { name: labels[0], exact: true }).waitFor() const metrics = await measureRowActions(row) expect(await row.boundingBox()).toEqual(restingBox) - expect(metrics.height).toBe(kind === 'session' ? 32 : 34) + expect(metrics.height).toBe(32) expect(metrics.overflows).toBe(false) expect(metrics.actions.map(action => action.label)).toEqual(labels) for (const [index, action] of metrics.actions.entries()) { diff --git a/apps/web/tests/sidebar-scrollbar.e2e.ts b/apps/web/tests/sidebar-scrollbar.e2e.ts index afcedbf0ca..534a1292aa 100644 --- a/apps/web/tests/sidebar-scrollbar.e2e.ts +++ b/apps/web/tests/sidebar-scrollbar.e2e.ts @@ -56,7 +56,7 @@ interface ListMetrics { */ function measureList(page: Page): Promise { return page.evaluate(() => { - const list = document.querySelector('[role="tree"][aria-label="Sessions"]') + const list = document.querySelector('[data-sidebar-list-area]') if (list === null) throw new Error('sidebar session list not in the DOM') const time = list.querySelector('[class*="time"]') if (time === null) throw new Error('no row relative-time element in the sidebar list') @@ -94,8 +94,8 @@ function measureList(page: Page): Promise { const pseudoWidth = getComputedStyle(list, '::-webkit-scrollbar').width const barWidth = pseudoWidth === 'auto' ? 15 : Number.parseFloat(pseudoWidth) const listRect = list.getBoundingClientRect() - const listParentRight = list.parentElement?.getBoundingClientRect().right - if (listParentRight === undefined) throw new Error('sidebar session list has no layout parent') + const sidebarEdge = list.closest('[class*="sidebarCol"]')?.getBoundingClientRect().right + if (sidebarEdge === undefined) throw new Error('sidebar session list has no layout parent') return { gutter: style.scrollbarGutter, width: pseudoWidth, @@ -107,9 +107,9 @@ function measureList(page: Page): Promise { hoverToken: resolve('--dsh-scrollbar-thumb-hover'), overflows: list.scrollHeight > list.clientHeight, band: listRect.width - list.clientWidth, - scrollbarEdgeOffset: listParentRight - listRect.right, - rowEdgeInset: listParentRight - row.getBoundingClientRect().right, - rowPaintInset: listParentRight - row.getBoundingClientRect().right + Number.parseFloat(getComputedStyle(row).borderRightWidth), + scrollbarEdgeOffset: sidebarEdge - listRect.right, + rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right, + rowPaintInset: sidebarEdge - row.getBoundingClientRect().right + Number.parseFloat(getComputedStyle(row).borderRightWidth), clientRight: listRect.left + list.clientWidth, borderRight: listRect.right, timeRight: time.getBoundingClientRect().right, @@ -133,16 +133,16 @@ function measureList(page: Page): Promise { */ function measureRowInset(page: Page): Promise> { return page.evaluate(() => { - const list = document.querySelector('[role="tree"][aria-label="Sessions"]') + const list = document.querySelector('[data-sidebar-list-area]') if (list === null) throw new Error('sidebar session list not in the DOM') const row = list.querySelector('[role="treeitem"]') if (row === null) throw new Error('no row in the sidebar list') - const listParentRight = list.parentElement?.getBoundingClientRect().right - if (listParentRight === undefined) throw new Error('sidebar session list has no layout parent') + const sidebarEdge = list.closest('[class*="sidebarCol"]')?.getBoundingClientRect().right + if (sidebarEdge === undefined) throw new Error('sidebar session list has no layout parent') return { overflows: list.scrollHeight > list.clientHeight, - rowEdgeInset: listParentRight - row.getBoundingClientRect().right, - rowPaintInset: listParentRight - row.getBoundingClientRect().right + Number.parseFloat(getComputedStyle(row).borderRightWidth), + rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right, + rowPaintInset: sidebarEdge - row.getBoundingClientRect().right + Number.parseFloat(getComputedStyle(row).borderRightWidth), } }) } @@ -195,9 +195,9 @@ function renderGeometry(light: PaletteMetrics, dark: PaletteMetrics): string { `- --dsh-scrollbar-thumb-hover, pointer over the list: ${metrics.hoverToken}`, `- list overflows: ${String(metrics.overflows)}`, `- reserved band: ${String(metrics.band)}px`, - `- scrollbar inset from the list parent edge: ${String(metrics.scrollbarEdgeOffset)}px`, - `- row hitbox inset from the list parent edge: ${String(metrics.rowEdgeInset)}px`, - `- row background inset from the list parent edge: ${String(metrics.rowPaintInset)}px`, + `- scrollbar inset from the sidebar edge: ${String(metrics.scrollbarEdgeOffset)}px`, + `- row hitbox inset from the sidebar edge: ${String(metrics.rowEdgeInset)}px`, + `- row background inset from the sidebar edge: ${String(metrics.rowPaintInset)}px`, `- relative time covered by the bar: ${String(metrics.timeCoveredBy)}px`, `- relative time ends inside the content area: ${String(metrics.timeRight <= metrics.clientRight)}`, `- content area ends before the border box: ${String(metrics.clientRight < metrics.borderRight)}`, @@ -220,7 +220,7 @@ function renderGeometry(light: PaletteMetrics, dark: PaletteMetrics): string { */ function resolveThumb(page: Page): Promise { return page.evaluate(() => { - const list = document.querySelector('[role="tree"][aria-label="Sessions"]') + const list = document.querySelector('[data-sidebar-list-area]') if (list === null) throw new Error('sidebar session list not in the DOM') const probe = document.createElement('span') probe.style.color = 'var(--dsh-scrollbar-thumb)' @@ -243,7 +243,7 @@ const NO_THUMB = 'rgba(0, 0, 0, 0)' * of the viewport (the conversation column). */ async function pointAt(page: Page, where: 'list' | 'away'): Promise { - const box = await page.locator('[role="tree"][aria-label="Sessions"]').boundingBox() + const box = await page.locator('[data-sidebar-list-area]').boundingBox() if (box === null) throw new Error('sidebar session list has no layout box') const viewport = page.viewportSize() if (viewport === null) throw new Error('page has no viewport') @@ -272,18 +272,63 @@ async function expandSeededSessions(page: Page): Promise { } const showMore = page.getByRole('button', { name: /Show \d+ more sessions/ }) if (await bucket.getAttribute('aria-expanded') === 'true' - && await rows.count() <= SEED_COUNT / 2 + && await rows.count() < SEED_COUNT && await showMore.count() > 0) { await showMore.click() } - if (await bucket.getAttribute('aria-expanded') === 'true' && await rows.count() > SEED_COUNT / 2) return + if (await bucket.getAttribute('aria-expanded') === 'true' && await rows.count() >= SEED_COUNT) return if (Date.now() > deadline) { - throw new Error(`Ungrouped bucket never revealed more than ${SEED_COUNT / 2} rows`) + throw new Error(`Ungrouped bucket never revealed all ${SEED_COUNT} rows`) } await page.waitForTimeout(200) } } +/** + * Scroll positions of the sidebar viewport and the session list, with the fixed controls around them. + * @param page - the page under test. + * @returns both scroll offsets and ranges, plus the toolbar, navigation, and fixed-control edges. + */ +function nestedScrollGeometry(page: Page) { + return page.evaluate(() => { + const viewport = document.querySelector('[data-sidebar-scrollport]')! + const list = document.querySelector('[data-sidebar-list-area]')! + const column = viewport.closest('[class*="sidebarCol"]')! + const edge = (selector: string) => column.querySelector(selector)!.getBoundingClientRect() + return { + viewportTop: viewport.getBoundingClientRect().top, + viewportScroll: viewport.scrollTop, + viewportRange: viewport.scrollHeight - viewport.clientHeight, + listScroll: list.scrollTop, + listRange: list.scrollHeight - list.clientHeight, + headerTop: edge('[data-sidebar-workspace-header]').top, + navBottom: edge('[class*="panelList"]').bottom, + buttonTop: edge('button[class*="newSession"]').top, + footerTop: edge('[class*="footArea"]').top, + } + }) +} + +/** + * Return both scrollers to their tops. + * @param page - the page under test. + */ +async function resetNestedScroll(page: Page): Promise { + await page.locator('[data-sidebar-list-area]').evaluate((el) => { el.scrollTop = 0 }) + await page.locator('[data-sidebar-scrollport]').evaluate((el) => { el.scrollTop = 0 }) +} + +/** + * Park the pointer at the center of the first element matching `selector`. + * @param page - the page under test. + * @param selector - the element to point at. + */ +async function pointInto(page: Page, selector: string): Promise { + const box = await page.locator(selector).first().boundingBox() + if (box === null) throw new Error(`${selector} has no layout box`) + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) +} + describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thumb)', () => { let scaffold: WebScaffold let browser: Browser @@ -322,6 +367,12 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum await expect.poll(async () => (await measureList(page)).overflows, { timeout: 10_000 }).toBe(true) const metrics = await measureList(page) expect(metrics.gutter).toBe('stable') + const horizontal = await page.locator('[data-sidebar-scrollport]').evaluate(viewport => ({ + overflow: getComputedStyle(viewport).overflowX, + scrollbarHeight: viewport.getBoundingClientRect().height - viewport.clientHeight, + })) + expect(horizontal.overflow).toBe('hidden') + expect(horizontal.scrollbarHeight).toBe(0) // Pin presence, not width, because the width is platform-dependent. expect(metrics.band).toBeGreaterThan(0) expect(metrics.scrollbarEdgeOffset).toBe(2) @@ -354,7 +405,7 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum // leaves the column quiet. This is the one deliberate loss, and // it is pinned here rather than only described, so making a scroll // re-reveal the bar has to be a decision rather than a side effect. - await page.locator('[role="tree"][aria-label="Sessions"]').evaluate((el) => { el.scrollTop += 200 }) + await page.locator('[data-sidebar-list-area]').evaluate((el) => { el.scrollTop += 200 }) await page.waitForTimeout(500) expect(await resolveThumb(page)).toBe(NO_THUMB) await pointAt(page, 'list') @@ -362,16 +413,103 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('keeps the row background inset when overflow disappears', async () => { + it('removes both scroll ranges without moving row edges when grouped content fits', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-stable-inset')) + const viewport = page.locator('[data-sidebar-list-area]') + const height = await viewport.evaluate(el => el.scrollHeight) expect(await measureRowInset(page)).toEqual({ overflows: true, rowEdgeInset: 8, rowPaintInset: 11 }) const bucket = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..') await bucket.click() try { - await expect.poll(async () => (await measureRowInset(page)).overflows, { timeout: 10_000 }).toBe(false) + await expect.poll(async () => viewport.evaluate(el => el.scrollHeight)).toBeLessThan(height) + await expect.poll(async () => (await measureRowInset(page)).overflows).toBe(false) expect(await measureRowInset(page)).toEqual({ overflows: false, rowEdgeInset: 8, rowPaintInset: 11 }) + // A section that fits leaves the sidebar viewport nothing to scroll, so the navigation stays. + expect((await nestedScrollGeometry(page)).viewportRange).toBe(0) } finally { await expandSeededSessions(page) + await viewport.evaluate((el) => { el.scrollTop = 0 }) + } + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('hands one downward wheel event from the navigation to the session list and back', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-nested-scroll')) + const size = page.viewportSize()! + try { + await page.setViewportSize({ width: size.width, height: 500 }) + await resetNestedScroll(page) + const before = await nestedScrollGeometry(page) + expect(before.viewportRange).toBeGreaterThan(0) + expect(before.listRange).toBeGreaterThan(60) + expect(before.headerTop).toBeGreaterThan(before.viewportTop) + await pointInto(page, '[data-sidebar-list-area] [role="treeitem"]') + await page.mouse.wheel(0, before.viewportRange + 60) + await expect.poll(async () => (await nestedScrollGeometry(page)).listScroll).toBe(60) + const after = await nestedScrollGeometry(page) + expect(after.viewportScroll).toBe(before.viewportRange) + expect(after.headerTop).toBe(after.viewportTop) + expect(after.navBottom).toBeLessThanOrEqual(after.viewportTop) + expect(after.buttonTop).toBe(before.buttonTop) + expect(after.footerTop).toBe(before.footerTop) + await page.mouse.wheel(0, -110) + await expect.poll(async () => (await nestedScrollGeometry(page)).viewportScroll).toBe(before.viewportRange - 50) + expect((await nestedScrollGeometry(page)).listScroll).toBe(0) + } finally { + await page.setViewportSize(size) + await resetNestedScroll(page) + } + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('continues a wheel event that starts over the navigation into the session list', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-nested-scroll-navigation')) + const size = page.viewportSize()! + try { + await page.setViewportSize({ width: size.width, height: 500 }) + await resetNestedScroll(page) + const before = await nestedScrollGeometry(page) + await pointInto(page, '[data-sidebar-scrollport] [class*="panelList"]') + await page.mouse.wheel(0, before.viewportRange + 30) + await expect.poll(async () => (await nestedScrollGeometry(page)).listScroll).toBe(30) + expect((await nestedScrollGeometry(page)).viewportScroll).toBe(before.viewportRange) + } finally { + await page.setViewportSize(size) + await resetNestedScroll(page) + } + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('keeps the toolbar at the top through empty search results', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-nested-scroll-search')) + const header = page.locator('[data-sidebar-workspace-header]') + const input = header.locator('input') + const size = page.viewportSize()! + try { + await page.setViewportSize({ width: size.width, height: 500 }) + await resetNestedScroll(page) + const range = (await nestedScrollGeometry(page)).viewportRange + await pointInto(page, '[data-sidebar-list-area] [role="treeitem"]') + await page.mouse.wheel(0, range) + await expect.poll(async () => { + const geometry = await nestedScrollGeometry(page) + return geometry.headerTop === geometry.viewportTop + }).toBe(true) + const searchButton = await page.getByRole('button', { name: 'Search sessions', exact: true }).boundingBox() + if (searchButton === null) throw new Error('Search sessions has no layout box') + await page.mouse.click(searchButton.x + searchButton.width / 2, searchButton.y + searchButton.height / 2) + expect(await input.evaluate(el => el === document.activeElement)).toBe(true) + await input.fill('no-sidebar-scroll-matches-9f4c') + await page.locator('[class*="searchTree"]').locator('..').locator('[class*="empty"]').waitFor() + const filtered = await nestedScrollGeometry(page) + expect(filtered.viewportScroll).toBe(range) + expect(filtered.headerTop).toBe(filtered.viewportTop) + expect((await header.boundingBox())?.height).toBe(36) + } finally { + if (await input.isVisible()) { await input.fill(''); await input.press('Escape') } + await page.setViewportSize(size) + await expandSeededSessions(page) + await resetNestedScroll(page) } expect(tripwire.pageErrors).toEqual([]) }, 60_000) diff --git a/apps/web/tests/skill-user-invoke.e2e.ts b/apps/web/tests/skill-user-invoke.e2e.ts index a7788ea7d8..c25d67d5f1 100644 --- a/apps/web/tests/skill-user-invoke.e2e.ts +++ b/apps/web/tests/skill-user-invoke.e2e.ts @@ -130,7 +130,12 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro // every later Node rebuild of the Turn (process publication, turn close). expect(await bubble.count()).toBe(1) expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`) - expect(await page.locator('[data-chat-flow-kind="context"]').count()).toBe(0) + // The loaded instructions stay out of the bubble as their own collapsed Context row. + // The folded Turn process holds it, so match the row without requiring it to be visible. + const skillContext = page.locator('[data-chat-flow-kind="context"] [data-disclosure-row]') + .filter({ hasText: new RegExp(`^Context injection${SKILL_NAME}`) }) + expect(await skillContext.count()).toBe(1) + expect(await skillContext.getAttribute('aria-expanded')).toBe('false') const session = scaffold.ctx.sessions.get(sessionId) if (session === undefined) throw new Error('skill invocation session is unavailable') const injected = session.snapshotEvents().flatMap(event => event.type === 'user/message' diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index d763d6847a..e2992e2a63 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -706,6 +706,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke let browser: Browser let page: Page const pageErrors: string[] = [] + // The product boot only warns about inactive optional entries; this lane requires the whole composition. + const bootWarnings: string[] = [] beforeAll(async () => { requireDist() @@ -742,6 +744,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke browser = await chromium.launch() page = await newEnglishPage(browser) page.on('pageerror', e => pageErrors.push(String(e))) + page.on('console', (message) => { + if (message.text().startsWith('web boot: warning')) bootWarnings.push(message.text()) + }) await page.goto(baseUrl, { waitUntil: 'load' }) }, 120_000) @@ -760,6 +765,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke onTestFailed(() => saveFailureShot(page, 'w5-cold-start')) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) expect(await page.locator('text=Failed to load plugins').count()).toBe(0) + expect(bootWarnings).toEqual([]) const template = await page.locator('[class*="frame"]').evaluate(el => getComputedStyle(el).gridTemplateColumns) expect(template.split(' ').length).toBe(3) await screen(page, '01-cold-start') diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md index e4e2f71b2c..6162e9e477 100644 --- a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md +++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md @@ -9,6 +9,8 @@ - button "More conversation views" - text: Stream one TypeScript fence for the highlighting snapshot. {{clock}} - button "Copy" +- button "Context injection runtime-context" +- button "Context injection time-context" - text: ts - button "Wrap lines" [pressed] - button "Copy" diff --git a/apps/web/tests/web-search-round.e2e.ts b/apps/web/tests/web-search-round.e2e.ts index f831fb6324..a4cbfc5207 100644 --- a/apps/web/tests/web-search-round.e2e.ts +++ b/apps/web/tests/web-search-round.e2e.ts @@ -1,7 +1,8 @@ -// Web e2e scenario for the shipped default search composition. A real browser -// drives `web_search`; the model stream is replayed while the real DeepSeek -// provider calls a deterministic local Search API endpoint through -// the real credentials service. +// Web e2e scenario for the shipped default search provider, the DeepSeek +// Search API, with no provider selection patched. A real browser drives +// `web_search`; the model stream is replayed while the real DeepSeek provider +// calls a deterministic local Search API endpoint through the real +// credentials service. import { readFile } from 'node:fs/promises' import { createServer, type Server } from 'node:http' import type { AddressInfo } from 'node:net' @@ -140,7 +141,7 @@ async function startSearchServer(captured: CapturedSearchRequest[]): Promise<{ s return { server, baseURL: `http://127.0.0.1:${address.port}` } } -describe('web e2e: opt-in Search API web search', () => { +describe('web e2e: default Search API web search', () => { let scaffold: WebScaffold let browser: Browser let page: Page @@ -155,10 +156,10 @@ describe('web e2e: opt-in Search API web search', () => { scaffold = await launchWebScaffold({ extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), compareReplaySession: true, + // Own YAML endpoint settings pin Search to the local endpoint and a dedicated key. deepSeekWebApi: { baseURL: `${search.baseURL}/api/v1/tools`, apiKeyEnv: SEARCH_CREDENTIAL_REF, - select: true, }, ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }), }) diff --git a/apps/web/tests/worktree-opt-in.e2e.ts b/apps/web/tests/worktree-opt-in.e2e.ts index 37b94a16ec..341d72c68f 100644 --- a/apps/web/tests/worktree-opt-in.e2e.ts +++ b/apps/web/tests/worktree-opt-in.e2e.ts @@ -1,4 +1,4 @@ -/** Git Worktrees opt-in through the shipped plugin manager, profile, and Loader. */ +/** Unified review and worktree opt-in through the shipped plugin manager, profile, and Loader. */ import { execFile } from 'node:child_process' import { readFile, writeFile } from 'node:fs/promises' @@ -6,16 +6,19 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { FiberState } from '@deepseek-ai/cordis' +import { ToolCallId } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-experimental-worktree' +import type {} from '@deepseek-ai/dsh-experimental-client-ui-forge' import { chromium } from 'playwright' import { expect, it, onTestFailed, onTestFinished } from 'vitest' import { launchWebScaffold, watchConsole } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, PLUGIN_TOGGLE_SETTLE_MS, saveFailureShot } from './support.ts' -const BUNDLE = '@deepseek-ai/dsh-experimental-tool-worktree' +const BUNDLE = '@deepseek-ai/dsh-experimental-forge-bundle' +const STANDALONE = '@deepseek-ai/dsh-experimental-tool-worktree' const exec = promisify(execFile) -it('persists the Git Worktrees switch and removes its capability while retaining checkouts', async () => { +it('persists the unified review and worktree switch and retains checkouts after disabling', async () => { const scaffold = await launchWebScaffold({ extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), }) @@ -70,13 +73,15 @@ it('persists the Git Worktrees switch and removes its capability while retaining } let panel = await openPlugins() let card = panel.locator(`[data-plugin-group="more"] [data-plugin-package="${BUNDLE}"]`) - await card.getByRole('button', { name: 'View Git worktrees', exact: true }).waitFor() + await card.getByRole('button', { name: 'View Change requests & worktrees', exact: true }).waitFor() + expect(await panel.locator(`[data-plugin-package="${STANDALONE}"]`).count()).toBe(0) expect(await card.getByText('Experimental', { exact: true }).count()).toBe(1) - let toggle = card.getByRole('switch', { name: 'Enable Git worktrees', exact: true }) + let toggle = card.getByRole('switch', { name: 'Enable Change requests & worktrees', exact: true }) expect(await toggle.getAttribute('aria-checked')).toBe('false') expect(await selectedBundles()).not.toContain(BUNDLE) expect(rows()).toEqual([]) expect(scaffold.ctx.get('worktrees')).toBeUndefined() + expect(scaffold.ctx.get('forgePanel')).toBeUndefined() expect(tools()).toContain('working_directory') expect(tools()).not.toContain('create_worktree') @@ -85,14 +90,21 @@ it('persists the Git Worktrees switch and removes its capability while retaining await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('true') expect(await selectedBundles()).toContain(BUNDLE) expect(tools()).toContain('create_worktree') - const created = await scaffold.ctx.worktrees.create(agent, { name: 'gui-opt-in' }) + expect(scaffold.ctx.get('forgePanel')).toBeDefined() + const created = { path: join(repository, '.agents/worktrees/gui-opt-in'), baseCommit: (await git('rev-parse', 'HEAD')).stdout.trim() } + const result = await scaffold.ctx.tools.execute({ + agent, signal: new AbortController().signal, + calls: [{ type: 'tool-call', id: ToolCallId('gui-opt-in'), name: 'create_worktree', arguments: JSON.stringify({ name: 'gui-opt-in' }) }], + }) + expect(result.results[0]?.result.isError).not.toBe(true) + expect(result.results[0]?.result.value).toEqual({ ...created, branch: 'gui-opt-in', repositoryRoot: repository }) expect(await readFile(join(created.path, 'retained.txt'), 'utf8')).toBe('retained checkout\n') expect(scaffold.ctx.workingDirectory.get(agent.session)).toBe(created.path) await page.reload({ waitUntil: 'load' }) panel = await openPlugins() card = panel.locator(`[data-plugin-group="more"] [data-plugin-package="${BUNDLE}"]`) - toggle = card.getByRole('switch', { name: 'Enable Git worktrees', exact: true }) + toggle = card.getByRole('switch', { name: 'Enable Change requests & worktrees', exact: true }) await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') expect(tools()).toContain('create_worktree') await toggle.click() @@ -100,6 +112,7 @@ it('persists the Git Worktrees switch and removes its capability while retaining await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('false') expect(await selectedBundles()).not.toContain(BUNDLE) expect(scaffold.ctx.get('worktrees')).toBeUndefined() + expect(scaffold.ctx.get('forgePanel')).toBeUndefined() expect(tools()).not.toContain('create_worktree') expect(tools()).toContain('working_directory') expect(await readFile(join(created.path, 'retained.txt'), 'utf8')).toBe('retained checkout\n') @@ -108,7 +121,7 @@ it('persists the Git Worktrees switch and removes its capability while retaining await page.reload({ waitUntil: 'load' }) panel = await openPlugins() - await expect.poll(() => panel.getByRole('switch', { name: 'Enable Git worktrees', exact: true }) + await expect.poll(() => panel.getByRole('switch', { name: 'Enable Change requests & worktrees', exact: true }) .getAttribute('aria-checked')).toBe('false') expect(tools()).not.toContain('create_worktree') expect(await selectedBundles()).not.toContain(BUNDLE) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 2a35700aff..d7b38d2f2c 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -23,7 +23,6 @@ // this client-registered project must not also hold them (one program // cannot see both sides of the cordis Context merges). "exclude": [ - "tests/credential-editor.e2e.ts", "tests/sidebar-drawer.e2e.ts", "tests/creation-recommendations.e2e.ts", "tests/plugin-recommendations.e2e.ts", @@ -134,6 +133,7 @@ "tests/desktop-locale.e2e.ts", "tests/settings-import.e2e.ts", "tests/desktop-updates.e2e.ts", + "tests/desktop-channel.e2e.ts", "tests/developer-tools-settings.e2e.ts", "tests/models-settings.e2e.ts", "tests/deepseek-messages-settings.e2e.ts", diff --git a/docs/assets/ui-design-review/inline-metrics-comparison.png b/docs/assets/ui-design-review/inline-metrics-comparison.png new file mode 100644 index 0000000000..b232b03412 Binary files /dev/null and b/docs/assets/ui-design-review/inline-metrics-comparison.png differ diff --git a/docs/assets/ui-design-review/modal-grouping-comparison.png b/docs/assets/ui-design-review/modal-grouping-comparison.png new file mode 100644 index 0000000000..8b35e361c8 Binary files /dev/null and b/docs/assets/ui-design-review/modal-grouping-comparison.png differ diff --git a/docs/assets/ui-design-review/peer-content-comparison.png b/docs/assets/ui-design-review/peer-content-comparison.png new file mode 100644 index 0000000000..a7b433faad Binary files /dev/null and b/docs/assets/ui-design-review/peer-content-comparison.png differ diff --git a/docs/assets/ui-design-review/principles-alignment.svg b/docs/assets/ui-design-review/principles-alignment.svg new file mode 100644 index 0000000000..196ea4b0a4 --- /dev/null +++ b/docs/assets/ui-design-review/principles-alignment.svg @@ -0,0 +1,15 @@ + +Sidebar alignment follows roles and states + + +ALIGNMENT · schematic only; use the recorded master design for dimensions + + ++ New conversation + +NavigationNavigationWorkspacesProjectConversationConversation + + +Signed-in account +Center the primary action as a unit.Continue axes across intended peers.Header and row controls have distinct roles.Search expansion changes header geometry.Compare paint and hit targets separately.Keep the account row’s own geometry. + diff --git a/docs/assets/ui-design-review/principles-grouping.svg b/docs/assets/ui-design-review/principles-grouping.svg new file mode 100644 index 0000000000..4190b68203 --- /dev/null +++ b/docs/assets/ui-design-review/principles-grouping.svg @@ -0,0 +1 @@ +Section separation must exceed the heading-to-content gapGROUPING · schematic, not an application captureAmbiguous section boundaryMembersLeadAvailableReviewerAvailableShared tasksReview the interfaceCompare axes and grouping.Owner: ReviewerGgG ≈ g: the heading floatsNested relationshipsMembersLeadAvailableReviewerAvailableShared tasksReview the interfaceCompare axes and grouping.Owner: ReviewerGgG > g: heading belongs belowPair → repeated unit → section: preserve spacing differences at each level.More internal complexity needs a clearer outer separation, not larger gaps everywhere. diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 4dc23c45e6..9536ef05f7 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -6,5 +6,5 @@ en: 41d772192075e133 zh: 5ef3da69b4de1d8a /capability-seams-and-core-services: - en: 6215d15e0e273fa4 - zh: dec5c7779e0a42f5 + en: 81ada544b91db085 + zh: 4442cc22a114987d diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 334e3757bc..f3f73f43b6 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -210,6 +210,8 @@ flowchart LR svc_agentLoop["ctx.agentLoop
Concrete loop driver"] pkg_base["base"] pkg_sdk_minimal["sdk-minimal"] + pkg_mcp_manager["mcp-manager"] + svc_mcpManager["ctx.mcpManager
Settings-driven MCP server fleet"] pkg_schedule["schedule"] svc_schedule["ctx.schedule
Host scheduled messages"] pkg_goal["goal"] @@ -280,8 +282,6 @@ flowchart LR pkg_tool_jobs["tool-jobs"] pkg_web["web"] svc_web["ctx.web
Web access provider registry"] - pkg_web_search_exa["web-search-exa"] - pkg_web_search_perplexity["web-search-perplexity"] pkg_web_search_deepseek["web-search-deepseek"] pkg_web_deepseek_api["web-deepseek-api"] pkg_web_fetch_http["web-fetch-http"] @@ -402,6 +402,7 @@ flowchart LR pkg_lsp --> svc_lsp pkg_lsp_stdio --> svc_lsp pkg_mcp_client --> svc_mcpResources + pkg_mcp_manager --> svc_mcpManager pkg_mcp_resources --> svc_mcpResources pkg_message_feedback --> svc_messageFeedback pkg_office_to_pdf --> svc_officeToPdf @@ -474,8 +475,6 @@ flowchart LR pkg_web_deepseek_api --> svc_web pkg_web_fetch_http --> svc_web pkg_web_search_deepseek --> svc_web - pkg_web_search_exa --> svc_web - pkg_web_search_perplexity --> svc_web pkg_webhook --> svc_webhookRuntime pkg_workflow --> svc_workflowEngine pkg_workflow_ptc --> svc_workflowEngine @@ -698,7 +697,7 @@ flowchart LR | `ctx.configEditor` | `core` | [`config-editor`](../packages/boot/config-editor) | - | [`settings`](../packages/settings/settings), [`agent-default-model`](../packages/core/agent-default-model) | - | Persists profile config patches under the application file lock and HMR queue, then reconciles Loader entries. | | `ctx.settings` | `core` | [`settings`](../packages/settings/settings) | - | [`api-settings-controller`](../packages/api/settings-controller) | - | Forms project volatile Config fields from active profile entries and delegate validated edits to config-editor. Plugins consume their own Config references. | | `ctx.subagentModelSelection` | `core` | [`tool-subagent`](../packages/subagent/tool-subagent) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Owns the default-off settings namespace that Agent-scoped delegation tools sample when composing a new top-level Session. | -| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the settings controller exposes value-free metadata, writes, and owner-only reads of stored keys. | +| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the settings controller exposes value-free views and write-only storage. | | `ctx.deepseekAccount` | `seam` | [`deepseek-account`](../packages/credentials/deepseek-account) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | [`api-account-controller`](../packages/api/account-controller), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | The Host owns browser authorization and local credentials; UI consumers receive state without tokens. | | `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Flows are registered by the plugin that knows how to obtain one credential and keyed by the record they write; the seam owns the conversation and the one-attempt-per-key lifecycle, never the protocol. | | `ctx.productAnalytics` | `service` | [`client-product-analytics`](../packages/client/product-analytics) | - | - | - | Accepts selected Desktop events, enriches available login identity, and observes live compaction under the live Host collection policy. | @@ -726,6 +725,7 @@ flowchart LR | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent`](../packages/subagent/subagent) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | Reads the default ModelSelection from volatile Config and saves selections through the profile editor. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`base`](../packages/bundle/base), [`sdk-minimal`](../packages/bundle/sdk-minimal) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | +| `ctx.mcpManager` | `core` | [`mcp-manager`](../packages/mcp/mcp-manager) | - | - | - | Reconciles mcp-client instances against its volatile servers configuration, aggregates their connection statuses, and serves restart and probe to management surfaces. | | `ctx.schedule` | `core` | [`schedule`](../packages/schedule/schedule) | - | - | - | Stores tasks independently of Session activation and queues due messages in each task's target Session. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | | `ctx.ssh` | `core` | [`ssh`](../packages/ssh/ssh) | - | [`fs-ssh`](../packages/ssh/fs-ssh), [`subprocess-ssh`](../packages/ssh/subprocess-ssh), [`sandbox-ssh`](../packages/ssh/sandbox-ssh) | - | Owns one authenticated OpenSSH connection, installed helper identity, independent program streams and disconnect cleanup for the paired remote providers. | @@ -746,7 +746,7 @@ flowchart LR | `ctx.claudeCodeMods` | `core` | [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | - | - | - | Loads mods that defineMod plugins add, raises their hook chains from harness extension points, and draws the band above the prompt over its Remote. | | `ctx.inspector` | `core` | `inspector` | - | - | - | Owns the Worker-hosted CDP target and the transport-independent Host and Client observation and Cordis-tree query API. | | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/experimental/tool-terminal), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | Producers (background bash/pwsh and PTY sends) register running work; record-declaring jobs additionally stream raw output for non-consuming observers; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | -| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-deepseek-api`](../packages/web/web-deepseek-api), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | +| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-deepseek-api`](../packages/web/web-deepseek-api), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | | `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). | | `ctx.webServer` | `core` | [`host-webserver`](../packages/host/webserver) | - | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-hmr`](../packages/client/hmr) | - | Plain node:http carrier: named-route registry, index transform taps, and the static dist fallback; web-transport plugins register their own routes. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index d0f0bdffb0..9b24db11e8 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -212,6 +212,8 @@ flowchart LR svc_agentLoop["ctx.agentLoop
Concrete loop driver"] pkg_base["base"] pkg_sdk_minimal["sdk-minimal"] + pkg_mcp_manager["mcp-manager"] + svc_mcpManager["ctx.mcpManager
Settings-driven MCP server fleet"] pkg_schedule["schedule"] svc_schedule["ctx.schedule
Host scheduled messages"] pkg_goal["goal"] @@ -282,8 +284,6 @@ flowchart LR pkg_tool_jobs["tool-jobs"] pkg_web["web"] svc_web["ctx.web
Web access provider registry"] - pkg_web_search_exa["web-search-exa"] - pkg_web_search_perplexity["web-search-perplexity"] pkg_web_search_deepseek["web-search-deepseek"] pkg_web_deepseek_api["web-deepseek-api"] pkg_web_fetch_http["web-fetch-http"] @@ -404,6 +404,7 @@ flowchart LR pkg_lsp --> svc_lsp pkg_lsp_stdio --> svc_lsp pkg_mcp_client --> svc_mcpResources + pkg_mcp_manager --> svc_mcpManager pkg_mcp_resources --> svc_mcpResources pkg_message_feedback --> svc_messageFeedback pkg_office_to_pdf --> svc_officeToPdf @@ -476,8 +477,6 @@ flowchart LR pkg_web_deepseek_api --> svc_web pkg_web_fetch_http --> svc_web pkg_web_search_deepseek --> svc_web - pkg_web_search_exa --> svc_web - pkg_web_search_perplexity --> svc_web pkg_webhook --> svc_webhookRuntime pkg_workflow --> svc_workflowEngine pkg_workflow_ptc --> svc_workflowEngine @@ -700,7 +699,7 @@ flowchart LR | `ctx.configEditor` | `core` | [`config-editor`](../packages/boot/config-editor) | - | [`settings`](../packages/settings/settings), [`agent-default-model`](../packages/core/agent-default-model) | - | Persists profile config patches under the application file lock and HMR queue, then reconciles Loader entries. | | `ctx.settings` | `core` | [`settings`](../packages/settings/settings) | - | [`api-settings-controller`](../packages/api/settings-controller) | - | Forms project volatile Config fields from active profile entries and delegate validated edits to config-editor. Plugins consume their own Config references. | | `ctx.subagentModelSelection` | `core` | [`tool-subagent`](../packages/subagent/tool-subagent) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | - | 拥有默认关闭的设置命名空间;Agent 作用域的委派工具会在组合新顶层 Session 时读取它。 | -| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;settings controller 提供不含实际值的元数据和写入操作,并仅向所有者读取已保存密钥。 | +| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;settings controller 提供不含实际值的视图和只写存储。 | | `ctx.deepseekAccount` | `seam` | [`deepseek-account`](../packages/credentials/deepseek-account) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | [`api-account-controller`](../packages/api/account-controller), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | Host 负责浏览器授权和本地凭证;UI 使用方只接收不含 token 的状态。 | | `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | flow 由知道如何取得某份凭据的插件注册,并以其写入的记录为键;seam 拥有这段对话与"每个键同时只跑一次尝试"的生命周期,而非协议本身。 | | `ctx.productAnalytics` | `service` | [`client-product-analytics`](../packages/client/product-analytics) | - | - | - | 根据 Host 动态采集策略接收指定桌面事件、补充可用登录身份并监听实时压缩。 | @@ -728,6 +727,7 @@ flowchart LR | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent`](../packages/subagent/subagent) | - | 拥有实时 Agent 句柄、创建/恢复工厂 seam,以及进程本地的发起方传播。 | | `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | Reads the default ModelSelection from volatile Config and saves selections through the profile editor. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`base`](../packages/bundle/base), [`sdk-minimal`](../packages/bundle/sdk-minimal) | - | 唯一的具体循环插件;扩展包依赖 dsh-agent 的事件和服务,而不依赖此包。 | +| `ctx.mcpManager` | `core` | [`mcp-manager`](../packages/mcp/mcp-manager) | - | - | - | 依据自身 volatile servers 配置对账 mcp-client 实例,聚合它们的连接状态,并向管理界面提供 restart 与 probe。 | | `ctx.schedule` | `core` | [`schedule`](../packages/schedule/schedule) | - | - | - | 独立于 Session 的加载状态存储任务,并将到期消息排入每个任务的目标 Session。 | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | 从会话日志折叠带修订版本的目标状态,并将实时延续激活保留在进程本地。 | | `ctx.ssh` | `core` | [`ssh`](../packages/ssh/ssh) | - | [`fs-ssh`](../packages/ssh/fs-ssh), [`subprocess-ssh`](../packages/ssh/subprocess-ssh), [`sandbox-ssh`](../packages/ssh/sandbox-ssh) | - | 负责一条经过认证的 OpenSSH 连接、已安装辅助程序身份、独立程序流,以及配套远端提供方的断连清理。 | @@ -748,7 +748,7 @@ flowchart LR | `ctx.claudeCodeMods` | `core` | [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | - | - | - | 加载 defineMod 插件添加的模组,从 harness 扩展点触发其钩子链,并通过其 Remote 绘制提示框上方的横幅。 | | `ctx.inspector` | `core` | `inspector` | - | - | - | 负责 Worker 托管的 CDP target,以及独立于传输的 Host 和 Client observation 与 Cordis tree query API。 | | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/experimental/tool-terminal), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | 生产方(后台 bash/pwsh 与 PTY 发送)登记正在运行的工作;声明 record 的 job 还为非消费观察者流式提供原始输出;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | -| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-deepseek-api`](../packages/web/web-deepseek-api), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 | +| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-deepseek-api`](../packages/web/web-deepseek-api), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | 后端保存过大的工具文本,并返回面向模型的定位信息和取回提示;spill-policy 是 tools/post-execute 消费方,负责决定何时 spill。 | | `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | 带判别标记的交互能力:原生后端在 Host 显示设备上打开一个操作系统选择器,浏览后端为应用内浏览器提供列表与创建原语;双端后端通过其浏览器侧填充 ui-workspace 目录流程的 slot(不通过协议发布)。 | | `ctx.webServer` | `core` | [`host-webserver`](../packages/host/webserver) | - | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-hmr`](../packages/client/hmr) | - | 普通的 node:http 载体:具名路由注册表、索引转换 tap,以及静态 dist 回退;Web 传输插件注册自己的路由。 | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b60380b102..304b6de767 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -180,8 +180,10 @@ export interface Config { - `source`: [`packages/team/agent-team/src/types.ts:132`](../packages/team/agent-team/src/types.ts) ```ts config-catalog -/** Team-service deployment limits. */ +/** Team-service defaults and deployment limits. */ export interface Config { + /** Enable new roots without a saved selection before their first step; defaults to false. Resumes retain saved mode. */ + readonly defaultEnabled?: boolean /** Maximum non-deleted tasks retained by one Team. */ readonly maxTasks?: number } @@ -2376,7 +2378,7 @@ export interface LspLocalServerConfig { ## `@deepseek-ai/dsh-mcp-client` - `inject`: `tools` -- `source`: [`packages/mcp/mcp-client/src/index.ts:104`](../packages/mcp/mcp-client/src/index.ts) +- `source`: [`packages/mcp/mcp-client/src/index.ts:138`](../packages/mcp/mcp-client/src/index.ts) ```ts config-catalog /** Configuration for one stdio or Streamable HTTP MCP server. */ @@ -2389,7 +2391,7 @@ export interface StdioConfig { /** * Stable local namespace for this server's model-facing tool names * (`mcp____`). Must match `[A-Za-z0-9_-]{1,32}` and be - * unique across live mcp-client instances. + * unique among live mcp-client instances in the same registration scope. */ serverName: string /** Executable used to start the server. */ @@ -2402,6 +2404,12 @@ export interface StdioConfig { cwd: string /** Timeout per tool call or resource request in milliseconds. */ toolCallTimeoutMs: number + /** Optional budget for one connection attempt plus its initial tool sync, in milliseconds. */ + startupTimeoutMs?: number + /** Budget for transport closure and local cleanup, in milliseconds (default 5000). */ + closeTimeoutMs: number + /** Resolve `${credential:…}` in transport strings at runtime and redact resolved values from diagnostics (default false). */ + expandTemplates: boolean /** Fail plugin activation when the initial connection or tool synchronization fails. */ failOnStartupError: boolean /** Maximum UTF-8 bytes of attributed server instructions (default 32768). */ @@ -2417,7 +2425,7 @@ export interface StreamableHttpConfig { /** * Stable local namespace for this server's model-facing tool names * (`mcp____`). Must match `[A-Za-z0-9_-]{1,32}` and be - * unique across live mcp-client instances. + * unique among live mcp-client instances in the same registration scope. */ serverName: string /** MCP endpoint URL. */ @@ -2426,6 +2434,12 @@ export interface StreamableHttpConfig { headers: Record /** Timeout per tool call or resource request in milliseconds. */ toolCallTimeoutMs: number + /** Optional budget for one connection attempt plus its initial tool sync, in milliseconds. */ + startupTimeoutMs?: number + /** Budget for transport closure and local cleanup, in milliseconds (default 5000). */ + closeTimeoutMs: number + /** Resolve `${credential:…}` in transport strings at runtime and redact resolved values from diagnostics (default false). */ + expandTemplates: boolean /** Fail plugin activation when the initial connection or tool synchronization fails. */ failOnStartupError: boolean /** Maximum UTF-8 bytes of attributed server instructions (default 32768). */ @@ -2448,6 +2462,57 @@ export interface ReconnectConfig { ``` + + + +## `@deepseek-ai/dsh-mcp-manager` + +- `inject`: `tools` +- `refs`: [`ReconnectConfig`](../packages/mcp/mcp-client/src/index.ts) · `Volatile` (`@deepseek-ai/cordis`) +- `source`: [`packages/mcp/mcp-manager/src/index.ts:71`](../packages/mcp/mcp-manager/src/index.ts) + +```ts config-catalog +/** Manager configuration with live server entries. */ +export interface Config { + /** Managed servers keyed by `serverName`. */ + servers: Volatile> +} + +/** + * One managed server entry. Deliberately flat rather than a per-transport + * union: {@link validateEntries} owns the per-transport field rules, and its + * write-time refusals name the offending field where a union's shape error + * would not. + */ +export interface McpManagedServerEntry { + /** Which transport the instance uses. */ + transport: 'stdio' | 'streamable-http' + /** stdio: executable used to start the server; `''` while unset. */ + command: string + /** stdio: arguments passed without shell interpolation. */ + args: string[] + /** stdio: extra child environment; values may use `${credential:…}` templates. */ + env: Record + /** stdio: working directory for the child process. */ + cwd: string + /** streamable-http: MCP endpoint URL; `''` while unset. */ + url: string + /** streamable-http: extra request headers; values may use templates. */ + headers: Record + /** Durable intent: whether the entry asks for a running instance. */ + enabled: boolean + /** Per-tool-call timeout override in milliseconds; omission uses the mcp-client default. */ + toolCallTimeoutMs?: number + /** Per-attempt startup budget in milliseconds; the manager schema defaults to 30000. */ + startupTimeoutMs: number + /** Cleanup budget override in milliseconds; omission uses the mcp-client default. */ + closeTimeoutMs?: number + /** Reconnect policy; the schema fills the mcp-client defaults. */ + reconnect: ReconnectConfig +} +``` + + @@ -2740,6 +2805,8 @@ export interface Config { maxMessageBytes?: number /** Maximum simultaneous host binding calls accepted from a program. */ maxPendingCalls?: number + /** Time to wait for cooperative stop acknowledgment before terminating the process. */ + stopGraceMs?: number /** Managed process termination and output-drain grace in milliseconds. */ graceMs?: number } @@ -4011,6 +4078,8 @@ export interface Config { ```ts config-catalog /** Program execution limits. */ export interface Config { + /** Nested tool-call attempts per minute of resolved runtime timeout, including failed calls. */ + maxSubCallsPerMinute: number /** Maximum overlapping concurrency-safe program subcalls. */ maxParallelSubCalls: number } @@ -4448,9 +4517,9 @@ export interface Config { search?: boolean /** Register `web_fetch`. Defaults to true. */ fetch?: boolean - /** Upper bound on sources returned by one `web_search` call. */ + /** Upper bound on sources returned by one `web_search` call. Defaults to 10. */ searchMaxResults?: number - /** Upper bound on queries accepted by one `web_search` call. */ + /** Upper bound on queries accepted by one `web_search` call. Defaults to 10. */ searchMaxQueries?: number /** Cap on each source summary in `web_search` output, in characters. Defaults to 800. */ searchSummaryMaxChars?: number @@ -4571,7 +4640,7 @@ export type ApprovalPolicy = 'ask' | 'never' ## `@deepseek-ai/dsh-web` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/web/web/src/index.ts:57`](../packages/web/web/src/index.ts) +- `source`: [`packages/web/web/src/index.ts:62`](../packages/web/web/src/index.ts) ```ts config-catalog /** @@ -4630,21 +4699,40 @@ export interface Config { - `inject`: `web` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/web/web-deepseek-api/src/index.ts:36`](../packages/web/web-deepseek-api/src/index.ts) +- `source`: [`packages/web/web-deepseek-api/src/index.ts:58`](../packages/web/web-deepseek-api/src/index.ts) ```ts config-catalog -/** Settings read by the next search or fetch without re-registering either provider. */ +/** + * Settings read by the next search or fetch without re-registering either provider. A non-empty `*BaseURL` or + * `*ApiKeyEnv` gives that capability its own endpoint and key; with neither, it follows the conversation's DeepSeek + * credential. Stored fields of earlier settings (`baseURL`, `apiKeyEnv`, `apiKey`) are not schema fields: they load + * without error and have no effect. + */ export interface Config { - /** Literal DeepSeek API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */ - apiKey: Volatile - /** Credential reference resolved for each operation; defaults to `DEEPSEEK_API_KEY`. */ - apiKeyEnv: Volatile - /** API base including its path prefix; `/search` and `/fetch` are appended. */ - baseURL: Volatile /** Search API results per search, from 1 through 50; a smaller request bound wins. Defaults to 10. */ count: Volatile + /** + * Most Search requests this plugin instance sends in any one-second window; later searches wait, + * and the wait counts against the caller's timeout. Other processes and clients on the same account + * share the account limit without coordination. Defaults to 5. + */ + searchRequestsPerSecond: Volatile /** Send the triggering user question, bounded to 128 characters, as `ori_question`. Defaults to true. */ sendOriginalQuestion: Volatile + /** + * Own Search API base including its path prefix; `/search` is appended. With only {@link searchApiKeyEnv} set, the + * official base applies. + */ + searchBaseURL: Volatile + /** Own Search API credential reference. With only {@link searchBaseURL} set, `DEEPSEEK_API_KEY` applies. */ + searchApiKeyEnv: Volatile + /** + * Own Fetch API base including its path prefix; `/fetch` is appended. With only {@link fetchApiKeyEnv} set, the + * official base applies. + */ + fetchBaseURL: Volatile + /** Own Fetch API credential reference. With only {@link fetchBaseURL} set, `DEEPSEEK_API_KEY` applies. */ + fetchApiKeyEnv: Volatile } ``` @@ -4681,7 +4769,7 @@ export interface Config { - `inject`: `web` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/web/web-search-deepseek/src/index.ts:51`](../packages/web/web-search-deepseek/src/index.ts) +- `source`: [`packages/web/web-search-deepseek/src/index.ts:54`](../packages/web/web-search-deepseek/src/index.ts) ```ts config-catalog /** Plugin config (all optional — `apply` fills env-var and constant defaults). */ @@ -4704,56 +4792,6 @@ export interface Config { ``` - - - -## `@deepseek-ai/dsh-web-search-exa` - -- `inject`: `web` -- `source`: [`packages/web/web-search-exa/src/index.ts:35`](../packages/web/web-search-exa/src/index.ts) - -```ts config-catalog -/** Plugin config (all optional — `apply` fills env-var and constant defaults). */ -export interface Config { - /** Exa API key. Falls back to `$EXA_API_KEY`. Empty → provider unavailable. */ - apiKey?: string - /** Endpoint base; `/search` is appended. Defaults to the public API. */ - baseURL?: string - /** Retrieval mode sent as Exa's `type`. Defaults to `auto`. */ - searchType?: 'auto' | 'keyword' | 'neural' - /** Default result count when a request carries no `maxResults`. Omitted = none. */ - numResults?: number - /** Highlight sentences requested per result. Defaults to 1. */ - highlightsPerResult?: number -} -``` - - - - - -## `@deepseek-ai/dsh-web-search-perplexity` - -- `inject`: `web` -- `source`: [`packages/web/web-search-perplexity/src/index.ts:30`](../packages/web/web-search-perplexity/src/index.ts) - -```ts config-catalog -/** Plugin config (all optional — `apply` fills env-var and constant defaults). */ -export interface Config { - /** Perplexity API key. Falls back to `$PERPLEXITY_API_KEY`. Empty → unavailable. */ - apiKey?: string - /** Endpoint base; `/chat/completions` is appended. Defaults to the public API. */ - baseURL?: string - /** Search model name. Defaults to `sonar`. */ - model?: string - /** Upper bound on generated answer tokens. Defaults to 1024. */ - maxTokens?: number - /** Recency window sent as `search_recency_filter`. Omitted = no filter. */ - searchRecency?: 'day' | 'week' | 'month' | 'year' -} -``` - - diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 95af04f5dd..182c220092 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -182,8 +182,10 @@ export interface Config { - `source`: [`packages/team/agent-team/src/types.ts:132`](../packages/team/agent-team/src/types.ts) ```ts config-catalog -/** Team-service deployment limits. */ +/** Team-service defaults and deployment limits. */ export interface Config { + /** Enable new roots without a saved selection before their first step; defaults to false. Resumes retain saved mode. */ + readonly defaultEnabled?: boolean /** Maximum non-deleted tasks retained by one Team. */ readonly maxTasks?: number } @@ -2378,7 +2380,7 @@ export interface LspLocalServerConfig { ## `@deepseek-ai/dsh-mcp-client` - `inject`: `tools` -- `source`: [`packages/mcp/mcp-client/src/index.ts:104`](../packages/mcp/mcp-client/src/index.ts) +- `source`: [`packages/mcp/mcp-client/src/index.ts:138`](../packages/mcp/mcp-client/src/index.ts) ```ts config-catalog /** Configuration for one stdio or Streamable HTTP MCP server. */ @@ -2391,7 +2393,7 @@ export interface StdioConfig { /** * Stable local namespace for this server's model-facing tool names * (`mcp____`). Must match `[A-Za-z0-9_-]{1,32}` and be - * unique across live mcp-client instances. + * unique among live mcp-client instances in the same registration scope. */ serverName: string /** Executable used to start the server. */ @@ -2404,6 +2406,12 @@ export interface StdioConfig { cwd: string /** Timeout per tool call or resource request in milliseconds. */ toolCallTimeoutMs: number + /** Optional budget for one connection attempt plus its initial tool sync, in milliseconds. */ + startupTimeoutMs?: number + /** Budget for transport closure and local cleanup, in milliseconds (default 5000). */ + closeTimeoutMs: number + /** Resolve `${credential:…}` in transport strings at runtime and redact resolved values from diagnostics (default false). */ + expandTemplates: boolean /** Fail plugin activation when the initial connection or tool synchronization fails. */ failOnStartupError: boolean /** Maximum UTF-8 bytes of attributed server instructions (default 32768). */ @@ -2419,7 +2427,7 @@ export interface StreamableHttpConfig { /** * Stable local namespace for this server's model-facing tool names * (`mcp____`). Must match `[A-Za-z0-9_-]{1,32}` and be - * unique across live mcp-client instances. + * unique among live mcp-client instances in the same registration scope. */ serverName: string /** MCP endpoint URL. */ @@ -2428,6 +2436,12 @@ export interface StreamableHttpConfig { headers: Record /** Timeout per tool call or resource request in milliseconds. */ toolCallTimeoutMs: number + /** Optional budget for one connection attempt plus its initial tool sync, in milliseconds. */ + startupTimeoutMs?: number + /** Budget for transport closure and local cleanup, in milliseconds (default 5000). */ + closeTimeoutMs: number + /** Resolve `${credential:…}` in transport strings at runtime and redact resolved values from diagnostics (default false). */ + expandTemplates: boolean /** Fail plugin activation when the initial connection or tool synchronization fails. */ failOnStartupError: boolean /** Maximum UTF-8 bytes of attributed server instructions (default 32768). */ @@ -2450,6 +2464,57 @@ export interface ReconnectConfig { ``` + + + +## `@deepseek-ai/dsh-mcp-manager` + +- `inject`: `tools` +- `refs`: [`ReconnectConfig`](../packages/mcp/mcp-client/src/index.ts) · `Volatile` (`@deepseek-ai/cordis`) +- `source`: [`packages/mcp/mcp-manager/src/index.ts:71`](../packages/mcp/mcp-manager/src/index.ts) + +```ts config-catalog +/** Manager configuration with live server entries. */ +export interface Config { + /** Managed servers keyed by `serverName`. */ + servers: Volatile> +} + +/** + * One managed server entry. Deliberately flat rather than a per-transport + * union: {@link validateEntries} owns the per-transport field rules, and its + * write-time refusals name the offending field where a union's shape error + * would not. + */ +export interface McpManagedServerEntry { + /** Which transport the instance uses. */ + transport: 'stdio' | 'streamable-http' + /** stdio: executable used to start the server; `''` while unset. */ + command: string + /** stdio: arguments passed without shell interpolation. */ + args: string[] + /** stdio: extra child environment; values may use `${credential:…}` templates. */ + env: Record + /** stdio: working directory for the child process. */ + cwd: string + /** streamable-http: MCP endpoint URL; `''` while unset. */ + url: string + /** streamable-http: extra request headers; values may use templates. */ + headers: Record + /** Durable intent: whether the entry asks for a running instance. */ + enabled: boolean + /** Per-tool-call timeout override in milliseconds; omission uses the mcp-client default. */ + toolCallTimeoutMs?: number + /** Per-attempt startup budget in milliseconds; the manager schema defaults to 30000. */ + startupTimeoutMs: number + /** Cleanup budget override in milliseconds; omission uses the mcp-client default. */ + closeTimeoutMs?: number + /** Reconnect policy; the schema fills the mcp-client defaults. */ + reconnect: ReconnectConfig +} +``` + + @@ -2742,6 +2807,8 @@ export interface Config { maxMessageBytes?: number /** Maximum simultaneous host binding calls accepted from a program. */ maxPendingCalls?: number + /** Time to wait for cooperative stop acknowledgment before terminating the process. */ + stopGraceMs?: number /** Managed process termination and output-drain grace in milliseconds. */ graceMs?: number } @@ -4013,6 +4080,8 @@ export interface Config { ```ts config-catalog /** Program execution limits. */ export interface Config { + /** Nested tool-call attempts per minute of resolved runtime timeout, including failed calls. */ + maxSubCallsPerMinute: number /** Maximum overlapping concurrency-safe program subcalls. */ maxParallelSubCalls: number } @@ -4450,9 +4519,9 @@ export interface Config { search?: boolean /** Register `web_fetch`. Defaults to true. */ fetch?: boolean - /** Upper bound on sources returned by one `web_search` call. */ + /** Upper bound on sources returned by one `web_search` call. Defaults to 10. */ searchMaxResults?: number - /** Upper bound on queries accepted by one `web_search` call. */ + /** Upper bound on queries accepted by one `web_search` call. Defaults to 10. */ searchMaxQueries?: number /** Cap on each source summary in `web_search` output, in characters. Defaults to 800. */ searchSummaryMaxChars?: number @@ -4573,7 +4642,7 @@ export type ApprovalPolicy = 'ask' | 'never' ## `@deepseek-ai/dsh-web` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/web/web/src/index.ts:57`](../packages/web/web/src/index.ts) +- `source`: [`packages/web/web/src/index.ts:62`](../packages/web/web/src/index.ts) ```ts config-catalog /** @@ -4632,21 +4701,40 @@ export interface Config { - `inject`: `web` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/web/web-deepseek-api/src/index.ts:36`](../packages/web/web-deepseek-api/src/index.ts) +- `source`: [`packages/web/web-deepseek-api/src/index.ts:58`](../packages/web/web-deepseek-api/src/index.ts) ```ts config-catalog -/** Settings read by the next search or fetch without re-registering either provider. */ +/** + * Settings read by the next search or fetch without re-registering either provider. A non-empty `*BaseURL` or + * `*ApiKeyEnv` gives that capability its own endpoint and key; with neither, it follows the conversation's DeepSeek + * credential. Stored fields of earlier settings (`baseURL`, `apiKeyEnv`, `apiKey`) are not schema fields: they load + * without error and have no effect. + */ export interface Config { - /** Literal DeepSeek API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */ - apiKey: Volatile - /** Credential reference resolved for each operation; defaults to `DEEPSEEK_API_KEY`. */ - apiKeyEnv: Volatile - /** API base including its path prefix; `/search` and `/fetch` are appended. */ - baseURL: Volatile /** Search API results per search, from 1 through 50; a smaller request bound wins. Defaults to 10. */ count: Volatile + /** + * Most Search requests this plugin instance sends in any one-second window; later searches wait, + * and the wait counts against the caller's timeout. Other processes and clients on the same account + * share the account limit without coordination. Defaults to 5. + */ + searchRequestsPerSecond: Volatile /** Send the triggering user question, bounded to 128 characters, as `ori_question`. Defaults to true. */ sendOriginalQuestion: Volatile + /** + * Own Search API base including its path prefix; `/search` is appended. With only {@link searchApiKeyEnv} set, the + * official base applies. + */ + searchBaseURL: Volatile + /** Own Search API credential reference. With only {@link searchBaseURL} set, `DEEPSEEK_API_KEY` applies. */ + searchApiKeyEnv: Volatile + /** + * Own Fetch API base including its path prefix; `/fetch` is appended. With only {@link fetchApiKeyEnv} set, the + * official base applies. + */ + fetchBaseURL: Volatile + /** Own Fetch API credential reference. With only {@link fetchBaseURL} set, `DEEPSEEK_API_KEY` applies. */ + fetchApiKeyEnv: Volatile } ``` @@ -4683,7 +4771,7 @@ export interface Config { - `inject`: `web` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/web/web-search-deepseek/src/index.ts:51`](../packages/web/web-search-deepseek/src/index.ts) +- `source`: [`packages/web/web-search-deepseek/src/index.ts:54`](../packages/web/web-search-deepseek/src/index.ts) ```ts config-catalog /** Plugin config (all optional — `apply` fills env-var and constant defaults). */ @@ -4706,56 +4794,6 @@ export interface Config { ``` - - - -## `@deepseek-ai/dsh-web-search-exa` - -- `inject`: `web` -- `source`: [`packages/web/web-search-exa/src/index.ts:35`](../packages/web/web-search-exa/src/index.ts) - -```ts config-catalog -/** Plugin config (all optional — `apply` fills env-var and constant defaults). */ -export interface Config { - /** Exa API key. Falls back to `$EXA_API_KEY`. Empty → provider unavailable. */ - apiKey?: string - /** Endpoint base; `/search` is appended. Defaults to the public API. */ - baseURL?: string - /** Retrieval mode sent as Exa's `type`. Defaults to `auto`. */ - searchType?: 'auto' | 'keyword' | 'neural' - /** Default result count when a request carries no `maxResults`. Omitted = none. */ - numResults?: number - /** Highlight sentences requested per result. Defaults to 1. */ - highlightsPerResult?: number -} -``` - - - - - -## `@deepseek-ai/dsh-web-search-perplexity` - -- `inject`: `web` -- `source`: [`packages/web/web-search-perplexity/src/index.ts:30`](../packages/web/web-search-perplexity/src/index.ts) - -```ts config-catalog -/** Plugin config (all optional — `apply` fills env-var and constant defaults). */ -export interface Config { - /** Perplexity API key. Falls back to `$PERPLEXITY_API_KEY`. Empty → unavailable. */ - apiKey?: string - /** Endpoint base; `/chat/completions` is appended. Defaults to the public API. */ - baseURL?: string - /** Search model name. Defaults to `sonar`. */ - model?: string - /** Upper bound on generated answer tokens. Defaults to 1024. */ - maxTokens?: number - /** Recency window sent as `search_recency_filter`. Omitted = no filter. */ - searchRecency?: 'day' | 'week' | 'month' | 'year' -} -``` - - diff --git a/docs/cookbook/reviewing-ui-design.i18n.yaml b/docs/cookbook/reviewing-ui-design.i18n.yaml new file mode 100644 index 0000000000..90d11a9960 --- /dev/null +++ b/docs/cookbook/reviewing-ui-design.i18n.yaml @@ -0,0 +1,55 @@ +# Bilingual-pair consistency record for reviewing-ui-design.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/cookbook/reviewing-ui-design.md +/: + en: 7a1a3135b7683184 + zh: b518133afc663a6e +/cookbook-review-a-ui-design: + en: d42b121ca63177aa + zh: 68b637fae3252c7b +/cookbook-review-a-ui-design/summary: + en: 8a1ff691b28a2533 + zh: 8c54ae85df6f6e12 +/cookbook-review-a-ui-design/table-of-contents: + en: 84af073b2a7eca95 + zh: 750633fa3c458d5b +/cookbook-review-a-ui-design/1-define-the-page-or-modal-in-scope: + en: c9ee94acd903d809 + zh: c8d07c75ba94531b +/cookbook-review-a-ui-design/1-define-the-page-or-modal-in-scope/select-scope-by-visual-impact: + en: ef9ad1428375f1d9 + zh: 8f59cd04947c16dd +/cookbook-review-a-ui-design/2-inspect-the-task-and-its-implementation: + en: 86301e7487e9cd74 + zh: f13bc3da0b2ac2d3 +/cookbook-review-a-ui-design/2-inspect-the-task-and-its-implementation/reconcile-the-baseline-across-the-whole-interface: + en: 76d008d0bac44f1f + zh: 2357d78f350b5540 +/cookbook-review-a-ui-design/2-inspect-the-task-and-its-implementation/make-the-relationships-explicit: + en: 4d26eca838abeb7a + zh: 30f2046b2dc70ed6 +/cookbook-review-a-ui-design/2-inspect-the-task-and-its-implementation/keep-a-coverage-record: + en: 61b9e821e98b042a + zh: 9b67719f013e7c3c +/cookbook-review-a-ui-design/3-preserve-the-starting-point: + en: a0f8e8d720097515 + zh: 4ff54f0c03bfdb48 +/cookbook-review-a-ui-design/4-change-the-smallest-responsible-component: + en: 8bcdb909b6825a5e + zh: e140ca3873b302b9 +/cookbook-review-a-ui-design/5-verify-behavior-and-comparable-images: + en: 437f3a735dbfa486 + zh: 7d6ccfd8da698c49 +/cookbook-review-a-ui-design/6-deliver-the-complete-evidence: + en: cea04ecd81785ca2 + zh: b321ae244f050b1c +/cookbook-review-a-ui-design/6-deliver-the-complete-evidence/comparison-delivery-and-cumulative-changes: + en: c81cdec4ac3decca + zh: 9dcb16b292414fda +/cookbook-review-a-ui-design/further-exploration: + en: 98ac3512211d94ae + zh: 055418a72f29a15b +/cookbook-review-a-ui-design/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/docs/cookbook/reviewing-ui-design.md b/docs/cookbook/reviewing-ui-design.md new file mode 100644 index 0000000000..210508f3b0 --- /dev/null +++ b/docs/cookbook/reviewing-ui-design.md @@ -0,0 +1,166 @@ +--- +description: "Review a DSH page or modal as a whole, explain findings through design principles, and verify supported changes." +--- + +# Cookbook: review a UI design + +English | [中文](reviewing-ui-design.zh.md) + +## Summary + +Use this cookbook for explicitly requested comprehensive reviews and for evidence steps justified by a change's impact or regression risk. Ordinary development follows the lightweight path in the [UI change workflow](../../.agents/skills/dsh-client-ui-ux/SKILL.md#required-review-loop); it does not execute every section below. The [principles and case reference](../ui-design-review-cases.md) supplies judgment criteria, not a mandatory checklist. Existing tests, GUI PR GIFs and designer/product approval remain governed by their original owners. + +Check the complete icon box, including intentional internal whitespace, text columns and gaps before judging the drawn contour. Hit targets are not icon boxes; do not align each glyph’s painted pixels independently. See the [alignment principle](../ui-design-review-cases.md#alignment). + +Check associated icons and text against the [same-foreground rule](../../.agents/skills/dsh-client-ui-ux/references/icons.md#placement-and-use), including submenu values with chevrons and search placeholders with magnifiers. Compare rendered color and opacity in both themes; preserve semantic exceptions. + +## Table of Contents + +- [1. Define the page or modal in scope](#proposition) +- [2. Inspect the task and its implementation](#inspect) +- [3. Preserve the starting point](#baseline) +- [4. Change the smallest responsible component](#implement) +- [5. Verify behavior and comparable images](#verify) +- [6. Deliver the complete evidence](#deliver) +- [Further Exploration](#further-exploration) +- [Dev Note](#dev-note) + + +## 1. Define the page or modal in scope + +Identify the user task and affected page or modal. Record scope when several regions, consumers or design choices need coordination; a straightforward local change needs no separate plan. For an explicitly requested comprehensive review, enumerate the requested pages/modals and relevant states. Do not present local inspection as a whole-page result. + +Use the [judgment priorities](../ui-design-review-cases.md#judgment) across that scope: alignment and spatial grouping first, then density, hierarchy, states, component choice, icon optics and geometry. Express each finding as an observable relationship, such as content appearing closer to the next group than to its own label. Follow the user’s requested order and authorization; a full review can identify multiple related defects, and permission to implement routine adjustments does not replace the [required designer/product review](../../.agents/skills/dsh-client-ui-ux/SKILL.md#design-review-escalation). + +Read the [client UI skill](../../.agents/skills/dsh-client-ui-ux/SKILL.md) and the applicable architecture before editing. Use the [responsive skill](../../.agents/skills/dsh-responsive-ui/SKILL.md) for width and input changes, the [style reference](../web-styling.md) for tokens, and the [radius standard](../ui-radius.md) for corner geometry. This workflow adds review and evidence steps, not another set of visual tokens. + + +### Select scope by visual impact + +Choose inspection scope from the table below. For broader changes, a few task-note sentences can track affected groups, consumers and risk conditions; use a coverage table only when it helps a multi-region review. Local changes do not need a scene inventory. File count and package boundaries do not determine visual reach. + +| Change | Minimum inspection scope | +|---|---| +| Local copy, icon, color or spacing | Read the containing page or modal for context; inspect the owning group, neighboring groups and affected peers sharing its axes or role, including distant peers | +| Page, form or card layout; new page or modal | Review the containing page or modal as a whole, including relevant scrolling and expanded content | +| Shared component | Trace all affected consumers; inspect each distinct layout, content and state variant in its assembled context | +| Global typography, tokens or styling | Map affected component families and page layouts; expand coverage across those families, rather than treating the change as local | +| Behavior without an intended style change | Inspect the states that can change visible relationships, including loading, errors, disabled controls, expansion and focus | + +A shared consumer can reuse evidence only when the relevant implementation, size, container constraints, content behavior and states are equivalent; record the grouping and its basis. Inspect uncertain cases directly. Source inspection of all consumers is not a claim to have opened every page. Expand scope when a defect recurs beyond the initial region or a fix changes adjacent relationships. Record unrelated discoveries separately instead of silently starting a product-wide redesign. + +Apply the [minimum checks](../../.agents/skills/dsh-client-ui-ux/SKILL.md#minimum-checks-for-affected-ui) to each changed property: color requires both themes; layout, spacing, typography and sizing require constrained and long-content checks; interaction requires the affected path and states. These are inspection requirements, including for local edits, not a demand for a screenshot of every condition. Apply the owning responsive and behavior requirements; do not exhaust unrelated combinations or replace coverage reasoning with a fixed screenshot count. Reuse unchanged rules and valid evidence within the task, and recapture after a coherent batch of edits rather than every CSS adjustment. Read only the relevant principle sections and examples; an uncertain design decision is a reason to retrieve more detail. + + +## 2. Inspect the task and its implementation + +Inspect the affected result at the normal application entry point. Reuse an instance serving the current implementation; do not restart or rebuild solely for each small edit. Follow the relevant task path and use computed styles when needed to distinguish layout defects from content, loading or unfinished animation. + +For ordinary changes, a finding needs its location, visible problem, correction and verification. Add source ownership, alternatives and conditions that would invalidate the correction when they affect the decision. Prioritize visible or shared defects; differing source numbers alone do not justify standardization. + +When diagnosing spacing, record layout boxes, margins, padding, line boxes and visible glyph edges separately. State whether a close control aligns by its button edge or icon box, and check trigger/menu text insets across their different icon sizes. An annotation of visible whitespace is not a measured CSS margin. + +### Reconcile the baseline across the whole interface + +Use the [applicable-standards map](../ui-design-review-cases.md#review-baseline) to gather the current radius, material, typography, component, feedback, responsive and artwork rules that apply to this task. Record named exceptions and the relevant existing variants. When a personal or external skill differs, use the repository owner; when implementation differs from the owner, preserve the discrepancy as a finding rather than silently choosing one. + +Apply the [whole-page and modal principles](../ui-design-review-cases.md#whole-interface-review) to the selected scope. Keep the whole composition as context and compare affected regions and repeated relationships before using local fixtures for detail. Explicit full reviews cover every region; local corrections must preserve neighboring alignment, grouping, nested corners, clipping and focus. + +### Make the relationships explicit + +Identify the relevant groups and alignment references. Draw axes or annotate a full-context screenshot only when the relationship is complex, disputed or needs explanation; this is not a prerequisite to reading CSS. The following prompts are optional aids for documenting a review, not fields to fill for every local change. + +| Observation | Record | Example | +|---|---|---| +| Task and groups | What each group describes or operates on; its children and neighboring groups | Conversation title / three-action cluster; members section / tasks section | +| Alignment | Objects, axis, and reference container; deliberate exceptions | Baseline-defined peer-row text starts; centered icon-plus-label in the primary button | +| Spacing | Which gaps are internal, repeated, and between groups | Heading is closer to its cards than to the preceding section | +| Peer geometry | Comparable roles and visible size, radius, and color relationships | Equal circular bases; one search-prompt foreground role | +| States | Simultaneous and neighboring combinations | Selected conversation beside another hovered conversation | +| Evidence scope | What is visible and what remains uninspected | Full Desktop sidebar versus an isolated conversation row | + +Walk the region in three passes: whole composition, relationships between groups, and details within groups. Use source and computed geometry to explain the visible result afterward. For alignment, compare rendered text starts and slot centers; for spacing, compare visible boundaries as well as CSS boxes. Record CSS pixels separately from screenshot pixels and preserve the zoom/device scale. Do not infer a new design constant from a scaled image. + +A useful finding states a violated relationship: “The title-to-action gap is smaller than the gaps inside the action cluster, so the title joins the first action visually; separate the two groups and retest long-title hover.” “Spacing looks off” and “all gaps use tokens” do not explain the judgment. A visible inconsistency can fail the design requirement even when it does not block a task; do not dismiss it as harmless because a click still succeeds. + +Before declaring the page or modal reviewed, reconcile its actual regions and relevant states with the inspected evidence. Include content reached by scrolling, expansion or tab changes. A local fixture or example cannot substitute for an uninspected part of that whole. For a limited review, list the remaining scope explicitly. + +Use locally maintained production-component fixtures to reproduce controlled states or compare variants. They supplement actual application inspection. Capture/report tools and their scene adapters can live outside the repository and consume the source checkout; they are not required product dependencies or CI acceptance. Read their local instructions for supported scenes and limits. + +### Keep a coverage record + +Use the following record for an explicitly requested comprehensive review or when several regions need tracking. Ordinary local changes need no coverage table or separate note reproducing every field. Inspect the affected relationships and preserve justified equivalence when grouping shared consumers. + +| Page or dialog and entry | Semantic groups and cross-group relationships | Inspected conditions | Finding and owner | Evidence after correction | Uninspected | +|---|---|---|---|---|---| +| Name the actual entry and region | Specify both endpoints and complete boxes or text axes | Content, width, theme, state | Record the rule and responsible component | Comparison and relevant behavior results | State limits rather than an ambiguous covered status | + +For complex findings, explain the compared objects, intended relationship, supporting evidence and risky neighboring effects. Include a plausible wrong fix or retest condition when it clarifies the decision; ordinary findings use the short form above. Keep unsupported candidates pending and investigate confirmed repeated problems within the chosen scope, without starting unrelated cleanup. + + +## 3. Preserve the starting point + +Preserve a recoverable starting version and relevant captures when a comparison is requested or needed to assess a broader change or regression risk. Routine local changes do not require an extra source snapshot or before image. When retaining a baseline, include existing uncommitted work rather than assuming `HEAD` represents the visible application, and record the source identity and capture conditions. + +When captures are needed, save images and their conditions in `.ui-review/` at the checkout root or another explicitly selected local directory. The repository ignores `.ui-review/` for generated evidence; review tools can remain installed outside the checkout. + +Use a scene map for comprehensive reviews or multi-state comparisons that need it; simple changes can be checked directly at the affected position. Include expanded rows, dialogs and relevant loading/disabled states when they affect the claim. A collapsed parent does not cover hidden controls. Use safe fixture data for destructive or unavailable states instead of changing a user's real task just for a screenshot. + + +## 4. Change the smallest responsible component + +Fix the owner responsible for the task: the page for page layout, its primitive for a shared control state, or the library for glyph imbalance repeated across consumers. Reuse components and theme aliases, verify affected relationships and preserve independent workspace changes. A development fix need not first become a formal review finding. + +Check the states that can conflict with the change: for example selected with hover or keyboard focus, expanded with focus, busy with disabled, and validation with long content. Check the narrow container and the width just on each side of a changed breakpoint. Preserve input drafts, keyboard focus, dismissal, and access to the primary action when layout changes. Follow the [testing policy](../testing.md) for checks that exercise behavior; do not add assertions that merely repeat CSS declarations. + +Verify the causal structure first, confirm that styles actually apply, then inspect appearance and behavior. Record complete icon boxes, text starts, painted button edges, and hit-target positions separately where useful; target rectangles cannot stand in for every alignment measurement. Shared-component adjustments include consumers with and without optional descriptions, unequal content, and real neighbors rather than only the report fixture. + +When build or hot-reload state is uncertain, create a separate application instance using the intended sources and verify its artifacts to avoid old scripts with new styles. An unchanged DOM snapshot, stale test selector, or failing capture hook cannot establish whether a visual correction passes or fails; identify the failure owner and verify current behavior with relevant tests without capture hooks. + + +## 5. Verify behavior and comparable images + +Inspect the affected result and directly affected neighbors in the actual page. If a required scene is unavailable, report the missing check, reason and next verification; labeled production-component fixtures provide partial evidence, not an actual-page pass. Extra screenshots and comparisons are required when requested or needed to explain broad changes or assess regression risk; routine edits need no separate report. This changes recordkeeping, not the minimum inspection or existing GIF rules. For a comparison, match content, theme, locale, viewport, zoom, device scale, interaction state and content anchor; let fonts, data and transitions settle. + +When producing a comparison, save final captures separately and pair them with the corresponding starting scene under matching conditions. + +For retained comparisons, check dimensions and capture conditions; use scene IDs when there is an inventory. Correct mismatched conditions instead of stretching images, silently changing crops or presenting a reconstructed fixture as an earlier application screenshot. Inspect the delivered images and check that they support the stated scope. + +Two captures with the same rendered-source fingerprint demonstrate the capture and reporting workflow, not a UI improvement. An actual before-and-after comparison captures the starting implementation before editing and the resulting implementation afterward. Keep that distinction visible in the delivered report. + +Use equal-sized paired crops with the same content origin when a full page hides a small difference; retain enough context to show the relationship being judged. If a content change requires a different crop, disclose it and keep the original full-context captures available. Verify both themes when color, state, or icon balance is involved, and use enlarged views only as a supplement to the intended display size. + + +## 6. Deliver the complete evidence + +An ordinary handoff states what changed, what was checked and any material gap; a short explanation or screenshot can suffice. For a requested detailed case report, place the scene/problem and design judgment beside its image, caption the source and explain what to inspect. Avoid detached screenshot collections or images that do not show the claimed relationship. + +When a before/after comparison is part of the deliverable, label both sides and distinguish application captures from isolated component scenes. If the user requests all changes, reconcile that scope with the final diff, including nested controls, and state missing evidence. Do not require a paired report for every ordinary correction. + +For comprehensive reviews, distinguish inspected-without-findings, defects and uninspected regions. For ordinary changes, report the result and material gaps without a per-region status ledger. Add rule ownership, wrong-fix examples, exceptions or retest conditions only when useful to explain a finding. Known defects cannot be canceled by unrelated passing checks. + +Report the checks actually run and their outcome, and distinguish automated metadata checks, behavior tests, and human visual inspection. Include material limitations such as an unavailable application state or an untested platform. Do not describe the work as fully verified while required scenes or checks remain outstanding. + +Use the [UI change workflow](../../.agents/skills/dsh-client-ui-ux/SKILL.md#required-review-loop) at handoff, review and push to check new cumulative effects or invalidated evidence, not to repeat completed inspection. No extra report is required for an unchanged result. Tests and agent inspection do not replace the existing [designer/product approval requirement](../../.agents/skills/dsh-client-ui-ux/SKILL.md#design-review-escalation). + +### Comparison delivery and cumulative changes + +For a formal comparison report, use before on the left and after on the right. Add non-obscuring annotations only when helpful, on the after image, and retain originals; a removable overlay is useful for interactive reports. Choose a readable narrow-screen presentation rather than requiring a special report interface. Ordinary handoffs may use simple labeled images or concise text; the original GIF requirements remain unchanged. + +Deduplicate cumulative summaries by location: pair the earliest reliable before image with the final implementation, retaining comparable conditions. Keep intermediate rounds only as historical evidence, not separate final results; reverted proposals are not current results. List another session's retained changes separately without claiming them. Disclose missing baselines or uncertain ownership. Record actual checks without adding repeated runs as extra coverage. + +For an all-changes summary, trace each claim to the cumulative diff, current owner and retained evidence. Separate net changes from behavior already in the baseline; resolve later user revisions before naming final values. Label older captures as historical when they no longer show the current result, and distinguish source-confirmed behavior from a scene inspected again. Feed reusable decisions back to their rule owner and contrasting cases, not local pixel values or an unsupported whole-product pass. + + +## Further Exploration + +Use these owners for the details needed by a particular review. + +- [UI design principles and review cases](../ui-design-review-cases.md) — whole-page and modal judgment, applicable rules, and explanatory examples. +- [Rule ownership and maintenance](../../.agents/skills/dsh-client-ui-ux/SKILL.md#maintain-one-owner-per-rule) — where to change a principle and how local tools consume it. +- [Web UI style reference](../web-styling.md) — component reuse and styling ownership. + + +## Dev Note + +None. diff --git a/docs/cookbook/reviewing-ui-design.zh.md b/docs/cookbook/reviewing-ui-design.zh.md new file mode 100644 index 0000000000..2a13d4aa2c --- /dev/null +++ b/docs/cookbook/reviewing-ui-design.zh.md @@ -0,0 +1,166 @@ +--- +description: "把 DSH 页面或弹窗作为整体审查,按设计原则说明问题,并验证有依据的修改。" +--- + +# Cookbook:审查 UI 设计 + +[English](reviewing-ui-design.md) | 中文 + +## 概览 + +本手册用于明确要求的完整审查,以及由改动影响或回归风险决定需要采用的证据步骤。普通开发按 [UI 改动流程](../../.agents/skills/dsh-client-ui-ux/SKILL.md#required-review-loop)走轻量路径,不逐节执行本手册。[原则与案例参考](../ui-design-review-cases.zh.md)提供判断依据,不是必做清单。既有测试、GUI PR GIF 和设计/产品审批继续按原有规则执行。 + +对齐审查先核对图标完整空间(含有意保留的内部留白)、文字列和间距,再检查实际轮廓。热区不等于图标框;不逐个拉齐 glyph 的像素边缘。判断顺序见[对齐原则](../ui-design-review-cases.zh.md#alignment)。 + +按[同前景色规则](../../.agents/skills/dsh-client-ui-ux/references/icons.md#placement-and-use)核对关联图标与文字,包括子菜单值与箭头、搜索占位文字与放大镜;检查明暗主题中的实际颜色和透明度,保留语义例外。 + +## 目录 + +- [1. 确定受审页面或弹窗](#proposition) +- [2. 检查任务路径与实现](#inspect) +- [3. 保留起点](#baseline) +- [4. 修改最小责任组件](#implement) +- [5. 验证行为与可比较的截图](#verify) +- [6. 交付完整证据](#deliver) +- [延伸阅读](#further-exploration) +- [开发备注](#dev-note) + + +## 1. 确定受审页面或弹窗 + +确定用户任务和受影响页面或弹窗。涉及多个区域、使用方或设计取舍时记录范围;明确的局部修改不需要单独写计划。用户明确要求完整审查时,列出指定页面/弹窗及相关状态。不能把局部检查表述成整页结论。 + +在这个范围内应用[判断优先级](../ui-design-review-cases.zh.md#judgment):先检查对齐与空间分组,再审查密度、层级、状态、组件选型、图标光学和几何。每个发现写成可观察的关系,例如内容与隔壁组的距离反而小于与自身标签的距离。遵循用户指定的顺序和授权;完整审查可以发现多个相关问题,常规调整的实施授权不能替代[既有设计/产品审查要求](../../.agents/skills/dsh-client-ui-ux/SKILL.md#design-review-escalation)。 + +编辑前阅读[客户端 UI Skill](../../.agents/skills/dsh-client-ui-ux/SKILL.md)和适用架构。宽度与输入方式变更使用[响应式 Skill](../../.agents/skills/dsh-responsive-ui/SKILL.md),令牌规则查阅[样式参考](../web-styling.zh.md),圆角几何查阅[圆角标准](../ui-radius.zh.md)。本流程补充审查和证据步骤,不另建一套视觉令牌。 + + +### 按视觉影响选择范围 + +按下表选择检查范围。较大改动可用几句任务笔记追踪受影响的组、使用方与风险条件;多区域审查确有需要时再用覆盖表。局部修改无需场景清单。文件数量和包边界不能决定视觉影响范围。 + +| 改动 | 最小检查范围 | +|---|---| +| 局部文案、图标、颜色或间距 | 先看所属页面或弹窗的上下文;检查所属组、相邻组及共用轴线或角色的受影响并列元素,包括远处的并列元素 | +| 页面、表单或卡片布局;新页面或弹窗 | 将所属页面或弹窗作为整体审查,包含相关滚动和展开内容 | +| 共享组件 | 排查全部受影响使用方;在实际组合上下文中检查每种不同的布局、内容和状态变体 | +| 全局字体、令牌或样式 | 梳理受影响的组件族与页面布局,按这些类别扩大覆盖,不能当成局部改动 | +| 没有预期样式变化的行为改动 | 检查可能改变可见关系的状态,包括加载、错误、禁用、展开和焦点 | + +只有相关实现、尺寸、容器约束、内容行为和状态等价时,共享组件使用方才能复用证据;记录归类和依据。不确定的场景直接检查。源码排查全部使用方不等于逐页打开过。问题在初始区域外重复出现,或改法影响邻接关系时,扩大范围。无关发现单独记录,不静默开始整个产品重设计。 + +对每项变化执行[最低检查要求](../../.agents/skills/dsh-client-ui-ux/SKILL.md#minimum-checks-for-affected-ui):颜色必须查明暗主题;布局、间距、字体和尺寸必须查窄容器与长内容;交互必须查受影响路径和状态。局部修改也要完成这些检查,但不要求每个条件都保存截图。遵守所属响应式与行为要求,不穷举无关组合,也不用固定截图数替代覆盖判断。同一任务中复用未变规则和有效证据,一组相关修改完成后再截图,不逐条 CSS 调整都重拍。只读取相关原则和案例;设计判断不明确时再补充查阅。 + + +## 2. 检查任务路径与实现 + +从正常应用入口检查受影响的结果。复用正在运行且使用当前实现的实例,不为每次微调单独重启或重建。沿相关任务路径检查,必要时使用计算样式区分布局缺陷与内容、加载或未结束动画造成的现象。 + +普通改动说明问题位置、可见现象、修正和验证即可。源码归属、替代方案及改法不适用的条件影响判断时再补充。优先处理可见或共享缺陷;源码数值不同本身不能成为统一数值的理由。 + +诊断间距时,分别记录布局框、margin、padding、文字行框和字形可见边缘。明确关闭控件按按钮边缘还是图标框对齐,并核对不同图标尺寸下入口与菜单的文本内缩。可见留白的标注不等于测得了 CSS margin。 + +### 在整个界面内核对适用规范 + +按[适用规范映射](../ui-design-review-cases.zh.md#review-baseline)收集本任务相关的当前圆角、材质、字体、组件、反馈、响应式和图案规则,记录明确例外与相关已有变体。个人或外部 skill 不同时,以仓库所有方为准;实现与所有方不同时,把差异保留为发现,不能默默任选一方。 + +将[整页与整个弹窗的审查原则](../ui-design-review-cases.zh.md#whole-interface-review)应用于选定范围。保留整体构图上下文,先比较受影响区域与重复关系,再用局部 fixture 分析细节。明确的完整审查覆盖全部区域;局部修正需保留邻接的对齐、分组、嵌套圆角、裁切与焦点关系。 + +### 把关系明确写出来 + +确定相关分组和对齐参照。关系复杂、有争议或需要解释时,才画轴线或标注保留上下文的截图;画图不是阅读 CSS 的前置条件。下表是记录审查时可选的提示,不要求每次局部修改逐项填写。 + +| 观察项 | 记录内容 | 示例 | +|---|---|---| +| 任务与分组 | 每组描述或操作什么,包含哪些子组,与谁相邻 | 会话标题/三操作组;成员区/任务区 | +| 对齐 | 对象、轴线、参照容器及有意的例外 | 基线定义的同类行文字起点;主按钮内 icon+文字整体居中 | +| 间距 | 哪些是组内、重复单元之间和组间距离 | 标题与所属卡片比与前一个分区更近 | +| 并列几何 | 可比较角色的可见尺寸、圆角与颜色关系 | 圆形底板等大;搜索提示共用一个前景色角色 | +| 状态 | 同时叠加及相邻共存的组合 | 当前会话选中,同时另一条会话悬停 | +| 证据范围 | 能看到什么,哪些尚未检查 | 完整 Desktop 侧栏与隔离会话行的区别 | + +按整体构图、组与组的关系、组内细节三层观察,再用源码与计算几何解释可见结果。对齐比较实际文字起点和槽位中心;间距同时比较可见边界与 CSS 框。区分 CSS 像素与截图像素,保留缩放和设备像素比,不从缩放后的图片推导新的设计常数。 + +有效发现要说明违反了什么关系:“标题到操作组的距离小于操作组内部距离,视觉上把标题粘到首个操作;分开两组,并重测长标题悬停。”只说“间距不对”或“都使用 token”都没有完成判断。明显不一致即使不阻塞任务,也能构成设计不通过,不能因为仍然点得动就忽略。 + +宣布页面或弹窗审查完成前,将它实际包含的区域和相关状态与已检查证据逐一核对,包含滚动、展开或切换标签页后才能看到的内容。局部 fixture 或案例不能替代这个整体中尚未检查的部分。局部审查则明确列出未覆盖范围。 + +用本地维护的生产组件 fixture 复现受控状态或比较变体,作为真实应用检查的补充。截图、报告工具及其场景适配可放在仓库外并读取源码工作树,不作为产品依赖或 CI 验收;支持场景与限制以工具的本地说明为准。 + +### 为审查建立覆盖记录 + +明确要求完整审查,或确有多个区域需要追踪时使用下表。普通局部修改不需要覆盖表,也不必另写笔记重述所有字段。检查受影响的关系,对共享使用方分组时保留有依据的等价判断。 + +| 页面/弹窗与入口 | 语义组及跨组关系 | 已查条件 | 发现与归属方 | 修正后证据 | 尚未检查 | +|---|---|---|---|---|---| +| 填实际入口与区域 | 指定比较两端、完整框或文字轴 | 内容、宽度、主题、状态 | 记录规则和责任组件 | 对照图与相关行为结果 | 写明限制,不留含糊的“已覆盖” | + +复杂问题再说明比较对象、预期关系、支持证据和可能受损的邻接关系。错误修法或复查条件有助于判断时再写;普通发现采用前面的简短形式。证据不足的候选保留为待核实,在选定范围内排查已确认的重复问题,不顺带开展无关清理。 + + +## 3. 保留起点 + +明确要求对照,或较大改动及回归风险需要对照时,保留可恢复的起始版本与相关截图。普通局部修改不要求额外源码快照或修改前截图。保留基线时需包含已有未提交改动,不能假定 `HEAD` 就是当前可见应用,并记录源码身份与截图条件。 + +需要截图时,把图像及其条件保存在工作树根目录的 `.ui-review/`,或另行明确选择的本地目录。仓库忽略 `.ui-review/` 中的生成证据;审查工具仍可安装在工作树之外。 + +完整审查或需要追踪多个状态的对照使用场景映射;简单改动可直接检查受影响位置。展开行、弹窗及相关加载/禁用状态影响结论时应纳入,折叠父组件不能覆盖隐藏控件。破坏性或暂不可达状态使用安全 fixture 数据,不为截图而修改用户的真实任务。 + + +## 4. 修改最小责任组件 + +修改负责该任务的归属方:页面布局由页面负责,共享控件状态由基础组件负责,跨使用方重复的 glyph 失衡由图标库负责。复用组件与主题别名,验证受影响关系并保留工作区已有独立改动。开发修正无需先写成一条正式审查发现。 + +检查可能与本次修改冲突的状态,例如选中与悬停或键盘焦点叠加、展开与焦点叠加、忙碌与禁用叠加,以及校验信息与长内容共存。检查窄容器和变更断点两侧的临界宽度。布局变化时保留输入草稿、键盘焦点、关闭方式及主操作的可达性。按[测试策略](../testing.zh.md)验证行为,不添加只复述 CSS 声明的断言。 + +先验证造成问题的结构,再确认样式实际生效,最后检查视觉与行为。可记录完整 icon 框、文字起点、按钮可见边缘和热区各自的计算位置;不要拿热区矩形代替全部对齐证据。公共组件的调整至少覆盖有/无可选说明、长短内容与实际相邻控件,不能仅修复报告中的一个 fixture。 + +构建或热更新状态不确定时,重新建立使用目标源码的独立应用实例并核对产物,避免旧脚本配上新样式。仅因 DOM 快照没变、测试选择器过期或截图钩子失败,不能判定视觉修正通过或失败;定位失败归属,再以更新后的真实行为预期和无截图钩子的相关用例验证。 + + +## 5. 验证行为与可比较的截图 + +在实际页面中检查受影响结果及直接相关的邻接元素。必要场景不可用时,说明缺了哪项检查、原因和下一步验证方法;标明来源的生产组件 fixture 可提供部分证据,不能算作实际页面通过。用户要求对照,或解释较大改动、评估回归风险需要时,才额外保存截图和对照;常规修改无需独立报告。这里简化的是留档,不是最低检查或既有 GIF 要求。制作对照时保持内容、主题、语言、视口、缩放、设备像素比、交互状态和内容锚点一致,并等待字体、数据和过渡稳定。 + +制作对照时,将最终截图单独保存,并按对应的起始场景和相同条件配对。 + +保留对照时检查尺寸和截图条件;有场景清单时再核对场景 ID。修正不匹配的条件,不拉伸图片、默默改变裁剪,或把重建 fixture 当作更早的应用截图。检查交付图片,确认它们支持所声明的范围。 + +两次截图的渲染源码指纹相同时,只能演示截图与报告流程,不能证明 UI 改进。真正的前后对照应当在编辑前拍摄起始实现,编辑后拍摄结果实现。交付报告中也要清楚保留这一区别。 + +整页截图掩盖细小差异时,可使用尺寸相同、内容起点相同的成对裁剪图,并保留足以判断关系的上下文。内容变化确实需要不同裁剪时,说明差异,并保留完整上下文的原图。涉及颜色、状态或图标平衡时验证明暗两种主题;放大图只能补充实际显示尺寸下的检查。 + + +## 6. 交付完整证据 + +普通交付说明改了什么、检查结果和重要缺口,简短解释或截图即可。明确要求详细案例报告时,将场景/问题、设计判断与图片放在一起,图注说明来源及关注位置。避免脱离正文的截图集合或无法展示所述关系的图片。 + +交付需要前后对照时,标明两侧含义并区分真实应用截图与隔离组件场景。用户要求全部修改时,将该范围与最终 diff 核对,包含嵌套控件,并说明缺少的证据。普通修正不要求每项制作成对报告。 + +完整审查区分已查无发现、有缺陷和尚未检查的区域。普通修改报告结果和重要缺口,无需逐区域状态账目。规则归属、错误修法、例外或复查条件有助于解释发现时再补充。无关检查通过不能抵消已知缺陷。 + +报告实际运行的检查及结果,区分自动元数据校验、行为测试与人工读图。说明重要限制,例如应用状态暂不可达或某个平台尚未测试。必需场景或检查未完成时,不要声称已经完整验证。 + +交付、评审和推送时按 [UI 改动流程](../../.agents/skills/dsh-client-ui-ux/SKILL.md#required-review-loop)检查新增累计影响或失效证据,不重复已经完成的检查。结果未变时无需额外报告。测试和 agent 检查不能替代既有[设计/产品审批要求](../../.agents/skills/dsh-client-ui-ux/SKILL.md#design-review-escalation)。 + +### 对比交付与累计改动 + +正式对照报告采用左前右后。有助于解释时才在修改后图片上添加不遮挡内容的标注,并保留原图;交互报告可使用可关闭的叠加层。窄屏选择可读的呈现方式,不强制制作专门的报告界面。普通交付可用简单标明含义的图片或简短文字;既有 GIF 要求保持不变。 + +累计汇总按位置去重:在条件可比的前提下,将最早可靠的修改前截图与最终实现配对。中间轮次只保留为历史证据,不作为独立最终成果;撤销方案不计入现存成果。另一会话的保留改动单列,不归为本次成果。缺少起点或归属不确定时明确说明。记录实际检查,不将重复执行数量当作新增覆盖。 + +汇总全部修改时,将每项结论追溯到累计 diff、当前实现所有方及保留证据。区分净改动与基线已有行为,先消解用户后续修订,再给出最终数值。旧截图不再代表当前结果时标为历史证据,并区分源码核验与重新打开场景检查。将可复用的判断回写规则所有方及正反案例,不把局部像素值或缺乏依据的全产品通过结论变成规范。 + + +## 延伸阅读 + +按本次审查需要查阅对应归属文档。 + +- [UI 设计原则与审查案例](../ui-design-review-cases.zh.md)——整页与整个弹窗的判断原则、适用规则及解释性案例。 +- [规则归属与维护](../../.agents/skills/dsh-client-ui-ux/SKILL.md#maintain-one-owner-per-rule)——原则的修改位置及本地工具的读取方式。 +- [Web UI 样式参考](../web-styling.zh.md)——组件复用与样式归属。 + + +## 开发备注 + +无。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index cc7a806a71..d344748ffd 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -13,7 +13,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:198`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:399`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | -| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `browser-use-runtime`, `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | [`agent-team`](../packages/team/agent-team), `browser-use-runtime`, `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller`, [`subagent`](../packages/subagent/subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:429`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:328`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | @@ -23,11 +23,11 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:700`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:680`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:707`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:686`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:693`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:701`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:681`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:708`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:687`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:694`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:55`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes`, `server` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | @@ -41,7 +41,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `cordis/request-run` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:371`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:377`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:99`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization), [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform), `remotes` | -| `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:87`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `remotes` | +| `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:87`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`mcp-client`](../packages/mcp/mcp-client), `remotes` | | `deepseek-account/model-sign-in-required` | `emit` | [`packages/credentials/deepseek-account/src/types.ts:97`](../packages/credentials/deepseek-account/src/types.ts) | [`deepseek-account`](../packages/credentials/deepseek-account) (`emit`) | `remotes` | | `deepseek-account/session-expired` | `emit` | [`packages/credentials/deepseek-account/src/types.ts:93`](../packages/credentials/deepseek-account/src/types.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | `account-controller`, `remotes` | | `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:12`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | [`deepseek-account`](../packages/credentials/deepseek-account) | @@ -60,6 +60,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:24`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` | | `llm/models-updated` | `emit` | [`packages/llm/llm/src/types.ts:30`](../packages/llm/llm/src/types.ts) | [`llm-deepseek`](../packages/llm/llm-deepseek) (`emit`) | `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | +| `mcp-manager/status` | `parallel` | [`packages/mcp/mcp-manager/src/types.ts:46`](../packages/mcp/mcp-manager/src/types.ts) | [`mcp-manager`](../packages/mcp/mcp-manager) (`parallel`) | - | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` | | `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:432`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:438`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | @@ -89,6 +90,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `forge-session`, `liveshare`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present), `ui-trajectory` | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` | +| `web/providers-changed` | `emit` | [`packages/web/web/src/types.ts:21`](../packages/web/web/src/types.ts) | [`web`](../packages/web/web) (`events.dispatch`) | `remotes` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:44`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | @@ -107,12 +109,12 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | --- | --- | --- | | `agent/request` | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic) | | `connection/reset` | `gateway` (`emit`) | `terminal-controller`, `ui-settings` | -| `internal/config` | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | +| `internal/config` | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`mcp-manager`](../packages/mcp/mcp-manager) | | `internal/dispatch` | - | [`terminal-bash`](../packages/terminal/terminal-bash) | -| `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` | +| `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), [`mcp-manager`](../packages/mcp/mcp-manager), `modules` | | `internal/service` | - | `client-ui-forge`, `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent), `claude-code-mods`, `inspector`, `translator`, [`web`](../packages/web/web) | -| `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` | +| `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`mcp-manager`](../packages/mcp/mcp-manager), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` | | `locale/change` | `locale` (`emit`) | `locale` | | `slots/changed` | `ui-renderer` (`emit`) | - | | `theme/change` | `ui-theme` (`emit`) | `ui-layout`, `ui-theme` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 6c437ea8b6..cee3960282 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -15,7 +15,7 @@ | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:198`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:399`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | -| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `browser-use-runtime`, `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | [`agent-team`](../packages/team/agent-team), `browser-use-runtime`, `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller`, [`subagent`](../packages/subagent/subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:429`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:328`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | @@ -25,11 +25,11 @@ | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:700`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:680`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:707`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:686`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:693`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:701`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:681`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:708`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:687`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:694`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:55`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes`, `server` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | @@ -43,7 +43,7 @@ | `cordis/request-run` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:371`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:377`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:99`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization), [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform), `remotes` | -| `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:87`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `remotes` | +| `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:87`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`mcp-client`](../packages/mcp/mcp-client), `remotes` | | `deepseek-account/model-sign-in-required` | `emit` | [`packages/credentials/deepseek-account/src/types.ts:97`](../packages/credentials/deepseek-account/src/types.ts) | [`deepseek-account`](../packages/credentials/deepseek-account) (`emit`) | `remotes` | | `deepseek-account/session-expired` | `emit` | [`packages/credentials/deepseek-account/src/types.ts:93`](../packages/credentials/deepseek-account/src/types.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | `account-controller`, `remotes` | | `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:12`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | [`deepseek-account`](../packages/credentials/deepseek-account) | @@ -62,6 +62,7 @@ | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:24`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` | | `llm/models-updated` | `emit` | [`packages/llm/llm/src/types.ts:30`](../packages/llm/llm/src/types.ts) | [`llm-deepseek`](../packages/llm/llm-deepseek) (`emit`) | `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | +| `mcp-manager/status` | `parallel` | [`packages/mcp/mcp-manager/src/types.ts:46`](../packages/mcp/mcp-manager/src/types.ts) | [`mcp-manager`](../packages/mcp/mcp-manager) (`parallel`) | - | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` | | `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:432`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:438`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | @@ -91,6 +92,7 @@ | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `forge-session`, `liveshare`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present), `ui-trajectory` | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` | +| `web/providers-changed` | `emit` | [`packages/web/web/src/types.ts:21`](../packages/web/web/src/types.ts) | [`web`](../packages/web/web) (`events.dispatch`) | `remotes` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:44`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | @@ -109,12 +111,12 @@ | --- | --- | --- | | `agent/request` | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic) | | `connection/reset` | `gateway` (`emit`) | `terminal-controller`, `ui-settings` | -| `internal/config` | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | +| `internal/config` | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`mcp-manager`](../packages/mcp/mcp-manager) | | `internal/dispatch` | - | [`terminal-bash`](../packages/terminal/terminal-bash) | -| `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` | +| `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), [`mcp-manager`](../packages/mcp/mcp-manager), `modules` | | `internal/service` | - | `client-ui-forge`, `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent), `claude-code-mods`, `inspector`, `translator`, [`web`](../packages/web/web) | -| `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` | +| `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`mcp-manager`](../packages/mcp/mcp-manager), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` | | `locale/change` | `locale` (`emit`) | `locale` | | `slots/changed` | `ui-renderer` (`emit`) | - | | `theme/change` | `ui-theme` (`emit`) | `ui-layout`, `ui-theme` | diff --git a/docs/module-graph.md b/docs/module-graph.md index 3236cc783c..f58da15929 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -93,8 +93,6 @@ flowchart TD pkg_web_deepseek_api["web-deepseek-api"] pkg_web_fetch_http["web-fetch-http"] pkg_web_search_deepseek["web-search-deepseek"] - pkg_web_search_exa["web-search-exa"] - pkg_web_search_perplexity["web-search-perplexity"] end subgraph group_spill["packages/spill"] pkg_spill["spill"] @@ -366,6 +364,7 @@ flowchart TD end subgraph group_mcp["packages/mcp"] pkg_mcp_client["mcp-client"] + pkg_mcp_manager["mcp-manager"] pkg_mcp_resources["mcp-resources"] end subgraph group_preset["packages/preset"] @@ -517,10 +516,6 @@ flowchart TD pkg_skill_office --> pkg_skill pkg_web_fetch_http --> pkg_timeout pkg_web_fetch_http --> pkg_web - pkg_web_search_exa --> pkg_launch_environment - pkg_web_search_exa --> pkg_web - pkg_web_search_perplexity --> pkg_launch_environment - pkg_web_search_perplexity --> pkg_web pkg_spill --> pkg_brand pkg_spill --> pkg_llm pkg_spill --> pkg_session @@ -767,16 +762,11 @@ flowchart TD pkg_fs_sandbox --> pkg_fs_local pkg_fs_sandbox --> pkg_sandbox pkg_fs_sandbox --> pkg_sandbox_policy - pkg_web_deepseek_api --> pkg_agent - pkg_web_deepseek_api --> pkg_credentials - pkg_web_deepseek_api --> pkg_deepseek_account - pkg_web_deepseek_api --> pkg_launch_environment - pkg_web_deepseek_api --> pkg_session - pkg_web_deepseek_api --> pkg_web pkg_web_search_deepseek --> pkg_agent pkg_web_search_deepseek --> pkg_credentials pkg_web_search_deepseek --> pkg_deepseek_account pkg_web_search_deepseek --> pkg_launch_environment + pkg_web_search_deepseek --> pkg_llm_deepseek pkg_web_search_deepseek --> pkg_session pkg_web_search_deepseek --> pkg_web pkg_api_account_controller --> pkg_agent @@ -904,6 +894,16 @@ flowchart TD pkg_tool_web --> pkg_system_prompt pkg_tool_web --> pkg_tools pkg_tool_web --> pkg_web + pkg_web_deepseek_api --> pkg_agent + pkg_web_deepseek_api --> pkg_credentials + pkg_web_deepseek_api --> pkg_deepseek_account + pkg_web_deepseek_api --> pkg_launch_environment + pkg_web_deepseek_api --> pkg_llm + pkg_web_deepseek_api --> pkg_llm_deepseek + pkg_web_deepseek_api --> pkg_llm_deepseek_account + pkg_web_deepseek_api --> pkg_llm_deepseek_api_key + pkg_web_deepseek_api --> pkg_session + pkg_web_deepseek_api --> pkg_web pkg_tool_todo --> pkg_agent pkg_tool_todo --> pkg_session pkg_tool_todo --> pkg_session_projection @@ -1156,6 +1156,7 @@ flowchart TD pkg_host_plugin_inventory --> pkg_brand pkg_host_plugin_inventory --> pkg_typert_protocol pkg_mcp_client --> pkg_attachment + pkg_mcp_client --> pkg_credentials pkg_mcp_client --> pkg_llm pkg_mcp_client --> pkg_mcp_resources pkg_mcp_client --> pkg_scope @@ -1293,6 +1294,10 @@ flowchart TD pkg_webhook_github --> pkg_host_webserver pkg_webhook_github --> pkg_session pkg_webhook_github --> pkg_webhook + pkg_mcp_manager --> pkg_mcp_client + pkg_mcp_manager --> pkg_tools + pkg_mcp_manager --> pkg_typert_protocol + pkg_mcp_manager --> pkg_util_values pkg_goal_round_driver --> pkg_agent pkg_goal_round_driver --> pkg_goal pkg_goal_round_driver --> pkg_llm @@ -1681,8 +1686,6 @@ flowchart TD | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) | | [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) | | [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | -| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | -| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | @@ -1759,8 +1762,7 @@ flowchart TD | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`web-deepseek-api`](../packages/web/web-deepseek-api) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) | -| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) | +| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm-deepseek`](../packages/llm/llm-deepseek), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | `credentials` | [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`host-webserver`](../packages/host/webserver) | @@ -1785,6 +1787,7 @@ flowchart TD | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-workspace-dependencies`](../packages/skill/tool-workspace-dependencies) | `skill` | [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | +| [`web-deepseek-api`](../packages/web/web-deepseek-api) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-account`](../packages/llm/llm-deepseek-account), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction) | @@ -1835,7 +1838,7 @@ flowchart TD | [`webhook`](../packages/experimental/webhook) | `experimental` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`app-boot`](../packages/boot/app-boot), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`tools`](../packages/core/tools) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | -| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-resources`](../packages/mcp/mcp-resources), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`llm`](../packages/llm/llm), [`mcp-resources`](../packages/mcp/mcp-resources), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`agent-preset`](../packages/preset/agent-preset) | `preset` | [`agent-preset-registry`](../packages/preset/agent-preset-registry) | | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`otel`](../packages/telemetry/otel), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | @@ -1854,6 +1857,7 @@ flowchart TD | [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | `experimental` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`subprocess`](../packages/subprocess/subprocess), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) | | [`tool-session-query`](../packages/experimental/tool-session-query) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`webhook-github`](../packages/experimental/webhook-github) | `experimental` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/experimental/webhook) | +| [`mcp-manager`](../packages/mcp/mcp-manager) | `mcp` | [`mcp-client`](../packages/mcp/mcp-client), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values) | | [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 8ce9f2337b..372f734770 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -95,8 +95,6 @@ flowchart TD pkg_web_deepseek_api["web-deepseek-api"] pkg_web_fetch_http["web-fetch-http"] pkg_web_search_deepseek["web-search-deepseek"] - pkg_web_search_exa["web-search-exa"] - pkg_web_search_perplexity["web-search-perplexity"] end subgraph group_spill["packages/spill"] pkg_spill["spill"] @@ -368,6 +366,7 @@ flowchart TD end subgraph group_mcp["packages/mcp"] pkg_mcp_client["mcp-client"] + pkg_mcp_manager["mcp-manager"] pkg_mcp_resources["mcp-resources"] end subgraph group_preset["packages/preset"] @@ -519,10 +518,6 @@ flowchart TD pkg_skill_office --> pkg_skill pkg_web_fetch_http --> pkg_timeout pkg_web_fetch_http --> pkg_web - pkg_web_search_exa --> pkg_launch_environment - pkg_web_search_exa --> pkg_web - pkg_web_search_perplexity --> pkg_launch_environment - pkg_web_search_perplexity --> pkg_web pkg_spill --> pkg_brand pkg_spill --> pkg_llm pkg_spill --> pkg_session @@ -769,16 +764,11 @@ flowchart TD pkg_fs_sandbox --> pkg_fs_local pkg_fs_sandbox --> pkg_sandbox pkg_fs_sandbox --> pkg_sandbox_policy - pkg_web_deepseek_api --> pkg_agent - pkg_web_deepseek_api --> pkg_credentials - pkg_web_deepseek_api --> pkg_deepseek_account - pkg_web_deepseek_api --> pkg_launch_environment - pkg_web_deepseek_api --> pkg_session - pkg_web_deepseek_api --> pkg_web pkg_web_search_deepseek --> pkg_agent pkg_web_search_deepseek --> pkg_credentials pkg_web_search_deepseek --> pkg_deepseek_account pkg_web_search_deepseek --> pkg_launch_environment + pkg_web_search_deepseek --> pkg_llm_deepseek pkg_web_search_deepseek --> pkg_session pkg_web_search_deepseek --> pkg_web pkg_api_account_controller --> pkg_agent @@ -906,6 +896,16 @@ flowchart TD pkg_tool_web --> pkg_system_prompt pkg_tool_web --> pkg_tools pkg_tool_web --> pkg_web + pkg_web_deepseek_api --> pkg_agent + pkg_web_deepseek_api --> pkg_credentials + pkg_web_deepseek_api --> pkg_deepseek_account + pkg_web_deepseek_api --> pkg_launch_environment + pkg_web_deepseek_api --> pkg_llm + pkg_web_deepseek_api --> pkg_llm_deepseek + pkg_web_deepseek_api --> pkg_llm_deepseek_account + pkg_web_deepseek_api --> pkg_llm_deepseek_api_key + pkg_web_deepseek_api --> pkg_session + pkg_web_deepseek_api --> pkg_web pkg_tool_todo --> pkg_agent pkg_tool_todo --> pkg_session pkg_tool_todo --> pkg_session_projection @@ -1158,6 +1158,7 @@ flowchart TD pkg_host_plugin_inventory --> pkg_brand pkg_host_plugin_inventory --> pkg_typert_protocol pkg_mcp_client --> pkg_attachment + pkg_mcp_client --> pkg_credentials pkg_mcp_client --> pkg_llm pkg_mcp_client --> pkg_mcp_resources pkg_mcp_client --> pkg_scope @@ -1295,6 +1296,10 @@ flowchart TD pkg_webhook_github --> pkg_host_webserver pkg_webhook_github --> pkg_session pkg_webhook_github --> pkg_webhook + pkg_mcp_manager --> pkg_mcp_client + pkg_mcp_manager --> pkg_tools + pkg_mcp_manager --> pkg_typert_protocol + pkg_mcp_manager --> pkg_util_values pkg_goal_round_driver --> pkg_agent pkg_goal_round_driver --> pkg_goal pkg_goal_round_driver --> pkg_llm @@ -1683,8 +1688,6 @@ flowchart TD | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) | | [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) | | [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | -| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | -| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | @@ -1761,8 +1764,7 @@ flowchart TD | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`web-deepseek-api`](../packages/web/web-deepseek-api) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) | -| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) | +| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm-deepseek`](../packages/llm/llm-deepseek), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | `credentials` | [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`host-webserver`](../packages/host/webserver) | @@ -1787,6 +1789,7 @@ flowchart TD | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-workspace-dependencies`](../packages/skill/tool-workspace-dependencies) | `skill` | [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | +| [`web-deepseek-api`](../packages/web/web-deepseek-api) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-account`](../packages/llm/llm-deepseek-account), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction) | @@ -1837,7 +1840,7 @@ flowchart TD | [`webhook`](../packages/experimental/webhook) | `experimental` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`app-boot`](../packages/boot/app-boot), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`tools`](../packages/core/tools) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | -| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-resources`](../packages/mcp/mcp-resources), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`llm`](../packages/llm/llm), [`mcp-resources`](../packages/mcp/mcp-resources), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`agent-preset`](../packages/preset/agent-preset) | `preset` | [`agent-preset-registry`](../packages/preset/agent-preset-registry) | | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`otel`](../packages/telemetry/otel), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | @@ -1856,6 +1859,7 @@ flowchart TD | [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | `experimental` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`subprocess`](../packages/subprocess/subprocess), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) | | [`tool-session-query`](../packages/experimental/tool-session-query) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`webhook-github`](../packages/experimental/webhook-github) | `experimental` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/experimental/webhook) | +| [`mcp-manager`](../packages/mcp/mcp-manager) | `mcp` | [`mcp-client`](../packages/mcp/mcp-client), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values) | | [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 8f7366da1c..338dcc1299 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -225,20 +225,20 @@ en: a640800a38b3c874 zh: a640800a38b3c874 /session-persistence-event-catalog/events/team/team-member-log-only: - en: de16dcf2046b402c - zh: b6c73eb866413523 + en: b52b572664da0f8e + zh: 1c8fd9d1634ec6e1 /session-persistence-event-catalog/events/team/team-message-delivered-log-only: - en: 9c9d9238de0eeb89 - zh: 1f52e5da217dae3d + en: 6c9a217924277caa + zh: 417f47f16980f494 /session-persistence-event-catalog/events/team/team-message-queued-log-only: - en: 184b700f0a0363ae - zh: 74b7939bf1bface1 + en: 1cf7f8d33840ab23 + zh: 6b52612308519ddc /session-persistence-event-catalog/events/team/team-mode-log-only: - en: 9481b9f0697707e4 - zh: 83aa2d33782a7b00 + en: a3d97b5c076edb80 + zh: b45b64d87c16479a /session-persistence-event-catalog/events/team/team-task-log-only: - en: 16a961e45e7ff021 - zh: c61e51809823dc17 + en: 36e28e9681653ab9 + zh: 3db7addbba6899e4 /session-persistence-event-catalog/events/todo: en: 308d4bb4b6b96e35 zh: 308d4bb4b6b96e35 @@ -1362,8 +1362,8 @@ en: 4147b170f52814af zh: e78991ac1c41ebec /session-persistence-event-catalog/resolved-persistence-types/string: - en: c178efefdf01cade - zh: 200b1ab00ed8bfd3 + en: caa52cd38cec3aee + zh: 3ff0f1fceaac2eb1 /session-persistence-event-catalog/resolved-persistence-types/string~2: en: 0089620bf4638263 zh: 1922bef1713094bb @@ -1506,8 +1506,8 @@ en: c6fbe4268a4430db zh: 8fec9d6def3c775c /session-persistence-event-catalog/resolved-persistence-types/enabled: - en: b8b8f28b5bfcd153 - zh: 602d970c3c090522 + en: 413aaf647388ee21 + zh: f36d0158e587bab3 /session-persistence-event-catalog/resolved-persistence-types/end-start: en: 4b7a8793663d3fef zh: 249459c825d330c3 @@ -1884,8 +1884,8 @@ en: 4e102391d685eff5 zh: 9762243a13014f3d /session-persistence-event-catalog/resolved-persistence-types/kind-user: - en: 984cac7296874653 - zh: 13b4ee707a8ce305 + en: eaecebdbcd5cc4e4 + zh: f519d7ade666f48a /session-persistence-event-catalog/resolved-persistence-types/kind-user-approval: en: a52d2dfbaf844127 zh: 4c5306ca40f20873 @@ -1920,17 +1920,17 @@ en: 3216b23c131d0070 zh: 73ca171c6014f16e /session-persistence-event-catalog/resolved-persistence-types/member-teamid-version: - en: 815fc4d70ba0375b - zh: ae9f6d2a163ff62b + en: d363c9c9b9989694 + zh: 67b0745c05540989 /session-persistence-event-catalog/resolved-persistence-types/message-step-turn: en: 86fe91fe83eeb08a zh: 2ebf2f1904829753 /session-persistence-event-catalog/resolved-persistence-types/message-teamid-version: - en: edb364403e9e9f84 - zh: d1298ec2e67a0eeb + en: 4a479e13089129e3 + zh: 657d71530c8c36c3 /session-persistence-event-catalog/resolved-persistence-types/messageid-targetid-teamid-version: - en: 42a1e343b5e87854 - zh: ae777d3cd40835a6 + en: bdbf80439923b458 + zh: 624ee595a4bd994a /session-persistence-event-catalog/resolved-persistence-types/mode-source: en: 3028b222670ce5ad zh: cae4c9437e560d61 @@ -1962,8 +1962,8 @@ en: 5360af614fe753e3 zh: 96427c489cc71de6 /session-persistence-event-catalog/resolved-persistence-types/task-teamid-version: - en: 4952b12d354b8aaf - zh: b8c2ef51b7ab082e + en: 0922cbe4e7059d58 + zh: 9a7840f818d0a746 /session-persistence-event-catalog/resolved-persistence-types/todos: en: 8ab873d30e062fd5 zh: a138832c6e299ebb diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 16c1cb90f8..f4c69dd93e 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -1015,7 +1015,7 @@ Source: [`packages/core/session/src/types.ts:338`](../packages/core/session/src/ Types: [TeamId](subsystems/agent-team.md) · [TeamMemberSnapshot](subsystems/agent-team.md) -Source: [`packages/team/agent-team/src/types.ts:173`](../packages/team/agent-team/src/types.ts) +Source: [`packages/team/agent-team/src/types.ts:175`](../packages/team/agent-team/src/types.ts) @@ -1033,7 +1033,7 @@ Source: [`packages/team/agent-team/src/types.ts:173`](../packages/team/agent-tea Types: [TeamId](subsystems/agent-team.md) · [TeamMessageId](subsystems/agent-team.md) -Source: [`packages/team/agent-team/src/types.ts:179`](../packages/team/agent-team/src/types.ts) +Source: [`packages/team/agent-team/src/types.ts:181`](../packages/team/agent-team/src/types.ts) @@ -1046,7 +1046,7 @@ Source: [`packages/team/agent-team/src/types.ts:179`](../packages/team/agent-tea Types: [TeamId](subsystems/agent-team.md) · [TeamMessageSnapshot](subsystems/agent-team.md) -Source: [`packages/team/agent-team/src/types.ts:177`](../packages/team/agent-team/src/types.ts) +Source: [`packages/team/agent-team/src/types.ts:179`](../packages/team/agent-team/src/types.ts) @@ -1057,7 +1057,7 @@ Source: [`packages/team/agent-team/src/types.ts:177`](../packages/team/agent-tea 'team/mode': { enabled: boolean } ``` -Source: [`packages/team/agent-team/src/types.ts:171`](../packages/team/agent-team/src/types.ts) +Source: [`packages/team/agent-team/src/types.ts:173`](../packages/team/agent-team/src/types.ts) @@ -1070,7 +1070,7 @@ Source: [`packages/team/agent-team/src/types.ts:171`](../packages/team/agent-tea Types: [TeamId](subsystems/agent-team.md) · [TeamTaskSnapshot](subsystems/agent-team.md) -Source: [`packages/team/agent-team/src/types.ts:175`](../packages/team/agent-team/src/types.ts) +Source: [`packages/team/agent-team/src/types.ts:177`](../packages/team/agent-team/src/types.ts) ### `todo/*` @@ -5639,7 +5639,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:388`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/team/agent-team/src/types.ts:20`](../packages/team/agent-team/src/types.ts) · [`packages/team/agent-team/src/types.ts:32`](../packages/team/agent-team/src/types.ts) · [`packages/team/agent-team/src/types.ts:8`](../packages/team/agent-team/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:389`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/team/agent-team/src/types.ts:20`](../packages/team/agent-team/src/types.ts) · [`packages/team/agent-team/src/types.ts:32`](../packages/team/agent-team/src/types.ts) · [`packages/team/agent-team/src/types.ts:8`](../packages/team/agent-team/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -6282,7 +6282,7 @@ Sources: [`packages/experimental/liveshare/src/host/viewer-principal.ts:29`](../ SHA-256: `c2a13242efc3bea4b36a843ddf1524ff4d33c765a71b582fa1fdbfa4fa148434` -Sources: [`packages/team/agent-team/src/types.ts:171`](../packages/team/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:173`](../packages/team/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7759,7 +7759,7 @@ Sources: [`packages/core/tools/src/index.ts:28`](../packages/core/tools/src/inde SHA-256: `a4afec59b75aad5d13ef82d6931f35cc2d98918503916cf069355b77c5e42302` -Sources: [`packages/api/session-controller/src/types.ts:397`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:398`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7912,7 +7912,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:61`](../packages/web SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1` -Sources: [`packages/team/agent-team/src/types.ts:173`](../packages/team/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:175`](../packages/team/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7940,7 +7940,7 @@ Sources: [`packages/core/session/src/types.ts:338`](../packages/core/session/src SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` -Sources: [`packages/team/agent-team/src/types.ts:177`](../packages/team/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:179`](../packages/team/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7954,7 +7954,7 @@ Sources: [`packages/team/agent-team/src/types.ts:177`](../packages/team/agent-te SHA-256: `0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1` -Sources: [`packages/team/agent-team/src/types.ts:179`](../packages/team/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:181`](../packages/team/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -8100,7 +8100,7 @@ Sources: [`packages/compaction/compaction-image-offload/src/projection.ts:25`](. SHA-256: `a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c` -Sources: [`packages/team/agent-team/src/types.ts:175`](../packages/team/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:177`](../packages/team/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 5e045309fd..6b26e80ae8 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -1017,7 +1017,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMemberSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/team/agent-team/src/types.ts:173`](../packages/team/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:175`](../packages/team/agent-team/src/types.ts) @@ -1035,7 +1035,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageId](subsystems/agent-team.zh.md) -来源:[`packages/team/agent-team/src/types.ts:179`](../packages/team/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:181`](../packages/team/agent-team/src/types.ts) @@ -1048,7 +1048,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/team/agent-team/src/types.ts:177`](../packages/team/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:179`](../packages/team/agent-team/src/types.ts) @@ -1059,7 +1059,7 @@ export type SessionEvent = { 'team/mode': { enabled: boolean } ``` -来源:[`packages/team/agent-team/src/types.ts:171`](../packages/team/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:173`](../packages/team/agent-team/src/types.ts) @@ -1072,7 +1072,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamTaskSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/team/agent-team/src/types.ts:175`](../packages/team/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:177`](../packages/team/agent-team/src/types.ts) ### `todo/*` @@ -5641,7 +5641,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:388`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/team/agent-team/src/types.ts:20`](../packages/team/agent-team/src/types.ts) · [`packages/team/agent-team/src/types.ts:32`](../packages/team/agent-team/src/types.ts) · [`packages/team/agent-team/src/types.ts:8`](../packages/team/agent-team/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:389`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/team/agent-team/src/types.ts:20`](../packages/team/agent-team/src/types.ts) · [`packages/team/agent-team/src/types.ts:32`](../packages/team/agent-team/src/types.ts) · [`packages/team/agent-team/src/types.ts:8`](../packages/team/agent-team/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -6284,7 +6284,7 @@ SHA-256: `0a92c5bdfa06c2e2615b777728f3aa95f408d43b9012e7b754568311251db623` SHA-256: `c2a13242efc3bea4b36a843ddf1524ff4d33c765a71b582fa1fdbfa4fa148434` -来源:[`packages/team/agent-team/src/types.ts:171`](../packages/team/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:173`](../packages/team/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7761,7 +7761,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` SHA-256: `a4afec59b75aad5d13ef82d6931f35cc2d98918503916cf069355b77c5e42302` -来源:[`packages/api/session-controller/src/types.ts:397`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:398`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7914,7 +7914,7 @@ SHA-256: `9c001ecb5e1d3c3894410db2a1e383842e5f8454fb52182338686158abc1b3ee` SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1` -来源:[`packages/team/agent-team/src/types.ts:173`](../packages/team/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:175`](../packages/team/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7942,7 +7942,7 @@ SHA-256: `6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5` SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` -来源:[`packages/team/agent-team/src/types.ts:177`](../packages/team/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:179`](../packages/team/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7956,7 +7956,7 @@ SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` SHA-256: `0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1` -来源:[`packages/team/agent-team/src/types.ts:179`](../packages/team/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:181`](../packages/team/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -8102,7 +8102,7 @@ SHA-256: `477614bca595a9e7adb5a1870be446b9a8684f3ba8950259d6289f2d3b74f028` SHA-256: `a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c` -来源:[`packages/team/agent-team/src/types.ts:175`](../packages/team/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:177`](../packages/team/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 3a30b6f025..a025a3f846 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -92,7 +92,7 @@ {"digest":"0af3ecfb8c60d416fc40a7cc4fb2c4851a8e46608b2b38ab9e510670c971aa1f","names":[],"sources":[]}, {"digest":"0b6d718c693fdf7f06dcccb0bcbbabe78cc63514eafc62d6b2a3899735279d6f","names":["packages/goal/goal/src/types.ts#GoalRef"],"sources":["packages/goal/goal/src/types.ts:20"]}, {"digest":"0b9b8facbe2c19773b3fb0ecc824ccbcc603d41b8f18069faa3807d449572fce","names":[],"sources":[]}, - {"digest":"0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1","names":[],"sources":["packages/team/agent-team/src/types.ts:179"]}, + {"digest":"0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1","names":[],"sources":["packages/team/agent-team/src/types.ts:181"]}, {"digest":"0bdaf9cb63594be4ef034b68901fffe792fe09d6d5f6bac42d7f56f6e7834fb9","names":[],"sources":[]}, {"digest":"0c4205180f75b9ee1e0d878bd9a9851628b5dc18448486389d9c5fdc3f1ccda3","names":[],"sources":[]}, {"digest":"0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52","names":[],"sources":[]}, @@ -253,7 +253,7 @@ {"digest":"4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731","names":["packages/llm/llm/src/message.ts#ToolResultMessage"],"sources":["packages/llm/llm/src/message.ts:173"]}, {"digest":"4b7ddaf3710b88ff9b8df66304f5f97bf03688fd19730eedc2e9535203c820ea","names":[],"sources":[]}, {"digest":"4bb451330ff699f36b45539ceb0eb2606ead72084e2c5bb637c36cc80e2252db","names":[],"sources":["packages/core/session/src/types.ts:198"]}, - {"digest":"4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b","names":["packages/api/session-controller/src/types.ts#SessionRequestId","packages/attachment/attachment/src/brand.ts#AttachmentId","packages/compaction/compaction/src/brand.ts#CompactionId","packages/core/session/src/types.ts#SessionId","packages/experimental/webhook/src/brand.ts#WebhookDeliveryId","packages/experimental/webhook/src/brand.ts#WebhookRuleId","packages/experimental/webhook/src/brand.ts#WebhookSourceId","packages/feedback/message-feedback/src/types.ts#MessageFeedbackVersion","packages/goal/goal/src/types.ts#GoalId","packages/interaction/commands/src/brand.ts#CommandId","packages/interaction/user-approval/src/types.ts#ApprovalRequestId","packages/jobs/jobs/src/brand.ts#JobId","packages/llm/llm-retry/src/brand.ts#RetryId","packages/llm/llm/src/brand.ts#MessageId","packages/llm/llm/src/brand.ts#ProviderRequestId","packages/llm/llm/src/brand.ts#ReasoningEffortId","packages/llm/llm/src/brand.ts#ToolCallId","packages/schedule/schedule/src/types.ts#ScheduleId","packages/session/session-title/src/index.ts#SessionTitleProviderId","packages/session/session-title/src/types.ts#SessionTitleProviderId","packages/team/agent-team/src/types.ts#TeamId","packages/team/agent-team/src/types.ts#TeamMessageId","packages/team/agent-team/src/types.ts#TeamTaskId","packages/workflow/workflow/src/types.ts#WorkflowRunId"],"sources":["packages/api/session-controller/src/types.ts:388","packages/attachment/attachment/src/brand.ts:6","packages/compaction/compaction/src/brand.ts:4","packages/core/session/src/types.ts:21","packages/experimental/webhook/src/brand.ts:12","packages/experimental/webhook/src/brand.ts:6","packages/experimental/webhook/src/brand.ts:9","packages/feedback/message-feedback/src/types.ts:14","packages/goal/goal/src/types.ts:17","packages/interaction/commands/src/brand.ts:31","packages/interaction/user-approval/src/types.ts:17","packages/jobs/jobs/src/brand.ts:19","packages/llm/llm-retry/src/brand.ts:4","packages/llm/llm/src/brand.ts:16","packages/llm/llm/src/brand.ts:31","packages/llm/llm/src/brand.ts:43","packages/llm/llm/src/brand.ts:67","packages/schedule/schedule/src/types.ts:14","packages/session/session-title/src/index.ts:42","packages/session/session-title/src/types.ts:17","packages/team/agent-team/src/types.ts:20","packages/team/agent-team/src/types.ts:32","packages/team/agent-team/src/types.ts:8","packages/workflow/workflow/src/types.ts:13"]}, + {"digest":"4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b","names":["packages/api/session-controller/src/types.ts#SessionRequestId","packages/attachment/attachment/src/brand.ts#AttachmentId","packages/compaction/compaction/src/brand.ts#CompactionId","packages/core/session/src/types.ts#SessionId","packages/experimental/webhook/src/brand.ts#WebhookDeliveryId","packages/experimental/webhook/src/brand.ts#WebhookRuleId","packages/experimental/webhook/src/brand.ts#WebhookSourceId","packages/feedback/message-feedback/src/types.ts#MessageFeedbackVersion","packages/goal/goal/src/types.ts#GoalId","packages/interaction/commands/src/brand.ts#CommandId","packages/interaction/user-approval/src/types.ts#ApprovalRequestId","packages/jobs/jobs/src/brand.ts#JobId","packages/llm/llm-retry/src/brand.ts#RetryId","packages/llm/llm/src/brand.ts#MessageId","packages/llm/llm/src/brand.ts#ProviderRequestId","packages/llm/llm/src/brand.ts#ReasoningEffortId","packages/llm/llm/src/brand.ts#ToolCallId","packages/schedule/schedule/src/types.ts#ScheduleId","packages/session/session-title/src/index.ts#SessionTitleProviderId","packages/session/session-title/src/types.ts#SessionTitleProviderId","packages/team/agent-team/src/types.ts#TeamId","packages/team/agent-team/src/types.ts#TeamMessageId","packages/team/agent-team/src/types.ts#TeamTaskId","packages/workflow/workflow/src/types.ts#WorkflowRunId"],"sources":["packages/api/session-controller/src/types.ts:389","packages/attachment/attachment/src/brand.ts:6","packages/compaction/compaction/src/brand.ts:4","packages/core/session/src/types.ts:21","packages/experimental/webhook/src/brand.ts:12","packages/experimental/webhook/src/brand.ts:6","packages/experimental/webhook/src/brand.ts:9","packages/feedback/message-feedback/src/types.ts:14","packages/goal/goal/src/types.ts:17","packages/interaction/commands/src/brand.ts:31","packages/interaction/user-approval/src/types.ts:17","packages/jobs/jobs/src/brand.ts:19","packages/llm/llm-retry/src/brand.ts:4","packages/llm/llm/src/brand.ts:16","packages/llm/llm/src/brand.ts:31","packages/llm/llm/src/brand.ts:43","packages/llm/llm/src/brand.ts:67","packages/schedule/schedule/src/types.ts:14","packages/session/session-title/src/index.ts:42","packages/session/session-title/src/types.ts:17","packages/team/agent-team/src/types.ts:20","packages/team/agent-team/src/types.ts:32","packages/team/agent-team/src/types.ts:8","packages/workflow/workflow/src/types.ts:13"]}, {"digest":"4d60dddaa65d3da8d4cd014d85ce3d2c2875664dd0433e331d8e9d395289686b","names":["packages/experimental/liveshare/src/host/viewer-principal.ts#ShareViewerPrincipal"],"sources":["packages/experimental/liveshare/src/host/viewer-principal.ts:24"]}, {"digest":"4e011753f8263f436efaabfbb3b4db53a98fc55342c5ded2daf7208b217be159","names":[],"sources":[]}, {"digest":"4e44c9118152f6829634bfd508e47669ab0497caffa380c94b4be91f9390d35d","names":[],"sources":[]}, @@ -473,8 +473,8 @@ {"digest":"a3a259a1aa5401af786ce08c4a8419681d3000d73467991b5fc563389bc4ebd2","names":[],"sources":[]}, {"digest":"a3c6f1ea38e9a61d7a9706057ba65aabe02dfe6d9dd90d4736aa62325d874531","names":[],"sources":[]}, {"digest":"a3edd1efd1fef21b72c55e213c8a8b24196b091abde2b89db52be0b2aaae3cfa","names":[],"sources":[]}, - {"digest":"a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c","names":[],"sources":["packages/team/agent-team/src/types.ts:175"]}, - {"digest":"a4afec59b75aad5d13ef82d6931f35cc2d98918503916cf069355b77c5e42302","names":[],"sources":["packages/api/session-controller/src/types.ts:397"]}, + {"digest":"a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c","names":[],"sources":["packages/team/agent-team/src/types.ts:177"]}, + {"digest":"a4afec59b75aad5d13ef82d6931f35cc2d98918503916cf069355b77c5e42302","names":[],"sources":["packages/api/session-controller/src/types.ts:398"]}, {"digest":"a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc","names":[],"sources":[]}, {"digest":"a6b41f5bede8094d7bf635be5d69b1bd7a25e006ca9cf9415bceb9322f1a7feb","names":["packages/experimental/hook-protocol/src/types.ts#HookDialect"],"sources":["packages/experimental/hook-protocol/src/types.ts:48"]}, {"digest":"a6da9ad5d42ae83a6833a8288b076688e8e0801633ea36cbdd8a30a6d9f3160c","names":["packages/workflow/tool-workflow/src/types.ts#ToolWorkflowRunStartData"],"sources":["packages/workflow/tool-workflow/src/types.ts:14"]}, @@ -513,7 +513,7 @@ {"digest":"b120362fd216a16a40fe787f6d3286086aa131266322bab79c026a130ccd0f72","names":["packages/llm/llm/src/message.ts#MessageSource"],"sources":["packages/llm/llm/src/message.ts:136"]}, {"digest":"b158f3ff6923714807c0d73882f0b05dc87f8fa85c9e90814852d48bbc866081","names":["packages/core/session/src/types.ts#RequestContext"],"sources":["packages/core/session/src/types.ts:260"]}, {"digest":"b1664fa89f8eab49a14e7e3168d8bc4da4aa9fd76332f6c4a053d839e5276ae7","names":[],"sources":[]}, - {"digest":"b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1","names":[],"sources":["packages/team/agent-team/src/types.ts:173"]}, + {"digest":"b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1","names":[],"sources":["packages/team/agent-team/src/types.ts:175"]}, {"digest":"b1f62380f6746fedd629e71065e3583cef83334875b499ac9665c2eb87acfbf5","names":[],"sources":[]}, {"digest":"b222069eea2d768065161c1147b1f2c78c1b54328c84b3586ae5c8f91b8ed35e","names":[],"sources":[]}, {"digest":"b2245a906e13aa94f2d3cc6cf7b04cfa0267f7b7f4d468de1b00b1a314e54626","names":[],"sources":[]}, @@ -561,7 +561,7 @@ {"digest":"c1f0f17b6493b85db6453bb30e01b208d703731b25bc1e68e5d4fc72eea9cdea","names":["packages/llm/llm/src/types.ts#ToolCallBlock"],"sources":["packages/llm/llm/src/types.ts:112"]}, {"digest":"c1f9e0405de6d18cabb9ee70782a027f9bbdc57e5abec9dcccdd56119e2e9058","names":[],"sources":[]}, {"digest":"c219efbdfd53b836786202ab4532913d555798f0a8615a4e9629736e3799fb70","names":[],"sources":[]}, - {"digest":"c2a13242efc3bea4b36a843ddf1524ff4d33c765a71b582fa1fdbfa4fa148434","names":[],"sources":["packages/team/agent-team/src/types.ts:171"]}, + {"digest":"c2a13242efc3bea4b36a843ddf1524ff4d33c765a71b582fa1fdbfa4fa148434","names":[],"sources":["packages/team/agent-team/src/types.ts:173"]}, {"digest":"c2deb7c9183735d4e0fffddf5c623f4e34daa1c8e96b111982e957dfcbbcf896","names":["packages/workflow/workflow/src/types.ts#WorkflowAgentOutcome"],"sources":["packages/workflow/workflow/src/types.ts:110"]}, {"digest":"c2f49004d9ae38a964e80850c87bc6849d6a872091a1d43e99eedf00c641e2a3","names":[],"sources":[]}, {"digest":"c43b119ce579b3e9983a0d3adc4ba402ebb9ba773d89738a2dab01ecf5813943","names":[],"sources":[]}, @@ -647,7 +647,7 @@ {"digest":"e7f4cbb3a57f4cbdb61ea41e95bcba626ad1fff6bb9b1e063b1d50a32da6e864","names":[],"sources":[]}, {"digest":"e8de0f457fed5b9ff94dc4950b59ef75fd8ba8af665cc7899485924a62653367","names":[],"sources":[]}, {"digest":"e95f957154b2b1b58692af53d4131180b6dd19e2264869f876dbaa959991494e","names":[],"sources":[]}, - {"digest":"ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403","names":[],"sources":["packages/team/agent-team/src/types.ts:177"]}, + {"digest":"ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403","names":[],"sources":["packages/team/agent-team/src/types.ts:179"]}, {"digest":"eaa71be0fbf0f702df8ea6c11d0aa2f9782263d45a10d0fb14eda662f4bfe04c","names":[],"sources":["packages/core/session/src/types.ts:201"]}, {"digest":"ebb768d85ec87fb68e42fc14fd7d8535b5ac11791b14d04c6770180f0a719bf8","names":[],"sources":[]}, {"digest":"ec38b5949af8eacaf00df002f4acbe344f934f8a061e9cdc65a52a48e5f6dd93","names":[],"sources":[]}, diff --git a/docs/rescope.i18n.yaml b/docs/rescope.i18n.yaml index f418069ce3..3eb63034c4 100644 --- a/docs/rescope.i18n.yaml +++ b/docs/rescope.i18n.yaml @@ -6,8 +6,8 @@ en: c8aa4d3384ea1306 zh: 5aa030587d5b634d /vendored-package-rescope/name-mapping: - en: 239fa7993825ac62 - zh: df35fa9f5ef42a05 + en: 50a68b9b3724eff8 + zh: 9facad930deeb014 /vendored-package-rescope/what-the-rename-does-not-touch: en: 9bb7d09f8cccf695 zh: 2a8f3f60ae5116f3 diff --git a/docs/rescope.md b/docs/rescope.md index 85917bbfce..c3ec1d0682 100644 --- a/docs/rescope.md +++ b/docs/rescope.md @@ -11,8 +11,8 @@ The Cordis framework and its foundation libraries are vendored under [`vendor/`] | `vendor/cordis/` | `cordis` | `@deepseek-ai/cordis` | 4.0.0-rc.11 | Framework core: `Context`, `Service`, `Fiber`, events | | `vendor/cosmokit/` | `cosmokit` | `@deepseek-ai/cosmokit` | 1.8.1 | Shared utilities the framework and Schemastery build on | | `vendor/schemastery/` | `schemastery` | `@deepseek-ai/schemastery` | 3.18.0 | Config schemas (`Schema`) behind every plugin's `Config` | -| `vendor/loader/` | `@cordisjs/plugin-loader` | `@deepseek-ai/cordis-plugin-loader` | 1.0.0-rc.5 | `cordis.yml` loading, plugin resolution, repository cache | -| `vendor/include/` | `@cordisjs/plugin-include` | `@deepseek-ai/cordis-plugin-include` | 1.0.4 | Config includes and patch overlays | +| `vendor/loader/` | `@cordisjs/plugin-loader` | `@deepseek-ai/cordis-plugin-loader` | 1.0.0-rc.8 | `cordis.yml` loading, plugin resolution, repository cache | +| `vendor/include/` | `@cordisjs/plugin-include` | `@deepseek-ai/cordis-plugin-include` | 1.1.1 | Config includes and patch overlays | | `vendor/group/` | `@cordisjs/plugin-group` | `@deepseek-ai/cordis-plugin-group` | 1.0.0 | Nested plugin groups | | `vendor/timer/` | `@cordisjs/plugin-timer` | `@deepseek-ai/cordis-plugin-timer` | 1.1.4 | Disposal-aware timers on `ctx` | | `vendor/hmr/` | `@cordisjs/plugin-hmr` | `@deepseek-ai/cordis-plugin-hmr` | 1.0.15 | Hot module replacement for plugins and config | diff --git a/docs/rescope.zh.md b/docs/rescope.zh.md index 8d13ed539b..a02d152204 100644 --- a/docs/rescope.zh.md +++ b/docs/rescope.zh.md @@ -11,8 +11,8 @@ Cordis 框架及其基础库以源码形式 vendored 在 [`vendor/`](../vendor/R | `vendor/cordis/` | `cordis` | `@deepseek-ai/cordis` | 4.0.0-rc.11 | 框架核心:`Context`、`Service`、`Fiber`、事件 | | `vendor/cosmokit/` | `cosmokit` | `@deepseek-ai/cosmokit` | 1.8.1 | 框架与 Schemastery 共用的基础工具 | | `vendor/schemastery/` | `schemastery` | `@deepseek-ai/schemastery` | 3.18.0 | 配置 schema(`Schema`),每个插件的 `Config` 都基于它 | -| `vendor/loader/` | `@cordisjs/plugin-loader` | `@deepseek-ai/cordis-plugin-loader` | 1.0.0-rc.5 | `cordis.yml` 装载、插件解析、repository 缓存 | -| `vendor/include/` | `@cordisjs/plugin-include` | `@deepseek-ai/cordis-plugin-include` | 1.0.4 | 配置包含与 patch 叠加 | +| `vendor/loader/` | `@cordisjs/plugin-loader` | `@deepseek-ai/cordis-plugin-loader` | 1.0.0-rc.8 | `cordis.yml` 装载、插件解析、repository 缓存 | +| `vendor/include/` | `@cordisjs/plugin-include` | `@deepseek-ai/cordis-plugin-include` | 1.1.1 | 配置包含与 patch 叠加 | | `vendor/group/` | `@cordisjs/plugin-group` | `@deepseek-ai/cordis-plugin-group` | 1.0.0 | 嵌套插件分组 | | `vendor/timer/` | `@cordisjs/plugin-timer` | `@deepseek-ai/cordis-plugin-timer` | 1.1.4 | `ctx` 上随 disposal 回收的定时器 | | `vendor/hmr/` | `@cordisjs/plugin-hmr` | `@deepseek-ai/cordis-plugin-hmr` | 1.0.15 | 插件与配置的热替换 | diff --git a/docs/subsystems/conversation.i18n.yaml b/docs/subsystems/conversation.i18n.yaml index 64ca728d7e..54f5e781d8 100644 --- a/docs/subsystems/conversation.i18n.yaml +++ b/docs/subsystems/conversation.i18n.yaml @@ -22,10 +22,10 @@ zh: f3db8c0d01080dba /conversation-assembly/predecessor-reads: en: da849b3b5da81da8 - zh: 37a647a439bc24b1 + zh: 467a05d89664602f /conversation-assembly/window-update-paths: - en: 1e22169d3d7b8ff1 - zh: 669d03d2148e9a46 + en: 5c6c7af4a1bf39f2 + zh: 498f142570b25d30 /conversation-assembly/verification-obligations: - en: ee832671cb7d94ef - zh: 380303a2de942710 + en: 5568539107a25029 + zh: 74584d37a45ba0a8 diff --git a/docs/subsystems/conversation.md b/docs/subsystems/conversation.md index ef6f6ffbdb..abc9ec13d4 100644 --- a/docs/subsystems/conversation.md +++ b/docs/subsystems/conversation.md @@ -280,6 +280,7 @@ History may be requested from the tail backward one page at a time. The Session | Replace on open, resync, or gap repair | Rebuild the loaded window, match every standard event or packed run once per Definition, then replay each started Context | `start`, followed by its updates in ascending logical `seq`; pending update-only Contexts remain without State | | Prepend one older page | Match only fresh older inputs, merge them into Contexts by `(kind, id)`, preserve existing keyed nodes, and replay only affected Contexts and dependencies | A newly found scalar start activates its collected scalar and packed updates; a changed Location or predecessor may rerun the Context | | Append one live event | Call each Definition's `match` once, look up the matched Context by key, and update only that Context | One scalar `update` and one requested publication for a matching post-start event; no existing Context scan | +| Incremental flush failure | Replay the complete loaded window into new Contexts, replace every active view, and throw `ConversationUpdateError` with `rebuilt: true`; if the rebuild or replacement also fails, throw `rebuilt: false` and retry it on the next flush | Every Context replays as on replace. A Definition that fails on every incremental update makes each publication replay the whole window. Immediate publications report the error through the event-feed subscriber log; animation-frame publications report it from the frame callback | With `D` registered Definitions, one incoming scalar event or packed run performs `D` current-input matches and constant-time Context-key lookup after a match. Definition code must preserve that property: do not traverse the complete event window, every Context, `context.matches`, or the rendered Node collection on the normal append path. Use State for accumulated facts, Location data for same-Turn/Step sharing, and `reader.previous()` for indexed predecessor dependencies. @@ -297,5 +298,6 @@ Add focused tests that establish these outcomes: 6. The keyed renderer consumes `node.data` and constrained Location hooks only; it does not scan the Session event window, Contexts, or Chat Nodes. 7. Scalar and packed Assistant history produce the same final State, timing boundaries, and target snapshot, while one packed run remains one Match through replace, prepend, Location replay, and registry rebuild. 8. Creating a target source performs no builder work; explicit selection or the first subscription performs one complete replacement, later updates reach every active target, and repeated activation performs no replacement. +9. A Definition that throws during an incremental flush leaves every active target showing a complete rebuild of the loaded window, and later events keep updating it. Use [`packages/client/ui-chat/src/client/conversation-nodes/assistant.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/assistant.ts) for streaming and interruption, [`inbox.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/inbox.ts) plus [`message.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/message.ts) for predecessor queries, and [`packages/client/ui-deliverables`](../../packages/client/ui-deliverables) for a Definition that publishes Turn data without creating its own Node. diff --git a/docs/subsystems/conversation.zh.md b/docs/subsystems/conversation.zh.md index a3ef583db2..3c2d2f313e 100644 --- a/docs/subsystems/conversation.zh.md +++ b/docs/subsystems/conversation.zh.md @@ -273,6 +273,8 @@ Chat Node 还通过 `groupTree` 选择所在行的位置。位置由外到内排 Assembler 会记录这项依赖。如果后续 older prepend 带来了更近的前序 Context、补齐了原先未知的窗口缺口,或者前序 State 被修订,引擎会从 `start` 重新运行依赖方 Context,并按 `seq` 升序回放其 update。被查询的 Definition 仍负责把有用信息写入自身 State;Reader 不提供业务专用查询方法,也不授予修改其他 Context 的权限。 + + ## Window 更新路径 历史可能从尾部开始一页一页向前请求。Session journal 先校验互不重叠的逻辑 seq range,Assembler 再按每个已接受 input 的首 `seq` 排序并进入 State 回放。 @@ -282,6 +284,7 @@ Assembler 会记录这项依赖。如果后续 older prepend 带来了更近的 | open、resync 或 gap repair 时 replace | 重建已加载窗口,每条标准 event 或 packed run 对每个 Definition 匹配一次,再回放每个已有 start 的 Context | 先执行 `start`,再按逻辑 `seq` 升序执行其 update;只有 update 的 pending Context 仍没有 State | | prepend 一页更早历史 | 只匹配新增的更早 input,按 `(kind, id)` 合并进 Context,保留现有 keyed node,并只重放受影响的 Context 与依赖 | 新发现的 scalar start 会激活已收集的 scalar 与 packed update;Location 或前序依赖变化也可能重跑 Context | | append 一条实时事件 | 每个 Definition 各调用一次 `match`,按 key 查找命中的 Context,只更新该 Context | 对 start 之后的匹配事件执行一次 scalar `update` 并请求一次发布;不扫描已有 Context | +| 增量 flush 失败 | 把已加载的完整窗口回放到新的 Context,替换每个 active view,然后抛出 `rebuilt: true` 的 `ConversationUpdateError`;重建或替换也失败时抛出 `rebuilt: false`,并在下一次 flush 重试 | 每个 Context 都像 replace 一样回放。若某个 Definition 在每次增量更新中都失败,每次发布都会回放整个窗口。立即发布通过事件源订阅者日志报告错误;animation-frame 发布在帧回调中报告错误 | 注册 `D` 个 Definition 时,一条新 scalar event 或 packed run 会进行 `D` 次仅当前 input 匹配;命中后的 Context key 查询是常数时间。Definition 代码必须维持这个性质:正常 append 热路径不得遍历完整事件窗口、所有 Context、`context.matches` 或已渲染 Node 集合。累计事实放进 State,同 Turn/Step 共享信息放进 Location data,有索引的前序依赖使用 `reader.previous()`。 @@ -299,5 +302,6 @@ Assembler 会记录这项依赖。如果后续 older prepend 带来了更近的 6. keyed renderer 只消费 `node.data` 与受限 Location hook,不扫描 Session 事件窗口、Context 或 Chat Node。 7. scalar 与 packed Assistant 历史产生相同的最终 State、timing boundary 和 target snapshot;一个 packed run 在 replace、prepend、Location replay 与 registry rebuild 中始终只保留一个 Match。 8. 创建 target source 不执行 builder 工作;显式选择或首次订阅执行一次完整 replace,后续更新送达所有 active target,重复激活不会再次 replace。 +9. Definition 在增量 flush 中抛错后,每个 active target 都显示已加载窗口的完整重建结果,后续事件继续更新它。 流式与中断处理可参考 [`packages/client/ui-chat/src/client/conversation-nodes/assistant.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/assistant.ts),前序查询可参考 [`inbox.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/inbox.ts) 与 [`message.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/message.ts),只发布 Turn data 而不创建自有 Node 的例子见 [`packages/client/ui-deliverables`](../../packages/client/ui-deliverables)。 diff --git a/docs/subsystems/credentials.i18n.yaml b/docs/subsystems/credentials.i18n.yaml index 06c51858d4..bad9827407 100644 --- a/docs/subsystems/credentials.i18n.yaml +++ b/docs/subsystems/credentials.i18n.yaml @@ -9,8 +9,8 @@ en: 0083032b9d19467d zh: 15d7a7520bac094e /user-credentials/resolution: - en: 0583aeab49d80a4b - zh: 1ffd60d7e79f67cf + en: baaab4bca76bea6b + zh: 54ccdc03ffd44f63 /user-credentials/description: en: f7b09588c0f34952 zh: 1b93d673c5bf6a80 diff --git a/docs/subsystems/credentials.md b/docs/subsystems/credentials.md index b4ffca123b..2a084fb29f 100644 --- a/docs/subsystems/credentials.md +++ b/docs/subsystems/credentials.md @@ -20,8 +20,6 @@ type CredentialRef = Branded<'CredentialRef'> `resolve(ref)` returns the value with the provider-defined source layer that supplied it, or `undefined` while unconfigured. Consumers re-resolve at each operation and never cache across operations — that per-operation read is the hot-update mechanism. -`readStored(ref)` returns only the provider-managed value for editing, or `undefined` when none is stored. It never reads environment fallbacks. The Remote editor operation restricts this read to the owner. - ```ts type-equiv /** One resolved credential value and the source layer that supplied it. */ interface ResolvedCredential { @@ -154,13 +152,6 @@ A CredentialKey answers "what credential does this plugin hold for this id". Not */ abstract resolve(ref: CredentialRef): Promise -/** - * Read the provider-managed value without consulting environment fallbacks. - * @param ref - the reference to read. - * @returns the stored secret, or undefined when no non-empty value is stored. - */ -abstract readStored(ref: CredentialRef): Promise - /** * Describe one reference for configuration surfaces without exposing the * value. @@ -237,7 +228,7 @@ Source: [`packages/credentials/credentials/src/index.ts`](../../packages/credent ### `ctx.credentialsController` — `CredentialsController` -Host service backing the generated `ctx.remote.credentials` namespace. It carries every wire obligation the credential seam itself does not: the batch fan-out bound, the field-by-field view projection, the reference-grammar guard, and the refusal mapping. Metadata reads exclude secrets; readStored returns only a provider-managed value for an explicitly opened editor. +Host service backing the generated `ctx.remote.credentials` namespace. It carries every wire obligation the credential seam itself does not: the batch fan-out bound, the field-by-field view projection, the reference-grammar guard, and the refusal mapping. Secret values cross in one direction only — no method here returns one. ```ts cordis-catalog /** @@ -252,15 +243,8 @@ Host service backing the generated `ctx.remote.credentials` namespace. It carrie @Remote async describe(refs: string[]): Promise> /** - * Read one stored value for a credential editor, excluding environment fallbacks. - * @param ref - reference name to read. - * @returns the stored secret, or undefined when the provider stores none. - * @throws RemoteError for a non-owner Remote caller, an invalid reference, or an absent provider. - */ -@Remote async readStored(ref: string): Promise - -/** - * Store one value from a configuration surface. + * Store one value from a configuration surface. The value crosses the wire in + * this direction only: no read path returns it. * @param ref - reference name to store under. * @param value - the non-empty secret value. * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. diff --git a/docs/subsystems/credentials.zh.md b/docs/subsystems/credentials.zh.md index d4784832ca..1e9221699a 100644 --- a/docs/subsystems/credentials.zh.md +++ b/docs/subsystems/credentials.zh.md @@ -20,8 +20,6 @@ type CredentialRef = Branded<'CredentialRef'> `resolve(ref)` 返回值及提供该值的来源层(由提供方定义);未配置期间返回 `undefined`。消费方在每个操作中重新解析,绝不跨操作缓存——这种按操作进行的读取正是热更新机制。 -`readStored(ref)` 仅返回用于编辑的提供方托管值;未存储时返回 `undefined`。它不读取环境后备值。编辑器的 Remote 操作仅允许所有者执行此读取。 - ```ts type-equiv /** One resolved credential value and the source layer that supplied it. */ interface ResolvedCredential { @@ -154,13 +152,6 @@ A CredentialKey answers "what credential does this plugin hold for this id". Not */ abstract resolve(ref: CredentialRef): Promise -/** - * Read the provider-managed value without consulting environment fallbacks. - * @param ref - the reference to read. - * @returns the stored secret, or undefined when no non-empty value is stored. - */ -abstract readStored(ref: CredentialRef): Promise - /** * Describe one reference for configuration surfaces without exposing the * value. @@ -237,7 +228,7 @@ Source: [`packages/credentials/credentials/src/index.ts`](../../packages/credent ### `ctx.credentialsController` — `CredentialsController` -Host service backing the generated `ctx.remote.credentials` namespace. It carries every wire obligation the credential seam itself does not: the batch fan-out bound, the field-by-field view projection, the reference-grammar guard, and the refusal mapping. Metadata reads exclude secrets; readStored returns only a provider-managed value for an explicitly opened editor. +Host service backing the generated `ctx.remote.credentials` namespace. It carries every wire obligation the credential seam itself does not: the batch fan-out bound, the field-by-field view projection, the reference-grammar guard, and the refusal mapping. Secret values cross in one direction only — no method here returns one. ```ts cordis-catalog /** @@ -252,15 +243,8 @@ Host service backing the generated `ctx.remote.credentials` namespace. It carrie @Remote async describe(refs: string[]): Promise> /** - * Read one stored value for a credential editor, excluding environment fallbacks. - * @param ref - reference name to read. - * @returns the stored secret, or undefined when the provider stores none. - * @throws RemoteError for a non-owner Remote caller, an invalid reference, or an absent provider. - */ -@Remote async readStored(ref: string): Promise - -/** - * Store one value from a configuration surface. + * Store one value from a configuration surface. The value crosses the wire in + * this direction only: no read path returns it. * @param ref - reference name to store under. * @param value - the non-empty secret value. * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. diff --git a/docs/subsystems/forge.i18n.yaml b/docs/subsystems/forge.i18n.yaml index 92bc2298cd..379e181766 100644 --- a/docs/subsystems/forge.i18n.yaml +++ b/docs/subsystems/forge.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/forge.md /forges: - en: 1b51f9d385dafe76 - zh: 4bb73627aa69d652 + en: 3ee521b77b4601e2 + zh: 4170c33e49248f8f /forges/providers-and-change-requests: en: 4ec7d1892b4067db zh: 6bb70d21ae3d7a89 diff --git a/docs/subsystems/forge.md b/docs/subsystems/forge.md index 2a3cdf2087..f56d88b848 100644 --- a/docs/subsystems/forge.md +++ b/docs/subsystems/forge.md @@ -2,7 +2,7 @@ English | [中文](forge.zh.md) -The experimental forge capability seam reads change requests, meaning pull requests and merge requests, from code hosts. [`dsh-experimental-forge`](../../packages/experimental/forge/README.md) is the Service Definition and provides `ctx.forge`; [`dsh-experimental-forge-github`](../../packages/experimental/forge-github/README.md) is the Service Provider for GitHub hosts; [`dsh-experimental-forge-session`](../../packages/experimental/forge-session/README.md) is the Consumer that binds change requests to Sessions and provides `ctx.forgeSession`; [`dsh-experimental-client-ui-forge`](../../packages/experimental/client-ui-forge/README.md) is the GUI Consumer that provides `ctx.forgePanel`. The default-disabled [forge bundle](../../packages/experimental/forge-bundle/README.md) composes all four, with an isolated [worktree service](../../packages/experimental/worktree/README.md) for the panel, from the Plugins page; otherwise they load only through explicit composition. +The experimental forge capability seam reads change requests, meaning pull requests and merge requests, from code hosts. [`dsh-experimental-forge`](../../packages/experimental/forge/README.md) is the Service Definition and provides `ctx.forge`; [`dsh-experimental-forge-github`](../../packages/experimental/forge-github/README.md) is the Service Provider for GitHub hosts; [`dsh-experimental-forge-session`](../../packages/experimental/forge-session/README.md) is the Consumer that binds change requests to Sessions and provides `ctx.forgeSession`; [`dsh-experimental-client-ui-forge`](../../packages/experimental/client-ui-forge/README.md) is the GUI Consumer that provides `ctx.forgePanel`. The default-disabled [forge bundle](../../packages/experimental/forge-bundle/README.md) composes all four, with the [worktree model tool](../../packages/experimental/tool-worktree/README.md) and an isolated [worktree service](../../packages/experimental/worktree/README.md) for the panel, from the Plugins page; otherwise they load only through explicit composition. ## Providers and change requests diff --git a/docs/subsystems/forge.zh.md b/docs/subsystems/forge.zh.md index 5c78d7bb02..bbcf8ace8f 100644 --- a/docs/subsystems/forge.zh.md +++ b/docs/subsystems/forge.zh.md @@ -2,7 +2,7 @@ [English](forge.md) | 中文 -实验性的 forge 能力 seam 从代码托管平台读取变更请求(change request,即 pull request 与 merge request)。[`dsh-experimental-forge`](../../packages/experimental/forge/README.zh.md) 是 Service Definition,提供 `ctx.forge`;[`dsh-experimental-forge-github`](../../packages/experimental/forge-github/README.zh.md) 是服务 GitHub 主机的 Service Provider;[`dsh-experimental-forge-session`](../../packages/experimental/forge-session/README.zh.md) 是把变更请求绑定到 Session 的 Consumer,提供 `ctx.forgeSession`;[`dsh-experimental-client-ui-forge`](../../packages/experimental/client-ui-forge/README.zh.md) 是提供 `ctx.forgePanel` 的 GUI Consumer。默认禁用的 [forge Bundle](../../packages/experimental/forge-bundle/README.zh.md) 可从插件页面组合这四个包,并为面板附带一个隔离的 [worktree 服务](../../packages/experimental/worktree/README.zh.md);除此之外,它们只能通过显式组合加载。 +实验性的 forge 能力 seam 从代码托管平台读取变更请求(change request,即 pull request 与 merge request)。[`dsh-experimental-forge`](../../packages/experimental/forge/README.zh.md) 是 Service Definition,提供 `ctx.forge`;[`dsh-experimental-forge-github`](../../packages/experimental/forge-github/README.zh.md) 是服务 GitHub 主机的 Service Provider;[`dsh-experimental-forge-session`](../../packages/experimental/forge-session/README.zh.md) 是把变更请求绑定到 Session 的 Consumer,提供 `ctx.forgeSession`;[`dsh-experimental-client-ui-forge`](../../packages/experimental/client-ui-forge/README.zh.md) 是提供 `ctx.forgePanel` 的 GUI Consumer。默认禁用的 [forge Bundle](../../packages/experimental/forge-bundle/README.zh.md) 可从插件页面组合这四个包,同时加载[工作树模型工具](../../packages/experimental/tool-worktree/README.zh.md),并为面板附带一个隔离的 [worktree 服务](../../packages/experimental/worktree/README.zh.md);除此之外,它们只能通过显式组合加载。 ## 提供方与变更请求 diff --git a/docs/subsystems/liveshare.i18n.yaml b/docs/subsystems/liveshare.i18n.yaml index e1ba0426b4..e6afd9ab2f 100644 --- a/docs/subsystems/liveshare.i18n.yaml +++ b/docs/subsystems/liveshare.i18n.yaml @@ -9,8 +9,8 @@ en: f6144f8e81359a9c zh: 1924869ac7475b59 /liveshare/share-modes: - en: 985cb7a5c20c1cbd - zh: a5f5372dddfefaa3 + en: 08ce9269b4556bdd + zh: 7b0e6e74e2f60865 /liveshare/forks-and-their-ceiling: en: 357a2147f48f5bac zh: a8a297603f984ede diff --git a/docs/subsystems/liveshare.md b/docs/subsystems/liveshare.md index 78c59825ee..abf140dd59 100644 --- a/docs/subsystems/liveshare.md +++ b/docs/subsystems/liveshare.md @@ -16,7 +16,7 @@ The plugin manager lists the card under **Experimental plugins** only while at l ## Share modes -`ViewerSessionMode` has two tiers, and both name a sandbox: `view` reads the shared Session and drives its loop inside a read-only sandbox in the granted workspace, so it never writes a file; it may use the read and search surface (`read`, `read_image`, `glob`, `grep`, `present`) and the tools with no local file effect (`todo_write`, `skill`, `web_search`, `web_fetch`), with every declared path checked against the grant. `write` runs in a bounded writable sandbox that also modifies files inside that workspace by adding bounded `edit` and `write`, under the Host's write ceiling. Forking is not a tier: the share carries its own `allowsFork` switch, independent of the tier. Attachment upload is not a tier either: every Session a share serves the reader accepts files, whatever the tier and the fork switch. The withdrawn `fork-readonly` name is not read back as `view`: a persisted owner record that names it is rejected when the owner state loads. Nothing infers `write`, and a share whose mode the host cannot establish stays readable but is never driven. +`ViewerSessionMode` has two tiers, and both name a sandbox: `view` reads the shared Session and drives its loop inside a read-only sandbox in the granted workspace, so it never writes a file; it may use the read and search surface (`read`, `read_image`, `glob`, `grep`, `present`) and the tools with no local file effect (`todo_write`, `skill`, `web_search`, `web_fetch`), with every declared path checked against the grant. Both tiers also admit the PTC transport `run_code`: the program runs under the owner's standing file policy rather than the share tier, so it can read and write outside the grant, while its nested tool calls still pass the same guard and path checks. `write` runs in a bounded writable sandbox that also modifies files inside that workspace by adding bounded `edit` and `write`, under the Host's write ceiling. Forking is not a tier: the share carries its own `allowsFork` switch, independent of the tier. Attachment upload is not a tier either: every Session a share serves the reader accepts files, whatever the tier and the fork switch. The withdrawn `fork-readonly` name is not read back as `view`: a persisted owner record that names it is rejected when the owner state loads. Nothing infers `write`, and a share whose mode the host cannot establish stays readable but is never driven. ## Forks and their ceiling diff --git a/docs/subsystems/liveshare.zh.md b/docs/subsystems/liveshare.zh.md index e794ce64cf..cd30d9ee46 100644 --- a/docs/subsystems/liveshare.zh.md +++ b/docs/subsystems/liveshare.zh.md @@ -16,7 +16,7 @@ ## 分享模式 -`ViewerSessionMode` 只有两档,两档都对应一个沙箱:`view` 读取共享会话,并在授予工作区的只读沙箱里驱动它的 loop,绝不写文件;它可以使用读取与检索面(`read`、`read_image`、`glob`、`grep`、`present`)和无本地文件副作用的工具(`todo_write`、`skill`、`web_search`、`web_fetch`),每个声明路径都按授予范围检查。`write` 在受宿主写入上限约束的可写沙箱里运行,通过有界 `edit` 与 `write` 修改该工作区内的文件。fork 不是一档:分享自带独立的 `allowsFork` 开关,与档位无关。附件上传也不是一档:分享向读者提供的每条会话都接受文件,与档位和分叉开关无关。已退场的 `fork-readonly` 名称不会读回为 `view`:持久化的 owner 记录若写有该值,owner 状态加载时会直接拒绝。任何环节都不会推断出 `write`,而宿主机无法确定模式的分享仍可读取,但绝不会被驱动。 +`ViewerSessionMode` 只有两档,两档都对应一个沙箱:`view` 读取共享会话,并在授予工作区的只读沙箱里驱动它的 loop,绝不写文件;它可以使用读取与检索面(`read`、`read_image`、`glob`、`grep`、`present`)和无本地文件副作用的工具(`todo_write`、`skill`、`web_search`、`web_fetch`),每个声明路径都按授予范围检查。`write` 在受宿主写入上限约束的可写沙箱里运行,通过有界 `edit` 与 `write` 修改该工作区内的文件。fork 不是一档:分享自带独立的 `allowsFork` 开关,与档位无关。附件上传也不是一档:分享向读者提供的每条会话都接受文件,与档位和分叉开关无关。已退场的 `fork-readonly` 名称不会读回为 `view`:持久化的 owner 记录若写有该值,owner 状态加载时会直接拒绝。任何环节都不会推断出 `write`,而宿主机无法确定模式的分享仍可读取,但绝不会被驱动。两档也都准入 PTC 传输工具 `run_code`:程序运行在所有者自己的常驻文件策略下而不是分享档位下,因此可以读写授权范围之外的文件;它发起的嵌套工具调用仍走同样的守卫与路径检查。 ## fork 与其上限 diff --git a/docs/subsystems/mcp.i18n.yaml b/docs/subsystems/mcp.i18n.yaml index 74f839078e..e4fea9f3c9 100644 --- a/docs/subsystems/mcp.i18n.yaml +++ b/docs/subsystems/mcp.i18n.yaml @@ -12,11 +12,11 @@ en: 5c0aa5a2e528af97 zh: ae2ba2297294e3de /mcp/configuration: - en: 2f81683ec4a5d00a - zh: 24d09bf8c02029da + en: ac3c1765cd3756da + zh: f8c401e4c06fbd30 /mcp/responsibilities-and-scope: - en: dd1323247f70625e - zh: 10a7eb288d7bd223 + en: 9aa0df86a35e1a0e + zh: 58f5382ef3f11f06 /mcp/protocol-and-results: en: 46e9b914f016b636 zh: e1e9ee92a22f2562 @@ -30,5 +30,5 @@ en: 27a59706525c88ed zh: 1fb1684bc03bb6ae /mcp/further-reading: - en: d9bb664b4fd11b99 - zh: f1a1d6badffdfb74 + en: 1d24646cc4174154 + zh: 3cb4253d39402451 diff --git a/docs/subsystems/mcp.md b/docs/subsystems/mcp.md index 0f68f9d7d0..a07100c4e7 100644 --- a/docs/subsystems/mcp.md +++ b/docs/subsystems/mcp.md @@ -23,6 +23,8 @@ Model Context Protocol (MCP) connects the model to tools supplied by external se MCP servers are opt-in. Configure one `@deepseek-ai/dsh-mcp-client` entry per server in the intended Cordis scope. Every shipped profile supplies the [tool registry](tools.md) and mounts the shared resource service once; users configure only client entries. Callers with no visible configured server receive no MCP prompt text or tools in native or PTC mode. +The Web and Desktop profiles also mount the [MCP server manager](../../packages/mcp/mcp-manager/README.md) once. It runs servers configured through Settings and servers that installed plugins register for their own lifetime, and serves their status, restart, and probe operations to Remote clients. + | Choice | Configuration owner | |---|---| | Server identity, local process or HTTP endpoint, credentials, and process environment | [Client configuration](../../packages/mcp/mcp-client/README.md#use-this-package) | @@ -38,7 +40,7 @@ Protocol negotiation follows the SDK's supported revisions; there is no product ## Responsibilities and scope -The client is a per-server connection plugin and a consumer of the harness tool registry. It does not publish a shared `ctx.mcp` service. The external server implements MCP operations; the SDK owns protocol exchange; the client adapts discovered tools to harness execution. +The client is a per-server connection plugin and a consumer of the harness tool registry. It does not publish a shared `ctx.mcp` service; the manager publishes `ctx.mcpManager` for the Host-global servers it mounts. The external server implements MCP operations; the SDK owns protocol exchange; the client adapts discovered tools to harness execution. `mcp-resources` owns the shared resource tools and selects providers in the caller's scope. Each MCP client supplies resource operations through its own connection. The first provider in a scope enables the local shared tools, and removing the last removes them; inherited providers remain visible. The service owns these tool registrations independently of any one client. Connection failures do not remove shared resource tools while a visible client entry remains active. @@ -107,6 +109,7 @@ MCP prompt templates, human-input elicitation, task-based execution, and resourc - [MCP package group](../../packages/mcp/README.md) — package entry points. - [MCP resources](../../packages/mcp/mcp-resources/README.md) — shared tools and resource-provider semantics. +- [MCP manager](../../packages/mcp/mcp-manager/README.md) — Settings-configured and plugin-registered servers, status, and Remote operations. - [Resource visibility reference](../../packages/mcp/mcp-resources/README.md) — shared profile mounting and visibility from configured servers. - [Third-party memory servers](../user/guide/mcp-memory.md) — product configuration guide. - [Protocol negotiation reference](../../packages/mcp/mcp-client/README.md) — SDK ownership and compatibility decisions. @@ -119,6 +122,71 @@ MCP prompt templates, human-input elicitation, task-based execution, and resourc Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.mcpManager` — `McpServerManager` + +The manager service. Reconciliation and `restart` serialize instance replacement. Probes run independently and are cancelled before replacement or disposal waits for their cleanup. + +```ts cordis-catalog +/** + * Read configured and registered servers and retained runtime records without mounting or probing. + * @returns configured entries in document order, then registered entries, then retained records for removed entries. + */ +list(): McpManagedServerView[] + +/** + * Contribute one server for the calling plugin's lifetime. A registered + * entry is enabled while registered; its removal awaits the instance's + * unmount, so plugin disposal withdraws the server's tools before settling. + * @param name - server key and tool namespace, unique across configured and registered servers. + * @param entry - transport, endpoint, and budgets, validated like a configured entry. + * @returns an idempotent disposer that removes the registration. + * @throws when the name is taken, the entry is invalid, or the manager is disposed. + */ +register(name: string, entry: McpServerRegistration): () => void + +/** + * Stream server views for a management surface: the current list on + * subscription, then the complete list again after each status or entry change. + * @param signal - subscription lifetime; aborting ends only this stream. + * @returns coalesced server lists until the subscriber or the manager stops. + */ +@Remote({ mode: 'stream' }) async* watch(signal: AbortSignal): AsyncIterable + +/** + * Rebuild one server from its current entry: dispose the live instance (if + * any) and mount a fresh one with a fresh reconnect budget. Incomplete + * cleanup blocks replacement until the Host restarts. + * @param name - the configured or registered server key. + * @throws when the manager is disposed, no such entry exists, or cleanup is incomplete. + */ +@Remote async restart(name: string): Promise + +/** + * Probe one server for a management surface; see {@link McpServerManager.probe}. + * @param name - the configured or registered server key. + * @param signal - caller cancellation. + * @returns the stage-classified probe outcome and independent cleanup result. + */ +@Remote('probe') probeFromClient(name: string, signal: AbortSignal): Promise + +/** + * Probe one configured server without touching its live instance: resolve + * the entry's templates, connect, list tools, close. A disabled entry can be + * probed — that is how a management surface tests it before enabling. + * Configuration changes and manager disposal cancel the probe. + * @param name - the configured or registered server key. + * @param options - probe budget, listing caps, and abort signal. + * @returns the stage-classified probe outcome and independent cleanup result. + * @throws when the manager is disposed, the entry is absent or changing, cleanup is + * incomplete, options are invalid, or a required credentials provider is missing. + */ +async probe(name: string, options?: McpProbeOptions): Promise +``` + +Source: [`packages/mcp/mcp-manager/src/index.ts`](../../packages/mcp/mcp-manager/src/index.ts) + ### `ctx.mcpResources` — `McpResourceRuntime` @@ -136,4 +204,31 @@ register(server: string, provider: McpResourceProvider): () => void ``` Source: [`packages/mcp/mcp-resources/src/index.ts`](../../packages/mcp/mcp-resources/src/index.ts) + + + +### `mcp-manager/*` events + + + +#### `mcp-manager/status` — parallel + +One managed server's status transitioned. Emitted for every published change while the manager is live and either the entry is configured or registered, or a runtime record remains. Removed entries may emit cleanup failures; `watch` and `list()` report removals. Lifecycle operations do not wait for listeners. Synchronous throws and returned promise rejections are logged without skipping other listeners. + +```ts cordis-catalog +/** + * One managed server's status transitioned. Emitted for every published + * change while the manager is live and either the entry is configured or + * registered, or a runtime record remains. Removed entries may emit cleanup + * failures; `watch` and `list()` report removals. + * Lifecycle operations do not wait for listeners. Synchronous throws and + * returned promise rejections are logged without skipping other listeners. + * @param name - the configured `servers` key or registered name. + * @param status - the status now current for that server. + * @mode parallel + */ +'mcp-manager/status'(name: string, status: McpManagedServerStatus): void | Promise +``` + +Source: [`packages/mcp/mcp-manager/src/types.ts`](../../packages/mcp/mcp-manager/src/types.ts) diff --git a/docs/subsystems/mcp.zh.md b/docs/subsystems/mcp.zh.md index 1d62d6b98e..216db4413a 100644 --- a/docs/subsystems/mcp.zh.md +++ b/docs/subsystems/mcp.zh.md @@ -23,6 +23,8 @@ MCP 服务器需要主动配置。在目标 Cordis 作用域中,为每台服务器配置一个 `@deepseek-ai/dsh-mcp-client` 条目。每个随附 profile 都提供[工具注册表](tools.zh.md),并统一挂载共享资源服务一次;用户只需配置客户端条目。调用方没有可见的已配置服务器时,在 native 或 PTC 模式下都不会获得 MCP 提示词文本或工具。 +Web 与 Desktop profile 还统一挂载一次 [MCP 服务器管理器](../../packages/mcp/mcp-manager/README.zh.md)。它运行通过 Settings 配置的服务器,以及已安装插件在自身生命周期内注册的服务器,并向 Remote 客户端提供它们的状态、重启与探测操作。 + | 选择 | 配置维护位置 | |---|---| | 服务器身份、本地进程或 HTTP 端点、凭据和进程环境 | [客户端配置](../../packages/mcp/mcp-client/README.zh.md#use-this-package) | @@ -38,7 +40,7 @@ MCP 服务器需要主动配置。在目标 Cordis 作用域中,为每台服 ## 职责与作用域 -客户端是每服务器一个的连接插件,也是 Harness 工具注册表的消费者。它不发布共享的 `ctx.mcp` 服务。外部服务器实现 MCP 操作;SDK 拥有协议交换;客户端将发现的工具适配到 Harness 执行过程。 +客户端是每服务器一个的连接插件,也是 Harness 工具注册表的消费者。它不发布共享的 `ctx.mcp` 服务;管理器为其挂载的宿主全局服务器发布 `ctx.mcpManager`。外部服务器实现 MCP 操作;SDK 拥有协议交换;客户端将发现的工具适配到 Harness 执行过程。 `mcp-resources` 拥有共享资源工具,并在调用方作用域中选择提供方。每个 MCP 客户端通过自己的连接提供资源操作。作用域中的首个提供方启用本地共享工具,移除最后一个提供方时移除这些工具;继承的提供方仍然可见。服务独立于任何单一客户端拥有这些工具注册。只要可见的客户端条目保持激活,连接失败就不会移除共享资源工具。 @@ -107,6 +109,7 @@ interface McpResourceProvider { - [MCP 包组](../../packages/mcp/README.zh.md) — 包入口。 - [MCP 资源](../../packages/mcp/mcp-resources/README.zh.md) — 共享工具与资源提供方语义。 +- [MCP 管理器](../../packages/mcp/mcp-manager/README.zh.md) — 通过 Settings 配置与插件注册的服务器、状态及 Remote 操作。 - [资源可见性参考](../../packages/mcp/mcp-resources/README.zh.md) — profile 统一挂载及由已配置服务器决定的可见性。 - [第三方记忆服务器](../user/guide/mcp-memory.zh.md) — 产品配置指南。 - [协议协商参考](../../packages/mcp/mcp-client/README.zh.md) — SDK 职责与兼容性决策。 @@ -119,6 +122,71 @@ interface McpResourceProvider { Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.mcpManager` — `McpServerManager` + +The manager service. Reconciliation and `restart` serialize instance replacement. Probes run independently and are cancelled before replacement or disposal waits for their cleanup. + +```ts cordis-catalog +/** + * Read configured and registered servers and retained runtime records without mounting or probing. + * @returns configured entries in document order, then registered entries, then retained records for removed entries. + */ +list(): McpManagedServerView[] + +/** + * Contribute one server for the calling plugin's lifetime. A registered + * entry is enabled while registered; its removal awaits the instance's + * unmount, so plugin disposal withdraws the server's tools before settling. + * @param name - server key and tool namespace, unique across configured and registered servers. + * @param entry - transport, endpoint, and budgets, validated like a configured entry. + * @returns an idempotent disposer that removes the registration. + * @throws when the name is taken, the entry is invalid, or the manager is disposed. + */ +register(name: string, entry: McpServerRegistration): () => void + +/** + * Stream server views for a management surface: the current list on + * subscription, then the complete list again after each status or entry change. + * @param signal - subscription lifetime; aborting ends only this stream. + * @returns coalesced server lists until the subscriber or the manager stops. + */ +@Remote({ mode: 'stream' }) async* watch(signal: AbortSignal): AsyncIterable + +/** + * Rebuild one server from its current entry: dispose the live instance (if + * any) and mount a fresh one with a fresh reconnect budget. Incomplete + * cleanup blocks replacement until the Host restarts. + * @param name - the configured or registered server key. + * @throws when the manager is disposed, no such entry exists, or cleanup is incomplete. + */ +@Remote async restart(name: string): Promise + +/** + * Probe one server for a management surface; see {@link McpServerManager.probe}. + * @param name - the configured or registered server key. + * @param signal - caller cancellation. + * @returns the stage-classified probe outcome and independent cleanup result. + */ +@Remote('probe') probeFromClient(name: string, signal: AbortSignal): Promise + +/** + * Probe one configured server without touching its live instance: resolve + * the entry's templates, connect, list tools, close. A disabled entry can be + * probed — that is how a management surface tests it before enabling. + * Configuration changes and manager disposal cancel the probe. + * @param name - the configured or registered server key. + * @param options - probe budget, listing caps, and abort signal. + * @returns the stage-classified probe outcome and independent cleanup result. + * @throws when the manager is disposed, the entry is absent or changing, cleanup is + * incomplete, options are invalid, or a required credentials provider is missing. + */ +async probe(name: string, options?: McpProbeOptions): Promise +``` + +Source: [`packages/mcp/mcp-manager/src/index.ts`](../../packages/mcp/mcp-manager/src/index.ts) + ### `ctx.mcpResources` — `McpResourceRuntime` @@ -136,4 +204,31 @@ register(server: string, provider: McpResourceProvider): () => void ``` Source: [`packages/mcp/mcp-resources/src/index.ts`](../../packages/mcp/mcp-resources/src/index.ts) + + + +### `mcp-manager/*` events + + + +#### `mcp-manager/status` — parallel + +One managed server's status transitioned. Emitted for every published change while the manager is live and either the entry is configured or registered, or a runtime record remains. Removed entries may emit cleanup failures; `watch` and `list()` report removals. Lifecycle operations do not wait for listeners. Synchronous throws and returned promise rejections are logged without skipping other listeners. + +```ts cordis-catalog +/** + * One managed server's status transitioned. Emitted for every published + * change while the manager is live and either the entry is configured or + * registered, or a runtime record remains. Removed entries may emit cleanup + * failures; `watch` and `list()` report removals. + * Lifecycle operations do not wait for listeners. Synchronous throws and + * returned promise rejections are logged without skipping other listeners. + * @param name - the configured `servers` key or registered name. + * @param status - the status now current for that server. + * @mode parallel + */ +'mcp-manager/status'(name: string, status: McpManagedServerStatus): void | Promise +``` + +Source: [`packages/mcp/mcp-manager/src/types.ts`](../../packages/mcp/mcp-manager/src/types.ts) diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 917f2152fb..5c37a0ee71 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md /session-projections: - en: b2789f5257a935c5 - zh: 12d430a9cd454592 + en: 1804bc8fc5f226e4 + zh: 2f31d0f014508d29 /session-projections/the-unit: en: d48814d3ab0e997f zh: d0337d8eba097a63 @@ -12,11 +12,11 @@ en: bc9d438b90fc2baa zh: df7cbea2dad3786b /session-projections/summary-reads: - en: d2bc9bcc775acfb7 - zh: b1d2ba74496305b8 + en: fcc9d07e5c41abb7 + zh: 798004ad1c66787b /session-projections/summary-reads/summary-vocabulary: en: 6a93a4e1682fd6e6 zh: 28729129c3d8a3b1 /session-projections/the-registry-ctx-sessionprojections: - en: 67fe0f49dee90e25 - zh: c23de3a2803040f0 + en: 33e4aa5276d4c0fa + zh: 18019b4990734b82 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 7ea7c06716..512542d505 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -6,7 +6,7 @@ The session-projection seam — a [capability seam](../capability-seams.md) thro Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts) -A `ProjectionHandle` retains its synchronous reader after unregistration. `stateOf(session)` folds newly committed events with the original unit; it does not rejoin global snapshots, checkpoints, or notifications. Registration must still finish before the handle can be used. See the [registration lifecycle](../../packages/session/session-projection/README.md). +A `ProjectionHandle` retains its synchronous reader after unregistration. `stateOf(session)` folds newly committed events with the original unit; it does not rejoin global snapshots or notifications. Registration must still finish before the handle can be used. See the [registration lifecycle](../../packages/session/session-projection/README.md). ```ts type-equiv /** An effect-owned registration with a reader retained independently of registration. */ @@ -14,6 +14,8 @@ interface ProjectionHandle { /** * Read the current state, including after unregistering, by folding new * Session events with this handle's original unit. Do not mutate the live value. + * After the final release, a read also advances the released cell that + * `checkpoint(session)` serves, until the key registers again. * @param session - Session whose committed events are folded. * @returns the projection state at the Session's current cursor. */ @@ -156,9 +158,9 @@ type ProjectionEventChangeListener = ( A Session summary carries every registered client view except the keys whose `SessionProjectionMap` entry carries the `SessionProjectionSummaryExcluded` mark. The mark has no runtime representation: it decides the client view type, while `wire.summary` decides the runtime key set, with omission meaning `true`. A key whose entry carries the mark declares `wire.summary: false` so the two agree; the field is a plain boolean, so the type does not enforce it. -`summaryKeys()` derives the key set from the live registrations and keeps returning the same instance until a registration or its disposal changes the key table. A summary consumer passes it to the existing key-taking faces — `cachedSnapshot(session, keys)` for cached cells and `viewCheckpoint(rows, keys)` for checkpoint rows, both keeping their watermark rules and both using a set without copying — so there is no summary-specific read face and no second definition of the set. +`summaryKeys()` derives the key set from the live registrations and keeps returning the same instance until a registration or its disposal changes the key table. A summary consumer passes it to the existing key-taking faces — `snapshot(session, keys)` for live Sessions and `viewCheckpoint(rows, keys)` for checkpoint rows, both using a set without copying — so there is no summary-specific read face and no second definition of the set. -A list row carries that summary projection, never the full projection: it promises the summary key set and nothing more. `snapshot`, `cachedSnapshot`, and `viewCheckpoint` remain complete when called without a key subset, which is how an opened Session, its follow baseline, and the control baseline keep every projected value; the `@` reference reads only its label keys through the same key-taking faces. +A list row carries that summary projection, never the full projection: it promises the summary key set and nothing more. `snapshot(session, keys)` computes only the units it returns. Without a key subset, `snapshot` computes every client-visible unit and no Host-only unit, and `viewCheckpoint` serves every wire key, so an opened Session, its follow baseline, and the control baseline carry every projected value; the `@` reference reads only its label keys through the same key-taking faces. The registry's `cachedSnapshot(session, keys)` is deprecated: it returns only values another read already computed and may omit keys. ### Summary vocabulary @@ -230,7 +232,7 @@ type ProjectionKeySelection = ## The registry: `ctx.sessionProjections` -`SessionProjectionRegistry` ([signatures](#ctxsessionprojections--sessionprojectionregistry)) owns the drive: one `session/event` subscription, eager `apply` over every registered unit, and per-session per-unit watermark cells. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Domains await `register(definition)` during activation and retain its handle. Registration belongs to the calling fiber: its last release removes the key from global drives and snapshots, while retained handles remain readable. Same-key, same-version registrants share one unit; incompatible versions reject registration. +`SessionProjectionRegistry` ([signatures](#ctxsessionprojections--sessionprojectionregistry)) owns the drive: one `session/event` subscription, eager `apply` over every registered unit, and per-session per-unit watermark cells. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Domains await `register(definition)` during activation and retain its handle. Registration belongs to the calling fiber: its last release removes the key from global drives and snapshots, checkpoints keep each Session's last state of it until the key registers again, and retained handles remain readable. Same-key, same-version registrants share one unit; incompatible versions reject registration. @@ -331,7 +333,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Consumers declare `sessionProjections` as a required injection, await registration during activation, and retain its ready handle. Registrants sharing a key share one unit and are counted. The last release removes the unit from live drives, checkpoints, and client snapshots; retained handles still fold new events on demand using the original unit. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Consumers declare `sessionProjections` as a required injection, await registration during activation, and retain its ready handle. Registrants sharing a key share one unit and are counted. The last release removes the unit from live drives, restores, and client snapshots, while checkpoints keep each Session's last cell of it until the key registers again and the new unit refolds the log; retained handles still fold new events on demand using the original unit. ```ts cordis-catalog /** @@ -376,30 +378,22 @@ onEventChanged(listener: ProjectionEventChangeListener): () => void /** * One consistent cut over every registered client-visible unit for one session, read from * the watermark cache (missing cells fold lazily over the in-memory log). + * Only the returned units are computed: other units, including Host-only + * units without `wire`, are neither folded nor advanced by this read. * Fully synchronous — every value and `asOfSeq` reflect the same log * position. Each value passes its unit's `viewSchema` before leaving. * @param session - the session whose projection values are read. - * @param keys - optional client-visible outputs; state materialization remains complete. + * @param keys - optional client-visible outputs; only these units are computed. * @returns the snapshot; `values` is empty when no selected client-visible unit is registered. */ snapshot( session: Session, keys?: ProjectionKeySelection, ): ProjectionSnapshot -/** - * Read only already-materialized client-visible cells without folding history. - * Values may trail the live Session and are therefore hints, not a complete - * baseline. Missing cells are omitted. - * @param session - attached Session whose cached cells are inspected. - * @param keys - optional wire keys to view. - * @returns the lowest common cached cut, or `undefined` when no wire cell exists. - */ -cachedSnapshot( session: Session, keys?: ProjectionKeySelection, ): ProjectionSnapshot | undefined - /** * The keys a Session summary carries: every registered client-visible key * whose unit does not declare `summary: false`. The returned set is stable — * the same instance on every call — until a registration or its disposal * changes the key table, so a consumer may hold it across rows. Callers pass - * it to a key-taking read face such as `cachedSnapshot(session, summaryKeys())` + * it to a key-taking read face such as `snapshot(session, summaryKeys())` * or `viewCheckpoint(rows, summaryKeys())`; those faces use the set as-is. * @returns the current summary key set; do not mutate it. */ @@ -416,8 +410,13 @@ summaryKeys(): ReadonlySet> * mutable state, and a caller reaching the live reference could corrupt * every subsequent snapshot and frame through it (plain JSON by the unit * contract, so the clone is total). + * A released unit contributes the Session's last cell. The registry no + * longer advances that cell, while a retained handle read still advances it + * forward, so a writer running after a plugin unloads keeps that unit's + * latest state; a Session that has no cell of it gets no row. {@link restore} + * folds only live units, so its refreshed rows omit released units. * @param session - the session whose unit states are checkpointed. - * @returns one row per registered key. + * @returns one row per registered key, plus one per released key with a cell for this Session. */ checkpoint(session: Session): ProjectionCheckpoint @@ -434,7 +433,7 @@ checkpoint(session: Session): ProjectionCheckpoint * re-read. * @param checkpoint - persisted rows for one session (possibly stale or empty). * @returns the offset for the stored-log suffix read (`SessionHandle.read`), - * or `undefined` when no unit is registered (no read needed — + * or `undefined` when no live unit is registered (released predecessors do not count; no read needed — * {@link restore} would serve empty values regardless). */ restoreFloor(checkpoint: ProjectionCheckpoint): SessionLogOffset | undefined @@ -475,6 +474,8 @@ viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: ProjectionKeySelection, * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last * supplied event's seq, `baseSeq - 1` for an empty tail) plus the * refreshed checkpoint rows at that cut, ready for a durable write-back. + * The rows cover live units only: a released unit has no cell for a log + * folded without a live `Session`, so a write-back of them omits it. */ restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: SessionLogOffset, header: SessionHeader, inheritedEventCount: SessionLogOffset, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index b84720e804..d6b38f28d3 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -6,7 +6,7 @@ 源码:[`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts) -`ProjectionHandle` 在注销后仍保留同步 reader。`stateOf(session)` 用原有单元折叠新提交的事件,不会重新加入全局快照、检查点或通知。必须等待注册完成才能使用 handle。参见[注册生命周期](../../packages/session/session-projection/README.zh.md)。 +`ProjectionHandle` 在注销后仍保留同步 reader。`stateOf(session)` 用原有单元折叠新提交的事件,不会重新加入全局快照或通知。必须等待注册完成才能使用 handle。参见[注册生命周期](../../packages/session/session-projection/README.zh.md)。 ```ts type-equiv /** An effect-owned registration with a reader retained independently of registration. */ @@ -14,6 +14,8 @@ interface ProjectionHandle { /** * Read the current state, including after unregistering, by folding new * Session events with this handle's original unit. Do not mutate the live value. + * After the final release, a read also advances the released cell that + * `checkpoint(session)` serves, until the key registers again. * @param session - Session whose committed events are folded. * @returns the projection state at the Session's current cursor. */ @@ -156,9 +158,9 @@ type ProjectionEventChangeListener = ( Session 摘要包含每个已注册客户端视图,但排除 `SessionProjectionMap` 条目带 `SessionProjectionSummaryExcluded` 标记的 key。标记没有运行时表示:它决定客户端视图类型,`wire.summary` 决定运行时键集合,省略即 `true`。map 条目带标记的 key 应声明 `wire.summary: false` 以保持一致;该字段是普通布尔值,类型层不做强制。 -`summaryKeys()` 从实时注册派生该集合,并在注册或注销改变 key 表之前持续返回同一实例。摘要消费方把它传给既有的带 key 读取面 —— 缓存 cell 用 `cachedSnapshot(session, keys)`,checkpoint 行用 `viewCheckpoint(rows, keys)`,两者保持各自的水位规则且直接使用 Set、不做复制 —— 因此不存在摘要专用读取面,集合也只有一处定义。 +`summaryKeys()` 从实时注册派生该集合,并在注册或注销改变 key 表之前持续返回同一实例。摘要消费方把它传给既有的带 key 读取面 —— 实时 Session 用 `snapshot(session, keys)`,checkpoint 行用 `viewCheckpoint(rows, keys)`,两者都直接使用 Set、不做复制 —— 因此不存在摘要专用读取面,集合也只有一处定义。 -列表行携带的是这个摘要投影,绝不是全量投影:它只承诺摘要键集合,不承诺更多。`snapshot`、`cachedSnapshot` 与 `viewCheckpoint` 在不传 key 子集时仍是完整的,打开的 Session、其 follow 基线与控制基线因此保留每个投影值;`@` 引用只通过同一组带 key 的读取面读取其标签 key。 +列表行携带的是这个摘要投影,绝不是全量投影:它只承诺摘要键集合,不承诺更多。`snapshot(session, keys)` 只计算它返回的单元。不传 key 子集时,`snapshot` 计算每个客户端可见单元、不计算 Host 专用单元,`viewCheckpoint` 返回每个 wire key,因此打开的 Session、其 follow 基线与控制基线携带每个投影值;`@` 引用只通过同一组带 key 的读取面读取其标签 key。注册表的 `cachedSnapshot(session, keys)` 已废弃:它只返回其他读取已经算好的值,可能缺少 key。 ### 摘要词汇 @@ -230,7 +232,7 @@ type ProjectionKeySelection = ## 注册表:`ctx.sessionProjections` -`SessionProjectionRegistry`([签名](#ctxsessionprojections--sessionprojectionregistry))拥有驱动逻辑:一个 `session/event` 订阅、对所有已注册单元立即执行的 `apply`,以及按会话、按单元保存的水位线缓存。缓存单元惰性构建:若单元在事件产生后才注册,或会话早于注册表存在,第一次访问(事件或读取)会从 `init` 开始折叠内存日志。领域在激活期间等待 `register(definition)`,并保留返回的 handle(句柄)。注册归调用 fiber 所有:最后一次释放会从全局驱动和快照中移除该 key,已有句柄仍可读取。同 key、同版本的注册者共享一个单元;版本不兼容时注册失败。 +`SessionProjectionRegistry`([签名](#ctxsessionprojections--sessionprojectionregistry))拥有驱动逻辑:一个 `session/event` 订阅、对所有已注册单元立即执行的 `apply`,以及按会话、按单元保存的水位线缓存。缓存单元惰性构建:若单元在事件产生后才注册,或会话早于注册表存在,第一次访问(事件或读取)会从 `init` 开始折叠内存日志。领域在激活期间等待 `register(definition)`,并保留返回的 handle(句柄)。注册归调用 fiber 所有:最后一次释放会从全局驱动和快照中移除该 key,检查点仍保留每个会话中它的最后状态,直到该 key 再次注册,已有句柄仍可读取。同 key、同版本的注册者共享一个单元;版本不兼容时注册失败。 @@ -331,7 +333,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Consumers declare `sessionProjections` as a required injection, await registration during activation, and retain its ready handle. Registrants sharing a key share one unit and are counted. The last release removes the unit from live drives, checkpoints, and client snapshots; retained handles still fold new events on demand using the original unit. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Consumers declare `sessionProjections` as a required injection, await registration during activation, and retain its ready handle. Registrants sharing a key share one unit and are counted. The last release removes the unit from live drives, restores, and client snapshots, while checkpoints keep each Session's last cell of it until the key registers again and the new unit refolds the log; retained handles still fold new events on demand using the original unit. ```ts cordis-catalog /** @@ -376,30 +378,22 @@ onEventChanged(listener: ProjectionEventChangeListener): () => void /** * One consistent cut over every registered client-visible unit for one session, read from * the watermark cache (missing cells fold lazily over the in-memory log). + * Only the returned units are computed: other units, including Host-only + * units without `wire`, are neither folded nor advanced by this read. * Fully synchronous — every value and `asOfSeq` reflect the same log * position. Each value passes its unit's `viewSchema` before leaving. * @param session - the session whose projection values are read. - * @param keys - optional client-visible outputs; state materialization remains complete. + * @param keys - optional client-visible outputs; only these units are computed. * @returns the snapshot; `values` is empty when no selected client-visible unit is registered. */ snapshot( session: Session, keys?: ProjectionKeySelection, ): ProjectionSnapshot -/** - * Read only already-materialized client-visible cells without folding history. - * Values may trail the live Session and are therefore hints, not a complete - * baseline. Missing cells are omitted. - * @param session - attached Session whose cached cells are inspected. - * @param keys - optional wire keys to view. - * @returns the lowest common cached cut, or `undefined` when no wire cell exists. - */ -cachedSnapshot( session: Session, keys?: ProjectionKeySelection, ): ProjectionSnapshot | undefined - /** * The keys a Session summary carries: every registered client-visible key * whose unit does not declare `summary: false`. The returned set is stable — * the same instance on every call — until a registration or its disposal * changes the key table, so a consumer may hold it across rows. Callers pass - * it to a key-taking read face such as `cachedSnapshot(session, summaryKeys())` + * it to a key-taking read face such as `snapshot(session, summaryKeys())` * or `viewCheckpoint(rows, summaryKeys())`; those faces use the set as-is. * @returns the current summary key set; do not mutate it. */ @@ -416,8 +410,13 @@ summaryKeys(): ReadonlySet> * mutable state, and a caller reaching the live reference could corrupt * every subsequent snapshot and frame through it (plain JSON by the unit * contract, so the clone is total). + * A released unit contributes the Session's last cell. The registry no + * longer advances that cell, while a retained handle read still advances it + * forward, so a writer running after a plugin unloads keeps that unit's + * latest state; a Session that has no cell of it gets no row. {@link restore} + * folds only live units, so its refreshed rows omit released units. * @param session - the session whose unit states are checkpointed. - * @returns one row per registered key. + * @returns one row per registered key, plus one per released key with a cell for this Session. */ checkpoint(session: Session): ProjectionCheckpoint @@ -434,7 +433,7 @@ checkpoint(session: Session): ProjectionCheckpoint * re-read. * @param checkpoint - persisted rows for one session (possibly stale or empty). * @returns the offset for the stored-log suffix read (`SessionHandle.read`), - * or `undefined` when no unit is registered (no read needed — + * or `undefined` when no live unit is registered (released predecessors do not count; no read needed — * {@link restore} would serve empty values regardless). */ restoreFloor(checkpoint: ProjectionCheckpoint): SessionLogOffset | undefined @@ -475,6 +474,8 @@ viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: ProjectionKeySelection, * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last * supplied event's seq, `baseSeq - 1` for an empty tail) plus the * refreshed checkpoint rows at that cut, ready for a durable write-back. + * The rows cover live units only: a released unit has no cell for a log + * folded without a live `Session`, so a write-back of them omits it. */ restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: SessionLogOffset, header: SessionHeader, inheritedEventCount: SessionLogOffset, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } diff --git a/docs/subsystems/web-client.i18n.yaml b/docs/subsystems/web-client.i18n.yaml index 593dcc3494..f201ba2f7e 100644 --- a/docs/subsystems/web-client.i18n.yaml +++ b/docs/subsystems/web-client.i18n.yaml @@ -9,8 +9,8 @@ en: c0454bca818f86ee zh: 427582150fd3b976 /web-client-architecture/browser-boot: - en: 5ac7d3aed7bf273a - zh: e34869c8ad64052d + en: cd68c46a3f365235 + zh: 9ac2f7c65cabb144 /web-client-architecture/remote-communication: en: 426b09164210b77a zh: ce576099ee668984 diff --git a/docs/subsystems/web-client.md b/docs/subsystems/web-client.md index bb68cd64a1..93f2fbdf1d 100644 --- a/docs/subsystems/web-client.md +++ b/docs/subsystems/web-client.md @@ -23,7 +23,7 @@ The dependency direction is Host state → Remote transport → Client model → The Host writes the composed `WebBootGraph` to `window.__DSH_BOOT__` and installs the browser module-loader facade before parser-preloaded scripts execute. The module system is a lazy CommonJS table: loading a bundle registers its factory, while materializing an entry runs the factory with synchronous `require` over platform modules and declared dynamic dependencies. -The Web boot kernel creates the module system, prefetches `immediately` entries, mounts the vendored Cordis Loader, and creates every graph entry. Cordis service injection determines activation; module graph order determines only whether synchronous imports can be materialized. After the complete roster reaches a settled state, `ui-renderer` hydrates the framework-free boot DOM and calls the sole context-level `renderSlot('root')` operation. [Client Modules](client-modules.md) owns the graph, bundle route, cache revision, and loader details. +The Web boot kernel creates the module system, prefetches `immediately` entries, mounts the vendored Cordis Loader, and creates every graph entry. Cordis service injection determines activation; module graph order determines only whether synchronous imports can be materialized. After the complete roster reaches a settled state and the boot audit accepts it (an inactive entry on the browser required list rejects startup; other inactive entries produce one console warning), `ui-renderer` hydrates the framework-free boot DOM and calls the sole context-level `renderSlot('root')` operation. [Client Modules](client-modules.md) owns the graph, bundle route, cache revision, and loader details. ## Remote communication diff --git a/docs/subsystems/web-client.zh.md b/docs/subsystems/web-client.zh.md index a3d62f738f..3c0c0da5bb 100644 --- a/docs/subsystems/web-client.zh.md +++ b/docs/subsystems/web-client.zh.md @@ -23,7 +23,7 @@ Web Client 是由独立加载插件组装而成的浏览器侧 Cordis 应用。 Host 把组合后的 `WebBootGraph` 写入 `window.__DSH_BOOT__`,并在 parser-preloaded script 执行前安装浏览器 module-loader facade。模块系统是一张 lazy CommonJS 表:加载 bundle 只注册 factory;materialize entry 时才以同步 `require` 运行 factory,并解析 platform module 和已声明的动态依赖。 -Web boot kernel 创建模块系统、预取 `immediately` entry、挂载 vendored Cordis Loader,再创建图中的每个 entry。Cordis service injection 决定激活顺序;module graph 顺序只决定同步 import 能否被 materialize。完整 roster 到达 settled 状态后,`ui-renderer` hydrate 不依赖框架的 boot DOM,并调用唯一一次 context 级 `renderSlot('root')`。[Client Modules](client-modules.zh.md)负责 graph、bundle route、cache revision 与 loader 细节。 +Web boot kernel 创建模块系统、预取 `immediately` entry、挂载 vendored Cordis Loader,再创建图中的每个 entry。Cordis service injection 决定激活顺序;module graph 顺序只决定同步 import 能否被 materialize。完整 roster 到达 settled 状态且启动审计通过后(浏览器必需名单中的 entry 未激活会拒绝启动;其他未激活 entry 输出一条控制台警告),`ui-renderer` hydrate 不依赖框架的 boot DOM,并调用唯一一次 context 级 `renderSlot('root')`。[Client Modules](client-modules.zh.md)负责 graph、bundle route、cache revision 与 loader 细节。 ## Remote 通信 diff --git a/docs/subsystems/web.i18n.yaml b/docs/subsystems/web.i18n.yaml index f35bc0a539..55cee3b518 100644 --- a/docs/subsystems/web.i18n.yaml +++ b/docs/subsystems/web.i18n.yaml @@ -3,20 +3,20 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/web.md /web-access: - en: 86d12e6e81fb63b8 - zh: 52ed66dcacaf6d85 + en: 3186d79f26bd8c45 + zh: de31c570ea8be79c /web-access/why-one-capability-has-two-operations: en: 380da3cd0aabd52b zh: 1317c26089c3ab07 /web-access/search-request-and-result: - en: 1ad94deb713024e2 - zh: 9d3df730fa99ad89 + en: c6eee490ae914ae0 + zh: d576eaeb48480c81 /web-access/fetch-request-and-result: en: b96bcdf42ced9eab zh: aa927e2450238557 /web-access/provider-availability: - en: 38515cfa348e0950 - zh: 890b624c15d8aa41 + en: ba99c3876cb05fd9 + zh: cd366d7c3279bd87 /web-access/fetch-network-policy: en: 6851874be5841d90 zh: dc085bf3b82d77df diff --git a/docs/subsystems/web.md b/docs/subsystems/web.md index 543e80fb47..097b388fd1 100644 --- a/docs/subsystems/web.md +++ b/docs/subsystems/web.md @@ -2,7 +2,7 @@ English | [中文](web.zh.md) -The web access seam — a [capability seam](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md) that spans **two operations** (search and fetch) on one `ctx.web` service, split across packages: Service Definition ([dsh-web](../../packages/web/web), `ctx.web` + the provider registries), Service Providers ([dsh-web-search-exa](../../packages/web/web-search-exa), [dsh-web-search-perplexity](../../packages/web/web-search-perplexity), [dsh-web-search-deepseek](../../packages/web/web-search-deepseek), [dsh-web-deepseek-api](../../packages/web/web-deepseek-api), [dsh-web-fetch-http](../../packages/web/web-fetch-http)), and Consumer ([dsh-tool-web](../../packages/web/tool-web), the `web_search`/`web_fetch` tool schemas). Web is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A search-provider swap does not change how the model asks for a query, and a fetch-provider swap does not change how the model asks for a URL. +The web access seam — a [capability seam](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md) that spans **two operations** (search and fetch) on one `ctx.web` service, split across packages: Service Definition ([dsh-web](../../packages/web/web), `ctx.web` + the provider registries), Service Providers ([dsh-web-search-deepseek](../../packages/web/web-search-deepseek), [dsh-web-deepseek-api](../../packages/web/web-deepseek-api), [dsh-web-fetch-http](../../packages/web/web-fetch-http)), and Consumer ([dsh-tool-web](../../packages/web/tool-web), the `web_search`/`web_fetch` tool schemas). Web is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A search-provider swap does not change how the model asks for a query, and a fetch-provider swap does not change how the model asks for a URL. Source: [`packages/web/web/src/types.ts`](../../packages/web/web/src/types.ts) @@ -12,7 +12,7 @@ Search and fetch share no request schema and no business logic, but they are del ## Search request and result -Each seam request carries exactly one `query`. The `dsh-tool-web` consumer accepts a required `queries` array and fans it out into separate seam requests; a one-item array performs one search. `maxResults` is a consumer-owned bound (`dsh-tool-web`'s `searchMaxResults` config, default `8`) passed through the seam and enforced on the way back — if a provider over-returns, the seam truncates `sources[]` and sets `truncated`. +Each seam request carries exactly one `query`. The `dsh-tool-web` consumer accepts a required `queries` array and fans it out into separate seam requests; a one-item array performs one search. `maxResults` is a consumer-owned bound (`dsh-tool-web`'s `searchMaxResults` config, default `10`) passed through the seam and enforced on the way back — if a provider over-returns, the seam truncates `sources[]` and sets `truncated`. ```ts type-equiv /** @@ -26,9 +26,9 @@ interface WebSearchRequest { /** * Upper bound on returned sources; the seam truncates to it. Omitted = no * bound. `dsh-tool-web` always sets it. A provider whose API supports a - * result-count control (Exa's `numResults`) should apply it at the request - * layer as a cost/latency optimization; the seam enforces the bound - * regardless. + * result-count control (the DeepSeek Search API's `count`) should apply it + * at the request layer as a cost/latency optimization; the seam enforces the + * bound regardless. */ readonly maxResults?: number } @@ -37,8 +37,8 @@ interface WebSearchRequest { ```ts type-equiv /** * Normalized search outcome. `content` is optional provider-generated answer - * text or summary (Exa and DeepSeek return none; Perplexity returns a - * generated answer). + * text or summary (DeepSeek returns none; a provider whose API generates an + * answer returns it here). * `sources[]` is the portable citation shape. `truncated` is set by the seam * when it cut `sources[]` down to `maxResults`. */ @@ -56,8 +56,8 @@ interface WebSearchResult { /** * One citeable source. A source always has a URL; `title`, `snippet`, `summary`, * and `publishedAt` are optional because not every provider returns them — forcing - * adapters to invent them would make the seam lie (Perplexity citations may be - * URL-only). `dsh-tool-web` renders `title ?? hostname(url)` for display. + * adapters to invent them would make the seam lie (some providers return + * URL-only citations). `dsh-tool-web` renders `title ?? hostname(url)` for display. */ interface WebSearchSource { readonly url: string @@ -123,9 +123,9 @@ type WebFetchBody = ## Provider availability -A provider's `available(): boolean` is a cheap LOCAL check (credential presence, parseable config) and **must not make network calls**. It is an input to execution-time selection, not a health system: `search()`/`fetch()` read it to pick a usable provider, and a selection failure surfaces as the structured `WebError` the caller routes on — which carries the branchable detail (the missing id or ambiguous candidate set) in its code and message. +A provider's `available(): boolean` is a cheap LOCAL check (credential presence, parseable config) and **must not make network calls**. It is an input to execution-time selection, not a health system: `search()`/`fetch()` read it to pick a usable provider, and a selection failure surfaces as the structured `WebError` the caller routes on — which carries the branchable detail (the missing id or ambiguous candidate set) in its code and message. Registration, unlike availability, is observable before an operation runs: `listProviders()` returns every registered search and fetch provider by `id`, optional `title`, and optional localized `description`, and the payload-free `web/providers-changed` event fires after each registration or disposal; availability still shows only when an operation runs. -A search or fetch provider may also implement `ready(llmProvider): Promise`, a configuration-only check that must not make network calls: it answers whether an operation made for a model request on that chat route has a credential to send. `searchReady(llmProvider?)` and `fetchReady(llmProvider?)` ask only the provider that `search()` or `fetch()` would select, treat a provider without `ready()` as ready, and answer true when selection fails, because the operation reports that failure as a `WebError`. Readiness is not a selection input; `dsh-tool-web` makes it the activation condition of `web_search` and `web_fetch`, so a model step whose route answers false neither declares nor dispatches the tool. +A search or fetch provider may also implement `ready(llmProvider): Promise`, a configuration-only check that must not make network calls: it answers whether an operation made for a model request on that chat route has a credential to send. `searchReady(llmProvider?)` and `fetchReady(llmProvider?)` ask only the provider that `search()` or `fetch()` would select, treat a provider without `ready()` as ready, and answer true when selection fails, because the operation reports that failure as a `WebError`. Readiness is not a selection input; `dsh-tool-web` makes it the activation condition of `web_search` and `web_fetch`, so a model step whose route answers false neither declares nor dispatches the tool. The DeepSeek Search and Fetch API providers use the route to choose between the account token and the API key, as the [web-deepseek-api authentication](../../packages/web/web-deepseek-api/README.md#authentication) section describes. Selection never depends on registration, config, or HMR order: a capability has an explicit provider id (config `searchProvider`/`fetchProvider`, or the matching env var feeding the same field), or auto-selects when exactly one usable provider is registered; multiple usable providers with no configured id is `WEB_PROVIDER_AMBIGUOUS`, not first-wins. Each operation reads the configured id when it runs, so a saved settings change applies to the next search or fetch without remounting `ctx.web`. @@ -155,7 +155,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.web` — `WebRuntime` -The web access service. Registered as `ctx.web` (one instance per context). +The web access service. Registered as `ctx.web` (one instance per context) and exposed to Clients as the `web` Remote namespace (`listProviders`). Selection semantics (resolved at execution time, never order-dependent): @@ -168,23 +168,32 @@ Selection semantics (resolved at execution time, never order-dependent): ```ts cordis-catalog /** - * Register a search provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER` - * if its id is already registered for search. Returns a disposer; disposed - * with the calling fiber. + * Register a search provider. `listProviders()` lists it until the + * registration is disposed, which happens with the calling fiber or through + * the returned disposer; registration and disposal each emit + * `web/providers-changed`. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER` + * if its id is already registered for search. * @param provider - the provider; its `id` is the registry key. * @returns the disposer that unregisters the provider. */ registerSearchProvider(provider: WebSearchProvider): () => void /** - * Register a fetch provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER` - * if its id is already registered for fetch. Returns a disposer; disposed - * with the calling fiber. + * Register a fetch provider, with the listing, disposal, and event behavior + * of {@link WebRuntime.registerSearchProvider}. Throws {@link WebError} + * `WEB_DUPLICATE_PROVIDER` if its id is already registered for fetch. * @param provider - the provider; its `id` is the registry key. * @returns the disposer that unregisters the provider. */ registerFetchProvider(provider: WebFetchProvider): () => void +/** + * List every registered search and fetch provider for provider pickers. + * Availability does not filter the lists. + * @returns detached `{ id, title, description }` entries per capability, in registration order. + */ +@Remote listProviders(): WebProviderList + /** * Run one search through the selected provider. Resolves the provider at call * time with the selection rules above; throws {@link WebError} when the @@ -228,4 +237,28 @@ async fetchReady(llmProvider?: string): Promise ``` Source: [`packages/web/web/src/index.ts`](../../packages/web/web/src/index.ts) + + + +### `web/*` events + + + +#### `web/providers-changed` — emit + +The set of registered search or fetch providers changed: one provider registered, or its registration was disposed. Payload-free; consumers re-read `ctx.web.listProviders()` (Remote `web/listProviders`) for the new list. Fires after each committed registry mutation. Observer failures are contained and cannot veto the mutation. + +```ts cordis-catalog +/** + * The set of registered search or fetch providers changed: one provider + * registered, or its registration was disposed. Payload-free; consumers + * re-read `ctx.web.listProviders()` (Remote `web/listProviders`) for the + * new list. Fires after each committed registry mutation. Observer failures + * are contained and cannot veto the mutation. + * @mode emit + */ +'web/providers-changed'(): void +``` + +Source: [`packages/web/web/src/types.ts`](../../packages/web/web/src/types.ts) diff --git a/docs/subsystems/web.zh.md b/docs/subsystems/web.zh.md index f678fdd442..38a1b088f4 100644 --- a/docs/subsystems/web.zh.md +++ b/docs/subsystems/web.zh.md @@ -2,7 +2,7 @@ [English](web.md) | 中文 -Web 访问 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md),在同一个 `ctx.web` 服务上横跨**两项操作**(search 与 fetch),并拆分到多个包:Service Definition([dsh-web](../../packages/web/web),`ctx.web` + 提供方注册表)、Service Provider([dsh-web-search-exa](../../packages/web/web-search-exa)、[dsh-web-search-perplexity](../../packages/web/web-search-perplexity)、[dsh-web-search-deepseek](../../packages/web/web-search-deepseek)、[dsh-web-deepseek-api](../../packages/web/web-deepseek-api)、[dsh-web-fetch-http](../../packages/web/web-fetch-http))与 Consumer([dsh-tool-web](../../packages/web/tool-web),即 `web_search`/`web_fetch` 工具 schema)。Web 是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.zh.md) 中。更换 search 提供方不会改变模型提交查询的方式,更换 fetch 提供方也不会改变模型请求 URL 的方式。 +Web 访问 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md),在同一个 `ctx.web` 服务上横跨**两项操作**(search 与 fetch),并拆分到多个包:Service Definition([dsh-web](../../packages/web/web),`ctx.web` + 提供方注册表)、Service Provider([dsh-web-search-deepseek](../../packages/web/web-search-deepseek)、[dsh-web-deepseek-api](../../packages/web/web-deepseek-api)、[dsh-web-fetch-http](../../packages/web/web-fetch-http))与 Consumer([dsh-tool-web](../../packages/web/tool-web),即 `web_search`/`web_fetch` 工具 schema)。Web 是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.zh.md) 中。更换 search 提供方不会改变模型提交查询的方式,更换 fetch 提供方也不会改变模型请求 URL 的方式。 源码:[`packages/web/web/src/types.ts`](../../packages/web/web/src/types.ts) @@ -12,7 +12,7 @@ Web 访问 seam 是一个[能力 seam](../../.agents/notes/implemented/architect ## 搜索请求与结果 -每个 seam 请求只携带一个 `query`。消费方 `dsh-tool-web` 接受必填的 `queries` 数组,并把它扇出为多个独立 seam 请求;单元素数组执行一次搜索。`maxResults` 是消费方自有的上限(`dsh-tool-web` 的 `searchMaxResults` 配置,默认 `8`),通过 seam 传递并在返回时强制执行——如果提供方返回超量,seam 截断 `sources[]` 并设置 `truncated`。 +每个 seam 请求只携带一个 `query`。消费方 `dsh-tool-web` 接受必填的 `queries` 数组,并把它扇出为多个独立 seam 请求;单元素数组执行一次搜索。`maxResults` 是消费方自有的上限(`dsh-tool-web` 的 `searchMaxResults` 配置,默认 `10`),通过 seam 传递并在返回时强制执行——如果提供方返回超量,seam 截断 `sources[]` 并设置 `truncated`。 ```ts type-equiv /** @@ -26,9 +26,9 @@ interface WebSearchRequest { /** * Upper bound on returned sources; the seam truncates to it. Omitted = no * bound. `dsh-tool-web` always sets it. A provider whose API supports a - * result-count control (Exa's `numResults`) should apply it at the request - * layer as a cost/latency optimization; the seam enforces the bound - * regardless. + * result-count control (the DeepSeek Search API's `count`) should apply it + * at the request layer as a cost/latency optimization; the seam enforces the + * bound regardless. */ readonly maxResults?: number } @@ -37,8 +37,8 @@ interface WebSearchRequest { ```ts type-equiv /** * Normalized search outcome. `content` is optional provider-generated answer - * text or summary (Exa and DeepSeek return none; Perplexity returns a - * generated answer). + * text or summary (DeepSeek returns none; a provider whose API generates an + * answer returns it here). * `sources[]` is the portable citation shape. `truncated` is set by the seam * when it cut `sources[]` down to `maxResults`. */ @@ -56,8 +56,8 @@ interface WebSearchResult { /** * One citeable source. A source always has a URL; `title`, `snippet`, `summary`, * and `publishedAt` are optional because not every provider returns them — forcing - * adapters to invent them would make the seam lie (Perplexity citations may be - * URL-only). `dsh-tool-web` renders `title ?? hostname(url)` for display. + * adapters to invent them would make the seam lie (some providers return + * URL-only citations). `dsh-tool-web` renders `title ?? hostname(url)` for display. */ interface WebSearchSource { readonly url: string @@ -123,9 +123,9 @@ type WebFetchBody = ## 提供方可用性 -提供方的 `available(): boolean` 是一个廉价的本地检查(凭证是否存在、配置是否可解析),**禁止发起网络调用**。它是执行时选择提供方的输入,而不是健康检查系统:`search()`/`fetch()` 会读取它来选择可用的提供方。选择失败时,调用方会收到可据以分支处理的结构化 `WebError`;其错误代码和消息会说明缺失的 id 或存在歧义的候选集。 +提供方的 `available(): boolean` 是一个廉价的本地检查(凭证是否存在、配置是否可解析),**禁止发起网络调用**。它是执行时选择提供方的输入,而不是健康检查系统:`search()`/`fetch()` 会读取它来选择可用的提供方。选择失败时,调用方会收到可据以分支处理的结构化 `WebError`;其错误代码和消息会说明缺失的 id 或存在歧义的候选集。与可用性不同,注册情况在操作运行前即可观测:`listProviders()` 按 `id`、可选的 `title` 和可选的多语言 `description` 返回每个已注册的搜索与抓取提供方,每次注册或注销后都会触发不带载荷的 `web/providers-changed` 事件;可用性仍只在操作运行时体现。 -搜索或抓取提供方还可以实现 `ready(llmProvider): Promise`,这是只检查配置、禁止发起网络调用的判断:它回答为该对话路由上的模型请求发起的操作是否有可发送的凭据。`searchReady(llmProvider?)` 与 `fetchReady(llmProvider?)` 只询问 `search()` 或 `fetch()` 会选中的提供方,把没有 `ready()` 的提供方视为就绪,并在选择失败时回答 true,因为对应操作会把该失败报告为 `WebError`。就绪判断不是选择输入;`dsh-tool-web` 把它作为 `web_search` 与 `web_fetch` 的激活条件,因此路由回答 false 的模型步骤既不声明也不派发该工具。 +搜索或抓取提供方还可以实现 `ready(llmProvider): Promise`,这是只检查配置、禁止发起网络调用的判断:它回答为该对话路由上的模型请求发起的操作是否有可发送的凭据。`searchReady(llmProvider?)` 与 `fetchReady(llmProvider?)` 只询问 `search()` 或 `fetch()` 会选中的提供方,把没有 `ready()` 的提供方视为就绪,并在选择失败时回答 true,因为对应操作会把该失败报告为 `WebError`。就绪判断不是选择输入;`dsh-tool-web` 把它作为 `web_search` 与 `web_fetch` 的激活条件,因此路由回答 false 的模型步骤既不声明也不派发该工具。DeepSeek Search 与 Fetch API 提供方依据该路由在账号令牌与 API 密钥之间选择,见 [web-deepseek-api 鉴权](../../packages/web/web-deepseek-api/README.zh.md#authentication)一节。 选择从不依赖注册顺序、配置顺序或 HMR(热模块替换)顺序:一项能力要么有显式的提供方 id(配置 `searchProvider`/`fetchProvider`,或填充同一字段的对应环境变量),要么在恰好只有一个可用提供方注册时自动选择;如果存在多个可用提供方却未配置 id,则抛出 `WEB_PROVIDER_AMBIGUOUS`,而不会选用最先注册的提供方。每次操作在执行时读取配置的 id,因此保存的设置变更无需重新挂载 `ctx.web` 即对下一次搜索或抓取生效。 @@ -155,7 +155,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.web` — `WebRuntime` -The web access service. Registered as `ctx.web` (one instance per context). +The web access service. Registered as `ctx.web` (one instance per context) and exposed to Clients as the `web` Remote namespace (`listProviders`). Selection semantics (resolved at execution time, never order-dependent): @@ -168,23 +168,32 @@ Selection semantics (resolved at execution time, never order-dependent): ```ts cordis-catalog /** - * Register a search provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER` - * if its id is already registered for search. Returns a disposer; disposed - * with the calling fiber. + * Register a search provider. `listProviders()` lists it until the + * registration is disposed, which happens with the calling fiber or through + * the returned disposer; registration and disposal each emit + * `web/providers-changed`. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER` + * if its id is already registered for search. * @param provider - the provider; its `id` is the registry key. * @returns the disposer that unregisters the provider. */ registerSearchProvider(provider: WebSearchProvider): () => void /** - * Register a fetch provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER` - * if its id is already registered for fetch. Returns a disposer; disposed - * with the calling fiber. + * Register a fetch provider, with the listing, disposal, and event behavior + * of {@link WebRuntime.registerSearchProvider}. Throws {@link WebError} + * `WEB_DUPLICATE_PROVIDER` if its id is already registered for fetch. * @param provider - the provider; its `id` is the registry key. * @returns the disposer that unregisters the provider. */ registerFetchProvider(provider: WebFetchProvider): () => void +/** + * List every registered search and fetch provider for provider pickers. + * Availability does not filter the lists. + * @returns detached `{ id, title, description }` entries per capability, in registration order. + */ +@Remote listProviders(): WebProviderList + /** * Run one search through the selected provider. Resolves the provider at call * time with the selection rules above; throws {@link WebError} when the @@ -228,4 +237,28 @@ async fetchReady(llmProvider?: string): Promise ``` Source: [`packages/web/web/src/index.ts`](../../packages/web/web/src/index.ts) + + + +### `web/*` events + + + +#### `web/providers-changed` — emit + +The set of registered search or fetch providers changed: one provider registered, or its registration was disposed. Payload-free; consumers re-read `ctx.web.listProviders()` (Remote `web/listProviders`) for the new list. Fires after each committed registry mutation. Observer failures are contained and cannot veto the mutation. + +```ts cordis-catalog +/** + * The set of registered search or fetch providers changed: one provider + * registered, or its registration was disposed. Payload-free; consumers + * re-read `ctx.web.listProviders()` (Remote `web/listProviders`) for the + * new list. Fires after each committed registry mutation. Observer failures + * are contained and cannot veto the mutation. + * @mode emit + */ +'web/providers-changed'(): void +``` + +Source: [`packages/web/web/src/types.ts`](../../packages/web/web/src/types.ts) diff --git a/docs/subsystems/worktrees.i18n.yaml b/docs/subsystems/worktrees.i18n.yaml index f771dd3cf2..23dde9244e 100644 --- a/docs/subsystems/worktrees.i18n.yaml +++ b/docs/subsystems/worktrees.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/worktrees.md /git-worktrees: - en: 21ec4a3b825d7e81 - zh: c841c5ca4b4e8959 + en: 3a3d69d2b12a78f9 + zh: a7a7d12487968bbc /git-worktrees/creation-and-retention: en: 53f643c5d30e00b2 zh: ba9e52efc62c7656 diff --git a/docs/subsystems/worktrees.md b/docs/subsystems/worktrees.md index 8d4fec853a..aefa3e4a3c 100644 --- a/docs/subsystems/worktrees.md +++ b/docs/subsystems/worktrees.md @@ -2,7 +2,7 @@ English | [中文](worktrees.zh.md) -The experimental [`dsh-experimental-worktree`](../../packages/experimental/worktree/README.md) service provides `ctx.worktrees`. Its [`create_worktree`](../../packages/experimental/tool-worktree/README.md) consumer creates a new Git branch and checkout, then selects it through the [working-directory service](working-directory.md). The [Git Worktrees optional bundle](../../packages/experimental/tool-worktree/README.md#use-this-package) loads both packages through the GUI plugin manager. It ships switched off; explicit compositions can also mount the two packages. +The experimental [`dsh-experimental-worktree`](../../packages/experimental/worktree/README.md) service provides `ctx.worktrees`. Its [`create_worktree`](../../packages/experimental/tool-worktree/README.md) consumer creates a new Git branch and checkout, then selects it through the [working-directory service](working-directory.md). The [Change requests & worktrees bundle](../../packages/experimental/forge-bundle/README.md#use-this-package) loads both packages through the GUI plugin manager and ships switched off. Other profiles can select the [standalone worktree bundle](../../packages/experimental/tool-worktree/README.md#use-this-package) or mount the two packages explicitly. ## Creation and retention diff --git a/docs/subsystems/worktrees.zh.md b/docs/subsystems/worktrees.zh.md index e6a7c71ecc..0f7c7b1b02 100644 --- a/docs/subsystems/worktrees.zh.md +++ b/docs/subsystems/worktrees.zh.md @@ -2,7 +2,7 @@ [English](worktrees.md) | 中文 -实验性的 [`dsh-experimental-worktree`](../../packages/experimental/worktree/README.zh.md) 服务提供 `ctx.worktrees`。其消费方 [`create_worktree`](../../packages/experimental/tool-worktree/README.zh.md) 创建新的 Git 分支与检出目录,再通过[工作目录服务](working-directory.zh.md)选中它。[Git 工作树可选 bundle](../../packages/experimental/tool-worktree/README.zh.md#use-this-package)通过 GUI 插件管理器一起加载这两个包。它随安装提供,但默认关闭;显式组合也可以挂载这两个包。 +实验性的 [`dsh-experimental-worktree`](../../packages/experimental/worktree/README.zh.md) 服务提供 `ctx.worktrees`。其消费方 [`create_worktree`](../../packages/experimental/tool-worktree/README.zh.md) 创建新的 Git 分支与检出目录,再通过[工作目录服务](working-directory.zh.md)选中它。[变更请求与工作树组合包](../../packages/experimental/forge-bundle/README.zh.md#use-this-package)通过 GUI 插件管理器一起加载这两个包,随安装提供但默认关闭。其他 profile 可以选择[独立工作树组合包](../../packages/experimental/tool-worktree/README.zh.md#use-this-package),或显式挂载这两个包。 ## 创建与保留 diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index 4a45861506..879a5e290a 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.i18n.yaml @@ -6,8 +6,8 @@ en: 0d7399a58529b9eb zh: 7f445b06430f21a4 /testing-policy/tiers: - en: c024da7e8d9cb056 - zh: c4cc1a66d2903656 + en: ae5eb9cc384542cb + zh: 3473df2dc1e36bd0 /testing-policy/how-specs-execute: en: 022c534e72e23142 zh: c07a20994224f37c diff --git a/docs/testing.md b/docs/testing.md index 1ba63f9c35..9064aef93d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -8,7 +8,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Unit** (`pnpm run test`): vitest over package and example specs under their `tests/**` directories plus repository script specs under `scripts/**/*.spec.ts`; tests stay with the code area they exercise. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, concurrency races, and permanent tests for contract regressions (see `packages/core/agent-loop/tests/contract-regressions.spec.ts`). - **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate flags for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. Per-file 100% on `packages/shell/pwsh-local/src` needs a real `pwsh`: without one its executor suites self-skip and `vitest.config.ts` exempts the file so pwsh-less hosts stay green, while CI runners ship pwsh and enforce the full bar. -- **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). +- **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys; each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). - **Owner-local expected output** (`pnpm run test:expected`): keyless assembled CLI/process expectations without a recorded-session round trip. Drivers use `*.expected.e2e.ts` beside `tests/expected/`; CI runs built exports. Package/script expectations use `test`, while browser expectations use `test:web`. - **Performance benchmarks** (`pnpm run test:bench`; required Linux PR gate `node 24 / benchmarks`): `benchmarks/` groups user-path gates. It builds libraries and workers; timed code runs under plain Node, never TSX. Synthetic inputs enforce time, heap, and scaling budgets; package-local `.perf.ts` stays diagnostic ([rules](../.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md)). - **Snapshot** (`pnpm run test:snapshot`): a top-level scenario's highest recorded parent generation supplies user input and model replay, then serves as the expected persisted result. Parent filenames are `session[.vN].jsonl`; child roles are `session.[.vN].jsonl`; v0 omits `.v0`, positive versions require lowercase `.vN`, and each filename must agree with its header. Process scenarios start through `dsh`: headless owns one-shot behavior, the SDK owns persistent control, ACP owns automation-protocol behavior, and Web retains browser/ARIA evidence beside the same Session. `snapshot.yml` declares the profile, composition/header class, recording policy, exceptional replay or input metadata, and workspace facts. Typed tokens preserve parent/child identity relationships; only header pins own prompt/schema sidecars. A mutating scenario independently compares the complete `workspace.expected/` tree, which record and refresh never rewrite. Use `test:snapshot:record` when a model transcript changes and `test:snapshot:refresh` when replay input remains valid; review every resulting diff. diff --git a/docs/testing.zh.md b/docs/testing.zh.md index c7545a9686..f756cc9306 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -8,7 +8,7 @@ - **单元测试**(`pnpm run test`):vitest 运行包和示例各自的 `tests/**` 目录下的测试,以及匹配 `scripts/**/*.spec.ts` 的仓库脚本测试;测试文件与其所覆盖的代码区域放在一起。每个注册表都有一个 HMR(热模块替换)安全测试(对向该注册表贡献内容的 fiber 执行 dispose(资源释放),并断言清理完成)。优先覆盖边界情况、错误路径、事件顺序、并发竞态,以及针对约定回归的永久测试(见 `packages/core/agent-loop/tests/contract-regressions.spec.ts`)。 - **覆盖率门禁**(`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,但永远不是充分条件:它证明行被执行过,不证明功能按交付预期工作。`packages/shell/pwsh-local/src` 的按文件 100% 覆盖需要真实的 `pwsh`:缺少它时其执行器套件会自动跳过,`vitest.config.ts` 会豁免该文件以使无 pwsh 的主机保持绿色,而 CI runner 自带 pwsh,仍按完整标准执行门禁。 -- **真实 API e2e**(`pnpm run test:e2e`):带密钥测试调用真实提供方 API,包括 DeepSeek 模型以及各提供方特有的冒烟测试;这些测试各自由自己的密钥控制(`EXA_API_KEY`、`PERPLEXITY_API_KEY` 等),缺少密钥时套件会自动跳过,使 keyless CI 保持绿色([真实 API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md))。 +- **真实 API e2e**(`pnpm run test:e2e`):带密钥测试调用真实提供方 API,包括 DeepSeek 模型以及各提供方特有的冒烟测试;这些测试各自由自己的密钥控制,缺少密钥时套件会自动跳过,使 keyless CI 保持绿色([真实 API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md))。 - **所属位置的预期输出**(`pnpm run test:expected`):无录制会话往返的无密钥组装 CLI/进程预期。驱动使用 `*.expected.e2e.ts`,并与 `tests/expected/` 同属一处;CI 针对构建产物运行。包/脚本预期使用 `test`,浏览器预期使用 `test:web`。 - **性能基准**(`pnpm run test:bench`;必需的 Linux PR gate `node 24 / benchmarks`):`benchmarks/` 按用户路径组织门禁。它先构建 library 和 worker;被计时代码在纯 Node 下运行,不使用 TSX。合成输入执行耗时、堆和缩放预算;包内 `.perf.ts` 保留为诊断([规则](../.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md))。 - **快照**(`pnpm run test:snapshot`):顶层场景数值最高的已录制 parent generation 同时提供用户输入和模型回放,并作为持久化结果的预期值。parent 文件名是 `session[.vN].jsonl`;child 角色使用 `session.[.vN].jsonl`;v0 省略 `.v0`,正版本必须使用小写 `.vN`,且每个文件名必须与其 header 一致。进程级场景都通过 `dsh` 启动:headless 负责一次性行为,SDK 负责持久控制,ACP 负责自动化协议行为,Web 在同一 Session 旁保留浏览器与 ARIA 证据。`snapshot.yml` 声明 profile、组合与请求头类别、录制策略、例外回放或输入元数据以及 workspace 事实。带类型的 token 保留父子身份关系;只有请求头 pin 拥有 prompt/schema sidecar。变更 workspace 的场景会独立比较完整的 `workspace.expected/` 目录,record 与 refresh 绝不改写该目录。当模型 transcript(文本记录)变化时使用 `test:snapshot:record`,回放输入仍有效时使用 `test:snapshot:refresh`;请审查所有结果差异。 diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index b13947ae96..2c07846e9d 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -9,8 +9,8 @@ en: 6cc2cd9ced70bb09 zh: 1c0e6cb4ba2ed637 /tool-schema-catalog/tool-package-map: - en: 6e8419f195bc96a1 - zh: 564c4f04420d329a + en: 7e4516e824b1500e + zh: b0ea6f61792d98a0 /tool-schema-catalog/deepseek-ai-dsh-plugin-manager: en: e0143916bb634762 zh: e0143916bb634762 @@ -72,8 +72,8 @@ en: 8c8643ef46fd075f zh: 8c8643ef46fd075f /tool-schema-catalog/deepseek-ai-dsh-tool-executor-ptc/run-code: - en: a5d49232a604a2fb - zh: 720040d11a3e31c2 + en: 7917e16cbc2c366f + zh: c39eb0420544f04a /tool-schema-catalog/deepseek-ai-dsh-plan-mode: en: b500b530845e26a9 zh: b500b530845e26a9 @@ -264,8 +264,8 @@ en: b53326fedeab850d zh: ecb75b6ce31da009 /tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control/wait-agent: - en: f3cf5a0c56ddc07a - zh: 7f6754d71e1b4ae4 + en: 4b95d11b610d34ca + zh: 9b0d9b6012be30d8 /tool-schema-catalog/deepseek-ai-dsh-tool-jobs: en: 9be673b1604d0114 zh: 9be673b1604d0114 @@ -309,8 +309,8 @@ en: d5f5679abe86e316 zh: d5f5679abe86e316 /tool-schema-catalog/deepseek-ai-dsh-experimental-tool-worktree/create-worktree: - en: d8fe3c566ba2147c - zh: baf372cb9242670c + en: 41950f76db71e7f7 + zh: 654d2e5505ff3d3c /tool-schema-catalog/deepseek-ai-dsh-tool-workflow: en: bb38a9d29f46563c zh: bb38a9d29f46563c diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 3ecf64fa3b..c6a06cef4b 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -21,7 +21,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-experimental-browser-use-stagehand-native` | `stagehand_act`, `stagehand_extract`, `stagehand_navigate`, `stagehand_observe`, `stagehand_screenshot`, `stagehand_tabs` | `ctx.browserUse`, `ctx.agents`, `ctx.tools`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-action-cards` | `propose_action` | `ctx.tools`, `ctx.agents`, `ctx.sessionProjections`, `ctx.remoteAccess` | `tool/call`, `tool/result`, `plugin:action-cards/decision`, `user/message` | - | Declared only to an Agent that loaded the skill of a registered card kind; the kind packages register their actions, and the Personalized recommendations bundle supplies the shipped kinds. | | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userQuestions` | `tool/call`, `tool/result after an answer or timeout`, `late user/message` | - | ask_user_question keeps the original blocking behavior by default; set `mode: timed` to opt into a foreground timeout and pending result while the question remains answerable. In timed mode, `timeout: -1` keeps that call blocking indefinitely. | -| `@deepseek-ai/dsh-tool-executor-ptc` | `run_code` | `ctx.workingDirectory (Agent executions)`, `ctx.tools`, `ctx.ptcRuntime`, `ctx.systemPrompt` | `tool/call`, `one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`, `tool/result` | - | The PTC executor exposes only run_code to the model and declares other visible capabilities in a generated SDK in the runtime’s language. Program bindings enter the shared guarded tool pipeline in submission order; concurrency-safe bodies overlap up to maxParallelSubCalls, and nested results remain linked to the outer program. | +| `@deepseek-ai/dsh-tool-executor-ptc` | `run_code` | `ctx.workingDirectory (Agent executions)`, `ctx.tools`, `ctx.ptcRuntime`, `ctx.systemPrompt` | `tool/call`, `one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`, `tool/result` | - | The PTC executor exposes only run_code to the model and declares other visible capabilities in a generated SDK in the runtime’s language. Program bindings enter the shared guarded tool pipeline in submission order; concurrency-safe bodies overlap up to maxParallelSubCalls, and nested results remain linked to the outer program. The resolved timeout and maxSubCallsPerMinute set the total call allowance; exceeding it terminates the program. | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agents`, `ctx.userQuestions (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-questions seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.workingDirectory`, `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs for run_in_background and the job-backed foreground path` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. With a job registry composed every call registers with the generic `ctx.jobs` runtime as it starts, collected/stopped through the `job_*` tools from `@deepseek-ai/dsh-tool-jobs`; without one, or with `enableRunInBackground: false`, the tool registers a foreground-only schema without the `run_in_background` parameter. | | `@deepseek-ai/dsh-tool-present` | `present` | `ctx.workingDirectory`, `ctx.tools`, `ctx.fs`, `ctx.agents` | `tool/call`, `deliverables/presented after a successful final result`, `tool/result` | - | Deliveries belong to the calling Session; Web ui-deliverables supplies source-file opening and cards. | @@ -46,7 +46,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-agent-team` | `team_task_create`, `team_task_get`, `team_task_list`, `team_task_update` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agentTeams`, `ctx.subagents`, `an exact live Team member Agent` | `tool/call`, `team/task`, `tool/result` | - | Four task-board tools share the subagent tree’s root Session. Enabling Team mode on the root Session adds task-board guidance alongside standard subagent delegation and control tools. Standard subagent controls provide required identity independently of Team mode. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. `allowParallelInProgress` is required with no default, so the catalog states its choice: `true`, whose description invites several `in_progress` items. A deployment choosing `false` receives the same tool with a description asking for exactly one active task. | | `@deepseek-ai/dsh-tool-working-directory` | `working_directory` | `ctx.tools`, `ctx.workingDirectory` | `tool/call`, `working-directory/change`, `user context for directory changes`, `tool/result` | - | Reads or changes the calling Session directory. Existing shells and processes retain their own directories; origin metadata and permission roots stay fixed. | -| `@deepseek-ai/dsh-experimental-tool-worktree` | `create_worktree` | `ctx.tools`, `ctx.worktrees`, `a calling Agent` | `tool/call`, `Git branch and checkout`, `working-directory/change`, `tool/result` | - | Explicit experimental composition only. Creates a new branch and checkout from a pinned local commit under existing write permissions, then changes the Session directory. Leaving retains the branch and checkout. | +| `@deepseek-ai/dsh-experimental-tool-worktree` | `create_worktree` | `ctx.tools`, `ctx.worktrees`, `a calling Agent` | `tool/call`, `Git branch and checkout`, `working-directory/change`, `tool/result` | - | Enabled through the Change requests & worktrees bundle on Web and Desktop; other profiles can select the standalone bundle. Creates a new branch and checkout from a pinned local commit under existing write permissions, then changes the Session directory. Leaving retains the branch and checkout. | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflowEngine`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-workspace-dependencies` | `load_workspace_dependencies` | `ctx.tools` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-web` | `web_fetch`, `web_search` | `ctx.tools`, `ctx.web`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps. | @@ -661,7 +661,7 @@ Execute a TypeScript program against the available tools. Takes two required arg Source: [`packages/core/tool-executor-ptc/src/ptc.ts`](../packages/core/tool-executor-ptc/src/ptc.ts) -The PTC executor exposes only run_code to the model and declares other visible capabilities in a generated SDK in the runtime’s language. Program bindings enter the shared guarded tool pipeline in submission order; concurrency-safe bodies overlap up to maxParallelSubCalls, and nested results remain linked to the outer program. +The PTC executor exposes only run_code to the model and declares other visible capabilities in a generated SDK in the runtime’s language. Program bindings enter the shared guarded tool pipeline in submission order; concurrency-safe bodies overlap up to maxParallelSubCalls, and nested results remain linked to the outer program. The resolved timeout and maxSubCallsPerMinute set the total call allowance; exceeding it terminates the program. @@ -2157,7 +2157,7 @@ Source: [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/sub ### `wait_agent` -Wait for input at your next step, including agent messages, completion notifications, and user steering. Returns immediately when that input is already pending. +Wait for input at your next step, including agent messages, completion notifications, and user steering. Returns immediately if new input is pending. `timedOut: false` means new input is pending. Pending input is delivered to the model after code execution finishes. ```json { @@ -2521,7 +2521,7 @@ Create a new Git branch and worktree from a local commit, branch, or tag, then c Source: [`packages/experimental/tool-worktree/src/index.ts`](../packages/experimental/tool-worktree/src/index.ts) -Explicit experimental composition only. Creates a new branch and checkout from a pinned local commit under existing write permissions, then changes the Session directory. Leaving retains the branch and checkout. +Enabled through the Change requests & worktrees bundle on Web and Desktop; other profiles can select the standalone bundle. Creates a new branch and checkout from a pinned local commit under existing write permissions, then changes the Session directory. Leaving retains the branch and checkout. @@ -2670,7 +2670,7 @@ Search the web for current information. Returns an optional summary answer and a "properties": { "queries": { "type": "array", - "description": "1–4 search queries; their results are merged.", + "description": "1–10 search queries; their results are merged.", "items": { "type": "string" } diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 302e493cfd..ef5d06c5c1 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -25,7 +25,7 @@ | `@deepseek-ai/dsh-experimental-browser-use-stagehand-native` | `stagehand_act`、`stagehand_extract`、`stagehand_navigate`、`stagehand_observe`、`stagehand_screenshot`、`stagehand_tabs` | `ctx.browserUse`、`ctx.agents`、`ctx.tools`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | - | | `@deepseek-ai/dsh-action-cards` | `propose_action` | `ctx.tools`、`ctx.agents`、`ctx.sessionProjections`、`ctx.remoteAccess` | `tool/call`、`tool/result`、`plugin:action-cards/decision`、`user/message` | - | 只向加载过已注册卡片种类 Skill 的 Agent 声明;各种类的包注册自己的动作,个性化推荐 Bundle 提供随产品发布的种类。 | | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`、`ctx.userQuestions` | `tool/call`、`tool/result after an answer or timeout`、`late user/message` | - | ask_user_question 默认保持原有阻塞行为;设置 `mode: timed` 后才启用前台超时与 pending 结果,同时问题仍可回答;timed 模式内 `timeout: -1` 让本次调用无限期阻塞。 | -| `@deepseek-ai/dsh-tool-executor-ptc` | `run_code` | `ctx.workingDirectory (Agent executions)`、`ctx.tools`、`ctx.ptcRuntime`、`ctx.systemPrompt` | `tool/call`、`one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`、`tool/result` | - | PTC 执行器只向模型公开 run_code,并在运行时语言生成的 SDK 中声明其他可见能力。程序绑定按提交顺序进入共享的受守卫工具流水线;并行安全的函数体最多重叠 maxParallelSubCalls 个,嵌套结果保持与外层程序关联。 | +| `@deepseek-ai/dsh-tool-executor-ptc` | `run_code` | `ctx.workingDirectory (Agent executions)`、`ctx.tools`、`ctx.ptcRuntime`、`ctx.systemPrompt` | `tool/call`、`one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`、`tool/result` | - | PTC 执行器只向模型公开 run_code,并在运行时语言生成的 SDK 中声明其他可见能力。程序绑定按提交顺序进入共享的受守卫工具流水线;并行安全的函数体最多重叠 maxParallelSubCalls 个,嵌套结果保持与外层程序关联。最终超时时间与 maxSubCallsPerMinute 决定调用总额度;超限会终止程序。 | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`、`ctx.systemPrompt`、`ctx.agents`、`ctx.userQuestions (execution time, opportunistic)` | `tool/call`、`plan/mode inactive on an approved review`、`tool/result` | - | 规划未激活时,exit_plan_mode 仍保留在面向模型的 schema 中,这样状态转换不会在规划策略变更之外额外造成工具目录变动。其执行路径会拒绝规划模式之外的调用;在规划模式下,它通过用户交互 seam 提交计划(批准/根据反馈继续规划),批准后会在步骤边界记录规划模式已停用。 | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.workingDirectory`、`ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs for run_in_background and the job-backed foreground path` | `tool/call`、`tool/result` | - | bash 工具是 bash 执行器 seam 面向模型的消费方。组合中有 job 注册表时,每次调用一启动就注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具(来自 `@deepseek-ai/dsh-tool-jobs`)收集/停止;没有注册表或 `enableRunInBackground: false` 时,工具注册不带 `run_in_background` 参数的纯前台 schema。 | | `@deepseek-ai/dsh-tool-present` | `present` | `ctx.workingDirectory`、`ctx.tools`, `ctx.fs`, `ctx.agents` | `tool/call`, `deliverables/presented 在成功的最终结果之后`, `tool/result` | - | 交付归调用方 Session 所有;Web ui-deliverables 提供源文件打开与卡片。 | @@ -50,7 +50,7 @@ | `@deepseek-ai/dsh-tool-agent-team` | `team_task_create`, `team_task_get`, `team_task_list`, `team_task_update` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agentTeams`, `ctx.subagents`, `an exact live Team member Agent` | `tool/call`, `team/task`, `tool/result` | - | 四个任务板工具共享 subagent 树的根 Session。在根 Session 开启 Team 模式,会在标准 subagent 委派和控制工具之外增加任务板指导。标准 subagent 控制工具提供必需身份,不受 Team 模式影响。 | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`、`owning Agent session` | `tool/call`、`todo/write`、`tool/result` | - | todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为检查清单。`allowParallelInProgress` 是没有默认值的必填项,因此本目录明确选择 `true`,对应描述允许同时存在多个 `in_progress` 项。选择 `false` 的部署会获得同一工具,但描述会要求只能有 1 个活动任务。 | | `@deepseek-ai/dsh-tool-working-directory` | `working_directory` | `ctx.tools`, `ctx.workingDirectory` | `tool/call`, `working-directory/change`, `user context for directory changes`, `tool/result` | - | 读取或改变调用方 Session 的目录。已有 shell 和进程保留自己的目录;原始元数据和权限根目录保持不变。 | -| `@deepseek-ai/dsh-experimental-tool-worktree` | `create_worktree` | `ctx.tools`, `ctx.worktrees`, `a calling Agent` | `tool/call`, `Git branch and checkout`, `working-directory/change`, `tool/result` | - | 仅通过显式实验组合加载。在现有写权限下从固定的本地提交创建新分支与检出目录,再改变 Session 工作目录。离开时保留分支与检出目录。 | +| `@deepseek-ai/dsh-experimental-tool-worktree` | `create_worktree` | `ctx.tools`, `ctx.worktrees`, `a calling Agent` | `tool/call`, `Git branch and checkout`, `working-directory/change`, `tool/result` | - | 在 Web 和 Desktop 上通过变更请求与工作树组合包启用;其他 profile 可以选择独立组合包。在现有写权限下从固定的本地提交创建新分支与检出目录,再改变 Session 工作目录。离开时保留分支与检出目录。 | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`、`ctx.workflowEngine`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents the script children)` | `tool/call`、`tool/result` | - | - | | `@deepseek-ai/dsh-tool-workspace-dependencies` | `load_workspace_dependencies` | `ctx.tools` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-web` | `web_fetch`、`web_search` | `ctx.tools`、`ctx.web`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | web_search 和 web_fetch 将提供方选择置于 ctx.web 之后,使模型可见 schema 在更换后端时保持稳定。 | @@ -665,7 +665,7 @@ ask_user_question 默认保持原有阻塞行为;设置 `mode: timed` 后才 来源:[`packages/core/tool-executor-ptc/src/ptc.ts`](../packages/core/tool-executor-ptc/src/ptc.ts) -PTC 执行器只向模型公开 run_code,并在运行时语言生成的 SDK 中声明其他可见能力。程序绑定按提交顺序进入共享的受守卫工具流水线;并行安全的函数体最多重叠 maxParallelSubCalls 个,嵌套结果保持与外层程序关联。 +PTC 执行器只向模型公开 run_code,并在运行时语言生成的 SDK 中声明其他可见能力。程序绑定按提交顺序进入共享的受守卫工具流水线;并行安全的函数体最多重叠 maxParallelSubCalls 个,嵌套结果保持与外层程序关联。最终超时时间与 maxSubCallsPerMinute 决定调用总额度;超限会终止程序。 @@ -2163,7 +2163,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 ### `wait_agent` -等待下一步可处理的输入,包括 Agent 消息、完成通知和用户引导。已有此类待处理输入时立即返回。 +等待下一步可处理的输入,包括 Agent 消息、完成通知和用户引导。有新的待处理输入时立即返回。`timedOut: false` 表示有新输入待处理。待处理输入会在代码执行结束后交付给模型。 ```json { @@ -2527,7 +2527,7 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为 来源:[`packages/experimental/tool-worktree/src/index.ts`](../packages/experimental/tool-worktree/src/index.ts) -仅通过显式实验组合加载。在现有写权限下从固定的本地提交创建新分支与检出目录,再改变 Session 工作目录。离开时保留分支与检出目录。 +在 Web 和 Desktop 上通过变更请求与工作树组合包启用;其他 profile 可以选择独立组合包。在现有写权限下从固定的本地提交创建新分支与检出目录,再改变 Session 工作目录。离开时保留分支与检出目录。 @@ -2677,7 +2677,7 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为 "properties": { "queries": { "type": "array", - "description": "1–4 search queries; their results are merged.", + "description": "1–10 search queries; their results are merged.", "items": { "type": "string" } diff --git a/docs/ui-design-review-cases.i18n.yaml b/docs/ui-design-review-cases.i18n.yaml new file mode 100644 index 0000000000..3cec936eea --- /dev/null +++ b/docs/ui-design-review-cases.i18n.yaml @@ -0,0 +1,148 @@ +# Bilingual-pair consistency record for ui-design-review-cases.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/ui-design-review-cases.md +/: + en: 1da13fa1c536aa24 + zh: 52ef6218c8cea359 +/ui-design-principles-and-review-cases: + en: 4be954bceeeb7940 + zh: d91f6595cd3e85f0 +/ui-design-principles-and-review-cases/summary: + en: 75120a3fd02294c9 + zh: 498343d7b9b30e46 +/ui-design-principles-and-review-cases/table-of-contents: + en: c5680dfc600a7495 + zh: c76ec05c69f1e93c +/ui-design-principles-and-review-cases/what-a-review-must-judge: + en: eeff4a5f5e487908 + zh: 51eab38ccd10190b +/ui-design-principles-and-review-cases/review-the-entire-page-or-modal-as-one-unit: + en: 324c8d9a0ff36ebb + zh: 3a4c64e15b59f98f +/ui-design-principles-and-review-cases/review-the-entire-page-or-modal-as-one-unit/judge-the-whole-the-groups-and-the-elements: + en: 08450af012ed9ae9 + zh: 7776aad3791eee79 +/ui-design-principles-and-review-cases/review-the-entire-page-or-modal-as-one-unit/relate-each-finding-back-to-the-whole: + en: 40b5b26e5ff31ae8 + zh: 0fdb1fdef7243c35 +/ui-design-principles-and-review-cases/review-the-entire-page-or-modal-as-one-unit/use-a-relationship-inventory-to-establish-coverage: + en: 138392750105bf23 + zh: 2cf34065251d93df +/ui-design-principles-and-review-cases/combine-the-applicable-standards-first: + en: fd2d0f3433eb7e20 + zh: 2034723f45360e79 +/ui-design-principles-and-review-cases/choose-cases-that-expose-a-design-decision: + en: f8b0f72251aa044f + zh: a8b05ce5f3da4221 +/ui-design-principles-and-review-cases/principle-examples: + en: 7b8a8bdc5acc11d6 + zh: 96820c11d2e61787 +/ui-design-principles-and-review-cases/1-alignment: + en: 0cc9d24851a11497 + zh: f05bda4fcdf5deac +/ui-design-principles-and-review-cases/1-alignment/text-metrics-and-unequal-content: + en: 14558eb00e9b4902 + zh: 333b28dabfccbd6c +/ui-design-principles-and-review-cases/1-alignment/example-a1-role-based-sidebar-alignment: + en: 593b824ab3fc6474 + zh: 19692388cdf7da7d +/ui-design-principles-and-review-cases/1-alignment/example-a2-distinct-header-and-row-action-groups: + en: 81d165cc1c5c1f7e + zh: 4aef022e141ced64 +/ui-design-principles-and-review-cases/1-alignment/example-a3-alignment-between-rows-and-compound-controls: + en: 9e7fae1a4e649ffd + zh: c3992d7dbedbb247 +/ui-design-principles-and-review-cases/1-alignment/example-a4-centering-a-content-group-within-its-container: + en: 478771b47daf2cba + zh: 0eee64751cff6c94 +/ui-design-principles-and-review-cases/1-alignment/example-a5-visible-size-and-center-of-peer-controls: + en: 376359e2a1de44e9 + zh: 2a0b0e4472efefb7 +/ui-design-principles-and-review-cases/1-alignment/example-a6-text-starts-across-triggers-and-menus: + en: 1afbbce3f4908bf2 + zh: aaf54b32b90a2c82 +/ui-design-principles-and-review-cases/1-alignment/example-a7-choosing-the-close-control-edge: + en: c5e9a409c13637bc + zh: 37c915f187d6e9f7 +/ui-design-principles-and-review-cases/2-spatial-grouping: + en: 5a2aebab7e8ab498 + zh: 46a18d4ddde7fccc +/ui-design-principles-and-review-cases/2-spatial-grouping/example-g1-separating-content-from-its-action-group: + en: a4c0b1f1ad441155 + zh: 360ed71994a101a8 +/ui-design-principles-and-review-cases/2-spatial-grouping/example-g2-spacing-between-complex-sections: + en: 2585b4f2a35bda67 + zh: 59b6c9d987ee95bb +/ui-design-principles-and-review-cases/2-spatial-grouping/example-g3-nested-groups-along-the-reading-order: + en: 4d24915951f86d97 + zh: 4d6c348fdb7b78de +/ui-design-principles-and-review-cases/2-spatial-grouping/example-g3-nested-groups-along-the-reading-order/primer-equal-control-widths-do-not-fix-a-broken-reading-order: + en: 65f62d6b2d933d6e + zh: 3332fa1c82053c71 +/ui-design-principles-and-review-cases/2-spatial-grouping/example-g3-nested-groups-along-the-reading-order/carbon-different-distances-carry-different-meanings: + en: 9d7e04a0314ac527 + zh: fc654332df43d0c1 +/ui-design-principles-and-review-cases/2-spatial-grouping/example-g4-separating-groups-with-different-purposes: + en: 77bc7ec83d75ff29 + zh: caab9079ae3e416a +/ui-design-principles-and-review-cases/2-spatial-grouping/composed-whitespace-and-responsive-grouping: + en: 47f8ea920c78c5be + zh: e64ab054666c3727 +/ui-design-principles-and-review-cases/3-whole-page-density: + en: 10fb29eddaac1951 + zh: e05636b47e860966 +/ui-design-principles-and-review-cases/4-information-hierarchy: + en: 7e86e6ff5cd55742 + zh: 852a1bb0361134a6 +/ui-design-principles-and-review-cases/4-information-hierarchy/example-c1-color-emphasis-within-one-information-unit: + en: c17aea1873e9a1a6 + zh: dc7a8df8a60e582d +/ui-design-principles-and-review-cases/5-control-states: + en: d247699132b484a0 + zh: b71e25d834ce2633 +/ui-design-principles-and-review-cases/5-control-states/example-s1-persistent-selection-beside-temporary-hover: + en: 729e11936e471a67 + zh: d1da0c090e78efb5 +/ui-design-principles-and-review-cases/6-component-choice: + en: 7a49fdd5a601aa48 + zh: e2f9de0d5628a878 +/ui-design-principles-and-review-cases/7-icon-optics: + en: ff60eb03e9724e71 + zh: 8df625c33f3fc3aa +/ui-design-principles-and-review-cases/7-icon-optics/example-o1-optical-alignment-of-different-icon-types: + en: 3858a85bdf6ee111 + zh: 9c8242a594df069f +/ui-design-principles-and-review-cases/7-icon-optics/example-o2-shared-artwork-at-different-consumer-sizes: + en: 6f29eb80bfe3baf4 + zh: eb3161af01dd752e +/ui-design-principles-and-review-cases/8-control-geometry-and-corner-relationships: + en: 262cfa8e7dfb969f + zh: 4987ad06db795885 +/ui-design-principles-and-review-cases/8-control-geometry-and-corner-relationships/decide-geometry-in-this-order: + en: 26419c2cbae93712 + zh: 0f72032f89169b75 +/ui-design-principles-and-review-cases/8-control-geometry-and-corner-relationships/decide-geometry-in-this-order/fluent-the-split-button-notch-is-a-relationship-error: + en: ac1410636b2683ae + zh: c39f006a594cbc36 +/ui-design-principles-and-review-cases/8-control-geometry-and-corner-relationships/example-r1-radii-across-container-and-control-roles: + en: d7833e6828c48bec + zh: 66728e53ef3221dc +/ui-design-principles-and-review-cases/8-control-geometry-and-corner-relationships/example-r2-small-selection-controls-within-a-form: + en: e632805e838b1d3a + zh: 6bda70ade710d4c0 +/ui-design-principles-and-review-cases/8-control-geometry-and-corner-relationships/example-r3-justified-geometry-differences-between-roles: + en: 5137089b3644cfb5 + zh: e8b0da147f77a63e +/ui-design-principles-and-review-cases/reproduce-and-compare: + en: ec1ff65e6e15cea6 + zh: 9dc2344aadbbeea7 +/ui-design-principles-and-review-cases/external-references-and-what-dsh-adopts: + en: 274dcca699c277ed + zh: ecf31d4082bbd309 +/ui-design-principles-and-review-cases/external-references-and-what-dsh-adopts/codex-a-limited-reference-for-composition: + en: 38e0bfd3e40da01d + zh: 87aa406f9138be3e +/ui-design-principles-and-review-cases/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/docs/ui-design-review-cases.md b/docs/ui-design-review-cases.md new file mode 100644 index 0000000000..3697c66cdf --- /dev/null +++ b/docs/ui-design-review-cases.md @@ -0,0 +1,445 @@ +--- +description: "Design judgment for DSH: alignment, spatial grouping, hierarchy, states, geometry, and worked review cases." +--- + +# UI design principles and review cases + +English | [中文](ui-design-review-cases.zh.md) + +## Summary + +Consult the relevant cases when a design relationship is unclear. Ordinary changes inspect affected relationships in page/modal context; they do not require every case, an inventory or a full-page audit. The whole-interface review sections apply when a comprehensive review is explicitly requested. Examples explain transferable principles rather than coverage. See the [review cookbook](cookbook/reviewing-ui-design.md) for optional detailed methods and the owning workflow for effort levels. + +## Table of Contents + +- [What a review must judge](#judgment) +- [Review the entire page or modal as one unit](#whole-interface-review) +- [Combine the applicable standards first](#review-baseline) +- [Choose cases that expose a design decision](#case-selection) +- [Principle examples](#case-index) +- [1. Alignment](#alignment) +- [2. Spatial grouping](#grouping) +- [3. Whole-page density](#density) +- [4. Information hierarchy](#hierarchy) +- [5. Control states](#states) +- [6. Component choice](#component-choice) +- [7. Icon optics](#icon-optics) +- [8. Control geometry and corner relationships](#geometry) +- [Reproduce and compare](#reproduce) +- [External references and what DSH adopts](#references) +- [Dev Note](#dev-note) + + +## What a review must judge + +A well-composed interface makes relationships visible before the reader studies individual labels. Alignment lets people compare parallel items without repeatedly searching for their starting points. Spacing tells them which icon, label, description, control, and action belong together. These are primary design requirements, including for a fully functional screen; they are not optional polishing after token checks pass. + +DSH prioritizes alignment and spatial grouping in visual review. First identify the task and semantic groups, then inspect shared axes across the whole region, then check each group's internal relationships. Follow with hierarchy, state, geometry, color, density, and optical detail. A blocked action or misleading state still takes priority over cosmetic work. Do not make every element equally large, equally spaced, or equally prominent in pursuit of consistency. + +The page or modal provides the reference for composition; the [impact scope](cookbook/reviewing-ui-design.md#review-scope) determines which relationships require inspection for a change. Navigation, content, forms and actions may come from different components while sharing text axes and spacing rules. Explain exceptions through role, hierarchy or state; component ownership alone is not a design reason. + +The reference diagrams explain relationships; they are not implementation captures or accepted before-and-after results. The examples below explain particular principles and do not establish coverage of the page or modal under review. + + +## Review the entire page or modal as one unit + +Treat the entire page or modal as one visual unit when choosing references and judging relationships. A routine change reviews its affected groups and cross-group relationships in that context; it does not automatically audit every region or the whole product. An explicit whole-interface review must inspect all its regions. A sidebar, form or toolbar remains part of the larger composition even when owned by a separate component. + +For a modal, include its title, close control, all content sections, scrolling area and footer actions. For a page, include its navigation, header, toolbars, content and persistent actions. Content below the fold remains part of the same unit. Follow the page or modal's own edges when choosing alignment and centering references; an open modal has its own composition above the underlying page. + +### Judge the whole, the groups and the elements + +First read the whole composition: content edges, visual center, reading order, primary action and overall density. Then identify groups and subgroups from what they describe or operate on. Compare neighboring groups, followed by the elements within them. Repeated relationships must remain consistent across the entire page or modal, including distant sections. + +| Aspect | Compare throughout the page or modal | Basis for judgment | +|---|---|---| +| Alignment | Text starts, icon centers, control edges, repeated action columns | Choose left, center or two-sided alignment by role; continue shared axes across groups; judge an icon and label together | +| Spatial grouping | Label-to-content, item-to-item, group-to-group and section-to-section distances | Internal gaps are smaller than external gaps; more complex groups need greater separation; repeated group structures reuse internal gaps | +| Size and proportion | Same-role controls, icon backgrounds, icons and adjacent text | Use consistent size variants and optical balance; adjusting size can support shared alignment | +| Corner geometry | Containers, cards, fields, buttons and selection controls | Apply one role-based radius system throughout; distinguish inset, flush and independent relationships using the existing radius standard | +| Color and emphasis | Text, associated icons, backgrounds, borders and secondary information | Match the emphasis of an icon and label that communicate together; explain role or state differences | +| Typography and density | Headings, labels, descriptions, repeated rows and overall whitespace | Keep equal-level content consistent and make hierarchy readable; local spacing must fit the whole composition | +| States and behavior | Selected, hovered, focused, disabled, expanded, loading and failed content | State changes preserve recognizable groups, alignment and action access; compare states that appear together | + +Consistency means applying the same reasoning throughout this unit. Elements with the same role and size variant follow the same treatment; differences follow from role, hierarchy or state. Being implemented by a different component does not justify a different text start, spacing pattern or radius. Likewise, a centered title and a left-aligned reading column can coexist when their roles support that composition. + +### Relate each finding back to the whole + +Record which elements conflict, which relationship they should share, and how the correction fits the rest of the page or modal. Compare every occurrence of that relationship within the review scope. After a local adjustment, return to the whole composition and check affected axes, grouping, geometry and hierarchy together. + +A full review covers all regions in scope, including relevant scroll positions, expanded content and tabs, and states that change their relationships. Inspect supported widths, themes and input methods where applicable. Preserve drafts, focus, dismissal and primary-action access; show failures with the appropriate recovery. Report uninspected regions and states explicitly. One clean crop, component fixture or selected example cannot establish that the whole page or modal is consistent. + +### Use a relationship inventory to establish coverage + +List the semantic groups in the assembled interface, then the relationships to compare: parent-child indentation, text columns across sections, repeated action slots, headings and descriptions, fields and validation, prose and inline controls, and scrolling content versus fixed actions. Inspect more than group interiors; every cross-group relationship needs two actually inspected endpoints. When changing a shared primitive, include consumers with and without descriptions, unequal content, and different action combinations. + +A relationship passes only when its structure uses one reference, its rendered appearance communicates that relationship, and relevant content or state changes preserve it. An existing assertion, shared token, successful capture, or isolated crop cannot replace these three requirements. Prioritize by misleading effect, frequency, and reach; a difference of only a few pixels does not excuse repeated misalignment. + +Record inspected without additional findings, fixed and rechecked, found but unresolved, and uninspected separately. Declare platform and exceptional-state limits where exhaustive coverage is unavailable; example counts, component-test counts, and page-entry counts do not establish that every design passes. + + +## Combine the applicable standards first + +Use the [rule-owner map](../.agents/skills/dsh-client-ui-ux/SKILL.md#select-the-rule-owners) to select current implementation guidance, including alignment, spacing and icons. + +A review has two independent obligations: honor the applicable DSH rules and explain whether the assembled result communicates the intended relationships. A convincing silhouette does not excuse the wrong control semantics; legal tokens do not excuse bad alignment. For a scoped review, mark unrelated areas as out of scope. For a whole-interface review, apply the relevant rows below across all regions of the page or modal under review. + +| Owner | What it contributes to the review | What to compare across the page or modal | +|---|---|---| +| [Radius standard](ui-radius.md#radius-scale), [nested geometry](ui-radius.md#nested-hover) | Role before size; inset, flush-fill, and independent-child arrangements; curve and documented exceptions | Outer surface, field, hover fill, indicator and clipped edges together | +| [Web styling](web-styling.md#component-rules) | Semantic colors, typography pairs, hairlines, elevation, menu material and motion | Same-role surfaces across features, both themes, native Desktop backdrop | +| [Client UI/UX skill](../.agents/skills/dsh-client-ui-ux/SKILL.md) | Component reuse, hierarchy, loading, feedback ownership and window clearance | Loaded/empty/error scenes and the actual surrounding page | +| [Responsive skill](../.agents/skills/dsh-responsive-ui/SKILL.md) | Space allocation before truncation, input capabilities, focus and primary-action access | Wide, constrained, keyboard and touch use of the same task | +| [Official artwork skill](../.agents/skills/dsh-official-plugin-artwork/SKILL.md) | Package artwork semantics, optical balance, transparent regions and neighboring references | Plugin artwork in list and detail contexts; action icons remain a separate family | +| [Error-handling skill](../.agents/skills/dsh-error-handling/SKILL.md#localized-actionable-visible-reports) | Visible failure, retained valid state and a safe next action | Field correction, failed operation and failed query as distinct situations | +| [Primitives catalog](../packages/client/ui-primitives/README.md#component-catalog) and the relevant package README | Existing variants, focus/interaction behavior and feature-specific requirements | Intended component role versus a visually similar substitute | + +The current repository owner and its explicitly recorded exceptions take precedence over an older personal skill or external example. A user choice applies to its named family; it does not silently replace the global system. If implementation disagrees with its stated owner, report that discrepancy instead of treating the existing code as a new rule. Re-read the owner when selecting a radius or material rather than copying a scale into the review. + + +## Choose cases that expose a design decision + +An example makes a principle observable through a task, related elements, surrounding context and a condition that can challenge the correction. Extract the relationship and apply it throughout the page or modal being reviewed. A sidebar or model-editor example does not restrict that principle to those components. Examples are explanatory evidence, not a review menu or coverage list. + +Distinguish three kinds of evidence. An actual DSH scene establishes a defect in the displayed conditions. An official design-system example teaches a particular relationship; its dimensions and interaction policy remain that system's. An original diagram explains a proposed relation and must not be labeled an implemented improvement. A fixture only covers its rendered conditions. Keep source, compared objects, applied DSH rule and retest conditions adjacent to the case. + +Present each case as scene and problem, design judgment, then its screenshot or paired comparison immediately below. Add a caption naming what to inspect, what each side means, and the source. Do not collect unrelated cases into a detached screenshot wall. If the difference is only faintly visible or the image omits the relationship, choose stronger evidence instead of presenting it as a design exemplar. + + +## Principle examples + +Use these examples to understand a relationship that is unclear. Apply the same principles to every relevant region of the current page or modal, including regions not illustrated here. Review scope comes from the requested interface, not from this example list. + +| Case | Relationship to inspect | +|---|---| +| [A1](#case-sidebar-axes) | Role-based sidebar alignment | +| [A2](#case-action-columns) | Distinct header and row action groups | +| [A3](#case-preview-toolbar) | Alignment between rows and compound controls | +| [A4](#case-empty-center) | Centering a content group within its container | +| [A5](#case-composer-controls) | Visible size and center of peer controls | +| [A6](#case-trigger-menu-text) | Text starts across triggers and menus | +| [A7](#case-close-control-edge) | Choosing the close-control edge | +| [G1](#case-title-actions) | Separating content from its action group | +| [G2](#case-team-sections) | Spacing between complex sections | +| [G3](#case-plugin-groups) | Nested groups along the reading order | +| [G4](#case-sidebar-groups) | Separating groups with different purposes | +| [C1](#case-search-color) | Color emphasis within one information unit | +| [S1](#case-sidebar-state) | Persistent selection beside temporary hover | +| [O1](#case-file-optics) | Optical alignment of different icon types | +| [O2](#case-chevron-consumers) | Shared artwork at different consumer sizes | +| [R1](#case-model-radius) | Radii across container and control roles | +| [R2](#case-checkbox-radius) | Small selection controls within a form | +| [R3](#case-automation-radius) | Justified geometry differences between roles | + + +## 1. Alignment + +The [alignment reference](../.agents/skills/dsh-client-ui-ux/references/alignment.md) owns shared axes, complete layout boxes, indentation and text metrics. Use the examples below to apply those decisions. + +![Alignment diagram: peer rows share axes while account and header controls retain their own geometry](assets/ui-design-review/principles-alignment.svg) + +The schematic distinguishes peer-row axes from account and header geometry; it is not a pixel specification. Compare content, painted backgrounds and hit targets separately; different roles need not share every axis. + +### Text metrics and unequal content + +An inline icon refers to adjacent text, not the center of the entire paragraph line box. Cap height can calibrate a particular font combination, but is not a universal formula for every font and icon; do not copy one successful offset into every control. A status icon beside multiline guidance usually attaches to the first line, while a single-line button centers its icon and label together. Recheck that relationship after changes to font size, fallback, or language. + +The comparison below uses actual Markdown rendering, before on the left and after on the right, with annotations only on the right. CJK, Latin, and code links retain icon dimensions while font metrics correct vertical placement; external and internal gaps separately establish link grouping. Inspect wrapping in the complete paragraph for long links; this crop cannot establish their usability. + +![Actual inline-link comparison: align icons with adjacent font metrics and retain internal whitespace](assets/ui-design-review/inline-metrics-comparison.png) + +Peer cards jointly establish heading starts, description first lines, and action edges. A short description must not independently center vertically because its neighbor is longer; place remaining height after the content group, while bottom actions may align separately. Equal card height does not establish a consistent reading order or require truncating real content to force equal heights. + +The comparison below shows actual preset cards with unequal descriptions. On the left, automatic margins detach the short description from its heading; on the right, descriptions start together below their headings and actions remain aligned. The relationship also applies to result cards, choice cards, and parallel summaries; it does not require every page to use cards. + +![Actual peer-card comparison: align description first lines while retaining bottom action alignment](assets/ui-design-review/peer-content-comparison.png) + + +### Example A1: Role-based sidebar alignment + +**Scene and relationship.** Compare navigation, workspace rows, conversations and the account area in the expanded sidebar. Include the centered New conversation action and the painted row edges, not only text starts. + +**Failure and correction.** Applying one leading-slot width to every role can move titles, enlarge spacing or distort the signed-in account row. Preserve the owning shared variables, compact icon slots and paint insets. Retain deliberate account geometry and hierarchy instead of manufacturing one text axis across all roles. + +**Acceptance.** Use guides to compare same-role rows. Check short and long titles, optional icons, project expansion and signed-in/out account states. Revealing row actions must not move the title start. A list crop does not establish whole-sidebar alignment. + + +### Example A2: Distinct header and row action groups + +**Scene and relationship.** Workspace search/filter/add and conversation more/archive/pin occupy the trailing sidebar region but have different roles and spacing. Compare repeated rows with each other; compare the header with its own collapsed and expanded search states. + +**Failure and correction.** Choose control sizes, gaps and trailing insets by group role, including during search expansion. Keep the title-to-action separation independent of the gaps inside a cluster. + +**Acceptance.** Show the header and row actions together, then expand search and collapse the sidebar. Verify the intended edge relationships, unclipped hit targets and long-title clearance. Do not add empty actions or offsets merely to make unrelated controls form vertical columns. + + +### Example A3: Alignment between rows and compound controls + +**Scene and relationship.** Panel controls sit above file-display controls and an “Open in app” split button. The two rows belong to one preview header, while each row contains operations with different scopes. + +**Failure and correction.** Each component may be internally centered while the assembled header has unrelated right insets, heights, and group gaps. Relate the upper icon boxes to the complete lower control, preserving internal icon whitespace rather than aligning extra hit padding or an individual glyph’s painted pixels. Align peer control centers; keep display controls together and separate the external-open action. Treat the split button as one unit. Reducing its outer width can equalize visible gaps while retaining the preceding icon columns; verify both segment targets and the frame’s contribution to layout. Do not force its arrow onto a column intended for an unrelated panel action. + +**Acceptance.** Inspect both rows with the path, tabs, and content edge visible. Repeat with a long path and a narrow pane. A row of standalone icons is not a substitute for the actual split control. + + +### Example A4: Centering a content group within its container + +**Scene and relationship.** The automation page has a heading and filters above an illustration, explanation, and create action. The empty-state group belongs in the available content region, with one shared horizontal center and a deliberate vertical position. + +**Failure and correction.** Horizontally centering each child while giving the parent fixed top padding leaves the whole group stranded near the top of a tall viewport. Let the content region occupy the remaining height, then position the complete group. Review its internal rhythm too; moving only the button does not center the group. + +**Acceptance.** Mark the remaining content rectangle and the bounds of the complete stack. Compare tall, short, and narrow windows with the same content. Distinguish a deliberate, documented optical offset from accidental fixed spacing; do not demand window centering when a sidebar or page toolbar changes the usable area. + + +### Example A5: Visible size and center of peer controls + +**Scene and relationship.** Attachment and send actions anchor the same bottom toolbar. DSH's intended treatment uses equal circular bases and a shared vertical center; their different importance is expressed through fill and state. + +**Failure and correction.** Different base diameters plus an extra vertical translation create two unrelated anchors. Normalize the peer controls' outer geometry and remove layout compensation, then balance the plus and arrow within their slots. A thin plus and a solid arrow need not have equal painted area. + +**Acceptance.** Compare both bases and centers in empty, enabled, busy/stop, and narrow layouts. Review the toolbar alongside the composer outline, not as two isolated button screenshots. A reference product's unequal controls do not override an explicit DSH requirement for equal bases. + + +### Example A6: Text starts across triggers and menus + +**Scene and relationship.** A permission trigger uses a larger, heavier icon than its menu rows, but their corresponding labels should start on one axis. A search prompt, result heading and option label can share a similar reading edge. + +**Correction and acceptance.** Sum each surface’s border, padding, leading slot and gap before adjusting it. Matching only icon sizes or outer card edges leaves different text starts. Check the assembled open menu, longer labels and narrow layouts; distinguish device-pixel rounding of portal coordinates from a whole inset mismatch. + + +### Example A7: Choosing the close-control edge + +**Scene and relationship.** A modal has a close glyph inside a larger button and a footer action at the content edge. An icon-box edge and a button edge imply different header insets. + +**Correction and acceptance.** Name the intended edge before changing the shared header. If the full button aligns with the footer action, retain the icon’s internal centering instead of pulling it to that edge. An explicitly icon-aligned composition remains a distinct variant. Check short and wrapping titles, description/body combinations and footer actions, and preserve the target size. + + +## 2. Spatial grouping + +The [spacing reference](../.agents/skills/dsh-client-ui-ux/references/spacing.md) owns semantic groups, internal/external gap relationships and visible whitespace. The cases below explain how to apply it. + +![Grouping diagram: equally spaced blocks versus a nested section with smaller inner gaps and larger section separation](assets/ui-design-review/principles-grouping.svg) + +G is the separation from the previous section to the new heading; g connects the heading to its content. Repeated member cards retain the same internal arrangement and gap. Its gap markers describe relationships in one construction, not new global spacing tokens. The crucial relation survives when the page becomes denser: keep the hierarchy between levels rather than subtracting the same amount from every gap. + + +### Example G1: Separating content from its action group + +**Scene and relationship.** The title is one group and the three actions are another. Inside the action cluster, the icons are peers. A title-to-action gap smaller than the icon-to-icon gaps visually attaches the title to the first icon while separating that icon from its peers. + +**Correction and acceptance.** Reserve the cluster as a unit, use compact regular internal gaps, and leave a distinctly larger title-to-cluster gap. Inspect the visible title ending, not only its flex box. Repeat with a long title, hover, focus, and an open menu; truncation or marquee behavior must not consume the separation. + + +### Example G2: Spacing between complex sections + +**Scene and relationship.** Member cards form a section; the following “Shared tasks” heading belongs to the task cards below it. The transition must be more evident than the separation within either section, especially when the last member row is only partly filled. + +**Correction and acceptance.** Treat each heading and its content as one section and give the two sections explicit separation. Compare the last member card → task heading gap with the heading → first task gap, using the actual text and card edges. A 2:1 CSS margin ratio is not a substitute for looking. Check one and several member rows, wrapping task text, and the empty-task variant. + + +### Example G3: Nested groups along the reading order + +**Scene and relationship.** The page heading and introduction form one group; search serves the catalog; a subsection heading and its description introduce that subsection's cards. Reusing the same vertical gap at every transition flattens three levels into one sequence. + +**Correction and acceptance.** Keep each heading close to its own explanation, separate page introduction from catalog tools, and keep each subsection closer to its cards than to its neighbor. Repeated subsections reuse this rhythm. Inspect the full stack with a wrapped introduction and expanded cards; do not solve the problem by placing a border around every paragraph. + +#### Primer: equal control widths do not fix a broken reading order + +The [Forms structure comparison](https://primer.style/product/ui-patterns/forms/#structure) contrasts one vertical sequence with a shorter two-column arrangement. In the latter, a long option-group label wraps while unrelated fields start a second reading path. Inspect the sequence and ownership of groups, not just matching input edges. + +| Primer's positive example | Primer's counterexample | +|---|---| +| [![Sequential form controls in one reading column](https://user-images.githubusercontent.com/2313998/170069921-98bb9035-2125-4807-b790-1da90e6bde3b.png)](https://primer.style/product/ui-patterns/forms/#structure) | [![The same form split across columns to save vertical space](https://user-images.githubusercontent.com/2313998/170069922-ed8a0ef7-0573-4f02-bd15-da6a5acbc527.png)](https://primer.style/product/ui-patterns/forms/#structure) | + +Images: GitHub Primer, linked from their original sources. Compare the two reading paths and the wrapping option-group label. Apply that grouping judgment to forms throughout the page or modal, including long labels and errors. Related values can share a row when the relation is clear and the pair can stack. These are external examples, not DSH implementation results. + +#### Carbon: different distances carry different meanings + +The [Form specifications](https://www.carbondesignsystem.com/building-blocks/core/components/form/specifications) include an [annotated complete form](https://s3.us-south.cloud-object-storage.appdomain.cloud/cxp-prod-payload-cms/form-style-1.png). Read its title/description, field rows, option group and submit area in order. The example separates the introductory area, repeated fields and final action with different spacing levels while maintaining a common content edge; the annotations make the hierarchy inspectable. + +Use that comparison to review DSH's plugin introduction, search, section description and cards, or a model editor's final action. Compare which elements belong together after adding a line of text. Carbon's numerical scale is evidence for its own example, not a new DSH spacing catalog. A DSH result must also satisfy its existing density, font and responsive rules. + +[![Carbon form anatomy: a shared content edge with distinct title, field and submit spacing](https://s3.us-south.cloud-object-storage.appdomain.cloud/cxp-prod-payload-cms/form-style-1.png)](https://www.carbondesignsystem.com/building-blocks/core/components/form/specifications) + +Image: IBM Carbon Design System. Follow the annotations between the introduction, repeated fields and final action. The image demonstrates its own spacing system; it does not prescribe DSH pixel values. + + +### Example G4: Separating groups with different purposes + +**Scene and relationship.** “New conversation” is the main action; plugins/automations/more are a global navigation group; the workspace heading introduces the project list. Equal row heights do not express these different relationships. + +**Correction and acceptance.** Keep the navigation rows together, separate that group from the workspace section, and bring the workspace heading closer to the projects it labels. The main action has its own separation. Review the full sidebar with the account area visible; compare the role-specific relationships from A1. A larger gap before every project would falsely turn each item into a separate section. + +### Composed whitespace and responsive grouping + +Identify the layer responsible for group separation, then account for the assembled result: parent gap, child margins, native paragraph margins, heading padding, line-box whitespace, and conditional nodes can accumulate. Compare visible internal and external distances at the same hierarchy level; do not mechanically compare every large internal space against an unrelated outer gap. Initial-content spacing may differ when a heading has a description, but both structures need clear ownership. + +Shared containers should provide stable heading, description, body, and action arrangements that consumers reuse. Do not spread negative margins through consumers to cancel container defaults. Express a concrete role or variant when a different distance is needed, then recheck other shared-container combinations; a tighter local arrangement does not imply compressing every dialog. + +The comparison below shows an actual voice setup dialog. On the left, body and native paragraph margins accumulate and detach guidance from the heading; on the right, the shared description region keeps heading and guidance together while separating actions. This illustrates judging composed relationships, not adopting this dialog's distances everywhere. + +![Actual dialog comparison: remove accumulated margins and distinguish introductory content from actions](assets/ui-design-review/modal-grouping-comparison.png) + +Reflow complete semantic groups first in narrow containers. If an input squeezes its guidance into a very narrow text column, keep label and guidance together and move that field's input below them; do not shrink every font or move guidance into a neighboring group. Verify actual component width and both sides of the breakpoint, not only browser width. Container queries must work in the assembled application; selectors, CSS module names, or ancestor structure can prevent a plausible declaration from applying. + + +## 3. Whole-page density + +Allocate available space to the task's content and controls instead of reducing every gap or font size. + +Density concerns how much useful work fits in the available space and how clearly it is organized. Compact repeatable rows can coexist with generous section gaps. Start with the reading and operating task, not an empty-space percentage. A long list benefits from stable row rhythm; a decision form needs its explanations and validation; an empty page needs a coherent central group rather than the same padding as a populated list. + +When width decreases, preserve semantic groups before shortening labels or moving actions. Wrap complete groups and keep their internal spacing patterns. When height increases, decide which region should expand; fixed gaps alone do not place an empty state. Inspect both excessive compression and excessive separation. A dense page is acceptable when comparison remains easy; a sparse page can still have poor grouping. + + +## 4. Information hierarchy + +Make the next reading or decision step visible without turning every text element into a heading. + +Use emphasis to express a task-relevant difference. A current selection, an actionable error, and the data needed for a decision deserve stable recognition. Labels and supporting metadata can be quieter, but making useful text difficult to read is not hierarchy. Inspect size, weight, color, position, and surrounding whitespace together; piling all five onto every heading removes meaningful contrast. + +Use the [icon owner](../.agents/skills/dsh-client-ui-ux/references/icons.md) for icon/label foreground roles and their semantic exceptions; example C1 applies that rule to search fields. + +Typography participates in geometry: compare text starts, baselines and line boxes with the intended font, long translations and relevant font preferences. Pair size with line height and use the weight limits from [visual foundation](../.agents/skills/dsh-client-ui-ux/SKILL.md#visual-foundation). Content, code, terminal and fixed interface roles need not scale identically; check the [font-setting owner](../packages/client/ui-theme/README.md) before treating a deliberate difference as a defect. Correct the container allocation before shrinking useful text to force alignment. + + +### Example C1: Color emphasis within one information unit + +**Scene and relationship.** Compare the empty automation search with the expanded sidebar search. Both pair a magnifier with a prompt, but one makes the icon darker and the other makes the text darker. + +**Correction and acceptance.** Give the empty-field prompt one foreground role, including the leading icon. Compare computed color, opacity, disabled styling, and the background together; two identical color tokens can still differ after an ancestor fades one child. Test empty, typed, focused, disabled, light, and dark variants. Typed query text and an independently actionable clear button need not use the placeholder's weaker role. A submenu value and its trailing chevron also form one value: a muted model name with a darker chevron fails that relationship even when their geometry is correct. + + +## 5. Control states + +Persistent state and transient feedback must remain distinguishable when they occur together. + +State design assigns stable meanings, not just different colors. Selection says “this is the current item”; hover says “the pointer is here”; focus says “keyboard input goes here”; disabled says “this action is unavailable.” Compare simultaneous states and adjacent items: the selected row beside a hovered unselected row matters as much as selected-plus-hover on one row. + +| Combination | What must remain legible | Failure to look for | +|---|---|---| +| Selected + another row hovered | Current location remains recognizable | The temporary row looks more selected than the current row | +| Selected + hover on the same item | Selection persists while interaction feedback appears | A generic hover fill erases the current state | +| Selected + keyboard focus | Location and keyboard target remain distinguishable | Focus disappears into the selection treatment | +| Selected + disabled | Retained choice and temporary unavailability both remain visible | Fading the item makes it look deselected | +| Expanded + menu open | The active operation has a clear owner | A panel highlight and row highlight compete without a stable relationship | + + +### Example S1: Persistent selection beside temporary hover + +**Scene and relationship.** Compare the selected conversation, a neighboring hovered row and an open row menu. Background paint is one cue alongside the current-row semantics and revealed actions; selection and hover do not require different colors. + +**Correction and acceptance.** Verify that selection persists when hover ends or a menu closes. Move between neighboring rows and traverse by keyboard; inspect focus, action visibility and current-row semantics together. Compare the same theme and platform material; an opaque fixture cannot establish translucent Desktop contrast. + + +## 6. Component choice + +Choose the component from the interaction it represents, then fit its layout to the available space. + +Choose the element by what users can do, then make peers visually coherent. Navigation opens a destination, a toggle changes a setting, a checkbox adds an independent choice, and an action button starts an operation. Similar geometry does not make these interchangeable. A card adds a containing group and more space; it is justified when that group needs an independent summary, state, or action, not merely because a row looks plain. + +Keep the control's label, visible bounds, focus behavior, and click target consistent with the chosen interaction. If a visual correction changes those semantics, it needs behavior review as well. Geometry and radius are judged separately in [section 8](#geometry); “native control” is neither a guarantee of visual fit nor a reason to discard keyboard and accessibility behavior. + + +## 7. Icon optics + +The [icon reference](../.agents/skills/dsh-client-ui-ux/references/icons.md) owns interface icon roles, slots, color and optical balance. [Official artwork](../.agents/skills/dsh-official-plugin-artwork/SKILL.md) retains its separate role. + + +### Example O1: Optical alignment of different icon types + +**Scene and relationship.** At one tree depth, compare closed/open folders, native file-type icons, generic files, and names without icons. Then compare the same pattern one level deeper. + +**Correction and acceptance.** Preserve the depth indent and common text start. Normalize the placement slots, then compare visible centers and perceived weight within them. Do not move the label to compensate for one glyph, or align all depths to one edge. Inspect at normal size and magnification, with selected backgrounds and long names; the magnified view diagnoses the drawing, while normal size determines whether the result balances. + + +### Example O2: Shared artwork at different consumer sizes + +**Scene and relationship.** A chevron appears beside a menu label and inside a larger floating button. Enlarging its drawing within the same view box changes both appearances. + +**Correction and acceptance.** Compare the directional family, both weights and actual consumer sizes. Keep a modest supporting glyph beside text; reduce the floating control’s icon size if needed without shrinking its button. A large glyph sheet alone does not establish balance in either composition. + + +## 8. Control geometry and corner relationships + +Consistency means that equal roles and size variants follow one rule, and different roles have an understandable relationship. Review outer height, width, radius, curve, inset, and visible base together. Legal tokens can produce an incoherent combination; one radius on every element can be equally incoherent. The [radius standard](ui-radius.md) owns values and nested geometry, so this section does not create another scale. + +Distinguish a tightly inset surface that follows its container from an independent card inside a panel. The first needs coordinated outer/inner curves and inset; the second follows its own component role. Check the background, border, highlight, pseudo-elements, and clipping together. A hover fill that changes the visible silhouette is a state defect even if the resting border is correct. + +### Decide geometry in this order + +Classify the semantic role, select its existing size variant, identify the parent/child relationship, then inspect curve, material and state. The [radius scale](ui-radius.md#radius-scale) and its named exceptions supply values; a larger bounding box after expansion does not select a new tier. Equal radius numbers can still look different when height or curve differs. + +| Relationship | Decision from the radius owner | Visual failure to look for | +|---|---|---| +| Circular concentric inset | Inner radius is outer radius minus inset, clamped at zero; arcs share a center | Same-radius inner/outer corners pinch or widen the visible band | +| Nested superellipses | Use the shared nominal size relation, then inspect diagonal spacing; they are not exact offset curves | Claiming mathematically equal spacing from subtraction alone | +| Flush fill or adjoining segments | The enclosing silhouette owns exposed corners; internal joins stay square | Notches, crescents, double contours or focus clipped with the fill | +| Independent child card/group | Choose its role tier instead of subtracting all surrounding space | Collapsing a legitimate child radius to zero from large parent padding | +| Explicit circle or pill | Preserve round arcs for the container and its painted contents | A rounded avatar container holding a noncircular image; squircle-like capsule ends | + +Compare fill, stroke, hover overlay, focus and clipping together throughout the page or modal. Flat card borders and elevated materials have different paint ownership under [Web styling](web-styling.md#component-rules); applying both can double the edge. When a contour clips its fill, keep popups and keyboard focus fully visible. Inspect the geometry and continued use together. + +#### Fluent: the split-button notch is a relationship error + +The [Shapes example](https://fluent2.microsoft.design/shapes/#when-to-avoid-rounded-corners) marks the gap where two separately rounded button halves meet. Inspect the internal seam rather than the outer corners: treating each half as a complete independent silhouette breaks the shared enclosure. This is a concrete negative example, not a recommendation to make every control rounder. + +[![Fluent's negative example: two rounded halves create an unwanted internal notch](https://fluent2.microsoft.design/_image?f=png&h=440&href=https%3A%2F%2Ffluent2websitecdn.azureedge.net%2Fcdn%2Fdesignlanguage-shapes-cornerradius-03.D70MlS4K.webp&w=728)](https://fluent2.microsoft.design/shapes/) + +Image: Microsoft Fluent 2, linked from its original source. In DSH, apply the flush-fill arrangement from the radius standard, then check independent hover regions, focus and the fully visible popup together. Fluent's radius and stroke scale are not DSH defaults. Look at the notch at the internal seam; this source image is a negative example, not a DSH before/after comparison. + + +### Example R1: Radii across container and control roles + +**Scene and relationship.** A model editor contains grouped model rows, two inputs per row, an “Add model” button, and cancel/save actions. Every corner appears on the same screen, so role and size relationships must be readable together. + +**Correction and acceptance.** Classify the outer editor, model group, field, and button before selecting their existing variants. Same-role controls at the same size match. Coordinate tightly nested curves with actual inset, while independent children retain their own role. Check collapsed/expanded rows, field focus, hover fills, and long model names. Do not justify a random radius by pointing to the total height after expansion. + + +### Example R2: Small selection controls within a form + +**Scene and relationship.** Input-type choices use dark native checkboxes beside rounded form controls. The mismatch concerns the small control's silhouette and visual weight, not a requirement to give a checkbox the radius of a large input. + +**Correction and acceptance.** Use a coherent small-selection-control treatment while preserving the recognizable checkbox silhouette and label association. If native appearance is replaced, retain checked/unchecked, disabled, keyboard focus, and assistive semantics. Compare these states at actual size in both themes; an unchecked outline and its checked fill must belong to the same control family. + + +### Example R3: Justified geometry differences between roles + +**Scene and relationship.** Nearby automation controls combine rounded filter chips, a search field, and a primary create action. Similar control heights can still produce a conspicuous jump in roundness when each feature chooses its own radius. + +**Correction and acceptance.** Reuse the established control variants. A true pill is an explicit component type; it does not authorize every compact button to use half its height as radius. Compare filters, search, and primary/secondary buttons in one frame. Check hover and focus contours too. Keep a meaningful pill/field distinction where intended rather than forcing equal radii solely because the controls share a row. + + +## Reproduce and compare + +Locally maintained production-component fixtures can supply isolated examples for repeatable content and state. Derive actual application captures from the whole page or modal under review, including its relevant regions and states. A fixture establishes only what it renders; it does not determine or complete the review scope. + +The dimensions found in these components are evidence about those examples, not universal design constants. The [style reference](web-styling.md), [radius standard](ui-radius.md), and component owners retain authority over shared values. Extend the evidence for new consumers or a different task rather than copying an example's number without its conditions. + + +## External references and what DSH adopts + +These are research inputs, not automatically invoked skills or replacements for DSH rules. The selection below was read on 2026-10-26. Public guidance supplies comparison methods; the worked DSH cases and explanatory diagrams are local synthesis. Links point to the original authors. No external skill installation or execution is required to use this reference. + +| Reference | Useful contribution | Scope and deliberate limit | +|---|---|---| +| [Anthropic frontend-design](https://github.com/anthropics/skills/blob/main/skills/frontend-design/SKILL.md) | Explicit composition, content-specific choices, and rendered self-critique | Its expressive visual direction is not a mandate to restyle a workbench or change DSH typography | +| [Vercel web-design-guidelines skill](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md) and [rules](https://github.com/vercel-labs/web-interface-guidelines/blob/main/command.md) | Locate findings in code and cover focus, labels, long content, and interaction | Code compliance alone does not establish alignment or grouping; its title-case advice does not override DSH sentence case | +| [Impeccable](https://github.com/pbakaus/impeccable/blob/main/.agents/skills/impeccable/SKILL.md), [critique](https://github.com/pbakaus/impeccable/blob/main/.agents/skills/impeccable/reference/critique.md), [polish](https://github.com/pbakaus/impeccable/blob/main/.agents/skills/impeccable/reference/polish.md), [operate](https://github.com/pbakaus/impeccable/blob/main/.agents/skills/impeccable/reference/operate.md) | Separate a task-oriented interface from promotional design; distinguish visual judgment from detector output | Adopt the complete-path inspection and existing-system comparison, not its execution, delegation, or scoring machinery | +| [Interface Design](https://github.com/Dammyjay93/interface-design/blob/main/.claude/skills/interface-design/SKILL.md) | Explicit product intent, repeated patterns, and comparison with established neighboring interfaces | Preserve DSH's existing system; do not import its example font weights, create another token catalog, or chase novelty for its own sake | +| [OpenAI figma-implement-design](https://github.com/openai/skills/blob/main/skills/.curated/figma-implement-design/SKILL.md) | Compare rendered output against an actual visual reference and integrate existing components | A screenshot is evidence with a defined scope, not permission to infer unseen states or adopt every number | +| [Fluent Shapes](https://fluent2.microsoft.design/shapes/) and [Primer Forms](https://primer.style/product/ui-patterns/forms/) | Explicit component counterexamples with inspectable relationships | The case analyses above transfer a decision method, not their token or interaction system | +| [Atlassian spacing](https://atlassian.design/foundations/spacing/) | Positive/negative examples of proximity, repeated rhythm, hierarchy, and optical adjustment | Borrow the relationship tests, not Atlassian's token names or an automatic pixel formula | +| [Carbon spacing](https://www.carbondesignsystem.com/building-blocks/foundations/spacing/overview) | Spacing patterns express equal weight and section boundaries; fluid centering differs from fixed spacing | Use DSH's spacing ownership; a scale alone cannot select the right group relationship | +| [Codex official product introduction](https://openai.com/index/introducing-the-codex-app/) and [desktop documentation](https://learn.chatgpt.com/docs/app) | Task-oriented composition and real public examples to compare | Public examples are versioned observations, not the current local app or an unpublished Codex design specification | + +### Codex: a limited reference for composition + +The official [welcome/composer poster](https://learn.chatgpt.com/images/codex/video-posters/proactive-teammate-v2.webp) and [long-input poster](https://learn.chatgpt.com/images/codex/video-posters/draft-prds-from-slack-linear-docs.webp) show centered introductory content, left-aligned input text, and separate bottom control groups. They support that composition comparison. They do not reveal full-sidebar alignment, nested-corner behavior, neighboring selected/hovered states, or resizing. Use the more specific examples above for those decisions rather than treating a product poster as broad design approval. + +External images remain at their credited sources. If a source image is unavailable, the adjacent analysis still identifies the comparison; obtain a usable reference before using that image as visual evidence in a new review. + + +## Dev Note + +None. diff --git a/docs/ui-design-review-cases.zh.md b/docs/ui-design-review-cases.zh.md new file mode 100644 index 0000000000..69536d6f91 --- /dev/null +++ b/docs/ui-design-review-cases.zh.md @@ -0,0 +1,445 @@ +--- +description: "DSH 设计判断参考:对齐、空间分组、信息层级、状态与几何,以及完整的审查案例。" +--- + +# UI 设计原则与审查案例 + +[English](ui-design-review-cases.md) | 中文 + +## 概览 + +设计关系不明确时查阅相关案例。普通修改以页面/弹窗为上下文检查受影响关系,不要求遍历全部案例、建立清单或完整审查整页。下文整界面审查章节用于明确要求的完整审查。案例解释可迁移的原则,不定义覆盖范围。详细方法按需查阅[审查手册](cookbook/reviewing-ui-design.zh.md),执行深度以所属流程为准。 + +## 目录 + +- [审查究竟要判断什么](#judgment) +- [以整页或整个弹窗作为审查单位](#whole-interface-review) +- [先综合适用规范](#review-baseline) +- [如何用例子理解原则](#case-selection) +- [原则示例索引](#case-index) +- [1. 对齐](#alignment) +- [2. 空间分组](#grouping) +- [3. 整屏密度](#density) +- [4. 信息层级](#hierarchy) +- [5. 控件状态](#states) +- [6. 组件选型](#component-choice) +- [7. 图标光学](#icon-optics) +- [8. 控件几何与圆角关系](#geometry) +- [复现与比较](#reproduce) +- [外部参考与 DSH 的取舍](#references) +- [开发备注](#dev-note) + + +## 审查究竟要判断什么 + +好的界面会在读者逐字阅读前先表达元素之间的关系。对齐让人比较并列内容时不用反复寻找起点;间距告诉人哪些图标、标签、说明、控件和操作属于同一组。这些是基础设计要求,即使功能完全可用也要满足,不能等 token 检查通过后才当作可选打磨。 + +DSH 的视觉审查优先关注对齐与空间分组。先识别任务和语义组,再检查整个区域的跨组对齐轴,然后检查每组内部关系;随后检查信息层级、状态、几何、颜色、密度和光学细节。操作阻塞或误导性状态仍先于纯外观问题。不能为了统一而让所有元素一样大、一样疏密、一样突出。 + +页面或弹窗提供构图参照;一次改动需要检查哪些关系,由[影响范围](cookbook/reviewing-ui-design.zh.md#review-scope)决定。导航、内容、表单和操作即使由不同组件实现,也可能共用文字轴和间距规则。例外应由角色、层级或状态解释,组件归属本身不是设计理由。 + +本文原理图解释元素关系,不是实现截图或已验收的修改前后对照。下文例子用于讲清具体原则,不代表已覆盖当前要审查的页面或弹窗。 + + +## 以整页或整个弹窗作为审查单位 + +选择参照和判断关系时,将整个页面或弹窗视为一个视觉整体。日常改动在这个上下文中审查受影响的分组和跨组关系,不自动检查每个区域或整个产品。明确的完整界面审查必须检查其全部区域。侧栏、表单、工具栏即使由独立组件实现,也属于更大的整体构图。 + +对弹窗,标题、关闭按钮、全部内容分区、滚动区域和底部操作一起纳入判断;对页面,导航、页头、工具栏、内容区和固定操作一起纳入判断。滚动后才能看到的内容仍属于同一个整体。选择对齐和居中参照时,依据页面或弹窗自己的边缘;打开的弹窗在底层页面之上形成自己的构图。 + +### 从整体、组间关系看到组内元素 + +先看整体构图:内容边缘、视觉中心、阅读顺序、主操作和总体疏密。再按内容描述什么、操作什么,判断哪些元素组成一组、组内有哪些子组。先比较相邻组,再看组内元素。同一种关系应在整页或整个弹窗内保持一致,包括相距较远的分区。 + +| 审查方面 | 在整页或整个弹窗内统一比较 | 判断依据 | +|---|---|---| +| 对齐 | 文字起点、图标中心、控件边缘、重复的操作列 | 按角色选择居左、居中或两侧对齐;共用轴线跨组延续;图标与文字先作为一个单元判断 | +| 空间分组 | 标签到内容、元素之间、组之间、分区之间的距离 | 组内间距小于组间间距;组越复杂,组间距应越大;相似构成的组复用相同组内间距 | +| 尺寸与比例 | 同角色控件、图标底板、图标及相邻文字 | 使用一致的尺寸变体和视觉重量;必要时通过调整大小实现共同对齐 | +| 圆角与轮廓 | 外层容器、卡片、字段、按钮和选择控件 | 全部遵循同一套按角色划分的圆角规则;依据已有规范区分内缩、贴边和独立容器关系 | +| 颜色与强调 | 文字、关联图标、底色、描边和辅助信息 | 共同表达同一信息的图标与文字保持一致的强调;差异应能由角色或状态解释 | +| 字体与疏密 | 标题、标签、说明、重复行和整体留白 | 同层级内容保持一致,层级清楚;局部间距与整体疏密协调 | +| 状态与行为 | 选中、悬停、焦点、禁用、展开、加载和失败内容 | 状态变化后仍能认出分组、对齐关系和操作入口;同时出现的状态放在一起比较 | + +统一看待,意味着在这个范围内使用同一套判断依据。同角色、同尺寸变体使用一致处理,差异来自角色、层级或状态。“由不同组件实现”不能解释文字起点、间距模式或圆角为什么不一致。同样,居中的标题与居左的阅读列可以共存,前提是各自角色支持这种构图。 + +### 把每个发现放回整体判断 + +记录哪些元素发生冲突、它们应共享什么关系,以及改法如何与页面或弹窗的其他部分协调。在本次范围内比较这种关系的所有出现位置。局部调整后,回到整体构图,一起复查受到影响的对齐、分组、几何和层级。 + +完整审查覆盖范围内所有区域,包括相关滚动位置、展开内容、标签页,以及会改变这些关系的状态。按适用情况检查支持的宽度、主题和输入方式。保留草稿、焦点、关闭方式和主操作可达性,失败时提供相应恢复。没有检查的区域和状态明确列出。一张合格裁图、一个组件 fixture 或几个选定例子,都不能证明整页或整个弹窗已经协调一致。 + +### 用关系清单判断覆盖是否完整 + +把完整界面中的语义组列出来,再列出需要比较的关系:父子缩进、跨分区文字列、重复操作槽、标题与说明、字段与校验、正文与行内控件、滚动内容与固定操作。不只检查每组内部;每条跨组关系都必须连接到实际比较过的两端。修改共用原语时,补查它在有/无说明、长短内容和不同操作组合中的使用方。 + +关系通过至少满足三项:结构使用同一个参照;实际画面表达该关系;改变相关内容或状态后仍成立。测试断言存在、同一个 token、截图成功或单张裁图都不能单独替代这三项。优先级按误导程度、使用频率及传播范围决定,不能用“只差几像素”排除高频错列。 + +“已检查无新增问题”“已修复并复查”“发现但未修复”“未检查”分别记录。若无法穷尽平台或异常状态,交付明确的覆盖边界;不能将例子数量、组件测试数量或页面入口数量当成全部设计通过。 + + +## 先综合适用规范 + +用[规则归属表](../.agents/skills/dsh-client-ui-ux/SKILL.md#select-the-rule-owners)选择当前实现要求,包括对齐、间距和图标专项。 + +审查有两个独立要求:符合适用的 DSH 规则,同时解释组装后的界面是否清楚表达预期关系。轮廓好看不能抵消交互语义错误,token 合法也不能抵消对齐错误。局部审查应明确不涉及哪些方面;整体审查则在受审整页或整个弹窗的所有区域中检查下表相关项。 + +| 规则所有方 | 对审查的贡献 | 在整页或整个弹窗中比较什么 | +|---|---|---| +| [圆角规范](ui-radius.zh.md#radius-scale)、[嵌套几何](ui-radius.zh.md#nested-hover) | 先角色后尺寸;内缩、贴边填色、独立子容器三种关系;曲线与明确例外 | 外层容器、字段、hover 填色、指示块及裁切边缘一起比较 | +| [Web 样式](web-styling.zh.md#component-rules) | 语义色、字号与行高配对、发丝线、层级阴影、菜单材质、动效 | 跨功能的同角色表面、明暗主题与 Desktop 原生背景 | +| [客户端 UI/UX skill](../.agents/skills/dsh-client-ui-ux/SKILL.md) | 组件复用、层级、加载、反馈归属、窗口避让 | 加载后/空/错误状态及实际周边页面 | +| [响应式 skill](../.agents/skills/dsh-responsive-ui/SKILL.md) | 先分配空间再截断、输入能力、焦点、主操作可达性 | 同一任务的宽屏、受限宽度、键盘及触屏使用 | +| [官方图案 skill](../.agents/skills/dsh-official-plugin-artwork/SKILL.md) | 插件图案语义、光学平衡、透明区域及邻近参照 | 插件图案在列表与详情中的呈现;操作图标属于另一类 | +| [错误处理 skill](../.agents/skills/dsh-error-handling/SKILL.md#localized-actionable-visible-reports) | 失败可见、保留有效状态、提供安全下一步 | 区分字段纠错、操作失败与查询失败 | +| [基础组件目录](../packages/client/ui-primitives/README.zh.md#component-catalog)与相关包 README | 已有变体、焦点与交互行为、功能特有要求 | 实际组件角色与外观相似的替代品是否相符 | + +当前仓库所有方及其明确记录的例外,优先于旧版个人 skill 或外部案例。用户的具体选择适用于指定组件族,不会默默替换全局体系。实现与所属规范不一致时,报告差异,不能把既有代码自动当成新规则。选圆角和材质时重新核对所有方,不在 review 文档里复制另一套尺度。 + + +## 如何用例子理解原则 + +例子通过具体任务、相关元素、周边上下文,以及可能推翻改法的变化条件,让原则变得可观察。应提炼其中的关系,并用于当前整页或整个弹窗的判断。侧栏或模型编辑器的例子,不会把原则限定在这些组件上。例子是帮助理解的证据,不是审查菜单或覆盖清单。 + +区分三种证据:DSH 实际场景可以确认所展示条件下的缺陷;官方设计系统案例解释特定关系,其尺寸与交互策略仍属于原系统;原创示意图解释拟采用的关系,不能标成已实现的改善。fixture 仅覆盖它渲染的条件。来源、比较对象、适用的 DSH 规则和复查条件应当紧邻案例。 + +每个案例按“场景与问题 → 设计判断 → 紧接下方的截图或成对对照”呈现。图注说明看哪里、两侧分别表示什么及来源。不要把无关案例集中成脱离正文的截图墙。差异只有勉强可辨、或图片没有包含所讨论关系时,应选择更有解释力的证据,不能将其当作设计范例。 + + +## 原则示例索引 + +对某种关系不确定时,可借助这些例子理解。在当前页面或弹窗内,所有相关区域都适用相同原则,包括这里没有举到的区域。审查范围来自用户要求的界面,不由这份示例清单决定。 + +| 案例 | 要检查的关系 | +|---|---| +| [A1](#case-sidebar-axes) | 按角色判断侧栏对齐 | +| [A2](#case-action-columns) | 重复操作组的对应列 | +| [A3](#case-preview-toolbar) | 多排控件与复合控件的对齐 | +| [A4](#case-empty-center) | 内容组相对所属容器居中 | +| [A5](#case-composer-controls) | 并列控件的可见尺寸与中心 | +| [A6](#case-trigger-menu-text) | 入口与菜单之间的文本起点 | +| [A7](#case-close-control-edge) | 明确关闭控件的对齐边缘 | +| [G1](#case-title-actions) | 内容与操作组的分隔 | +| [G2](#case-team-sections) | 复杂分区之间的间距 | +| [G3](#case-plugin-groups) | 阅读顺序中的多层分组 | +| [G4](#case-sidebar-groups) | 不同用途的组之间的分隔 | +| [C1](#case-search-color) | 同一信息单元的图文强调 | +| [S1](#case-sidebar-state) | 持续选中与临时悬停共存 | +| [O1](#case-file-optics) | 不同类型图标的光学对齐 | +| [O2](#case-chevron-consumers) | 共享图形在不同使用尺寸下的表现 | +| [R1](#case-model-radius) | 不同层级容器与控件的圆角 | +| [R2](#case-checkbox-radius) | 表单中小型选择控件的轮廓 | +| [R3](#case-automation-radius) | 不同角色之间有依据的几何差异 | + + +## 1. 对齐 + +[对齐参考](../.agents/skills/dsh-client-ui-ux/references/alignment.md)负责共享轴线、完整布局框、层级缩进和文字度量;以下案例用于说明这些判断。 + +![对齐示意:同类行共享轴线,账号和标题操作保留各自几何关系](assets/ui-design-review/principles-alignment.svg) + +示意图区分同类行的轴线与账号、标题区域的几何关系,不是像素规范。分别比较内容、背景绘制区域和点击区域;不同角色不必共用所有轴线。 + +### 文字度量与不等长内容 + +行内图标的参照是相邻文字,不是整段的行框中心。`cap-height` 可以用于具体字体组合的校准,但不是所有字体与图标的统一公式;不能把某次有效的位移复制到所有控件。多行说明旁的状态图标通常附着首行,而单行按钮的 icon 与 label 作为整体居中。改变字号、字体回退或语言后重新检查这种关系。 + +下图使用真实 Markdown 渲染,左前右后,标注只在右图。中文、英文和代码链接保持原图标尺寸,通过字体度量修正垂直位置;外部留白与组内留白分别负责链接归属。检查长链接时还需看完整段落中的换行,裁图不能证明长链接可用。 + +![行内链接实际对比:图标与相邻字体对齐,保留框内留白](assets/ui-design-review/inline-metrics-comparison.png) + +并列卡片应共同确定标题起点、说明首行和操作区边缘。短说明不因相邻说明更长就独自垂直居中;剩余高度放在内容组之后,底部操作可以另行对齐。统一卡片高度不等于统一内部阅读顺序,也不要求裁短真实内容来制造等高。 + +下图为实际预设卡片的长短内容组合。左侧短说明因自动外距离开标题,右侧说明共同从标题下方开始,操作区仍对齐。该关系也适用于结果卡片、选项卡片和并列摘要;它不要求所有页面都采用卡片。 + +![并列卡片实际对比:说明首行对齐,底部操作保持对齐](assets/ui-design-review/peer-content-comparison.png) + + +### 示例 A1:按角色判断侧栏对齐 + +**场景与关系。**比较展开侧栏中的导航、工作区行、会话和账号区域。检查居中的新会话操作与行背景边缘,而不只比较文字起点。 + +**错误与修正。**为所有角色套用同一前导槽宽度,会移动标题、拉大间距或改变已登录账号行。保留所属组件的共享变量、紧凑图标槽和绘制内缩。保留有意设计的账号几何关系与层级,不强造贯穿所有角色的文字轴。 + +**验收。**用辅助线比较同角色的行。检查长短标题、可选图标、项目展开和账号登录/未登录状态。行操作浮现不能移动标题起点。列表裁图不能证明完整侧栏对齐。 + + +### 示例 A2:区分标题操作与行操作 + +**场景与关系。**工作区的搜索/筛选/添加与会话的更多/归档/置顶都处于侧栏尾部,但角色和间距不同。重复行之间互相比较;标题区域则比较自身搜索收起和展开的状态。 + +**错误与修正。**按各组角色选择控件尺寸、间距与尾部内缩,并检查搜索展开时的布局。标题到操作组的分隔独立于组内间距。 + +**验收。**同时显示标题与行操作,再展开搜索并折叠侧栏。检查预期边缘关系、未裁切的点击区域和长标题避让。不能为了让无关控件形成垂直列而补空操作或偏移。 + + +### 示例 A3:多排控件与复合控件的对齐 + +**场景与关系。**面板控制位于上排,文件显示控制与“在应用中打开”分段按钮位于下排。两排属于同一个预览页头,每排又包含作用范围不同的操作。 + +**错误与修正。**各组件内部可能已经居中,组装后仍出现无关的右边距、高度和分组间距。先按上排图标框与下排完整控件建立尾端关系,保留图标内部留白;不能直接对齐额外热区,也不能追齐单个 glyph 的像素边缘。再对齐并列控件中心,把显示控制收成一组,与外部打开操作分开。分段按钮按完整单元处理。缩小其外部宽度可以在保留前方图标列轴的同时,使可见间距相等;应检查两个分段的点击区域,以及边框对布局尺寸的贡献。不强迫其箭头与无关的面板操作共用列轴。 + +**验收。**保留路径、标签页与内容边缘,一并观察两排;再检查长路径和窄面板。用一排独立图标代替实际分段控件,不能验证这个关系。 + + +### 示例 A4:内容组相对所属容器居中 + +**场景与关系。**自动化页面在标题、筛选栏下面放置插画、说明和创建按钮。空状态组属于剩余内容区,应具有共同的水平中心和明确的纵向位置。 + +**错误与修正。**各子项水平居中,但父级只设置固定顶部 padding,会让整组停在高视口上方。让内容区占据剩余高度,再定位完整内容组;同时检查内部节奏,不能只移动按钮来声称整组居中。 + +**验收。**标出剩余内容矩形与整组的外接范围,以同样内容比较高、矮、窄窗口。区分有设计依据的光学偏移与意外固定间距;侧栏或页头改变可用区域时,不要求相对于整个窗口居中。 + + +### 示例 A5:并列控件的可见尺寸与中心 + +**场景与关系。**附件和发送操作分别位于底栏两端。DSH 的预期处理是圆形底板等大、垂直中心一致,主次通过填色与状态表达。 + +**错误与修正。**底板直径不同,再加额外的垂直平移,会形成两个不一致的端点。先统一并列控件外部几何并去掉布局补偿,再平衡槽内的加号和箭头。细线加号与实心箭头不需要具有相同的墨迹面积。 + +**验收。**检查空输入、可发送、忙碌/停止和窄布局中的底板与中心。保留输入框外轮廓一起观察,不能只看两张独立按钮截图。参考产品里不同大小的操作控件,不能覆盖 DSH 已明确的等大底板要求。 + + +### 示例 A6:入口与菜单之间的文本起点 + +**场景与关系。**权限入口使用比菜单行更大、更重的图标,但对应文字应从同一轴线开始。搜索提示、结果分组标题和选项文字也可能共享类似的阅读边缘。 + +**修正与验收。**先合计各表面的 border、padding、前导槽和 gap,再调整实现。只统一图标大小或卡片外缘,仍可能留下不同的文字起点。检查组装后的展开菜单、长标签和窄布局;区分浮层坐标按设备像素取整的微差与整段内缩错误。 + + +### 示例 A7:明确关闭控件的对齐边缘 + +**场景与关系。**弹窗的关闭图形位于较大的按钮内,底部操作靠内容边缘。图标框边缘与按钮边缘对应不同的标题栏内缩。 + +**修正与验收。**修改共享标题栏前先明确目标边缘。如果完整按钮与底部操作对齐,就保留图标在按钮内居中,不再把图标拉到该边缘。明确采用图标对齐的构图仍是独立变体。检查短标题、换行标题、说明/正文组合与底部操作,并保留点击区域大小。 + + +## 2. 空间分组 + +[间距分组参考](../.agents/skills/dsh-client-ui-ux/references/spacing.md)负责语义组、组内外间距关系和可见留白;以下案例说明如何应用。 + +![分组示意:等间距堆叠与分层分组的区别,组内较近,分区之间更远](assets/ui-design-review/principles-grouping.svg) + +图中 G 是前一个分区到新标题的间距,g 是新标题到所属内容的间距;重复成员卡片保持相同的内部排列与间隔。间距标记只描述这张构造图中的关系,不增加全局间距 token。页面变密时仍须保留层级关系,不能对每种间距都减去相同数值。 + + +### 示例 G1:内容与操作组的分隔 + +**场景与关系。**标题是一组,三个操作图标是另一组,图标之间是并列关系。标题到操作的距离比图标之间还小,会把标题与第一个图标视觉上粘在一起,反而把该图标与其他操作分开。 + +**修正与验收。**将操作组作为整体预留空间,组内采用紧凑且一致的间距,标题到操作组之间明显更宽。观察可见标题的末端,而非只看 flex 框。覆盖长标题、悬停、焦点和菜单打开状态;截断或滚动文字不能侵占分组留白。 + + +### 示例 G2:复杂分区之间的间距 + +**场景与关系。**成员卡片组成一个分区,随后“共享任务”标题属于它下方的任务卡片。两个分区之间的过渡应比各区内部的间隔更清楚,尤其是在最后一排成员卡片未填满时。 + +**修正与验收。**将每个标题及其内容作为一个完整分区,明确设置分区间距。按实际字面和卡片边界,比较“最后一张成员卡片 → 任务标题”与“任务标题 → 第一张任务卡片”的距离。CSS margin 的 2:1 比例不能替代读图。检查一排和多排成员、任务文案换行、任务为空的变体。 + + +### 示例 G3:阅读顺序中的多层分组 + +**场景与关系。**页面标题与介绍是一组,搜索服务于目录,子分区标题与说明介绍该分区的卡片。每次过渡都沿用相同纵向间距,会把三个层级摊平成一串元素。 + +**修正与验收。**各标题靠近自己的说明,页面介绍与目录工具分开,每个子分区与自己的卡片更近、与相邻分区更远。重复分区复用同一节奏。检查完整纵向序列,包含介绍换行和卡片展开;不能用给每段文字套边框来替代分组。 + +#### Primer:控件等宽不能修复阅读顺序 + +[Forms 结构对照](https://primer.style/product/ui-patterns/forms/#structure)将纵向连续表单与更矮的两列表单并列。后者的长选项组标签换行,同时无关字段开启另一条阅读路径。应该比较顺序和分组归属,不能只检查输入框边缘是否齐整。 + +| Primer 正例 | Primer 反例 | +|---|---| +| [![沿单一阅读列连续排列的表单控件](https://user-images.githubusercontent.com/2313998/170069921-98bb9035-2125-4807-b790-1da90e6bde3b.png)](https://primer.style/product/ui-patterns/forms/#structure) | [![为节省纵向空间而拆成两列的同一表单](https://user-images.githubusercontent.com/2313998/170069922-ed8a0ef7-0573-4f02-bd15-da6a5acbc527.png)](https://primer.style/product/ui-patterns/forms/#structure) | + +图片来自 GitHub Primer,链接到原始来源。比较两条阅读路径和换行的选项组标签,将这种分组判断用于整页或整个弹窗中的表单,包括长标签和错误。相关字段在归属清楚、整组能换行时可以同行。这些是外部参照,不是 DSH 实施结果。 + +#### Carbon:不同距离表达不同关系 + +[Form 规范](https://www.carbondesignsystem.com/building-blocks/core/components/form/specifications)提供[完整表单的标注图](https://s3.us-south.cloud-object-storage.appdomain.cloud/cxp-prod-payload-cms/form-style-1.png)。依次读标题/说明、字段行、选项组与提交区。示例为介绍区、重复字段、末尾操作使用不同层级的距离,同时保留共同内容边缘;标注使层级可以直接比较。 + +用这个关系审查 DSH 插件页的介绍、搜索、分区说明、卡片,或模型编辑器的末尾操作。增加一行文字后,再比较哪些元素仍然属于同一组。Carbon 数值属于它自己的示例,不是新的 DSH 间距目录;DSH 的结果还要满足既有密度、字号和响应式规则。 + +[![Carbon 表单解剖:共同内容边缘,以及标题、字段、提交区的不同间距](https://s3.us-south.cloud-object-storage.appdomain.cloud/cxp-prod-payload-cms/form-style-1.png)](https://www.carbondesignsystem.com/building-blocks/core/components/form/specifications) + +图片来自 IBM Carbon Design System。沿标注比较介绍区、重复字段和末尾操作之间的间距;图中展示其自身的间距体系,不规定 DSH 的像素数值。 + + +### 示例 G4:不同用途的组之间的分隔 + +**场景与关系。**“新会话”是主操作,插件/自动化/更多组成全局导航,工作区标题介绍项目列表。相同行高不能表达这几种不同关系。 + +**修正与验收。**导航行彼此靠近,导航组与工作区分开,工作区标题更靠近它所标注的项目,主操作与导航组保留独立间隔。连同账号区域检查完整侧栏;比较 A1 按角色区分的关系。在每个项目前都增加大间距,会错误地把每个项目变成独立分区。 + +### 组合后的留白与响应式分组 + +先确认哪一层负责组间距离,再核算实际组合:父级 gap、子级 margin、原生段落外距、标题 padding、行框空白和条件节点都可能叠加。组内可见距离与组间可见距离应在同一层级比较;不能把一组内部最大的所有空白与任意外部 gap 机械比较。标题带/不带说明时,首次内容的距离可以不同,但两种结构均需明确归属。 + +公共容器应提供稳定的标题、说明、正文和操作组织方式,使用方优先沿用。不要在使用方普遍增加负 margin 去抵消容器默认值。确需不同间距时,表达具体角色或变体,再复查共享容器的其他组合;局部更紧凑不等于所有弹窗都应压缩。 + +下图是实际语音提示弹窗。左侧普通段落叠加正文与原生外距,使说明脱离标题;右侧使用公共说明区,标题与解释更紧密,独立操作保留分隔。它说明间距应由组合后的关系决定,不规定全部弹窗采用这张图的距离。 + +![弹窗实际对比:去除重复外距,明确标题说明组与操作区](assets/ui-design-review/modal-grouping-comparison.png) + +窄容器首先重排完整语义组。若输入框挤压说明成极窄文字列,将标签与说明留在一起,并把该字段的输入放到下一行;不要缩小所有字体或让说明跨到邻组。验证组件实际容器宽度和断点两侧,而不只改变浏览器宽度。容器查询须在真实组装中生效;选择器、CSS module 名称或祖先结构都可能使看似正确的声明不起作用。 + + +## 3. 整屏密度 + +按任务内容与控件分配可用空间,不是缩小所有间距或字号。 + +密度关注可用空间能容纳多少有效工作,以及这些内容是否组织清楚。紧凑的重复行可以与宽松的分区间距共存。先确定阅读和操作任务,而非先追求某个留白百分比:长列表需要稳定行节奏,决策表单需要保留说明和校验,空页面需要连贯的中心内容组,不能沿用满列表的 padding 就算完成。 + +宽度变小时,先保留语义组,再考虑缩短文案或移动操作。以完整组为单位换行,组内间距模式保持稳定。高度增加时,明确哪个区域应该伸展,单靠固定 gap 无法定位空状态。同时检查过密和过疏:内容密集但便于比较可以成立,页面稀疏也可能分组混乱。 + + +## 4. 信息层级 + +让下一步需要阅读或决定的信息可见,不必把每段文字都变成标题。 + +强调必须表达与任务有关的差异。当前选择、可处理的错误和决策依据,需要稳定可辨;标签和补充信息可以更弱,但让有用的文字难以阅读不是层级设计。联合观察字号、字重、颜色、位置和周围留白;每个标题都把五种强调用满,反而失去有意义的区别。 + +图标与文字的前景角色及语义例外由[图标参考](../.agents/skills/dsh-client-ui-ux/references/icons.md)负责;案例 C1 将该规则用于搜索框。 + +字体也是几何的一部分:结合预期字体、长翻译与相关字体设置,比较文字起点、基线和行框。字号配套行高,并遵循[视觉基础](../.agents/skills/dsh-client-ui-ux/SKILL.md#visual-foundation)的字重限制。正文、代码、终端与固定界面角色不必同幅缩放;把差异判为缺陷前,核对[字体设置所有方](../packages/client/ui-theme/README.zh.md)。先修容器分配,不用缩小有用文字来强行对齐。 + + +### 示例 C1:同一信息单元的图文强调 + +**场景与关系。**比较空的自动化搜索框与展开的侧栏搜索框。二者都是放大镜配合提示文字,但一处图标更深,另一处文字更深。 + +**修正与验收。**为空输入提示指定一个前景色角色,包含前导图标。联合检查计算后的颜色、透明度、禁用样式与背景;祖先单独淡化某个子元素时,相同 token 仍可能产生不同结果。检查空值、输入文字、焦点、禁用及明暗主题。已输入的查询文字和具有独立操作的清除按钮,不必使用较弱的占位提示色。子菜单的值与尾部 chevron 也构成同一个值;即使几何正确,弱化的模型名称配更深的箭头仍不符合该关系。 + + +## 5. 控件状态 + +持续状态与临时反馈同时出现时,仍须能够区分。 + +状态设计要赋予稳定含义,而不只是换几种颜色。选中表示“当前所在项”,悬停表示“指针在这里”,焦点表示“键盘输入作用于这里”,禁用表示“这个操作暂不可用”。同时比较叠加状态与相邻条目:选中行旁边出现另一条悬停行,与同一行的选中+悬停同样重要。 + +| 组合 | 必须继续可辨的含义 | 应寻找的错误 | +|---|---|---| +| 已选中+另一行悬停 | 当前所在位置仍可识别 | 临时悬停行比当前行更像选中 | +| 同一项已选中+悬停 | 选中保留,同时有交互反馈 | 通用 hover 底色抹掉当前状态 | +| 已选中+键盘焦点 | 当前位置与键盘目标均可区分 | 焦点提示融进选中样式 | +| 已选中+禁用 | 保留的选择与暂不可用同时可辨 | 淡化后看起来像取消了选择 | +| 展开+菜单打开 | 当前操作有明确归属 | 面板高亮与行高亮无稳定关系地竞争 | + + +### 示例 S1:持续选中与临时悬停共存 + +**场景与关系。**比较当前会话、相邻悬停行和已打开的行菜单。背景绘制只是线索之一,还需结合当前行语义和浮现的操作;选中与悬停不必使用不同颜色。 + +**修正与验收。**确认悬停结束或菜单关闭后选中仍然保持。在相邻行之间移动指针并使用键盘遍历,同时检查焦点、操作可见性和当前行语义。比较相同主题与平台材质;不透明的隔离场景不能证明 Desktop 半透明背景的对比关系。 + + +## 6. 组件选型 + +先根据交互含义选择组件,再让布局适应可用空间。 + +先按用户能做什么选择元素,再让并列元素保持视觉一致。导航打开目的地,开关改变设置,复选框添加独立选项,操作按钮启动动作;几何相似不代表可以互换。卡片增加了容纳内容的分组与空间,只有当该组需要独立摘要、状态或操作时才成立,不能仅因一行看起来朴素就改成卡片。 + +标签、可见边界、焦点行为和点击区域应与交互含义一致。视觉修正若改变这些语义,还需检查行为。几何和圆角另见[第 8 节](#geometry);“原生控件”既不保证视觉适配,也不是丢掉键盘与可访问行为的理由。 + + +## 7. 图标光学 + +[图标参考](../.agents/skills/dsh-client-ui-ux/references/icons.md)负责界面图标的角色、槽位、颜色和视觉平衡;[官方 artwork](../.agents/skills/dsh-official-plugin-artwork/SKILL.md)保留独立职责。 + + +### 示例 O1:不同类型图标的光学对齐 + +**场景与关系。**在同一树形深度比较关闭/打开的文件夹、原生文件类型图标、通用文件及无图标名称,再比较下一层的同样组合。 + +**修正与验收。**保留层级缩进和共同文字起点,先统一放置槽,再比较槽内可见中心与视觉重量。不能移动文字去补偿单个 glyph,也不能把所有深度压到同一边缘。检查正常尺寸和放大图,包含选中底色与长文件名;放大图用于诊断绘制,正常尺寸决定结果是否平衡。 + + +### 示例 O2:共享图形在不同使用尺寸下的表现 + +**场景与关系。**同一个 chevron 既出现在菜单文字旁,也出现在较大的悬浮按钮中。在同一 viewBox 内放大绘制范围,会同时改变两处表现。 + +**修正与验收。**对比各方向、两种字重及实际使用尺寸。文字旁保留适度的辅助图形;必要时缩小悬浮控件里的图标,但不缩小按钮。只看放大的图标总表,不能证明这两种构图都平衡。 + + +## 8. 控件几何与圆角关系 + +一致意味着相同角色、相同尺寸变体遵循同一规则,不同角色之间具有可理解的关系。联合检查外部高度、宽度、圆角、曲线、内缩与可见底板。合法 token 也能组合出不协调的外观,所有元素套用同一个圆角同样可能不协调。数值与嵌套几何由[圆角规范](ui-radius.zh.md)拥有,本文不另建尺度。 + +区分沿容器内缩的贴合表面与面板里的独立卡片:前者要协调内外曲线和内缩距离,后者按自身组件角色选择。一起检查底色、描边、高亮、伪元素与裁切。即使静止描边正确,hover 底色改变了可见轮廓,也属于状态缺陷。 + +### 按这个顺序判断几何 + +先确定语义角色,选择已有尺寸变体,识别父子关系,再观察曲线、材质和状态。[圆角尺度](ui-radius.zh.md#radius-scale)及其明确例外提供数值;展开后的外接框变大,不会自动选择新档位。相同半径数值也可能因高度或曲线不同而产生不同观感。 + +| 关系 | 从圆角所有方得到的判断 | 要寻找的视觉错误 | +|---|---|---| +| 圆弧同心内缩 | 内半径为外半径减内缩,最小为零;圆弧共用圆心 | 内外同半径使可见间隔在角落挤压或扩大 | +| 嵌套超椭圆 | 采用共享名义尺寸关系,再观察斜向留白;它们不是严格偏移曲线 | 仅凭半径相减就声称数学上等距 | +| 贴边填色或相邻分段 | 外框拥有暴露角落,内部接缝保持方角 | 缺口、月牙、双轮廓,或焦点被随底色一起裁掉 | +| 独立子卡片/分组 | 按自身角色选档,不机械减去全部周围空间 | 因父级 padding 很大而把合理子圆角减成零 | +| 明确的圆形/胶囊 | 容器与实际绘制内容保持圆弧 | 圆形头像容器里嵌着非圆形图片;胶囊端部变方圆 | + +在整页或整个弹窗中一起比较底色、描边、hover 覆盖层、焦点和裁切。平面卡片边框与有层级阴影的材质,在 [Web 样式](web-styling.zh.md#component-rules)中具有不同绘制归属,同时应用可能让边缘加倍。轮廓裁切底色时,弹出菜单和键盘焦点仍须完整可见。几何与持续使用应一起判断。 + +#### Fluent:分段按钮的缺口是关系错误 + +[Shapes 案例](https://fluent2.microsoft.design/shapes/#when-to-avoid-rounded-corners)标出了两个各自圆角的按钮半区相接时形成的空隙。应观察内部接缝而非外部四角:把每半区都当成独立完整轮廓,会破坏共同外框。这是具体的反例,不是在建议所有控件都更圆。 + +[![Fluent 反例:两个全圆角半区造成内部缺口](https://fluent2.microsoft.design/_image?f=png&h=440&href=https%3A%2F%2Ffluent2websitecdn.azureedge.net%2Fcdn%2Fdesignlanguage-shapes-cornerradius-03.D70MlS4K.webp&w=728)](https://fluent2.microsoft.design/shapes/) + +图片来自 Microsoft Fluent 2,链接到原始来源。在 DSH 中采用圆角规范的贴边填色关系,再一起检查左右两半的悬停、焦点和弹出菜单是否完整显示。Fluent 的圆角与描边尺度不是 DSH 默认值。观察内部接缝的凹口;原图是反例,不是 DSH 的修改前后对照。 + + +### 示例 R1:不同层级容器与控件的圆角 + +**场景与关系。**模型编辑器包含模型分组行、每行两个输入框、“添加模型”按钮及取消/保存操作。这些圆角同时出现在一屏,角色和尺寸关系必须能够一起读懂。 + +**修正与验收。**先区分外层编辑器、模型组、字段、按钮,再选择现有变体。同角色同尺寸控件保持一致,紧密嵌套曲线与实际内缩距离协调,独立子控件保留自己的角色。检查行的折叠/展开、字段焦点、hover 底色及长模型名,不能拿展开后的总高度解释任意圆角。 + + +### 示例 R2:表单中小型选择控件的轮廓 + +**场景与关系。**输入类型选项使用深色原生复选框,旁边是圆角表单控件。这里检查小型选择控件的外轮廓和重量,不是要求复选框具有大输入框的圆角。 + +**修正与验收。**采用协调的小型选择控件样式,保留复选框易辨识的轮廓及标签关联。替换原生外观时,继续保留选中/未选中、禁用、键盘焦点和辅助技术语义。明暗主题中按实际尺寸比较这些状态;未选中的线框与选中的底色必须属于同一控件族。 + + +### 示例 R3:不同角色之间有依据的几何差异 + +**场景与关系。**自动化页相邻的筛选项、搜索框和主创建操作采用不同圆角。即使高度接近,各功能自己选择半径,仍可能产生明显的弯曲感跳变。 + +**修正与验收。**复用已有控件变体,真正的胶囊是明确的组件类型,不代表所有紧凑按钮都应使用高度一半作为半径。将筛选、搜索和主次按钮放在同一画面比较,同时检查 hover 与焦点轮廓。预期的胶囊/字段差异可以保留,不能仅因同行就强制圆角相等。 + + +## 复现与比较 + +本地维护的生产组件 fixture 可提供固定内容与状态的隔离示例。实际应用截图应来自本次要审查的整页或整个弹窗,包含相关区域与状态。fixture 只能证明它所渲染的条件,不能决定或完成审查范围。 + +这些组件中的尺寸是对应案例的证据,不是通用设计常数。共享数值仍由[样式参考](web-styling.zh.md)、[圆角标准](ui-radius.zh.md)及组件归属方定义。新增使用位置或任务不同时,应补充证据,不能脱离条件照抄案例数值。 + + +## 外部参考与 DSH 的取舍 + +这些是研究输入,不是自动启用的 skill,也不替代 DSH 规范。下列材料阅读于 2026-10-26。公开资料提供比较方法,本文 DSH 案例和原理图是结合本地场景的归纳;链接指向原作者。使用本文不需要安装或执行外部 skill。 + +| 参考 | 可采用的贡献 | 范围与明确取舍 | +|---|---|---| +| [Anthropic frontend-design](https://github.com/anthropics/skills/blob/main/skills/frontend-design/SKILL.md) | 明确构图、围绕实际内容做选择、观察渲染结果自评 | 表现性视觉方向不意味着应重设工作界面风格或更换 DSH 字体 | +| [Vercel web-design-guidelines skill](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md) 与[规则](https://github.com/vercel-labs/web-interface-guidelines/blob/main/command.md) | 定位问题源码,覆盖焦点、标签、长内容与交互 | 代码合规不能证明对齐和分组;其 title case 建议不覆盖 DSH 的 sentence case | +| [Impeccable](https://github.com/pbakaus/impeccable/blob/main/.agents/skills/impeccable/SKILL.md)、[critique](https://github.com/pbakaus/impeccable/blob/main/.agents/skills/impeccable/reference/critique.md)、[polish](https://github.com/pbakaus/impeccable/blob/main/.agents/skills/impeccable/reference/polish.md)、[operate](https://github.com/pbakaus/impeccable/blob/main/.agents/skills/impeccable/reference/operate.md) | 区分任务型界面与宣传设计,区分视觉判断与检测器输出 | 采用完整路径检查与现有系统比较,不引入其执行、委派或评分机制 | +| [Interface Design](https://github.com/Dammyjay93/interface-design/blob/main/.claude/skills/interface-design/SKILL.md) | 明确产品意图、复用重复模式、与已有相邻界面比较 | 保留 DSH 现有系统,不照搬示例字重、不另建 token 目录、不为独特而独特 | +| [OpenAI figma-implement-design](https://github.com/openai/skills/blob/main/skills/.curated/figma-implement-design/SKILL.md) | 用真实视觉参考比较渲染结果,并整合已有组件 | 截图是有范围的证据,不代表可以推断未展示状态或照搬每个数值 | +| [Fluent Shapes](https://fluent2.microsoft.design/shapes/)与 [Primer Forms](https://primer.style/product/ui-patterns/forms/) | 能直接检查关系的组件反例 | 上文案例分析迁移判断方法,不迁移其 token 或交互体系 | +| [Atlassian spacing](https://atlassian.design/foundations/spacing/) | 邻近关系、重复节奏、层级与光学调整的正反例 | 借鉴关系判断,不移植 Atlassian token 名称或自动像素公式 | +| [Carbon spacing](https://www.carbondesignsystem.com/building-blocks/foundations/spacing/overview) | 间距模式表达等权与分区边界,流式居中不同于固定距离 | 沿用 DSH 间距所有方,只有一套尺度仍不能选择正确分组关系 | +| [Codex 官方产品介绍](https://openai.com/index/introducing-the-codex-app/)与[桌面文档](https://learn.chatgpt.com/docs/app) | 任务导向的构图和可比较的真实公开示例 | 公开示例是特定版本的观察,不是当前本机应用或未公开的 Codex 设计规范 | + +### Codex:限定用途的构图参照 + +官方[欢迎区/输入框封面](https://learn.chatgpt.com/images/codex/video-posters/proactive-teammate-v2.webp)与[长输入封面](https://learn.chatgpt.com/images/codex/video-posters/draft-prds-from-slack-linear-docs.webp)展示居中的介绍内容、居左的输入文字,以及底部独立控件组,可以支持这项构图比较。它们没有展示完整侧栏对齐、嵌套圆角、相邻选中/悬停或缩放变化。这些判断应使用上文更具体的案例,不能把产品封面当作广泛设计背书。 + +外部图片保留在署名来源中。源图不可用时,邻近分析仍指出比较对象;新审查要使用该图作视觉证据前,应先取得可用参照。 + + +## 开发备注 + +无。 diff --git a/docs/ui-radius.i18n.yaml b/docs/ui-radius.i18n.yaml index 0c8d58fcd9..1f40042d22 100644 --- a/docs/ui-radius.i18n.yaml +++ b/docs/ui-radius.i18n.yaml @@ -12,8 +12,8 @@ en: aad65e4a8399adbb zh: e76b80d421b17992 /dsh-unified-corner-radius-standard/scope-and-authority: - en: cd8120cba5566e19 - zh: 65359436088c8bc5 + en: 909c1c11ccf420dc + zh: 23bec3e3dfa856b6 /dsh-unified-corner-radius-standard/select-the-radius: en: e736416ec85d6e16 zh: 0edf60fccc867295 @@ -24,8 +24,8 @@ en: 7fe4eb7707e6a9f3 zh: be7530a05c205e27 /dsh-unified-corner-radius-standard/settings-card-materials: - en: 85e7e63183df06d1 - zh: 1da2b44083d7ebe2 + en: 126066f9c638433c + zh: a7416d255f838b92 /dsh-unified-corner-radius-standard/apply-and-verify: en: 2f6f10e45b27c19b zh: 9eb5fd964c643be6 diff --git a/docs/ui-radius.md b/docs/ui-radius.md index 303e7c9382..3d4ac68d2c 100644 --- a/docs/ui-radius.md +++ b/docs/ui-radius.md @@ -18,7 +18,7 @@ Choose consistent corner radii by component role and size, and align nested regi ## Scope and authority -This reference defines the DSH Web and Desktop radius scale and its component rules. A local visual adjustment stays scoped to the named component or family and does not automatically change the global scale. +This reference defines the DSH Web and Desktop radius scale, curve rules and component exceptions. Use the [UI design skill's geometry decisions](../.agents/skills/dsh-client-ui-ux/SKILL.md#component-geometry) to select and review their application. A local visual adjustment stays scoped to the named component or family and does not automatically change the global scale. The [Web UI style reference](web-styling.md) defines general styling ownership. Shared values live in [`base.css`](../packages/client/ui-theme/src/styles/base.css), and curve behavior lives in [`corner-shape.css`](../packages/client/ui-theme/src/styles/corner-shape.css). Update the theme implementation and both language versions of this standard together when changing a rule. @@ -107,6 +107,8 @@ The theme resolves the card fill to `--dsw-alias-bg-layer-2` and the stroke to ` Flat neutral borders use the shared 0.5px hairline. Elevated menus, popovers, dialogs, and panels use `border: 0` with the existing `--dsw-elevation-*` material, whose shadow includes the hairline. Do not add a second neutral border over that stroke or add a shadow to an ordinary settings card merely to distinguish its tab. Preserve semantic state-colored borders and the existing translucent-menu backdrop behavior. +A page-level radius review compares roles and silhouettes together, including native small selection controls. Token compliance only establishes membership in the scale; it does not establish that adjacent roles or nested contours look coherent. Use the [geometry cases](ui-design-review-cases.md#geometry) for the comparison method, while this document remains the owner of values and curve rules. + ## Apply and verify diff --git a/docs/ui-radius.zh.md b/docs/ui-radius.zh.md index 80edb753c6..306a27223e 100644 --- a/docs/ui-radius.zh.md +++ b/docs/ui-radius.zh.md @@ -18,7 +18,7 @@ ## 范围与依据 -本参考文档规定 DSH Web 和 Desktop 的圆角尺度及组件应用规则。局部视觉调整限定在指定组件或组件族内,不自动改变全局尺度。 +本参考文档规定 DSH Web 和 Desktop 的圆角尺度、曲线规则及组件例外。应用时按 [UI 设计 skill 的几何判断](../.agents/skills/dsh-client-ui-ux/SKILL.md#component-geometry)选择并审查。局部视觉调整限定在指定组件或组件族内,不自动改变全局尺度。 通用样式职责见 [Web UI 样式参考](web-styling.zh.md)。共享数值位于 [`base.css`](../packages/client/ui-theme/src/styles/base.css),曲线行为位于 [`corner-shape.css`](../packages/client/ui-theme/src/styles/corner-shape.css)。修改规则时同步更新主题实现及本规范的中英文版本。 @@ -107,6 +107,8 @@ H 表示以 CSS 像素计、包含边框的设计外部高度。R 表示浏览 平面中性边框使用共享的 0.5px 发丝线。具有层级阴影的菜单、浮层、对话框和面板使用 `border: 0` 与既有 `--dsw-elevation-*` 材质,其阴影已包含发丝描边。不要在该描边上再叠一层中性边框,也不要仅为区分设置 tab 就给普通设置卡片加阴影。保留语义状态色边框与既有的半透明菜单背景效果。 +整页圆角审查要同时比较角色与轮廓,包含原生的小型选择控件。符合 token 只能证明数值属于尺度,不能证明相邻角色或嵌套轮廓协调。比较方法见[几何案例](ui-design-review-cases.zh.md#geometry),本规范继续拥有数值与曲线规则。 + ## 应用与验证 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/web-search-defaults/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/web-search-defaults/guide.i18n.yaml new file mode 100644 index 0000000000..1abbc77594 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/web-search-defaults/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.3/web-search-defaults/guide.md +/: + en: 0bac687ed0a7a944 + zh: 0dade41ad394e9ed +/web-search-defaults: + en: a8cdc03730cb149c + zh: 9ddab73784ab6241 +/web-search-defaults/change: + en: bb2364852099c2f0 + zh: 2ff6e8911af96569 +/web-search-defaults/migration: + en: dd501144b0f64744 + zh: 6940e5842fc140db diff --git a/docs/upgrade-guide/v0.2.1-rc.3/web-search-defaults/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/web-search-defaults/guide.md new file mode 100644 index 0000000000..2c652a55b3 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/web-search-defaults/guide.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "One web_search call accepts 10 queries and returns 10 sources by default, and the DeepSeek Search API provider paces its requests." +--- + +# Web search defaults + +English | [中文](guide.zh.md) + +## Change + +In `dsh-tool-web`, the `searchMaxQueries` default rises from 4 to 10 and the `searchMaxResults` default rises from 8 to 10. The `web_search` schema now advertises `1–10 search queries`, so a call with 5–10 queries runs instead of failing validation, and each call can return up to 10 sources. A call can therefore start more provider searches and add more source text to the context. With the shipped Messages search provider, each query is one auxiliary model request. `dsh-web-deepseek-api` adds `searchRequestsPerSecond`, default 5: its Search provider sends at most that many requests in any one-second window, so the second five queries of a 10-query call start one second after the first instead of exceeding the account's 5 QPS limit. Values that a deployment already sets are kept. + +## Migration + +1. To keep the previous limits, set `searchMaxQueries: 4` and `searchMaxResults: 8` in the `tool-web` entry's `config` in `cordis.patch.yml`. +2. If several processes share one DeepSeek account through the Search API, lower `searchRequestsPerSecond` in the `web-deepseek-api` entry so that their combined rate stays at or below 5. +3. Confirm the result by running a task that sends 10 queries in one `web_search` call: it returns results instead of `Error: queries must contain at most 4 queries`. See the [tool-web configuration](../../../../packages/web/tool-web/README.md). diff --git a/docs/upgrade-guide/v0.2.1-rc.3/web-search-defaults/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/web-search-defaults/guide.zh.md new file mode 100644 index 0000000000..390342f844 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/web-search-defaults/guide.zh.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "一次 web_search 调用默认接受 10 个查询并返回 10 个来源,DeepSeek Search API 提供方会为请求限速。" +--- + +# Web 搜索默认值 + +[English](guide.md) | 中文 + +## 变更 + +在 `dsh-tool-web` 中,`searchMaxQueries` 默认值从 4 提高到 10,`searchMaxResults` 默认值从 8 提高到 10。`web_search` 的 schema 现在公布 `1–10 search queries`,因此含 5–10 个查询的调用会执行,而不是校验失败;每次调用最多返回 10 个来源。因此一次调用可能发起更多提供方搜索,并向上下文加入更多来源文本。使用内置的 Messages 搜索提供方时,每个查询都是一次辅助模型请求。`dsh-web-deepseek-api` 新增 `searchRequestsPerSecond`,默认 5:其 Search 提供方在任意一秒窗口内最多发送这么多请求,因此 10 个查询的调用中后五个查询比前五个晚一秒开始,而不会超过账号的 5 QPS 限制。部署已设置的值保持不变。 + +## 迁移 + +1. 如需保留原上限,在 `cordis.patch.yml` 的 `tool-web` 条目 `config` 中设置 `searchMaxQueries: 4` 和 `searchMaxResults: 8`。 +2. 如果多个进程通过 Search API 共享同一个 DeepSeek 账号,在 `web-deepseek-api` 条目中调低 `searchRequestsPerSecond`,使它们的合计速率不超过 5。 +3. 运行一个在一次 `web_search` 调用中发送 10 个查询的任务来确认:调用返回结果,而不是 `Error: queries must contain at most 4 queries`。参见 [tool-web 配置](../../../../packages/web/tool-web/README.zh.md)。 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.i18n.yaml new file mode 100644 index 0000000000..9aa7a50527 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.md +/: + en: 02dfa517303f3206 + zh: abf9a22603299cc7 +/loader-entry-tree-reports-and-include-persistence: + en: dc27815908c90cd9 + zh: e9cc077d4d3fdbec +/loader-entry-tree-reports-and-include-persistence/change: + en: 6bbe6a3a14e182d1 + zh: 4256bd997293f8f2 +/loader-entry-tree-reports-and-include-persistence/migration: + en: 574f30ad53da1e66 + zh: 1b9914c4ead412f7 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.md new file mode 100644 index 0000000000..0f25effb5d --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.md @@ -0,0 +1,24 @@ +--- +kind: upgrade-guide +description: "Loader entry trees report mutations through commit(change), and Include persists them through a journal." +--- + +# Loader entry tree reports and Include persistence + +English | [中文](guide.zh.md) + +## Change + +`@deepseek-ai/cordis-plugin-loader` follows Loader 1.0.0-rc.8, and `@deepseek-ai/cordis-plugin-include` follows Include 1.1.1. This affects `cordis.yml` and patch files, code that subclasses `EntryTree`, and code that calls `applyEntryPatches` or relies on Include writes. + +- `EntryTree.write()` is removed. Subclasses implement `commit(change: EntryChange)`, which receives each create, update, move, removal and explicit Fiber save synchronously. In-memory trees implement a no-op. +- `applyEntryPatches(data, patches, warn)` always returns cloned entries, including without patches, and clones inserted rows. Callers can no longer mutate the input through the result. +- Include persists runtime changes through a journal reconciled with the latest file. File edits win conflicting fields and are logged as conflicts. Changes to patch-owned fields and inserted rows update the parent Include's patches. `flush()` waits for pending writes and rejects while changes remain unsaved; `refresh()` and `stop()` return the same result. +- Include writes generated ids back into file rows that had none. Runtime changes to rows inserted by patches, including bundle rows, update the parent's patches instead of the included file. The parent of the `dsh` profile's root Include is the in-memory Loader, so such changes, for example a bundle row's self-disposal `disabled: true`, no longer survive a restart; previously the whole patched tree was written into `cordis.yml`. + +## Migration + +1. In `EntryTree` subclasses, replace `write()` with `commit(change)`. Persist from `change.options` and `change.legacy`: a missing `legacy` is a creation and a missing `options` is a removal. +2. Treat the `applyEntryPatches` result as the only copy to modify. +3. Where code needs a runtime change on disk, await `include.flush()`; for patch-owned changes, flush the child Include and then its parent. +4. Start the application and confirm that runtime edits appear in the configuration file after `flush()`. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.zh.md new file mode 100644 index 0000000000..f91c2042a6 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/cordis-loader-entry-trees/guide.zh.md @@ -0,0 +1,24 @@ +--- +kind: upgrade-guide +description: "Loader 条目树通过 commit(change) 报告变更,Include 通过 journal 持久化这些变更。" +--- + +# Loader 条目树变更报告与 Include 持久化 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/cordis-plugin-loader` 跟随 Loader 1.0.0-rc.8,`@deepseek-ai/cordis-plugin-include` 跟随 Include 1.1.1。受影响的是 `cordis.yml` 与 patch 文件、继承 `EntryTree` 的代码,以及调用 `applyEntryPatches` 或依赖 Include 写盘的代码。 + +- 移除了 `EntryTree.write()`。子类改为实现 `commit(change: EntryChange)`,它会同步收到每一次创建、更新、移动、删除和显式 Fiber 保存。内存树实现为空操作。 +- `applyEntryPatches(data, patches, warn)` 总是返回克隆后的条目,没有 patch 时也一样,插入行同样会被克隆。调用方不能再通过结果修改输入。 +- Include 通过与最新文件协调的 journal 持久化运行时变更。文件编辑在字段冲突时优先,并记录为冲突。对 patch 所拥有字段和插入行的修改会写入父 Include 的 patch。`flush()` 等待待处理的写入,并在仍有未保存变更时拒绝;`refresh()` 和 `stop()` 返回相同的结果。 +- Include 会把生成的 id 写回原本没有 id 的文件行。对 patch 插入行(包括 bundle 行)的运行时修改会写入父级的 patch,而不是被 include 的文件。`dsh` profile 根 Include 的父级是内存中的 Loader,因此这类修改(例如 bundle 行自行释放时写入的 `disabled: true`)重启后不再保留;此前整棵已应用 patch 的树会被写入 `cordis.yml`。 + +## 迁移 + +1. 在 `EntryTree` 子类中把 `write()` 换成 `commit(change)`。根据 `change.options` 和 `change.legacy` 持久化:缺少 `legacy` 表示创建,缺少 `options` 表示删除。 +2. 只修改 `applyEntryPatches` 返回的结果。 +3. 需要运行时变更落盘时,await `include.flush()`;对于 patch 所拥有的变更,先 flush 子 Include,再 flush 其父 Include。 +4. 启动应用,确认 `flush()` 之后运行时编辑已出现在配置文件中。 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.i18n.yaml deleted file mode 100644 index 3acec7569f..0000000000 --- a/docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.i18n.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading -# section, a hash of its English and Chinese blocks outside code blocks and generated regions. -# After editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.md -/: - en: 2f1907963c732ea8 - zh: e25494c07150f6b9 -/saved-api-keys-in-provider-editors: - en: 63376a595a0e6dae - zh: 2709df13410087e2 -/saved-api-keys-in-provider-editors/change: - en: 463718dbe83f900d - zh: d2e55dfebf8a4b9e -/saved-api-keys-in-provider-editors/migration: - en: 19c94ae9626773b1 - zh: 1098744f47162c43 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.md deleted file mode 100644 index 8e721ad800..0000000000 --- a/docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -kind: upgrade-guide -description: "Saved API keys appear in masked editor fields, and clearing a saved key removes its override." ---- -# Saved API keys in provider editors - -English | [中文](guide.zh.md) - -## Change - -Provider editors show stored API keys as masked values instead of empty fields. Applying an empty field removes a previously loaded key and restores environment authentication. Cancel preserves the stored key. Environment values are never loaded into the editor. - -Credential providers implement `readStored(ref): Promise` to return only the managed value, without environment fallbacks. The owner-only `credentials.readStored` Remote method supplies this value to an opened editor; general metadata reads remain redacted. - -## Migration - -1. Keep the loaded value to retain a saved key, replace it to rotate the key, or clear it and apply to use environment authentication. -2. Implement `readStored` in custom `CredentialProvider` subclasses; return `undefined` when no non-empty value is stored. -3. Reopen the DeepSeek editor to confirm the saved key appears masked. After clearing and applying, confirm the field is empty and offers environment authentication. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.zh.md deleted file mode 100644 index 75efbcc4d7..0000000000 --- a/docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.zh.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -kind: upgrade-guide -description: "已保存的 API 密钥显示为编辑器中的掩码值,清空已保存密钥会删除其覆盖。" ---- -# 提供商编辑器中的已保存 API 密钥 - -[English](guide.md) | 中文 - -## 变更 - -提供商编辑器以掩码值显示已保存的 API 密钥,不再显示空字段。应用空字段会删除之前加载的密钥并恢复环境认证。「取消」保留已保存密钥。环境变量值从不加载到编辑器。 - -凭据提供方实现 `readStored(ref): Promise`,仅返回托管值,不使用环境后备。仅允许所有者调用的 `credentials.readStored` Remote 方法向已打开的编辑器提供此值;普通元数据读取仍保持脱敏。 - -## 迁移 - -1. 保留加载的值即可保留已保存密钥,替换它可轮换密钥,清空并应用则使用环境认证。 -2. 为自定义 `CredentialProvider` 子类实现 `readStored`;未存储非空值时返回 `undefined`。 -3. 重新打开 DeepSeek 编辑器,确认已保存密钥显示为掩码。清空并应用后,确认字段为空且提示使用环境认证。 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.i18n.yaml new file mode 100644 index 0000000000..3805e25d52 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.md +/: + en: bdb7c51789ea8d61 + zh: 5bd9ab5abbf48670 +/css-anchored-menus: + en: 2d9b87ac50fc94bb + zh: 97f8c85f921b3793 +/css-anchored-menus/change: + en: 6361b58498cf3297 + zh: 4e1d21372bec95a9 +/css-anchored-menus/migration: + en: 7dba5d8b6e6bdc4a + zh: d97a7c13086e9a00 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.md new file mode 100644 index 0000000000..bcb19d01bd --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Client UI consumers must replace JavaScript panel positioning and Menu rectangle callbacks with CSS anchors." +--- + +# CSS-anchored menus + +English | [中文](guide.zh.md) + +## Change + +`@deepseek-ai/dsh-client-ui-primitives` removes the `useAnchoredPosition` value export and `AnchoredPositionOptions` type export. `Menu` replaces `getAnchorRect` with `anchorRef` for an external trigger element or `anchorPoint` for a point-triggered menu. Custom Client UI that imports the removed exports or passes the removed prop must migrate. + +Portaled menus follow their anchors through CSS Anchor Positioning. Their size limits use the layout viewport; browser zoom and panning move them with the page, and the panels may extend beyond the visual viewport, including while the keyboard is open. Menu dismissal, keyboard navigation, and internal scrolling remain available. The browser must support the CSS anchor positioning properties used by the component. + +## Migration + +1. Remove imports of `useAnchoredPosition` and `AnchoredPositionOptions`. For a custom panel, give its trigger a unique `anchor-name` and set the panel's `position-anchor` to the same name. Define placement and size limits in CSS; see the [model selector](../../../../packages/client/ui-model-selection/README.md#understand-the-implementation). +2. When `Menu.getAnchorRect` returned a trigger's rectangle, pass the trigger's element ref as `anchorRef` instead. An inline `anchor` rendered by `Menu` needs no external ref. +3. When the callback returned a zero-size rectangle for a pointer gesture, pass `{ x, y }` as `anchorPoint` in the same client-viewport coordinates as `event.clientX`/`clientY` or `getBoundingClientRect()`. `Menu` performs the Safari-to-layout conversion when opening or changing the point; callers must not add the visual-viewport offset themselves. +4. Rebuild the custom Client UI and check that no removed import or prop remains. Verify opening, scrolling, Escape, focus restoration, and selection before and after browser zoom. Assertions must allow visual-viewport overflow while retaining layout-viewport bounds and content access. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.zh.md new file mode 100644 index 0000000000..615d38906a --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.zh.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Client UI 使用方必须将 JavaScript 面板定位和 Menu 矩形回调替换为 CSS 锚点。" +--- + +# CSS 锚定菜单 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-client-ui-primitives` 移除 `useAnchoredPosition` 值导出和 `AnchoredPositionOptions` 类型导出。`Menu` 将 `getAnchorRect` 替换为指向外部触发元素的 `anchorRef`,或用于按坐标打开菜单的 `anchorPoint`。导入已移除导出或传入已移除 prop 的自定义 Client UI 必须迁移。 + +使用 portal 的菜单通过 CSS Anchor Positioning 跟随锚点。尺寸限制采用布局视口;浏览器缩放和平移时菜单随页面移动,面板可以超出视觉视口,包括键盘打开时。菜单关闭、键盘导航和内部滚动保持可用。浏览器必须支持组件使用的 CSS 锚点定位属性。 + +## 迁移 + +1. 移除 `useAnchoredPosition` 和 `AnchoredPositionOptions` 导入。自定义面板为触发元素设置唯一的 `anchor-name`,将面板的 `position-anchor` 设置为相同名称,并用 CSS 定义位置和尺寸限制;参见[模型选择器](../../../../packages/client/ui-model-selection/README.zh.md#understand-the-implementation)。 +2. 原 `Menu.getAnchorRect` 返回触发元素矩形时,改为通过 `anchorRef` 传入该元素的 ref。由 `Menu` 渲染的内联 `anchor` 无需外部 ref。 +3. 原回调为指针手势返回零尺寸矩形时,改为通过 `anchorPoint` 传入 `{ x, y }`,坐标与 `event.clientX`/`clientY` 或 `getBoundingClientRect()` 使用相同的客户端视口坐标系。`Menu` 在打开或坐标变化时执行 Safari 到布局视口的转换;调用方不要自行叠加视觉视口偏移。 +4. 重新构建自定义 Client UI,确认不再使用已移除的导入或 prop。在浏览器缩放前后验证打开、滚动、Escape、焦点恢复和选择。断言应允许超出视觉视口,同时保留布局视口约束及内容访问能力。 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.i18n.yaml new file mode 100644 index 0000000000..ce81afec05 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.md +/: + en: 754ae2b680f57c78 + zh: b3586f3db403f9e3 +/desktop-stable-channel-name: + en: 085fffc14a9bb6be + zh: 51c269f7b97b6e31 +/desktop-stable-channel-name/change: + en: bca4377ec9032e3b + zh: 6da9f3be04f1cd39 +/desktop-stable-channel-name/migration: + en: 8442b24f420f6be2 + zh: b2517178eccbf8d5 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.md new file mode 100644 index 0000000000..9261aa2960 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "The Desktop stable update channel is named stable instead of latest." +--- + +# Desktop stable channel name + +English | [中文](guide.zh.md) + +## Change + +Desktop packages, update feeds, and channel configuration use `stable` instead of `latest`. The `nightly` channel is unchanged. The old channel value is rejected; there is no alias. Fixed website downloads named `dsh-latest-*` are unchanged. + +## Migration + +1. Select `stable` in packaging workflows or set `DSH_DESKTOP_UPDATE_CHANNEL=stable` when building a stable-channel package. Build a stable version, without a prerelease suffix. +2. If a local Desktop settings file contains `updates.channel: "latest"`, change the value to `"stable"` and restart Desktop. Omitting the field uses the package default. +3. Publish with the updated tooling: it writes `stable.yml` on Windows and `stable-mac.yml` on macOS within each architecture's feed directory, with stable-channel binaries under `stable/`. Verify the selected channel and requested feed in the test deployment before production publication. Old packages still requesting `latest` feeds do not migrate automatically. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.zh.md new file mode 100644 index 0000000000..441693811b --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.zh.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "桌面正式更新渠道由 latest 更名为 stable。" +--- + +# 桌面正式渠道名称 + +[English](guide.md) | 中文 + +## 变更 + +桌面安装包、更新清单及渠道配置使用 `stable`,不再使用 `latest`。`nightly` 渠道不变。旧渠道值会被拒绝,不提供别名。网站名为 `dsh-latest-*` 的固定下载文件保持不变。 + +## 迁移 + +1. 在打包工作流中选择 `stable`,或在构建正式渠道包时设置 `DSH_DESKTOP_UPDATE_CHANNEL=stable`。使用不含预发布后缀的稳定版本号。 +2. 如果本地桌面配置文件包含 `updates.channel: "latest"`,将该值改为 `"stable"` 并重启桌面应用。省略该字段时使用安装包默认值。 +3. 使用更新后的工具发布:在各架构的清单目录内,Windows 写入 `stable.yml`,macOS 写入 `stable-mac.yml`;正式渠道二进制位于 `stable/` 下。在正式发布前,通过测试部署核对选定渠道和请求的清单。仍请求 `latest` 清单的旧包不会自动迁移。 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/forge-worktree-entry/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/forge-worktree-entry/guide.i18n.yaml new file mode 100644 index 0000000000..a5bd96e947 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/forge-worktree-entry/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/forge-worktree-entry/guide.md +/: + en: e25e3adac59e3584 + zh: 1a16ac4c16ab3a0d +/one-plugin-entry-for-change-requests-and-worktrees: + en: 2eb2f0805d09f0c4 + zh: a7b02646515b39df +/one-plugin-entry-for-change-requests-and-worktrees/change: + en: b814d167dea20941 + zh: a8e3c416acd4fb65 +/one-plugin-entry-for-change-requests-and-worktrees/migration: + en: 08b0e3bae9e9697e + zh: 62f66d48328f1037 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/forge-worktree-entry/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/forge-worktree-entry/guide.md new file mode 100644 index 0000000000..3b8bf683bd --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/forge-worktree-entry/guide.md @@ -0,0 +1,29 @@ +--- +kind: upgrade-guide +description: "The Plugins page replaces the standalone Git worktrees card with Change requests & worktrees." +--- + +# One plugin entry for change requests and worktrees + +English | [中文](guide.zh.md) + +## Change + +Web and Desktop use **Change requests & worktrees** to enable the review panel, the new-Session worktree option, and the model's `create_worktree` tool together. The separate **Git worktrees** card is hidden. Profiles that already enabled the Forge bundle also gain the model tool. + +Existing profiles are not migrated automatically. A selected `@deepseek-ai/dsh-experimental-tool-worktree` bundle stays active even though its card is hidden, so turning off Forge does not disable that standalone tool. Selecting both it and `@deepseek-ai/dsh-experimental-forge-bundle` inserts duplicate `worktree` and `tool-worktree` ids, making those component controls read-only. Remove the standalone selection before using the unified switch. + +## Migration + +1. Stop Web or quit Desktop. Back up `$DSH_HOME/profiles//package.json` and its sibling `cordis.patch.yml`. Use `~/.dsh` when `DSH_HOME` is unset; the standard profile names are `web` and `desktop`. For a custom profile, use its name. +2. Edit `dsh.profile.bundles` in that `package.json`. Remove every `@deepseek-ai/dsh-experimental-tool-worktree` entry. To keep worktree support through the unified switch, add `@deepseek-ai/dsh-experimental-forge-bundle` once; if it is already selected, keep that entry. To disable both features, remove both bundle names. Preserve all other entries and fields. +3. Keep existing `cordis.patch.yml` overrides: the `worktree`, `tool-worktree`, and `optional-forge-panel` ids are unchanged. To use the Forge panel without exposing the model tool, add or update this entry in the existing patch list: + + ```yaml + - id: tool-worktree + disabled: true + ``` + +4. Restart the application. In Plugins, confirm that **Change requests & worktrees** reflects your selection. With no disabling row override, enabling it provides the panel and `create_worktree`; turning it off removes both. Confirm that the profile lists no standalone worktree bundle. Existing worktrees, branches, and Session records are retained. + +Headless profiles and custom compositions that need only the standalone tool can keep it; do not also select the Forge bundle. In Web or Desktop, that standalone selection remains hidden and must be managed through the profile file. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/forge-worktree-entry/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/forge-worktree-entry/guide.zh.md new file mode 100644 index 0000000000..118f00bd22 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/forge-worktree-entry/guide.zh.md @@ -0,0 +1,29 @@ +--- +kind: upgrade-guide +description: "插件页将独立的 Git 工作树卡片合入变更请求与工作树。" +--- + +# 变更请求与工作树使用同一个插件入口 + +[English](guide.md) | 中文 + +## 变更 + +Web 和 Desktop 通过**变更请求与工作树**同时启用评审面板、新会话的工作树选项和模型的 `create_worktree` 工具。独立的 **Git 工作树**卡片被隐藏。已经启用 Forge 组合包的 profile 也会新增模型工具。 + +已有 profile 不会自动迁移。选中的 `@deepseek-ai/dsh-experimental-tool-worktree` 组合包仍会运行,即使其卡片已隐藏,因此关闭 Forge 不会停用这个独立工具。同时选择它和 `@deepseek-ai/dsh-experimental-forge-bundle` 会插入重复的 `worktree` 与 `tool-worktree` id,使对应组件控件变为只读。使用统一开关前,请移除独立组合包的选择。 + +## 迁移 + +1. 停止 Web 或退出 Desktop。备份 `$DSH_HOME/profiles//package.json` 及同目录下的 `cordis.patch.yml`。未设置 `DSH_HOME` 时使用 `~/.dsh`;标准 profile 名称为 `web` 和 `desktop`。自定义 profile 使用其自身名称。 +2. 编辑该 `package.json` 的 `dsh.profile.bundles`。移除所有 `@deepseek-ai/dsh-experimental-tool-worktree` 条目。若要通过统一开关保留工作树功能,添加一次 `@deepseek-ai/dsh-experimental-forge-bundle`;若已选中它,则保留该条目。若要关闭两项功能,则移除这两个组合包名称。保留其他所有条目和字段。 +3. 保留现有的 `cordis.patch.yml` 覆盖配置:`worktree`、`tool-worktree` 和 `optional-forge-panel` 的 id 均未改变。若只使用 Forge 面板,不向模型提供工作树工具,在现有 patch 列表中添加或更新以下条目: + + ```yaml + - id: tool-worktree + disabled: true + ``` + +4. 重启应用。在插件页确认**变更请求与工作树**的状态符合你的选择。没有禁用插件行的覆盖配置时,启用它会提供面板与 `create_worktree`,关闭后两者均移除。确认 profile 不再列出独立的工作树组合包。已有工作树、分支和 Session 记录均保留。 + +仅需要独立工具的 Headless profile 和自定义组合可以继续保留它,但不要同时选择 Forge 组合包。在 Web 或 Desktop 中,该独立选择仍然隐藏,需要通过 profile 文件管理。 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/projection-released-checkpoint/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/projection-released-checkpoint/guide.i18n.yaml new file mode 100644 index 0000000000..b4d7a475e4 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/projection-released-checkpoint/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/projection-released-checkpoint/guide.md +/: + en: 738f2376c5a8ebe5 + zh: 078f2a08d8b6ad4b +/released-projection-units-stay-in-checkpoints: + en: 9355d9de711acc7d + zh: eb89da6b886a7556 +/released-projection-units-stay-in-checkpoints/change: + en: 5944b6c7b72e15e4 + zh: 051d68e2eb5151d7 +/released-projection-units-stay-in-checkpoints/migration: + en: 9f24ecf12748eb41 + zh: c07649e79a0174f9 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/projection-released-checkpoint/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/projection-released-checkpoint/guide.md new file mode 100644 index 0000000000..125c513005 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/projection-released-checkpoint/guide.md @@ -0,0 +1,30 @@ +--- +kind: upgrade-guide +description: "`ctx.sessionProjections.checkpoint(session)` keeps a released unit's last row instead of omitting it." +--- + +# Released projection units stay in checkpoints + +English | [中文](guide.zh.md) + +## Change + +Previously, when the last registrant of a projection key released it, through `handle.dispose()` or by unloading the owning fiber, the registry deleted the unit and `ctx.sessionProjections.checkpoint(session)` stopped returning its row. + +Now the released unit stays in the registry as a predecessor. It leaves event drives, `snapshot()`, `summaryKeys()`, `restoreFloor()`, `restore()`, `viewCheckpoint()`, and `hydrate()`, and Sessions created afterward get no cell for it. `checkpoint(session)` still returns the last row the unit folded for each existing Session, and the registry no longer advances that row. Registering the same key again replaces the predecessor and refolds the log; see the [registration lifecycle](../../../../packages/session/session-projection/README.md). + +This affects plugin code and tests that read `checkpoint()` after a projection's owner unloads, such as HMR tests that assert a released key is absent. `@deepseek-ai/dsh-session-projection-cache` stores these rows, so a graceful shutdown no longer drops the values that Web-layer plugins folded before their Sessions detach. + +## Migration + +1. Find reads of `sessionProjections.checkpoint(...)` that run after a projection's owner unloads, including calls through an alias of `ctx.sessionProjections`. +2. Replace an assertion that the released key is absent from an existing Session with one that its row keeps the pre-unload value: + + ```ts ignore-check + const row = ctx.sessionProjections.checkpoint(session).myKey + await owner.dispose() + expect(ctx.sessionProjections.checkpoint(session)).toHaveProperty('myKey', row) + ``` + +3. To show that the unit left live participation, assert that a Session created after the unload has no row, `expect(ctx.sessionProjections.checkpoint(ctx.sessions.create())).not.toHaveProperty('myKey')`, or that an event appended after the unload leaves the row unchanged. A client-visible key is also absent from `snapshot(session).values`. +4. Run the affected tests; they pass when no assertion expects a released key to disappear from an existing Session's checkpoint. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/projection-released-checkpoint/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/projection-released-checkpoint/guide.zh.md new file mode 100644 index 0000000000..7ec9b32ece --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/projection-released-checkpoint/guide.zh.md @@ -0,0 +1,30 @@ +--- +kind: upgrade-guide +description: "`ctx.sessionProjections.checkpoint(session)` 保留已释放单元的最后一行,而不再省略它。" +--- + +# 已释放的投影单元保留在检查点中 + +[English](guide.md) | 中文 + +## 变更 + +此前,投影 key 的最后一个注册方通过 `handle.dispose()` 或卸载所属 fiber 释放它时,注册表会删除该单元,`ctx.sessionProjections.checkpoint(session)` 也不再返回它的行。 + +现在,已释放的单元作为前任留在注册表中。它退出事件推进、`snapshot()`、`summaryKeys()`、`restoreFloor()`、`restore()`、`viewCheckpoint()` 和 `hydrate()`,此后新建的 Session 也不会为它建立 cell。`checkpoint(session)` 仍为每个已有 Session 返回该单元最后折叠出的行,注册表不再推进这一行。同一 key 再次注册时会替换前任并重新折叠日志;参见[注册生命周期](../../../../packages/session/session-projection/README.zh.md)。 + +这会影响在投影所属方卸载后读取 `checkpoint()` 的插件代码和测试,例如断言已释放 key 不存在的 HMR 测试。`@deepseek-ai/dsh-session-projection-cache` 会存储这些行,因此正常关停不再丢掉 Web 层插件在其 Session detach 之前已折叠出的值。 + +## 迁移 + +1. 找出在投影所属方卸载后执行的 `sessionProjections.checkpoint(...)` 读取,包括通过 `ctx.sessionProjections` 别名的调用。 +2. 把「已有 Session 中不存在已释放 key」的断言,改为断言它的行保持卸载前的值: + + ```ts ignore-check + const row = ctx.sessionProjections.checkpoint(session).myKey + await owner.dispose() + expect(ctx.sessionProjections.checkpoint(session)).toHaveProperty('myKey', row) + ``` + +3. 如需证明该单元已退出实时参与,可断言卸载后新建的 Session 没有这一行,即 `expect(ctx.sessionProjections.checkpoint(ctx.sessions.create())).not.toHaveProperty('myKey')`,或断言卸载后追加的事件不会改变这一行。客户端可见的 key 也不会出现在 `snapshot(session).values` 中。 +4. 运行受影响的测试;当没有断言要求已释放 key 从已有 Session 的检查点中消失时,测试即通过。 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/projection-snapshot-scope/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/projection-snapshot-scope/guide.i18n.yaml new file mode 100644 index 0000000000..b1cc4b5f88 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/projection-snapshot-scope/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/projection-snapshot-scope/guide.md +/: + en: 82a6e0261297097c + zh: c86e496c258e72b2 +/projection-snapshot-scope: + en: c8b08322a943d6b2 + zh: 31862a259b7738bb +/projection-snapshot-scope/change: + en: d95cd644b01419a1 + zh: 89038a6de9b74e8a +/projection-snapshot-scope/migration: + en: 408d88a4ea6fe3a9 + zh: 397de0dc2d8318df diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/projection-snapshot-scope/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/projection-snapshot-scope/guide.md new file mode 100644 index 0000000000..66138aa3f2 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/projection-snapshot-scope/guide.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "Projection snapshot reads compute only the returned units, the registry cached snapshot read is deprecated, and Session list summaries omit context pressure and breakdown." +--- + +# Projection snapshot scope + +English | [中文](guide.zh.md) + +## Change + +`ctx.sessionProjections.snapshot(session, keys)` from `@deepseek-ai/dsh-session-projection` computes only the units it returns: with `keys` it computes only the selected units, and without `keys` it computes every unit that defines `wire` and no Host-only unit. A plugin can no longer rely on other units being computed after it calls `snapshot`. The registry's `cachedSnapshot(session, keys)` is deprecated; it returns only values another read already computed and may omit keys. The persisted cache read `ctx.sessionProjectionCache.cachedSnapshot(header, keys)` is unchanged. `session.list` summary `projections` no longer carry `contextBreakdown` or `contextPressure`; the `@deepseek-ai/dsh-token-meter` entries are now `SessionProjectionSummaryExcluded`. Plugins and SDK or wire clients that read these values are affected. + +## Migration + +1. Read Host state of your own unit through the handle that `await ctx.sessionProjections.register(definition)` returns, with `handle.stateOf(session)`, instead of calling `snapshot` first and reading cells afterwards. +2. Replace calls to the registry's `cachedSnapshot(session, keys)` with `snapshot(session, keys)`. +3. Read `contextBreakdown` and `contextPressure` from an opened Session, for example the Session projection snapshot or follow baseline, instead of from `session.list` rows. +4. Rebuild your plugins and clients, confirm that type checks pass without `no-deprecated` findings for `cachedSnapshot`, and confirm that an opened Session still shows its context meter. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/projection-snapshot-scope/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/projection-snapshot-scope/guide.zh.md new file mode 100644 index 0000000000..f11e97173b --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/projection-snapshot-scope/guide.zh.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "投影 snapshot 读取只计算返回的单元,注册表的 cached snapshot 读取已废弃,Session 列表摘要不再包含 context pressure 和 breakdown。" +--- + +# 投影 snapshot 的计算范围 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-session-projection` 的 `ctx.sessionProjections.snapshot(session, keys)` 只计算它返回的单元:传 `keys` 时只计算选中的单元,不传时计算每个定义了 `wire` 的单元、不计算 Host 专用单元。插件不能再依赖调用 `snapshot` 之后其他单元已经算好。注册表的 `cachedSnapshot(session, keys)` 已废弃,它只返回其他读取已经算好的值,可能缺少 key。持久化缓存的读取 `ctx.sessionProjectionCache.cachedSnapshot(header, keys)` 不变。`session.list` 摘要的 `projections` 不再携带 `contextBreakdown` 和 `contextPressure`,`@deepseek-ai/dsh-token-meter` 的这两项改为 `SessionProjectionSummaryExcluded`。读取这些值的插件以及 SDK 或 wire 客户端受影响。 + +## 迁移 + +1. 通过 `await ctx.sessionProjections.register(definition)` 返回的句柄,用 `handle.stateOf(session)` 读取自己单元的 Host 状态,不要先调用 `snapshot` 再读取 cell。 +2. 把对注册表 `cachedSnapshot(session, keys)` 的调用替换为 `snapshot(session, keys)`。 +3. 从打开的 Session 读取 `contextBreakdown` 和 `contextPressure`,例如 Session 投影 snapshot 或 follow 基线,不要从 `session.list` 行读取。 +4. 重新构建插件和客户端,确认类型检查通过、`cachedSnapshot` 没有 `no-deprecated` 告警,并确认打开的 Session 仍显示上下文用量。 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/retained-ptc-timeout/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/retained-ptc-timeout/guide.i18n.yaml new file mode 100644 index 0000000000..c3bae57567 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/retained-ptc-timeout/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/retained-ptc-timeout/guide.md +/: + en: 81282ebd9fb3522f + zh: a10b0dd429c1e527 +/retained-ptc-timeout-recovery: + en: 077a655f2f967e61 + zh: 2c3458dc17023b75 +/retained-ptc-timeout-recovery/change: + en: f3ebfde587b16d65 + zh: b48f8fdc91d84ccb +/retained-ptc-timeout-recovery/migration: + en: 969be7aa004c1c83 + zh: a8dfde8f75efaa9a diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/retained-ptc-timeout/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/retained-ptc-timeout/guide.md new file mode 100644 index 0000000000..90b11d626b --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/retained-ptc-timeout/guide.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Retained Node PTC uses cooperative stop on timeout or cancellation, with process termination when acknowledgment fails." +--- + +# Retained PTC timeout recovery + +English | [中文](guide.zh.md) + +## Change + +Timeouts and cancellation in `persistent-in-memory` mode and `vm-script` with `scriptPersistence: true` request cooperative stop. Previously, they always cleared the environment. Canceled tool calls throw, allowing `catch` and `finally` to run. Stop acknowledgment waits for the evaluation to settle and preserves variables and earlier mutations. The host waits up to `stopGraceMs` (default `3000`) for acknowledgment, then terminates the managed process range if acknowledgment is missing or fails. A blocked loop therefore still loses state. Temporary execution continues to terminate its process. + +The runtime returns after acknowledgment or fallback cleanup, with the original `timeout` or `abort` kind and a diagnostic stating whether variables survived. Confirmed termination allows the next call to create an empty environment. Failed cleanup returns `worker-exit` and blocks reuse until host release succeeds. Timers, arbitrary user Promises, I/O callbacks, Workers and subprocesses have no per-evaluation cancellation guarantee; acknowledgment does not mean all Node activity has stopped. + +## Migration + +1. Check the reported state outcome instead of assuming that timeout or cancellation clears variables. Reuse retained bindings; recreate them after fallback termination. Earlier effects are not rolled back. +2. Use the host's `PtcExecutor.reset(agent)` or runtime `release(owner)` if cleanup failed or retained resources cannot be trusted. No reset tool is exposed to the model. Await reset before running more code. +3. Set `stopGraceMs` in the `@deepseek-ai/dsh-ptc-runtime-node` configuration if needed. It bounds acknowledgment separately from the existing process-cleanup `graceMs`. Verify that an asynchronous wait preserves an earlier variable and a blocked loop clears it. See the [runtime reference](../../../../packages/ptc-runtime/ptc-runtime-node/README.md). +4. When `launch.bootstrapPath` points to a separately installed Node bootstrap, upgrade it with the host. Readiness checks the protocol revision before dispatch; an older bootstrap reports `bootstrap protocol mismatch` instead of running code. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/retained-ptc-timeout/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/retained-ptc-timeout/guide.zh.md new file mode 100644 index 0000000000..3d0fd5d770 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/retained-ptc-timeout/guide.zh.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "保留状态的 Node PTC 在超时或取消时协作式停止,确认失败时终止进程兜底。" +--- + +# 保留状态的 PTC 超时恢复 + +[English](guide.md) | 中文 + +## 变更 + +`persistent-in-memory` 模式及启用 `scriptPersistence: true` 的 `vm-script` 在超时或取消时请求协作式停止。此前这两种情况总会清空环境;现在取消的工具调用正常抛错,允许 `catch` 和 `finally` 运行。停止确认等待本次执行结束,并保留变量及此前修改。宿主最多等待 `stopGraceMs`(默认 `3000`)取得确认,未收到确认或确认失败时终止受管进程范围。因此阻塞的死循环仍会丢失状态。临时执行仍终止进程。 + +运行时在收到确认或兜底清理完成后返回,保留原来的 `timeout` 或 `abort` 类型,并在诊断中说明变量是否保留。确认终止后,下一次调用可创建空环境。清理失败返回 `worker-exit`,并阻止复用,直到宿主释放成功。定时器、任意用户 Promise、I/O 回调、Worker 和子进程不承诺按执行取消;收到确认不表示所有 Node 活动均已停止。 + +## 迁移 + +1. 检查报告的状态结果,不再假定超时或取消会清空变量。复用保留的绑定;兜底终止后重新创建。此前的效果不会回滚。 +2. 清理失败或保留资源不可信时,使用宿主的 `PtcExecutor.reset(agent)` 或运行时 `release(owner)`。模型不会获得重置工具。等待重置完成后再执行代码。 +3. 按需在 `@deepseek-ai/dsh-ptc-runtime-node` 配置中设置 `stopGraceMs`。它限制确认等待时间,与现有进程清理的 `graceMs` 独立。验证异步等待会保留此前变量,阻塞的死循环会清空变量。参阅[运行时参考](../../../../packages/ptc-runtime/ptc-runtime-node/README.zh.md)。 +4. 如果 `launch.bootstrapPath` 指向单独安装的 Node bootstrap,随宿主一起升级它。派发前的就绪握手会检查协议版本;旧 bootstrap 报告 `bootstrap protocol mismatch`,不会执行代码。 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.i18n.yaml new file mode 100644 index 0000000000..f0cb2c310b --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.md +/: + en: 5b07a9a586fe0ef8 + zh: 957123dc3a23c9a9 +/deepseek-web-search-and-fetch-follow-the-deepseek-model-route: + en: bf3ccc3a2347cdbf + zh: ff4557b79d972e56 +/deepseek-web-search-and-fetch-follow-the-deepseek-model-route/change: + en: ff12048c0d6a6e11 + zh: 159e178776739bbe +/deepseek-web-search-and-fetch-follow-the-deepseek-model-route/migration: + en: 97da49ad7b5342fa + zh: ce0f49d638045891 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.md new file mode 100644 index 0000000000..aa3eefe5fc --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.md @@ -0,0 +1,17 @@ +--- +kind: upgrade-guide +description: "DeepSeek web search and fetch take their endpoint from the DeepSeek model route; the shared baseURL, apiKeyEnv, and apiKey fields are removed in favor of per-capability fields." +--- + +# DeepSeek web search and fetch follow the DeepSeek model route + +English | [中文](guide.zh.md) + +## Change + +Previously, the DeepSeek Search API and Fetch API (`web-deepseek-api`) sent their credential to one shared `baseURL` (default `https://api.deepseek.com/v1/tools`): the account token when the Session's latest model request used the `deepseek-account` route and the user was signed in, otherwise the key under `apiKeyEnv` (default `DEEPSEEK_API_KEY`) or a literal `apiKey`. Now each capability follows the same route rule but takes the endpoint from the route: on `deepseek-account` the account token goes to the account route's origin with `/v1/tools`; otherwise, including a signed-out account conversation, the key under the `apiKeyEnv` of the `deepseek-official` route goes to that route's origin with `/v1/tools`, so `DEEPSEEK_BASE_URL` now moves Search and Fetch too. Without a `deepseek-official` route, `DEEPSEEK_API_KEY` goes to `https://api.deepseek.com/v1/tools`; a mounted route whose settings do not resolve fails the operation instead. The `baseURL`, `apiKeyEnv`, and `apiKey` fields are removed; stored values load without error and are ignored. Setting either `searchBaseURL` or `searchApiKeyEnv` (`fetchBaseURL` or `fetchApiKeyEnv` for Fetch) pins that capability to its own endpoint (default `https://api.deepseek.com/v1/tools`) and key (default `DEEPSEEK_API_KEY`) and never sends the account token. These fields are YAML-only. Users without those YAML fields, without `DEEPSEEK_BASE_URL`, and without a custom model base URL see no change. + +## Migration + +1. Users who set `baseURL`, `apiKeyEnv`, or a literal `apiKey` on the `web-deepseek-api` row: for each of Search and Fetch, compare the old endpoint and key with what the route now sends, the origin of the DeepSeek model provider's base URL with `/v1/tools` and that provider's `apiKeyEnv`. When both match, remove the old fields. Otherwise set the pair, `searchBaseURL` with `searchApiKeyEnv` (`fetchBaseURL` with `fetchApiKeyEnv`), to the old endpoint and key reference, setting both fields even when one old value equals the route's: an unset field of the pair falls back to `https://api.deepseek.com/v1/tools` or `DEEPSEEK_API_KEY`, never to the route's value. For a literal `apiKey`, store it in the credential store or the launching environment under a reference and use that reference. A pinned capability never sends the account token. Confirm: `dsh` starts, and a search and a fetch reach the intended endpoint without a credential error. +2. Users with `DEEPSEEK_BASE_URL` or a custom base URL on the DeepSeek model provider whose host does not serve `/v1/tools`: set the same pair to `https://api.deepseek.com/v1/tools` and the reference of a key that endpoint accepts. Confirm: a search returns results. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.zh.md new file mode 100644 index 0000000000..ad2ff9a93b --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.zh.md @@ -0,0 +1,17 @@ +--- +kind: upgrade-guide +description: "DeepSeek 网页搜索与获取从 DeepSeek 模型路由取得端点;共享的 baseURL、apiKeyEnv 与 apiKey 字段已移除,改用按能力区分的字段。" +--- + +# DeepSeek 网页搜索与获取跟随 DeepSeek 模型路由 + +[English](guide.md) | 中文 + +## 变更 + +过去,DeepSeek Search API 与 Fetch API(`web-deepseek-api`)把凭据发往同一个共享的 `baseURL`(默认 `https://api.deepseek.com/v1/tools`):会话最近一次模型请求使用 `deepseek-account` 路由且用户已登录时发送账号令牌,否则发送 `apiKeyEnv`(默认 `DEEPSEEK_API_KEY`)下的密钥或字面 `apiKey`。现在每个能力沿用相同的路由规则,但端点取自路由:在 `deepseek-account` 上把账号令牌发往账号路由的 origin 加 `/v1/tools`;其他情况(包括未登录的账号会话)把 `deepseek-official` 路由 `apiKeyEnv` 下的密钥发往该路由的 origin 加 `/v1/tools`,因此 `DEEPSEEK_BASE_URL` 现在也会改变 Search 与 Fetch 的端点。没有 `deepseek-official` 路由时,把 `DEEPSEEK_API_KEY` 发往 `https://api.deepseek.com/v1/tools`;已挂载的路由设置无法解析时,操作直接失败。`baseURL`、`apiKeyEnv` 与 `apiKey` 字段已移除;已保存的值可以正常加载,但会被忽略。只要设置 `searchBaseURL` 或 `searchApiKeyEnv` 中的任一项(Fetch 为 `fetchBaseURL` 或 `fetchApiKeyEnv`),就会把该能力固定到其自己的端点(默认 `https://api.deepseek.com/v1/tools`)和密钥(默认 `DEEPSEEK_API_KEY`),且从不发送账号令牌。这些字段只能在 YAML 中设置。没有设置这些 YAML 字段、没有设置 `DEEPSEEK_BASE_URL`、也没有自定义模型基础地址的用户不受影响。 + +## 迁移 + +1. 在 `web-deepseek-api` 条目上设置过 `baseURL`、`apiKeyEnv` 或字面 `apiKey` 的用户:对 Search 与 Fetch 分别比较原端点和密钥与路由现在发送的值,即 DeepSeek 模型提供方基础地址的 origin 加 `/v1/tools`,以及该提供方的 `apiKeyEnv`。两者都一致时,删除旧字段。否则把成对字段 `searchBaseURL` 与 `searchApiKeyEnv`(Fetch 为 `fetchBaseURL` 与 `fetchApiKeyEnv`)设为原端点和原密钥引用;即使其中一个原值与路由的值相同,也要两个字段都设置:成对字段中未设置的一项回退到 `https://api.deepseek.com/v1/tools` 或 `DEEPSEEK_API_KEY`,不会继承路由的值。设置过字面 `apiKey` 时,把该值以某个引用存入凭据存储或启动环境,并使用该引用。单独配置的能力从不发送账号令牌。确认:`dsh` 正常启动,且搜索和抓取都到达预期端点,没有凭据错误。 +2. 设置了 `DEEPSEEK_BASE_URL` 或为 DeepSeek 模型提供方设置了自定义基础地址、且该主机不提供 `/v1/tools` 的用户:把同一对字段设为 `https://api.deepseek.com/v1/tools` 和该端点接受的密钥引用。确认:搜索返回结果。 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/web-search-default-provider/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/web-search-default-provider/guide.i18n.yaml new file mode 100644 index 0000000000..bee12dc3c8 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/web-search-default-provider/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/web-search-default-provider/guide.md +/: + en: 2a8012259f83b47f + zh: 913cf3290acedfd0 +/search-defaults-to-the-deepseek-search-api-exa-and-perplexity-are-removed: + en: d74d9ae56bde707f + zh: 45eafef25e659780 +/search-defaults-to-the-deepseek-search-api-exa-and-perplexity-are-removed/change: + en: acb404257af50295 + zh: c22c29e18fc1dbab +/search-defaults-to-the-deepseek-search-api-exa-and-perplexity-are-removed/migration: + en: 5f69931cdd39ea2f + zh: 7e569007c068151c diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/web-search-default-provider/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/web-search-default-provider/guide.md new file mode 100644 index 0000000000..59d6706645 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/web-search-default-provider/guide.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "The shipped Search default moves from legacy Messages search to the DeepSeek Search API, and the optional Exa and Perplexity search packages are removed." +--- + +# Search defaults to the DeepSeek Search API; Exa and Perplexity are removed + +English | [中文](guide.zh.md) + +## Change + +Previously, the shared base composition set `web.searchProvider` to `deepseek-official`: each search was an auxiliary Messages request to `web-search-deepseek`'s endpoint, sending `Perform a web search for the query: ` to `deepseek-v4-flash` with a server-side `web_search` tool, billed as model tokens. Now it sets `deepseek-search-api`: each search is one direct request to the DeepSeek Search API, without a model request, and may incur Search API charges instead of model tokens. That request sends `query`, `count`, `need_content: false`, and, unless `web-deepseek-api.sendOriginalQuestion` is `false`, the user's latest prompt cut to 128 characters as `ori_question`. Its credential and endpoint follow the [DeepSeek model route](../web-search-credential-modes/guide.md). Compositions that set `web.searchProvider` themselves see no change, and Fetch still defaults to `http`. + +The optional packages `@deepseek-ai/dsh-web-search-exa` (provider `exa`) and `@deepseek-ai/dsh-web-search-perplexity` (provider `perplexity`) are removed. A row naming either package no longer loads, and `searchProvider: exa` or `searchProvider: perplexity` names no provider. + +## Migration + +1. To keep legacy Messages search, set `searchProvider: deepseek-official` in the `config` of the `@deepseek-ai/dsh-web` row, or select DeepSeek (legacy) in the Web search provider menu on the Web search and fetch settings page. Confirm: that menu shows DeepSeek (legacy), and a search appends a `web/deepseek-search-llm-request` event to the Session log. +2. Deployments that mounted Exa or Perplexity: delete the `@deepseek-ai/dsh-web-search-exa` or `@deepseek-ai/dsh-web-search-perplexity` row from `cordis.yml` or the profile patch, remove `EXA_API_KEY` or `PERPLEXITY_API_KEY` if nothing else uses it, and set `searchProvider` to `deepseek-search-api` or `deepseek-official`, or delete it to take the default. Confirm: `dsh` starts without a load error for either package, the Web search provider menu shows the chosen provider, and a search returns results. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/web-search-default-provider/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/web-search-default-provider/guide.zh.md new file mode 100644 index 0000000000..09614fdd2c --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/web-search-default-provider/guide.zh.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "交付的 Search 默认值从旧版 Messages 搜索改为 DeepSeek Search API,可选的 Exa 与 Perplexity 搜索包已移除。" +--- + +# Search 默认使用 DeepSeek Search API;Exa 与 Perplexity 已移除 + +[English](guide.md) | 中文 + +## 变更 + +过去,共享的基础组合把 `web.searchProvider` 设为 `deepseek-official`:每次搜索都是发往 `web-search-deepseek` 端点的一次辅助 Messages 请求,以 `Perform a web search for the query: ` 调用带服务端 `web_search` 工具的 `deepseek-v4-flash`,按模型 token 计费。现在它设为 `deepseek-search-api`:每次搜索是一次直接发往 DeepSeek Search API 的请求,不调用模型,可能产生 Search API 费用而非模型 token 费用。该请求发送 `query`、`count`、`need_content: false`,并且除非 `web-deepseek-api.sendOriginalQuestion` 为 `false`,还会把用户最近一条提示截取前 128 个字符作为 `ori_question` 发送。其凭据和端点跟随 [DeepSeek 模型路由](../web-search-credential-modes/guide.zh.md)。自行设置了 `web.searchProvider` 的组合不受影响,Fetch 仍默认为 `http`。 + +可选包 `@deepseek-ai/dsh-web-search-exa`(提供方 `exa`)与 `@deepseek-ai/dsh-web-search-perplexity`(提供方 `perplexity`)已移除。指名任一包的条目不再能加载,`searchProvider: exa` 或 `searchProvider: perplexity` 也不再对应任何提供方。 + +## 迁移 + +1. 要继续使用旧版 Messages 搜索,在 `@deepseek-ai/dsh-web` 条目的 `config` 中设置 `searchProvider: deepseek-official`,或在「网页搜索与获取」设置页的「网页搜索提供方」菜单中选择 DeepSeek(旧版)。确认:该菜单显示 DeepSeek(旧版),且每次搜索都会向会话日志追加一个 `web/deepseek-search-llm-request` 事件。 +2. 挂载过 Exa 或 Perplexity 的部署:从 `cordis.yml` 或 profile 补丁中删除 `@deepseek-ai/dsh-web-search-exa` 或 `@deepseek-ai/dsh-web-search-perplexity` 条目;若 `EXA_API_KEY` 或 `PERPLEXITY_API_KEY` 不再被其他地方使用,一并移除;再把 `searchProvider` 设为 `deepseek-search-api` 或 `deepseek-official`,或删除该字段以采用默认值。确认:`dsh` 启动时没有这两个包的加载错误,「网页搜索提供方」菜单显示所选提供方,且搜索返回结果。 diff --git a/docs/upgrade-guide/v0.2.2-alpha.3/chat-process-activity-context/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.3/chat-process-activity-context/guide.i18n.yaml new file mode 100644 index 0000000000..55a424f875 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.3/chat-process-activity-context/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.3/chat-process-activity-context/guide.md +/: + en: cee1fd1a4a871edb + zh: d02fdd6e917aacc1 +/chat-processactivity-adds-context: + en: eb074a142ff239d1 + zh: a73ee547890e9ec9 +/chat-processactivity-adds-context/change: + en: 27273494c758b014 + zh: aa34a345a74a39c8 +/chat-processactivity-adds-context/migration: + en: 220c40bf5382ac52 + zh: aae0d1731127571a diff --git a/docs/upgrade-guide/v0.2.2-alpha.3/chat-process-activity-context/guide.md b/docs/upgrade-guide/v0.2.2-alpha.3/chat-process-activity-context/guide.md new file mode 100644 index 0000000000..1c857d6f8b --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.3/chat-process-activity-context/guide.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "Chat ProcessActivity gains a context category, and the running activity uses RunningProcessActivity." +--- + +# Chat `ProcessActivity` adds `context` + +English | [中文](guide.zh.md) + +## Change + +`ProcessActivity`, exported from `@deepseek-ai/dsh-client-ui-chat/client`, gains the member `context`. A closed process group whose members are all Context injections reports it as its only category. `ProcessActivitySummary.running` is now typed as the new exported `RunningProcessActivity`, which excludes `context`. This affects extensions that switch exhaustively over `ProcessActivity` or map every member to a label or icon. + +## Migration + +1. Add a `context` branch wherever code handles every `ProcessActivity` member, for example in a `Record` or a `switch` ending in `assertNever`. +2. Type values read from `summary.running` as `RunningProcessActivity` when they need not cover `context`. +3. Run the extension's typecheck and confirm it compiles without a missing-member error. diff --git a/docs/upgrade-guide/v0.2.2-alpha.3/chat-process-activity-context/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.3/chat-process-activity-context/guide.zh.md new file mode 100644 index 0000000000..c22597612f --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.3/chat-process-activity-context/guide.zh.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "Chat 的 ProcessActivity 新增 context 类别,运行中活动改用 RunningProcessActivity。" +--- + +# Chat `ProcessActivity` 新增 `context` + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-client-ui-chat/client` 导出的 `ProcessActivity` 新增成员 `context`。成员全部为上下文注入的已结束过程组以它作为唯一类别。`ProcessActivitySummary.running` 的类型改为新导出的 `RunningProcessActivity`,该类型不含 `context`。对 `ProcessActivity` 做穷尽 switch 或为每个成员映射文案、图标的扩展受影响。 + +## 迁移 + +1. 在处理全部 `ProcessActivity` 成员的位置补上 `context` 分支,例如 `Record` 或以 `assertNever` 结尾的 `switch`。 +2. 读取 `summary.running` 且无需覆盖 `context` 的值,类型改为 `RunningProcessActivity`。 +3. 运行扩展的类型检查,确认没有缺少成员的编译错误。 diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index 62801fa60d..b04aada43b 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.i18n.yaml @@ -6,11 +6,11 @@ en: b1631dcf25e805a1 zh: 8ded02e2ae87f00b /configure-models/configure-deepseek: - en: 5cf396eb7f22b0f6 - zh: d70688c6cd826f3a + en: 44852da64a6a39ec + zh: 4c280e428f0fec08 /configure-models/add-a-third-party-provider: - en: c8fd933006c3830c - zh: 386f8f7d302e98c3 + en: f6fb44a85dc5e4dd + zh: 2b86ea4492c32ed7 /configure-models/add-a-custom-model-api: en: 17867305a3e1b52a zh: 876fedf1f6f563e4 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index 1ab90f440e..fe831bcaf9 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -10,7 +10,7 @@ Open **Settings → Models**. The DeepSeek card exposes one API-key field; enter ![The Models page: the DeepSeek card, with Add model provider below it](providers-models-page.png) -Opening a provider editor loads its saved key into a masked input. Environment values are never loaded into this input. The key is stored in `$DSH_HOME/.credentials.yaml`, while settings retain only its credential reference. Credential metadata remains redacted. +Keys are write-only. The page receives a redacted descriptor after saving, never the literal secret. The key is stored in `$DSH_HOME/.credentials.yaml`, while settings retain only its credential reference. With a key saved and the default endpoint, DeepSeek's models follow the list the DeepSeek API publishes. The list loads when the models are first needed, refreshes in the background about every ten minutes while they are in use, and falls back to the built-in models when it cannot load. Editing **Models** under **Customized settings** keeps your own list instead and stops following the published one; **Restore defaults** follows it again and reloads it. While the published list is in use, the card shows the built-in models, so a newly published model appears only in the model picker. The [API-key provider reference](../../../packages/llm/llm-deepseek-api-key/README.md) owns the listing, fallback, and gating rules. @@ -18,11 +18,11 @@ With a key saved and the default endpoint, DeepSeek's models follow the list the Choose **Add model provider**. The card opens on **Third-party model provider**: pick a provider dsh ships with — the list shows provider ids such as `anthropic`, `openai`, `moonshotai` for Kimi, or `zai` for GLM — enter its API key, and save. The installed catalog supplies the endpoint, protocol, and model list. -The authentication **?** explains saved-credential priority and environment fallbacks. With no saved key, leave the input blank to try the listed environment variables. A green hint identifies a detected environment variable. A yellow hint means no credential variable was detected; enter an API key or configure the listed variable. An unconfirmed hint means the status read failed or a saved key still covers the environment lookup. Clearing a saved key shows an unconfirmed hint until you save its removal and the page refreshes the environment check. Detection does not validate the credential or test the connection. Provider-managed credential files, including AWS profiles and Google application-default credentials, are outside this check. Providers without native key authentication, such as `openai-codex`, show no key field unless the profile explicitly names a credential reference. +The authentication **?** explains saved-credential priority and environment fallbacks. Leave the key blank to use a saved credential or try the listed environment variables. A green hint identifies a detected environment variable. A yellow hint means no credential variable was detected; enter an API key or configure the listed variable. An unconfirmed hint means the status read failed. Detection does not validate the credential or test the connection. Provider-managed credential files, including AWS profiles and Google application-default credentials, are outside this check. Providers without native key authentication, such as `openai-codex`, show no key field unless the profile explicitly names a credential reference. -A saved key appears masked, with no status text below the input. An empty input uses **Leave blank to use env as auth** and one detection hint. A nonempty input shows no authentication status text. Address configuration variables appear under **Base URL** and do not count as credentials. +A saved credential uses the placeholder **Leave blank to use saved credentials**, with no status text below the input. Environment fallback uses **Leave blank to use env as auth** and one detection hint while the input is empty. A nonempty input shows no authentication status text. Address configuration variables appear under **Base URL** and do not count as credentials. -A nonempty key input has an eye button. Select **Show secret** to check the loaded or newly entered key, and **Hide secret** to mask it again. Changing the input masks it again. Saving an unchanged key preserves its exact stored value. Clearing it and saving removes the saved key. Cancelling preserves the saved key. +A nonempty key input has an eye button. Select **Show secret** to check what you typed, and **Hide secret** to mask it again. Changing the input masks it again. Stored credentials remain write-only. ## Add a custom model API diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index ddb44f1814..bb518c42b9 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -10,7 +10,7 @@ ![模型页:DeepSeek 卡片,及其下方的添加模型提供商入口](providers-models-page.zh.png) -打开提供商编辑器时,已保存密钥会载入遮罩输入框。环境变量的值不会载入此输入框。密钥存储在 `$DSH_HOME/.credentials.yaml` 中,settings 只保留它的凭据引用。凭据元数据仍保持脱敏。 +密钥是只写的。保存后,页面只会收到脱敏描述符,永远不会收到明文密钥。密钥存储在 `$DSH_HOME/.credentials.yaml` 中,settings 只保留它的凭据引用。 保存了密钥且使用默认端点时,DeepSeek 的模型跟随 DeepSeek API 发布的列表。第一次需要模型时加载列表,使用期间约每十分钟在后台刷新一次,无法加载时退回内置模型。在**自定义设置**里编辑**模型目录**会改用你自己的列表,不再跟随发布的列表;点**恢复默认模型**会重新跟随并重新加载。使用发布的列表时,卡片上显示的是内置模型,因此新发布的模型只出现在模型选择器中。列表、兜底和门控规则见 [API key 提供方参考](../../../packages/llm/llm-deepseek-api-key/README.zh.md)。 @@ -18,11 +18,11 @@ 选择**添加模型提供商**。卡片默认打开在**第三方模型提供商**:选取 dsh 自带的提供商——列表显示的是提供商 id,例如 `anthropic`、`openai`、Kimi 对应的 `moonshotai`、GLM 对应的 `zai`——输入其 API 密钥并保存。已安装目录会提供端点、协议和模型列表。 -认证 **?** 说明已保存凭据的优先级和环境后备。没有已保存密钥时,输入留空会尝试读取列出的环境变量。绿色提示标明已检测到环境变量。黄色提示表示未检测到凭据变量;请输入 API 密钥或配置列出的变量。状态未确认表示读取失败,或已保存密钥仍覆盖环境读取。清空已保存密钥后,提示会保持未确认,直到保存删除操作并刷新环境检测。检测不验证凭据,也不测试连接。AWS profile 和 Google 应用默认凭据等提供商管理的凭据文件不在检查范围内。没有原生密钥认证的提供商(例如 `openai-codex`)不显示密钥字段,除非 profile 显式指定了凭据引用。 +认证 **?** 说明已保存凭据的优先级和环境后备。密钥留空时,使用已保存凭据,或尝试读取列出的环境变量。绿色提示标明已检测到环境变量。黄色提示表示未检测到凭据变量;请输入 API 密钥或配置列出的变量。状态未确认表示读取失败。检测不验证凭据,也不测试连接。AWS profile 和 Google 应用默认凭据等提供商管理的凭据文件不在检查范围内。没有原生密钥认证的提供商(例如 `openai-codex`)不显示密钥字段,除非 profile 显式指定了凭据引用。 -已保存密钥以遮罩显示,输入框下方不显示状态文字。输入留空时,显示**留空使用环境认证**占位文字和一条检测提示。输入非空时,不显示认证状态文字。地址配置变量显示在**基础 URL**下方,不计作凭据。 +已保存凭据使用**留空使用已保存凭据**占位文字,输入框下方不显示状态文字。环境后备使用**留空使用环境认证**占位文字,输入留空时显示一条检测提示。输入非空时,不显示认证状态文字。地址配置变量显示在**基础 URL**下方,不计作凭据。 -密钥输入非空时,输入框内显示眼睛按钮。选择**显示密钥**检查已载入或新输入的密钥,选择**隐藏密钥**重新遮住输入。修改输入后会重新遮住密钥。保存未修改的密钥会保留原值。清空并保存会删除已保存密钥。取消编辑会保留已保存密钥。 +密钥输入非空时,输入框内显示眼睛按钮。选择**显示密钥**检查当前输入,选择**隐藏密钥**重新遮住输入。修改输入后会重新遮住密钥。已保存凭据仍保持只写。 ## 添加自定义模型 API diff --git a/docs/user/guide/python-sdk.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml index db2d757606..b4a0f88a1b 100644 --- a/docs/user/guide/python-sdk.i18n.yaml +++ b/docs/user/guide/python-sdk.i18n.yaml @@ -21,8 +21,11 @@ en: 592dd306b920cd83 zh: 8c22110621c12ba5 /get-started-with-the-python-sdk/install-or-define-plugins/opt-in-to-str-replace-editor: - en: 450d54b6e9e38469 - zh: f0dc06a08c073571 + en: 83bd8ce585536906 + zh: 955f9fe02992b387 +/get-started-with-the-python-sdk/install-or-define-plugins/enable-team-mode: + en: 820e942903b8660a + zh: 195b8f6cb00009b8 /get-started-with-the-python-sdk/understand-the-minimal-profile: en: ea2a6521cff50517 zh: 3769760a5e40af8d diff --git a/docs/user/guide/python-sdk.md b/docs/user/guide/python-sdk.md index 38f6a2a531..48885b3258 100644 --- a/docs/user/guide/python-sdk.md +++ b/docs/user/guide/python-sdk.md @@ -162,6 +162,21 @@ The bundled runtime includes `str_replace_editor`, but `sdk-minimal` omits it fr Pass `patches=("/absolute/path/to/editor.patch.yml",)` when constructing `DeepSeekHarness(profile="sdk-minimal", ...)`, or put the patch in `$DSH_HOME/profiles/sdk-minimal/cordis.patch.yml` for persistent configuration. On the next runtime launch, model requests include `str_replace_editor` beside the default tools. The local filesystem provider uses the runtime working directory for relative paths; like the minimal shell, it does not confine access to that directory. The same patch works with the standard `sdk` profile. + +### Enable Team mode + +Use the standard `sdk` profile for Agent Teams; `sdk-minimal` does not include subagents or Team services. Save the [Team patch](../../../apps/cli/config/examples/team-mode.yml) as `team-mode.yml`: + +```yaml +- id: agent-team + config: + defaultEnabled: true +``` + +Pass its absolute path in the SDK's `patches` option with `profile="sdk"`. Python uses `patches=("/absolute/path/to/team-mode.yml",)`; TypeScript uses `patches: ["/absolute/path/to/team-mode.yml"]`. The patch replaces the `agent-team` row's whole configuration, so include any custom `maxTasks` limit in the same mapping. + +Create a new root Session and ask it to create and list a shared task. Its first model request includes the Team tools and collaboration guidance. The root saves its mode, and subagents share it. Resuming an existing Session preserves its mode, even if it was explicitly turned off or never enabled; changing or removing this patch does not override saved selections. See the [Team service reference](../../../packages/team/agent-team/README.md#use-this-package) for mode and task semantics. + ## Understand the minimal profile | Property | Value | diff --git a/docs/user/guide/python-sdk.zh.md b/docs/user/guide/python-sdk.zh.md index cd321cfad6..502c14b8e2 100644 --- a/docs/user/guide/python-sdk.zh.md +++ b/docs/user/guide/python-sdk.zh.md @@ -162,6 +162,21 @@ dsh plugin --profile sdk-minimal add file:C:/work/my-plugin-bundle 构造 `DeepSeekHarness(profile="sdk-minimal", ...)` 时传入 `patches=("/absolute/path/to/editor.patch.yml",)`,或将 patch 写入 `$DSH_HOME/profiles/sdk-minimal/cordis.patch.yml` 以持久保存配置。下次运行时启动后,模型请求会在默认工具之外包含 `str_replace_editor`。本地文件系统后端以运行时工作目录解析相对路径;与极简 shell 一样,它不会将访问限制在该目录内。该 patch 也适用于标准 `sdk` profile。 + +### 启用 Team 模式 + +使用 Agent Teams 时选择标准 `sdk` profile;`sdk-minimal` 不包含子代理或 Team 服务。将 [Team patch](../../../apps/cli/config/examples/team-mode.yml) 保存为 `team-mode.yml`: + +```yaml +- id: agent-team + config: + defaultEnabled: true +``` + +选择 `profile="sdk"`,并在 SDK 的 `patches` 选项中传入其绝对路径。Python 使用 `patches=("/absolute/path/to/team-mode.yml",)`;TypeScript 使用 `patches: ["/absolute/path/to/team-mode.yml"]`。此 patch 替换 `agent-team` 配置项的整个配置,因此自定义 `maxTasks` 限制也应写在同一个映射中。 + +创建新根 Session,让它创建并列出共享任务。首次模型请求即包含 Team 工具和协作提示词。根会话保存其模式,子代理共享该模式。恢复已有 Session 时保留其模式,即使它被明确关闭或从未启用;修改或移除此 patch 不会覆盖已保存的选择。模式和任务语义参见 [Team 服务参考](../../../packages/team/agent-team/README.zh.md#use-this-package)。 + ## 理解极简 profile | 属性 | 值 | diff --git a/docs/web-styling.i18n.yaml b/docs/web-styling.i18n.yaml index 28ee616353..c02bbea299 100644 --- a/docs/web-styling.i18n.yaml +++ b/docs/web-styling.i18n.yaml @@ -9,14 +9,17 @@ en: dc8c41fbf9869179 zh: e45976a585cb9c86 /web-ui-style-reference/component-rules: - en: 4ee4a6fd2917306a - zh: d7d68c140a698ab0 + en: 8b301ae4862ee8e5 + zh: eb65019fb1e26f30 /web-ui-style-reference/input-capabilities: en: bf2e98bce68743e2 zh: fc58dcbb60bb944a /web-ui-style-reference/corner-radii-and-settings-cards: en: 118972f6c2bd6ce4 zh: 85f8416b03e4dd8b +/web-ui-style-reference/review-the-assembled-design: + en: 21bed565fa27b823 + zh: 84b5a96127c842c0 /web-ui-style-reference/changing-the-system: en: 71501119492a3ad4 zh: 87bf6c9406d0d9ed diff --git a/docs/web-styling.md b/docs/web-styling.md index f2ae2a6ffd..1b8a631f79 100644 --- a/docs/web-styling.md +++ b/docs/web-styling.md @@ -26,6 +26,8 @@ Global style sheets belong in `ui-theme/src/styles/`. Component styles live besi - Elevated surfaces (menus, popovers, modals, panels, floating buttons, the composer) set `border: 0` and take `box-shadow: var(--dsw-elevation-panel)`, `var(--dsw-elevation-prominent)`, or the composer's `var(--dsw-elevation-soft)` (larger blur at lower alpha): the 0.5px hairline stroke is the first shadow layer, and `--dsw-elevation-stroke-color` rebinds or suppresses it per surface or state. Never pair a `--dsw-alias-border-*` border with an lv/elevation shadow — the ui-theme elevation spec rejects the pairing; state-colored borders (warn panels) stay real borders. - Existing and new dropdown, context, submenu, and selection menus use `Menu` or wrap custom content in `MenuSurface`. The material is the theme-owned `--dsw-menu-surface-fill` plus `--dsw-menu-backdrop-filter`; feature and platform CSS must not override either. The ui-theme menu spec rejects unwrapped menu/listbox renderers, material overrides, and token redefinitions. The schedule-owned `TaskMenu` and `ClockPicker` retain their existing containers and material as explicit exceptions ([owner](../packages/client/ui-schedule/README.md)). - Modal backdrops, including settings and confirmation dialogs, retain their translucent dark mask without blur. The theme sets `--dsw-mask-blur` to `none`; Desktop-owned modal overlays also leave the parent page unfiltered. +- Floating panels preserve horizontal anchor alignment when neither vertical side fits. Try vertical window bounds before a fallback that also clears horizontal anchoring; every candidate reserves the window edge and frame margins. + - Menu background filtering stays on an isolated layer so nested menus and fixed overlays retain their positioning and backdrop. On macOS, `MenuSurface` adds an opaque backing behind page content only within each menu’s bounds, allowing Chromium to blur content over native vibrancy; CSS anchors follow placement and size, and unmounting removes the backing. Overlays without this backing use `--dsw-specific-menu`, whose theme-owned macOS fill is 94% opaque to keep underlying text from bleeding through. Other elevated surfaces using that fill pair it with the same filter on their own isolated background when they contain fixed overlays. - Flat borders and separators that use a neutral `--dsw-alias-border-*` token draw at `0.5px` — buttons, inputs, cards, row dividers, and separators drawn as filled boxes (menu separators, the conversation header seam, markdown `hr`, vertical rails) share the hairline weight, which Chromium paints as one device pixel. Dashed affordances and state-colored borders keep 1px; spinner ring tracks keep their width through the spec's explicit allowlist. The ui-theme elevation spec rejects wider neutral solid borders. - Clickable artifact links (markdown anchors, prose file mentions, web source and fetch links, produced-file chips, workflow member links) color through `--dsw-alias-link` at `font-weight: 500`, with no underline at rest and a dotted 3px-offset underline on hover/focus. Compact Thinking Markdown keeps tertiary text color and a resting dotted underline ([compact presentation](../packages/client/ui-primitives/README.md#rendering-agent-output)). Text-leading anchors also lead with the ui-primitives `LinkIconMedium` category glyph riding `currentColor`, which for a well-known external host is that site's own mark instead of the globe; workflow member links and image-only anchors carry no glyph, and tool-row file links keep their grey dotted affordance. @@ -46,6 +48,10 @@ On a mixed-input device, a mouse preview can appear while the primary-pointer CS The [DSH unified corner-radius standard](ui-radius.md) defines the radius scale, component size mapping, circle and capsule exceptions, nested hover geometry, and settings-card fills and strokes. +## Review the assembled design + +Use the [UI review baseline](ui-design-review-cases.md#review-baseline) to combine these material and typography rules with radius, responsive behavior, feedback and artwork guidance across the [entire page or modal](ui-design-review-cases.md#whole-interface-review). Compare all regions and same-role elements within that unit; individually compliant components can still form doubled edges, inconsistent grouping or clipped focus indicators. This document and the theme remain the styling owners. + ## Changing the system Add or change a shared token in the owning `ui-theme` sheet, then consume its semantic alias from feature packages. Update the owning package reference when a public styling contract changes. Visual behavior follows the [testing policy](testing.md); the [archived styling-system Agent Note](../.agents/notes/archived/process/2026-07-19-web-styling-system.md) records framework rationale. diff --git a/docs/web-styling.zh.md b/docs/web-styling.zh.md index 6ad928862e..4c56e73820 100644 --- a/docs/web-styling.zh.md +++ b/docs/web-styling.zh.md @@ -26,6 +26,8 @@ - 高层级表面(菜单、浮层、对话框、面板、悬浮按钮、输入框)设 `border: 0` 并使用 `box-shadow: var(--dsw-elevation-panel)`、`var(--dsw-elevation-prominent)` 或输入框专用的 `var(--dsw-elevation-soft)`(更大模糊、更低透明度):0.5px 发丝描边是第一层投影,`--dsw-elevation-stroke-color` 可按表面或状态重绑或抑制描边。不得将 `--dsw-alias-border-*` border 与 lv/elevation 投影配对——ui-theme 的 elevation spec 会拒绝;状态色 border(warn 面板)保持真 border。 - 存量和新增的下拉菜单、上下文菜单、子菜单及选择菜单使用 `Menu`,自定义内容用 `MenuSurface` 包裹。材质由主题的 `--dsw-menu-surface-fill` 和 `--dsw-menu-backdrop-filter` 组成;功能及平台 CSS 不得覆盖两者。ui-theme 菜单检查拒绝未使用共享容器的 menu/listbox 渲染、材质覆盖和 token 重定义。计划任务自有的 `TaskMenu` 与 `ClockPicker` 是明确例外,保留现有容器和材质([所属包](../packages/client/ui-schedule/README.zh.md))。 - 设置、确认框等模态弹窗保留黑色半透明遮罩,不模糊背景。主题将 `--dsw-mask-blur` 设为 `none`;Desktop 自有模态浮层也不对父页面施加模糊。 +- 浮层在上下两侧均放不下时,保留与锚点的横向对齐。先尝试窗口内的纵向范围,再尝试同时清除横向锚定的回退位置;每个候选位置均须保留窗口边距与顶栏间距。 + - 菜单背景模糊绘制在独立图层上,保证嵌套菜单和 fixed 浮层的定位及背景采样。macOS 上,`MenuSurface` 仅在各菜单范围内、页面内容后方添加实色底层,使 Chromium 能在原生透明材质上模糊页面内容;CSS 锚点跟随位置及尺寸,卸载时移除底层。没有该底层的浮层使用 `--dsw-specific-menu`,主题在 macOS 上将其设为 94% 不透明度,防止下方文字透出。其他使用该填充的浮起表面也须配套同一模糊;包含 fixed 浮层时,将两者绘制在独立背景层上。 - 使用中性 `--dsw-alias-border-*` token 的平面边框与分割线一律 `0.5px`——按钮、输入框、卡片、行分割线,以及以填充盒绘制的分隔线(菜单分隔、对话标题栏接缝、markdown `hr`、竖向轨道线)共用发丝线粗细,Chromium 将其绘制为一个设备像素。dashed 记号与状态色 border 保持 1px;spinner 圆环经 spec 的显式豁免保留原宽度。更宽的中性 solid border 会被 ui-theme elevation spec 拒绝。 - 可点击产物链接(Markdown 锚点、正文文件引用、网页来源与抓取链接、产物 chips、workflow 成员链接)经 `--dsw-alias-link` 着色、`font-weight: 500`,默认无下划线,hover/focus 时为 3px offset 的点状下划线。紧凑 Thinking Markdown 保持 tertiary 文字色和默认点状下划线([紧凑展示](../packages/client/ui-primitives/README.zh.md#rendering-agent-output))。带文字的锚点另以 ui-primitives 的 `LinkIconMedium` 分类图形(随 `currentColor`)作前置;目的地是已知外部站点时改用该站点自己的标记而非地球;workflow 成员链接与只包图片的锚点不带图形,工具行文件链接保持其灰色点线示能。 @@ -46,6 +48,10 @@ 圆角尺度、组件尺寸映射、正圆与胶囊例外、嵌套 hover,以及设置卡片的底色与描边统一由 [DSH 统一圆角规范](ui-radius.zh.md)定义。 +## 审查组装后的设计 + +按 [UI review 基准](ui-design-review-cases.zh.md#review-baseline),在[整页或整个弹窗](ui-design-review-cases.zh.md#whole-interface-review)中综合判断材质、字体、圆角、响应式行为、反馈和图案。统一比较这个范围内的所有区域与同角色元素;组件分别合规,组合后仍可能出现双描边、分组不一致或焦点被裁切。本规范与主题继续拥有样式规则。 + ## 变更系统 在所属 `ui-theme` 样式表中添加或修改共享 token,然后在功能包中使用其语义别名。公共样式约定发生变化时,更新所属包的参考文档。视觉行为遵循[测试策略](testing.zh.md);[已归档的样式系统 Agent Note](../.agents/notes/archived/process/2026-07-19-web-styling-system.md) 记录框架依据。 diff --git a/package.json b/package.json index 9dccd6008b..dcf0f5a609 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-root", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "license": "MIT", "private": true, "type": "module", diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index 877006761a..1ffb46e425 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp", "description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/api/account-controller/package.json b/packages/api/account-controller/package.json index 1bd865aeeb..6970251ca0 100644 --- a/packages/api/account-controller/package.json +++ b/packages/api/account-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-account-controller", "description": "Expose safe account operations over authenticated Remote", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index f67e41c300..5a3c973df3 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-gateway", "description": "Typert Remote Host dispatcher and Client API endpoint", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/api/job-controller/package.json b/packages/api/job-controller/package.json index c550f1f912..a948e43c3c 100644 --- a/packages/api/job-controller/package.json +++ b/packages/api/job-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-job-controller", "description": "Job Remote observation stream and the reference-counted client job-output service", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/api/remote-access/package.json b/packages/api/remote-access/package.json index 5e81e757c2..a240048ef8 100644 --- a/packages/api/remote-access/package.json +++ b/packages/api/remote-access/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-remote-access", "description": "Method-level Remote access control for non-owner Peers and the per-Peer access snapshot", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index 64b8496be6..4178adfa26 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/README.i18n.yaml @@ -15,11 +15,11 @@ en: a7a78c6d1aeebeb3 zh: 4bb69f32d8b05ba3 /deepseek-ai-dsh-api-remotes/use-this-package: - en: 6e3448339c646a7e - zh: 5dc00ff4cceaa88c + en: 5e5770cc1cbf49db + zh: b9ee3c309d0cd93c /deepseek-ai-dsh-api-remotes/forwarded-host-events: - en: 31bb9a2f50094b84 - zh: 226c055739d5def7 + en: 1dea001229f8c06a + zh: 3acb1fa1c5d24f8c /deepseek-ai-dsh-api-remotes/build-boundary: en: a73c2b7e1069a750 zh: 5b3f87a9ea29cb14 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index a05773a4af..71e5dc385f 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -29,7 +29,7 @@ Two-sided BFF for Host Remote capabilities selected by this application. The Hos [`@deepseek-ai/dsh-api-session-controller`](../session-controller/README.md) owns Agent and Session identity policy, including the Typert lookup resolvers used by other namespaces. This package selects and mounts that generated Session contribution without a direct runtime dependency on `@deepseek-ai/dsh-session`; activation policy stays with Session Controller. -The Client assembly mounts Office conversion, Commands, Agent Teams, credentials, settings, Goal, Schedule, dynamic Cordis, file and Session references, read-only Host plugin inventory, plugin-installation registry response comparison, message feedback, permission presets, Session Controller, subagents, and Workspace Controller contributions. The `permissionPresets` namespace returns the complete process-level catalog used by current-session controls. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, streams, and cancellation. The Client entry consumes the shared `TypertClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation. +The Client assembly mounts Office conversion, Commands, Agent Teams, credentials, settings, Goal, Schedule, dynamic Cordis, file and Session references, read-only Host plugin inventory, plugin-installation registry response comparison, message feedback, permission presets, Session Controller, subagents, web provider list, and Workspace Controller contributions. The `permissionPresets` namespace returns the complete process-level catalog used by current-session controls. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, streams, and cancellation. The Client entry consumes the shared `TypertClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation. This facade is also the front door for the wire type vocabulary a Client package names. It re-exports, type-only, the Remote failure vocabulary (`RemoteResult`, `RemoteFailure`, `RemoteErrorCode`, `RemoteErrorDetailsMap`), the Host facts (`RemoteHostFacts`), and each selected domain's client-safe payload types, so a Client feature package imports one specifier instead of reaching into `dsh-typert-protocol`, the Gateway, or an owner's Host entry. Two kinds of package deliberately skip this door: the API-layer packages this assembly itself selects — importing it back would close a dependency cycle — and their tests, which take the failure vocabulary from `dsh-typert-protocol` directly. A UI package's tests instead take the `RemoteError` constructor from [`dsh-client-test-runtime`](../../test-support/client-runtime/README.md). @@ -48,7 +48,7 @@ An `emit` entry may declare `resources`, readers keyed by `TypertResourceMap` ki The Host entry registers an independent allowlist listener set and queue for each Client stream. It rejects non-JSON ordinary-event arguments before enqueueing. For a waterfall, it projects only the top-level Agent identity and JSON request fields; a Client result must also be lossless JSON, while `next()` delegates to the following Host listener. Each scoped waterfall request must carry its routed Agent directly as `request.agent`; the Host rejects a missing or mismatched identity before forwarding. The source attaches all listeners synchronously before `ctx.typertGateway.registerRemoteEvents()` exposes Gateway's internal `$events` logical stream, so its first `ready` item proves that incremental delivery is active and carries the Host home for Client path display. Withdrawing the registration aborts active streams. -The payload-free `schedule/changed` event invalidates Schedule task queries; Clients re-read after a notification and reconnect. The payload-free `permission-presets/catalog-changed` event invalidates the process catalog. A Client subscribes before its first `permissionPresets.catalog()` read and re-reads the complete snapshot after a notification; the event carries no catalog state and changes no Session sequence. +The payload-free `schedule/changed` event invalidates Schedule task queries; Clients re-read after a notification and reconnect. The payload-free `permission-presets/catalog-changed` event invalidates the process catalog. A Client subscribes before its first `permissionPresets.catalog()` read and re-reads the complete snapshot after a notification; the event carries no catalog state and changes no Session sequence. The payload-free `web/providers-changed` event invalidates the `web.listProviders()` provider lists; Clients re-read after a notification and reconnect. ## Build boundary diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index bc616b9588..5bef7cb29c 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" [`@deepseek-ai/dsh-api-session-controller`](../session-controller/README.zh.md) 拥有 agent(智能体)与会话身份策略,包括供其他 namespace 使用的 Typert lookup 解析器。本包选择并挂载生成的会话 contribution,无需直接依赖 `@deepseek-ai/dsh-session` 的运行时;激活策略由会话控制器负责。 -Client 组合挂载 Office 转换、Commands、Agent Teams、凭据、settings、Goal、Schedule、动态 Cordis、文件与会话引用、只读 Host 插件清单、插件安装源响应比较、消息反馈、权限预设、会话控制器、subagents 和 Workspace 控制器 contribution。`permissionPresets` namespace 返回 current-session 控件使用的完整进程级目录。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪的 namespace 服务、直接与作用域方法、调用、流与取消。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。 +Client 组合挂载 Office 转换、Commands、Agent Teams、凭据、settings、Goal、Schedule、动态 Cordis、文件与会话引用、只读 Host 插件清单、插件安装源响应比较、消息反馈、权限预设、会话控制器、subagents、Web 提供方列表和 Workspace 控制器 contribution。`permissionPresets` namespace 返回 current-session 控件使用的完整进程级目录。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪的 namespace 服务、直接与作用域方法、调用、流与取消。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。 本 facade 同时是 Client 包指称 wire 类型词汇的正门。它以 type-only 方式转出 Remote 失败词汇(`RemoteResult`、`RemoteFailure`、`RemoteErrorCode`、`RemoteErrorDetailsMap`)、Host 事实(`RemoteHostFacts`),以及各已选领域对 Client 安全的载荷类型,因此 Client 功能包只 import 一个 specifier,不必伸手进 `dsh-typert-protocol`、Gateway 或某个拥有方的 Host 入口。有两类包刻意不走这道门:本装配自己选中的 API 层包——反向 import 会形成依赖环——以及它们的测试,后者直接从 `dsh-typert-protocol` 取失败词汇。UI 包的测试则从 [`dsh-client-test-runtime`](../../test-support/client-runtime/README.zh.md) 取 `RemoteError` 构造器。 @@ -48,7 +48,7 @@ Client 组合挂载 Office 转换、Commands、Agent Teams、凭据、settings Host entry 为每条 Client 流独立注册一组 allowlist listener 和一个队列,并在普通事件入队前拒绝非 JSON 参数。对于 waterfall,它只投影顶层 agent 身份与 JSON 请求字段;Client 结果也必须能无损表示为 JSON,而 `next()` 会委托给后续 Host listener。每个作用域 waterfall 请求都必须以 `request.agent` 直接携带路由所用的 agent;Host 会在转发前拒绝缺失或不匹配的身份。该 source 在 `ctx.typertGateway.registerRemoteEvents()` 暴露 Gateway 内部的 `$events` 逻辑流前同步挂好所有 listener,因此首个 `ready` 项既能证明增量投递已就绪,也会携带供 Client 显示路径的 Host home。撤回注册会中止活动流。 -无 payload 的 `schedule/changed` 事件使 Schedule 任务查询失效;Client 在收到通知及重连后重新读取。无 payload 的 `permission-presets/catalog-changed` 事件使进程目录失效。Client 在首次读取 `permissionPresets.catalog()` 前先订阅,并在通知后重新读取完整快照;该事件不携带目录状态,也不改变 Session 序号。 +无 payload 的 `schedule/changed` 事件使 Schedule 任务查询失效;Client 在收到通知及重连后重新读取。无 payload 的 `permission-presets/catalog-changed` 事件使进程目录失效。Client 在首次读取 `permissionPresets.catalog()` 前先订阅,并在通知后重新读取完整快照;该事件不携带目录状态,也不改变 Session 序号。无 payload 的 `web/providers-changed` 事件使 `web.listProviders()` 提供方列表失效;Client 在收到通知及重连后重新读取。 ## 构建边界 diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 1fe16a4a87..529a762528 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-remotes", "description": "Remote BFF assembly for application-selected Host capabilities", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, @@ -93,6 +93,7 @@ "@deepseek-ai/dsh-command-feedback": "workspace:*", "@deepseek-ai/dsh-api-terminal-controller": "workspace:*", "@deepseek-ai/dsh-plugin-manager": "workspace:*", + "@deepseek-ai/dsh-mcp-manager": "workspace:*", "@deepseek-ai/dsh-plugin-market": "workspace:*", "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*", "@deepseek-ai/dsh-office-to-pdf": "workspace:*", @@ -100,6 +101,7 @@ "@deepseek-ai/dsh-deepseek-account": "workspace:*", "@deepseek-ai/dsh-client-product-analytics": "workspace:*", "@deepseek-ai/dsh-agent-team": "workspace:*", - "@deepseek-ai/dsh-client-ui-trajectory": "workspace:*" + "@deepseek-ai/dsh-client-ui-trajectory": "workspace:*", + "@deepseek-ai/dsh-web": "workspace:*" } } diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 86112b7cc2..a3012bb69f 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -16,6 +16,7 @@ import scheduleRemote from '@deepseek-ai/dsh-schedule/remote' import llmRemote from '@deepseek-ai/dsh-llm/remote' import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote' import pluginManagerRemote from '@deepseek-ai/dsh-plugin-manager/remote' +import mcpManagerRemote from '@deepseek-ai/dsh-mcp-manager/remote' import toolResultsRemote from '@deepseek-ai/dsh-client-ui-trajectory/remote' import pluginRegistryProbeRemote from '@deepseek-ai/dsh-client-ui-plugin-manager/remote' import pluginMarketRemote from '@deepseek-ai/dsh-plugin-market/remote' @@ -31,6 +32,7 @@ import jobRemote from '@deepseek-ai/dsh-api-job-controller/remote' import workspaceRemote from '@deepseek-ai/dsh-api-workspace-controller/remote' import terminalRemote from '@deepseek-ai/dsh-api-terminal-controller/remote' import workspaceFilesRemote from '@deepseek-ai/dsh-api-workspace-files/remote' +import webRemote from '@deepseek-ai/dsh-web/remote' import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' export type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' @@ -42,7 +44,9 @@ export type { PluginUpdateProgress, PluginUpdateResult, PluginUpdateStep, PluginVersion, PluginVersionList, RegistryAttempt, ReadOnlyReason, Registry, RestartReason, } from '@deepseek-ai/dsh-plugin-manager/types' +export type { McpManagedServerStatus, McpManagedServerView } from '@deepseek-ai/dsh-mcp-manager/types' export type {} from '@deepseek-ai/dsh-plugin-manager/remote' +export type {} from '@deepseek-ai/dsh-mcp-manager/remote' export type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/remote' export type {} from '@deepseek-ai/dsh-client-ui-trajectory/remote' export type {} from '@deepseek-ai/dsh-plugin-market/remote' @@ -54,6 +58,7 @@ export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inven export type {} from '@deepseek-ai/dsh-agent-preset-registry/remote' export type {} from '@deepseek-ai/dsh-user-questions/remote' export type {} from '@deepseek-ai/dsh-commands/remote' +export type {} from '@deepseek-ai/dsh-web/remote' export type {} from '@deepseek-ai/dsh-api-settings-controller/remote' export type {} from '@deepseek-ai/dsh-api-account-controller/remote' export type {} from '@deepseek-ai/dsh-goal/remote' @@ -85,6 +90,7 @@ export type { ApiRemoteForwardedEvent } from '../types.ts' // signatures `$on` hands to a listener, so a consumer reads the very // declaration the Host emits rather than a flattened restatement of it. export type {} from '@deepseek-ai/dsh-commands/types' +export type {} from '@deepseek-ai/dsh-web/types' export type {} from '@deepseek-ai/dsh-cordis-host-runner/types' export type {} from '@deepseek-ai/dsh-credentials/types' export type {} from '@deepseek-ai/dsh-llm/types' @@ -193,11 +199,12 @@ export async function apply(ctx: Context): Promise<() => Promise> { for (const contribution of [ productAnalyticsRemote, agentPresetsRemote, commandsRemote, agentTeamsRemote, settingsControllerRemote, accountRemote, goalsRemote, llmRemote, dynamicRemote, scheduleRemote, - pluginInventoryRemote, pluginManagerRemote, pluginMarketRemote, pluginRegistryProbeRemote, toolResultsRemote, + pluginInventoryRemote, pluginManagerRemote, mcpManagerRemote, pluginMarketRemote, pluginRegistryProbeRemote, + toolResultsRemote, messageFeedbackRemote, sessionFeedbackRemote, fileUploadsRemote, sessionReferencesRemote, permissionPresetsRemote, subagentsRemote, sessionRemote, jobRemote, workspaceRemote, workspaceFilesRemote, terminalRemote, - officeToPdfRemote, userQuestionsRemote, + officeToPdfRemote, userQuestionsRemote, webRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) } diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts index d2fa2989f1..1ede5adf89 100644 --- a/packages/api/remotes/src/index.ts +++ b/packages/api/remotes/src/index.ts @@ -28,6 +28,7 @@ import type {} from '@deepseek-ai/dsh-permission-presets/types' import type {} from '@deepseek-ai/dsh-settings/types' import type {} from '@deepseek-ai/dsh-user-approval' import type {} from '@deepseek-ai/dsh-user-questions' +import type {} from '@deepseek-ai/dsh-web/types' export type {} from '@deepseek-ai/dsh-api-session-controller/types' export { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts' diff --git a/packages/api/remotes/src/remote-events.ts b/packages/api/remotes/src/remote-events.ts index 38f87e99b4..514723b031 100644 --- a/packages/api/remotes/src/remote-events.ts +++ b/packages/api/remotes/src/remote-events.ts @@ -14,6 +14,7 @@ import type {} from '@deepseek-ai/dsh-plugin-manager/types' import type {} from '@deepseek-ai/dsh-plugin-market/types' import type {} from '@deepseek-ai/dsh-schedule/client' import type { TypertForwardableEventEntry } from '@deepseek-ai/dsh-typert-protocol' +import type {} from '@deepseek-ai/dsh-web/types' /** * Host events this application forwards without renaming. The explicit mode is @@ -54,4 +55,5 @@ export const API_REMOTE_FORWARDED_EVENTS = [ { event: 'settings/document-updated', mode: 'emit' }, { event: 'schedule/changed', mode: 'emit' }, { event: 'user-questions/request', mode: 'waterfall' }, + { event: 'web/providers-changed', mode: 'emit' }, ] as const satisfies readonly TypertForwardableEventEntry[] diff --git a/packages/api/remotes/tests/remote-events.host.spec.ts b/packages/api/remotes/tests/remote-events.host.spec.ts index 977257ee44..a44297e56b 100644 --- a/packages/api/remotes/tests/remote-events.host.spec.ts +++ b/packages/api/remotes/tests/remote-events.host.spec.ts @@ -133,6 +133,16 @@ describe('Remote event Host source', () => { value: { event: 'schedule/changed', args: [] }, }) + emitRaw(ctx, 'web/providers-changed', []) + await expect(first.next()).resolves.toEqual({ + done: false, + value: { event: 'web/providers-changed', args: [] }, + }) + await expect(second.next()).resolves.toEqual({ + done: false, + value: { event: 'web/providers-changed', args: [] }, + }) + const firstDone = first.next() firstAbort.abort(new Error('first Client disconnected')) emitRaw(ctx, 'commands/change', []) diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 49b3180c43..858f7b7d7d 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -103,6 +103,9 @@ { "path": "../../boot/plugin-manager" }, + { + "path": "../../mcp/mcp-manager" + }, { "path": "../../boot/plugin-market" }, @@ -114,6 +117,9 @@ }, { "path": "../../credentials/deepseek-account" + }, + { + "path": "../../web/web" } ] } diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json index 3fd7ea3bc4..7c0421f641 100644 --- a/packages/api/remotes/tsconfig.host.json +++ b/packages/api/remotes/tsconfig.host.json @@ -80,6 +80,9 @@ { "path": "../../boot/plugin-manager" }, + { + "path": "../../mcp/mcp-manager" + }, { "path": "../../boot/plugin-market" }, @@ -91,6 +94,9 @@ }, { "path": "../../credentials/deepseek-account" + }, + { + "path": "../../web/web" } ] } diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index fb96bac533..38ebd48e01 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1fdf707322fb6996 zh: 0204f7e9d4aa3cce /session-controller/use-this-package: - en: 25c9c54037dc7971 - zh: 7fa87a380b046afb + en: 300f02be359a3170 + zh: ac7fb4abbe78eddf /session-controller/client-references: en: e8979a803ec3e194 zh: c567efcd971728ba diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 4ce2afd480..960c9bde1f 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -33,7 +33,7 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se The Client journal validates current Session event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host. -Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. A receipt names a staged file or a staged image, and an image receipt subjects the prompt to the same image-input check as an encoded image. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and the subagent ownership fence determined by `origin: 'subagent'` to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values. +Each endpoint states its activation policy. For a cold Session, list reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. An attached Session is summarized from its in-memory log. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. A receipt names a staged file or a staged image, and an image receipt subjects the prompt to the same image-input check as an encoded image. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and the subagent ownership fence determined by `origin: 'subagent'` to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values. Every Remote endpoint is access-controlled: a Peer whose principal is not the owner reaches an endpoint only when, on every resource the call touches, a binding of that principal kind's policy in [`dsh-api-remote-access`](../remote-access/README.md) grants a role listing the action ([Peer principals Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md)). The controller injects `remoteAccess` and registers a default resolver for the `session` resource kind through `typert.resources`. It reads a named `sessionId` parameter, otherwise the first object argument's `sessionId` or `address`; unrelated string parameters name no Session. An object containing both `sessionId` and `address` is invalid, even when they name the same Session. `session/events`, the deprecated `session/page`, `session/observeEvent`, and `session/follow` use this resolver to read `address.sessionId` for an ordinary Session address and `address.childSessionId` for a subagent address; whether access to the parent Session covers its children is the policy's rule. `session/projections`, `session/observeProjections`, `session/state`, `session/observeState`, and `session/wait` read the Session resource from `request.sessionId`, so each call and subscription names only the Sessions it may read. `session/list`, `session/search`, and `session/control` read no resource, so their calls touch the `global` resource and need a `global` role; for a non-owner caller, `session/list` returns only the rows, `session/search` only the hits, and `session/control` only the baseline projections and projection frames, whose Session `ctx.remoteAccess.allows()` admits for the same action, so that action also belongs to a `session` role; owner results are unfiltered. `session/prompt` builds the durable `user-rpc` message source in its Remote method and records `principal` only for a non-owner caller, read through `principalOf(this.ctx.invocation)`; a source without `principal` belongs to the owner. @@ -41,7 +41,7 @@ Every Remote endpoint is access-controlled: a Peer whose principal is not the ow List rows carry `formatStatus` when available, so Clients can identify migration-required Sessions without requesting their projections. -Host list generation yields between complete rows only when its configured work slice is exhausted; cheap remaining work returns without a forced yield. Request cancellation is checked per row and after each yield and rejects without partial results. Each row is synchronous; the list does not promise one cross-Session snapshot. A Session already queued as cold remains a cached row if it attaches during a yield: Client in-flight mutation replay corrects its availability, and sequenced projections supersede cached hints. +Host list generation yields between complete rows only when its configured work slice is exhausted; cheap remaining work returns without a forced yield. Request cancellation is checked per row and after each yield and rejects without partial results. Each row is synchronous; the list does not promise one cross-Session snapshot. A Session already queued as cold remains a cached row if it attaches during a yield: Client in-flight mutation replay corrects its availability, and sequenced projections supersede cached hints. An attached Session's summary reads its blankness and last-prompt recency through the `sessionListMetadata` projection handle, and its projection hints carry every summary key computed through `snapshot`. The first summary of an attached Session computes these projections over its whole in-memory log, and later summaries advance them incrementally; this computation runs inside one synchronous row, so `listWorkSliceMs` cannot split it. Client list rows and resident Sessions use the current `sessionListMetadata` projection to reject stale blank-session hints; recency is the later of the summary timestamp and the projected last user prompt. When SessionManager creates an instance before its list row arrives, it reconciles blankness with metadata already retained for that Session. An opened conversation is therefore not reused by New Session even when an older list response still marks it blank. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 0aecad0564..5b18b4aa38 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" Client journal 在发布 follow 快照、live entry 或历史页之前验证当前 Session 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。 -每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。凭证指向一个暂存文件或一张暂存图片;图片凭证与编码图片一样要经过图片输入检查。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和由 `origin: 'subagent'` 确定的 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。 +每个 endpoint 都声明自己的激活策略。对冷 Session,列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。已接入 Session 从其内存日志生成摘要。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。凭证指向一个暂存文件或一张暂存图片;图片凭证与编码图片一样要经过图片输入检查。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和由 `origin: 'subagent'` 确定的 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。 每个 Remote 端点都受访问控制:principal 不是 owner 的 Peer 只有在本次调用访问的每个资源上,该 principal kind 在 [`dsh-api-remote-access`](../remote-access/README.zh.md) 中的 policy 都有某个 binding 授予列出该 action 的 role 时,才能调用该端点([Peer 身份属性 Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md))。控制器注入 `remoteAccess`,并经 `typert.resources` 为 `session` 资源种类登记默认 resolver。它读取具名 `sessionId` 参数,否则读取第一个对象参数的 `sessionId` 或 `address`;无关字符串参数不指明 Session。对象同时包含 `sessionId` 和 `address` 时无效,即使两者指向同一 Session。`session/events`、已弃用的 `session/page`、`session/observeEvent` 与 `session/follow` 使用该 resolver:对普通 Session 地址读 `address.sessionId`,对 subagent 地址读 `address.childSessionId`;父会话的访问权是否覆盖其子会话由 policy 决定。`session/projections`、`session/observeProjections`、`session/state`、`session/observeState` 与 `session/wait` 从 `request.sessionId` 读取会话资源,因此每次调用与订阅都只点名它可读的会话。`session/list`、`session/search` 与 `session/control` 不读取资源,其调用访问 `global` 资源,需要 `global` role;对非 owner 调用方,`session/list` 只返回其 Session 被 `ctx.remoteAccess.allows()` 按同一 action 允许的行,`session/search` 只返回这类 Session 的命中,`session/control` 也只返回这类 Session 的 baseline projection 与 projection 帧,因此该 action 也要列在 `session` role 中;owner 的结果不过滤。`session/prompt` 在其 Remote 方法内构造持久的 `user-rpc` 消息 source,且只为非 owner 调用方记录经 `principalOf(this.ctx.invocation)` 读取的 `principal`;不带 `principal` 的 source 属于 owner。 @@ -41,7 +41,7 @@ Client journal 在发布 follow 快照、live entry 或历史页之前验证当 列表行在状态可用时携带 `formatStatus`,Client 无需请求其投影即可识别需要迁移的 Session。 -Host 仅在配置的工作时间片耗尽时,于完整条目之间让出事件循环;剩余工作较少时直接返回,不强制让出。每行及每次让出之后检查请求取消,取消时拒绝返回部分结果。每行同步计算;列表不承诺跨 Session 的统一快照。已经进入冷态队列的 Session 若在让出期间接入,仍返回缓存行:Client 对进行中请求的变更重放会纠正其可用性,带序号的投影会覆盖缓存提示。 +Host 仅在配置的工作时间片耗尽时,于完整条目之间让出事件循环;剩余工作较少时直接返回,不强制让出。每行及每次让出之后检查请求取消,取消时拒绝返回部分结果。每行同步计算;列表不承诺跨 Session 的统一快照。已经进入冷态队列的 Session 若在让出期间接入,仍返回缓存行:Client 对进行中请求的变更重放会纠正其可用性,带序号的投影会覆盖缓存提示。已接入 Session 的摘要通过 `sessionListMetadata` 投影句柄读取是否为空和最后一次提示词的最近活动时间,其投影提示携带通过 `snapshot` 计算的每个摘要 key。已接入 Session 的首次摘要在其完整内存日志上计算这些投影,之后的摘要增量推进;这次计算在单行的同步工作内进行,`listWorkSliceMs` 无法切分它。 Client 列表行和驻留 Session 使用当前 `sessionListMetadata` 投影纠正过期的空白会话提示;最近活动时间取摘要时间戳与投影中最后一次用户提示词时间的较晚值。当 SessionManager 在列表行到达前创建实例时,会使用已保留的该 Session 元数据对账空白状态。因此,即使旧列表响应仍将已有对话标为空白,新会话操作也不会复用已经打开过的对话。 diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 4a7e14aa2b..30e143744e 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-session-controller", "description": "Session Remote commands, cold reads, and live control transport", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/api/session-controller/src/list.ts b/packages/api/session-controller/src/list.ts index d258ace5c8..830ebe21a9 100644 --- a/packages/api/session-controller/src/list.ts +++ b/packages/api/session-controller/src/list.ts @@ -6,7 +6,7 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent-preset-registry' import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' -import type { ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection' +import type { ProjectionHandle, ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-session-projection-cache' import { SessionQueryError, type SessionRecord, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' @@ -76,6 +76,8 @@ export function truncateUnicodeCodePoints(value: string, maximum: number): strin /** Owns list projection registration, bounded cold summaries, and authorized search. */ export class ApiSessionList { + private listMetadata!: ProjectionHandle + /** * @param ctx - Host context carrying Session, query, persistence, and projection services. * @param workSliceMs - Resolved positive integral list-work budget in milliseconds. @@ -87,7 +89,7 @@ export class ApiSessionList { * @returns when all required registrations are ready. */ async initialize(): Promise { - await this.ctx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({ + this.listMetadata = await this.ctx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({ key: 'sessionListMetadata', stateSchema: sessionListMetadataSchema, init: () => ({ blank: true, lastPromptAt: null }), @@ -111,19 +113,25 @@ export class ApiSessionList { /** * Build one current attached-Session summary. + * Blankness and recency come from the `sessionListMetadata` handle; projection + * hints carry every summary key computed through `snapshot`. The first call + * for a Session computes these projections over its whole in-memory log, and + * later calls advance them incrementally. The handle read and the hint with + * the same key come from one cell; the handle read does not depend on any + * other unit's view. * @param session - attached Session to summarize. * @returns current list metadata and available projections. */ summaryFor(session: Session): SessionSummary { + const metadata = this.listMetadata.stateOf(session) const projections = this.projectionsFor(session.header, session) - const metadata = projections?.values.sessionListMetadata return { sessionId: session.id, updatedAt: updatedAt(session.header, metadata), agentAvailable: this.ctx.agents.get(session.id)?.session === session, formatStatus: 'current', running: this.ctx.agents.get(session.id)?.status === 'running', - blank: metadata?.blank ?? session.seq === 0, + blank: metadata.blank, ...listFields(session.header), ...(projections === undefined ? {} : { projections }), } @@ -304,9 +312,9 @@ export class ApiSessionList { // itself summary-excluded. const keys = this.ctx.sessionProjections.summaryKeys() if (session !== undefined) { - // The live registry computed the block for this Session: its watermark + // The live registry computes the block for this Session: its watermark // shares the sequence space of the Session's baselines and frames. - return hintsOf('sequenced', this.ctx.sessionProjections.cachedSnapshot(session, keys)) + return hintsOf('sequenced', this.ctx.sessionProjections.snapshot(session, keys)) } // A cold row reads the persisted cache by header alone; the cache serves // seeded and unseeded lifecycles alike because a listing never seeds a @@ -334,8 +342,8 @@ function hintsOf( block: ProjectionSnapshot | undefined, ): SessionProjectionHints | undefined { if (block === undefined || Object.keys(block.values).length === 0) return undefined - // Listing hints contain every summary wire value the source currently holds - // but remain partial: missing cells and cache rows are never materialized here. + // Listing hints contain every summary wire value the source holds; cached + // hints remain partial because missing cache rows are never folded here. return { kind, asOfSeq: block.asOfSeq, values: block.values } } diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 6c3c737cb6..1d846d4c9a 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -36,9 +36,10 @@ export interface SessionListMetadata { } /** - * Every available summary wire value a Session-list row carries as partial, - * possibly stale hints. The block omits the keys whose unit declares - * `summary: false`. `kind` and `asOfSeq` are independent facts: `kind` says + * Summary wire values a Session-list row carries. A `sequenced` block carries + * every summary key computed at its `asOfSeq`; a `cached` block carries the + * values the persisted cache holds and may omit keys or trail the Session. + * The block omits the keys whose unit declares `summary: false`. `kind` and `asOfSeq` are independent facts: `kind` says * which sequence space `asOfSeq` belongs to, and therefore how a client may * merge the block; `asOfSeq` is the producer's watermark in that space. */ diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index e5f721d37d..49e0b3210d 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -275,7 +275,8 @@ describe('attached updatedAt tracks human prompts', () => { const listed = await remote.list(request({})) if (!listed.ok) throw new Error('list failed') const summary = listed.value.items.find(item => item.sessionId === 'resumed-untouched') - expect(summary?.updatedAt).toBe(500) + // The resumed log's last human prompt, not its creation time or the later pickup. + expect(summary?.updatedAt).toBe(worked) // A lifecycle boundary is not a human update. resumed.append('turn/start', { turn: 2 }) diff --git a/packages/api/session-controller/tests/session-list-blank.host.spec.ts b/packages/api/session-controller/tests/session-list-blank.host.spec.ts index f433d20c1b..e91fd6689e 100644 --- a/packages/api/session-controller/tests/session-list-blank.host.spec.ts +++ b/packages/api/session-controller/tests/session-list-blank.host.spec.ts @@ -5,21 +5,40 @@ * fresh session keeps it list-hidden and reusable, while the first accepted * prompt's turn/start clears it. The host/session-added frame shares the * same predicate function (covered by the workspace spec's frame assertion). + * A resumed Session computes its own list metadata (blankness and last-prompt + * recency) when it is published, independent of which other projections were + * read before publication. */ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' -import SessionStore from '@deepseek-ai/dsh-session' -import type { Session } from '@deepseek-ai/dsh-session' +import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionSeq } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { CommandId } from '@deepseek-ai/dsh-commands/brand' // Side-effect type imports: the configuration-event SessionEventMap merges. import type {} from '@deepseek-ai/dsh-permission-presets' import type {} from '@deepseek-ai/dsh-sandbox-policy' +import type { SessionSummary } from '../src/types.ts' import { createSessionTestRemote, type TestSessionRemote } from './test-remote.ts' +const ownedContexts = new Set() +const ownedRoots = new Set() +afterEach(async () => { + await Promise.all([...ownedContexts].map(ctx => ctx.fiber.dispose())) + ownedContexts.clear() + await Promise.all([...ownedRoots].map(root => rm(root, { recursive: true, force: true }))) + ownedRoots.clear() +}) + async function harness(): Promise<{ ctx: Context; remote: TestSessionRemote; attach: (session: Session) => Promise }> { const ctx = new Context() await ctx.plugin(SessionStore) @@ -74,3 +93,110 @@ describe('summary blank = conversation not started', () => { expect(await listBlank(remote, session.id)).toBe(false) }) }) + +/** + * Mount the real AgentLoop over JSONL persistence without the projection + * cache, whose creation checkpoint would otherwise compute every unit before + * the Session Controller observes the published Session. + */ +async function resumeHarness(): Promise<{ ctx: Context; remote: TestSessionRemote; cwd: string }> { + const root = await mkdtemp(join(tmpdir(), 'dsh-list-blank-')) + ownedRoots.add(root) + const ctx = new Context() + ownedContexts.add(ctx) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(JsonlSessionPersistence, { root }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: root }) + await mountAgentLoopTestHarness(ctx) + return { ctx, remote, cwd: root } +} + +/** Store one Session through persistence so a later resume seeds it from disk. */ +async function storeSession(ctx: Context, id: SessionId, cwd: string, events: readonly SessionEvent[]): Promise { + const prepared = ctx.sessions.prepare(id, { meta: { cwd } }) + const handle = await ctx.sessionPersistence.create(prepared.header) + await handle.append(events) + await handle.close() +} + +/** Configuration events a Session records at creation, before any prompt. */ +const configurationEvents = [ + { type: 'permission/preset', seq: SessionSeq(0), time: 1, data: { preset: 'danger-full-access' } }, + { type: 'sandbox/mode', seq: SessionSeq(1), time: 2, data: { mode: 'danger-full-access' } }, + { type: 'approval/policy', seq: SessionSeq(2), time: 3, data: { policy: 'never' } }, +] as SessionEvent[] + +/** Resume one stored Session and collect the summaries published for it. */ +async function resumeAndObserve(ctx: Context, remote: TestSessionRemote, id: SessionId): Promise<{ + added: SessionSummary[] + listed: SessionSummary | undefined +}> { + const added: SessionSummary[] = [] + ctx.on('api-session/added', (summary) => { + if (summary.sessionId === id) added.push(summary) + }) + await ctx.agents.resume({ resumeSessionId: id }) + const listed = await remote.list({}) + if (!listed.ok) throw new Error('list failed') + return { added, listed: listed.value.items.find(item => item.sessionId === id) } +} + +describe('resumed Session summaries', () => { + it('publish a turnless resumed Session as blank', async () => { + const { ctx, remote, cwd } = await resumeHarness() + const id = 'session-resumed-blank' as SessionId + await storeSession(ctx, id, cwd, configurationEvents) + + const { added, listed } = await resumeAndObserve(ctx, remote, id) + + expect(added.length).toBeGreaterThan(0) + expect(added.map(summary => summary.blank)).toEqual(added.map(() => true)) + expect(listed?.blank).toBe(true) + }) + + it('publish a resumed conversation as non-blank', async () => { + const { ctx, remote, cwd } = await resumeHarness() + const id = 'session-resumed-conversation' as SessionId + await storeSession(ctx, id, cwd, [ + ...configurationEvents, + { type: 'turn/start', seq: SessionSeq(3), time: 4, data: { turn: 1 } }, + { + type: 'user/message', seq: SessionSeq(4), time: 4, + data: createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }), + surfaceOp: 'append', + }, + { type: 'turn/end', seq: SessionSeq(5), time: 5, data: { turn: 1, reason: { kind: 'completed' } } }, + ] as SessionEvent[]) + + const { added, listed } = await resumeAndObserve(ctx, remote, id) + + expect(added.length).toBeGreaterThan(0) + expect(added.map(summary => summary.blank)).toEqual(added.map(() => false)) + expect(listed?.blank).toBe(false) + }) + + it('publish a resumed conversation with its last prompt time from the first frame', async () => { + const { ctx, remote, cwd } = await resumeHarness() + const id = 'session-resumed-recency' as SessionId + const promptAt = Date.now() + 60_000 + await storeSession(ctx, id, cwd, [ + ...configurationEvents, + { type: 'turn/start', seq: SessionSeq(3), time: promptAt, data: { turn: 1 } }, + { + type: 'user/message', seq: SessionSeq(4), time: promptAt, + data: createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }), + surfaceOp: 'append', + }, + { type: 'turn/end', seq: SessionSeq(5), time: promptAt + 1, data: { turn: 1, reason: { kind: 'completed' } } }, + ] as SessionEvent[]) + + const { added, listed } = await resumeAndObserve(ctx, remote, id) + + expect(added.length).toBeGreaterThan(0) + expect(added.map(summary => [summary.updatedAt, summary.blank])).toEqual(added.map(() => [promptAt, false])) + expect(Object.keys(added[0]?.projections?.values ?? {})).toContain('sessionListMetadata') + expect(Object.keys(added[0]?.projections?.values ?? {})).toEqual(expect.arrayContaining([...ctx.sessionProjections.summaryKeys()])) + expect(listed?.updatedAt).toBe(promptAt) + expect(listed?.blank).toBe(false) + }) +}) diff --git a/packages/api/session-controller/tests/session-projections.host.spec.ts b/packages/api/session-controller/tests/session-projections.host.spec.ts index 89dc6d6963..fe8fc245c2 100644 --- a/packages/api/session-controller/tests/session-projections.host.spec.ts +++ b/packages/api/session-controller/tests/session-projections.host.spec.ts @@ -21,6 +21,7 @@ import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, Sess import type { Session, SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import type { SessionProjectionSummaryExcluded } from '@deepseek-ai/dsh-session-projection/types' import SessionProjectionCache, { projectionCacheDomainSpec } from '@deepseek-ai/dsh-session-projection-cache' import { titleProjectionDefinition } from '@deepseek-ai/dsh-session-title' import Storage from '@deepseek-ai/dsh-storage' @@ -43,6 +44,7 @@ let nextHarnessSession = 1 declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionMap { 'test/last-user': { text: string } | null + 'test/summary-excluded': SessionProjectionSummaryExcluded } } @@ -468,7 +470,7 @@ describe('session.history projections block', () => { }) describe('session.list projections column', () => { - it('serves every already-materialized wire value from the live registry without folding', async () => { + it('serves every summary wire value from the live registry', async () => { const { ctx, session } = await harness(true) await ctx.sessionProjections.register(lastUserUnit()) const gateway = await remote(ctx) @@ -502,29 +504,38 @@ describe('session.list projections column', () => { expect(row?.projections?.values.agentPreset).toBe('minimal') }) - it('omits an unmaterialized live projection instead of folding history for listing', async () => { + it('computes every summary key for a live row without computing summary-excluded units', async () => { const { ctx, session } = await harness(true) seedMessages(session, 1) - const unit = lastUserUnit() - const apply = vi.fn(unit.apply) - await ctx.sessionProjections.register({ ...unit, apply }) + await ctx.sessionProjections.register(lastUserUnit()) + const excludedApply = vi.fn((state: number) => state + 1) + await ctx.sessionProjections.register({ + key: 'test/summary-excluded', + stateSchema: z.number().int().nonnegative(), + init: () => 0, + apply: excludedApply, + wire: { viewSchema: z.number().int().nonnegative(), view: state => state, summary: false }, + stateVersion: 1, + }) const response = await (await remote(ctx)).list(request({})) if (!response.ok) throw new Error('unreachable') const row = response.value.items.find(item => item.sessionId === session.id) - expect(row).toBeDefined() - expect('test/last-user' in (row?.projections?.values ?? {})).toBe(false) - expect(apply).not.toHaveBeenCalled() + expect(row?.projections?.values['test/last-user']).toEqual({ text: 'm0' }) + expect(row?.projections?.asOfSeq).toBe(session.seq - 1) + expect('test/summary-excluded' in (row?.projections?.values ?? {})).toBe(false) + expect(excludedApply).not.toHaveBeenCalled() }) - it('omits the column entirely when no registry is mounted', async () => { + it('carries every registered summary key, which is only its own list metadata when no other unit is registered', async () => { const { ctx, session } = await harness(false) seedMessages(session, 1) const response = await (await remote(ctx)).list(request({})) if (!response.ok) throw new Error('unreachable') const row = response.value.items.find(item => item.sessionId === session.id) - expect(row).toBeDefined() - expect(row !== undefined && 'projections' in row).toBe(false) + expect(row?.projections?.kind).toBe('sequenced') + expect(Object.keys(row?.projections?.values ?? {})).toEqual([...ctx.sessionProjections.summaryKeys()]) + expect(Object.keys(row?.projections?.values ?? {})).toEqual(['sessionListMetadata']) }) it('serves the summary projection hints from the cache with zero log loads', async () => { diff --git a/packages/api/session-controller/tests/summary-list.host.spec.ts b/packages/api/session-controller/tests/summary-list.host.spec.ts index d54c2a5821..19237931d4 100644 --- a/packages/api/session-controller/tests/summary-list.host.spec.ts +++ b/packages/api/session-controller/tests/summary-list.host.spec.ts @@ -1,7 +1,8 @@ /** * Session-list summary payloads: a `session.list` row carries every registered * wire projection except the units that declare `summary: false` - * (`turnOutline`, `sessionStats`, `todos`, `imageLimits`), while an opened Session's + * (`turnOutline`, `sessionStats`, `todos`, `imageLimits`, `contextPressure`, + * `contextBreakdown`), while an opened Session's * `session/follow` opening snapshot keeps serving them. */ @@ -21,9 +22,10 @@ import { import { apply as toolTodoApply, Config as toolTodoConfig, inject as toolTodoInject, name as toolTodoName, } from '@deepseek-ai/dsh-tool-todo' +import TokenMeter from '@deepseek-ai/dsh-token-meter' import { createSessionTestRemote } from './test-remote.ts' -const SUMMARY_EXCLUDED_KEYS = ['turnOutline', 'sessionStats', 'todos', 'imageLimits'] as const +const SUMMARY_EXCLUDED_KEYS = ['turnOutline', 'sessionStats', 'todos', 'imageLimits', 'contextPressure', 'contextBreakdown'] as const const ownedContexts = new Set() afterEach(async () => { @@ -51,7 +53,7 @@ async function opening( } /** - * Mount the registry plus the four summary-excluded units and one ordinary + * Mount the registry plus the summary-excluded units and one ordinary * wire unit. The tool plugin needs a tool registry; the list path never * executes a tool, so a registration sink is enough. */ @@ -71,6 +73,7 @@ async function harness(): Promise<{ ctx: Context; sessionContext: Context }> { Config: toolTodoConfig, apply: toolTodoApply, }, { allowParallelInProgress: false }) + await ctx.plugin(TokenMeter) // Build the remote (and with it the list owner that registers // `sessionListMetadata`) before any Session exists, so every registered unit // seeds a cell at `session/created`. diff --git a/packages/api/settings-controller/README.i18n.yaml b/packages/api/settings-controller/README.i18n.yaml index 979d0867fb..4b3f0d24d3 100644 --- a/packages/api/settings-controller/README.i18n.yaml +++ b/packages/api/settings-controller/README.i18n.yaml @@ -9,14 +9,14 @@ en: 7fecb4d8800e8f97 zh: 3e2c3fdb29b73cd0 /settings-controller/summary: - en: 94a995854f855609 - zh: 7165eecdd85a5632 + en: e5068267079e62d0 + zh: 9e2d663a6ca02c58 /settings-controller/table-of-contents: en: 3a654e12f423946b zh: 4e3a19aaee58f8a7 /settings-controller/use-this-package: - en: d6fbd0324044be6b - zh: 5e86d8c95e51b421 + en: cf2f4de5bee8be55 + zh: b2630dcba4be5ae8 /settings-controller/configuration: en: e0297f144b649af7 zh: e70ed56edffd97f0 diff --git a/packages/api/settings-controller/README.md b/packages/api/settings-controller/README.md index 17357971ed..b38072546d 100644 --- a/packages/api/settings-controller/README.md +++ b/packages/api/settings-controller/README.md @@ -8,7 +8,7 @@ English | [中文](README.zh.md) ## Summary -`@deepseek-ai/dsh-api-settings-controller` exposes generated `ctx.remote.settings` and `ctx.remote.credentials` namespaces for browser configuration surfaces. It returns redacted settings and credential metadata and supports settings and credential writes. It reads stored keys for owner-opened editors. It also opens provider-owned settings or Agent preset locations on the Host desktop. When a provider is absent, the namespace remains registered and returns an actionable configuration error. +`@deepseek-ai/dsh-api-settings-controller` exposes generated `ctx.remote.settings` and `ctx.remote.credentials` namespaces for browser configuration surfaces. It returns redacted settings and credential metadata, supports settings and credential writes without returning secret values, and opens provider-owned settings or Agent preset locations on the Host desktop. When a provider is absent, the namespace remains registered and returns an actionable configuration error. ## Table of Contents @@ -25,7 +25,7 @@ English | [中文](README.zh.md) Mount this package as a Loader entry in a profile that serves browser configuration. The entry registers both namespaces independently of their providers so a missing provider produces a named configuration error at invocation. Its generated descriptors enter the strict Typert registry, while the settings and credential Definitions remain plain Cordis Services with no wire obligations of their own. -`describe(refs)` answers one map keyed by the requested names, so a settings page describing every reference its rows carry settles those rows together. It accepts at most 64 names per call, reports an invalid name or empty write value as `bad-request`, and copies each answer field by field — a provider returning more than `CredentialInfo` declares cannot widen what crosses. Valid `set(ref, value)` and `unset(ref)` calls report a provider refusal as `credential-rejected`, carrying the provider's message with only the reference in its details. `readStored(ref)` returns a provider-managed key only for the owner or trusted in-process code; it returns no environment fallback. A non-owner read returns `credential/rejected` before consulting the provider. Metadata reads remain secret-free. +`describe(refs)` answers one map keyed by the requested names, so a settings page describing every reference its rows carry settles those rows together. It accepts at most 64 names per call, reports an invalid name or empty write value as `bad-request`, and copies each answer field by field — a provider returning more than `CredentialInfo` declares cannot widen what crosses. Valid `set(ref, value)` and `unset(ref)` calls report a provider refusal as `credential-rejected`, carrying the provider's message with only the reference in its details. Secret values cross in this direction only: no method here returns one. `settings.describe()` returns deployment facts and every namespace under `redactSecrets: true`. `settings.update`, `settings.replace`, and `settings.mutate` expose the settings service's three write operations and return the namespace's new redacted view; stale writes use `settings-conflict` and other provider refusals use `settings-rejected`. diff --git a/packages/api/settings-controller/README.zh.md b/packages/api/settings-controller/README.zh.md index 2c4f4a2b29..3c9e876740 100644 --- a/packages/api/settings-controller/README.zh.md +++ b/packages/api/settings-controller/README.zh.md @@ -8,7 +8,7 @@ kind: "package-reference" ## 概述 -`@deepseek-ai/dsh-api-settings-controller` 为浏览器配置界面提供生成的 `ctx.remote.settings` 与 `ctx.remote.credentials` namespace。它返回脱敏的 settings 与凭据元数据,支持 settings 与凭据写入。它为所有者打开的编辑器读取已保存密钥。它还在 Host 桌面打开由提供方持有的 settings 或 Agent preset 位置。提供方缺失时,namespace 仍会注册,并返回可操作的配置错误。 +`@deepseek-ai/dsh-api-settings-controller` 为浏览器配置界面提供生成的 `ctx.remote.settings` 与 `ctx.remote.credentials` namespace。它返回脱敏的 settings 与凭据元数据,支持 settings 与凭据写入而不返回机密值,并在 Host 桌面打开由提供方持有的 settings 或 Agent preset 位置。提供方缺失时,namespace 仍会注册,并返回可操作的配置错误。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" 请把本包作为 Loader entry 挂载到提供浏览器配置的 profile 中。本 entry 不依赖提供方是否存在而注册两个 namespace,因此缺少提供方会在调用时产生具名配置错误。它生成的 descriptor 进入严格 Typert 注册表,而 settings 与凭据 Definition 仍是普通 Cordis 服务,自身不承担任何 wire 义务。 -`describe(refs)` 以请求的名字为键返回一份 map,因此设置页描述其各行携带的全部引用时,这些行会一起落定。单次调用最多接受 64 个名字,无效名字或空写入值报告为 `bad-request`,并逐字段复制每个答案——提供方返回超出 `CredentialInfo` 声明的内容也无法扩大跨越 wire 的字段。有效的 `set(ref, value)` 与 `unset(ref)` 调用把提供方拒绝报告为 `credential-rejected`,携带提供方的消息,details 中只有该引用。`readStored(ref)` 仅向所有者或可信进程内代码返回提供方管理的密钥,不返回环境后备值。非所有者读取会在访问提供方之前返回 `credential/rejected`。元数据读取仍不包含机密。 +`describe(refs)` 以请求的名字为键返回一份 map,因此设置页描述其各行携带的全部引用时,这些行会一起落定。单次调用最多接受 64 个名字,无效名字或空写入值报告为 `bad-request`,并逐字段复制每个答案——提供方返回超出 `CredentialInfo` 声明的内容也无法扩大跨越 wire 的字段。有效的 `set(ref, value)` 与 `unset(ref)` 调用把提供方拒绝报告为 `credential-rejected`,携带提供方的消息,details 中只有该引用。机密值只在这个方向跨越 wire:这里没有任何方法会返回它。 `settings.describe()` 返回部署信息,以及在 `redactSecrets: true` 下读取的所有 namespace。`settings.update`、`settings.replace` 与 `settings.mutate` 暴露 settings 服务的三种写入操作,并返回该 namespace 的新脱敏视图;陈旧写入使用 `settings-conflict`,其他提供方拒绝使用 `settings-rejected`。 diff --git a/packages/api/settings-controller/package.json b/packages/api/settings-controller/package.json index e424a9e808..aebf4624c5 100644 --- a/packages/api/settings-controller/package.json +++ b/packages/api/settings-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-settings-controller", "description": "Remote owner for the configuration surfaces over the settings-domain seams", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/api/settings-controller/src/credentials.ts b/packages/api/settings-controller/src/credentials.ts index f9172f8f9e..3889669048 100644 --- a/packages/api/settings-controller/src/credentials.ts +++ b/packages/api/settings-controller/src/credentials.ts @@ -9,7 +9,7 @@ import { Context } from '@deepseek-ai/cordis' import { credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialProvider } from '@deepseek-ai/dsh-credentials' import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types' -import { principalOf, Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { z } from 'zod' /** @@ -61,8 +61,8 @@ declare module '@deepseek-ai/cordis' { * Host service backing the generated `ctx.remote.credentials` namespace. It * carries every wire obligation the credential seam itself does not: the batch * fan-out bound, the field-by-field view projection, the reference-grammar - * guard, and the refusal mapping. Metadata reads exclude secrets; readStored - * returns only a provider-managed value for an explicitly opened editor. + * guard, and the refusal mapping. Secret values cross in one direction only — + * no method here returns one. */ export class CredentialsController extends TypertRemoteService { /** @param ctx - Host context where a credential provider may be mounted. */ @@ -90,22 +90,8 @@ export class CredentialsController extends TypertRemoteService { } /** - * Read one stored value for a credential editor, excluding environment fallbacks. - * @param ref - reference name to read. - * @returns the stored secret, or undefined when the provider stores none. - * @throws RemoteError for a non-owner Remote caller, an invalid reference, or an absent provider. - */ - @Remote - async readStored(ref: string): Promise { - const request = parseRequest('credentials.readStored', unsetRequestSchema, { ref }) - if (principalOf(this.ctx.invocation) !== undefined) { - throw new RemoteError('credential/rejected', 'Only the owner can read stored credentials.', { ref: request.ref }) - } - return this.provider().readStored(credentialRef(request.ref)) - } - - /** - * Store one value from a configuration surface. + * Store one value from a configuration surface. The value crosses the wire in + * this direction only: no read path returns it. * @param ref - reference name to store under. * @param value - the non-empty secret value. * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. diff --git a/packages/api/settings-controller/src/types.ts b/packages/api/settings-controller/src/types.ts index d1dadc1e16..3d11c6ab0c 100644 --- a/packages/api/settings-controller/src/types.ts +++ b/packages/api/settings-controller/src/types.ts @@ -20,9 +20,9 @@ declare module '@deepseek-ai/dsh-typert-protocol' { */ 'settings/conflict': { readonly ns: string; readonly expected: number; readonly actual: number } /** - * A non-owner requested a stored secret, or the provider refused a valid - * credential write, for example because the stored document no longer - * parses. The details name only the reference, never the value. + * The provider refused a valid credential write, for example because the + * stored document no longer parses. The details name only the reference, + * never the value. */ 'credential/rejected': { readonly ref: string } } diff --git a/packages/api/settings-controller/tests/credentials-controller.host.spec.ts b/packages/api/settings-controller/tests/credentials-controller.host.spec.ts index 552baa7b5d..5eeb190385 100644 --- a/packages/api/settings-controller/tests/credentials-controller.host.spec.ts +++ b/packages/api/settings-controller/tests/credentials-controller.host.spec.ts @@ -2,17 +2,9 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types' import { remoteErrorOf, remoteMethods } from '@deepseek-ai/dsh-typert-protocol' -import type { PeerId, RemoteInvocation } from '@deepseek-ai/dsh-typert-protocol' import CredentialsController from '../src/credentials.ts' import { MemoryCredentials } from '../../../credentials/credentials/tests/memory.ts' -declare module '@deepseek-ai/dsh-typert-protocol' { - interface PrincipalMap { - /** Non-owner used to verify stored-key read denial. */ - 'credential-viewer': { readonly kind: 'credential-viewer' } - } -} - /** A store whose `describe` carries more than the view declares, as a foreign provider might. */ class LeakyCredentials extends MemoryCredentials { override describe(): Promise { @@ -54,7 +46,6 @@ describe('the credentials Remote namespace a configuration surface calls', () => expect(binding.namespace).toBe('credentials') expect(remoteMethods(controller)).toEqual([ { method: 'describe', invocation: { kind: 'direct' } }, - { method: 'readStored', invocation: { kind: 'direct' } }, { method: 'set', invocation: { kind: 'direct' } }, { method: 'unset', invocation: { kind: 'direct' } }, ]) @@ -65,7 +56,6 @@ describe('the credentials Remote namespace a configuration surface calls', () => await ctx.plugin(CredentialsController) for (const call of [ () => ctx.credentialsController.describe(['DEEPSEEK_API_KEY']), - () => ctx.credentialsController.readStored('DEEPSEEK_API_KEY'), () => ctx.credentialsController.set('DEEPSEEK_API_KEY', 'sk-live'), () => ctx.credentialsController.unset('DEEPSEEK_API_KEY'), ]) { @@ -92,7 +82,6 @@ describe('the credentials Remote namespace a configuration surface calls', () => const controller = await boot() for (const call of [ () => controller.describe(['DEEPSEEK_API_KEY', 'not a var']), - () => controller.readStored('not a var'), () => controller.set('not a var', 'sk-live'), () => controller.unset('not a var'), ]) { @@ -126,35 +115,6 @@ describe('the credentials Remote namespace a configuration surface calls', () => .toEqual({ DEEPSEEK_API_KEY: { configured: false, writable: true } }) }) - it('reads stored secrets only through the explicit editor read', async () => { - const controller = await boot({ DEEPSEEK_API_KEY: 'fixture-saved-key' }) - expect(await controller.readStored('DEEPSEEK_API_KEY')).toBe('fixture-saved-key') - expect(await controller.readStored('OPENAI_API_KEY')).toBeUndefined() - expect(JSON.stringify(await controller.describe(['DEEPSEEK_API_KEY']))).not.toContain('fixture-saved-key') - await controller.unset('DEEPSEEK_API_KEY') - expect(await controller.readStored('DEEPSEEK_API_KEY')).toBeUndefined() - }) - - it('denies non-owner stored-key reads before consulting the provider', async () => { - const ctx = new Context() - await ctx.plugin(MemoryCredentials, { DEEPSEEK_API_KEY: 'fixture-saved-key' }) - await ctx.plugin(CredentialsController) - const signal = new AbortController().signal - const invocation: RemoteInvocation = { - request: { namespace: 'credentials', method: 'readStored', args: { ref: 'DEEPSEEK_API_KEY' } }, - service: 'credentialsController', signal, - peer: { - id: 'credential-viewer' as PeerId, ctx, signal, principal: { kind: 'credential-viewer' }, - interactions: 'none', dispose: () => Promise.resolve(), - }, - uplink: async function* (): AsyncGenerator {}, - } - const caller = ctx.extend({ invocation }) - const failure = await caller.credentialsController.readStored('DEEPSEEK_API_KEY').catch((error: unknown) => error) - expect(remoteErrorOf(failure)).toMatchObject({ code: 'credential/rejected', details: { ref: 'DEEPSEEK_API_KEY' } }) - expect(JSON.stringify(remoteErrorOf(failure))).not.toContain('fixture-saved-key') - }) - it('reports a refused write as credential/rejected naming only the reference', async () => { const controller = await boot({}, RejectingCredentials) const failure = await controller.set('DEEPSEEK_API_KEY', 'sk-live').catch((error: unknown) => error) diff --git a/packages/api/terminal-controller/package.json b/packages/api/terminal-controller/package.json index 84a53b5df6..0cf239bd87 100644 --- a/packages/api/terminal-controller/package.json +++ b/packages/api/terminal-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-terminal-controller", "description": "Session-owned interactive terminals with shell discovery, screen recovery and typed Remote control", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index 8a7363da4f..05bd18da51 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-workspace-controller", "description": "Workspace Remote commands and reconnect-safe state transport", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/api/workspace-files/package.json b/packages/api/workspace-files/package.json index 5170995310..5e0761a952 100644 --- a/packages/api/workspace-files/package.json +++ b/packages/api/workspace-files/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-workspace-files", "description": "Workspace file service and Client resource provider: bounded reads, directory listing, and live metadata over the workspaceFiles Remote namespace", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index 8e7071c71d..322f35c396 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment-local", "description": "Private content-addressed DSH_HOME attachment storage", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index 650a98b1ae..749f557959 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment", "description": "Durable immutable attachment storage seam for the DeepSeek Harness", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index 6bcc5fa3d2..fa44d7262a 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-app-boot", "description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index b89eb3632a..daf1406a43 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -413,7 +413,7 @@ async function recompose( // Ascending original positions put each row back where it was. for (const { group, options, index } of stopped.toSorted((left, right) => left.index - right.index)) { // `create` starts the entry but leaves the group's option list as it is; that list is what the next update - // compares against to remove the row and what the Include writes back. + // compares against to remove the row. group.data.splice(index, 0, options) await group.create(options) } @@ -656,10 +656,9 @@ export function renderConfigDump( const base = parsed as Parameters[0] // snapshot_k = ONE application of layers 1..k flattened, using the exact // arguments boot passes for that prefix. snapshot_N is the mounted composition. - // The patches are cloned per call: applyEntryPatches detaches the entry - // list but pushes `insert` rows by reference from the patch list, so - // sharing patch objects across snapshot calls would leak a later - // snapshot's mutations into an earlier one's result. + // The patches are cloned per call: applyEntryPatches clones entries and + // inserted rows but assigns override values such as `config` by reference, + // so each snapshot keeps its own copies of those values. const snapshot = (count: number, warnings: string[]): ReturnType => { const flattened = structuredClone(layers.slice(0, count).flatMap(layer => layer.patches)) return applyEntryPatches(base, flattened, (message: string, ...args: unknown[]) => { @@ -804,8 +803,8 @@ export interface FailLoudProcess { exit(code: number): void } -// Loader rc.5 derives and drops a rejected promise after a fiber fails. Keep -// exact reasons already folded into the boot diagnostic visible through the +// An activation rejection already folded into the boot diagnostic can also +// reach the process rejection handler. Keep exact reasons visible through the // next process rejection checkpoint so the process guard can coalesce them. const assembledActivationRejections = new Map() diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index 04f5f23be1..291cc2da01 100644 --- a/packages/boot/cmdline/package.json +++ b/packages/boot/cmdline/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cmdline", "description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/boot/config-editor/package.json b/packages/boot/config-editor/package.json index 783ce890db..a754166669 100644 --- a/packages/boot/config-editor/package.json +++ b/packages/boot/config-editor/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-config-editor", "description": "Persist plugin configuration through profile patches and Loader reconciliation", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/boot/hmr/package.json b/packages/boot/hmr/package.json index eeba988bfb..404f80021e 100644 --- a/packages/boot/hmr/package.json +++ b/packages/boot/hmr/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hmr", "description": "Coordinated module and profile configuration hot reload", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/boot/hmr/src/index.ts b/packages/boot/hmr/src/index.ts index 36c16ba6ac..8c5c97070a 100644 --- a/packages/boot/hmr/src/index.ts +++ b/packages/boot/hmr/src/index.ts @@ -469,7 +469,10 @@ class Hmr extends Service { loader.exit() return } - for (const include of includes) await include.refresh() + for (const include of includes) { + // A rejected refresh reports unsaved runtime changes; Include keeps its running tree. + try { await include.refresh() } catch (error) { this.ctx.logger.warn(error) } + } if (this.stashed.size > 0) { try { await this.partialReload() } finally { this.stashed.clear() } } diff --git a/packages/boot/hmr/tests/coordination.spec.ts b/packages/boot/hmr/tests/coordination.spec.ts index e0dceccb99..d19e92729e 100644 --- a/packages/boot/hmr/tests/coordination.spec.ts +++ b/packages/boot/hmr/tests/coordination.spec.ts @@ -135,6 +135,31 @@ it('refreshes an Include through the queue and skips registered exact paths', as expect(refresh).toHaveBeenCalledOnce() }) +it('continues the batch when an Include refresh reports unsaved changes', async () => { + const { ctx, dir } = await fixture() + const moduleWatcher = watchers.at(-1)! + const imported = vi.spyOn(ctx.loader, 'import').mockResolvedValue(Include) + onTestFinished(() => { imported.mockRestore() }) + const includes = [] + for (const name of ['first.yml', 'second.yml']) { + const file = join(dir, name) + writeFileSync(file, '[]\n') + const id = await ctx.loader.create({ name: 'include', config: { path: pathToFileURL(file).href } }) + includes.push({ file, id }) + } + await ctx.loader.await() + const [first, second] = includes.map(({ id }) => ctx.loader.resolve(id).subtree as Include) + const failure = new Error('config file has 1 unsaved change(s)') + const rejected = vi.spyOn(first!, 'refresh').mockRejectedValue(failure) + const refreshed = vi.spyOn(second!, 'refresh') + const warn = vi.spyOn(ctx.logger, 'warn') + moduleWatcher.emit('change', includes[0]!.file) + moduleWatcher.emit('change', includes[1]!.file) + await vi.waitFor(() => { expect(refreshed).toHaveBeenCalledOnce() }) + expect(rejected).toHaveBeenCalledOnce() + expect(warn).toHaveBeenCalledWith(failure) +}) + it('queues cached module replacements behind configuration mutations and recovers after failure', async () => { const { ctx, dir, hmr } = await fixture() const file = join(dir, 'source.mjs') diff --git a/packages/boot/plugin-manager/package.json b/packages/boot/plugin-manager/package.json index cee821053b..075f8adb93 100644 --- a/packages/boot/plugin-manager/package.json +++ b/packages/boot/plugin-manager/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plugin-manager", "description": "Current-profile plugin and bundle management shared by dsh CLI, Web and agent tools", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/boot/plugin-market/package.json b/packages/boot/plugin-market/package.json index 86c97b79b7..c737098a31 100644 --- a/packages/boot/plugin-market/package.json +++ b/packages/boot/plugin-market/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plugin-market", "description": "Configured plugin market indexes for the Web Discover page and the plugin_market agent tool", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/browser-use/browser-use/package.json b/packages/browser-use/browser-use/package.json index 5a67143621..c5dbaea00a 100644 --- a/packages/browser-use/browser-use/package.json +++ b/packages/browser-use/browser-use/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-browser-use", "description": "Exclusive named browser-use provider registration", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/acp-app/package.json b/packages/bundle/acp-app/package.json index d48b0bf711..b10217beac 100644 --- a/packages/bundle/acp-app/package.json +++ b/packages/bundle/acp-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp-app", "description": "The dsh ACP profile bundle: automation-only JSON-RPC stdio and process lifecycle over dsh-base", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 6740234ade..d94f5cf2dd 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -297,7 +297,7 @@ name: '@deepseek-ai/dsh-tool-fs' config: # Set false to drop the apply_patch tool beside edit. - applyPatch: true + applyPatch: false - id: tool-fs-search name: '@deepseek-ai/dsh-tool-fs-search' @@ -461,21 +461,28 @@ # The shared base enables the stable model-facing web_search and web_fetch # tools. The Web app disables this host row and composes both tools per agent - # preset; products with a stricter network policy override tool-web. DeepSeek - # search resolves the same DEEPSEEK_API_KEY - # credential the Models page manages for chat, at each search; its Messages - # endpoint is configured independently from the model route, so it takes its own - # base-URL override. Anonymous fetch accepts only public HTTP(S) destinations, - # resolves and validates every destination, and pins every actual connection. - # Search is a full auxiliary model request with server-side retrieval, so this - # shipped DeepSeek route gets 60s while the provider-neutral tool default - # remains 30s. The DeepSeek Search and Fetch APIs register as - # deepseek-search-api and deepseek-fetch-api with the same credential; the Web - # search settings switch selects both in place of these defaults. + # preset; products with a stricter network policy override tool-web. Search + # defaults to the DeepSeek Search API (deepseek-search-api) and fetch to the + # anonymous local fetcher (http). The DeepSeek Search and Fetch APIs + # (deepseek-search-api, deepseek-fetch-api) follow the conversation: on the + # deepseek-account route they send the account token to that route's + # origin, and otherwise the API key of the DeepSeek API-key route to its + # origin, so a DEEPSEEK_API_KEY alone serves both without a sign-in. + # searchBaseURL / searchApiKeyEnv (fetch* for Fetch) pin one capability to + # its own endpoint and key instead. + # Legacy Messages search (deepseek-official) is selectable separately and + # uses its own endpoint, with the account token or DEEPSEEK_API_KEY. + # Anonymous fetch accepts only public + # HTTP(S) destinations, resolves and validates every destination, and pins + # every actual connection. This composition gives web_search 60s whichever + # provider serves it; the provider-neutral tool default remains 30s. The Web + # search provider and Web fetch provider + # menus on the Web search and fetch settings page select a provider for each + # capability in place of these defaults. - id: web name: '@deepseek-ai/dsh-web' config: - searchProvider: deepseek-official + searchProvider: deepseek-search-api fetchProvider: http - id: web-search-deepseek @@ -485,8 +492,6 @@ - id: web-deepseek-api name: '@deepseek-ai/dsh-web-deepseek-api' - config: - apiKeyEnv: DEEPSEEK_API_KEY - id: web-fetch-http name: '@deepseek-ai/dsh-web-fetch-http' diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 68a395af92..2565cbc7d5 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-base", "description": "The shared dsh core as a profile bundle: the first patch layer of base-backed profiles, inserting core rows over the empty profile root", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/base/tests/base.spec.ts b/packages/bundle/base/tests/base.spec.ts index 0976268a07..4a9d5b5c54 100644 --- a/packages/bundle/base/tests/base.spec.ts +++ b/packages/bundle/base/tests/base.spec.ts @@ -41,12 +41,18 @@ describe('dsh-base bundle', () => { }) expect(rows.filter(row => row.id === 'subagent-codex')).toHaveLength(0) expect(rows.filter(row => row.id === 'subagent-claude-code')).toHaveLength(0) - expect(rows.find(row => row.id === 'web')?.config).toMatchObject({ fetchProvider: 'http' }) + expect(rows.find(row => row.id === 'web')?.config).toEqual({ searchProvider: 'deepseek-search-api', fetchProvider: 'http' }) + expect(rows.find(row => row.id === 'web-deepseek-api')).toEqual({ id: 'web-deepseek-api', name: '@deepseek-ai/dsh-web-deepseek-api' }) expect(rows.find(row => row.id === 'web-fetch-http')).toBeDefined() + expect(rows.find(row => row.id === 'web-search-deepseek')).toMatchObject({ + name: '@deepseek-ai/dsh-web-search-deepseek', config: { apiKeyEnv: 'DEEPSEEK_API_KEY' }, + }) expect(rows.find(row => row.id === 'tool-web')?.config).toMatchObject({ fetch: true }) expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-codex') expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-claude-code') expect(manifest.dependencies).toHaveProperty('@deepseek-ai/dsh-web-fetch-http') + expect(manifest.dependencies).toHaveProperty('@deepseek-ai/dsh-web-deepseek-api') + expect(manifest.dependencies).toHaveProperty('@deepseek-ai/dsh-web-search-deepseek') }) it('gates each shell stack by platform with a symmetric disabled expression', () => { diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index d636b5d4b3..136146268d 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/packages/bundle/headless/README.i18n.yaml @@ -21,8 +21,8 @@ en: b3d0ddfb56a1ac7f zh: c1762158e49a4851 /deepseek-ai-dsh-headless/use-this-package/credentials: - en: ca8217837ee9c698 - zh: fa20daf27d4e09ef + en: bf02dd1c6888da25 + zh: 83dca9600cd7e8ff /deepseek-ai-dsh-headless/use-this-package/choosing-the-session-identity: en: 85120c2d6db2cac4 zh: bac532df6c84e755 diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index f9c53e7d2e..8bcc97a4ca 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -55,6 +55,8 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a The shipped headless profile disables the managed credential store and its authorization and DeepSeek account consumers. API-key routes use the launch environment, then `/.env`, then `$DSH_HOME/.env`. Keys and sign-ins saved by the GUI are ignored without changing their file. Providers that require a saved sign-in fail unless stored authentication is restored; external provider credential chains, such as AWS profiles, remain available. +The DeepSeek Search and Fetch APIs [follow the session's DeepSeek credentials](../../web/web-deepseek-api/README.md#authentication). In the shipped headless profile, they send the DeepSeek model route's `DEEPSEEK_API_KEY` to that route's origin, so no sign-in is needed. Without that key, the model request omits `web_search`. + Unset an empty launch variable to allow its `.env` fallback; an exported empty value shadows that fallback. To restore stored authentication, add these rows to `$DSH_HOME/profiles/headless/cordis.patch.yml` or a `--patch` overlay: diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index 8600a6f2f3..c6b7138278 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -55,6 +55,8 @@ agent 会完成该任务,把提供方的每个非空推理(reasoning)增 随附的 headless profile 禁用托管凭据存储,以及依赖它的 authorization 和 DeepSeek account 消费方。API 密钥路由依次使用启动环境、`<运行命令的目录>/.env` 和 `$DSH_HOME/.env`。GUI 保存的密钥和登录凭据不会被使用,存储文件保持原样。需要已保存登录凭据的提供方会失败,除非恢复存储认证;AWS profile 等提供方外部凭据链仍然可用。 +DeepSeek Search 与 Fetch API [跟随会话的 DeepSeek 凭据](../../web/web-deepseek-api/README.zh.md#authentication)。在随附的 headless profile 中,它们将 DeepSeek 模型路由的 `DEEPSEEK_API_KEY` 发往该路由的 origin,因此无需登录。没有该密钥时,模型请求不包含 `web_search`。 + 启动变量为空时,请取消导出该变量,让 `.env` 后备生效;已导出的空值会遮蔽后备值。 要恢复存储认证,请把以下行加入 `$DSH_HOME/profiles/headless/cordis.patch.yml` 或 `--patch` 覆盖文件: diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index dd523037fe..121a0feb5a 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-headless", "description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json index 7be81f2b9c..2bb70ddf4e 100644 --- a/packages/bundle/sdk-app/package.json +++ b/packages/bundle/sdk-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-app", "description": "The dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-base", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index 499aad21e4..ed56dcb80a 100644 --- a/packages/bundle/sdk-minimal/package.json +++ b/packages/bundle/sdk-minimal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-minimal", "description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, and JSONL sessions", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 0e4ff251e5..fba72cd385 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -146,6 +146,14 @@ - id: settings-controller name: '@deepseek-ai/dsh-api-settings-controller' + # One shared MCP server fleet: Settings-configured servers plus servers + # that installed plugins register, with status and recovery over Remote. + # Credential templates and OAuth grants resolve through the credentials + # provider, so the manager starts after it. + - id: mcp-manager + name: '@deepseek-ai/dsh-mcp-manager' + inject: [credentials] + # Workspace commands and reconnect-safe projection over Typert Remote. - id: workspace-controller name: '@deepseek-ai/dsh-api-workspace-controller' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 89cc800d9f..db79100589 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-app", "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, bash runtime variables, URL line)", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, @@ -150,6 +150,7 @@ "@deepseek-ai/dsh-host-product-telemetry-otel": "workspace:*", "@deepseek-ai/dsh-host-webserver": "workspace:*", "@deepseek-ai/dsh-launch-environment": "workspace:*", + "@deepseek-ai/dsh-mcp-manager": "workspace:*", "@deepseek-ai/dsh-message-feedback": "workspace:*", "@deepseek-ai/dsh-office-to-pdf": "workspace:*", "@deepseek-ai/dsh-persona": "workspace:*", diff --git a/packages/bundle/web-app/presets/standard.patch.yml b/packages/bundle/web-app/presets/standard.patch.yml index a1aff022b0..e2e619fddd 100644 --- a/packages/bundle/web-app/presets/standard.patch.yml +++ b/packages/bundle/web-app/presets/standard.patch.yml @@ -28,7 +28,7 @@ name: '@deepseek-ai/dsh-tool-fs' config: # Set false to drop the apply_patch tool beside edit. - applyPatch: true + applyPatch: false - id: tool-fs-search name: '@deepseek-ai/dsh-tool-fs-search' config: diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 85d5d69354..008dfa67de 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -115,6 +115,8 @@ One UI feature = one plugin package (`src/client/` browser half). A multi-domain ## Styling and localization +Use [dsh-client-ui-ux](../../.agents/skills/dsh-client-ui-ux/SKILL.md) for UI design rules and review requirements. The client test ladder below still applies. + [docs/web-styling.md](../../docs/web-styling.md) is authoritative. Shared `--dsw-*` tokens and global sheets live in `ui-theme/src/styles/`; feature components consume semantic aliases through CSS Modules and `clsx`, with no literal colors, component library, or Tailwind. Code comments are English. Design judgment beyond those rules — feedback surfaces, loading states, overlay safety, platform window adaptation — follows [dsh-client-ui-ux](../../.agents/skills/dsh-client-ui-ux/SKILL.md); responsive layout and telling a mobile or touch client from a desktop one follow [dsh-responsive-ui](../../.agents/skills/dsh-responsive-ui/SKILL.md). Menus use `Menu` or `MenuSurface`, including custom listboxes, except the explicitly listed schedule containers. Preserve the shared transparency and blur ([rules](../../docs/web-styling.md#component-rules)). diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 1c3494a2f5..f77d3835d7 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1eb35b015a6c423f zh: a8a3dceee2c9e943 /client-web-gui-browser-half/packages: - en: d7adae6baeed495b - zh: 9e49fb00adc8eec1 + en: 2165938f26300774 + zh: 5f739534a8509545 /client-web-gui-browser-half/related-documentation: en: d1d506e68fab7333 zh: 1a90fe6f3f8f24a9 diff --git a/packages/client/README.md b/packages/client/README.md index a61299c2d2..3047a181c1 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -81,7 +81,7 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`ui-settings-agent-loop/`](ui-settings-agent-loop/README.md) | Provides the agent-loop settings page on the Plugins page | — | | [`ui-settings-session-log/`](ui-settings-session-log/README.md) | Controls API Session-log upload in General settings | — | | [`ui-settings-subagent/`](ui-settings-subagent/README.md) | Provides the Subagent settings page on the Plugins page | — | -| [`ui-settings-web-search/`](ui-settings-web-search/README.md) | Provides the web-search settings page on the Plugins page | — | +| [`ui-settings-web-search/`](ui-settings-web-search/README.md) | Provides the web search and fetch settings page on the Plugins page | — | | [`ui-plugin-manager/`](ui-plugin-manager/README.md) | Contributes the sidebar Plugins panel: install, enable, disable, retry, and compose installed packages | — | | [`ui-entry-customization/`](ui-entry-customization/README.md) | Adds region-local More menus that pin or unpin plugin launchers and start a plugin draft | — | | [`ui-plugin-market/`](ui-plugin-market/README.md) | Contributes the Discover tab to the Plugins page: browse market indexes and install their bundles | — | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 1e865a79c2..e327a717d9 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -81,7 +81,7 @@ kind: "package-group" | [`ui-settings-agent-loop/`](ui-settings-agent-loop/README.zh.md) | 在插件页提供 Agent 循环设置页 | — | | [`ui-settings-session-log/`](ui-settings-session-log/README.zh.md) | 在通用设置中控制随 API 上传会话日志 | — | | [`ui-settings-subagent/`](ui-settings-subagent/README.zh.md) | 在插件页提供子智能体设置页 | — | -| [`ui-settings-web-search/`](ui-settings-web-search/README.zh.md) | 在插件页提供网页搜索设置页 | — | +| [`ui-settings-web-search/`](ui-settings-web-search/README.zh.md) | 在插件页提供网页搜索与获取设置页 | — | | [`ui-plugin-manager/`](ui-plugin-manager/README.zh.md) | 贡献侧栏的“插件”面板:安装、启用、停用、重试与组合已安装的包 | — | | [`ui-entry-customization/`](ui-entry-customization/README.zh.md) | 添加分区域的“更多”菜单,固定或收起插件入口,并开始创建插件的草稿 | — | | [`ui-plugin-market/`](ui-plugin-market/README.zh.md) | 为“插件”页面贡献“发现”标签页:浏览市场索引并安装其中的组合包 | — | diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 22f21df125..c241804058 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-connection", "description": "Authenticated RPC transport and generation lifecycle", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/file-upload/package.json b/packages/client/file-upload/package.json index d91c931e21..469d7fca07 100644 --- a/packages/client/file-upload/package.json +++ b/packages/client/file-upload/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-file-upload", "description": "Agent-scoped browser file upload, streaming intake, and staged receipt service", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index 0ad8192ce6..e65b6b8b30 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-hmr", "description": "Web client graph synchronization and rebuilt-bundle reload transport", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 44227c6ebe..f1d983c979 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-locale", "description": "Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/modules/README.i18n.yaml b/packages/client/modules/README.i18n.yaml index 8f9a9d597f..86ee490193 100644 --- a/packages/client/modules/README.i18n.yaml +++ b/packages/client/modules/README.i18n.yaml @@ -54,8 +54,8 @@ en: 253d9f265cd83ed0 zh: 9d0f8df84d9d3ac9 /deepseek-ai-dsh-client-modules/understand-the-implementation/entry-ownership: - en: df2994f9ff8872c9 - zh: cd776526ed2b51e6 + en: 608a25c52159629f + zh: c5ef242899cb32c1 /deepseek-ai-dsh-client-modules/understand-the-implementation/source-map: en: d8b5407a0279a3f9 zh: 27af3437fad78672 diff --git a/packages/client/modules/README.md b/packages/client/modules/README.md index a27f4271fd..3cf748d9a0 100644 --- a/packages/client/modules/README.md +++ b/packages/client/modules/README.md @@ -92,7 +92,7 @@ The host contributes structured index rows that inject, into ``: the `wind ### Entry ownership -`ClientEntries` records the entries created during boot and serializes full-graph updates, retries and code reloads over the same Loader. A local generation prevents an older download from mounting after its desired entry or code changes; snapshots of the same targets share the pending load. A graph from another release than the boot release stays outside the queue: retries keep reconciling the last applied graph, and rebuilt frames are ignored while the Host serves that release. New arrivals use single-resource URLs, never startup batches that could register existing factories twice. Factories retain their artifact revision before an entry exists; graph updates discard stale unowned factories, their styles and failed arrival targets before importing consumers. Cleanup retains declared and observed transitive module requests from every remaining Loader entry. Its observable status has no runtime library import because the modules bootstrap materializes before platform seeds are available. +`ClientEntries` records the entries created during boot and serializes full-graph updates, retries and code reloads over the same Loader. As the Host Loader group does, a boot entry whose creation rejects, such as a module that exports no plugin, is logged and stays inactive for the boot audit instead of aborting startup. A local generation prevents an older download from mounting after its desired entry or code changes; snapshots of the same targets share the pending load. A graph from another release than the boot release stays outside the queue: retries keep reconciling the last applied graph, and rebuilt frames are ignored while the Host serves that release. New arrivals use single-resource URLs, never startup batches that could register existing factories twice. Factories retain their artifact revision before an entry exists; graph updates discard stale unowned factories, their styles and failed arrival targets before importing consumers. Cleanup retains declared and observed transitive module requests from every remaining Loader entry. Its observable status has no runtime library import because the modules bootstrap materializes before platform seeds are available. ### Source map diff --git a/packages/client/modules/README.zh.md b/packages/client/modules/README.zh.md index ab6fe609b3..9016041c36 100644 --- a/packages/client/modules/README.zh.md +++ b/packages/client/modules/README.zh.md @@ -93,7 +93,7 @@ bundle 路由随注入的 `webServer` 生命周期注册:服务就绪时注册 ### 条目所有权 -`ClientEntries` 记录启动时创建的条目,并在同一个 Loader 上串行执行完整图更新、重试和代码重载。本地代际阻止旧下载在目标条目或代码变化后挂载;目标相同的快照共用进行中的加载。来自启动 release 以外 release 的图不进入队列:重试继续调和上次应用的图,Host 提供该 release 期间忽略 rebuilt 帧。新增模块使用单资源 URL,不会重新执行可能重复注册现有 factory 的启动 batch。Factory 在条目创建前就保留产物 revision;图更新会在导入消费者前丢弃未归属条目的陈旧 factory、其样式和失败的到达目标。清理会保留每个剩余 Loader 条目的已声明及已观察到的传递模块依赖。其可观察状态不导入运行时库,因为 modules bootstrap 在平台种子可用之前物化。 +`ClientEntries` 记录启动时创建的条目,并在同一个 Loader 上串行执行完整图更新、重试和代码重载。与 Host Loader 分组一样,启动时创建失败的条目(例如模块未导出插件)会记录日志并保持未激活,交由启动审计处理,而不会中止启动。本地代际阻止旧下载在目标条目或代码变化后挂载;目标相同的快照共用进行中的加载。来自启动 release 以外 release 的图不进入队列:重试继续调和上次应用的图,Host 提供该 release 期间忽略 rebuilt 帧。新增模块使用单资源 URL,不会重新执行可能重复注册现有 factory 的启动 batch。Factory 在条目创建前就保留产物 revision;图更新会在导入消费者前丢弃未归属条目的陈旧 factory、其样式和失败的到达目标。清理会保留每个剩余 Loader 条目的已声明及已观察到的传递模块依赖。其可观察状态不导入运行时库,因为 modules bootstrap 在平台种子可用之前物化。 ### 源码索引 diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index 558dc133e9..82d1743a16 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-modules", "description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/modules/src/client/entries.ts b/packages/client/modules/src/client/entries.ts index e33d0055f8..5da84399ff 100644 --- a/packages/client/modules/src/client/entries.ts +++ b/packages/client/modules/src/client/entries.ts @@ -77,6 +77,8 @@ export class ClientEntries { /** * Create the initial roster and retain its entry identities for subsequent reconciliation. + * As the Host Loader group does, a row whose creation rejects (for example a module that exports no + * plugin) is logged and stays an inactive entry, so one row cannot abort startup before the boot audit. * @param loader - Page Loader, already configured with the module system. * @param manifest - Initial roster audited by the boot caller. * @returns after initial entries and their activation settle; boot owns its activation audit. @@ -92,7 +94,7 @@ export class ClientEntries { }, 'client-modules: entry reconciliation') return this.enqueue(async () => { await Promise.all(this.desired.plugins.map(async ({ id }) => { - await this.create(loader, id) + await this.create(loader, id).catch((error: unknown) => { loader.ctx.logger.error(error) }) })) await loader.await() for (const row of this.modules.manifest.modules) this.revisions.set(row.id, row.rev) diff --git a/packages/client/modules/tests/entries.client.spec.ts b/packages/client/modules/tests/entries.client.spec.ts index dc908bea33..0f56f99b10 100644 --- a/packages/client/modules/tests/entries.client.spec.ts +++ b/packages/client/modules/tests/entries.client.spec.ts @@ -347,6 +347,22 @@ it('retains a rejected Loader entry for retry instead of creating an orphan sibl expect(entry.fiber?.state).toBe(2) }) +it('logs a row whose Loader creation rejects at startup and leaves it inactive for the boot audit', async () => { + let broken = true + const b = await bench(graph(row('a'), row('b')), { a: () => broken ? { default: 'invalid' } : { apply() {} }, b: () => ({ apply() {} }) }, false) + const error = vi.spyOn(b.ctx.logger, 'error').mockImplementation(() => {}) + await b.modules.entries.start(b.ctx.loader, b.modules.manifest) + const entries = new Map([...b.ctx.loader.entries()].map(entry => [entry.options.name, entry])) + expect(error).toHaveBeenCalledOnce() + expect(error.mock.calls[0]?.[0]).toHaveProperty('message', expect.stringContaining('invalid plugin')) + expect(entries.get('a')?.fiber).toBeUndefined() + expect(entries.get('b')?.fiber?.state).toBe(2) + broken = false + await b.modules.entries.retry() + expect([...b.ctx.loader.entries()]).toHaveLength(2) + expect(entries.get('a')?.fiber?.state).toBe(2) +}) + it('reports Loader import failures and recovers the same entry after the importer recovers', async () => { const b = await bench(graph(), { a: () => ({ apply() {} }) }) let broken = true diff --git a/packages/client/product-analytics/package.json b/packages/client/product-analytics/package.json index 9848ca3c08..f076c28603 100644 --- a/packages/client/product-analytics/package.json +++ b/packages/client/product-analytics/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-product-analytics", "description": "Desktop product event collection and authenticated Host reporting", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/resources/package.json b/packages/client/resources/package.json index fd54263983..c371a6fccd 100644 --- a/packages/client/resources/package.json +++ b/packages/client/resources/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-resources", "description": "Unified client resource model: protocol-registered providers turn URL addresses into live values, consumed through the useResource global standard hook", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/shortcuts/package.json b/packages/client/shortcuts/package.json index 8ae1eb16a2..6b0e6ecfff 100644 --- a/packages/client/shortcuts/package.json +++ b/packages/client/shortcuts/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-shortcuts", "description": "Application keyboard command registry and physical-key routing", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/store/package.json b/packages/client/store/package.json index 81ce31353c..a7101a61f2 100644 --- a/packages/client/store/package.json +++ b/packages/client/store/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-store", "description": "React-free observable and snapshot-store contracts with the shared Zustand/Immer engine", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 18982db48e..aa97a7b60d 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-agent-preset", "description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-agent-team/package.json b/packages/client/ui-agent-team/package.json index 271137193a..12887a765d 100644 --- a/packages/client/ui-agent-team/package.json +++ b/packages/client/ui-agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-agent-team", "description": "Web shared task board for a subagent tree", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-approval/README.i18n.yaml b/packages/client/ui-approval/README.i18n.yaml index ab94e8e499..1262b1d325 100644 --- a/packages/client/ui-approval/README.i18n.yaml +++ b/packages/client/ui-approval/README.i18n.yaml @@ -12,8 +12,8 @@ en: 8d6c7c6e63f849f6 zh: d89fa273e30b99d3 /deepseek-ai-dsh-client-ui-approval/table-of-contents: - en: 7b5184a35d7607f9 - zh: 55ebfea4d5ad26ed + en: 4dba952a4e1f1d6b + zh: a33ee85e0b0cb212 /deepseek-ai-dsh-client-ui-approval/model-experience: en: 8161dce49e9f899c zh: a17401e1e78ba6be diff --git a/packages/client/ui-approval/README.md b/packages/client/ui-approval/README.md index 890c1e8035..4040a15a65 100644 --- a/packages/client/ui-approval/README.md +++ b/packages/client/ui-approval/README.md @@ -22,6 +22,8 @@ Focus the approval detail region to approve with Enter or reject with Escape. Th When offered, **Always allow** is the highlighted primary button. Otherwise, the current-request allow action is primary. Enter in the detail region always grants the limited scope: once for a tool approval, or the current session for CUA. Persistent access requires explicitly activating Always allow. The shortcut reference uses Allow because the caller supplies that scope. +Callers can supply localized warning copy through the shared card's optional `warning` field, with paragraphs separated by blank lines (`\n\n`). Hovering or keyboard-focusing the tag opens a formatted nonmodal card; clicking or tapping also opens it. The pointer can cross into the card to read or select its text. Leaving both surfaces closes it after a short grace period unless keyboard focus remains inside. Moving focus outside, clicking outside, and Escape also dismiss it without rejecting the request. Hover does not move focus. Moving focus elsewhere also closes a hover-open card, including when a foreground modal takes focus. Switching away from the window or hiding the browser tab closes it without restoring focus. Foreground modals and input-method composition retain their keyboard handling. CSS anchors position the card above the tag with window-edge fallbacks and internal scrolling when space is limited. + ## Model Experience diff --git a/packages/client/ui-approval/README.zh.md b/packages/client/ui-approval/README.zh.md index 76ec67b275..8e24f913d6 100644 --- a/packages/client/ui-approval/README.zh.md +++ b/packages/client/ui-approval/README.zh.md @@ -22,6 +22,8 @@ kind: "package-reference" 提供“始终允许”时,将它显示为高亮主按钮;否则高亮当前请求的允许按钮。在详情区域按 Enter 始终授予有限范围的许可:工具审批为单次,CUA 为当前会话。持久授权需要显式操作“始终允许”按钮。快捷键说明使用“允许”,具体范围由调用方提供。 +调用方可以通过共享卡片的可选 `warning` 字段提供本地化风险说明,段落以空行(`\n\n`)分隔。悬停或通过键盘聚焦标签会打开保留格式的非模态卡片;点击或轻触也可打开。指针可以移入卡片阅读或选择文字。离开标签与卡片后,卡片会在短暂宽限期后关闭,但键盘焦点仍在其中时保持打开。焦点移出、点击外部或按 Escape 也可关闭卡片,不会拒绝请求。悬停不会移动焦点。悬停打开后,焦点转移到其他位置也会关闭卡片,包括前景模态框取得焦点时。切换到其他窗口或隐藏浏览器标签页会关闭卡片,不会将焦点拉回。前景模态框和输入法组合保留各自的键盘处理。 卡片通过 CSS 锚点优先显示在标签上方;空间不足时会调整位置以避开窗口边缘,并在卡片内部滚动。 + ## 模型体验 diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json index a1a6a8e7c2..fa8ff9cbc3 100644 --- a/packages/client/ui-approval/package.json +++ b/packages/client/ui-approval/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-approval", "description": "Approval composer takeover over the scoped Remote Event waterfall", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, @@ -61,6 +61,7 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", "react": "^18.2.0", + "react-dom": "^18.2.0", "@deepseek-ai/dsh-client-shortcuts": "workspace:*" }, "files": [ diff --git a/packages/client/ui-approval/src/client/ApprovalCard.tsx b/packages/client/ui-approval/src/client/ApprovalCard.tsx index 50d1b4cffb..7e1d86f7aa 100644 --- a/packages/client/ui-approval/src/client/ApprovalCard.tsx +++ b/packages/client/ui-approval/src/client/ApprovalCard.tsx @@ -4,6 +4,7 @@ import { Button, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { FactoryComponentPropsOf } from '@deepseek-ai/dsh-client-ui-slots' import type {} from './contract/slots.ts' import css from './ApprovalPanel.module.css' +import { ApprovalWarning } from './ApprovalWarning.tsx' /** * Render one decision with caller-owned actions and an optional persistent choice. @@ -15,7 +16,7 @@ export function ApprovalCard(props: FactoryComponentPropsOf<'conversation.approv } function ApprovalFlow(props: FactoryComponentPropsOf<'conversation.approval.card'>) { - const { requestKey, title, description, subject, allowLabel, canAnswer, onAllow, onReject, onAlways, t } = props + const { requestKey, title, description, subject, warning, allowLabel, canAnswer, onAllow, onReject, onAlways, t } = props const detail = props.callId === undefined ? null : props.renderSlot('conversation.approval.detail', { callId: props.callId }) const [answered, setAnswered] = useState(false) const waiting = useRef(false) @@ -68,7 +69,10 @@ function ApprovalFlow(props: FactoryComponentPropsOf<'conversation.approval.card {subject.icon !== undefined && } {subject.name} } -
{title}
+
+
{title}
+ {warning !== undefined && } +
{description !== undefined &&

{description}

} {detail !== null &&
{detail}
} diff --git a/packages/client/ui-approval/src/client/ApprovalPanel.module.css b/packages/client/ui-approval/src/client/ApprovalPanel.module.css index dd47ff045e..6661ee4555 100644 --- a/packages/client/ui-approval/src/client/ApprovalPanel.module.css +++ b/packages/client/ui-approval/src/client/ApprovalPanel.module.css @@ -45,6 +45,113 @@ line-height: 24px; } +.heading { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px 8px; +} + +.warningTrigger { + padding: 0; + flex-shrink: 0; +} + +.warningTag { gap: 4px; } + +/* The tag supplies the fill; keep its larger button hit target transparent. */ +.heading .warningTrigger:active:not(:disabled) { background: transparent; } + +@media (hover: hover) { + .heading .warningTrigger:hover:not(:disabled) { background: transparent; } + .warningTrigger:hover:not(:disabled) .warningTag { + background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 18%, transparent); + } +} + +.warningTrigger:active:not(:disabled) .warningTag { + background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 22%, transparent); +} + +.warningPopover { + --dsh-warning-window-top: max(12px, var(--dsh-frame-overlay-top, 12px)); + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); + border: 0; + box-shadow: var(--dsw-elevation-panel); + position: fixed; + position-area: none; + top: var(--dsh-warning-window-top); + bottom: max(12px, calc(anchor(top) + 8px)); + left: 12px; + right: max(12px, anchor(right)); + align-self: end; + justify-self: end; + margin: 0; + position-try-fallbacks: --dsh-warning-wide-above, --dsh-warning-below, --dsh-warning-wide-below, --dsh-warning-window, --dsh-warning-wide-window; + z-index: 1100; + box-sizing: border-box; + width: 420px; + max-width: calc(100vw - 24px); + height: fit-content; + max-height: calc(100dvh - 12px - var(--dsh-warning-window-top)); + overflow-y: auto; + padding: 24px; + color: var(--dsw-alias-label-secondary); + font-size: 13px; + line-height: 20px; +} + +@position-try --dsh-warning-wide-above { + right: 12px; + justify-self: safe end; +} + +@position-try --dsh-warning-below { + top: max(var(--dsh-warning-window-top), calc(anchor(bottom) + 8px)); + bottom: 12px; + align-self: start; +} + +@position-try --dsh-warning-wide-below { + top: max(var(--dsh-warning-window-top), calc(anchor(bottom) + 8px)); + bottom: 12px; + right: 12px; + align-self: start; + justify-self: safe end; +} + +@position-try --dsh-warning-window { + top: var(--dsh-warning-window-top); + bottom: 12px; + align-self: safe start; +} + +@position-try --dsh-warning-wide-window { + top: var(--dsh-warning-window-top); + bottom: 12px; + right: 12px; + align-self: safe start; + justify-self: safe end; +} + +.warningPopover p { margin: 16px 0 0; } + +.warningPopover p + p:last-child { + border-top: 0.5px solid var(--dsw-alias-border-l2); + padding-top: 16px; + color: var(--dsw-alias-label-primary); + font-weight: 500; +} + +.warningTitle { + margin: 0; + color: var(--dsw-alias-label-primary); + font-size: 15px; + line-height: 24px; + font-weight: 500; +} + .command { color: var(--dsw-alias-label-tertiary); font-family: var(--ds-font-family-code); diff --git a/packages/client/ui-approval/src/client/ApprovalWarning.tsx b/packages/client/ui-approval/src/client/ApprovalWarning.tsx new file mode 100644 index 0000000000..8bd2437395 --- /dev/null +++ b/packages/client/ui-approval/src/client/ApprovalWarning.tsx @@ -0,0 +1,97 @@ +/** Formatted hover notice that keeps the pending approval unanswered. */ +import { useEffect, useId, useRef, useState, type FocusEvent, type PointerEvent } from 'react' +import { createPortal } from 'react-dom' +import { + Button, IconWarningOutlineRegular, MenuSurface, Tag, + isBehindModal, observeComposition, useDismissOnOutsidePointer, usePointerGrace, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { ApprovalCardInput } from './contract/slots.ts' +import css from './ApprovalPanel.module.css' + +/** + * Present caller-owned risks on hover, keyboard focus, or touch activation. + * @param props Localized warning content. + * @returns The warning tag and its readable, nonmodal hover card. + */ +export function ApprovalWarning({ warning }: { + warning: NonNullable +}) { + const [open, setOpen] = useState(false) + const anchor = useRef(null) + const panel = useRef(null) + const id = useId() + const titleId = useId() + const anchorName = `--dsh-approval-warning-${id.replaceAll(':', '')}` + useDismissOnOutsidePointer(anchor, open, setOpen, panel) + const { arm: armClose, cancel: cancelClose } = usePointerGrace(() => { + if (anchor.current?.matches(':focus-visible') + || (panel.current?.contains(document.activeElement) && document.activeElement?.matches(':focus-visible'))) return + setOpen(false) + }) + const show = () => { + if (isBehindModal(anchor.current)) return + cancelClose() + setOpen(true) + } + const enter = (event: PointerEvent) => { + if (event.pointerType === 'touch' || !window.matchMedia('(any-hover: hover)').matches) return + show() + } + const leave = (event: PointerEvent) => { + if (event.pointerType !== 'touch') armClose() + } + const blur = (event: FocusEvent) => { + const next = event.relatedTarget + if (next instanceof Node && (anchor.current?.contains(next) || panel.current?.contains(next))) return + cancelClose() + setOpen(false) + } + useEffect(() => { + if (!open) return + const composition = observeComposition(document) + const close = () => { cancelClose(); setOpen(false) } + const focusOutside = (event: Event) => { + if (event.target instanceof Node && !anchor.current?.contains(event.target) && !panel.current?.contains(event.target)) close() + } + const visibilityChanged = () => { if (document.visibilityState === 'hidden') close() } + const dismiss = (event: KeyboardEvent) => { + if (composition.guards(event) || isBehindModal(anchor.current) || event.defaultPrevented + || event.ctrlKey || event.altKey || event.metaKey || event.shiftKey || event.key !== 'Escape') return + event.preventDefault() + event.stopPropagation() + if (event.repeat) return + cancelClose() + if (panel.current?.contains(document.activeElement)) anchor.current?.focus() + setOpen(false) + } + document.addEventListener('keydown', dismiss, true) + document.addEventListener('focusin', focusOutside) + document.addEventListener('visibilitychange', visibilityChanged) + window.addEventListener('blur', close) + return () => { + document.removeEventListener('keydown', dismiss, true) + document.removeEventListener('focusin', focusOutside) + document.removeEventListener('visibilitychange', visibilityChanged) + window.removeEventListener('blur', close) + composition.dispose() + } + }, [open, cancelClose]) + return <> + + {open && createPortal( + +

{warning.title}

+ {warning.detail.split('\n\n').map(paragraph =>

{paragraph}

)} +
, document.body, + )} + +} diff --git a/packages/client/ui-approval/src/client/contract/slots.ts b/packages/client/ui-approval/src/client/contract/slots.ts index 2df54a54c4..c03e2b3b77 100644 --- a/packages/client/ui-approval/src/client/contract/slots.ts +++ b/packages/client/ui-approval/src/client/contract/slots.ts @@ -211,6 +211,8 @@ export interface ApprovalCardInput { title: string /** Localized scope or consequence of the decision. */ description?: string + /** Caller-localized warning label and explanation, with paragraphs separated by blank lines (`\n\n`). */ + warning?: { label: string; title: string; detail: string } /** Subject metadata for human recognition; the caller admits icon URLs. Never an authorization identity. */ subject?: { name: string; icon?: string } /** Optional tool-owned detail rendered through the existing child slot. */ diff --git a/packages/client/ui-approval/tests/ui-approval.client.spec.tsx b/packages/client/ui-approval/tests/ui-approval.client.spec.tsx index 1885d6b047..2357857a72 100644 --- a/packages/client/ui-approval/tests/ui-approval.client.spec.tsx +++ b/packages/client/ui-approval/tests/ui-approval.client.spec.tsx @@ -6,6 +6,8 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { ApprovalCard } from '../src/client/ApprovalCard.tsx' +import { ApprovalWarning } from '../src/client/ApprovalWarning.tsx' +import { Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { FactoryComponentPropsOf } from '@deepseek-ai/dsh-client-ui-slots' import { ApprovalPanel } from '../src/client/ApprovalPanel.tsx' import type { ApprovalCardInput, ApprovalComposerProps, ApprovalInjected } from '../src/client/contract/slots.ts' @@ -123,7 +125,9 @@ const id = (value: string): SessionId => value as SessionId afterEach(() => { cleanup() + vi.useRealTimers() vi.restoreAllMocks() + vi.unstubAllGlobals() }) describe('PendingApproval', () => { @@ -541,3 +545,181 @@ it.each(['allow', 'reject', 'always', 'keyboard'] as const)('settles a fixed app await waitFor(() => { expect(actions[action]).toHaveBeenCalledOnce() }) expect(Object.values(actions).filter(callback => callback.mock.calls.length > 0)).toEqual([actions[action]]) }) + +it('opens and dismisses risk details without answering the approval', () => { + const onAllow = vi.fn(async () => {}), onReject = vi.fn(async () => {}) + const props = { + requestKey: 'risk-approval', title: 'Control this app?', + warning: { label: 'Risks', title: 'Computer use risks', detail: 'Background control.\n\nAccount restrictions.\n\nStop in DSH.' }, + canAnswer: () => true, onAllow, onReject, renderSlot: () => null, t: (key: string) => key, + } as FactoryComponentPropsOf<'conversation.approval.card'> + const view = render() + const trigger = screen.getByRole('button', { name: 'Risks' }) + vi.spyOn(trigger, 'matches').mockImplementation(selector => selector === ':focus-visible') + act(() => { trigger.focus() }) + const dialog = screen.getByRole('dialog', { name: 'Computer use risks' }) + expect([...dialog.querySelectorAll('p')].map(p => p.textContent).join('\n\n')).toBe(props.warning!.detail) + expect(document.activeElement).toBe(trigger) + expect(trigger.getAttribute('aria-expanded')).toBe('true') + act(() => { trigger.focus() }) + fireEvent.keyDown(trigger, { key: 'Enter' }) + expect(onAllow).not.toHaveBeenCalled() + fireEvent.keyDown(trigger, { key: 'Escape' }) + expect(screen.queryByRole('dialog', { name: 'Computer use risks' })).toBeNull() + expect(onReject).not.toHaveBeenCalled() + expect(document.activeElement).toBe(trigger) + fireEvent.click(trigger) + fireEvent.pointerDown(document.body) + expect(screen.queryByRole('dialog', { name: 'Computer use risks' })).toBeNull() + fireEvent.click(trigger) + view.rerender() + expect(screen.queryByRole('dialog', { name: 'Computer use risks' })).toBeNull() + expect(onAllow).not.toHaveBeenCalled() + expect(onReject).not.toHaveBeenCalled() +}) + +it('keeps risk details readable across the pointer gap and after a touch tap', () => { + vi.useFakeTimers() + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true, addEventListener() {}, removeEventListener() {} }))) + render() + const trigger = screen.getByRole('button', { name: 'Risks' }) + const pointer = (target: Element, type: string, pointerType = 'mouse') => { + const event = new Event(type, { bubbles: true }) + Object.defineProperty(event, 'pointerType', { value: pointerType }) + fireEvent(target, event) + } + pointer(trigger, 'pointerover') + const dialog = screen.getByRole('dialog', { name: 'Computer use risks' }) + expect(document.activeElement).toBe(document.body) + pointer(trigger, 'pointerout') + act(() => { vi.advanceTimersByTime(100) }) + pointer(dialog, 'pointerover') + act(() => { vi.advanceTimersByTime(200) }) + expect(screen.getByRole('dialog', { name: 'Computer use risks' })).toBe(dialog) + pointer(dialog, 'pointerout') + act(() => { vi.advanceTimersByTime(200) }) + expect(screen.queryByRole('dialog')).toBeNull() + pointer(trigger, 'pointerover', 'touch') + expect(screen.queryByRole('dialog')).toBeNull() + fireEvent.click(trigger) + pointer(trigger, 'pointerout', 'touch') + act(() => { vi.advanceTimersByTime(200) }) + expect(screen.getByRole('dialog', { name: 'Computer use risks' })).toBeTruthy() +}) + +it('leaves Escape to composition and dismisses risk details when a modal takes focus', () => { + const onClose = vi.fn() + const warning = + const view = render(warning) + fireEvent.click(screen.getByRole('button', { name: 'Risks' })) + fireEvent.compositionStart(document) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.getByRole('dialog', { name: 'Computer use risks' })).toBeTruthy() + fireEvent.compositionEnd(document) + fireEvent.keyUp(document, { key: 'Escape' }) + fireEvent.keyDown(document, { key: 'Escape', shiftKey: true }) + expect(screen.getByRole('dialog', { name: 'Computer use risks' })).toBeTruthy() + view.rerender(<>{warning}) + const modal = screen.getByRole('dialog', { name: 'Foreground' }) + expect(screen.queryByRole('dialog', { name: 'Computer use risks' })).toBeNull() + act(() => { modal.focus() }) + fireEvent.keyDown(modal, { key: 'Escape' }) + expect(onClose).toHaveBeenCalledOnce() + expect(document.activeElement).toBe(modal) + expect(screen.queryByRole('dialog', { name: 'Computer use risks' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Risks' })) + expect(screen.queryByRole('dialog', { name: 'Computer use risks' })).toBeNull() + expect(document.activeElement).toBe(modal) +}) + +it('keeps risk details open for keyboard focus and closes when focus leaves', () => { + vi.useFakeTimers() + render(<> + + + ) + const trigger = screen.getByRole('button', { name: 'Risks' }) + const focusVisible = vi.spyOn(trigger, 'matches').mockReturnValue(false) + act(() => { trigger.focus() }) + expect(screen.queryByRole('dialog')).toBeNull() + fireEvent.click(trigger) + const dialog = screen.getByRole('dialog', { name: 'Computer use risks' }) + vi.spyOn(dialog, 'matches').mockImplementation(selector => selector === ':focus-visible') + act(() => { dialog.focus() }) + fireEvent.pointerLeave(dialog) + act(() => { vi.advanceTimersByTime(200) }) + expect(screen.getByRole('dialog')).toBe(dialog) + + focusVisible.mockImplementation(selector => selector === ':focus-visible') + act(() => { trigger.focus() }) + fireEvent.pointerLeave(trigger) + act(() => { vi.advanceTimersByTime(200) }) + expect(screen.getByRole('dialog')).toBe(dialog) + act(() => { screen.getByRole('button', { name: 'Outside' }).focus() }) + expect(screen.queryByRole('dialog')).toBeNull() + + fireEvent.click(trigger) + act(() => { screen.getByRole('dialog').focus() }) + act(() => { screen.getByRole('dialog').blur() }) + expect(screen.queryByRole('dialog')).toBeNull() +}) + +it('dismisses pointer-focused risk details when the pointer leaves', () => { + vi.useFakeTimers() + render() + fireEvent.click(screen.getByRole('button', { name: 'Risks' })) + const dialog = screen.getByRole('dialog', { name: 'Computer use risks' }) + vi.spyOn(dialog, 'matches').mockReturnValue(false) + act(() => { dialog.focus() }) + fireEvent.pointerLeave(dialog) + act(() => { vi.advanceTimersByTime(200) }) + expect(screen.queryByRole('dialog')).toBeNull() +}) + +it('ignores repeated Escape and restores focus when dismissing focused risk details', () => { + render() + const trigger = screen.getByRole('button', { name: 'Risks' }) + fireEvent.click(trigger) + const dialog = screen.getByRole('dialog', { name: 'Computer use risks' }) + act(() => { dialog.focus() }) + fireEvent.keyDown(dialog, { key: 'Escape', repeat: true }) + expect(screen.getByRole('dialog')).toBe(dialog) + expect(document.activeElement).toBe(dialog) + fireEvent.keyDown(dialog, { key: 'Escape' }) + expect(screen.queryByRole('dialog')).toBeNull() + expect(document.activeElement).toBe(trigger) +}) + +it('dismisses hover-open risks when focus moves elsewhere without taking focus on open', () => { + render(<> + + + + ) + const before = screen.getByRole('button', { name: 'Before' }) + const after = screen.getByRole('button', { name: 'After' }) + act(() => { before.focus() }) + fireEvent.click(screen.getByRole('button', { name: 'Risks' })) + expect(document.activeElement).toBe(before) + expect(screen.getByRole('dialog', { name: 'Computer use risks' })).toBeTruthy() + act(() => { after.focus() }) + expect(screen.queryByRole('dialog')).toBeNull() + expect(document.activeElement).toBe(after) +}) + +it.each(['window blur', 'hidden tab'] as const)('dismisses risk details on %s without restoring focus', (reason) => { + render() + const trigger = screen.getByRole('button', { name: 'Risks' }) + fireEvent.click(trigger) + fireEvent(document, new Event('visibilitychange')) + expect(screen.getByRole('dialog')).toBeTruthy() + if (reason === 'window blur') fireEvent(window, new Event('blur')) + else { + vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden') + fireEvent(document, new Event('visibilitychange')) + } + expect(screen.queryByRole('dialog')).toBeNull() + expect(document.activeElement).toBe(document.body) + fireEvent(window, new Event('focus')) + expect(trigger.getAttribute('aria-expanded')).toBe('false') +}) diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 2f5d2d5d93..50a75b6f8c 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-attachment", "description": "Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json index 468dd1613d..47c1960beb 100644 --- a/packages/client/ui-brand-official/package.json +++ b/packages/client/ui-brand-official/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-brand-official", "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar slots", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 54168bf257..bbed78bfaf 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -18,14 +18,14 @@ en: 9386791a9e5efa00 zh: 7cb9f3bbc42319d6 /deepseek-ai-dsh-client-ui-chat/hidden-chat-rows: - en: 91b7c7a5263eda35 - zh: 16d39b45edee038f + en: 8ca277dc7fed699f + zh: 167478dba50f3904 /deepseek-ai-dsh-client-ui-chat/command-and-failure-rows: en: c5b23d0ed29a0425 zh: 689bab2c8253294a /deepseek-ai-dsh-client-ui-chat/turn-token-usage: - en: cfdfe3d99d3f4e09 - zh: 9330bad3d3f7a689 + en: d363057b5c0b2571 + zh: 0f6fe864d9673576 /deepseek-ai-dsh-client-ui-chat/completed-turn-footer: en: 382821ca3ffa60dd zh: 9840ffa07c13082a diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index b23b9dc3e1..a430e12e74 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -54,7 +54,7 @@ Settings → General → Open chat links in selects the destination for ordinary ## Hidden Chat rows -Chat omits system-prompt, ordinary Context injection, and `permission` command rows in every work-details mode. It also hides pending and successful `/team on` and `/team off` receipts, identified by their recorded name and trimmed arguments; failures and commands with missing or unrecognized arguments remain visible. Context containing tool additions or removals remains visible. The filter changes neither recorded Session events nor Trajectory inspection and excludes hidden receipts from flow spacing and process summaries. Non-human Turn triggers remain independent, bordered notices with a contrasting neutral background; other command rows remain in Chat. +Chat omits system-prompt and `permission` command rows in every work-details mode. It also hides pending and successful `/team on` and `/team off` receipts, identified by their recorded name and trimmed arguments; failures and commands with missing or unrecognized arguments remain visible. Context injections remain visible as collapsed rows that use the tool-call disclosure row; within a Turn they join its process group like tool calls. The filter changes neither recorded Session events nor Trajectory inspection and excludes hidden receipts from flow spacing and process summaries. Non-human Turn triggers remain independent, bordered notices with a contrasting neutral background; other command rows remain in Chat. When an Assistant attempt retires without a visible message, Chat hides its already-published Node instead of removing its key. A retry in the same Step reuses that key when visible content returns. This also applies when the loaded window lacks the Step start. @@ -78,7 +78,7 @@ A completed Turn shows an expandable usage row only when the loaded window inclu Settings → General → Performance & usage appears after Send behavior while busy and before Session Log upload, and stores `ui-chat.performanceUsage` as `detailed` (default) or `compact`. Compact shows only available output speed and cache-hit percentage beneath the composer, without interactive statistic dialogs or per-Turn usage. Detailed exposes session statistics and per-Turn token usage. Neither mode shows elapsed time in the completed-turn footer. The preference changes presentation only; accounting and Session events remain intact. -The composer statistics are two `conversation.composer.dock` list entries: `activity` (order 0, turn and step counts and output speed, with LLM time, tool time, and TTFT) and `usage` (order 1, token total and cache hit). The controls follow the shared [statistics-control radius](../../../docs/ui-radius.md#radius-scale). Another plugin replaces one control by registering the same id and the same `order`; the dock orders rows by `order`, while a lower `priority` picks the replacement: a dynamic plugin receives one automatically, and a statically composed plugin passes it explicitly. A new id adds a control. Each control carries its id in `data-composer-stat`. An open statistics dialog closes on Escape or on a pointerdown or click outside it, so activating another control by pointer or keyboard swaps dialogs instead of stacking them. When an ellipsis cuts off a reading, a tooltip shows the full reading. A dialog control shows it on hover or keyboard focus, but not while its dialog is open; a reading without a dialog shows it on hover, click, or tap. The session statistics dialog heads its rows with the turn and step counts. +The composer statistics are two `conversation.composer.dock` list entries: `activity` (order 0, turn and step counts and output speed, with LLM time, tool time, and TTFT) and `usage` (order 1, token total and cache hit). The controls follow the shared [statistics-control radius](../../../docs/ui-radius.md#radius-scale). Another plugin replaces one control by registering the same id and the same `order`; the dock orders rows by `order`, while a lower `priority` picks the replacement: a dynamic plugin receives one automatically, and a statically composed plugin passes it explicitly. A new id adds a control. Each control carries its id in `data-composer-stat`. An open statistics dialog closes on Escape or on a pointerdown or click outside it, so activating another control by pointer or keyboard swaps dialogs instead of stacking them. When an ellipsis cuts off a reading, a tooltip shows the full reading. A dialog control shows it on hover or keyboard focus, but not while its dialog is open; a reading without a dialog shows it on hover, click, or tap. The session statistics dialog heads its rows with the turn and step counts. Statistics dialogs use CSS anchors and layout-viewport size limits; browser zoom and panning move them with the page, so they may extend beyond the visual viewport. Taller dialog content scrolls internally. Explicit selections update every consumer immediately; accepted Host settings reconcile the live value, so a refused or pending write keeps the selected mode until the Host answers. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index e5f8add05b..ecf93eed36 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -54,7 +54,7 @@ Session 作用域的 `conversation.chat.user.content` Factory 只渲染用户内 ## Chat 隐藏的行 -Chat 在所有工作步骤展示模式下都不显示系统提示词行、普通上下文注入和 `permission` 命令行。记录中的命令名和去除首尾空白后的参数确定为 `/team on` 或 `/team off` 时,也隐藏执行中与成功回执;失败以及参数缺失或无法识别的命令仍然可见。包含工具添加或移除记录的上下文仍然可见。该过滤不改变已记录的 Session 事件或 Trajectory 查看能力,隐藏回执也不计入行间距和过程摘要。非人工轮次触发仍作为独立通知显示,其他命令行仍保留在 Chat 中。 +Chat 在所有工作步骤展示模式下都不显示系统提示词行和 `permission` 命令行。记录中的命令名和去除首尾空白后的参数确定为 `/team on` 或 `/team off` 时,也隐藏执行中与成功回执;失败以及参数缺失或无法识别的命令仍然可见。上下文注入以默认收起、与工具调用相同的展开行显示;在 Turn 内与工具调用一样加入过程组。该过滤不改变已记录的 Session 事件或 Trajectory 查看能力,隐藏回执也不计入行间距和过程摘要。非人工轮次触发仍作为独立通知显示,其他命令行仍保留在 Chat 中。 Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node,不移除其 key。同一 Step 的重试再次产生可见内容时,复用该 key。已加载窗口缺少 Step 起点时也遵循此规则。 @@ -78,7 +78,7 @@ Chat 在 `MarkdownDelegateProvider.getTriggerProps` 接入处适配 Markdown 目 设置 → 通用设置 → 性能与用量位于繁忙时的发送行为之后、Session Log 上传之前,将 `ui-chat.performanceUsage` 保存为 `detailed`(默认)或 `compact`。简洁模式仅在输入框下方显示可用的输出速度和缓存命中率,不显示统计交互卡片或每轮用量。详细模式提供会话统计和每轮 token 用量。两种模式的已完成轮次页脚均不显示耗时。该偏好仅影响展示,记账和 Session 事件保持完整。 -输入框统计信息由两个 `conversation.composer.dock` 列表条目组成:`activity`(order 0,轮次数、步骤数与输出速度,以及 LLM 耗时、工具耗时与 TTFT)和 `usage`(order 1,token 总量与缓存命中)。这些控件遵循公共的[统计控件圆角规则](../../../docs/ui-radius.zh.md#radius-scale)。其他插件以相同的 id 和相同的 `order` 注册即可替换某个控件;dock 按 `order` 排列各行,更低的 `priority` 决定由替换者渲染:动态插件会自动获得,静态组合的插件需显式传入。以新 id 注册则新增控件。每个控件在 `data-composer-stat` 中携带其 id。按下 Escape,或在已打开的对话框之外发生 pointerdown 或点击时,该对话框会关闭,因此用指针或键盘激活另一个控件会切换对话框,而不会叠加两个。读数被省略号截断时,提示气泡会显示完整读数。带对话框的控件在悬停或键盘聚焦时显示,对话框打开期间不显示;没有对话框的读数在悬停、点击或轻触时显示。会话统计对话框的标题行显示轮次数与步骤数。 +输入框统计信息由两个 `conversation.composer.dock` 列表条目组成:`activity`(order 0,轮次数、步骤数与输出速度,以及 LLM 耗时、工具耗时与 TTFT)和 `usage`(order 1,token 总量与缓存命中)。这些控件遵循公共的[统计控件圆角规则](../../../docs/ui-radius.zh.md#radius-scale)。其他插件以相同的 id 和相同的 `order` 注册即可替换某个控件;dock 按 `order` 排列各行,更低的 `priority` 决定由替换者渲染:动态插件会自动获得,静态组合的插件需显式传入。以新 id 注册则新增控件。每个控件在 `data-composer-stat` 中携带其 id。按下 Escape,或在已打开的对话框之外发生 pointerdown 或点击时,该对话框会关闭,因此用指针或键盘激活另一个控件会切换对话框,而不会叠加两个。读数被省略号截断时,提示气泡会显示完整读数。带对话框的控件在悬停或键盘聚焦时显示,对话框打开期间不显示;没有对话框的读数在悬停、点击或轻触时显示。会话统计对话框的标题行显示轮次数与步骤数。统计对话框通过 CSS 锚点定位,并按布局视口限制尺寸;浏览器缩放和平移时随页面移动,因此可以超出视觉视口。较高的对话框内容可在内部滚动。 明确选择会立即更新所有使用方;Host 接受的设置会校正实时值,因此写入被拒绝或尚未应答时保持所选模式,直到 Host 应答。 diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index c8a468b48c..dd85d72096 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-chat", "description": "Chat Conversation target, node definitions, renderers, and details surface", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx index f219a5ae56..49d8f12300 100644 --- a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx @@ -2,12 +2,12 @@ import { memo, useCallback, useEffect, useId, useRef, useState, type ComponentProps, type ReactNode } from 'react' import { IconAgentPresetOutlineRegular, IconApiOutlineRegular, IconBrowseOutlineRegular, IconChevronDownOutlineRegular, - IconChevronUpOutlineRegular, IconCodeOutlineRegular, IconEditOutlineRegular, IconGlobeOutlineRegular, - IconPlanOutlineRegular, IconQuestionOutlineRegular, IconSearchOutlineRegular, IconSparkleRegular, + IconChevronUpOutlineRegular, IconCodeOutlineRegular, IconContextInjectionOutlineRegular, IconEditOutlineRegular, + IconGlobeOutlineRegular, IconPlanOutlineRegular, IconQuestionOutlineRegular, IconSearchOutlineRegular, IconSparkleRegular, IconThinkOutlineRegular, TextShimmer, } from '@deepseek-ai/dsh-client-ui-primitives' import type { GroupKey, NodeReference } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { ProcessActivity } from '../contract/process-groups.ts' +import type { ProcessActivity, RunningProcessActivity } from '../contract/process-groups.ts' import type { ChatStoreState } from '../contract/store.ts' import type { ChatFlowSlotProps } from '../contract/slots.ts' import { turnProcessOpen } from '../stores.ts' @@ -30,7 +30,7 @@ const PROCESS_TITLE_MINIMUM_MS = 150 type ProcessTitleActivity = ProcessActivity | 'thinking' interface LiveProcessTitle { - readonly activity: ProcessTitleActivity + readonly activity: RunningProcessActivity | 'thinking' readonly detail: string readonly preparing: boolean } @@ -50,6 +50,7 @@ const PROCESS_ICONS: Record = { plan: , questions: , tools: , + context: , } function sameLiveProcessTitle(left: LiveProcessTitle, right: LiveProcessTitle): boolean { diff --git a/packages/client/ui-chat/src/client/chat/StatsPills.tsx b/packages/client/ui-chat/src/client/chat/StatsPills.tsx index 7108c44719..dd5060ff5c 100644 --- a/packages/client/ui-chat/src/client/chat/StatsPills.tsx +++ b/packages/client/ui-chat/src/client/chat/StatsPills.tsx @@ -17,7 +17,7 @@ import type { ChatSnapshot } from '../contract/snapshot.ts' import { formatTokensPerSecond } from './message-chrome.ts' import { assistantStepReading } from '../contract/turn-metrics.ts' import { formatCacheHitPercent, formatExactTokens, formatTokens } from './token-format.ts' -import { MEASURE_STYLE, useStatDialog } from './stat-dialog.ts' +import { useStatDialog } from './stat-dialog.ts' import css from './StatsPills.module.css' import dialogCss from './stat-dialog.module.css' @@ -203,9 +203,9 @@ function DialogPill({ stat, icon, label, text, title, titleValue, children }: Pi titleValue?: string children: ReactNode }) { - const { open, setOpen, rootRef, panelRef, pos } = useStatDialog() + const { open, setOpen, rootRef, panelRef, anchorName } = useStatDialog() return ( - + {/* The open dialog already shows every figure; a tooltip would cover it. */} + ), }, {}, [{ id: 'discover', label: 'Discover' }]) const installed = screen.getByRole('tab', { name: en.installedTab }) expect(document.getElementById(installed.getAttribute('aria-controls') as string)?.getAttribute('role')).toBe('tabpanel') set({ status: 'ready', packages: [] }) expect(screen.getByRole('tabpanel', { name: en.installedTab }).textContent).toBe(en.empty) - set({ packages: [pkg({ name: worktree, official: true, optional: true, installed: false, enabled: false })] }) + set({ packages: [pkg({ name: inspector, official: true, optional: true, installed: false, enabled: false })] }) act(() => { navigation.actions.setView({ kind: 'more' }) }) expect(screen.queryByRole('tablist')).toBeNull() expect(screen.queryByRole('tabpanel')).toBeNull() act(() => { navigation.actions.setView({ kind: 'list' }) }) fireEvent.click(screen.getByRole('tab', { name: 'Discover' })) fireEvent.click(screen.getByRole('button', { name: 'Discover open' })) - expect(document.querySelector(`[data-plugin-detail="${worktree}"]`)).not.toBeNull() + expect(document.querySelector(`[data-plugin-detail="${inspector}"]`)).not.toBeNull() // An official plugin of the More list opened from Discover returns to Discover, not to the More list. fireEvent.click(screen.getByRole('button', { name: en.backToList })) expect(navigation.getSnapshot()).toEqual({ tab: 'discover', view: { kind: 'list' } }) fireEvent.click(screen.getByRole('tab', { name: en.installedTab })) act(() => { navigation.actions.setView({ kind: 'more' }) }) - fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', worktree) })) + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', inspector) })) fireEvent.click(screen.getByRole('button', { name: en.backToPackage.replace('{name}', en.moreTitle) })) expect(screen.getByRole('heading', { name: en.moreTitle, level: 1 })).toBeTruthy() }) @@ -1237,7 +1237,7 @@ describe('PluginManagerPage', () => { .toEqual(['more']) const more = document.querySelector('[data-plugin-group="more"]') as HTMLElement expect(within(more).getAllByRole('listitem').map(card => card.getAttribute('data-plugin-package'))) - .toEqual([team, '@acme/new-official', '@deepseek-ai/dsh-experimental-tool-worktree', '@deepseek-ai/dsh-experimental-badge-skill-bundle', '@deepseek-ai/dsh-subagent-codex', '@deepseek-ai/dsh-subagent-claude-code']) + .toEqual([team, '@acme/new-official', '@deepseek-ai/dsh-experimental-badge-skill-bundle', '@deepseek-ai/dsh-subagent-codex', '@deepseek-ai/dsh-subagent-claude-code']) expect(document.querySelector('[data-plugin-package="dsh-better-sidebar"]')).toBeNull() fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', team) })) fireEvent.click(screen.getByRole('button', { name: en.backToPackage.replace('{name}', en.moreTitle) })) @@ -1255,6 +1255,20 @@ describe('PluginManagerPage', () => { expect(screen.getByRole('heading', { name: en.basicTitle })).toBeTruthy() }) + it.each([false, true])('shows one forge card and hides the standalone worktree card when enabled is %s', (enabled) => { + const forge = '@deepseek-ai/dsh-experimental-forge-bundle' + const worktree = '@deepseek-ai/dsh-experimental-tool-worktree' + renderTab({ packages: [ + pkg({ name: forge, official: true, enabled }), + pkg({ name: worktree, official: true, enabled }), + ] }) + expect(document.querySelectorAll(`[data-plugin-package="${forge}"]`)).toHaveLength(1) + expect(document.querySelector(`[data-plugin-package="${worktree}"]`)).toBeNull() + fireEvent.click(screen.getByRole('button', { name: en.moreAction })) + expect(document.querySelectorAll(`[data-plugin-package="${forge}"]`)).toHaveLength(1) + expect(document.querySelector(`[data-plugin-package="${worktree}"]`)).toBeNull() + }) + it.each([ '@deepseek-ai/dsh-experimental-session-search', '@deepseek-ai/dsh-experimental-ralph-bundle', @@ -1360,7 +1374,7 @@ describe('PluginManagerPage', () => { }) it.each([ - '@deepseek-ai/dsh-experimental-tool-worktree', + '@deepseek-ai/dsh-experimental-inspector-profile', '@deepseek-ai/dsh-personalized-recommendations', ])('pins enabled %s onto the main list and removes it when disabled', (first) => { const team = '@deepseek-ai/dsh-experimental-voice-input-bundle' diff --git a/packages/client/ui-plugin-market/package.json b/packages/client/ui-plugin-market/package.json index 7c926938d4..0430e20ef4 100644 --- a/packages/client/ui-plugin-market/package.json +++ b/packages/client/ui-plugin-market/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-plugin-market", "description": "Discover tab of the dsh web Plugins page: browse configured plugin market indexes and install their bundles", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 86cbc44ebe..da586b0139 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -18,23 +18,23 @@ en: ead6be55c7c9d7cf zh: 125ad36db7328551 /deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog: - en: 5063855009f5f6a2 - zh: ad285427837dc287 + en: 77383e8771784a60 + zh: 25c4c59b1cbc7f8b /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: - en: 7d45749328bcdbcc - zh: 05d911f95b5b79d8 + en: 2fc65b908307b52e + zh: e5c1181ecd2da6e5 /deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output: en: e2649937f077d4e0 zh: 6ad604cb5adfc16b /deepseek-ai-dsh-client-ui-primitives/use-this-package/localizing-copy: - en: 81e58c7b342baf3c - zh: 6cd47b16124b215c + en: 3fd7072b662eed0a + zh: 88c6c299c0a6f8cd /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation: - en: 2f8c4a72aab9dcba - zh: 6d46872e243d6b70 + en: 88448dce667337fe + zh: 5d9b6035191a0b08 /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/source-map: - en: fd510d34404eb090 - zh: d05b0890db929ae6 + en: a39e67d79e171221 + zh: 593e260c073336f9 /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/input-modality: en: cb61e1a37d6c27e5 zh: 1f3fa79dd5ab2e45 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index a356295625..c7ffd2e5c8 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -58,7 +58,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `StateDot` | Solid green `done`, amber `warning`, red `error`, and neutral-grey `idle` marks in a 10px slot, plus a tertiary-grey 14px rotating `ongoing` loader whose animations pin to document time zero on mount and on every animation restart, such as a list reorder, so every visible loader rotates in phase. `aria-hidden`, so the render site owns the name. `appearance="step"` shows a filled check for completion and a hollow pending circle. | | `ConnectionIndicator` | Inline connection-recovery control across outage, retry, and recovered states. | | `DisclosureRow` | 24px compact disclosure that lays title and content side by side. The header supplies tertiary at rest and secondary on hover; text and icons inherit it regardless of expandability or open state unless the caller supplies a feature-specific override. Semantic status colors remain explicit. Collapsed rows preview a down chevron on hover, and expanded rows keep an up chevron visible. `headerAccessory` places an independent action outside the disclosure target and shimmer; with an accessory, the title row shrinks to its content width. Memoized with shallow prop comparison; keep callbacks and React-node props stable when their content is unchanged. | -| `AnimatedRows` | Keyed list movement with 200 ms glides and 100 ms entry/exit fades. Starts after local pointer or keyboard input, honors reduced motion, and settles loading or view replacement. Mark rows with `data-row-key`, supply matching `rowKeys`, and position the parent for the inert exit overlay; `role` and `label` belong to the caller. | +| `AnimatedRows` | Keyed list movement with 200 ms glides and 100 ms entry/exit fades. Starts after local pointer or keyboard input, honors reduced motion, and settles loading or view replacement. Only rows intersecting both the list and the browser window animate; all rows remain mounted. Mark rows with `data-row-key`, supply matching `rowKeys`, and position the parent for the inert exit overlay; `role` and `label` belong to the caller. | | `useRowDragPreview` | Native drag preview using an inert offscreen row copy. The optional `inheritedProperties` list names the CSS custom properties whose computed values the offscreen wrapper copies; omitting it copies none. Keep the list reference stable, because replacing it clears an active preview. Unlisted custom properties follow normal inheritance at the preview's location. Supply a class for the surface background and, for a child drag handle, pass the row to `start`; `clear` and unmount remove the copy. | | `TextShimmer` | One left-to-right highlight across a row’s title, separators, summary, and suffix, with a 300ms initial delay, a one-second sweep and a 500ms rest. The mask spans the content width, capped by the visible row and tilts 15° from vertical, with a flat peak and soft edges. The moving decoration stays clipped to the row without enlarging its scrollable area. Base text inherits the caller’s color, including hover changes; the theme supplies the translucent highlight. Place icons and chevrons outside `TextShimmer` so only text and separators receive the highlight. A string child uses the same retained text interface; nested `TextShimmer` instances share the outer animation. Composite children render twice while active and must have no effects or element ids. Mark separators with `data-shimmer-decoration` so their fill receives the highlight. `DisclosureRow` forwards `contentClassName` for the text area and `contentLayoutClassName` for its inner layout. The inert decorative copy is excluded from interaction, selection, and accessibility; text updates retain the animation, and reduced-motion mode keeps the static base. | | `Modal` | Centered dialog over a page mask. A nested dialog can intercept keys with `onKeyDownCapture` before document Escape handlers. The tint and dialog fade in while backdrop blur stays fully applied, honoring reduced motion. Set `backdropBlur={false}` when the caller already blurs the source page. `tone="warning"` places an amber warning icon in the title and removes the body's extra top margin; other dialogs retain their spacing. | @@ -67,7 +67,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `HoverCard`, `HoverCardGroup` | Readable, optionally copyable hover preview; a physical list can opt cards into shared first-display dwell and exclusive display. | | `DraggableImage` | Original-image gestures with explicit transfer and feedback callbacks. | | `ImageLightbox` | Shared image modal with focus restoration and Escape dismissal. | -| `useAnchoredPosition` | Fixed-panel coordinates that follow the anchor through browser zoom, scrolling, and resizing. | +| `usePointerGrace` | Cancelable dismissal delay that lets the pointer cross the gap between a trigger and its card; pending timers are cleared on unmount. | | `Toast` | Transient top-center banner held for the owner's `holdMs`. | | `SettingsForm`, `SettingsValueField`, `SettingsSecretField` | The frame and the controls of a plugin's settings page: the frame takes its copy as `labels`, saves only on its button, and discards on unmount; a value field shows staged text with the overridden badge and reset; a secret field starts blank, requests no saved-password autofill, and reports only whether a value is configured. | | `SettingsFormModel`, `settingsNumberField`, `settingsTextField` | The staged-edit model behind such a page over a settings scope: drafts are staged and written on save, a field is overridden by its presence in the user layer, and a save that did not land keeps its drafts. | @@ -97,7 +97,7 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product icon renders an `` box with one `` of its `dsw-icon--` symbol, for example `dsw-icon-search-outline-regular`, and names that id in `data-icon`. `ICON_SYMBOLS` holds every symbol's default SVG document; ui-theme renders the document icon sprite from it, and an icon whose symbol is missing renders nothing. Artwork reached through `` inherits `currentColor` and CSS variables from the use site. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` measures fixed, body-portaled panels synchronously before paint and follows window scrolling, Visual Viewport changes, anchor movement, and element resizing. It normalizes WebKit client coordinates, preserves viewport margins and desktop frame clearance, and exposes visible size limits through `--dsh-anchored-max-width` and `--dsh-anchored-max-height`; panel CSS consumes those variables. `frameClearance: false` keeps only the viewport margin for pointer-positioned menus. `layoutKey` requests measurement after a content change, and `minWidthFromAnchor` follows the anchor width within the visible viewport. A missing host-provided anchor rect hides the panel until it becomes available. `useAnchoredMaxHeight` keeps bottom-anchored overlays within the visible viewport; an oversized panel keeps the near margin instead of sliding off that edge. A portalled `Menu` caps its card height against the dynamic viewport so its pinned footer stays on screen; child cards escape the parent's scroll clip through their own portals. Anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers; while an anchor is inside that top strip, its overlay, including a `HoverCard`, is never pushed lower than the anchor's bottom edge plus the overlay's gap. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance except below an anchor inside the top strip, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists use `useAnchoredPosition` to follow their anchor during dragging, CSS transforms, and browser zoom, including Safari visual-viewport panning; tracking stops when closed. They stay hidden until the first position resolves, and `autoFocus` waits until the list is visible. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` or `Medium`. Line artwork uses stroke widths of 1 and 1.3 SVG user units respectively; the `size` prop controls rendered dimensions. Strokes scale with the viewport: a 1-unit stroke in a 16-unit view box renders at 0.875 CSS px at 14px and 0.75 CSS px at 12px, before further transforms. The renderer does not apply non-scaling strokes; filled contours scale as geometry and do not respond to stroke-width changes. Shared menus render icons at 14px, or 12px in compact lists, through `Menu.module.css`; these are rendered sizes, not fixed stroke widths. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product icon renders an `` box with one `` of its `dsw-icon--` symbol, for example `dsw-icon-search-outline-regular`, and names that id in `data-icon`. `ICON_SYMBOLS` holds every symbol's default SVG document; ui-theme renders the document icon sprite from it, and an icon whose symbol is missing renders nothing. Artwork reached through `` inherits `currentColor` and CSS variables from the use site. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredMaxHeight` keeps bottom-anchored overlays within the visible viewport; an oversized panel keeps the near margin instead of sliding off that edge. A portalled `Menu` caps its card height against the layout viewport, with its pinned footer outside the inner scrolling region; child cards use their own body portal, so a scrolling parent never clips them. `HoverCard` adds 20px to the frame's published top clearance, or keeps 20px in native fullscreen, while preserving its own minimum margin in ordinary browsers; while an anchor is inside that top strip, the card is never pushed lower than the anchor's bottom edge plus its gap. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance except below an anchor inside the top strip, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists use CSS Anchor Positioning to follow their trigger and constrain their dimensions to the layout viewport. Every placement candidate reserves 12px layout-viewport margins and uses the frame overlay inset as its minimum top edge; pointer-positioned contextual menus keep 12px at the top. When the preferred placement cannot fit, CSS tries a horizontal adjustment or the opposite side before using a window-bounded fallback. Browser zoom and panning move the menu with the page; it may extend beyond the visual viewport, including while the keyboard is open. Internal scrolling, dismissal, and focus restoration remain independent. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. `Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. It stays hidden until its first fit, prefers the requested side, then tries the opposite and perpendicular sides, sliding along the anchor without covering it when a side fits. Horizontal placements keep a 10px gap; vertical placements use `gap` (8px by default). Width and height stay within a 12px viewport margin; text wraps, and content taller than the viewport scrolls when pinned. If no side fits, a stable viewport-bounded fallback can overlap the anchor. Viewport resize refreshes the anchor coordinates; label-size changes reuse them. Fitting does not synchronously measure the bubble or trigger a React render. Touch presses dismiss unpinned hints and do not trigger hover hints, including compatibility mouse events. Mouse entry opens hints only while `(any-hover: hover)` reports an available hover-capable input, excluding iOS mouse entry caused by a tapped icon changing. Keyboard focus and explicit click-to-open explanations remain available on touch-only devices. Opt-in `openOnClick` lets an informational button pin the bubble by click or tap and associates it with the anchor description. Another click or tap, Escape, Tab, or an outside pointerdown closes it. Ordinary action buttons execute their action directly without a touch tooltip. Opt-in `onlyWhenClipped` shows the bubble only when the anchor or an HTML element inside it overflows horizontally when the bubble is about to show, so a label that repeats ellipsis-cut text appears only while it adds information. @@ -133,7 +133,7 @@ Explicit `./`, `../`, slash/backslash-rooted paths (including UNC), and Windows The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionIndicator`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, `WebBlock`, and `ZoomControls` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface. -`Menu.contextual` positions from the pointer without the frame inset and closes on a right-click inside the popup. Contextual popups with no rendered `role="menuitem"` rows stay hidden, including when legacy Slots return no content. Structured `items` reserve an icon column per divider group; `footer` and each submenu calculate their own groups. A contextual `footer` with a leading separator uses that line instead of adding a second border. Opaque `children` retain their own layout; a legacy Slot after data rows keeps its declared leading separator. `exiting` retains an inert popup for its fade; `returnFocus` restores the original control after asynchronous preparation. `MenuSurface.phase` fades the material, direct content, and macOS backing separately so the material keeps blurring the page. `MENU_TRANSITION_MS` supplies both CSS timing and the popup lifecycle timers. Ordinary dropdowns retain their existing timing. +`Menu.anchorRef` names a trigger rendered outside the component, including one attached later in the same commit or replaced between openings. Keyboard dismissal of an `autoFocus` menu restores the previously focused control while it remains connected. `anchorPoint` accepts client-viewport coordinates, matching `event.clientX`/`clientY` and `getBoundingClientRect()`. On Safari, opening the menu or changing the point converts these coordinates to the layout viewport once. CSS then handles zoom and panning without continuous viewport listeners or rectangle measurements. `Menu.contextual` positions from that point without the frame inset and closes on a right-click inside the popup. Contextual popups with no rendered `role="menuitem"` rows stay hidden, including when legacy Slots return no content. Structured `items` reserve an icon column per divider group; `footer` and each submenu calculate their own groups. A contextual `footer` with a leading separator uses that line instead of adding a second border. Opaque `children` retain their own layout; a legacy Slot after data rows keeps its declared leading separator. `exiting` retains an inert popup for its fade; `returnFocus` restores the original control after asynchronous preparation. `MenuSurface.phase` fades the material, direct content, and macOS backing separately so the material keeps blurring the page. `MENU_TRANSITION_MS` supplies both CSS timing and the popup lifecycle timers. Ordinary dropdowns open without the contextual preparation delay. ----- @@ -144,11 +144,11 @@ The atoms cannot read the application locale, so every piece of user-facing copy Portalled menus close when their anchor becomes inert or hidden; contextual menus use the original control supplied by `returnFocus`. `isExternalTrigger(node)` lets the caller identify external controls whose pointer presses must reach its toggle handler without outside dismissal. Without this prop, presses outside the anchor and menu panels dismiss, including presses on `returnFocus` targets. `returnFocus` alone only controls focus restoration. While open, a contextual menu adds its ID to that control's `aria-controls` and restores the previous value on close. Their owner’s retained content stays mounted. -`Menu.listClassName` styles the menu card independently of the anchor wrapper, including in portal mode. Leading icons use the `--dsw-alias-menu-icon` color; destructive icons retain their error color. +`Menu.listClassName` styles the menu card independently of the anchor wrapper, including in portal mode. Tall portal menus retain the trigger’s horizontal alignment while using the window’s vertical scrolling space; horizontal clamping applies only when the card cannot fit beside that edge. Leading icons use the `--dsw-alias-menu-icon` color; destructive icons retain their error color. Submenu parents show a right chevron and open through focus, click, or hover-capable non-touch pointer events. ArrowRight on a menu row enters its child, never activates the external popup trigger; ArrowLeft and Escape return focus to the parent. Each child portals outside the parent's scrolling area, prefers the right side, flips left when it fits, and uses the root menu's visual-viewport limits and WebKit coordinate normalization with internal scrolling. When neither side fits, click or ArrowRight replaces the visible parent panel with the child and a return row; focus alone keeps the parent panel visible. `submenuBackLabel` supplies its localized text; when omitted, the return row uses the caller's parent label beside a left arrow and never selects the parent. Placement depends on available space, not the device's user agent; touch clicks open children on wide screens too. Resizing retains focused child nodes. JavaScript hover requires `(any-hover: hover)` and a non-touch pointer event, so secondary mice and pens can open children even when the primary pointer is touch. Compatibility mouse hover events do not open or collapse children. The pointer grace permits crossing the gap between cards; sibling rows close the previous child. Grace dismissal returns focus left in the child to its parent. Removing or disabling the parent, or emptying its children, restores the root panel and cancels that submenu. Focus left in the removed child returns to its enabled parent, the first enabled root row, or the anchor; focus already moved elsewhere stays there. -`useMenuSubmenu` owns child state, focus transitions, and pointer grace; `SubmenuPanel` renders the portal, and `MenuRow` supplies the shared component, data, and child row presentation. Root document listeners use the hook's containment, active-list, and return operations. Parent menus and child cards use the positioning core behind `useAnchoredPosition`, with per-card scroll/resize subscriptions, size observation, and frame-based movement tracking. The child placement function only chooses the preferred coordinates, side or page mode, and page width. The core owns `--dsh-anchored-max-width`, `--dsh-anchored-max-height`, and the optional `--dsh-anchored-width`; committed menu-content changes invalidate its natural-width measurement. Children have no independent ancestor, intersection, animation, or font observers. Internal font changes that leave the measured boxes and entry identity unchanged are picked up on the next placement invalidation, rather than through a dedicated font subscription. +`useMenuSubmenu` owns child state, focus transitions, and pointer grace; `SubmenuPanel` renders the portal, and `MenuRow` supplies the shared component, data, and child row presentation. Root document listeners use the hook's containment, active-list, and return operations. Child cards use CSS Anchor Positioning against an unscrolled box inside the parent card that matches the card. A side card opens to the parent's right with a 4px gap and aligns its top edge with the active row; when it does not fit, CSS tries the left side. The hook measures the row's offset within the parent card and the child's natural height on opening, window resize, list scrolling, and observed size changes; the card moves up only as far as needed to stay within the layout viewport, keeping 12px margins and the frame top clearance. `submenuNeedsPage` compares the child's natural width with both sides; when neither fits, an activated child replaces the parent page at the parent's position and at least its width. Pinch zoom moves child cards with the page, as it does parent menus. `Menu` delegates its card material to `MenuSurface`; custom menus use the same component. `MenuSurface` forwards div props and refs, uses translucent fill and blur, and accepts `compact` for the smaller radius. Its default relative positioning contains the material layer; caller classes can supply fixed or absolute placement. On macOS, its non-interactive backing follows the card through CSS anchors and unmounts with it; the backing requires the Web shell’s isolated body. Feature classes control layout and elevation, while the component owns material and outer radius ([menu rules](../../../docs/web-styling.md#component-rules)). Modal masks retain their dark translucent fill without background blur. @@ -176,7 +176,7 @@ The package enforces one separation: presentational React atoms with zero Cordis | [`src/code-highlighting.ts`](src/code-highlighting.ts) | Shared filename grammar selection and lazy line highlighting | | [`src/input-modality.ts`](src/input-modality.ts) | Document-wide input modality published on `` | | [`src/plugin-artwork.tsx`](src/plugin-artwork.tsx) | Fixed-palette plugin artwork with per-instance SVG def ids | -| [`src/useAnchoredPosition.ts`](src/useAnchoredPosition.ts) / [`src/useAnchoredMaxHeight.ts`](src/useAnchoredMaxHeight.ts) | Floating-panel and overlay geometry hooks | +| [`src/useAnchoredMaxHeight.ts`](src/useAnchoredMaxHeight.ts) | Height limits for bottom-anchored overlays | | [`src/settings-form/`](src/settings-form/) | The settings page kit: the staged form model over a settings scope, the value and secret fields, and the form frame | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index e4f172f352..f90076add7 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -58,7 +58,7 @@ kind: "package-library" | `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading,其动画在挂载时以及每次动画重启(如列表重排)时固定到文档时间零点,所以所有可见 loading 同相旋转。它是 `aria-hidden` 的,名称由渲染点提供。 `appearance="step"` 以实心勾表示完成、空心圆表示等待。 | | `ConnectionIndicator` | 行内连接恢复控件,覆盖断线、重试与已恢复三种状态。 | | `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。标题行默认使用 tertiary,悬停时使用 secondary;文字与图标继承该颜色,不受能否展开及开合状态影响,调用方可提供功能专属覆盖。语义状态色保持显式指定。收起时悬停预览向下箭头,展开后持续显示向上箭头。`headerAccessory` 将独立操作放在折叠触发区域和流光之外;有此操作时,标题行收窄到内容宽度。使用浅层 prop 比较进行 memo;内容未变时,保持回调与 React 节点 prop 的引用稳定。 | -| `AnimatedRows` | 带键列表的位移动画:移动为 200 ms,进出淡变为 100 ms。列表内发生指针或键盘输入后才启用,遵循减少动态效果设置,加载或替换视图时直接完成。为行标记 `data-row-key` 并传入对应的 `rowKeys`,为父容器设置定位以承载不可交互的退出层;`role` 和 `label` 由调用方提供。 | +| `AnimatedRows` | 带键列表的位移动画:移动为 200 ms,进出淡变为 100 ms。列表内发生指针或键盘输入后才启用,遵循减少动态效果设置,加载或替换视图时直接完成。只有同时与列表和浏览器窗口相交的行才做动画,所有行仍保持挂载。为行标记 `data-row-key` 并传入对应的 `rowKeys`,为父容器设置定位以承载不可交互的退出层;`role` 和 `label` 由调用方提供。 | | `useRowDragPreview` | 使用不可交互的离屏行副本生成原生拖拽预览。可选的 `inheritedProperties` 列表指定离屏包装层需要复制其计算值的 CSS 自定义属性;省略时不复制。列表引用需保持稳定,因为替换列表会清除活动预览。未列出的自定义属性按预览所在位置的正常继承规则生效。传入设置表面底色的类名;拖拽来自子级手柄时,将行元素传给 `start`;`clear` 和卸载会移除副本。 | | `TextShimmer` | 同一道高光从左到右扫过一行的标题、分隔符、摘要与后缀:首次等待 300ms,扫动一秒,再静止 500ms。遮罩覆盖内容宽度,并限制在可见行宽内,相对垂直方向倾斜 15°,峰值区域平坦,两侧柔和渐隐。移动的装饰层始终裁剪在行内,不会扩大可滚动区域。底色继承调用方的颜色,包括悬停变化;主题提供半透明高光。图标和箭头放在 `TextShimmer` 外,只有文字和分隔符参与扫光。字符串子节点沿用稳定的文字接口;嵌套的 `TextShimmer` 共用外层动画。活动期间组合子节点渲染两次,因此不得带副作用或元素 id。分隔符标记 `data-shimmer-decoration`,让填色参与扫光。`DisclosureRow` 将 `contentClassName` 传给文字区域,将 `contentLayoutClassName` 传给内部布局。惰性装饰副本不参与交互、选区和无障碍访问;文字更新保留动画,减少动态效果模式保留静态底色。 | | `Modal` | 页面遮罩之上的居中对话框。嵌套对话框可通过 `onKeyDownCapture` 在文档级 Escape 处理器之前拦截按键。 色层与弹窗淡入,背景模糊始终完整生效,并遵循减少动态效果偏好。调用方已模糊源页面时设置 `backdropBlur={false}`。`tone="warning"` 在标题中显示橙色警告图标,并移除正文额外的顶部间距;其他弹窗的间距保持不变。 | @@ -67,7 +67,7 @@ kind: "package-library" | `HoverCard`、`HoverCardGroup` | 可阅读、可选带复制按钮的悬停预览;物理列表可选择让卡片共享首次显示等待,并互斥显示。 | | `DraggableImage` | 通过显式传递与反馈回调提供原图拖放。 | | `ImageLightbox` | 共享图片浮层,支持焦点恢复与 Esc 关闭。 | -| `useAnchoredPosition` | 随浏览器缩放、滚动和尺寸变化持续跟随锚点的 fixed 面板坐标。 | +| `usePointerGrace` | 可取消的延迟关闭,让指针能够跨越触发控件与卡片间的空隙;卸载时清除待执行定时器。 | | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 | | `SettingsForm`、`SettingsValueField`、`SettingsSecretField` | 插件设置页的框架与控件:框架以 `labels` 接收文案,只在按钮点击时保存,卸载即丢弃;值字段显示暂存文本以及已覆盖标签和重置;密文字段每次为空,请求浏览器不要自动填入已保存的密码,只报告是否已配置。 | | `SettingsFormModel`、`settingsNumberField`、`settingsTextField` | 这类页面背后基于设置 scope 的暂存编辑模型:草稿先暂存、保存时写入,字段是否被覆盖看用户层是否含有它,未落地的保存保留草稿。 | @@ -97,7 +97,7 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品图标渲染一个 `` 框,其中包含一个引用对应 `dsw-icon--` symbol 的 ``,例如 `dsw-icon-search-outline-regular`,并在 `data-icon` 中写出该 id。`ICON_SYMBOLS` 保存每个 symbol 的默认 SVG 文档;ui-theme 据此渲染文档的图标 sprite,缺少对应 symbol 的图标不渲染任何内容。通过 `` 引用的图形会从使用处继承 `currentColor` 与 CSS 变量。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 在绘制前同步测量通过 body portal 渲染的 fixed 面板,跟踪窗口滚动、Visual Viewport 变化、锚点移动和元素尺寸变化。它统一 WebKit 的客户端坐标,保留视口边距和桌面标题栏避让,并通过 `--dsh-anchored-max-width`、`--dsh-anchored-max-height` 提供可见尺寸上限,由面板 CSS 应用。`frameClearance: false` 让按指针定位的菜单只保留视口边距。`layoutKey` 在内容变化后触发测量,`minWidthFromAnchor` 在可见视口内跟随锚点宽度。宿主提供的锚点矩形为空时,面板隐藏直到锚点可用。`useAnchoredMaxHeight` 将底部锚定浮层限制在可见视口内;过大的面板保留近端边距,不会滑出该边缘。portal 模式 `Menu` 按动态视口限制卡片高度,使固定页脚保持可见;子卡片通过各自的 portal 脱离父级滚动裁剪。锚定浮层与 portal 模式的 `Menu` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距;锚点位于该顶部区域内时,其浮层(包括 `HoverCard`)不会被推到低于锚点底边加浮层间距的位置。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区(位于顶部区域内的锚点下方时除外),高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表使用 `useAnchoredPosition` 在拖动、CSS 变换和浏览器缩放期间跟随锚点,包括 Safari visual viewport 平移;关闭时停止跟踪。首次定位完成前列表保持隐藏,`autoFocus` 等待列表可见后再聚焦。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 或 `Medium` 结尾。线条图形分别使用 1 和 1.3 SVG 用户单位的描边宽度;`size` prop 控制渲染尺寸。描边随视口缩放:16 单位 viewBox 中宽度为 1 的描边,在 14px 下为 0.875 CSS px,在 12px 下为 0.75 CSS px,尚未计入额外变换。渲染器不使用非缩放描边;填充轮廓随几何缩放,不受 stroke-width 变化影响。共享菜单通过 `Menu.module.css` 将图标渲染为 14px,紧凑列表为 12px;这些是渲染尺寸,不是固定描边宽度。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品图标渲染一个 `` 框,其中包含一个引用对应 `dsw-icon--` symbol 的 ``,例如 `dsw-icon-search-outline-regular`,并在 `data-icon` 中写出该 id。`ICON_SYMBOLS` 保存每个 symbol 的默认 SVG 文档;ui-theme 据此渲染文档的图标 sprite,缺少对应 symbol 的图标不渲染任何内容。通过 `` 引用的图形会从使用处继承 `currentColor` 与 CSS 变量。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredMaxHeight` 将底部锚定浮层限制在可见视口内;过大的面板保留近端边距,不会滑出该边缘。portal 模式的 `Menu` 按布局视口限制卡片高度,固定页脚位于内部滚动区域之外;子卡片使用各自的 body portal,父级滚动不会裁剪它们。`HoverCard` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距;锚点位于该顶部区域内时,卡片不会被推到低于锚点底边加自身间距的位置。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区(位于顶部区域内的锚点下方时除外),高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 通过 CSS Anchor Positioning 跟随触发元素,并按布局视口限制尺寸。每个候选位置都保留 12px 布局视口边距,并将框架浮层起点作为顶部下限;按指针定位的上下文菜单顶部保留 12px。首选位置放不下时,CSS 尝试横向调整或相反方向,最后使用窗口内的回退位置。浏览器缩放和平移时,菜单随页面移动,可以超出视觉视口,包括键盘打开时。内部滚动、菜单关闭和焦点恢复保持独立。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 `Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前保持隐藏,优先使用指定方向,再尝试相反方向和垂直于该方向的两侧;只要某一侧容得下,就沿锚点平移而不遮挡锚点。左右放置保留 10px 间距,上下放置使用 `gap`(默认 8px)。宽高限制在视口内并保留 12px 边距;文本自动换行,超出视口高度的内容在固定气泡后可滚动。若所有方向都放不下,则使用稳定的视口内回退位置,可能遮挡锚点。视口尺寸变化会重新读取锚点坐标,标签尺寸变化则复用坐标;定位不会同步测量气泡,也不会触发 React 渲染。 触摸按下会关闭未固定的提示,且不会触发悬停提示,包括触摸后的兼容鼠标事件。只有 `(any-hover: hover)` 表明存在可悬停的输入设备时,鼠标进入才会打开提示,从而排除 iOS 因已点击图标变化而生成的鼠标进入事件。仅有触摸输入的设备仍可使用键盘聚焦提示和显式点击打开的说明。信息按钮可显式启用 `openOnClick`,通过鼠标或触摸点击固定气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮在触摸点击时直接执行操作,不显示 Tooltip。可选的 `onlyWhenClipped` 只在气泡即将显示时锚点或其内部某个 HTML 元素横向溢出才显示气泡,因此重复被省略号截断文本的标签只在能补充信息时出现。 @@ -133,7 +133,7 @@ kind: "package-library" 这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionIndicator`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock`、`WebBlock` 与 `ZoomControls` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。 -`Menu.contextual` 从指针位置定位而不避让顶部栏,并在弹窗内右键时关闭。右键弹窗没有实际渲染的 `role="menuitem"` 行时保持隐藏,包括旧 Slot 返回空内容的情况。结构化 `items` 按分隔线组预留图标栏;`footer` 与各子菜单独立计算分组。右键菜单的 `footer` 以分隔线开头时,直接使用该线,不再叠加边框。不透明的 `children` 保留原有布局;数据行后方的旧 Slot 保留其声明的首条分隔线。`exiting` 在淡出期间保留不可交互的弹窗;`returnFocus` 在异步准备后恢复原控件焦点。`MenuSurface.phase` 分别对材质层、直接内容和 macOS 背景淡入淡出,让材质层继续模糊页面。`MENU_TRANSITION_MS` 同时提供 CSS 动画与弹窗生命周期计时。普通下拉菜单保留原有时序。 +`Menu` 的 `anchorRef` 指定组件外部的触发元素,支持同一次提交中稍后挂载的元素,以及在两次打开之间替换的元素。`autoFocus` 菜单通过键盘关闭时,会恢复到打开前仍在页面中的焦点控件。`anchorPoint` 接收客户端视口坐标,与 `event.clientX`/`clientY` 和 `getBoundingClientRect()` 一致。Safari 在菜单打开或坐标变化时将其转换为布局视口坐标一次,后续缩放和平移由 CSS 处理,不持续监听视口或测量矩形。`Menu.contextual` 从该指针位置定位而不避让顶部栏,并在弹窗内右键时关闭。右键弹窗没有实际渲染的 `role="menuitem"` 行时保持隐藏,包括旧 Slot 返回空内容的情况。结构化 `items` 按分隔线组预留图标栏;`footer` 与各子菜单独立计算分组。右键菜单的 `footer` 以分隔线开头时,直接使用该线,不再叠加边框。不透明的 `children` 保留原有布局;数据行后方的旧 Slot 保留其声明的首条分隔线。`exiting` 在淡出期间保留不可交互的弹窗;`returnFocus` 在异步准备后恢复原控件焦点。`MenuSurface.phase` 分别对材质层、直接内容和 macOS 背景淡入淡出,让材质层继续模糊页面。`MENU_TRANSITION_MS` 同时提供 CSS 动画与弹窗生命周期计时。普通下拉菜单不经过右键菜单的准备延迟。 ----- @@ -144,11 +144,11 @@ kind: "package-library" 锚点变为 inert 或隐藏时,portal 菜单会关闭;右键菜单使用 `returnFocus` 提供的原始控件。调用方通过 `isExternalTrigger(node)` 指定外部控件,使其指针按下事件不触发外部关闭,而由自己的切换处理器处理。省略该 prop 时,锚点与菜单面板之外的按下事件都会关闭菜单,包括 `returnFocus` 目标上的按下事件。单独设置 `returnFocus` 只控制焦点恢复。右键菜单打开期间会将自身 ID 加入该控件的 `aria-controls`,关闭时恢复原值。其所属区域保留的内容继续挂载。 -`Menu.listClassName` 独立控制菜单卡片样式,不影响入口容器,也适用于 portal 模式。前置图标使用 `--dsw-alias-menu-icon` 文本色;破坏性操作图标保留错误色。 +`Menu.listClassName` 独立控制菜单卡片样式,不影响入口容器,也适用于 portal 模式。较高的 portal 菜单使用窗口内的纵向滚动空间时,仍保持与触发元素的横向对齐;只有卡片在该侧放不下时才调整横向位置。前置图标使用 `--dsw-alias-menu-icon` 文本色;破坏性操作图标保留错误色。 子菜单父项显示向右箭头,可通过聚焦、点击或支持悬停的非触摸指针事件展开。菜单行上的 ArrowRight 进入其子级,不激活外部弹窗入口;ArrowLeft 与 Escape 将焦点还给父项。每个子级通过 portal 脱离父级滚动区域,优先向右展开,左侧能容纳时向左翻转,并采用根菜单相同的 visual viewport 限制与 WebKit 坐标归一化规则,在内部限高滚动。两侧均放不下时,点击或 ArrowRight 用子级及返回行替换当前可见的父菜单面板;仅聚焦时仍显示父面板。`submenuBackLabel` 提供该行的本地化文案;省略时使用调用方的父项标签配合向左箭头,返回操作不会选定父项。布局取决于可用空间,不取决于设备 UA;宽屏触控设备也可通过点击展开子级。调整窗口大小时保留已聚焦子项的 DOM 节点。JavaScript 悬停要求 `(any-hover: hover)` 与非触摸指针事件,所以主指针为触摸时,外接鼠标或笔仍可展开子级。兼容鼠标悬停事件不会展开或收起子级。指针宽限允许跨越卡片间隙;同级其他行会关闭原子级。宽限结束关闭子级时,仍在子级中的焦点会回到父项。父项被移除、禁用或其子项清空时,恢复根面板并取消该子菜单。留在被移除子级中的焦点会回到仍可用的父项、首个可用根菜单行或锚点;已经移到其他位置的焦点保持不变。 -`useMenuSubmenu` 持有子级状态、焦点转换与指针宽限;`SubmenuPanel` 渲染 portal,`MenuRow` 统一组件行、数据行与子行的展示。根菜单文档监听通过 hook 的包含判定、当前列表与返回操作交互。父菜单与子卡片复用 `useAnchoredPosition` 背后的定位核心,各卡片分别注册滚动/窗口变化订阅、尺寸观察与逐帧移动跟踪。子菜单放置函数只选择期望坐标、侧开或单面板模式及页宽。核心持有 `--dsh-anchored-max-width`、`--dsh-anchored-max-height` 和可选的 `--dsh-anchored-width`;菜单内容提交变更时会重新测量自然宽度。子菜单没有独立的祖先、交叉、动画或字体观察器。若内部字体变化未改变已测量的边界和条目身份,则在下一次定位更新时反映,而不通过专用字体订阅触发。 +`useMenuSubmenu` 持有子级状态、焦点转换与指针宽限;`SubmenuPanel` 渲染 portal,`MenuRow` 统一组件行、数据行与子行的展示。根菜单文档监听通过 hook 的包含判定、当前列表与返回操作交互。子卡片以父卡片内一个不随列表滚动、与卡片同尺寸的锚点框为锚点,使用 CSS Anchor Positioning 定位。侧开卡片默认在父卡片右侧打开,间距 4px,顶边与当前父行对齐;放不下时 CSS 尝试左侧。hook 在打开、窗口尺寸变化、列表滚动和观察到尺寸变化时测量该行在父卡片内的偏移与子卡片的自然高度;卡片只在需要时上移,以留在布局视口内,并保留 12px 边距和框架顶部避让。`submenuNeedsPage` 比较子卡片的自然宽度与两侧空间;两侧都放不下时,激活的子菜单以单面板形式替换父页面,位于父卡片的位置,宽度至少与父卡片相同。双指缩放时子卡片和父菜单一样随页面移动。 `Menu` 将卡片材质交给 `MenuSurface`,自定义菜单也使用该组件。`MenuSurface` 转发 div 属性和 ref,采用透明填充及模糊,`compact` 使用较小圆角。默认相对定位使材质层限制在容器内;调用方的类可以设置 fixed 或 absolute 定位。macOS 上,不接收交互的底层通过 CSS 锚点跟随卡片,并随卡片卸载;该底层要求 Web 外壳隔离 body 的层叠上下文。功能类控制布局和层级,组件负责材质和外圆角([菜单规则](../../../docs/web-styling.zh.md#component-rules))。 模态遮罩保留黑色半透明填充,不模糊背景。 @@ -176,7 +176,7 @@ kind: "package-library" | [`src/code-highlighting.ts`](src/code-highlighting.ts) | 共享的文件名 grammar 选择与惰性逐行高亮 | | [`src/input-modality.ts`](src/input-modality.ts) | 全文档输入模态,发布到 `` | | [`src/plugin-artwork.tsx`](src/plugin-artwork.tsx) | 固定配色插件插画,SVG def id 按实例生成 | -| [`src/useAnchoredPosition.ts`](src/useAnchoredPosition.ts) / [`src/useAnchoredMaxHeight.ts`](src/useAnchoredMaxHeight.ts) | 浮动面板与浮层几何钩子 | +| [`src/useAnchoredMaxHeight.ts`](src/useAnchoredMaxHeight.ts) | 底部锚定浮层的高度限制 | | [`src/settings-form/`](src/settings-form/) | 设置页套件:基于设置 scope 的暂存表单模型、值字段与密文字段、表单框架 | diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 40b94573d8..b52813da70 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-primitives", "description": "Pure React atoms for the dsh web UI: controls, icons, markdown, and JSON inspectors (zero cordis)", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-primitives/src/AnimatedRows.tsx b/packages/client/ui-primitives/src/AnimatedRows.tsx index fb4a8fac68..ca676494c4 100644 --- a/packages/client/ui-primitives/src/AnimatedRows.tsx +++ b/packages/client/ui-primitives/src/AnimatedRows.tsx @@ -24,7 +24,10 @@ interface RowPosition { opacity: number } +type Viewport = Pick + interface RowSnapshot { + viewport: Viewport positions: Map removed: Map } @@ -34,13 +37,22 @@ function sameRows(previous: AnimatedRowsProps, next: AnimatedRowsProps): boolean && previous.rowKeys.every((key, index) => key === next.rowKeys[index]) } -function intersects(row: DOMRect, viewport: DOMRect): boolean { +function visibleViewport(list: HTMLElement): Viewport { + const rect = list.getBoundingClientRect() + return { + top: Math.max(0, rect.top), bottom: Math.min(window.innerHeight, rect.bottom), + left: Math.max(0, rect.left), right: Math.min(window.innerWidth, rect.right), + } +} + +function intersects(row: DOMRect, viewport: Viewport): boolean { return row.bottom > viewport.top && row.top < viewport.bottom && row.right > viewport.left && row.left < viewport.right } /** * Animates keyed rows only when their rendered membership or order changes. + * Rows must intersect both the list and the browser window before or after the commit. * Motion starts after the first pointer or keyboard input inside the mounted list. * The parent supplies a positioned container for the inert exit overlay. */ @@ -58,7 +70,7 @@ export class AnimatedRows extends Component { || typeof list.animate !== 'function' || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return null - const viewport = list.getBoundingClientRect() + const viewport = visibleViewport(list) const positions = this.readPositions() const nextKeys = new Set(this.props.rowKeys) const removed = new Map() @@ -70,7 +82,7 @@ export class AnimatedRows extends Component { clone.style.paddingInlineStart = getComputedStyle(row.element).paddingInlineStart removed.set(key, { ...row, element: clone }) } - return { positions, removed } + return { viewport, positions, removed } } override componentDidUpdate(previous: AnimatedRowsProps, _state: unknown, snapshot: RowSnapshot | null): void { @@ -83,7 +95,7 @@ export class AnimatedRows extends Component { this.cancelMovements() const list = this.list.current as HTMLDivElement const overlay = this.overlay.current as HTMLDivElement - const viewport = list.getBoundingClientRect() + const viewport = visibleViewport(list) const origin = overlay.getBoundingClientRect() const positions = this.readPositions() @@ -91,7 +103,7 @@ export class AnimatedRows extends Component { this.removeExit(key) const previousRow = snapshot.positions.get(key) if (!intersects(row.rect, viewport) - && (previousRow === undefined || !intersects(previousRow.rect, viewport))) continue + && (previousRow === undefined || !intersects(previousRow.rect, snapshot.viewport))) continue if (previousRow === undefined) { this.move(row.element, [{ opacity: 0 }, { opacity: 1 }], ROW_FADE_MS) continue diff --git a/packages/client/ui-primitives/src/JsonTree.tsx b/packages/client/ui-primitives/src/JsonTree.tsx index c97008c3e8..70d3e10ad3 100644 --- a/packages/client/ui-primitives/src/JsonTree.tsx +++ b/packages/client/ui-primitives/src/JsonTree.tsx @@ -157,7 +157,7 @@ function JsonCopyAction({ store, target, persistent, labels, onCopy, onClose }: void onCopy(target, mode as CopyMode) }} onClose={onClose} - getAnchorRect={() => (buttonRef.current as HTMLButtonElement).getBoundingClientRect()} + anchorRef={buttonRef} /> )} diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index 7f908287d9..34625df712 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -30,8 +30,8 @@ top: calc(100% + 4px); left: 0; z-index: 100; - min-width: min(144px, var(--dsh-anchored-max-width, calc(100vw - 24px))); - max-width: min(360px, var(--dsh-anchored-max-width, calc(100vw - 24px))); + min-width: min(144px, calc(100vw - 24px)); + max-width: min(360px, calc(100vw - 24px)); } /* Legacy slots can render no rows; keep empty context popups out of layout. */ @@ -39,16 +39,91 @@ display: none; } -/* Portal mode: fixed in the viewport, coordinates supplied inline from the - * anchor rect (side/align resolved in JS, the in-place offset rules above - * don't apply). Portaled lists must layer above modal overlays (z 1000) — - * an anchor inside a dialog still expects its menu on top. */ +/* Every candidate reserves layout-window margins while browser zoom moves the page. */ .portal { + --dsh-menu-window-top: max(12px, var(--dsh-frame-overlay-top, 12px)); position: fixed; - top: auto; - left: auto; + position-area: none; + top: max(var(--dsh-menu-window-top), calc(anchor(bottom) + 4px)); + bottom: 12px; + left: max(12px, anchor(left)); + right: 12px; + justify-self: start; + align-self: start; + margin: 0; + position-try-fallbacks: --dsh-menu-wide-below, --dsh-menu-above, --dsh-menu-wide-above, --dsh-menu-window, --dsh-menu-wide-window; + width: max-content; + height: fit-content; z-index: 1100; } +.portal[data-anchor-side='top'] { + top: var(--dsh-menu-window-top); + bottom: max(12px, calc(anchor(top) + 4px)); + align-self: end; + position-try-fallbacks: --dsh-menu-wide-above, --dsh-menu-below, --dsh-menu-wide-below, --dsh-menu-window, --dsh-menu-wide-window; +} +.portal[data-anchor-side='right'] { + top: max(var(--dsh-menu-window-top), anchor(top)); + bottom: 12px; + left: max(12px, calc(anchor(right) + 4px)); + right: 12px; + position-try-fallbacks: --dsh-menu-left, --dsh-menu-wide-below, --dsh-menu-window, --dsh-menu-wide-window; +} +.portal[data-anchor-align='end']:not([data-anchor-side='right']) { + left: 12px; + right: max(12px, anchor(right)); + justify-self: end; + --dsh-menu-wide-align: start; +} +.portal[data-match-anchor-width] { min-width: min(anchor-size(width), calc(100vw - 24px)); } +.portal[data-context-menu] { --dsh-menu-window-top: 12px; } + +@position-try --dsh-menu-below { + top: max(var(--dsh-menu-window-top), calc(anchor(bottom) + 4px)); + bottom: 12px; + align-self: start; +} +@position-try --dsh-menu-above { + top: var(--dsh-menu-window-top); + bottom: max(12px, calc(anchor(top) + 4px)); + align-self: end; +} +@position-try --dsh-menu-wide-below { + top: max(var(--dsh-menu-window-top), calc(anchor(bottom) + 4px)); + bottom: 12px; + left: 12px; + right: 12px; + align-self: start; + justify-self: safe var(--dsh-menu-wide-align, end); +} +@position-try --dsh-menu-wide-above { + top: var(--dsh-menu-window-top); + bottom: max(12px, calc(anchor(top) + 4px)); + left: 12px; + right: 12px; + align-self: end; + justify-self: safe var(--dsh-menu-wide-align, end); +} +@position-try --dsh-menu-left { + left: 12px; + right: max(12px, calc(anchor(left) + 4px)); + justify-self: end; +} +/* A tall card keeps its trigger alignment before horizontal clamping is needed. */ +@position-try --dsh-menu-window { + top: var(--dsh-menu-window-top); + bottom: 12px; + align-self: safe start; +} + +@position-try --dsh-menu-wide-window { + top: var(--dsh-menu-window-top); + left: 12px; + right: 12px; + bottom: 12px; + align-self: safe start; + justify-self: safe var(--dsh-menu-wide-align, end); +} /* Open above the anchor (empty-state workspace chip: figma 122:9481). */ .sideTop { @@ -61,15 +136,11 @@ right: 0; } -/* Viewport fit: the card stops 12px short of the viewport's top/bottom edges - * (the portal MARGIN in Menu.tsx; the top side widens to the frame's published - * overlay inset) and taller content scrolls inside .viewport, so a pinned - * .footer stays visible. The dynamic unit follows mobile browser chrome; - * `vh` remains the fallback. Submenus escape the scroll clip through their - * own body portal, so their parent cards retain the same height cap. */ +/* Tall lists scroll within the layout viewport, independently of pinch zoom. + * Child cards use their own body portal, so a scrolling parent never clips them. */ .scrollable { - max-height: min(calc(100vh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px))), var(--dsh-anchored-max-height, 100vh)); - max-height: min(calc(100dvh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px))), var(--dsh-anchored-max-height, 100vh)); + max-height: calc(100vh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px))); + max-height: calc(100dvh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px))); } .viewport { @@ -146,7 +217,7 @@ .list.compactList, .submenu.compactList { - min-width: min(156px, var(--dsh-anchored-max-width, 100vw)); + min-width: min(156px, calc(100vw - 24px)); padding: 4px; } @@ -292,14 +363,66 @@ display: block; } -/* Child cards remain mounted while resizing between side and single-panel views. */ +/* The parent card's anchor for an open child card: an unscrolled box matching the + * card. WebKit does not re-resolve anchors on the anchor-positioned card itself + * after it settles, and resolves rows inside the scrolling list without their + * scroll offset; a descendant box avoids both. */ +.submenuAnchor { + position: absolute; + inset: 0; + pointer-events: none; +} + +/* Child cards stay mounted while switching between side and single-panel layouts. + * Both layouts use the parent card's anchor box as the default anchor, which + * browsers track through movement and zoom. JavaScript selects the layout and + * supplies the active row's offset within the parent card and the child card's + * natural height. */ .submenu { + --dsh-menu-window-top: max(12px, var(--dsh-frame-overlay-top, 12px)); position: fixed; + position-anchor: var(--dsh-submenu-parent); + position-visibility: always; + margin: 0; z-index: 1101; - width: var(--dsh-anchored-width, max-content); - min-width: min(163px, var(--dsh-anchored-max-width, calc(100vw - 24px))); - max-width: min(360px, var(--dsh-anchored-max-width, calc(100vw - 24px))); - max-height: var(--dsh-anchored-max-height, calc(100dvh - 24px)); + width: max-content; + min-width: min(163px, calc(100vw - 24px)); + max-width: min(360px, calc(100vw - 24px)); + max-height: calc(100dvh - 12px - var(--dsh-menu-window-top)); +} +.submenu[data-contextual] { --dsh-menu-window-top: 12px; } + +/* Side card: beside the parent card with a 4px gap, top edge on the active row, + * moved up when its height does not fit below the row. The only placement + * fallback is the left side. */ +.submenu[data-submenu-layout='side'] { + top: max(var(--dsh-menu-window-top), min( + calc(anchor(top) + var(--dsh-submenu-row-offset, 0px)), + calc(100dvh - 12px - var(--dsh-submenu-height, 0px)) + )); + left: calc(anchor(right) + 4px); + right: 12px; + justify-self: start; + height: max-content; + position-try-fallbacks: --dsh-submenu-left; +} +@position-try --dsh-submenu-left { + left: 12px; + right: calc(anchor(left) + 4px); + justify-self: end; +} + +/* Single-panel page: the child takes the parent card's place and at least its width. */ +.submenu[data-submenu-layout='page'] { + top: max(var(--dsh-menu-window-top), anchor(top)); + bottom: 12px; + left: max(12px, anchor(left)); + right: 12px; + align-self: start; + justify-self: start; + height: fit-content; + min-width: min(anchor-size(width), calc(100vw - 24px)); + max-width: calc(100vw - 24px); } /* Retain the parent's geometry and rows for placement and focus restoration. */ @@ -315,6 +438,6 @@ .list[data-menu-phase='dismiss'], .list[data-menu-phase='replace'] { pointer-events: none; } .list[data-context-menu].scrollable { - max-height: min(calc(100vh - 24px), var(--dsh-anchored-max-height, 100vh)); - max-height: min(calc(100dvh - 24px), var(--dsh-anchored-max-height, 100vh)); + max-height: calc(100vh - 24px); + max-height: calc(100dvh - 24px); } diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index 7fdf13db51..47b31bb407 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -1,8 +1,7 @@ -import { useCallback, useEffect, useId, useLayoutEffect, useRef } from 'react' -import type { CSSProperties, ReactNode } from 'react' +import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react' +import type { ReactNode, RefObject } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' -import { useAnchoredPosition } from './useAnchoredPosition.ts' import { useMenuSubmenu, menuHoverAllowed } from './useMenuSubmenu.ts' import { MenuRow, menuIconColumns, renderMenuDecoration } from './MenuRow.tsx' import { SubmenuPanel } from './SubmenuPanel.tsx' @@ -96,9 +95,6 @@ export function MenuItemButton({ onSelect, ...props }: MenuItemButtonProps) { return } -/** Unplaced portal list: hidden but laid out at a fixed origin so offsetWidth/offsetHeight are real. */ -const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } - /** * Render an anchored dropdown menu. While the list is open its keys mirror the * composer's: Tab settles the focused row — from the trigger, Tab enters the @@ -129,8 +125,8 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * @param props.align - list alignment against the anchor (default 'start'). * @param props.side - open below (`bottom`, default) or above (`top`) the anchor. * @param props.portal - render the list into document.body, fixed-positioned - * synchronously (follows movement, resizing, and browser zoom while open) - * and clamped inside the visual viewport. Use when an ancestor's overflow clipping would crop the + * with CSS anchor positioning; it follows browser zoom and may exceed the + * visual viewport. Use when an ancestor's overflow clipping would crop the * in-place list; default false keeps the pure-CSS in-place behavior. * @param props.closeOnPointerLeave - close the list once the pointer has left * both trigger and list for the pointer grace (default false keeps it open @@ -138,12 +134,8 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * gap and a brief overshoot survivable; coming back cancels the close. * @param props.dense - reduce vertical row spacing without changing the standard typography or card width. * @param props.compact - use reduced menu typography and spacing. - * @param props.getAnchorRect - portal mode only: supply the anchor rect - * directly (e.g. from a host-owned trigger button) instead of measuring the - * Menu's own wrapper span. Required when the wrapper isn't itself laid out at - * the trigger (render-prop anchors, effect-positioned proxies — measuring the - * wrapper there races the host's layout effects). Called while tracking the - * anchor and on scroll/resize; return null to defer placement. + * @param props.anchorRef - external element used as the CSS anchor in portal mode. + * @param props.anchorPoint - client viewport coordinates captured when a context menu opens. * @param props.minWidthFromAnchor - in portal mode, keep the menu at least as wide as its anchor, bounded by viewport margins. * @param props.footer - rows pinned below the scrolling items area, separated * by a hairline; contextual menus reuse an explicit leading separator. These rows stay visible while the items above scroll. @@ -161,7 +153,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * document.body outside the owner's DOM subtree. * @returns anchor wrapper with the conditional list. */ -export function Menu({ open, anchor, items = [], children, submenuBackLabel, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', getAnchorRect, minWidthFromAnchor = false, footer, className, listClassName, contextual = false, exiting, returnFocus, isExternalTrigger }: { +export function Menu({ open, anchor, items = [], children, submenuBackLabel, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', anchorRef, anchorPoint, minWidthFromAnchor = false, footer, className, listClassName, contextual = false, exiting, returnFocus, isExternalTrigger }: { open: boolean /** Pointer-positioned popup: cursor clearance, grouped icons, and context gestures. */ contextual?: boolean @@ -189,7 +181,9 @@ export function Menu({ open, anchor, items = [], children, submenuBackLabel, sel dense?: boolean compact?: boolean selection?: 'check' | 'fill' - getAnchorRect?: () => DOMRect | null + anchorRef?: RefObject | undefined + /** Client coordinates captured for this opening; Safari offsets are applied once. */ + anchorPoint?: { readonly x: number; readonly y: number } | undefined minWidthFromAnchor?: boolean className?: string | undefined listClassName?: string | undefined @@ -197,6 +191,17 @@ export function Menu({ open, anchor, items = [], children, submenuBackLabel, sel const rootRef = useRef(null) const listRef = useRef(null) const menuId = useId() + const pointX = anchorPoint?.x + const pointY = anchorPoint?.y + const point = useMemo(() => { + if (pointX === undefined || pointY === undefined) return undefined + // WebKit reports client coordinates from the visual origin; fixed CSS uses + // the layout origin. Subsequent panning must not move this captured point. + const visualOrigin = open && typeof window.CSS !== 'undefined' + && window.CSS.supports('-webkit-backdrop-filter', 'none') + const viewport = visualOrigin ? window.visualViewport : undefined + return { x: pointX + (viewport?.offsetLeft ?? 0), y: pointY + (viewport?.offsetTop ?? 0) } + }, [open, pointX, pointY]) useLayoutEffect(() => { const trigger = returnFocus?.() if (!open || !contextual || trigger == null) return @@ -225,7 +230,8 @@ export function Menu({ open, anchor, items = [], children, submenuBackLabel, sel * @param navigation - whether explicit keyboard traversal should retain its focus indicator. */ const refocusAnchor = (navigation = false): void => { - const trigger = returnFocus?.() ?? triggerRef.current + const remembered = triggerRef.current + const trigger = returnFocus?.() ?? (remembered?.isConnected ? remembered : anchorRef?.current) ?? null const target = trigger !== null && document.contains(trigger) && !(trigger as HTMLButtonElement).disabled ? trigger : rootRef.current?.querySelector('button:not(:disabled)') if (target == null) return @@ -253,35 +259,48 @@ export function Menu({ open, anchor, items = [], children, submenuBackLabel, sel exitingRef.current = exiting const openRef = useRef(open) openRef.current = open - const submenu = useMenuSubmenu({ open: open && exiting === undefined, items, footer, contextual, listRef, refocusAnchor }) + const submenu = useMenuSubmenu({ open: open && exiting === undefined, items, footer, listRef, refocusAnchor }) const submenuEvents = useRef>(submenu) submenuEvents.current = submenu const close = useRef(onClose) close.current = onClose - // Keep the dismissal fade at the last interactive anchor position. - const lastAnchorRect = useRef(null) - const readAnchor = useCallback(() => { - const anchorElement = returnFocus?.() ?? rootRef.current - if (anchorElement?.closest('[inert], [hidden]') != null) { - if (exitingRef.current === undefined) close.current() - return lastAnchorRect.current + const anchorName = `--dsh-menu-trigger-${menuId.replaceAll(':', '')}` + const [externalAnchor, setExternalAnchor] = useState(null) + const positioned = !portal || anchorRef === undefined || externalAnchor !== null + // External refs can attach after this component or replace their element + // without changing ref identity. Publish the anchor after all refs commit. + useEffect(() => { + if (!open || !portal || anchorRef === undefined) { + setExternalAnchor(null) + return } - lastAnchorRect.current = getAnchorRect === undefined ? rootRef.current?.getBoundingClientRect() ?? null : getAnchorRect() - return lastAnchorRect.current - }, [getAnchorRect, returnFocus]) - const fixedPos = useAnchoredPosition({ - open: open && portal, - anchorRef: rootRef, - panelRef: listRef, - side, - align, - gap: 4, - margin: 12, - frameClearance: !contextual, - minWidthFromAnchor, - getAnchorRect: readAnchor, + const element = anchorRef.current + setExternalAnchor(element) + if (element === null) return + const previous = element.style.getPropertyValue('anchor-name') + const priority = element.style.getPropertyPriority('anchor-name') + element.style.setProperty('anchor-name', anchorName) + return () => { element.style.setProperty('anchor-name', previous, priority) } }) - const positioned = !portal || fixedPos !== null + useEffect(() => { + if (!open || !portal || exiting !== undefined) return + const currentAnchor = (): HTMLElement | null => returnFocus?.() ?? (anchorRef === undefined ? rootRef.current : anchorRef.current) + const element = currentAnchor() + if (element === null) return + let dismissed = false + const check = (): void => { + const current = currentAnchor() + if (dismissed || (current !== null && current.isConnected && current.closest('[inert], [hidden]') === null)) return + dismissed = true + close.current() + } + const observer = new MutationObserver(check) + observer.observe(element.ownerDocument.documentElement, { + subtree: true, childList: true, attributes: true, attributeFilter: ['inert', 'hidden'], + }) + check() + return () => { observer.disconnect() } + }, [open, portal, exiting, anchorRef, externalAnchor, returnFocus]) const { arm: armClose, cancel: cancelClose } = usePointerGrace(onClose) // Opening remembers where the keyboard was, so closing can hand it back to @@ -291,12 +310,13 @@ export function Menu({ open, anchor, items = [], children, submenuBackLabel, sel useEffect(() => { if (!open) return const active = document.activeElement - triggerRef.current = active instanceof HTMLElement && (getAnchorRect !== undefined || rootRef.current?.contains(active) === true) + triggerRef.current = active instanceof HTMLElement && active !== document.body + && (anchorRef !== undefined || anchorPoint !== undefined || rootRef.current?.contains(active) === true) ? active : null }, [open]) useEffect(() => { - if (!open || exiting !== undefined || !autoFocus || !positioned + if (!open || !positioned || exiting !== undefined || !autoFocus || listRef.current?.contains(document.activeElement) || submenu.contains(document.activeElement)) return const first = listRef.current?.querySelector('button:not(:disabled)') walkIndex.current = first === undefined || first === null ? null : 0 @@ -312,7 +332,7 @@ export function Menu({ open, anchor, items = [], children, submenuBackLabel, sel const onPointerDown = (e: PointerEvent) => { if (!(e.target instanceof Node) || (contextual && e.button === 2)) return // The portaled list is outside the anchor subtree; check both. - if (rootRef.current?.contains(e.target) === true) return + if (rootRef.current?.contains(e.target) === true || anchorRef?.current?.contains(e.target) === true) return if (isExternalTrigger?.(e.target) === true) return if (listRef.current?.contains(e.target) === true || submenuEvents.current.contains(e.target)) return onClose() @@ -324,7 +344,8 @@ export function Menu({ open, anchor, items = [], children, submenuBackLabel, sel // row or sits on its anchor region. const focused = document.activeElement const insideList = listRef.current?.contains(focused) === true || submenuEvents.current.contains(focused) - const anchored = focused === returnFocus?.() || rootRef.current?.contains(focused) === true || insideList + const anchored = focused === returnFocus?.() || anchorRef?.current?.contains(focused) === true + || rootRef.current?.contains(focused) === true || insideList if (e.key === 'Escape' && !e.shiftKey) { e.preventDefault() if (e.repeat) return @@ -426,7 +447,7 @@ export function Menu({ open, anchor, items = [], children, submenuBackLabel, sel document.removeEventListener('keydown', onEscape, true) window.removeEventListener('blur', onWindowBlur) } - }, [open, onClose, autoFocus, contextual, exiting, returnFocus, isExternalTrigger]) + }, [open, onClose, autoFocus, contextual, exiting, anchorRef, returnFocus, isExternalTrigger]) useLayoutEffect(() => { if (exiting === 'dismiss' && listRef.current?.contains(document.activeElement)) refocusAnchor() @@ -465,12 +486,14 @@ export function Menu({ open, anchor, items = [], children, submenuBackLabel, sel }}>{entry.label} } - // Portal lists stay hidden until their first synchronous measurement. - const list = open && ( + const list = open && positioned && ( { if (menuHoverAllowed(event)) submenu.collapseFrom(event) }} onFocus={submenu.collapseFrom} > + {/* Child cards anchor to this unscrolled box, which matches the card itself. */} + {submenu.entry !== undefined &&