diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 2dbf920a69..e42398340b 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -2662,6 +2662,9 @@ "simplification/2026-09-19-deepseek-messages-only.i18n.yaml": "sha256:7ffd708c5823c39cc9536178292e7e7fa57db5267bbc9acdda0a87e4953d5ba8", "simplification/2026-09-19-deepseek-messages-only.md": "sha256:241861200ce73e02f77d182633d6ea10b1f29c696c81bff002680762db01d9b5", "simplification/2026-09-19-deepseek-messages-only.zh.md": "sha256:311ef6f8f4cf60dae2a43ca94d772028b645631501fb3648aaeb406ba174991e", + "simplification/2026-09-26-team-direct-inbox.i18n.yaml": "sha256:9a6f1137f0c68d976fda4f44c5c301b851a8ee8e46965f3ea808ec5551045fb0", + "simplification/2026-09-26-team-direct-inbox.md": "sha256:88f7d9be3ae0b740ddb5a61a16e3c64044a207584b973acab8c3a40c36a7ff30", + "simplification/2026-09-26-team-direct-inbox.zh.md": "sha256:d6d1fafa4aa7035a11930cca3bf998d676a62b5f9b75176dff69c3c5ff6b92f0", "testing/2026-06-11-property-based-testing.i18n.yaml": "sha256:9d378912101ad30203f1fcbcad23e0f5d6ec6f39e04a83f17f90887b5c5d761e", "testing/2026-06-11-property-based-testing.md": "sha256:cca3b6299784cff94965f21b6d59966109a0bb41fdc2f8cf56653fa6c417a20a", "testing/2026-06-11-property-based-testing.zh.md": "sha256:09f3d1e2defd2792f87fb0e9892fb7e1db006f3c75ea4581f97e9cde4ea98175", diff --git a/.agents/notes/archived/simplification/2026-09-26-team-direct-inbox.i18n.yaml b/.agents/notes/archived/simplification/2026-09-26-team-direct-inbox.i18n.yaml new file mode 100644 index 0000000000..50788468df --- /dev/null +++ b/.agents/notes/archived/simplification/2026-09-26-team-direct-inbox.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/simplification/2026-09-26-team-direct-inbox.md +2026-09-26-team-direct-inbox.md: 9b90d7ae30e0b711c602de9280cc11845d0525f0 +2026-09-26-team-direct-inbox.zh.md: 8838fb4a2824838b76142f30a6ff4d0aedf0276b diff --git a/.agents/notes/implemented/simplification/2026-09-26-team-direct-inbox.md b/.agents/notes/archived/simplification/2026-09-26-team-direct-inbox.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-09-26-team-direct-inbox.md rename to .agents/notes/archived/simplification/2026-09-26-team-direct-inbox.md index 58d18d0ecf..9b90d7ae30 100644 --- a/.agents/notes/implemented/simplification/2026-09-26-team-direct-inbox.md +++ b/.agents/notes/archived/simplification/2026-09-26-team-direct-inbox.md @@ -1,6 +1,7 @@ # Agent Note: Team messages use the target inbox Status: implemented +Archived: 2026-10-15 English | [中文](2026-09-26-team-direct-inbox.zh.md) diff --git a/.agents/notes/implemented/simplification/2026-09-26-team-direct-inbox.zh.md b/.agents/notes/archived/simplification/2026-09-26-team-direct-inbox.zh.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-09-26-team-direct-inbox.zh.md rename to .agents/notes/archived/simplification/2026-09-26-team-direct-inbox.zh.md index 6928631c8f..8838fb4a28 100644 --- a/.agents/notes/implemented/simplification/2026-09-26-team-direct-inbox.zh.md +++ b/.agents/notes/archived/simplification/2026-09-26-team-direct-inbox.zh.md @@ -1,6 +1,7 @@ # Agent Note: Team 消息使用目标 inbox Status: implemented +Archived: 2026-10-15 [English](2026-09-26-team-direct-inbox.md) | 中文 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-08-27-adjacent-agent-steer-messaging.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.i18n.yaml index 260adf6610..97e7927b58 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.i18n.yaml @@ -3,23 +3,23 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md /agent-note-adjacent-agents-share-one-steer-send-message-operation: - en: 758d31ae04ce0d4f - zh: 112475efbd52b9f6 + en: 9d0af2bae38b1eec + zh: 8a6e8502a5195548 /agent-note-adjacent-agents-share-one-steer-send-message-operation/problem: - en: c988b8a3be4e6f19 - zh: b58011fd8d2e878e + en: 5dc4c218f648c27e + zh: b48531fac94b25db /agent-note-adjacent-agents-share-one-steer-send-message-operation/decision: - en: 1188c699ce6e6ca2 - zh: 4d5b974cd1216f06 + en: f4789878ac0e79b2 + zh: 68eb68d34cba810d /agent-note-adjacent-agents-share-one-steer-send-message-operation/decision/one-model-tool-and-one-return-instruction: - en: 4b0a25389324fa91 - zh: c2cdd9f7e40ef43b + en: 62284bd7dd804188 + zh: b3a8dfebffc01bdb /agent-note-adjacent-agents-share-one-steer-send-message-operation/decision/complete-removal-and-reintroduction-condition: - en: 5a9ec6478cf7430c - zh: f4c2f067cc72040e + en: 8c6d355d91ec83d2 + zh: fcc74d1e6ef477bf /agent-note-adjacent-agents-share-one-steer-send-message-operation/alternatives-considered: - en: 1f62d5d938144666 - zh: dec0f30228c5b7a7 + en: 7386e88e7051dce7 + zh: dcea5b9e30374be3 /agent-note-adjacent-agents-share-one-steer-send-message-operation/consequences: - en: d55de74742c3af4b - zh: d894f9a33c8e4691 + en: 9c23dd6ace029ec3 + zh: f08ef3f142534843 diff --git a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md index 4efb9d334e..a4cfbc91cb 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md +++ b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md @@ -4,26 +4,25 @@ Status: implemented English | [中文](2026-08-27-adjacent-agent-steer-messaging.zh.md) +The adjacency, discovery, and parent-lifetime restrictions below are superseded by [tree-wide communication](2026-10-16-tree-wide-agent-communication.md); the remaining decisions stay active. The tool identity marker and initial-task communication guidance below are superseded by [tool-owned communication](2026-10-22-subagent-tool-owned-communication.md). + ## Problem Continuable Agents originally used direction-specific model controls. A parent called `send_message({ subagent_id, message })`, which delegated to a FIFO `followup` service operation. A child instead received a child-scoped `report({ output })` tool, a `tool:report` system-prompt section, and deployment-selected quiet or waking delivery. The tools described one adjacent-Agent operation through different schemas, service paths, source attribution, and scheduling. -A continuable child owns its own Session, so its parent does not automatically receive the child's transcript, tool output, or reasoning. The return path must therefore remain explicit and repeatable: a child may send progress before it finishes, remain available after sending, or fail before it can cooperate. Turning every final assistant message into an implicit result would conflate turn completion with model-selected communication and would not cover abnormal endings. +A continuable child owns its own Session, so its parent does not automatically receive the child's transcript, tool output, or reasoning. Explicit messages let a child send actionable findings before finishing. The runtime separately delivers terminal output in a settlement notice when the direct creator is still online. The child-only tool and system-prompt section also preceded every inherited fork turn. They made a continuable fork child's request head differ from its parent's before the history that fork exists to reuse, forcing the provider to prefill the entire copied transcript again. ## Decision -`SubagentRuntime.sendMessage(sender, targetId, content, { signal })` is the only public model-authored message operation. The continuation manager accepts only the exact live sender and a target on one adjacent edge: +`SubagentRuntime.sendMessage(sender, targetId, content, { signal })` is the only public model-authored message operation. The continuation manager accepts only the exact live sender and a target in the same root tree; [tree-wide communication](2026-10-16-tree-wide-agent-communication.md) owns authorization and discovery. -- parent to direct continuable child, authorized by the child's durable `SessionHeader.parentSession`; -- resident continuable child to its exact live direct parent, authorized by the child's Activation. +Self-targets, stale Agent objects, unknown targets, and external executions are not alternate routes. The operation has no caller-supplied source, delivery mode, offline root mailbox, or provider dispatch. -Siblings, self-targets, ancestors beyond one edge, stale Agent objects, unknown targets, and one-shot children are not alternate routes. The operation has no caller-supplied source, delivery mode, offline parent mailbox, or provider dispatch. +Every accepted message uses `Agent.steer()`. A running target receives it at the nearest step boundary; an idle target starts a turn. An absent target is cold-resumed under the root before the same Steer delivery; its creation ancestors can remain offline. The manager retains waking-send accounting so a continuation-managed target cannot settle between synchronous inbox insertion and driver admission. -Every accepted message uses `Agent.steer()`. A running target receives it at the nearest step boundary; an idle target starts a turn. An absent direct child is cold-resumed through the existing continuation lifecycle before the same Steer delivery. The manager retains waking-send accounting so a continuation-managed target cannot settle between synchronous inbox insertion and driver admission. - -Every direction uses one durable source. The service derives `senderSessionId` from the authorized Agent and frames the model-visible content as `Agent sent a message:`, so attribution cannot diverge from authority. +Every direction uses one durable source. The service derives `senderSessionId` from the exact live sender and frames content as `Agent sent a message:`. ```ts import type { SessionId } from '@deepseek-ai/dsh-session' @@ -46,15 +45,15 @@ interface SendMessageInput { } ``` -Parents and children inherit the same definition in the same registry order. The standard definition carries a process-stable internal identity that a scoped same-name tool does not satisfy. An independently selected preset may omit the tool, a historical descriptor filter may remove it on resume, and a scoped replacement may provide different semantics; neither case receives the standard call instruction. When the standard tool remains visible, the continuation manager appends the JSON-encoded direct parent id and the instruction to send one self-contained result before finishing, plus earlier actionable findings, to the child's initial user task. For a fork child this task follows the inherited completed-turn prefix; no child-only system-prompt section or tool schema precedes that prefix. +Parents and children inherit the same definition in the same registry order. The standard definition carries a process-stable internal identity that a scoped same-name tool does not satisfy. An independently selected preset may omit the tool, a historical descriptor filter may remove it on resume, and a scoped replacement may provide different semantics; none receives the standard call instruction. When the standard tool remains visible, the continuation manager appends the JSON-encoded direct parent id, automatic final-answer delivery conditions, and guidance to send earlier actionable findings to the child's initial user task. For a fork child this task follows the inherited completed-turn prefix; no child-only system-prompt section or tool schema precedes that prefix. -The instruction is guidance, not settlement enforcement. Sending does not end the child's turn, zero or several calls remain mechanically valid, and the runtime never rejects a child for staying silent. The manager-owned `subagent-settled` notice remains unconditional and separately attributed because it records how an Activation ended and preserves terminal output when the child cannot cooperate. +The instruction is guidance, not settlement enforcement. Sending does not end the child's turn, zero or several calls remain mechanically valid, and the runtime never rejects a child for staying silent. The separately attributed `subagent-settled` notice records the Activation's outcome and terminal output only when its direct creator is still online and delivery is `parent`. Human browser prompts are not model-authored Agent messages. The remote prompt path keeps a private Queue delivery so each human prompt remains a distinct turn. Interrupt behavior and settlement delivery remain independent. ### Complete removal and reintroduction condition -The standalone `@deepseek-ai/dsh-tool-subagent-report` package, `report` schema, `tool:report` prompt section, `reportDelivery` configuration, report-specific message source, catalog entries, composition rows, and supported-behavior snapshots are absent. The unified tool gives up the recipient-free child shortcut and the old ability for a structural return tool to survive an explicit child allow-list. Those capabilities return only if a concrete use case requires semantics that an adjacent `agent_id` and fixed Steer cannot express; reintroducing them requires a distinct model operation and prefix-cost evidence, not an alias over `sendMessage()`. +The standalone `@deepseek-ai/dsh-tool-subagent-report` package, `report` schema, `tool:report` prompt section, `reportDelivery` configuration, report-specific message source, catalog entries, composition rows, and supported-behavior snapshots are absent. The unified tool gives up the recipient-free child shortcut and the old ability for a structural return tool to survive an explicit child allow-list. Those capabilities return only if a concrete use case requires semantics that a tree member’s `agent_id` and fixed Steer cannot express; reintroducing them requires a distinct model operation and prefix-cost evidence, not an alias over `sendMessage()`. ## Alternatives considered @@ -64,16 +63,16 @@ The standalone `@deepseek-ai/dsh-tool-subagent-report` package, `report` schema, **Make `report` global.** Roots, one-shot children, remote children, and agentless callers cannot derive a report recipient. Advertising it globally would make schema visibility disagree with authority, while `send_message` already makes the recipient explicit. -**Turn every child final message into an implicit send.** A long-lived child may have nothing useful to send in one turn and several findings in another. Automatic delivery would merge model-authored communication with the runtime's settlement account and could not replace the unconditional notice on errors, cancellation, or token exhaustion. +**Treat the final answer as a model-authored send.** A settlement notice must preserve terminal output and abnormal endings even when the child cannot cooperate. It keeps runtime attribution distinct from findings the child explicitly chose to send. -**Rely only on the tool description.** A tool description helps after the model considers that tool; the failure mode is a child that believes it is finished without considering any return call. Initial-task guidance reaches that decision without changing the inherited system or tool prefix. +**Rely only on the tool description.** A tool description helps after the model considers that tool; initial-task guidance also explains which earlier findings should reach the parent and what the final answer must contain, without changing the inherited system or tool prefix. **Keep quiet delivery as deployment policy.** A quiet model-authored message can be accepted while an idle target never reads it. Fixed Steer gives both directions one delivery meaning and preserves accepted order with later settlement notices. ## Consequences - Model consumers expose one `send_message({ agent_id, message })` definition to parents and children, with no model-selected Queue versus Steer parameter. -- The continuation manager remains the sole owner of adjacency authorization, residency, cold resume, waking admission, and teardown races. +- The continuation manager remains the sole owner of tree authorization, residency, cold resume, waking admission, and teardown races. - Accepted messages may extend a running target's current turn; messages waiting together share next-step FIFO ordering. - Caller cancellation owns work only until inbox acceptance and does not retract an accepted message or dispose the target. - The initial task carries JSON-encoded dynamic parent addressing after a fork prefix, while the request-head system prompt and tool ordering remain reusable. diff --git a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md index b7ed157f54..d907eaeac0 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md @@ -4,26 +4,25 @@ Status: implemented [English](2026-08-27-adjacent-agent-steer-messaging.md) | 中文 +邻接消息、发现范围和父子生命周期限制已由[整树通信](2026-10-16-tree-wide-agent-communication.zh.md)替代;其余决策仍然有效。 下文的工具身份标记和初始任务通信指导由[工具拥有通信指导](2026-10-22-subagent-tool-owned-communication.zh.md)取代。 + ## 问题 可继续 Agent 最初使用方向专属的模型控制。parent 调用 `send_message({ subagent_id, message })`,委托给 FIFO `followup` 服务操作。child 则获得 child 作用域的 `report({ output })` 工具、`tool:report` 系统提示词 section,以及由部署选择的静默或唤醒投递。两个工具用不同 schema、服务路径、来源与调度描述同一个相邻 Agent 操作。 -可继续 child 拥有自己的 Session,因此 parent 不会自动收到 child 的 transcript(文本记录)、工具输出或推理。返回路径必须保持显式且可重复:child 可以在结束前发送进度、发送后仍保持可用,也可能在来得及配合前失败。把每条最终 assistant 消息变成隐式结果会混淆轮次完成与模型选择的通信,而且无法覆盖异常结束。 +可继续 child 拥有自己的 Session,因此 parent 不会自动收到 child 的 transcript(文本记录)、工具输出或推理。显式消息让 child 在结束前发送可操作的发现。直接创建者仍在线时,runtime 另行通过结算通知投递终态输出。 child 专属工具与系统提示词 section 还位于每个继承 fork 轮次之前。它们让可继续 fork child 的请求头在 fork 旨在复用的历史之前就与 parent 不同,迫使提供方重新预填充整份复制 transcript。 ## 决策 -`SubagentRuntime.sendMessage(sender, targetId, content, { signal })` 是唯一公开的模型编写消息操作。继续执行管理器只接受确切在线 sender 与一条相邻边上的目标: +`SubagentRuntime.sendMessage(sender, targetId, content, { signal })` 是唯一公开的模型编写消息操作。继续执行管理器只接受确切在线 sender 与同一根树中的目标;[整树通信](2026-10-16-tree-wide-agent-communication.zh.md)负责授权与发现规则。 -- parent 到直接可继续 child,由 child 的持久化 `SessionHeader.parentSession` 授权; -- 驻留的可继续 child 到其确切在线直接 parent,由 child 的 Activation 授权。 +自身目标、过期 Agent 对象、未知目标和外部执行不是替代路由。操作不接受调用方提供的 source、投递模式、离线根邮箱或提供方分发。 -sibling、自身目标、超过一条边的 ancestor、陈旧 Agent 对象、未知目标与一次性 child 都不是替代路由。该操作没有调用方提供的 source、投递模式、离线 parent mailbox 或提供方分发。 +每条被接受的消息都使用 `Agent.steer()`。运行中目标在最近 step 边界接收消息;空闲目标启动轮次。不在线目标先在根下冷恢复,再接受同一 Steer 投递;其创建祖先可以保持离线。管理器保留唤醒发送记账,因此受继续执行管理的目标不会在同步 inbox 插入与 driver 准入之间结算。 -每条被接受的消息都使用 `Agent.steer()`。运行中目标在最近 step 边界接收消息;空闲目标启动轮次。缺失的直接 child 会先通过现有继续执行生命周期冷恢复,再接受同一 Steer 投递。管理器保留唤醒发送记账,因此受继续执行管理的目标不会在同步 inbox 插入与 driver 准入之间结算。 - -两个方向使用同一种持久来源。服务从已授权 Agent 推导 `senderSessionId`,并把模型可见内容组装为 `Agent sent a message:`,因此来源信息不会偏离权限。 +所有方向使用同一种持久 source。服务从确切在线发送方推导 `senderSessionId`,并以 `Agent sent a message:` 包装内容。 ```ts import type { SessionId } from '@deepseek-ai/dsh-session' @@ -46,15 +45,15 @@ interface SendMessageInput { } ``` -parent 与 child 以相同注册表顺序继承相同定义。标准定义携带进程稳定的内部身份,同名的作用域工具不满足该身份。独立选择的 preset 可以省略该工具,历史 descriptor 过滤器可在恢复时移除该工具,作用域替代工具也可以提供不同语义;两种情况都不会收到标准调用指令。当标准工具仍可见时,继续执行管理器会把经过 JSON 编码的直接 parent id、结束前发送一份自包含结果的指令,以及更早发送可操作发现的指令追加到 child 初始用户任务。对 fork child 而言,该任务位于继承的已完成轮次前缀之后;没有 child 专属系统提示词 section 或工具 schema 位于此前缀之前。 +parent 与 child 以相同注册表顺序继承相同定义。标准定义携带进程稳定的内部身份,同名的作用域工具不满足该身份。独立选择的 preset 可以省略该工具,历史 descriptor 过滤器可在恢复时移除该工具,作用域替代工具也可以提供不同语义;这些情况都不会收到标准调用指令。当标准工具仍可见时,继续执行管理器会把经过 JSON 编码的直接 parent id、最终回答的自动投递条件,以及提前发送可操作发现的指导追加到 child 初始用户任务。对 fork child 而言,该任务位于继承的已完成轮次前缀之后;没有 child 专属系统提示词 section 或工具 schema 位于此前缀之前。 -该指令是指导,不是结算强制。发送不会结束 child 轮次,机制仍允许零次或多次调用,runtime 绝不会因 child 保持沉默而拒绝它。由管理器负责的 `subagent-settled` 通知仍无条件发送并采用独立来源,因为它记录 Activation 如何结束,并在 child 无法配合时保留终态输出。 +该指令是指导,不是结算强制。发送不会结束 child 轮次,机制仍允许零次或多次调用,runtime 绝不会因 child 保持沉默而拒绝它。采用独立来源的 `subagent-settled` 通知仅在直接创建者仍在线且投递方式为 `parent` 时,记录 Activation 的结果与终态输出。 浏览器中的人类提示不是模型编写的 Agent 消息。远程提示路径保留私有 Queue 投递,使每条人类提示保持为独立轮次。中断行为与结算投递保持独立。 ### 完整移除与重新引入条件 -独立的 `@deepseek-ai/dsh-tool-subagent-report` 包、`report` schema、`tool:report` 提示词 section、`reportDelivery` 配置、report 专属消息来源、目录项、组合行和受支持行为快照均已不存在。统一工具放弃了无需接收方的 child 快捷方式,也放弃了让结构性返回工具绕过显式 child allow-list 的旧能力。只有具体用例需要相邻 `agent_id` 与固定 Steer 无法表达的语义时,这些能力才会重新出现;重新引入需要独立的模型操作与前缀成本证据,而非 `sendMessage()` 之上的别名。 +独立的 `@deepseek-ai/dsh-tool-subagent-report` 包、`report` schema、`tool:report` 提示词 section、`reportDelivery` 配置、report 专属消息来源、目录项、组合行和受支持行为快照均已不存在。统一工具放弃了无需接收方的 child 快捷方式,也放弃了让结构性返回工具绕过显式 child allow-list 的旧能力。只有具体用例需要树成员的 `agent_id` 与固定 Steer 无法表达的语义时,这些能力才会重新出现;重新引入需要独立的模型操作与前缀成本证据,而非 `sendMessage()` 之上的别名。 ## 考虑过的替代方案 @@ -64,16 +63,16 @@ parent 与 child 以相同注册表顺序继承相同定义。标准定义携带 **让 `report` 全局可见。** 根 Agent、一次性 child、远程 child 与无 Agent 调用方无法推导 report 接收方。全局宣传它会让 schema 可见性与权限不一致,而 `send_message` 已显式给出接收方。 -**把每条 child 最终消息变成隐式发送。** 长期运行的 child 可能在某个轮次没有值得发送的内容,在另一个轮次却有多条发现。自动投递会混合模型编写通信与 runtime 结算说明,而且无法替代错误、取消或 token 耗尽时的无条件通知。 +**将最终回答当作模型编写的发送。** 即使 child 无法配合,结算通知仍须保留终态输出与异常结束信息。它将 runtime 来源与 child 显式选择发送的发现区分开。 -**只依赖工具描述。** 工具描述会在模型考虑该工具后提供帮助;失败模式是 child 认为自己已经完成而根本没有考虑返回调用。初始任务指导能触及该决策,又不会改变继承的系统或工具前缀。 +**只依赖工具描述。** 工具描述会在模型考虑该工具后提供帮助;初始任务指导还说明哪些早期发现应发送给 parent,以及最终回答须包含什么,又不会改变继承的系统或工具前缀。 **保留静默投递作为部署策略。** 静默的模型编写消息可能被接受,但空闲目标永远不会读取它。固定 Steer 为两个方向提供一种投递含义,并保持与后续结算通知的接受顺序。 ## 后果 - 模型 Consumer 向 parent 与 child 公开一个 `send_message({ agent_id, message })` 定义,不提供模型选择的 Queue 与 Steer 参数。 -- 继续执行管理器仍是相邻关系授权、驻留、冷恢复、唤醒准入与拆卸竞态的唯一所有者。 +- 继续执行管理器仍是整树授权、驻留、冷恢复、唤醒准入与拆卸竞态的唯一所有者。 - 被接受的消息可以延长运行中目标的当前轮次;一起等待的消息共享 next-step FIFO 顺序。 - 调用方取消只在 inbox 接受前掌管工作,不会撤回已接受消息或 dispose(资源释放)目标。 - 初始任务在 fork 前缀之后携带经过 JSON 编码的动态 parent 地址,而请求头系统提示词与工具顺序保持可复用。 diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml index ddf55f9063..f8de1ed3b1 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml @@ -9,14 +9,14 @@ en: 98b49b900b8d2f84 zh: 557faac2976592be /agent-note-explicit-agent-identity-at-runtime-boundaries/decision: - en: 1c628cf9c743c639 - zh: d969b2475fe4a155 + en: 4a3ab5108f37520f + zh: d13fcd448101a95d /agent-note-explicit-agent-identity-at-runtime-boundaries/verification: - en: 85130faaa171036b - zh: 06e5e9fab32dbfb4 + en: ce1733edf03f0387 + zh: 8e1ca2b69e81968e /agent-note-explicit-agent-identity-at-runtime-boundaries/alternatives-considered: - en: 2c41456509d54d21 - zh: 5704ee9a2274e300 + en: 34f5f8571b21897c + zh: cf16094bc0027ca1 /agent-note-explicit-agent-identity-at-runtime-boundaries/consequences: - en: 08ab86b89fc3ec6f - zh: 3866e6557858543a + en: 0b5914067f14282e + zh: c41d21aeb28e357f diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md index 16cd668f65..336151fa83 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md @@ -14,34 +14,34 @@ Without an explicit owner, `SubagentManager` creates and resumes children throug ## Decision -Runtime interfaces carry Agent identity at the point that owns it. `AgentSetup` receives `(agentCtx, agent)`; Agent creation and resume options carry `parentAgent` for a runtime child; scoped events carry their Agent in the payload; Remote forwarding verifies that `request.agent` is the carrier key; and Host Typert Context resolution maps wire identity to a live Agent Context without a reverse scan. `agent.ctx` remains the registration and lifecycle owner and exposes no reverse Agent property. +Runtime interfaces carry Agent identity at the point that owns it. `AgentSetup` receives `(agentCtx, agent)`; each subagent Activation retains its exact runtime root; scoped events carry their Agent in the payload; Remote forwarding verifies that `request.agent` is the carrier key; and Host Typert Context resolution maps wire identity to a live Agent Context without a reverse scan. `agent.ctx` remains the registration and lifecycle owner and exposes no reverse Agent property. -Scope-aware registries continue to use the opaque scope key only for registration membership. Tool-subagent does not classify that key or resolve an Agent from Context. A direct `AgentSetup` passes the unpublished Session explicitly and installs through the supplied Context before publication. For a settings-backed standing preset, the event payload supplies the Agent, its Session supplies the policy target, and its Context owns the registrations. +Scope-aware registries continue to use the opaque scope key only for registration membership. Tool-subagent does not classify that key or resolve an Agent from Context. A direct `AgentSetup` receives the Agent explicitly and installs through the supplied Context before Agent publication. For a settings-backed standing preset, the event payload supplies the Agent, its Session supplies the policy target, and its Context owns the registrations. -`SubagentManager` puts the exact parent in both fresh-creation and cold-resume options. A live continuable child is therefore excluded from `AgentRegistry.roots()` and satisfies `isOwnedBy(child.id, parent)`. Durable `parentSession` metadata does not substitute for this relation: a fork or resumed Session may be a runtime root when no live Agent owns it. +`SubagentManager.rootOf(agent)` reads runtime membership from existing Activations; both the child and root must match their live registry objects. Ordinary Sessions resolve to themselves, and unmanaged subagent headers grant no membership. Durable `parentSession` describes creation edges; an Activation retains the exact root while intermediate creators are offline, and same-id replacements cannot inherit it. Root-only consumers classify the explicit subagent origin. The [scope reference](../../../../packages/core/scope/README.md) and [Agent loop reference](../../../../packages/core/agent-loop/README.md) document registration and lifecycle behavior. The [initiator-scope decision](2026-07-15-agent-initiator-scope.md) owns private-chain rationale; explicit runtime ownership does not change causal initiation or registration lifetime. ## Verification -Agent creation tests pin explicit root and child ownership. Continuation integration tests keep a real child live long enough to assert both `roots()` exclusion and `isOwnedBy()` membership. Existing Schedule tests verify that root-only registrations stay absent from an explicitly owned child. +Managed-child tests verify intermediate-creator disposal and exact-instance checks after child or root replacement. SDK approval tests verify routing during a turn after the direct creator exits. Schedule and user-question tests reject delegated Sessions while ordinary historical forks remain independent. -Remote-event tests reject a missing or mismatched Agent before forwarding a scoped waterfall. Tool-subagent tests verify that direct setup installs before Session publication; standing-preset tests verify per-Session policy sampling and inheritance. +Remote-event tests reject a missing or mismatched Agent before forwarding a scoped waterfall. Tool-subagent tests verify that direct setup installs before Agent publication; standing-preset tests verify per-Session policy sampling and inheritance. ## Alternatives considered **Keep `Context.agent`.** A reverse accessor makes registration ownership look like operation identity and requires every Context derivation, adapter, and test double to preserve an association unrelated to Cordis service selection or effect cleanup. -**Infer runtime ownership from the caller Context.** A private manager Context, an Agent Context, and a standing preset Context can all call the same factory. Context ancestry therefore does not state which live Agent owns the result; the creator must put the parent it already knows in the request options. +**Infer runtime ownership from the caller Context.** A private manager Context, an Agent Context, and a standing preset Context can all call the same factory. Context ancestry therefore does not state which live Agent owns the result; the subagent Activation retains the exact root supplied at admission. **Classify Agent scope keys.** An opaque scope key states routing membership, not domain identity. Classifying it would make Agent the center of composition and would still couple a plugin's effect owner to the Session whose policy it needs. **Use the initiating Agent as creation ownership.** Initiator scope records causal asynchronous execution, not lifetime ownership. A parent may initiate work that intentionally creates a root, and setup remains outside the child's driver boundary. -**Use durable Session lineage.** `parentSession` records conversation ancestry across process lifetimes. Runtime ownership controls live roots and teardown, so equating the two would prevent a legitimately resumed fork from becoming a top-level Agent. +**Use durable Session lineage.** `parentSession` records creation ancestry across process lifetimes. An intermediate creator can be offline while its descendant remains managed by a live root; durable ids also cannot distinguish replaced Agent instances. Historical ordinary forks may retain parent metadata without being delegated. ## Consequences Lifecycle options, events, service requests, and transport requests carry explicit Agent identities, so each operation states the identity it uses and TypeScript checks both sides. Context remains reusable for dependency access and effect ownership without becoming an alternate domain-object locator. -Continuable children have the same runtime parent relation as one-shot in-process children. Root-only consumers exclude them, parent teardown can reason from one live ownership graph, and durable lineage remains free to describe history rather than process-local lifetime. +Continuable children have one runtime root independent of creator residency. The subagent manager owns tree teardown and local membership; the core factory and registry retain only instance creation, publication, and disposal. Third-party callers create managed children through the subagent service rather than assigning ownership to arbitrary factory-created Agents. diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md index 3d4131af01..063f0490bb 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md @@ -14,34 +14,34 @@ Agent 的 Cordis Context 拥有注册及其清理。Agent 身份则为某项操 ## 决策 -运行时接口在拥有身份的位置携带 Agent 身份。`AgentSetup` 接收 `(agentCtx, agent)`;创建与恢复 Agent 的 options 通过 `parentAgent` 标识运行时子级;作用域事件在 payload 中携带 Agent;Remote 转发校验 `request.agent` 就是 carrier key;Host Typert Context 解析则把协议身份映射到存活 Agent Context,不执行反向扫描。`agent.ctx` 继续拥有注册和生命周期,不暴露反向 Agent 属性。 +运行时接口在拥有身份的位置携带 Agent 身份。`AgentSetup` 接收 `(agentCtx, agent)`;每个 subagent Activation 保留其确切运行时树根;作用域事件在 payload 中携带 Agent;Remote 转发校验 `request.agent` 就是 carrier key;Host Typert Context 解析则把协议身份映射到存活 Agent Context,不执行反向扫描。`agent.ctx` 继续拥有注册和生命周期,不暴露反向 Agent 属性。 -感知作用域的注册表继续仅使用不透明作用域键判断注册成员关系。tool-subagent 不会分类该键,也不会从 Context 解析 Agent。直接 `AgentSetup` 显式传入尚未发布的 Session,并在发布前通过所给 Context 完成安装。对于由设置控制的常驻 preset,事件 payload 提供 Agent,其 Session 提供策略目标,其 Context 拥有注册项。 +感知作用域的注册表继续仅使用不透明作用域键判断注册成员关系。tool-subagent 不会分类该键,也不会从 Context 解析 Agent。直接 `AgentSetup` 显式接收 Agent,并在 Agent 发布前通过所给 Context 完成安装。对于由设置控制的常驻 preset,事件 payload 提供 Agent,其 Session 提供策略目标,其 Context 拥有注册项。 -`SubagentManager` 会把确切父级放进全新创建与冷恢复的 options。因此,存活的可续跑子级不会出现在 `AgentRegistry.roots()` 中,并且满足 `isOwnedBy(child.id, parent)`。持久化 `parentSession` 元数据不能代替这项关系:没有存活 Agent 拥有 fork 或已恢复会话时,它仍可成为 runtime root。 +`SubagentManager.rootOf(agent)` 从已有 Activation 读取运行时成员关系;子级和树根都必须匹配注册表中的存活对象。普通 Session 返回自身,非托管的 subagent header 不授予成员身份。持久化 `parentSession` 描述创建关系;中间创建者离线时,Activation 保留确切树根,同 id 替代实例不能继承它。仅限根级的消费方依据显式 subagent 来源分类。 [作用域参考](../../../../packages/core/scope/README.zh.md)与 [Agent loop 参考](../../../../packages/core/agent-loop/README.zh.md)说明注册和生命周期行为。[发起方作用域决策](2026-07-15-agent-initiator-scope.zh.md)负责私有调用链理由;显式运行时归属不改变因果发起关系或注册生命周期。 ## 验证 -Agent 创建测试锁定显式的根级与子级归属。continuation 集成测试让一个真实子级保持存活,直到断言其既不属于 `roots()`、又满足 `isOwnedBy()`。现有 Schedule 测试验证仅限根级的注册项不会出现在显式归属的子级中。 +托管子级测试验证中间创建者释放,以及子级或树根替换后的确切实例检查。SDK 审批测试验证直接创建者退出后的轮次内路由。Schedule 与用户问题测试拒绝委派 Session,历史普通 fork 则保持独立。 -Remote 事件测试会在转发作用域 waterfall 前拒绝缺失或不匹配的 Agent。tool-subagent 测试验证 direct setup 会在 Session 发布前完成安装;常驻 preset 测试验证逐 Session 的策略读取与继承。 +Remote 事件测试会在转发作用域 waterfall 前拒绝缺失或不匹配的 Agent。tool-subagent 测试验证 direct setup 会在 Agent 发布前完成安装;常驻 preset 测试验证逐 Session 的策略读取与继承。 ## 考虑过的替代方案 **保留 `Context.agent`。** 反向 accessor 会让注册所有权看起来等同于操作身份,还要求每个 Context 派生、适配器和测试替身保留一项与 Cordis 服务选择或 effect 清理无关的关联。 -**从调用方 Context 推断运行时归属。** 私有管理器 Context、Agent Context 和常驻 preset Context 都能调用同一个工厂。因此,Context 祖先关系无法说明由哪个存活 Agent 拥有结果;创建方必须把它已知的父级放进请求 options。 +**从调用方 Context 推断运行时归属。** 私有管理器 Context、Agent Context 和常驻 preset Context 都能调用同一个工厂。因此,Context 祖先关系无法说明由哪个存活 Agent 拥有结果;subagent Activation 保留准入时传入的确切树根。 **分类 Agent 作用域键。** 不透明作用域键表达路由成员关系,而不是领域身份。分类该键会让 Agent 成为组合中心,也仍会把插件的 effect 所有者与策略所需的 Session 耦合起来。 **使用发起 Agent 作为创建归属。** 发起方作用域记录异步执行的因果关系,而非生命周期归属。父级可能发起有意创建根级 Agent 的工作,而 setup 仍位于子级驱动边界之外。 -**使用持久化会话谱系。** `parentSession` 跨进程生命周期记录对话祖先关系。运行时归属控制存活根级和 teardown,因此把二者等同会阻止合法恢复的 fork 成为顶层 Agent。 +**使用持久化会话谱系。** `parentSession` 跨进程生命周期记录创建祖先关系。中间创建者可以离线,而后代仍由存活树根管理;持久 id 也无法区分替换后的 Agent 实例。历史普通 fork 可能保留父级元数据,但并不属于委派。 ## 后果 生命周期 options、事件、服务请求和传输请求会携带显式 Agent 身份,因此每项操作都会声明自身使用的身份,TypeScript 也会检查两侧。Context 可以继续复用于依赖访问与 effect 所有权,而不会成为另一种领域对象定位器。 -可续跑子级与一次性进程内子级使用同一种运行时父级关系。仅限根级的消费方会排除这些子级,父级 teardown 可以依据唯一的存活归属图推理,而持久化谱系仍可描述历史,不必承担进程内生命周期语义。 +可续跑子级拥有独立于创建者驻留状态的唯一运行时树根。subagent 管理器拥有整树 teardown 和本地成员关系;核心工厂与注册表仅保留实例创建、发布和释放。第三方调用方通过 subagent 服务创建托管子级,而非给任意工厂创建的 Agent 指定归属。 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-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-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-09-cua-shared-native-helper.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.i18n.yaml index dc596b4fae..5494e130cf 100644 --- a/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.i18n.yaml @@ -9,8 +9,8 @@ en: 386864a282e5ad58 zh: a4ea75ec70eb7778 /agent-note-shared-native-cua-process-with-agent-scoped-state/decision: - en: 11bab6870c3d802f - zh: a22114f2217a3a41 + en: 60ec2276ed0bff33 + zh: 5fb48cc787194254 /agent-note-shared-native-cua-process-with-agent-scoped-state/alternatives-considered: en: 883f71387f599160 zh: 4b5f1450be799397 diff --git a/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.md b/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.md index 8e986aaa2f..ff06f52dc1 100644 --- a/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.md +++ b/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.md @@ -12,7 +12,7 @@ On macOS 26.6.2, replayd derives capture-client identity from the executable pat One user shares a native service per executable content digest across Agents and Hosts. A version lock elects the service; an additional canonical-executable-path lock prevents different builds published at one path from running together. Locks remain held until process exit. The managed startup command hands service lifetime to its client connections, and the last connection's completed cleanup ends the service. The diagnostic stdin/stdout entry proxies to this same service. -The service runs as a signed application launched through LaunchServices. Directly spawning the executable inherits its launcher’s TCC responsibility, so it cannot give the helper independent system permissions. A launch lock serializes discovery and startup. Complete signed applications use immutable content-addressed directories: replacing an executable used by a live process would violate both path-lock ownership and code-signature integrity. Ad-hoc builds can require fresh grants when their code hash changes; a stable bundle identifier alone does not preserve those grants. Installed packages retain their distributed signatures. +The service runs as a signed application launched through LaunchServices. Directly spawning the executable inherits its launcher’s TCC responsibility, so it cannot give the helper independent system permissions. A launch lock serializes discovery and startup. Complete signed applications use immutable content-addressed directories: replacing an executable used by a live process would violate both path-lock ownership and code-signature integrity. Ad-hoc builds can require fresh grants when their code hash changes; a stable bundle identifier alone does not preserve those grants. Source checkouts can keep grants with the [development signing identity](2026-10-29-cua-dev-signing-identity.md). Installed packages retain their distributed signatures. Each Agent connection owns its application handles, AX snapshots, limits and cancellation state. An app handle grants no exclusive use of the application. A FIFO orders complete desktop operations, including clipboard transactions, while response writes are independent. Agent disposal retires only its connection. The [object API decision](2026-09-17-experimental-cua-object-api.md) retains evaluator independence and coordinate semantics; the [consent decision](2026-09-26-cua-application-consent.md) retains application authorization. diff --git a/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.zh.md b/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.zh.md index 371ae5cc3a..2fa2f9b07f 100644 --- a/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.zh.md @@ -12,7 +12,7 @@ Status: implemented 同一用户的多个 Agent 和 Host 按可执行文件内容摘要共享原生服务。版本锁选出服务实例,规范化可执行路径上的另一把锁阻止发布在同一路径的不同构建同时运行。进程退出才释放锁。受管启动命令将服务生命期交给客户端连接,最后一条连接清理完成后服务退出。诊断用 stdin/stdout 入口也代理到同一个服务。 -服务通过 LaunchServices 以已签名应用运行。直接启动可执行文件会继承启动器的 TCC 责任,无法让 helper 独立取得系统权限。启动锁串行处理发现与启动。完整签名应用保存在不可变的内容寻址目录中,替换运行中进程的可执行文件会破坏路径锁的归属与代码签名完整性。临时签名构建的代码哈希变化后可能需要重新授权,固定 Bundle ID 不能保证保留授权。安装版保留分发时的签名。 +服务通过 LaunchServices 以已签名应用运行。直接启动可执行文件会继承启动器的 TCC 责任,无法让 helper 独立取得系统权限。启动锁串行处理发现与启动。完整签名应用保存在不可变的内容寻址目录中,替换运行中进程的可执行文件会破坏路径锁的归属与代码签名完整性。临时签名构建的代码哈希变化后可能需要重新授权,固定 Bundle ID 不能保证保留授权。源码检出可以用[开发签名身份](2026-10-29-cua-dev-signing-identity.zh.md)保留授权。安装版保留分发时的签名。 每条 Agent 连接独立持有应用句柄、AX 快照、资源限制和取消状态。持有 app 句柄不代表独占应用。FIFO 队列串行执行包含剪贴板事务的完整桌面操作,响应写入独立进行。Agent 销毁只释放自己的连接。[对象接口决策](2026-09-17-experimental-cua-object-api.zh.md)继续负责执行器独立性及坐标语义,[授权决策](2026-09-26-cua-application-consent.zh.md)继续负责应用许可。 diff --git a/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.i18n.yaml index 65205260c3..f1c602359c 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.i18n.yaml @@ -2,18 +2,18 @@ # 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-13-team-identity-runtime-context.md -/agent-note-team-identity-in-required-runtime-context: - en: 8c88759320ae8481 - zh: 4068b18723907eb9 -/agent-note-team-identity-in-required-runtime-context/problem: - en: c9a81cb731ddf7b2 - zh: 428c0e6f8937d7c5 -/agent-note-team-identity-in-required-runtime-context/decision: - en: 6c73479973c1e378 - zh: 98677c77f77f8d81 -/agent-note-team-identity-in-required-runtime-context/alternatives-considered: +/agent-note-subagent-identity-in-required-runtime-context: + en: ce8f378b6e7b57fa + zh: 26bd0415b4dba94a +/agent-note-subagent-identity-in-required-runtime-context/problem: + en: f944254565541454 + zh: 27fa9f67c671dec7 +/agent-note-subagent-identity-in-required-runtime-context/decision: + en: 85100936f97a139c + zh: 614081943944e28b +/agent-note-subagent-identity-in-required-runtime-context/alternatives-considered: en: 774566ffbf9e9db8 zh: ea761d876af462f2 -/agent-note-team-identity-in-required-runtime-context/consequences: - en: 18d1aa67936f1227 - zh: 7e06c1f51553ecdd +/agent-note-subagent-identity-in-required-runtime-context/consequences: + en: 2c01a24cd8cc4556 + zh: 37b5d1ca1173318a diff --git a/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.md b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.md index 39794f02d3..fb8e92e811 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.md +++ b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.md @@ -1,4 +1,4 @@ -# Agent Note: Team identity in required runtime context +# Agent Note: Subagent identity in required runtime context Status: implemented @@ -6,15 +6,15 @@ English | [中文](2026-10-13-team-identity-runtime-context.zh.md) ## Problem -A teammate identity embedded in its initial task depends on ordinary history retention. Compaction can remove it, and an ordinary fork from that teammate can retain the old role even though the fork is an independent Lead. Identity must reflect current membership while keeping shared system instructions uniform. +A teammate identity embedded in its initial task depends on ordinary history retention. Compaction can remove it, and an ordinary fork from that teammate can retain the old role even though the fork is an independent Lead. Identity must reflect the current subagent tree while keeping shared system instructions uniform. ## Decision -`tool-agent-team` contributes `team:identity` through the Agent-scoped `systemPrompt.context()` registry with `required: true`. Its provider reads the existing Team membership and renders the member's role, name, and addressing instructions. Optional-context suppression cannot remove it. Fixed collaboration policy stays in the system prompt; roster and task state remain tool queries. Initial tasks contain only their supplied content. +`tool-subagent-control` contributes required `subagent:identity` through `systemPrompt.context()` in ordinary subagent and Team compositions. It derives identity from `ctx.subagents.rootOf(agent)`: the root sees its own Session id, and descendants see their own, direct parent, and root ids. An unavailable root is reported explicitly. Optional-context suppression and Team mode do not remove identity. Agents receive messaging and discovery guidance only while the root is available and each corresponding standard tool is admitted. Team task policy stays in the shared system prompt; task state remains a tool query. The loop owns logging, retained-snapshot comparison, and re-admission after compaction. A changed role appends the current full runtime-context snapshot after inherited history; unchanged retained context adds no message. No Team-specific retention state, event type, or log migration is introduced. Existing recorded reminders remain historical messages. -This supersedes only the initial-task identity decision in [Agent Teams](../feature/2026-08-05-agent-teams.md#team-identity); its roster, mailbox, and task decisions remain active. +This supersedes only the initial-task identity decision in [Agent Teams](../feature/2026-08-05-agent-teams.md#team-identity). [Task-board wait coordination](2026-10-19-team-task-board-inbox.md) keeps ordinary inbox waiting independent of Team-only model guidance. ## Alternatives considered @@ -28,4 +28,4 @@ This supersedes only the initial-task identity decision in [Agent Teams](../feat Members pay for identity within the full runtime-context snapshot. Other context changes repeat it, while stable retained context adds no new message. Forks preserve inherited history and append current identity; actual provider cache hits remain best-effort. Existing Team authorization remains in tool execution. -Verification covers fresh and fork teammates, an independent fork from a teammate, optional-context suppression, compaction restoration, cold resume, and registration disposal. The recorded `team-targets` scenario pins Lead context; the keyless headless Team workflow pins teammate context through the shipped profile. +Verification covers fresh and fork children, an independent Session fork, optional-context suppression, compaction restoration, unavailable roots, cold resume, and registration disposal. Recorded ordinary subagent and Team workflows pin identity through their shipped compositions; delegated tasks retain final-result return guidance. diff --git a/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.zh.md b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.zh.md index d0e333a71e..f3ebb3e6b3 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 必需 runtime context 中的 Team 身份 +# Agent Note: 必需 runtime context 中的 subagent 身份 Status: implemented @@ -6,15 +6,15 @@ Status: implemented ## Problem -嵌入初始任务的 teammate 身份依赖普通历史保留。压缩可能移除它;从 teammate 创建的普通 fork 即使已成为独立 Lead,也可能保留旧角色。身份必须反映当前 membership,同时保持共享系统指令一致。 +嵌入初始任务的 teammate 身份依赖普通历史保留。压缩可能移除它;从 teammate 创建的普通 fork 即使已成为独立 Lead,也可能保留旧角色。身份必须反映当前 subagent 树,同时保持共享系统指令一致。 ## Decision -`tool-agent-team` 通过 Agent 作用域的 `systemPrompt.context()` 注册 `team:identity`,并设置 `required: true`。提供方读取现有 Team membership,渲染成员角色、名字与寻址说明。关闭可选 context 不能移除它。固定协作策略保留在 system prompt;roster 和 task 状态仍通过工具查询。初始任务只包含传入的正文。 +`tool-subagent-control` 在普通 subagent 和 Team 组合中通过 `systemPrompt.context()` 提供必需的 `subagent:identity`。它从 `ctx.subagents.rootOf(agent)` 派生身份:根看到自身 Session id,后代看到自身、直接父级和根 id。根不可用时会明确报告。可选上下文抑制和 Team 模式都不会移除身份。只有根可用时,agent 才会分别根据对应标准工具的获准情况收到通信与发现指导。Team 任务策略保留在共享系统提示中;任务状态仍通过工具查询。 Loop 负责日志记录、保留快照比较和压缩后的重新接纳。角色变化会在继承历史之后追加当前完整 runtime-context 快照;保留的 context 未变化时不追加消息。不引入 Team 专属的保留状态、事件类型或日志迁移。已有身份提醒仍作为历史消息保留。 -本决策仅取代 [Agent Teams](../feature/2026-08-05-agent-teams.zh.md#team-identity) 中的初始任务身份决策;其 roster、mailbox 和 task 决策仍然有效。 +这仅取代 [Agent Teams](../feature/2026-08-05-agent-teams.zh.md#team-identity) 中将身份放入初始任务的决策。[任务板等待协调](2026-10-19-team-task-board-inbox.zh.md)使普通收件箱等待独立于 Team 专用模型指导。 ## Alternatives considered @@ -28,4 +28,4 @@ Loop 负责日志记录、保留快照比较和压缩后的重新接纳。角色 成员承担完整 runtime-context 快照中的身份 token 成本。其他 context 变化会重复身份,保留的 context 稳定时则不追加消息。Fork 保留继承历史并追加当前身份;提供方实际缓存命中仍为尽力而为。现有 Team 授权仍在工具执行时落实。 -验证覆盖 fresh 和 fork teammate、从 teammate 创建的独立 fork、关闭可选 context、压缩后恢复、冷恢复和注册释放。录制的 `team-targets` 场景固定 Lead context;无密钥 headless Team 工作流通过内置 profile 固定 teammate context。 +验证覆盖新建和 fork 子级、独立 Session fork、可选上下文抑制、压缩恢复、不可用根、冷恢复和注册释放。录制的普通 subagent 和 Team 工作流通过各自的分发组合固定身份行为;委派任务保留最终结果返回指导。 diff --git a/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.i18n.yaml new file mode 100644 index 0000000000..282acab92a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-15-agent-teams-as-subagents.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-15-agent-teams-as-subagents.md +/agent-note-agent-teams-teammates-are-subagents: + en: 51e2fc51a6e1622a + zh: 7e09ea6292e5921d +/agent-note-agent-teams-teammates-are-subagents/problem: + en: ca2848e3bbc3a009 + zh: 0551e32ff6d187d0 +/agent-note-agent-teams-teammates-are-subagents/decision: + en: 2c29c42cc02a9a05 + zh: bc542e3a27cfd03d +/agent-note-agent-teams-teammates-are-subagents/alternatives-considered: + en: ef0cf648ff54784f + zh: 2a0b3745a54e7072 +/agent-note-agent-teams-teammates-are-subagents/consequences: + en: 3ff718671be076fa + zh: 38a367077ce95d52 +/agent-note-agent-teams-teammates-are-subagents/testing: + en: a0ec8546a783bfd9 + zh: 8920b46970e3a2de diff --git a/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.md b/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.md new file mode 100644 index 0000000000..8cab7023e0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.md @@ -0,0 +1,41 @@ +# Agent Note: Agent Teams teammates are subagents + +Status: implemented + +English | [中文](2026-10-15-agent-teams-as-subagents.zh.md) + +The adjacency and parent-lifetime restrictions are superseded by [tree-wide communication](2026-10-16-tree-wide-agent-communication.md). [Task-board inbox coordination](2026-10-19-team-task-board-inbox.md) supersedes the separate roster and Team-owned wait; the single subagent identity decision remains active. + +## Problem + +Teammates already use the subagent runtime for creation, Session identity, catalog membership, capacity, cold resume, settlement, and interruption. A separate Team roster duplicates identity, provisioning recovery, and teardown. Team-scoped messaging and control tools shadow the standard subagent tools, forcing profiles to disable ordinary delegation and making fork tool schemas diverge from their parent. + +## Decision + +Use one subagent identity: the Session id. The existing `description` tool argument supplies the catalog label and display text; descriptions may repeat and never address an agent. Creation accepts no member name, reserves no names, and adds no catalog payload version. + +[Tree-wide communication](2026-10-16-tree-wide-agent-communication.md) authorizes an exact live sender within one root tree and preserves the actual sender’s id. Interruption retains ancestor authority and id addressing. + +Every root Agent and its descendants form the Team identified by the root Session id. Team creates no agents and contributes task tools and task policy. Ordinary subagent controls supply required identity, discovery, messaging, interruption, and inbox waiting. Parent and fork children receive identical tool schemas and shared policies. + +Task ownership uses `ownerId`; root reassignment accepts itself or any reachable continuable descendant. Historical roster records remain decode-only. Team persists task changes without ending inbox waits or starting idle turns; [inbox-only waiting](../simplification/2026-10-29-inbox-only-agent-wait.md) owns wake behavior. Historical `team/message/*` records remain readable but are never delivered or acknowledged. + +This supersedes the roster, provisioning, recovery, and messaging decisions in [Agent Teams](../feature/2026-08-05-agent-teams.md) and the [Team direct inbox decision](../../archived/simplification/2026-09-26-team-direct-inbox.md). The task-board and shared-checkout decisions remain active. [Adjacent Agent messaging](2026-08-27-adjacent-agent-steer-messaging.md) includes sibling routing, while [required runtime-context identity](2026-10-13-team-identity-runtime-context.md) owns the role statement. The [upgrade guide](../../../../docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.md) lists removed tools, methods, and configuration. + +## Alternatives considered + +**Separate Team authority and tools.** This retains a second roster and recovery lifecycle for Agents already owned by the subagent runtime, plus same-name tools and a private delivery adapter. The shared parent relation supplies the required authority. + +**Unique names as sibling-routing opt-in.** Names add validation, reservation, persistence, and ambiguous resolution beside existing Session ids. Descriptions already explain responsibilities; stable ids distinguish repeated descriptions without another identity system. + +**Pluggable address books.** A registry would support groups spanning unrelated parents but add another authorization input. No current consumer needs this; revisit when collaboration must cross parent trees. + +## Consequences + +Tree members discover and message one another through ordinary controls. Team retains task ownership, dependencies, and task policy. Subagent capacity and lifecycle apply; failed starts create no Team roster row. Existing durable task generations remain unchanged. + +Descendants at any depth share the board and can be task owners. Historical members grant no authority. Separate working directories remain explicit caller choices; cross-process task coordination is unsupported. + +## Testing + +Verification covers tree delivery and cold resume with actual sender attribution, task CAS and owner discovery, historical decoding, inbox wait isolation, and registration disposal. Recorded sessions cover Team policy and identity, plus ordinary subagent return guidance. diff --git a/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.zh.md b/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.zh.md new file mode 100644 index 0000000000..8c113d288a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.zh.md @@ -0,0 +1,41 @@ +# Agent Note: Agent Teams 队友是子代理 + +Status: implemented + +[English](2026-10-15-agent-teams-as-subagents.md) | 中文 + +邻接和父级生命周期限制由[整树通信](2026-10-16-tree-wide-agent-communication.zh.md)取代。[任务板收件箱协调](2026-10-19-team-task-board-inbox.zh.md)取代独立名册和 Team 所有的等待;单一 subagent 身份决策仍有效。 + +## Problem + +队友已经使用子代理运行时完成创建、Session 身份、目录成员关系、容量、冷恢复、结算和中断。独立 Team 成员列表重复了身份、创建恢复和释放职责。Team 作用域内的消息及控制工具覆盖标准子代理工具,迫使 profile 禁用普通委派,并使分叉工具模式偏离父级。 + +## Decision + +统一使用子代理的 Session id 作为身份。现有 `description` 工具参数提供目录标签和展示文本;描述可以重复,不用于寻址代理。创建不接收成员名称、不预留名称,也不增加目录载荷版本。 + +[整树通信](2026-10-16-tree-wide-agent-communication.zh.md)在同一根树内授权当前在线发送方,并保留实际发送方 id。中断保留祖先权限和 id 寻址。 + +每个根 Agent 及其后代组成以根 Session id 标识的 Team。Team 不创建 agent,提供任务工具和任务策略。普通 subagent 控制工具提供必需身份、发现、通信、中断和收件箱等待。父级和 fork 子级接收相同工具 schema 和共享策略。 + +任务归属使用 `ownerId`;根重新分配可指定自身或任意可达的可继续执行后代。历史名册记录仅供解码。Team 持久化任务变更,不结束收件箱等待,也不启动空闲轮次;[仅等待收件箱](../simplification/2026-10-29-inbox-only-agent-wait.zh.md)负责唤醒行为。历史 `team/message/*` 记录仍可读取,但绝不再投递或确认。 + +本决策取代 [Agent Teams](../feature/2026-08-05-agent-teams.zh.md) 的成员列表、创建、恢复和消息决策,以及 [Team 直接收件箱决策](../../archived/simplification/2026-09-26-team-direct-inbox.md)。任务板和共享工作树决策仍有效。[相邻 Agent 消息](2026-08-27-adjacent-agent-steer-messaging.zh.md)纳入同级路由,[必需运行时身份](2026-10-13-team-identity-runtime-context.zh.md)负责角色声明。[升级指南](../../../../docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.zh.md)列出移除的工具、方法和配置。 + +## Alternatives considered + +**独立 Team 权限和工具。** 这会为子代理运行时已拥有的 Agent 保留第二套成员列表与恢复生命周期,以及同名工具和私有投递适配器。共同父级关系已经提供所需权限。 + +**以唯一名称选择性启用同级路由。** 名称会在现有 Session id 之外增加校验、预留、持久化和歧义解析。描述已能说明职责;稳定 id 可以区分重复描述,无需另一套身份系统。 + +**可插拔地址簿。** 注册表可支持跨不同父级的组,但会增加另一种授权输入。当前没有消费者需要此能力;协作必须跨父级树时再考虑。 + +## Consequences + +树内成员通过普通控制工具发现和联系彼此。Team 保留任务归属、依赖和任务策略。容量和生命周期遵循 subagent 规则;启动失败不会创建 Team 名册行。现有持久化任务代次保持不变。 + +任意深度后代共享任务板,均可成为任务所有者。历史成员不授予权限。独立工作目录仍由调用者显式选择;不支持跨进程任务协调。 + +## Testing + +验证覆盖具有实际发送方归属的整树投递和冷恢复、任务 CAS 与所有者发现、历史解码、收件箱等待隔离和注册释放。录制会话覆盖 Team 策略和身份,以及普通 subagent 返回指导。 diff --git a/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.i18n.yaml new file mode 100644 index 0000000000..ee52c30615 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-16-tree-wide-agent-communication.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-16-tree-wide-agent-communication.md +/agent-note-communication-follows-the-subagent-tree: + en: 849d806b901e9f04 + zh: 649b2984f844d510 +/agent-note-communication-follows-the-subagent-tree/problem: + en: c583c3cdb894e417 + zh: 6edc0354b79f4f26 +/agent-note-communication-follows-the-subagent-tree/decision: + en: da35a75e069fa1e4 + zh: 7edf62ad66ab04ec +/agent-note-communication-follows-the-subagent-tree/alternatives-considered: + en: 470f9cf8c3e32af9 + zh: 73bbe29767c5d02a +/agent-note-communication-follows-the-subagent-tree/consequences: + en: 6d409833d762d473 + zh: 000cd93577ec6b34 diff --git a/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.md b/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.md new file mode 100644 index 0000000000..30dee6781a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.md @@ -0,0 +1,29 @@ +# Agent Note: Communication follows the subagent tree + +Status: implemented + +English | [中文](2026-10-16-tree-wide-agent-communication.zh.md) + +## Problem + +A Team shares one task board across the root's entire subagent tree. Restricting discovery and messages to adjacent relatives forces deeper members to relay work through parents despite sharing tasks. + +## Decision + +`list_agents` starts at the caller's live root and lists that root and all reachable continuable descendants, including the caller. Parent ids and depth remain relative to the root. The existing parent catalogs remain authoritative; no Team directory is stored separately. + +`send_message` accepts another root or continuable member of the same tree. The sender must be the exact live root or a resident continuable subagent. Cross-branch delivery verifies the target descriptor and creation catalogs, then restores only the target under the Team root. Final inbox admission rechecks the sender and root. Cancellation or rejection before acceptance releases the new target instance; accepted input follows normal Agent persistence. + +The existing Agent registry records runtime ownership by the Team root. Creation parent ids and catalogs preserve ancestry for discovery and interrupt authority. A member finishing or being released affects only its own instance; Team-root shutdown joins all startup and cleanup. This removes ancestor restoration, parent retention, and recursive disposal without adding a Team runtime registry. Saved target presets and permission events supply restoration configuration. + +This supersedes the adjacency, discovery, and parent-lifetime restrictions in [adjacent messaging](2026-08-27-adjacent-agent-steer-messaging.md) and [subagent teammates](2026-10-15-agent-teams-as-subagents.md). Their shared inbox, scheduling, attribution, and Team reuse decisions remain active. Members use Session ids for addressing; ordinary Session forks lead independent Teams. + +## Alternatives considered + +**Relay through parents.** This preserves the narrower routing implementation but makes the creation hierarchy determine who can collaborate. Shared work requires direct communication across branches. + +**Separate Team directory and delivery.** A second member list and message path would duplicate the existing catalogs, inbox, and restoration lifecycle. Tree membership supplies the required communication scope without another registry. + +## Consequences + +Discovery output grows with the whole tree. Cold delivery requires one capacity slot and readable creation history. Intermediate creators may be offline. Completion notices reach only online creators, so work that requires descendant results must explicitly wait. Listing is a snapshot and does not promise that a later send succeeds. External and one-shot executions cannot receive follow-ups. diff --git a/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.zh.md b/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.zh.md new file mode 100644 index 0000000000..58d075e9a6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 通信范围由子代理树决定 + +Status: implemented + +[English](2026-10-16-tree-wide-agent-communication.md) | 中文 + +## Problem + +一个 Team 在根的整棵子代理树中共享任务板。把发现和消息限定在相邻成员之间,会迫使深层成员在共享任务的同时,仍通过父级转发工作。 + +## Decision + +`list_agents` 从调用方的在线根开始,列出该根与所有可达的可继续后代,包括调用方。父级 id 和深度始终相对根计算。现有父级目录仍是权威来源;不单独存储 Team 目录。 + +`send_message` 接受同一棵树中的其他根或可继续成员。发送者必须是确切在线根或驻留的可继续子代理。跨分支投递验证目标描述符和创建目录后,仅在 Team 根下恢复目标。最终收件箱准入重新检查发送者与根。接受前的取消或拒绝释放新建目标实例;已接受输入遵循普通 Agent 持久化。 + +现有 Agent 注册表记录 Team 根对运行时实例的所有权。创建父级 id 和目录保留用于发现及中断授权的祖先关系。成员完成或释放只影响自身实例;关闭 Team 根会等待全部启动和清理。这删除了祖先恢复、父级保留和递归释放,无需增加 Team 运行时注册表。目标保存的 preset 和权限事件提供恢复配置。 + +本决策替代[相邻消息](2026-08-27-adjacent-agent-steer-messaging.zh.md)与[子代理队友](2026-10-15-agent-teams-as-subagents.zh.md)中的邻接、发现及父子生命周期限制。它们的共享收件箱、调度、发送者归属和 Team 复用决策仍有效。成员使用 Session id 寻址;普通 Session 分叉领导独立 Team。 + +## Alternatives considered + +**通过父级转发。** 这保留了更窄的路由实现,却让创建层级决定谁能协作。共享工作需要跨分支直接通信。 + +**单独的 Team 目录与投递。** 第二份成员列表和消息路径会重复现有目录、收件箱与恢复生命周期。树成员身份已经提供所需通信范围,无需另一个注册表。 + +## Consequences + +发现输出随整棵树增长。冷投递需要一个容量名额和可读的创建历史。中间创建者可以离线。完成通知仅投递给在线创建者,因此依赖后代结果的工作必须显式等待。列表是快照,不保证后续发送成功。外部执行和一次性执行不能接收后续消息。 diff --git a/.agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.i18n.yaml new file mode 100644 index 0000000000..aa751903bf --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-19-team-task-board-inbox.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-19-team-task-board-inbox.md +/agent-note-team-task-boards-share-ordinary-subagent-waits: + en: 7f19734715f5d50f + zh: 95af09c9d855d1af +/agent-note-team-task-boards-share-ordinary-subagent-waits/problem: + en: 6fdc766a348d8659 + zh: 8a84748e147deed5 +/agent-note-team-task-boards-share-ordinary-subagent-waits/decision: + en: 154da8a8a5146c89 + zh: 76a7c921153d238d +/agent-note-team-task-boards-share-ordinary-subagent-waits/alternatives-considered: + en: eed6f6d4fa0ccd7b + zh: 61271057d6e5d019 +/agent-note-team-task-boards-share-ordinary-subagent-waits/consequences: + en: cc250eb21dbb45a1 + zh: 9d03d1eb53075fb6 diff --git a/.agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.md b/.agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.md new file mode 100644 index 0000000000..818298684c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.md @@ -0,0 +1,31 @@ +# Agent Note: Team task boards share ordinary subagent waits + +Status: implemented + +English | [中文](2026-10-19-team-task-board-inbox.zh.md) + +## Problem + +The subagent tree already supplies identity, discovery, routing, and lifecycle. A second Team roster can grant assignment authority from stale history and duplicates member navigation. Team-wide status waits observe unrelated activity and miss input already pending for the caller. Persisting task-board wakeups as messages adds unread state and message schemas even though the task journal already owns the state to inspect. + +## Decision + +The subagent tree is the Team. Ordinary controls own required identity, communication, discovery, interruption, and `wait_agent`, independently of the optional task board. Team tools own task policy, without adding it to ordinary subagent compositions. Team owns task storage, dependencies, and compare-and-set commands. Assignment accepts the root or any discovered continuable descendant. Historical roster records grant no authority; the Team panel shows tasks and ordinary navigation owns members. + +The wait-signal decision is superseded by [inbox-only Agent waits](../simplification/2026-10-29-inbox-only-agent-wait.md). Tree ownership, task durability, and historical non-authority remain unchanged. + +Task state remains in the existing journal. Ordinary tool results record what the waiting model observes. Historical roster, mailbox, and task generations remain readable and unchanged. This partially supersedes the roster and wait decisions in [Agent Teams](../feature/2026-08-05-agent-teams.md) and [subagent teammates](2026-10-15-agent-teams-as-subagents.md). Their task durability and single-identity rationale remain active; [required runtime identity](2026-10-13-team-identity-runtime-context.md) owns retention. + +## Alternatives considered + +**Keep Team membership and a separate wait tool.** This duplicates tree authority and wait lifecycle, allowing stale roster rows or unrelated activity to affect current work. + +**Persist task-board notices in every inbox.** The task journal already preserves every revision. Durable notices add message types, unread state, coalescing, and delivery-failure recovery solely to interrupt a current wait. + +**Poll Agent status or start every idle task owner.** Activity does not identify useful input, and task readiness does not authorize a new turn. Explicit messages continue idle work when needed. + +## Consequences + +Task completion, inbox availability, current waiting, and Agent lifetime remain separate. Task updates do not end waits; callers inspect current tasks when deciding what to do. The task journal retains committed revisions without storing redundant notifications in recipient Sessions. + +Verification distinguishes pending inbox input from task commits, isolates callers, excludes next-turn input and status changes, and covers wait cleanup and task persistence. Assignment coverage retains arbitrary depth and historical non-authority. diff --git a/.agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.zh.md b/.agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.zh.md new file mode 100644 index 0000000000..3a88ce8d19 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.zh.md @@ -0,0 +1,31 @@ +# Agent Note: Team 任务板复用普通 subagent 等待 + +Status: implemented + +[English](2026-10-19-team-task-board-inbox.md) | 中文 + +## 问题 + +subagent 树已经提供身份、发现、路由和生命周期。第二份 Team 名册可能根据过期历史授予分配权限,并重复成员导航。Team 范围状态等待会观察无关活动,却漏掉调用者已有的待处理输入。任务日志已经拥有待查询状态,将任务板唤醒持久化为消息仍会增加未读状态和消息 schema。 + +## 决策 + +subagent 树就是 Team。普通控制工具独立于可选任务板,负责必需身份、通信、发现、中断和 `wait_agent`。Team 工具负责任务策略,不向普通 subagent 组合添加该策略。Team 负责任务存储、依赖和比较并交换命令。分配接受根或任意已发现的可继续执行后代。历史名册不授予权限;Team 面板显示任务,普通导航负责成员。 + +等待信号决策已由[仅等待收件箱的 Agent 等待](../simplification/2026-10-29-inbox-only-agent-wait.zh.md)取代。树归属、任务持久性和历史记录不授予权限的决策保持不变。 + +任务状态保留在现有日志中。普通工具结果记录等待中的模型所观察到的结果。历史名册、邮箱和任务代次保持可读且不变。这部分取代 [Agent Teams](../feature/2026-08-05-agent-teams.zh.md) 和 [subagent 队友](2026-10-15-agent-teams-as-subagents.zh.md) 中的名册与等待决策。其任务持久性和单一身份理由继续生效;[必需运行时身份](2026-10-13-team-identity-runtime-context.zh.md)负责保留规则。 + +## 考虑过的替代方案 + +**保留 Team 成员关系与独立等待工具。** 这会重复树权限和等待生命周期,让过期名册行或无关活动影响当前工作。 + +**将任务板通知持久化到每个收件箱。** 任务日志已保留每个修订。持久通知仅为打断当前等待,就增加消息类型、未读状态、合并和投递失败恢复。 + +**轮询 Agent 状态或启动每个空闲任务所有者。** 活动状态不能标识有用输入,任务就绪也不授权新轮次。需要时通过显式消息继续空闲成员的工作。 + +## 后果 + +任务完成、收件箱可用性、当前等待和 Agent 生命周期保持独立。任务更新不会结束等待;调用者在决定下一步工作时查询当前任务。任务日志保留已提交修订,不在接收者 Session 中存储重复通知。 + +验证区分已有收件箱输入和任务提交,隔离调用者,排除下一轮输入及状态变化,并覆盖等待清理和任务持久性。分配验证保留任意深度和历史记录无权限场景。 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 new file mode 100644 index 0000000000..056ad6e589 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-20-session-team-mode.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-20-session-team-mode.md +/agent-note-team-availability-is-selected-per-session: + en: 5f36d81bd414ea67 + zh: 71883b0fe2afd1b6 +/agent-note-team-availability-is-selected-per-session/problem: + en: db7a81ab452fdd0f + zh: a14fdcdb8ff4cd47 +/agent-note-team-availability-is-selected-per-session/decision: + en: d1174db5157b91ee + zh: ceb6dc5a89a0a8d6 +/agent-note-team-availability-is-selected-per-session/alternatives-considered: + 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 new file mode 100644 index 0000000000..63fb659556 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.md @@ -0,0 +1,33 @@ +# Agent Note: Team availability is selected per Session + +Status: implemented + +English | [中文](2026-10-20-session-team-mode.zh.md) + +## Problem + +A plugin-level Team switch requires users to change the shared profile before choosing collaboration for one conversation. It can disable another Session’s capability and leaves a second control beside the Session selection. Ordinary tasks still need their existing tool catalog, and disabling collaboration must not discard task history or children. + +## Decision + +The base bundle loads the Team service and task tools, and the Web bundle loads the Team UI. The packages belong to the subagent and client product groups. A saved root-Session selection gates task tools and Team guidance throughout the tree, and the shared board in the root conversation; new Sessions default off. Ordinary subagent creation, communication, and navigation remain available. + +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 + +Default profiles carry the Team implementation while exposing no task tools until enabled. The removed package names require custom compositions to migrate; durable event names and task data remain unchanged. [The upgrade guide](../../../../docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.md) owns those steps. + +Verification covers default startup, new-Session opt-in, independent roots, persisted selections, retained tasks, old profile migration, and absence of the plugin switch. Product-isolation checks retain an unrelated experimental plugin as their negative control. 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 new file mode 100644 index 0000000000..e5ef16a331 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 按 Session 选择 Team 能力 + +Status: implemented + +[English](2026-10-20-session-team-mode.md) | 中文 + +## Problem + +插件级 Team 开关要求用户先修改共享 profile,才能为一个会话选择协作。它可能关闭另一个 Session 的能力,并在会话选项之外留下第二个控件。普通任务仍需要原有工具目录,关闭协作也不能丢弃任务历史或子智能体。 + +## Decision + +基础 bundle 加载 Team 服务与任务工具,Web bundle 加载 Team UI。相关包归属 subagent 与 client 产品组。根 Session 保存的选择控制整棵树的任务工具与 Team 指引,以及根会话中的共享任务板;新 Session 默认关闭。普通 subagent 创建、通信与导航继续可用。 + +模型与推理等级菜单承载用户选择。根会话的 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 + +默认 profile 包含 Team 实现,但开启前不暴露任务工具。自定义组合必须迁移已移除的包名;持久事件名称与任务数据保持不变。[升级指南](../../../../docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.zh.md)负责具体步骤。 + +验证覆盖默认启动、新 Session 手动开启、独立根、选择持久化、任务保留、旧 profile 迁移和插件开关移除。产品隔离检查使用另一个无关实验性插件作为负对照。 diff --git a/.agents/notes/implemented/architecture/2026-10-22-subagent-target-user-controls.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-22-subagent-target-user-controls.i18n.yaml new file mode 100644 index 0000000000..82eae5a35d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-22-subagent-target-user-controls.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-22-subagent-target-user-controls.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-22-subagent-target-user-controls.md +/agent-note-user-controls-address-the-target-subagent: + en: d7dcf54ddb311881 + zh: 5f388447737c5414 +/agent-note-user-controls-address-the-target-subagent/problem: + en: 962c75771235903c + zh: 203b651c0830310e +/agent-note-user-controls-address-the-target-subagent/decision: + en: 13a2617e28445744 + zh: 75d193ef7d8c7804 +/agent-note-user-controls-address-the-target-subagent/alternatives-considered: + en: 447adb24280bf2bb + zh: 08d95a93b14aa5c7 +/agent-note-user-controls-address-the-target-subagent/consequences: + en: 98e784f55775247d + zh: 1c62d2ebc71b658f diff --git a/.agents/notes/implemented/architecture/2026-10-22-subagent-target-user-controls.md b/.agents/notes/implemented/architecture/2026-10-22-subagent-target-user-controls.md new file mode 100644 index 0000000000..064dbb1e17 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-22-subagent-target-user-controls.md @@ -0,0 +1,33 @@ +# Agent Note: User controls address the target subagent + +Status: implemented + +English | [中文](2026-10-22-subagent-target-user-controls.zh.md) + +## Problem + +A browser knows the subagent it is displaying, but requiring its direct creator and a continuable marker makes every control caller repeat history metadata. A caller-supplied parent id checks address consistency without establishing the caller's identity or permission. Calls that resolve only the global access resource also cannot express a grant on one target Session. + +## Decision + +Browser prompts and `interruptByUser` address only the target child Session. Each Remote method explicitly declares that Session as its access resource; the Gateway checks the invocation's principal before attachment admission, restoration, or cancellation. A global or parent Session grant does not imply a child Session grant. Policies decide whether to grant both actions, either action, or inherited access. Same-process user interruption trusts its caller and does not accept a wire-supplied principal. + +Prompt delivery derives the live root from the resident Activation or verified persisted creation edges. Only the cold target resumes; its creator can remain offline. Shared delivery retains exact live root checks, shutdown admission, cancellation, capacity, and final inbox acceptance. A root Session is not a target of the browser subagent prompt endpoint. User interruption addresses a resident Activation without persistence reads or restoration; an absent target remains a no-op, and interruption preserves parked input. + +Model messages retain the exact live sender and same-tree rule from [tree-wide communication](2026-10-16-tree-wide-agent-communication.md). Model interruption retains its stricter ancestor rule. Creation parents still determine catalog edges, history-address consistency, ancestry, and completion recipients; they are not browser control credentials. + +This supersedes the direct-parent addressing requirement for human continuation in [continuable subagents](../feature/2026-07-28-continuable-subagent-conversations.md). That record retains its inbox and durable continuation rationale; tree-wide communication owns its superseded model adjacency and runtime ownership restrictions. No Session format changes. + +## Alternatives considered + +**Keep the parent check as authorization.** The client supplies the parent id and can obtain it from history. Matching that id does not authorize the principal, and requiring it couples controls to discovery metadata. + +**Require a root id from the browser.** The server already derives the runtime owner from the target. Another supplied id adds a consistency check without granting permission; Agent senders still need an independently resolved root to enforce same-tree messaging. + +**Unify user and Agent interruption permissions.** A user with a target grant can stop that target, while an Agent may stop only its descendants. Same-tree collaboration does not grant members control over each other's execution. + +## Consequences + +Remote callers update their prompt fields, interrupt endpoint, and non-owner policy actions and resources; the [upgrade guide](../../../../docs/upgrade-guide/v0.2.1-rc.2/subagent-user-controls/guide.md) owns the migration. History URLs retain parent ids for consistency checks. Cold host prompts may read creation ancestry twice, once to find the live root and again during restoration; no verified-ancestry cache is retained across asynchronous work. + +Gateway tests cover target grants, global-only and parent-only refusal, separate send and interrupt actions, owner access, and refusal before side effects. Continuation tests cover nested and cold targets with online or offline creators, unavailable roots, and rejection of a root as a subagent target. The Web interrupt scenario exercises the renamed endpoint and parked-input behavior through the shipped composition. diff --git a/.agents/notes/implemented/architecture/2026-10-22-subagent-target-user-controls.zh.md b/.agents/notes/implemented/architecture/2026-10-22-subagent-target-user-controls.zh.md new file mode 100644 index 0000000000..455a511778 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-22-subagent-target-user-controls.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 用户控制按目标子代理寻址 + +Status: implemented + +[English](2026-10-22-subagent-target-user-controls.md) | 中文 + +## 问题 + +浏览器知道当前展示的子代理,但要求提供直接创建者和 continuable 标记,使每个控制调用方都要重复历史元数据。调用方提供的父级 id 只能检查地址一致性,不能确定调用者身份或权限。仅解析出 global 访问资源的调用也无法表达对单个目标 Session 的授权。 + +## 决策 + +浏览器提示词和 `interruptByUser` 仅按目标子 Session 寻址。每个 Remote 方法显式声明该 Session 为访问资源;Gateway 在附件接纳、恢复或取消之前检查调用的 principal。global 或父 Session 授权不意味着子 Session 授权。策略决定授予两个操作、其中一个操作,或继承访问权。同进程用户中断信任其调用方,不接受从线上传入的 principal。 + +提示词投递从驻留 Activation 或经过验证的持久化创建关系中解析在线根。仅恢复冷目标,创建者可以保持离线。共享投递保留精确在线根检查、关闭准入、取消、容量和最终 inbox 接纳。根 Session 不是浏览器子代理提示词端点的目标。用户中断直接寻址驻留 Activation,不读取持久化数据,也不恢复目标;目标缺席仍为无操作,中断保留停放的输入。 + +模型消息保留[整树通信](2026-10-16-tree-wide-agent-communication.zh.md)中的精确在线发送者和同树规则。模型中断保留更严格的祖先规则。创建父级仍决定目录关系、历史地址一致性、祖先关系和完成通知接收者;它们不是浏览器控制凭证。 + +本决策取代[可继续的 subagent](../feature/2026-07-28-continuable-subagent-conversations.zh.md)对人工继续执行要求直接父级地址的部分。旧记录保留 inbox 和持久化继续执行的理由;整树通信决策拥有其中已取代的模型相邻关系和运行时所有权限制。Session 格式不变。 + +## 考虑过的替代方案 + +**保留父级检查作为授权。** 客户端提供父级 id,也能从历史中取得它。匹配该 id 不能授权 principal,还使控制依赖于发现元数据。 + +**要求浏览器提供根 id。** 服务端已经能从目标解析运行时所有者。再提供一个 id 只会增加一致性检查,不会授予权限;Agent 发送者仍需要独立解析的根来约束同树消息。 + +**统一用户和 Agent 的中断权限。** 用户获得目标授权后可以停止该目标,而 Agent 只能停止自己的后代。同树协作不赋予成员控制彼此执行的权限。 + +## 影响 + +Remote 调用方需要更新提示词字段、中断端点,以及非 owner 策略的操作和资源;迁移步骤由[升级指南](../../../../docs/upgrade-guide/v0.2.1-rc.2/subagent-user-controls/guide.zh.md)拥有。历史 URL 保留父级 id 用于一致性检查。冷目标的宿主提示词可能读取两次创建祖先:先查找在线根,再在恢复期间验证;不跨异步操作保留已验证祖先缓存。 + +Gateway 测试覆盖目标授权、仅 global 和仅父级授权的拒绝、独立的发送和中断操作、owner 访问,以及副作用前拒绝。继续执行测试覆盖创建者在线或离线时的嵌套和冷目标、根不可用,以及拒绝把根当作子代理目标。Web 中断场景通过交付组合验证重命名后的端点和输入停放行为。 diff --git a/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.i18n.yaml new file mode 100644 index 0000000000..81682a6981 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-22-subagent-tool-owned-communication.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-22-subagent-tool-owned-communication.md +/agent-note-subagent-tools-own-communication-guidance: + en: 0bd031aa81fbc18d + zh: c530e980826698ed +/agent-note-subagent-tools-own-communication-guidance/problem: + en: ac391b569d2bc7e9 + zh: a95f613aa2b330b8 +/agent-note-subagent-tools-own-communication-guidance/decision: + en: 52fb33963bedfe07 + zh: 75fe66d80f47323f +/agent-note-subagent-tools-own-communication-guidance/alternatives-considered: + en: c55d44c6414e0c4c + zh: 9fa2ab31843e259b +/agent-note-subagent-tools-own-communication-guidance/consequences: + en: 52f7234664c515ec + zh: 55ec7b54c044e9af diff --git a/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.md b/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.md new file mode 100644 index 0000000000..2222cd5dd7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.md @@ -0,0 +1,27 @@ +# Agent Note: Subagent tools own communication guidance + +Status: implemented + +English | [中文](2026-10-22-subagent-tool-owned-communication.zh.md) + +## Problem + +The continuation manager inspected a Symbol on the standard messaging tool before adding tool instructions and parent addressing to the initial task. This coupled orchestration to one tool implementation and made result-delivery guidance depend on tool installation. A published internal entry point carried both that marker and a host prompt adapter used only by tests. + +## Decision + +`tool-subagent-control` owns messaging instructions and parent addressing. Its logged runtime context gives delegated children their direct creator’s id from the Session header, explains when to send findings, and states that sending does not finish a turn. The context is emitted only for delegated children when the plugin’s own tool definition is admitted in the current scope and step; roots and ordinary Session forks receive none. Communication guidance follows inherited history without changing the shared system prompt or tool schema. + +The manager owns result delivery. Local activations with `delivery: 'parent'` receive automatic-result guidance even without messaging tools; caller-owned activations receive no parent-delivery promise. Settlement timing, recipients, output, and suppression of closing text after a final message to the parent remain unchanged. Tool visibility grants no messaging authority. + +This supersedes the Symbol identity and initial-task communication-guidance choices in [adjacent messaging](2026-08-27-adjacent-agent-steer-messaging.md). Its message attribution, fixed Steer delivery, and rejection of a separate report operation remain active. The internal entry point and prompt adapter are removed; the [upgrade guide](../../../../docs/upgrade-guide/v0.2.1-rc.2/subagent-user-controls/guide.md) owns caller migration. + +## Alternatives considered + +**Keep a cross-package marker or add a service hook.** Tool registration already gives its owner the exact definition, and prompt assembly already supplies the current Agent. Another protocol or stored identity has no independent consumer. + +**Move all return guidance into the tool.** Automatic parent notification depends on activation delivery, not on messaging capability. The tool must not promise it to caller-owned activations or withhold it from children without tools. + +## Consequences + +Tests cover visible, hidden and replaced definitions, logged identity, plugin disposal, fork prefix reuse, and parent versus caller result delivery. Recorded-session snapshots cover the assembled prompt and durable runtime context. Tests needing host input use the existing private manager test helper; no production adapter exists solely to expose that helper. diff --git a/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.zh.md b/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.zh.md new file mode 100644 index 0000000000..21242507b7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.zh.md @@ -0,0 +1,27 @@ +# Agent Note: Subagent 工具拥有通信指导 + +Status: implemented + +[English](2026-10-22-subagent-tool-owned-communication.md) | 中文 + +## 问题 + +继续执行管理器检查标准通信工具上的 Symbol,随后在初始任务中加入工具说明和父级地址。这让编排依赖一个工具实现,也让结果交付指导取决于是否安装工具。已发布的内部入口同时承载该标记和仅供测试使用的宿主 prompt 适配器。 + +## 决策 + +`tool-subagent-control` 拥有通信说明和父级寻址。持久记录的运行时上下文从 Session header 读取委派子级的直接创建者 id,说明何时发送发现,并指出发送不会结束轮次。仅当本插件的工具定义在当前作用域和 step 中可用时,才向委派子级提供上下文;根代理和普通 Session fork 不接收。通信指导位于继承历史之后,不改变共享系统提示词或工具 schema。 + +管理器拥有结果交付。采用 `delivery: 'parent'` 的本地 activation 即使没有通信工具,也会收到自动结果交付指导;调用方拥有的 activation 不会收到父级交付承诺。结算时序、接收方、输出,以及最后向父级发送消息后省略结束文本的规则保持不变。工具可见性不授予通信权限。 + +本决策取代[相邻代理通信](2026-08-27-adjacent-agent-steer-messaging.zh.md)中的 Symbol 身份和初始任务通信指导选择。该记录的消息来源归属、固定 Steer 投递,以及不另设 report 操作的决策继续有效。内部入口和 prompt 适配器已删除;调用方迁移由[升级指南](../../../../docs/upgrade-guide/v0.2.1-rc.2/subagent-user-controls/guide.zh.md)说明。 + +## 考虑过的替代方案 + +**保留跨包标记或增加 service hook。** 工具注册已经向所有者提供确切定义,提示词组装也已提供当前 Agent。另一套协议或身份存储没有独立消费者。 + +**把全部返回指导移入工具。** 自动父级通知取决于 activation 的交付方式,而非通信能力。工具不能向调用方拥有的 activation 承诺自动通知,也不能让没有工具的子级缺少交付说明。 + +## 后果 + +测试覆盖可见、隐藏及替换的定义、持久记录的身份、插件释放、fork 前缀复用,以及父级与调用方结果交付。录制会话快照覆盖组装后的提示词和持久运行时上下文。需要宿主输入的测试使用现有的私有 manager 测试帮助函数;生产代码不再仅为暴露该帮助函数而保留适配器。 diff --git a/.agents/notes/implemented/architecture/2026-10-29-cua-dev-signing-identity.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-29-cua-dev-signing-identity.i18n.yaml new file mode 100644 index 0000000000..de5a17b10d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-29-cua-dev-signing-identity.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-29-cua-dev-signing-identity.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-cua-dev-signing-identity.md +/agent-note-development-signing-identity-for-source-built-cua-helpers: + en: 472c4efd47184a32 + zh: 49f64628531970e9 +/agent-note-development-signing-identity-for-source-built-cua-helpers/problem: + en: a24857a312482724 + zh: dbac75d363670e86 +/agent-note-development-signing-identity-for-source-built-cua-helpers/decision: + en: d2265f9ec5fb7a79 + zh: 05da2479b024a4bb +/agent-note-development-signing-identity-for-source-built-cua-helpers/alternatives-considered: + en: e30db5bc9c7aab43 + zh: fa84883b5527334e +/agent-note-development-signing-identity-for-source-built-cua-helpers/consequences: + en: fb0e8fe44890bdf9 + zh: d9e9ace1a4c10060 diff --git a/.agents/notes/implemented/architecture/2026-10-29-cua-dev-signing-identity.md b/.agents/notes/implemented/architecture/2026-10-29-cua-dev-signing-identity.md new file mode 100644 index 0000000000..5f721474fc --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-29-cua-dev-signing-identity.md @@ -0,0 +1,35 @@ +# Agent Note: Development signing identity for source-built CUA helpers + +Status: implemented + +English | [中文](2026-10-29-cua-dev-signing-identity.zh.md) + +## Problem + +Source checkouts compile `DSH Computer Use.app` with an ad-hoc signature. macOS stores Accessibility and Screen Recording grants for a bundle identifier with the designated requirement of the granted code, and an ad-hoc requirement is the code hash. Every changed build and every other worktree therefore needs new grants, and these builds shared the release helper's bundle identifier and replaced its grant entry. + +## Decision + +The Host builds source checkouts as a development variant with bundle identifier `com.deepseek.harness.computer-use.dev` and name `DSH Computer Use Dev`, in `DSH Computer Use Dev.app`. The macOS privacy list and the helper’s reported launch application use the bundle filename, so that filename also includes Dev. The release variant and its packaging are unchanged. Grants stay with the helper application launched through LaunchServices, as the [shared helper decision](2026-10-09-cua-shared-native-helper.md) requires. + +`sourceSigning` belongs to the macOS provider and accepts only default `ad-hoc` or `{ identity, keychain }` selecting an existing certificate and absolute keychain path. Builds do not create, unlock or remove identities. Developer credentials stay separate from the user-facing DSH home, which stores only the certificate selection; the shared `cua` package owns no macOS signing configuration. + +The [developer signing skill](../../../skills/dsh-cua-macos-signing/SKILL.md) guides Agents through creating, reusing and unlocking an isolated identity under `~/.cua-macos-keys/`, then selecting its fingerprint and keychain path in user configuration. The tool stores a random password in a `0600` file, permits codesign access, and adds the dedicated keychain to the user search list. A file lock serializes setup and removal for that directory; a missing keychain with an existing record is preserved and rejected. A directory marker limits identity management to explicitly claimed, initially empty directories. Unexpected files or symlinked identity entries stop setup and removal before changes, so a developer-selected path cannot erase unrelated contents. + +Certificate signatures embed a signing time even without a timestamp server. Development publication records which generation each unsigned bundle and identity produced, then selects that generation again instead of re-signing, so identical sources keep one executable digest and one shared service. The cache uses certificate fingerprints; explicit certificate names are not reused because renewal can change the certificate behind a name. Signing failures stop publication. Developer setup diagnostics omit passwords and complete command arguments. The build CLI defaults to `--identity -` and accepts an existing certificate with `--identity` and optional `--keychain` for release packaging. + +## Alternatives considered + +**Grant the launching terminal.** Launching the service directly makes macOS attribute permissions to the terminal or editor that started DSH. Every program that application runs, including commands run by the model, then has the permissions. + +**Automatically create a fixed identity during source builds.** This makes ordinary startup create developer private keys and ties their lifetime to user data. An explicit developer workflow preserves the option to reduce repeated grants while keeping identity creation, removal and selection under developer control. + +**Self-signed identity in the login keychain.** The login keychain is already unlocked and on the search list, but allowing `codesign` to use a new key there without a dialog requires the user's login password. + +**Add the keychain to the search list only while signing.** The release packaging script does this for one invocation. Concurrent builds in several worktrees would read and restore the user list over each other, and a stopped build would leave the entry behind anyway. + +**A stable launcher that spawns each worktree's helper.** Grants would follow the launcher, but any local process could ask it to run an arbitrary executable with those grants, which is the same exposure as the stored key without a signature check. + +## Consequences + +Development builds signed by the same certificate share a signing requirement; actual permission retention after grants, rebuilds and worktree changes still requires verification. Other processes of the same user can read the stored password and sign with that identity; the release helper has a different bundle identifier. Deleting DSH home removes its selection, not the developer identity; deleting the identity requires clearing every configuration that references it. The real-signing e2e checks reuse, unlocking, requirements across output directories and search-list restoration after removal. diff --git a/.agents/notes/implemented/architecture/2026-10-29-cua-dev-signing-identity.zh.md b/.agents/notes/implemented/architecture/2026-10-29-cua-dev-signing-identity.zh.md new file mode 100644 index 0000000000..9491738cad --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-29-cua-dev-signing-identity.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 源码构建的 CUA helper 使用开发签名身份 + +Status: implemented + +[English](2026-10-29-cua-dev-signing-identity.md) | 中文 + +## 问题 + +源码检出用临时签名编译 `DSH Computer Use.app`。macOS 按 Bundle ID 和被授权代码的指定要求保存辅助功能与屏幕录制授权,临时签名的指定要求就是代码哈希。因此每次构建内容变化、每换一个 worktree,都需要重新授权;这些构建还与正式 helper 共用 Bundle ID,会替换正式版的授权条目。 + +## 决策 + +Host 以开发版构建源码检出,Bundle ID 为 `com.deepseek.harness.computer-use.dev`,名称为 `DSH Computer Use Dev`,应用包文件名为 `DSH Computer Use Dev.app`。macOS 隐私列表和 helper 返回的启动应用名称使用应用包文件名,因此文件名也需包含 Dev。正式版及其打包流程不变。按[共享 helper 决策](2026-10-09-cua-shared-native-helper.zh.md)的要求,授权仍归经 LaunchServices 启动的 helper 应用。 + +`sourceSigning` 由 macOS provider 定义,只接受默认的 `ad-hoc` 和指定已有证书及钥匙串绝对路径的 `{ identity, keychain }`。构建不创建、解锁或移除身份。开发者凭据与面向用户的 DSH home 分开,后者只保存选择证书的配置;共享 `cua` 包不持有 macOS 签名配置。 + +[开发者签名 skill](../../../skills/dsh-cua-macos-signing/SKILL.md)指导 Agent 在 `~/.cua-macos-keys/` 创建、复用和解锁独立身份,将证书指纹和钥匙串路径写入用户配置。工具把随机密码存入 `0600` 文件,允许 codesign 访问私钥,并把专用钥匙串加入用户搜索列表。文件锁让同一目录的初始化和移除串行执行;已有记录但钥匙串缺失时保留记录并报错。目录标记将身份管理限制在明确占用的初始空目录中。发现无关文件或身份文件软链接时,初始化和移除都会在修改前停止,避免开发者指定的路径导致无关内容被删除。 + +即使不使用时间戳服务器,证书签名也包含签名时间。开发构建记录每份未签名内容与身份对应的已签名目录,再次构建相同内容时复用该目录,保持可执行文件摘要和共享服务一致。缓存使用证书指纹;按证书名称选择时每次签名,避免续期后复用旧证书。签名失败会中止发布。开发者工具的诊断不包含密码或完整命令参数;构建 CLI 默认 `--identity -`,支持指定证书,正式打包也可省略 `--keychain`。 + +## 考虑过的替代方案 + +**把权限交给启动终端。** 直接启动服务时,macOS 把权限归属到启动 DSH 的终端或编辑器。该应用运行的所有程序,包括模型执行的命令,都会带上这些权限。 + +**源码构建自动创建固定身份。** 这会让普通启动生成开发者私钥,并把身份生命周期绑定到用户数据目录。显式开发者操作保留了减少重新授权的选择,也让证书的创建、移除和使用范围由开发者决定。 + +**把自签名身份放进登录钥匙串。** 登录钥匙串已解锁且在搜索列表中,但要让 `codesign` 不弹窗地使用其中的新私钥,需要用户的登录密码。 + +**只在签名期间把钥匙串加入搜索列表。** 正式打包脚本对单次调用这样处理。多个 worktree 并发构建时会互相覆盖读取和恢复的列表,构建被中断时条目也会残留。 + +**用固定的启动器运行各 worktree 的 helper。** 授权会跟随启动器,但任何本机进程都能让它带着这些授权运行任意可执行文件,暴露面与保存的私钥相同,还少了签名检查。 + +## 后果 + +同一证书签出的开发构建共用签名要求;授权后重建和跨 worktree 的实际权限保持仍需验证。同一用户的进程可读取保存的密码并使用该身份签名,正式 helper 使用不同的 Bundle ID。删除 DSH home 只移除配置,不删除开发者身份;删除该身份后应清理所有引用它的配置。真实签名 e2e 验证复用、解锁、不同输出目录的签名要求和移除后的搜索列表恢复。 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/bug-fix/2026-10-21-subagent-end-turn-guidance.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.i18n.yaml index 8b8bba1309..bbc63b3c3b 100644 --- a/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.i18n.yaml @@ -12,8 +12,8 @@ en: 47b44ed9313c2ccf zh: 7ad254f8ef88351d /agent-note-subagent-end-turn-guidance-stays-in-the-delegation-paragraph/alternatives-considered: - en: d52fa1af379dea5b - zh: 4e404994f7a607dc + en: 3153c1e7563a3357 + zh: 9841db2b0156075b /agent-note-subagent-end-turn-guidance-stays-in-the-delegation-paragraph/verification: en: be076216381ea64d zh: 2da8d9819c20e2fe diff --git a/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.md b/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.md index cd800e94fe..8a25a23102 100644 --- a/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.md +++ b/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.md @@ -29,7 +29,7 @@ The sentence stays one literal line. Rewording `the next result starts a new tur **Move the guidance into the tool description.** The paragraph is assembled from the set of visible delegation tools and lists their names; the tool description describes one tool. Moving the sentence changes ownership without changing the behavior it prevents. -**Restore every prompt phrase reverted together with this sentence.** The read guidance and the `run_code` argument order are separate decisions with their own evidence; this note records only the delegation paragraph. +**Restore every prompt phrase reverted together with this sentence.** The read guidance ([2026-10-26 note](2026-10-26-read-guidance-line-ranges.md)) and the `run_code` argument order are separate decisions with their own evidence; this note records only the delegation paragraph. ## Verification diff --git a/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.zh.md b/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.zh.md index b11656473a..ba1c5cbc6a 100644 --- a/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.zh.md @@ -29,7 +29,7 @@ Status: implemented **把说明移进工具描述。** 这段指导按可见委派工具集合组装并列出工具名,工具描述只描述单个工具。移动这句会改变归属,但不改变它阻止的行为。 -**把与这句一起被回退的所有提示词都恢复。** read 指导与 `run_code` 参数顺序是各自有证据的独立决定;本记录只覆盖委派段。 +**把与这句一起被回退的所有提示词都恢复。** read 指导([2026-10-26 note](2026-10-26-read-guidance-line-ranges.zh.md))与 `run_code` 参数顺序是各自有证据的独立决定;本记录只覆盖委派段。 ## 验证 diff --git a/.agents/notes/implemented/bug-fix/2026-10-26-read-guidance-line-ranges.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-10-26-read-guidance-line-ranges.i18n.yaml new file mode 100644 index 0000000000..86e21bee13 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-26-read-guidance-line-ranges.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-26-read-guidance-line-ranges.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/bug-fix/2026-10-26-read-guidance-line-ranges.md +/agent-note-read-guidance-names-shell-readers-and-line-ranges: + en: 1926710614598539 + zh: b0106d4a6a2d648d +/agent-note-read-guidance-names-shell-readers-and-line-ranges/problem: + en: 4189d516419d8d12 + zh: 30d8496064dd44e3 +/agent-note-read-guidance-names-shell-readers-and-line-ranges/decision: + en: 34f18307f7ad87af + zh: 21c466c6ce6c598d +/agent-note-read-guidance-names-shell-readers-and-line-ranges/alternatives-considered: + en: aea7ffb1159ab81a + zh: 4842ca79dc30654d +/agent-note-read-guidance-names-shell-readers-and-line-ranges/verification: + en: cbc16bfba4abe862 + zh: 9a71e6c020c01f69 +/agent-note-read-guidance-names-shell-readers-and-line-ranges/consequences: + en: cf55161ea197a7a9 + zh: fa10ca413f8fd726 diff --git a/.agents/notes/implemented/bug-fix/2026-10-26-read-guidance-line-ranges.md b/.agents/notes/implemented/bug-fix/2026-10-26-read-guidance-line-ranges.md new file mode 100644 index 0000000000..60ad77abee --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-26-read-guidance-line-ranges.md @@ -0,0 +1,35 @@ +# Agent Note: Read guidance names shell readers and line ranges + +Status: implemented + +English | [中文](2026-10-26-read-guidance-line-ranges.zh.md) + +## Problem + +The read guidance named only `cat` as the shell command the read tool replaces, and described `offset`/`limit` as continuing a large file rather than reading a line range. #6057 recorded a long Session reading ranges with `sed -n X,Yp` 60 times. #6054 added `sed`, `head`, `tail`, and the line-range wording; a later prompt revert removed them together with unrelated changes. + +The 2026-10-23 evaluation of the SDK profile (native tools, six benchmarks) measured this sentence as the only change: Pass@1 mean −0.03pp and Pass@3 mean +0.70pp over the five benchmarks that report Pass@3 (ProgramBench-New has none) versus the base commit, with mixed step and token deltas (ProgramBench-New −15.5 steps, Terminal-Bench-v4 +13.5 steps). The PTC mode runs did not include this variant; the section renders in every request where `read` is visible. + +## Decision + +Restore the #6054 wording, byte-identical: `Use the read tool — not shell commands like cat, sed, head, or tail — to inspect text files. Use offset and limit to read a line range or continue a large file.` The read tool and its guidance are owned by `@deepseek-ai/dsh-tool-fs`; write and edit guidance are unchanged. + +## Alternatives considered + +**Rely on the read tool schema.** The schema already documents `offset` and `limit`, but #6057 shows the model chose `sed` while that schema existed; the guidance is the only model-visible place that names `sed`, `head`, and `tail`. + +**Restore every prompt phrase reverted together with this sentence.** The subagent end-turn sentence, the `run_code` argument order, the PTC syntax ownership, and the SDK persona are separate decisions; the first is already restored ([subagent end-turn note](2026-10-21-subagent-end-turn-guidance.md)), and the others carry their own evidence. + +**Say "line range" without naming shell commands.** The observed failure was choosing shell commands, so the sentence names them. + +## Verification + +- `packages/fs/tool-fs/tests/tools.spec.ts` asserts the rendered guidance text. +- Every recorded `system-prompt*.expected.md` sidecar that renders the read guidance carries the restored sentence: `test:snapshot` replays the session and SDK set, and `pnpm run test:web:ci` replays the `snapshots/web` set (the `node 24 / snapshots and artifacts` lane runs both). Two pinned historical sidecars (`web/cordis-tool-round`, `web/schedule-catalog`) retain their recorded text. +- The evaluation above measured this single change on the native SDK profile. + +## Consequences + +- Every request with the read tool carries three more command names and a line-range clause, about 10 tokens. +- The model is directed to the read tool for line ranges instead of `cat`, `sed`, `head`, or `tail`. +- The benchmark measured this sentence as neutral; keeping it rests on the #6057 production observation, not on a measured quality gain. diff --git a/.agents/notes/implemented/bug-fix/2026-10-26-read-guidance-line-ranges.zh.md b/.agents/notes/implemented/bug-fix/2026-10-26-read-guidance-line-ranges.zh.md new file mode 100644 index 0000000000..25487ea733 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-26-read-guidance-line-ranges.zh.md @@ -0,0 +1,35 @@ +# Agent Note: read 指导点名 shell 读取命令与行区间 + +Status: implemented + +[English](2026-10-26-read-guidance-line-ranges.md) | 中文 + +## 问题 + +read 指导只把 `cat` 点名为 read 工具替代的 shell 命令,并把 `offset`/`limit` 描述成继续读大文件,而不是读行区间。#6057 记录了一个长 Session 用 `sed -n X,Yp` 读行区间 60 次。#6054 增加了 `sed`、`head`、`tail` 和行区间措辞;后来的一次提示词回退把它们和不相关的改动一起删掉了。 + +2026-10-23 对 SDK profile(native 工具、六个 benchmark)的评测里,这句是唯一改动:相对 base commit,Pass@1 均值 −0.03pp、Pass@3 均值 +0.70pp(该均值取报告 Pass@3 的五个 benchmark;ProgramBench-New 没有 Pass@3),步数与 token 变化方向不一(ProgramBench-New −15.5 步、Terminal-Bench-v4 +13.5 步)。PTC 模式的运行没有包含这个变体;只要 `read` 可见,这段就会进入每个请求。 + +## 决策 + +逐字恢复 #6054 的措辞:`Use the read tool — not shell commands like cat, sed, head, or tail — to inspect text files. Use offset and limit to read a line range or continue a large file.` read 工具及其指导归 `@deepseek-ai/dsh-tool-fs` 所有;write 与 edit 的指导不变。 + +## 考虑过的替代方案 + +**只依赖 read 工具 schema。** schema 已经写明 `offset` 与 `limit`,但 #6057 显示在那个 schema 存在时模型仍选择 `sed`;指导是唯一点名 `sed`、`head`、`tail` 的模型可见位置。 + +**把与这句一起被回退的所有提示词都恢复。** subagent 结束本轮句、`run_code` 参数顺序、PTC 语法归属、SDK persona 是各自独立的决定;第一项已经恢复([subagent 结束本轮 note](2026-10-21-subagent-end-turn-guidance.zh.md)),其余各有自己的证据。 + +**只说“行区间”,不点名 shell 命令。** 观测到的失败就是模型选择 shell 命令,所以这句点名它们。 + +## 验证 + +- `packages/fs/tool-fs/tests/tools.spec.ts` 断言渲染出的指导文本。 +- 所有渲染 read 指导的 `system-prompt*.expected.md` sidecar 都带有恢复后的句子:`test:snapshot` 回放 session 与 SDK 集合,`pnpm run test:web:ci` 回放 `snapshots/web` 集合(`node 24 / snapshots and artifacts` lane 两条都跑)。两个显式 pin 的历史 sidecar(`web/cordis-tool-round`、`web/schedule-catalog`)保留录制文本。 +- 上面的评测在 native SDK profile 上只改了这一句进行测量。 + +## 影响 + +- 只要 read 工具存在,每个请求多三个命令名和一句行区间说明,约 10 token。 +- 模型读行区间时被引导到 read 工具,而不是 `cat`、`sed`、`head`、`tail`。 +- benchmark 上这句测得为中性;保留它依据的是 #6057 的生产观测,而不是测到的质量收益。 diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml index 6566e1a57e..ef8bb48089 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml @@ -9,8 +9,8 @@ en: a72071eeedb77402 zh: 818d16ce429fd610 /agent-note-continuable-subagents/decision: - en: 3420195435e02a8f - zh: 6156eedb3c23d360 + en: 6ba1bf6184794aa8 + zh: 71b4e41556dafb93 /agent-note-continuable-subagents/decision/materialization-and-public-operations: en: c3a83ff748a02143 zh: 468bf62c8ddabf70 diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md index e76aae22fe..f5d178fec7 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md @@ -18,6 +18,8 @@ Parent Agents need to send later work to the same live child without changing it ## Decision +Human control addressing and permissions are superseded by [target Session user controls](../architecture/2026-10-22-subagent-target-user-controls.md); model-message adjacency and parent-owned runtime restrictions are superseded by [tree-wide communication](../architecture/2026-10-16-tree-wide-agent-communication.md). The direct-parent authorization statements below do not govern those current operations. + The shared activation entry point, external execution ownership, and caller-versus-parent result delivery are governed by [Unified subagent activations](../simplification/2026-09-17-unified-subagent-activations.md). This record retains the independent rationale described below. A continuable subagent has one durable Session and at most one process-local Activation: diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md index ce543b4efa..44f51e1d9e 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md @@ -18,6 +18,8 @@ parent Agent 还需要在不改变当前轮次的前提下,向同一个在线 ## 决策 +人工控制的地址和权限由[目标 Session 用户控制](../architecture/2026-10-22-subagent-target-user-controls.zh.md)取代;模型消息的相邻关系限制和父级运行时所有权限制由[整树通信](../architecture/2026-10-16-tree-wide-agent-communication.zh.md)取代。下文的直接父级授权表述不约束这些当前操作。 + 共享 activation 入口、外部执行所有权,以及调用方与父级之间的结果投递选择由[统一 subagent activation](../simplification/2026-09-17-unified-subagent-activations.zh.md) 决策拥有。本记录保留下述独立理由。 一个可继续 subagent 拥有一个持久化会话,并且至多拥有一个进程内激活: diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml index 68ef489fe0..dd2778d632 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml @@ -6,35 +6,35 @@ en: c914d8148bfc4bf9 zh: a8115f87c0ac6cae /agent-note-durable-agent-teams-over-continuable-children/problem: - en: bab62798c2dc07b4 - zh: fe42c5abf9127d37 + en: 567b68ab8eb4b5b2 + zh: 31a40aa22821d63a /agent-note-durable-agent-teams-over-continuable-children/decision: - en: 35650a30529c01f8 - zh: 4f9ee01b568d2fed + en: affeddb8788ea73b + zh: 73615d3ccbd71871 /agent-note-durable-agent-teams-over-continuable-children/profile-delegation: - en: 4835fb8c0c0d8a0d - zh: 5f9438a0d58104c4 + en: f4c8d8760c7ebcf4 + zh: 32ccca202a7769ac /agent-note-durable-agent-teams-over-continuable-children/team-identity: - en: f82812d6ac0ac78d - zh: 7dd80a6ad13674ca + en: d79f4425f2c1929f + zh: 6586410db4a02cd1 /agent-note-durable-agent-teams-over-continuable-children/provisioning-and-recovery: - en: 4345ac9a6ac7ad04 - zh: 1d19ecbb128a75a3 + en: 624e9d42af1af859 + zh: 020dcbb26211ea67 /agent-note-durable-agent-teams-over-continuable-children/mailbox-and-task-transactions: - en: bfc3063536837c0e - zh: 6b8e69ba85cc2bb1 + en: bfa07b5bc6354360 + zh: c82119c0a5ea7f01 /agent-note-durable-agent-teams-over-continuable-children/shared-checkout-boundary: - en: 34920da68b5763e0 - zh: 7604392594e4b8a8 + en: 384f75638b0f1fb6 + zh: b7ab1191784d3d27 /agent-note-durable-agent-teams-over-continuable-children/web-projection: - en: e6348054f024725b - zh: 8740636e99940308 + en: 1dc1386f5033e83b + zh: 533803da51f3716f /agent-note-durable-agent-teams-over-continuable-children/alternatives-considered: - en: 8cb2fd8839f5f8b7 - zh: e302ca03e569de65 + en: 087ffae4744a129b + zh: 842dc1ded7f8d9c3 /agent-note-durable-agent-teams-over-continuable-children/testing: - en: 092ac3c2b6f552aa - zh: c688c1bec5ad8a07 + en: 2ff7adaaaa65fad7 + zh: 9c7dd682abe988d1 /agent-note-durable-agent-teams-over-continuable-children/consequences: - en: da9cd22b1185a793 - zh: e20d7ef9226f237f + en: c071fa2ccdb911b5 + zh: 3b8cf0d3303a4d40 diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md index b93e2716f9..2056882e73 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md @@ -10,47 +10,41 @@ The subagent seam supplies fresh/fork providers, durable child Sessions, FIFO fo All same-process Agents also share one checkout. Filesystem edit tools can reject an observed stale version, but Bash, formatters, generators, and external writers bypass that fence. Treating a teammate name or task owner as a file lock would hide rather than solve this concurrency boundary. -Agent Teams needs an explicit opt-in composition while its public contracts remain experimental. The default tool catalog and simple-task behavior must remain unchanged, while an explicitly requested Team must survive child Activation settlement and mailbox delivery races long enough for the Lead to aggregate the result before process teardown. +Team collaboration requires explicit opt-in; [Session selection](../architecture/2026-10-20-session-team-mode.md) owns its granularity and default composition. The default tool catalog and simple-task behavior must remain unchanged, while an explicitly requested Team must survive child Activation settlement and mailbox delivery races long enough for the Lead to aggregate the result before process teardown. ## Decision -Every ordinary runtime root is the implicit Lead of a Team identified by that root's `SessionId`. The Team has no creation event: its Lead pseudo-row exists by identity, while durable state begins with the first member, message, or task event. A roster is flat and contains at most the configured number of immutable lowercase-kebab-case names. Each teammate is a continuable direct child with a reserved Session id; only the Lead creates or interrupts teammates. Ordinary provider-owned subagents outside the roster are not Team members, and an ordinary fork is a new root whose inherited Team records are excluded by their ancestor `TeamId`. +Every ordinary root and its descendants form the Team identified by the root `SessionId`; durable task state begins with the first task event. [Subagent teammates](../architecture/2026-10-15-agent-teams-as-subagents.md) owns the single agent identity. [Inbox-only waiting](../simplification/2026-10-29-inbox-only-agent-wait.md) keeps task-board changes independent of inbox waiting. An ordinary Session fork leads a new tree and excludes inherited Team authority. -The implementation is split into `@deepseek-ai/dsh-experimental-agent-team`, which owns `ctx.agentTeams` and durable semantics, and `@deepseek-ai/dsh-experimental-tool-agent-team`, which owns scoped schemas and model guidance. Every Team tool declares its complete result schema and renders that value as compact JSON, so the compiler checks each `execute` against what the model is promised and no result spends tokens on indentation. Deployments mount both plugins explicitly and may disable legacy continuable controls with the same model-visible names. The shared policy announces Team availability and describes coordination responsibilities. Both packages are public members of `packages/experimental/`; the [experimental package reference](../../../../packages/experimental/AGENTS.md) owns publication, dependency isolation, and promotion. +The implementation is split into `@deepseek-ai/dsh-agent-team`, which owns `ctx.agentTeams` and durable semantics, and `@deepseek-ai/dsh-tool-agent-team`, which owns scoped schemas and model guidance. Every Team tool declares its complete result schema and renders that value as compact JSON, so the compiler checks each `execute` against what the model is promised and no result spends tokens on indentation. The base bundle mounts both plugins; Team registers no tool that shares a name with the subagent controls. The shared policy announces Team availability and describes coordination responsibilities. Both packages belong to the [subagent family](../../../../packages/subagent/README.md); the root Session’s saved selection gates the task tools and policy. The [Session-mode upgrade guide](../../../../docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.md) describes profile migration. The Lead must wait for required work before its final answer. Process teardown remains the final lifecycle owner and drains continuation Activations; a Team task owner is durable state and is not automatically released by idle, interruption, or process exit. ## Profile delegation -The [Team profile](../../../../packages/experimental/agent-team-profile/README.md) disables `subagent` and `subagent_fork` together with the overlapping global controls. Direct model delegation uses `spawn_teammate` with fresh or fork context, keeping those children in the durable roster. Workflow remains available through the base profile’s fresh `spawn` provider for scripted orchestration; it cannot inherit a teammate’s conversation identity through the model tool. The Subagent services and providers remain shared infrastructure. Ordinary Session forks retain their history; [current identity handling](../architecture/2026-10-13-team-identity-runtime-context.md) supplies the fork’s own role. +The [base bundle](../../../../packages/bundle/base/README.md) inserts the Team rows and keeps the base delegation and control tools; teammates are started with those tools as described in [subagents](../architecture/2026-10-15-agent-teams-as-subagents.md). Ordinary Session forks retain their history; [current identity handling](../architecture/2026-10-13-team-identity-runtime-context.md) supplies the fork's own role. ## Team identity -Shared system policy and all tool schemas stay uniform across members; execution owns role restrictions. Team tools resolve the caller’s Team and accept member names, so the model needs no Team id. [Required runtime-context identity](../architecture/2026-10-13-team-identity-runtime-context.md) owns identity injection, retention, and fork handling. +Shared system policy and tool schemas stay uniform across members; execution enforces role restrictions. Team tools resolve the caller’s Team and use Session ids as task owners. [Required runtime-context identity](../architecture/2026-10-13-team-identity-runtime-context.md) owns identity injection, retention, and fork handling. ## Provisioning and recovery -Creation first appends and flushes a `team/member` provisioning snapshot in the Lead Session, then starts the reserved continuable child through the selected fresh or fork provider. Failure before initial inbox acceptance appends a failed snapshot. Success flushes the child's accepted inbox item before appending active. Recovery recognizes that initial message while it is still pending or after it enters user-message history. Names are reserved by the first provisioning record and never reused, including after failure. Disposal closes admission, aborts and awaits admitted creation and send operations, then stops every live child recorded by the roster; a failed child remains cleanup-owned until its Activation exits, and cleanup rejection fails disposal. - -A root recovery reconciles an unterminated provisioning record against the child's independently persisted Session. Matching direct-parent and continuable descriptors plus a recorded initial user message prove successful admission and produce active; absence, corruption, mismatched provider/lineage, or a missing admitted message produces failed. The creator re-reads the terminal phase under the same Lead-log serializer; if recovery marked failed while creation succeeded, it drains the child and reports a provisioning conflict instead of retaining an orphan. This avoids reconstructing an initial prompt that was never retained in the Team log and contains plugin-reload races. - -Fresh children have no inherited conversation. Fork children capture the Lead's completed-turn prefix once and retain it as their own durable seed. The current delegation turn remains excluded, matching the existing fork provider contract. +Teammate creation and cold resume belong to the subagent runtime ([subagent teammates](../architecture/2026-10-15-agent-teams-as-subagents.md)). Historical `team/member` records remain readable with their phases and descriptions; Team writes no new membership records. ## Mailbox and task transactions -The historical Lead-log mailbox remains a readable format: queued and delivered records keep their stable identity and sender, and Team payload validation still applies to them. Team no longer delivers or acknowledges them; new sends follow the [direct inbox decision](../simplification/2026-09-26-team-direct-inbox.md). +The historical Lead-log mailbox remains readable in original Session logs. The [task-only projection decision](../simplification/2026-10-29-team-task-only-projection.md) supersedes mailbox projection, checkpoint retention, and Team payload validation. Historical queued messages are never delivered or acknowledged. -Unknown mailbox content stays as decoded JSON during event projection and checkpoint admission. Validation inspects its type without rebuilding its fields, because generic object parsing can omit a valid own `__proto__` key. Locally declared content variants undergo structural validation; opaque plugin fields do not gain Team semantics. Cache invalidation is necessary because parser correction alone cannot recover keys omitted from cached state. The [Team documentation](../../../../packages/experimental/agent-team/README.md) owns the restore behavior. - -`send_message` uses Steer for running, idle, and cold targets. New sends return inbox acceptance. The [Team Steer messaging decision](../../archived/simplification/2026-08-30-team-send-message-steer.md) records the single-tool scheduling rationale. +Member messages use the subagent `send_message` operation, which steers running, idle, and cold targets and returns inbox acceptance. Shared tasks are complete snapshots with Team-local ids and monotonic revisions. Every mutation carries `expectedRevision`. Any member creates, reads, or claims a ready unowned task; the owner or Lead edits and transitions it, while only the Lead assigns another member. Numeric task ids remain within the safe-integer allocation range, and exhaustion fails without reusing an id. Dependencies must name non-deleted tasks and form a complete DAG. Deleted tasks are retained tombstones. `writeScopes` are normalized path prefixes that produce overlap diagnostics but never block claim or authorize a write. -`wait_agent` blocks on one roster, mailbox, task, or live-status edge registered after the call starts instead of encouraging model polling. It does not replay an earlier edge, so callers re-read authoritative state after wakeup or timeout. Lead-only interruption cancels the current turn with inbox preservation and does not alter mailbox or task ownership. +`wait_agent` belongs to ordinary subagent controls and waits for the exact caller’s next-step inbox, including already-pending input. Task commits do not end waits, deliver messages, or start idle turns. Agents send messages when another member needs to act on a task change. Ancestors may interrupt descendants without changing task ownership. ## Shared checkout boundary -Members start in the Lead’s current directory and observe shared filesystem writes immediately. Later directory changes stay local to each Session. The policy tells members to partition tasks, record advisory write scopes, order dependent work, and let the Lead inspect the final diff and run tests. A filesystem stale-version rejection requires rereading and rebasing the intended change. No equivalent guarantee is claimed for Bash, formatters, code generation, or direct external writes. +Members start in the Lead’s current directory and observe shared filesystem writes immediately. Later directory changes stay local to each Session. The task policy tells members to claim ready tasks, use advisory write scopes and dependencies, and complete tasks after doing the work. A filesystem stale-version rejection requires rereading and rebasing the intended change. No equivalent guarantee is claimed for Bash, formatters, code generation, or direct external writes. The Team domain does not infer branches, create worktrees, merge changes, or silently change cwd. A deployment or caller can use [explicit worktree creation](2026-09-13-explicit-worktree-creation.md) separately. This preserves the existing same-world subagent and sandbox contracts. @@ -58,36 +52,36 @@ The Team domain does not infer branches, create worktrees, merge changes, or sil The Web panel reads the Lead Session's `agentTeam` wire projection because the existing projection stream keeps browser state current without a separate Team read API. Both Lead and teammate conversations select values from the shared cross-Session store. The panel owns no projection reads: Session loading, cached list values, and control baselines supply the data. -The wire view carries durable roster phases, non-deleted task views with shared readiness and overlap derivation, and `failure` beside the last valid state. Live activity comes from Session status. Model selection has its own durable projection; the panel displays the shared store's available values without opening member conversations. A missing cached value remains absent until Session loading or a live update supplies it. +The task panel uses non-deleted task views with readiness and overlap diagnostics, plus `failure` beside the last valid state. Historical roster fields remain decodable but have no current UI or assignment authority. Ordinary subagent navigation owns member discovery; task owner labels reuse Session and catalog metadata. -Publication requires `apply` to return a new state object per applied Team event and replace only the touched collection. Mailbox-only events retain the view reference and publish nothing. The existing carrier sends each changed complete view to every connected browser; the package README records that cost. +Publication requires `apply` to return a new state per applied event and replace only the touched collection. Mailbox-only events retain the client view and publish nothing. The existing carrier sends each changed complete view to connected browsers; the package README records that cost. ## Alternatives considered -**Extend direct-child subagent tools with peer ids.** Rejected because parent/child authority and Team peer membership are different domains. Adding peer access to the continuation seam would weaken its exact-parent authorization and still leave roster and tasks without a persistence owner. +**Keep a separate Team communication authority.** The later [tree-wide communication decision](../architecture/2026-10-16-tree-wide-agent-communication.md) places routing and authorization in the subagent runtime; a task board cannot grant another route. -**Store mail in each target Session before Lead-log enqueue.** Rejected because target materialization and acceptance can fail after the Team has committed the send. The always-live Lead Session is the transaction home; target recording is the acknowledgement and de-duplication boundary. +**Keep a durable Team mailbox.** Target admission and task commit are independent operations. The historical Team mailbox remains readable, while current messaging uses the target inbox. Task commits neither deliver messages nor end waits. **Treat task ownership or write scopes as locks.** Rejected because external writers bypass them, crashed owners remain durable, and path-prefix overlap cannot prove semantic independence. False mutual exclusion is more dangerous than an explicit warning. **Create isolated worktrees automatically.** Rejected because worktree creation, branch naming, merge policy, ignored files, build artifacts, and cleanup are deployment choices. It also changes the same-world behavior existing subagents and sandboxes expose. -**Enable Teams in the default catalog.** Rejected because scoped Team controls would shadow same-named legacy globals and unsolicited delegation would add latency and token cost to simple tasks. An opt-in profile bundle inserts Team and disables the legacy controls without adding Team packages to shipped dependency graphs. +**Enable Teams in the default catalog.** Rejected because unsolicited coordination and Team tool schemas would add latency and token cost to simple tasks. An opt-in profile bundle inserts Team without adding Team packages to shipped dependency graphs. -**Use an in-memory board and mailbox.** Roster and task state must survive settlement, HMR, and interruption. Historical mailbox records stay readable; message ownership follows the target inbox. +**Use an in-memory board and mailbox.** Task state must survive settlement, HMR, and interruption. Historical mailbox records stay readable; message ownership follows the target inbox. -**Return Team tool results as untyped JSON.** Rejected because an undeclared result type lets `execute` drift from the value the model is promised without a compiler error, and it invites indentation that costs tokens on every roster, task, and receipt. Each Team tool therefore declares its complete result schema and one shared helper renders it compactly. +**Return Team tool results as untyped JSON.** Rejected because an undeclared result type lets `execute` drift from the value the model is promised without a compiler error, and it invites indentation that costs tokens on every task and wait result. Each Team tool therefore declares its complete result schema and one shared helper renders it compactly. ## Testing -Package tests cover Team authority, provisioning durability, task transitions, direct inbox acceptance, sender attribution, lifecycle cancellation, and historical mailbox readability. The keyless Team profile snapshot pins the policy, tools, and workflow; the CLI composition test checks persisted Team and child logs. +Package tests cover task transitions, tree-based assignment, historical roster and mailbox decoding, and task commits that leave inbox waits pending. Ordinary subagent tests cover inbox waiting; recorded Team compositions cover identity, task policy, and tools. -Model-facing member identities and availability follow the [tool projection reference](../../../../packages/experimental/tool-agent-team/README.md); service residency and durable identity remain distinct. +Identity and inbox waiting follow [subagent controls](../../../../packages/subagent/tool-subagent-control/README.md); task policy follows the [Team tool reference](../../../../packages/team/tool-agent-team/README.md). ## Consequences -The Lead Session retains whole task/member snapshots and historical mailbox records. This favors inspectable recovery over compact deltas; deleted and delivered history remains append-only until broader Session retention applies. +The Lead Session retains whole task snapshots and historical member and mailbox records. This favors inspectable recovery over compact deltas; deleted and delivered history remains append-only until broader Session retention applies. -An active roster member can be non-resident, so `inactive` is not failure and sends can incur cold-resume latency. Admission failures reject new sends. Historical pending mail is never delivered. Failed provisioning permanently consumes its name and member slot. +A teammate can be non-resident, so `inactive` is not failure and sends can incur cold-resume latency. Historical pending mail is never delivered. Coordination reduces likely checkout conflicts but cannot eliminate writes outside filesystem compare-and-set tools. The final diff and tests remain the Lead's integration boundary. diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md index f38c6f68c4..3fbb92daf3 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md @@ -10,47 +10,41 @@ subagent seam 已提供 fresh/fork provider、持久 child Session、FIFO foll 同进程 Agent 还共享一个 checkout。文件系统 edit 工具可以拒绝已观察到的陈旧版本,但 Bash、formatter、generator 与外部 writer 会绕过该屏障。把 teammate name 或 task owner 当作文件锁只会掩盖而不是解决该并发边界。 -Agent Teams 的公开约定仍处于实验阶段,因此需要显式启用的组合。默认工具目录与简单任务行为必须保持不变;而显式请求的 Team 必须能跨越 child Activation settlement 与 mailbox 投递竞争,使 Lead 在进程 teardown 前汇总结果。 +Team 协作需要显式开启;[Session 选择](../architecture/2026-10-20-session-team-mode.zh.md)负责选择粒度与默认组合。默认工具目录与简单任务行为必须保持不变;而显式请求的 Team 必须能跨越 child Activation settlement 与 mailbox 投递竞争,使 Lead 在进程 teardown 前汇总结果。 ## Decision -每个普通运行时 Root 都是一个隐式 Team 的 Lead,Team id 等于该 Root 的 `SessionId`。Team 没有 creation event:Lead pseudo-row 由身份直接存在,持久状态从第一条 member、message 或 task event 开始。roster 是扁平结构,最多包含配置数量、不可变且采用小写 kebab-case 的名字。每个 teammate 都是使用预留 Session id 的 continuable 直接 child;只有 Lead 可以创建或 interrupt teammate。roster 外由 provider 管理的普通 subagent 不是 Team member;普通 fork 是新的 Root,继承的 Team 记录会因 ancestor `TeamId` 被排除。 +每个普通根及其后代组成以根 `SessionId` 标识的 Team;持久任务状态从首个任务事件开始。[subagent 队友](../architecture/2026-10-15-agent-teams-as-subagents.zh.md)负责单一 agent 身份。[仅等待收件箱](../simplification/2026-10-29-inbox-only-agent-wait.zh.md)使任务板变更独立于收件箱等待。普通 Session fork 成为一棵新树的根,并排除继承的 Team 权限。 -实现拆分为 `@deepseek-ai/dsh-experimental-agent-team` 与 `@deepseek-ai/dsh-experimental-tool-agent-team`:前者负责 `ctx.agentTeams` 和持久语义,后者负责 scoped schema 与模型指引。每个 Team 工具都声明完整的结果 schema,并把该值渲染为紧凑 JSON,因此编译器会检查每个 `execute` 是否符合对模型的承诺,也没有结果把 token 花在缩进上。部署显式挂载两个插件,并可禁用具有相同模型可见名称的旧 continuable control。共享策略声明 Team 可用,并说明协调责任。两个包都是 `packages/experimental/` 的公开成员;[实验性包参考](../../../../packages/experimental/AGENTS.md)负责发布、依赖隔离与 promotion。 +实现拆分为 `@deepseek-ai/dsh-agent-team` 与 `@deepseek-ai/dsh-tool-agent-team`:前者负责 `ctx.agentTeams` 和持久语义,后者负责 scoped schema 与模型指引。每个 Team 工具都声明完整的结果 schema,并把该值渲染为紧凑 JSON,因此编译器会检查每个 `execute` 是否符合对模型的承诺,也没有结果把 token 花在缩进上。基础 bundle 挂载两个插件;Team 不注册与 subagent control 同名的工具。共享策略声明 Team 可用,并说明协调责任。两个包属于 [subagent 家族](../../../../packages/subagent/README.zh.md);根 Session 保存的选择控制任务工具与策略。[Session 模式升级指南](../../../../docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.zh.md)说明 profile 迁移。 Lead 必须等待所需工作后才能给出最终答案。进程 teardown 仍是最终生命周期 owner,并会 drain continuation Activation;Team task owner 是持久状态,不会因 idle、interrupt 或进程退出自动释放。 ## Profile delegation -[Team profile](../../../../packages/experimental/agent-team-profile/README.zh.md) 禁用 `subagent`、`subagent_fork` 以及名称重叠的全局控件。模型直接委派使用支持 fresh 或 fork 上下文的 `spawn_teammate`,使这些子代理进入持久 roster。Workflow 仍通过 base profile 的 fresh `spawn` 提供方执行脚本编排;经模型工具创建的 workflow 子代理不会继承 teammate 的对话身份。Subagent 服务和提供方仍是共享基础设施。普通 Session fork 保留历史;[当前身份处理](../architecture/2026-10-13-team-identity-runtime-context.zh.md)提供 fork 自己的角色。 +[基础 bundle](../../../../packages/bundle/base/README.zh.md) 插入 Team 行,并保留 base 的委派与控制工具;teammate 按[subagent](../architecture/2026-10-15-agent-teams-as-subagents.zh.md)所述使用这些工具启动。普通 Session fork 保留历史;[当前身份处理](../architecture/2026-10-13-team-identity-runtime-context.zh.md)提供 fork 自己的角色。 ## Team identity -共享 system 策略和全部工具 schema 在成员间保持一致;执行时检查角色权限。Team 工具根据调用者确定 Team,并接受成员名字,因此模型不需要 Team id。[必需 runtime-context 身份](../architecture/2026-10-13-team-identity-runtime-context.zh.md)负责身份注入、保留与 fork 处理。 +成员共享统一系统策略和工具模式;执行时强制角色权限。Team 工具解析调用方所属 Team,并使用 Session id 作为任务负责人。[必需运行时身份](../architecture/2026-10-13-team-identity-runtime-context.zh.md)负责身份注入、保留和分叉处理。 ## Provisioning and recovery -创建操作先在 Lead Session 中追加并 flush `team/member` provisioning 快照,再通过选定 fresh 或 fork provider 启动预留的 continuable child。初始 inbox 获准前的失败会追加 failed 快照;成功会先 flush child 中已接受的 inbox 条目,再追加 active。恢复会在初始消息仍处于 pending 或已进入用户消息历史时识别它。名字由第一条 provisioning 记录永久保留,包括失败后也不能复用。dispose 会关闭准入,中止并等待已获准的创建与发送操作,再停止 roster 记录的所有 live child;failed child 在 Activation 退出前仍由 cleanup 拥有,cleanup 拒绝会让 dispose 失败。 - -Root 恢复时会把未终结 provisioning 记录与独立持久 child Session 对账。直接 parent 与 continuable descriptor 匹配,并且已经记录初始用户消息,才能证明准入成功并转为 active;缺失、损坏、provider/lineage 不匹配或缺少已准入消息都会转为 failed。creator 会在同一 Lead 日志 serializer 内重读终态;如果 recovery 在创建成功时先标记 failed,creator 会 drain child 并报告 provisioning conflict,而不是遗留孤儿。这样既无需重建从未保存在 Team 日志中的初始 prompt,也能约束插件 reload 竞争。 - -fresh child 不继承对话。fork child 只捕获一次 Lead 已完成 turn 前缀,并保留为自己的持久 seed。当前 delegation turn 保持排除,与既有 fork provider 契约一致。 +队友创建和冷恢复属于子代理运行时([子代理队友](../architecture/2026-10-15-agent-teams-as-subagents.zh.md))。历史 `team/member` 记录保留阶段和描述并继续可读;Team 不写入新成员记录。 ## Mailbox and task transactions -历史 Lead-log mailbox 仍是可读格式:queued 与 delivered 记录保留稳定身份与发送者,Team payload 校验仍适用于它们。Team 不再投递或确认这些记录;新发送遵循[直接 inbox 决策](../simplification/2026-09-26-team-direct-inbox.zh.md)。 +历史 Lead-log mailbox 仍可从原始 Session 日志读取。[仅投影任务的决策](../simplification/2026-10-29-team-task-only-projection.zh.md)取代邮箱投影、检查点保留和 Team 载荷校验。历史排队消息绝不再投递或确认。 -事件投影和 checkpoint 准入期间,未知 mailbox 内容保持为已解码 JSON。校验只检查其 type,不重建字段,因为通用对象解析可能省略有效的自有 `__proto__` 键。本地声明的内容变体接受结构校验;不透明的插件字段不获得 Team 语义。缓存失效是必要的,因为只修正解析器无法恢复缓存状态中省略的键。[Team 文档](../../../../packages/experimental/agent-team/README.zh.md)拥有恢复行为的说明。 - -`send_message` 对 running、idle 和 cold target 使用 Steer。新发送返回 inbox 接收结果。[Team Steer 消息决策](../../archived/simplification/2026-08-30-team-send-message-steer.md)记录单一工具调度的理由。 +member 消息使用 subagent 的 `send_message` 操作,它对 running、idle 和 cold target 使用 Steer,并返回 inbox 接收结果。 共享 task 是带 Team-local id 与单调 revision 的完整快照。每次变更都携带 `expectedRevision`。任意 member 可以创建、读取或 claim ready 且无 owner 的任务;Owner 或 Lead 可以编辑和转换;只有 Lead 可以分配给另一个 member。数字 task id 保持在安全整数分配范围内;该范围耗尽时会失败,不会复用 id。依赖必须指向未删除任务,并形成完整 DAG。删除任务保留为 tombstone。`writeScopes` 是规范化路径前缀,只产生重叠诊断,绝不会阻止 claim 或授予写权限。 -`wait_agent` 等待调用注册后发生的下一条 roster、mailbox、task 或实时 status 边,避免模型轮询。它不会回放更早的边,因此调用方需要在唤醒或超时后重新读取权威状态。仅限 Lead 的 interrupt 使用 inbox preservation 取消当前 turn,不改变 mailbox 或 task owner。 +`wait_agent` 属于普通 subagent 控制工具,等待当前调用者的下一步收件箱,包括已有待处理输入。任务提交不结束等待、不投递消息,也不启动空闲轮次。需要另一个成员处理任务变更时,Agent 应发送消息。祖先可中断后代,不改变任务归属。 ## Shared checkout boundary -所有 member 创建时使用 Lead 的当前目录,并立即观察共享文件系统写入。后续目录变更仅影响各自 Session。策略要求 member 切分任务、记录提示性 write scope、为有序工作添加依赖,并由 Lead 检查最终 diff 和运行测试。文件系统 stale-version 拒绝后必须重新读取并 rebase 修改意图。Bash、formatter、codegen 与直接外部写入不具备等价保证。 +所有 member 创建时使用 Lead 的当前目录,并立即观察共享文件系统写入。后续目录变更仅影响各自 Session。任务策略要求 member 认领就绪任务、使用提示性 write scope 和依赖,并在完成工作后完成任务。文件系统 stale-version 拒绝后必须重新读取并 rebase 修改意图。Bash、formatter、codegen 与直接外部写入不具备等价保证。 Team 领域不会推断 branch、创建 worktree、merge 变更或静默改变 cwd。deployment 或调用方可以另外使用[显式工作树创建](2026-09-13-explicit-worktree-creation.zh.md)。这样保留既有 same-world subagent 与 sandbox 契约。 @@ -58,36 +52,36 @@ Team 领域不会推断 branch、创建 worktree、merge 变更或静默改变 c Web panel 读取 Lead Session 的 `agentTeam` 传输投影,因为现有投影流能持续更新浏览器状态,无需独立的 Team 读取 API。Lead 与 teammate 会话均从共享的跨 Session store 选择值。面板不负责投影读取:Session 加载、列表缓存值与控制基线提供数据。 -传输视图携带持久 roster phase、使用共享就绪与重叠派生逻辑的未删除任务视图,以及最后有效状态旁的 `failure`。实时活动来自 Session 状态。模型选择有自己的持久投影;面板展示共享 store 中可用的值,不打开成员会话。缺少的缓存值保持缺失,直到 Session 加载或实时更新提供它。 +任务面板使用包含就绪和重叠诊断的未删除任务视图,以及最后有效状态旁的 `failure`。历史名册字段仍可解码,但不拥有当前 UI 或分配权限。普通 subagent 导航负责成员发现;任务所有者标签复用 Session 和目录元数据。 -发布要求 `apply` 对每个已应用的 Team 事件返回新的 state 对象,并只替换被触及的集合。仅邮箱的事件保留视图引用,不发布 frame。现有传输层将每个变化后的完整视图发送给所有已连接浏览器;包 README 记录了这项开销。 +发布要求 `apply` 为每个应用的事件返回新状态,且只替换触及的集合。仅邮箱事件保留客户端视图,不发布更新。现有载体向已连接浏览器发送变更后的完整视图;包 README 记录该成本。 ## Alternatives considered -**用 peer id 扩展 direct-child subagent tool。** 拒绝,因为 parent/child 权限与 Team peer membership 是不同领域。向 continuation seam 增加 peer access 会削弱 exact-parent authorization,仍无法为 roster 与 task 提供持久 owner。 +**保留独立 Team 通信权限。** 后续的[整树通信决策](../architecture/2026-10-16-tree-wide-agent-communication.zh.md)将路由和授权置于 subagent 运行时;任务板不能授予另一条路由。 -**在 Lead 日志入队前把 mail 存入每个 target Session。** 拒绝,因为 Team 提交发送之后,target materialization 与准入仍可能失败。始终 live 的 Lead Session 是事务 owner;target recording 是 acknowledgement 与去重边界。 +**保留持久化 Team 邮箱。** 目标接纳和任务提交是独立操作。历史 Team 邮箱保持可读,当前通信使用目标收件箱。任务提交既不投递消息,也不结束等待。 **把 task ownership 或 write scope 当作锁。** 拒绝,因为外部 writer 会绕过它们,崩溃 owner 会持久保留,而路径前缀重叠不能证明语义独立。虚假的互斥保证比明确 warning 更危险。 **自动创建隔离 worktree。** 拒绝,因为 worktree 创建、branch 命名、merge 策略、ignored file、构建产物与 cleanup 都是 deployment 选择;它也会改变既有 subagent 与 sandbox 暴露的 same-world 行为。 -**在默认工具目录中启用 Team。** 拒绝,因为 scoped Team control 会覆盖同名旧全局工具,主动 delegation 也会给简单任务增加延迟和 token 成本。opt-in profile bundle 会插入 Team 并禁用旧 control,同时不向随附依赖图添加 Team 包。 +**在默认工具目录中启用 Team。** 拒绝,因为主动协调与 Team 工具 schema 会给简单任务增加延迟和 token 成本。opt-in profile bundle 会插入 Team,同时不向随附依赖图添加 Team 包。 -**使用内存 board 与 mailbox。** roster 与任务状态必须经受 settlement、HMR 与中断。历史 mailbox 记录保持可读;消息归属目标 inbox。 +**使用内存 board 与 mailbox。** 任务状态必须经受 settlement、HMR 与中断。历史 mailbox 记录保持可读;消息归属目标 inbox。 -**让 Team 工具返回未类型化 JSON。** 拒绝,因为未声明的结果类型会让 `execute` 在没有编译错误的情况下偏离对模型的承诺,也会引入在每份 roster、task 与回执上都消耗 token 的缩进。因此每个 Team 工具都声明完整的结果 schema,并由一个共享 helper 紧凑渲染。 +**让 Team 工具返回未类型化 JSON。** 拒绝,因为未声明的结果类型会让 `execute` 在没有编译错误的情况下偏离对模型的承诺,也会引入在每份 task 与 wait 结果上都消耗 token 的缩进。因此每个 Team 工具都声明完整的结果 schema,并由一个共享 helper 紧凑渲染。 ## Testing -包测试覆盖 Team 权限、provisioning 持久性、任务转换、直接 inbox 接收、发送者归属、生命周期取消和历史 mailbox 可读性。无密钥 Team profile 快照固定 policy、工具与工作流;CLI 组合测试检查持久 Team 和 child 日志。 +包测试覆盖任务转换、基于树的分配、历史名册和邮箱解码,以及任务提交后收件箱等待保持未完成。普通 subagent 测试覆盖收件箱等待;录制 Team 组合覆盖身份、任务策略和工具。 -模型可见的成员身份与可用状态遵循[工具投影说明](../../../../packages/experimental/tool-agent-team/README.zh.md);服务驻留状态和持久身份仍保持区分。 +身份和收件箱等待遵循 [subagent 控制工具](../../../../packages/subagent/tool-subagent-control/README.zh.md);任务策略遵循 [Team 工具参考](../../../../packages/team/tool-agent-team/README.zh.md)。 ## Consequences -Lead Session 保留完整 task/member 快照和历史 mailbox 记录。这优先支持可检查的恢复,而不是紧凑增量;deleted 与 delivered 历史在更广泛的 Session 保留策略生效前仍只追加。 +Lead Session 保留完整 task 快照以及历史 member 与 mailbox 记录。这优先支持可检查的恢复,而不是紧凑增量;deleted 与 delivered 历史在更广泛的 Session 保留策略生效前仍只追加。 -active roster 成员可能不驻留,因此 `inactive` 不表示失败,发送可能产生冷恢复延迟。准入失败会拒绝新发送。历史 pending 邮件永远不会被投递。provisioning 失败永久占用其名称和成员槽位。 +teammate 可能不驻留,因此 `inactive` 不表示失败,发送可能产生冷恢复延迟。历史 pending 邮件永远不会被投递。 协调可以降低 checkout 冲突概率,但无法消除文件系统 CAS 工具之外的写入。最终 diff 与测试仍是 Lead 的集成边界。 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-09-21-archive-stops-running-session-work.i18n.yaml b/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.i18n.yaml index bf652c02d6..db15a4f23d 100644 --- a/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.i18n.yaml @@ -9,8 +9,8 @@ en: 7926983089aebab2 zh: 366e32db00674a83 /agent-note-archiving-a-session-with-running-work/decision: - en: 23adfbe72d671d6c - zh: 6fec976acfa54720 + en: 703d8b6f837cc894 + zh: aabe24d201ca0d81 /agent-note-archiving-a-session-with-running-work/alternatives-considered: en: 708268e1fe4b4b75 zh: 64a8f6d6f333e090 diff --git a/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.md b/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.md index 4bc3bab644..fcaba90418 100644 --- a/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.md +++ b/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.md @@ -14,8 +14,8 @@ Archive never hides running work. The registry owns the rule and declares one ca - **Refuse by default.** `archiveSession(sessionId)` rejects a non-empty activity answer with `WorkspaceActiveSessionError`; the API maps it to `workspace/session-active`, whose details carry the families with item ids and labels. A caller that did not ask to stop work never hides it, whichever client or SDK it is. - **Stop on request, after the write.** `archiveSession(sessionId, { stopActivity: true })` skips the activity check, writes the archive, and then dispatches the stop event: the durable archive set is what the pre-step gate reads, so every wake a stop induces — a cancelled child's settlement, a queued follow-up — is already blocked. The stops are issued and never awaited to settlement, so the Remote returns once every provider's request is out and the user's row hides at once; a stop that fails is a logged warning, not a reason to undo the archive. -- **The stop is the user's stop.** The Agent registry cancels the turn with `agent.cancel({ kind: 'user' })` — the stop button's path, so the aborted tool call settles as a `tool/result` and the turn ends as `aborted`, but without the button's `keepInbox`, so queued input is discarded with a logged inbox splice. The job registry seam kills the owned jobs, installed by its constructor for every implementation through the abstract `list` and `kill`. The Subagent runtime cancels running subagent descendants (durable lineage, subagent origin, any depth, never a fork) as their parent would. Each request is contained on its own, so one child or job that throws does not keep the others running. The Schedule plugin deletes every active Host task stored for the Session ([archive admission reads the stored tasks](2026-09-22-archive-admission-reads-stored-tasks.md)) inside one slot of the same serialized queue the tools use. It reads the stored rows rather than a runtime fold of the Session log, because a Host task outlives its Session's Agent and is the only place a due reminder can be seen. Each provider owns exactly the vocabulary it already has: the Agent registry knows turns, the job seam knows jobs, the Subagent runtime knows children, the Schedule plugin knows reminders; the API Session Controller keeps only the archived-lineage step gate. No new event type and no write inside another owner's turn, so a restored Session continues from a regularly ended log and unarchive brings nothing back that was stopped. -- **Archived means no model step.** The API Session Controller's `agent/pre-step` listener rejects a step proposed for an archived Session or for a subagent descendant of one, which the loop ends as `blocked` without a request. This closes the window in which a late waking delivery — a subagent settlement, a queued follow-up, a turn started between the activity answer and the write — would run a hidden turn, and keeps a child whose cancel was ignored from working for a parent nobody can see; unarchiving lifts the gate for the lineage. +- **The stop is the user's stop.** The Agent registry cancels the turn with `agent.cancel({ kind: 'user' })` — the stop button's path, so the aborted tool call settles as a `tool/result` and the turn ends as `aborted`, but without the button's `keepInbox`, so queued input is discarded with a logged inbox splice. The job registry seam kills the owned jobs, installed by its constructor for every implementation through the abstract `list` and `kill`. The Subagent runtime reports and cancels running descendants through each Activation’s verified ancestry, with live header ancestry for unmanaged Agents (any depth, never a fork). Releasing a creator does not erase its descendants’ membership. Each request is contained on its own, so one child or job that throws does not keep the others running. The Schedule plugin deletes every active Host task stored for the Session ([archive admission reads the stored tasks](2026-09-22-archive-admission-reads-stored-tasks.md)) inside one slot of the same serialized queue the tools use. It reads the stored rows rather than a runtime fold of the Session log, because a Host task outlives its Session's Agent and is the only place a due reminder can be seen. Each provider owns exactly the vocabulary it already has: the Agent registry knows turns, the job seam knows jobs, the Subagent runtime knows children, the Schedule plugin knows reminders; the API Session Controller keeps only the archived-lineage step gate. No new event type and no write inside another owner's turn, so a restored Session continues from a regularly ended log and unarchive brings nothing back that was stopped. +- **Archived means no model step.** The API Session Controller checks archived Sessions and available header ancestry in `agent/pre-step`. The Subagent runtime checks each managed Agent’s retained creation ancestors in the same event; intermediate creators may be offline. Both reject the step, which the loop ends as `blocked` without a request. This closes the window in which a late waking delivery — a subagent settlement, a queued follow-up, a turn started between the activity answer and the write — would run a hidden turn, and keeps a child whose cancel was ignored from working for a parent nobody can see; unarchiving lifts the gate for the lineage. - **The refusal is the confirmation.** The sidebar sends the plain archive first; a quiet Session archives without a dialog as before. The `workspace/session-active` refusal opens the stop-and-archive dialog, whose list comes from the Host's answer rather than a client-side mirror of running state, so the dialog names exactly what the Host will stop. Confirming resends with `stopActivity`; the notice offers undo, which restores the Session without resuming the stopped work. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.zh.md b/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.zh.md index c7a912fa65..c511810b1d 100644 --- a/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.zh.md +++ b/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.zh.md @@ -14,8 +14,8 @@ - **默认拒绝。** `archiveSession(sessionId)` 对非空的活动答案以 `WorkspaceActiveSessionError` 拒绝;API 把它映射为 `workspace/session-active`,details 携带各族及各项的 id 与名称。没有要求停止工作的调用方绝不会把工作藏起来,无论它是哪个客户端或 SDK。 - **按请求停止,先写后停。** `archiveSession(sessionId, { stopActivity: true })` 跳过活动检查,先写入归档,再派发停止事件:pre-step 门禁读取的正是持久化的归档集合,因此停止所引发的每一次唤醒——被取消子代理的结算、排队的 follow-up——都已被拦下。停止请求只发出、从不等待收敛,因此 Remote 在每个提供方的请求发出后即返回,用户的行随即隐藏;停止失败只是一条 warn 日志,不是撤销归档的理由。 -- **停止即用户的停止。** Agent 注册表用 `agent.cancel({ kind: 'user' })` 取消回合——与停止按钮同一条路径,因此被打断的工具调用以 `tool/result` 收口、回合以 `aborted` 结束,但不带按钮的 `keepInbox`,因此排队输入被丢弃并记录一次收件箱拼接。任务注册表接缝 kill 所属任务,由其构造函数只通过抽象的 `list` 与 `kill` 为每个实现安装。Subagent runtime 以父身份取消运行中的子代理子孙(持久化血缘、subagent 来源、任意深度、从不包括 fork)。每个请求各自兜住,一个抛错的子代理或任务不会让其余的继续跑。Schedule 插件在与工具相同的串行队列的一个槽位里删除该 Session 已存储的全部活动宿主任务([归档准入读取已存储的任务](2026-09-22-archive-admission-reads-stored-tasks.zh.md))。它读已存储的行,而不是对会话日志做 runtime fold,因为宿主任务比其 Session 的 Agent 活得更久,是到期提醒唯一能被看见的地方。每个提供方只拥有它本来就有的词汇:Agent 注册表认识回合,任务接缝认识任务,Subagent runtime 认识子代理,Schedule 插件认识提醒;API Session Controller 只保留已归档血缘的步骤门禁。没有新事件类型,也不在别的所有者的回合内写入,因此恢复后的 Session 从一份正常收尾的日志继续,取消归档不会带回任何被停止的东西。 -- **已归档即不跑模型步。** API Session Controller 的 `agent/pre-step` 监听器拒绝为已归档 Session 或其子代理子孙提出的步骤,loop 以 `blocked` 收口且不发请求。这封住了迟到的唤醒投递——子代理结算、排队的 follow-up、在活动作答与写入之间开始的回合——跑出隐藏回合的窗口,也让无视 cancel 的子代理无法替一个谁也看不见的父级干活;取消归档即为整条血缘解除门禁。 +- **停止即用户的停止。** Agent 注册表用 `agent.cancel({ kind: 'user' })` 取消回合——与停止按钮同一条路径,因此被打断的工具调用以 `tool/result` 收口、回合以 `aborted` 结束,但不带按钮的 `keepInbox`,因此排队输入被丢弃并记录一次收件箱拼接。任务注册表接缝 kill 所属任务,由其构造函数只通过抽象的 `list` 与 `kill` 为每个实现安装。Subagent runtime 通过各 Activation 已验证的祖先关系报告并取消运行中的后代,非托管 Agent 使用在线 header 血缘(任意深度,从不包括 fork)。释放创建者不会抹去后代的归属。每个请求各自兜住,一个抛错的子代理或任务不会让其余的继续跑。Schedule 插件在与工具相同的串行队列的一个槽位里删除该 Session 已存储的全部活动宿主任务([归档准入读取已存储的任务](2026-09-22-archive-admission-reads-stored-tasks.zh.md))。它读已存储的行,而不是对会话日志做 runtime fold,因为宿主任务比其 Session 的 Agent 活得更久,是到期提醒唯一能被看见的地方。每个提供方只拥有它本来就有的词汇:Agent 注册表认识回合,任务接缝认识任务,Subagent runtime 认识子代理,Schedule 插件认识提醒;API Session Controller 只保留已归档血缘的步骤门禁。没有新事件类型,也不在别的所有者的回合内写入,因此恢复后的 Session 从一份正常收尾的日志继续,取消归档不会带回任何被停止的东西。 +- **已归档即不跑模型步。** API Session Controller 在 `agent/pre-step` 中检查已归档 Session 和可用的 header 血缘。Subagent runtime 在同一事件中检查每个托管 Agent 保留的创建祖先;中间创建者可以离线。两者都会拒绝该步骤,loop 以 `blocked` 收口且不发请求。这封住了迟到的唤醒投递——子代理结算、排队的 follow-up、在活动作答与写入之间开始的回合——跑出隐藏回合的窗口,也让无视 cancel 的子代理无法替一个谁也看不见的父级干活;取消归档即为整条血缘解除门禁。 - **拒绝即确认。** 侧栏先发普通归档;静止的 Session 一如既往无对话框直接归档。`workspace/session-active` 拒绝会打开"停止并归档"对话框,其清单来自宿主的答案而非客户端对运行状态的镜像,因此对话框列出的正是宿主将要停止的内容。确认后带 `stopActivity` 重发;提示提供撤销,撤销只恢复 Session,不会让被停止的工作继续。 ## 考虑过的替代方案 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-09-15-shipped-optional-bundles.i18n.yaml b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml index 9e538f26c6..970eaf9557 100644 --- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml @@ -9,8 +9,8 @@ en: dc43ba84345e1405 zh: dbc03572ab7710d2 /agent-note-ship-optional-bundles-with-the-installation/decision: - en: 2f20e356517715a6 - zh: 35c7ff8c4cd8a712 + en: 0f3e0daee65b6510 + zh: 2e9c8000da75fb6c /agent-note-ship-optional-bundles-with-the-installation/alternatives-considered: en: 95b48b61757f1f13 zh: e9fb2d6ea492a4bc diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md index 74f824fc88..aaa055f7b5 100644 --- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md @@ -14,7 +14,6 @@ The launcher names in `OPTIONAL_BUNDLES` (`packages/util/official-bundles/src/bu Default-product isolation keeps its rules with one declared exception: an optional bundle's dependency graph is outside the default product. The static gate skips the `dependencies` edge from `@deepseek-ai/dsh` to a listed bundle and still rejects a runtime import, a shipped composition, a preset, or a default template that names it, an experimental dependency the list does not name, and a listed name that is not a runtime dependency or not a bundle. The workspace-constraints check accepts the same `dependencies` edges and no other runtime section, and the packed-install release check skips them from the installed entry package while requiring each listed bundle to be installed. -The [single-bundle reference](../../../../packages/experimental/agent-team-profile/README.md) supersedes the two Agent Teams selections with one `@deepseek-ai/dsh-experimental-agent-team-profile` selection. This note retains the installation-owned optional-bundle policy; the [optional-bundle decision for experimental capabilities](../architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md) owns composition and display metadata; `OPTIONAL_BUNDLES` owns the current shipped list. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md index 129f76c454..6135f08763 100644 --- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md @@ -14,7 +14,6 @@ Web 插件页只管理用户装进 profile 的组合包。像 Agent Teams 这样 默认产品隔离的规则保持不变,只声明一个例外:可选组合包的依赖图在默认产品之外。静态门禁跳过从 `@deepseek-ai/dsh` 到列表中组合包的 `dependencies` 边,仍然拒绝运行时 import、随附组合、preset 或默认模板对它的引用,拒绝列表没有点名的实验依赖,也拒绝不是运行时依赖或不是组合包的列表项。workspace 约束检查接受同样的 `dependencies` 边而不接受其他运行时依赖段;发布时的 packed-install 检查对已安装入口包跳过这些边,并要求列表中的每个组合包都已安装。 -[单组合包参考](../../../../packages/experimental/agent-team-profile/README.zh.md)将两个 Agent Teams 选项替换为一个 `@deepseek-ai/dsh-experimental-agent-team-profile` 选项。本记录保留由安装方决定可选组合包的策略;[实验能力的可选 bundle 决策](../architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md)定义组合与显示元数据;当前随附名单由 `OPTIONAL_BUNDLES` 管理。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/simplification/2026-09-26-team-direct-inbox.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-26-team-direct-inbox.i18n.yaml deleted file mode 100644 index f830391e86..0000000000 --- a/.agents/notes/implemented/simplification/2026-09-26-team-direct-inbox.i18n.yaml +++ /dev/null @@ -1,19 +0,0 @@ -# Bilingual-pair consistency record for 2026-09-26-team-direct-inbox.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-09-26-team-direct-inbox.md -/agent-note-team-messages-use-the-target-inbox: - en: ae17b789652784b5 - zh: fa462ace95c8cdaa -/agent-note-team-messages-use-the-target-inbox/problem: - en: 83296699f94c77d3 - zh: e681a426bf0a703a -/agent-note-team-messages-use-the-target-inbox/decision: - en: 8d5fed34f19c7302 - zh: 25417793d3797775 -/agent-note-team-messages-use-the-target-inbox/alternatives-considered: - en: e847556d8e9f1071 - zh: bf1abe2f0e201eac -/agent-note-team-messages-use-the-target-inbox/consequences: - en: 24085acb09438dc8 - zh: 27c92e8de8cf7dbe diff --git a/.agents/notes/implemented/simplification/2026-10-29-inbox-only-agent-wait.i18n.yaml b/.agents/notes/implemented/simplification/2026-10-29-inbox-only-agent-wait.i18n.yaml new file mode 100644 index 0000000000..3c03e11100 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-10-29-inbox-only-agent-wait.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-29-inbox-only-agent-wait.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-10-29-inbox-only-agent-wait.md +/agent-note-inbox-only-agent-waits: + en: 8a451f9706af8dea + zh: 9818b5abdee12a9c +/agent-note-inbox-only-agent-waits/problem: + en: 7a6e28745bda3305 + zh: e862166572cb9a9f +/agent-note-inbox-only-agent-waits/decision: + en: 553a1e166dd7e7e9 + zh: c05f16577fa6753c +/agent-note-inbox-only-agent-waits/alternatives-considered: + en: 64a44ad3884a90c3 + zh: 1fffe0c6fd78acbe +/agent-note-inbox-only-agent-waits/consequences: + en: 45b6d456c3ca226c + zh: e9ebacd4b6bcaeca diff --git a/.agents/notes/implemented/simplification/2026-10-29-inbox-only-agent-wait.md b/.agents/notes/implemented/simplification/2026-10-29-inbox-only-agent-wait.md new file mode 100644 index 0000000000..80e46f63e6 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-10-29-inbox-only-agent-wait.md @@ -0,0 +1,31 @@ +# Agent Note: Inbox-only Agent waits + +Status: implemented + +English | [中文](2026-10-29-inbox-only-agent-wait.zh.md) + +## Problem + +A task-board commit changes shared state but does not deliver input to a member. Ending every member's wait on each edit couples ordinary subagent controls to Team coordination and makes callers resume without a message explaining what needs attention. + +## Decision + +`wait_agent` observes only its exact caller's next-step inbox through the existing insertion event. It subscribes before checking pending input, consumes nothing, and removes listeners and timers on input, timeout, cancellation, caller disposal, or tool unload. Queued next-turn input, other Agents' activity, and task-board changes do not end the wait. + +Task commands append and flush their revisions without notifying waits or writing inbox messages. A caller sends an explicit message when a member needs to act on an update. No Agent method, shared waiting service, or task-specific waiting event is introduced. + +This supersedes only the wait-signal decision in [Team task boards](../architecture/2026-10-19-team-task-board-inbox.md). Its tree authority, durable task storage, historical non-authority, and separation from ordinary controls remain applicable. No Session event generation changes. + +## Alternatives considered + +**Notify all current waits after every task commit.** This avoids duplicate inbox state but still makes unrelated task edits interrupt every waiting member. Moving the notification behind a service method retains that behavior and coupling. + +**Persist each task change as a message.** The task journal already preserves revisions; per-member notices add unread state and delivery recovery. Explicit messages communicate which updates require action. + +**Add waiting to Agent or its inbox.** Existing input events and tool-owned cancellation already implement the required wait without extending the core API. + +## Consequences + +Task updates alone do not resume a waiting or idle member. Callers must communicate when work should continue. The tool returns no task contents; members query the board when needed. Task changes before or during a wait have the same effect: none. + +Unit tests cover pending input, caller isolation, concurrent waits, cancellation, disposal, and task commits that leave waits pending. The recorded task-board scenario checks past and concurrent commits against timed-out waits, while completion notification remains ordinary inbox input. diff --git a/.agents/notes/implemented/simplification/2026-10-29-inbox-only-agent-wait.zh.md b/.agents/notes/implemented/simplification/2026-10-29-inbox-only-agent-wait.zh.md new file mode 100644 index 0000000000..6bf2e39562 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-10-29-inbox-only-agent-wait.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 仅等待收件箱的 Agent 等待 + +Status: implemented + +[English](2026-10-29-inbox-only-agent-wait.md) | 中文 + +## 问题 + +任务板提交改变共享状态,但不向成员投递输入。每次编辑都结束所有成员的等待,会使普通 subagent 控制依赖 Team 协调,并让调用者在没有消息说明需要关注什么时恢复执行。 + +## 决策 + +`wait_agent` 仅通过现有插入事件观察当前调用者自己的下一步收件箱。它先订阅再检查待处理输入,不消费输入,并在输入到达、超时、取消、调用者释放或工具卸载时移除监听器和定时器。排队的下一轮输入、其他 Agent 的活动和任务板变化不会结束等待。 + +任务命令追加并刷盘修订,不通知等待者,也不写入收件箱消息。需要成员处理更新时,调用者发送明确消息。不新增 Agent 方法、共享等待服务或任务专用等待事件。 + +这仅取代[Team 任务板](../architecture/2026-10-19-team-task-board-inbox.zh.md)中的等待信号决策。树权限、持久任务存储、历史记录不授予权限及与普通控制工具分离的决策仍然适用。不改变 Session 事件代次。 + +## 考虑过的替代方案 + +**每次任务提交后通知所有当前等待。** 这避免了重复收件箱状态,但仍让无关任务编辑打断每个等待成员。将通知移到服务方法后仍保留该行为和耦合。 + +**将每次任务变化持久化为消息。** 任务日志已经保留修订;逐成员通知增加未读状态和投递恢复。明确消息可以表达哪些更新需要行动。 + +**在 Agent 或收件箱中增加等待。** 现有输入事件和工具拥有的取消机制已能实现所需等待,无须扩展核心 API。 + +## 后果 + +仅更新任务不会恢复等待中或空闲的成员。需要继续工作时,调用者必须通信。工具不返回任务内容;成员按需查询任务板。等待前和等待期间发生的任务变化效果相同:都不影响等待。 + +单元测试覆盖已有输入、调用者隔离、并行等待、取消、释放,以及不结束等待的任务提交。任务板录制场景检查过去和并发提交下的等待超时,完成通知仍作为普通收件箱输入。 diff --git a/.agents/notes/implemented/simplification/2026-10-29-team-task-only-projection.i18n.yaml b/.agents/notes/implemented/simplification/2026-10-29-team-task-only-projection.i18n.yaml new file mode 100644 index 0000000000..cf5fb34bc3 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-10-29-team-task-only-projection.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-29-team-task-only-projection.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-10-29-team-task-only-projection.md +/agent-note-team-projection-retains-only-tasks-and-mode: + en: 030f2abb8b5137fe + zh: e661455f374c8b1f +/agent-note-team-projection-retains-only-tasks-and-mode/problem: + en: 43bd1c0daacd2b86 + zh: a35efb4e84cd9288 +/agent-note-team-projection-retains-only-tasks-and-mode/decision: + en: dd21640eb965f332 + zh: 345bc4df573cbadf +/agent-note-team-projection-retains-only-tasks-and-mode/alternatives-considered: + en: 96a0f421f9f92746 + zh: 314f4557f391f90d +/agent-note-team-projection-retains-only-tasks-and-mode/consequences: + en: 1c1b767b7905869f + zh: 1241351bfc433b70 diff --git a/.agents/notes/implemented/simplification/2026-10-29-team-task-only-projection.md b/.agents/notes/implemented/simplification/2026-10-29-team-task-only-projection.md new file mode 100644 index 0000000000..57a85f4105 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-10-29-team-task-only-projection.md @@ -0,0 +1,29 @@ +# Agent Note: Team projection retains only tasks and mode + +Status: implemented + +English | [中文](2026-10-29-team-task-only-projection.zh.md) + +## Problem + +The Team projection retained historical member, message, and delivery collections after subagent discovery and messaging replaced their runtime consumers. Its retired lifecycle and mailbox validators could still fail the current task board, while checkpoints retained message bodies with no current reader. + +## Decision + +Project only tasks, saved mode, and their recovery metadata. Ignore `team/member` and `team/message/*` payloads without rebuilding or validating their former state machines. This deliberately gives up historical teammate and mailbox state recovery and the associated validation failures. The user accepted that loss; task recovery remains required. + +Checkpoint version 7 invalidates prior state, including failures caused by retired records. The registry rebuilds from the unchanged Session log. Historical event declarations, format migrations, message sources, and attachment export remain supported; no Session format bump or log conversion is needed. + +This partially supersedes the projection and mailbox-validation decisions in [Agent Teams](../feature/2026-08-05-agent-teams.md). The [subagent identity](../architecture/2026-10-15-agent-teams-as-subagents.md) and [inbox-only wait](2026-10-29-inbox-only-agent-wait.md) decisions remain active, as do task durability and shared-checkout semantics. + +## Alternatives considered + +**Retain decode-only roster and mailbox projections.** No production consumer reads these collections. Keeping them preserves message history in checkpoints and lets obsolete validation stop current task operations. + +**Migrate or delete historical log events.** The task board can ignore these records directly. Rewriting them adds migration work and would discard archival data that existing format readers and exports can still use. + +## Consequences + +Old teammate and mailbox state is unavailable through Team projection. Original log records remain readable, but neither grant authority nor trigger delivery. Reintroducing recovery requires a concrete consumer and an explicit decision about its retained data and failure scope. + +Tests cover ignored legacy payloads, version-6 checkpoint invalidation, current checkpoint suffix replay, JSONL restart with task mutation, stable view references, and a recorded headless profile accepting a task after retired lifecycle and mailbox records. Task and mode validation still fail visibly. diff --git a/.agents/notes/implemented/simplification/2026-10-29-team-task-only-projection.zh.md b/.agents/notes/implemented/simplification/2026-10-29-team-task-only-projection.zh.md new file mode 100644 index 0000000000..221abe771b --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-10-29-team-task-only-projection.zh.md @@ -0,0 +1,29 @@ +# Agent Note: Team 投影仅保留任务与模式 + +Status: implemented + +[English](2026-10-29-team-task-only-projection.md) | 中文 + +## Problem + +子代理发现和通信替代运行时消费者后,Team 投影仍保留历史成员、消息与投递集合。已退役的生命周期和邮箱校验器仍可能使当前任务板失败,而检查点保留着没有当前读取方的消息正文。 + +## Decision + +仅投影任务、保存的模式及其恢复元数据。忽略 `team/member` 和 `team/message/*` 载荷,不再重建或校验原有状态机。这明确放弃历史队友与邮箱状态恢复,以及相应的校验失败行为。用户接受这一能力损失;任务恢复仍为必要能力。 + +检查点版本 7 使旧状态失效,包括退役记录导致的失败。注册表从未改写的 Session 日志重建状态。历史事件声明、格式迁移、消息来源和附件导出仍受支持;无需提升 Session 格式版本或转换日志。 + +本决策部分取代 [Agent Teams](../feature/2026-08-05-agent-teams.zh.md) 的投影与邮箱校验决策。[子代理身份](../architecture/2026-10-15-agent-teams-as-subagents.zh.md)和[仅等待收件箱](2026-10-29-inbox-only-agent-wait.zh.md)决策仍有效,任务持久性与共享检出语义也保持有效。 + +## Alternatives considered + +**保留仅供解码的名册与邮箱投影。** 没有生产消费者读取这些集合。保留它们会在检查点中保存消息历史,并允许过时校验阻断当前任务操作。 + +**迁移或删除历史日志事件。** 任务板可以直接忽略这些记录。改写增加迁移工作,还会丢失现有格式读取器和导出功能仍可使用的归档数据。 + +## Consequences + +Team 投影不再提供旧队友与邮箱状态。原始日志记录仍可读取,但不授予权限,也不触发投递。重新引入恢复需要具体消费者,并明确决定保留的数据和失败影响范围。 + +测试覆盖忽略旧载荷、版本 6 检查点失效、当前检查点后缀重放、JSONL 重启后修改任务、稳定视图引用,以及录制的 headless profile 在退役生命周期与邮箱记录之后接受任务。任务和模式校验仍可见地报错。 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/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml index 0fa00ec1bf..778be9ae01 100644 --- a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml @@ -21,11 +21,11 @@ en: f07bb6fbbdd01fe4 zh: 9313a0ef2a22af12 /agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/performance-gate: - en: 37faaffb4662fd54 - zh: df0a4bf016393be9 + en: 3ceb80f455e9a47d + zh: 89d8bd11b42d31b0 /agent-note-linear-credential-matching-and-performance-gates-for-user-text/alternatives-considered: - en: 559a8037caa9e3aa - zh: 4f3719ed09445cc2 + en: b5bfb4cfa231ce0f + zh: 631b9f2978241bc1 /agent-note-linear-credential-matching-and-performance-gates-for-user-text/consequences: en: 8f9d25b72d3e7660 zh: e5c6ae0194905876 diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md index c3c361047f..7c92d9c72e 100644 --- a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md @@ -43,7 +43,7 @@ English | [中文](2026-10-11-user-text-credential-matching.zh.md) | Item | Content | |---|---| | Workloads | Without credentials: ordinary messages, and long prose, code, base64, Markdown, log, JSON, and shell text. With credentials: messages each carrying one kind (model key, GitHub token, cloud key ID, JWT, address password, private-key block), and batches of `.env` keys. Adversarial: repeated input shaped against each format's prefix, length, and separator rules, at two sizes. All generated from constants; credential samples are assembled at run time | -| Timing | Ten unmeasured pairs warm each workload with the same evaluation count as measured pairs. Scaling inputs share one group: every warm-up and measured round evaluates both sizes adjacently and alternates which size goes first, limiting drift between sizes. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A single-document sample averages twenty evaluations, or forty for the half-sized input of a scaling pair, so both sizes process the same total keys or characters per sample; a 5,000-message sample evaluates the batch once. Garbage collection during evaluation remains included. Each side selects its minimum sample mean, with the mean process CPU time from that sample. The worker JSON in CI logs includes every measured pair and averaging count; the HTML report shows the selected with-credential mean. Registry disposal completes outside the timer before the next evaluation, preventing cleanup and retained registries from accumulating. Warm-up retains no matcher memo | +| Timing | Ten unmeasured pairs warm each workload with the same evaluation count as measured pairs. Scaling inputs share one group: every warm-up and measured round evaluates both sizes adjacently and alternates which size goes first, limiting drift between sizes. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A single-document sample averages twenty evaluations, or forty for the half-sized input of a scaling pair, so both sizes process the same total keys or characters per sample; a 5,000-message sample evaluates the batch once. Garbage collection during evaluation remains included. Absolute time budgets use each side's minimum sample mean, with the mean process CPU time from that sample. Doubling checks take the median of the five within-round ratios, keeping the adjacent samples paired through aggregation. The worker JSON in CI logs includes every measured pair and averaging count; the HTML report shows the selected with-credential mean. Registry disposal completes outside the timer before the next evaluation, preventing cleanup and retained registries from accumulating. Warm-up retains no matcher memo | | Allocation | A separate process with `--expose-gc` and a fixed semi-space larger than any evaluation; after warm-up and GC, the matcher alone answers every query once and the young-generation growth is taken; GC event timestamps confirm no GC ran inside the interval | | Assertions | Without credentials: absolute budgets and a cap on the with/without time ratio; with credentials: absolute budgets and the time ratio when the key count doubles; adversarial: absolute budgets and doubling ratios; allocation: a floor plus caps per credential, per line break inside masks, and per masked character | | Excluded | Cold code compilation, React rendering, browser paint and layout; allocation results are asserted and printed to the console, not added to the HTML report | @@ -54,7 +54,7 @@ The reference constants are calibrated for minima of five single evaluations. Mi ## Alternatives considered -- **Finishing one scaling size before measuring the other**: separate phases compare different load and V8 tiering conditions. Identical code measured JWT doubling at 1.91× in one CI run and 2.70× in another; a local Node 24 run also exceeded the unchanged 2.5× limit on a different input. Adjacent, alternating size rounds retain the existing sample counts, minimum-of-five aggregation, GC inclusion, workloads, and limits. A controlled extra evaluation cost on only the 128 KiB input must still fail the doubling assertion. +- **Finishing one scaling size before measuring the other**: separate phases compare different load and V8 tiering conditions. Identical code measured JWT doubling at 1.91× in one CI run and 2.70× in another; a local Node 24 run also exceeded the unchanged 2.5× limit on a different input. Adjacent, alternating size rounds retain the sample counts, GC inclusion, workloads, and limits. Independent minima can still select rounds at different speeds; the median of paired ratios prevents one faster half-sized sample from deciding the scaling result. A controlled extra evaluation cost on only the 128 KiB input must still fail the doubling assertion. - **Equal evaluation counts at both scaling sizes**: the larger input allocates twice as much per sample, so its fastest sample can include a collection while the smaller input's fastest sample includes none. Equal total input per sample makes the per-evaluation means compare similar allocation volumes without excluding GC or changing the doubling limit. - **Unwarmed, separate baseline and matcher batches**: sub-millisecond baseline measurements can reach optimized code before the matcher’s newly exercised paths do, and separate batches amplify clock-speed drift. Per-workload warm-up, adjacent pairs, and fixed batches compare warmed code while limiting noise in short intervals and preserving fresh documents and matcher state. - **One quantified pattern per format, run from every query**: patterns without a hit scan to the end of the text each time, so cost grows quadratically with credentials, and length quantifiers backtrack on failure. diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md index 7541efa2de..f5b31146b8 100644 --- a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md @@ -43,7 +43,7 @@ Status: implemented | 项 | 内容 | |---|---| | 负载 | 无凭据:普通消息,以及散文、代码、base64、Markdown、日志、JSON、shell 长文本。含凭据:每条消息含一种凭据(模型密钥、GitHub 令牌、云密钥 ID、JWT、地址密码、私钥块),以及成批的 `.env` 密钥。对抗:针对每种格式的前缀、长度与分隔规则构造的重复输入,两种长度。全部由常量生成,凭据样本运行时拼接 | -| 计时 | 每个负载先用十组未计入结果的配对预热,每组的求值次数与测量配对相同。翻倍比较的两个输入属于同一组:每轮预热和测量相邻求值两个尺寸,并交替先测的尺寸,减少尺寸之间的漂移。五组测量交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档样本取二十次求值的平均值;翻倍比较中的小输入取四十次,使两种大小的每个样本处理相同总量的密钥或字符;5,000 条消息的样本则整批求值一次。求值期间的垃圾回收仍计入耗时。两侧各取样本均值的最小值,并保留对应样本的平均进程 CPU 时间。CI 日志中的 worker JSON 包含全部测量配对和求平均值的次数,HTML 报告展示选中的带凭据匹配器均值。每次求值在计时结束后等待注册表清理完成,再开始下一次,避免清理工作和未释放注册表积累。预热不保留匹配器记忆 | +| 计时 | 每个负载先用十组未计入结果的配对预热,每组的求值次数与测量配对相同。翻倍比较的两个输入属于同一组:每轮预热和测量相邻求值两个尺寸,并交替先测的尺寸,减少尺寸之间的漂移。五组测量交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档样本取二十次求值的平均值;翻倍比较中的小输入取四十次,使两种大小的每个样本处理相同总量的密钥或字符;5,000 条消息的样本则整批求值一次。求值期间的垃圾回收仍计入耗时。绝对耗时预算取两侧样本均值的最小值,并保留对应样本的平均进程 CPU 时间。翻倍检查取五个同轮次耗时比的中位数,使相邻样本在汇总时仍保持配对。CI 日志中的 worker JSON 包含全部测量配对和求平均值的次数,HTML 报告展示选中的带凭据匹配器均值。每次求值在计时结束后等待注册表清理完成,再开始下一次,避免清理工作和未释放注册表积累。预热不保留匹配器记忆 | | 分配 | 单独进程,`--expose-gc`,semi-space 固定为大于任何一次求值的大小;预热后 GC,再单独驱动匹配器回答全部查询一遍,取新生代增量;用 GC 事件的时间戳确认区间内没有 GC | | 断言 | 无凭据:绝对预算,以及"有/无凭据匹配器"的耗时比上限;含凭据:绝对预算,密钥数翻倍时的耗时比;对抗:绝对预算与翻倍耗时比;分配:底噪加每个凭据、每个遮挡内换行和每个遮挡字符的上限 | | 排除 | 冷代码编译、React 渲染、浏览器绘制与布局;分配结果只用于断言并输出到控制台,不进 HTML 报告 | @@ -54,7 +54,7 @@ Status: implemented ## 考虑过的替代方案 -- **测完一个翻倍尺寸后再测另一个**:两个阶段比较的负载和 V8 编译层级状态不同。同一份代码的 JWT 翻倍比在两轮 CI 中分别为 1.91 倍和 2.70 倍;本地 Node 24 也在另一个输入上超过既有 2.5 倍上限。相邻、交替的尺寸轮次保留既有样本数、五组取最小值的汇总方式、GC 计入规则、负载与上限。仅给 128 KiB 输入增加受控求值开销时,翻倍断言仍须失败。 +- **测完一个翻倍尺寸后再测另一个**:两个阶段比较的负载和 V8 编译层级状态不同。同一份代码的 JWT 翻倍比在两轮 CI 中分别为 1.91 倍和 2.70 倍;本地 Node 24 也在另一个输入上超过既有 2.5 倍上限。相邻、交替的尺寸轮次保留样本数、GC 计入规则、负载与上限。独立选取最小值仍可能选中运行速度不同的轮次;配对比值的中位数避免单个更快的小输入样本决定翻倍结果。仅给 128 KiB 输入增加受控求值开销时,翻倍断言仍须失败。 - **两种大小使用相同求值次数**:大输入每个样本的分配量翻倍,因而其最快样本可能包含一次垃圾回收,而小输入的最快样本没有。让每个样本的输入总量相等,使单次求值均值比较相近的分配量,无需排除垃圾回收或修改翻倍上限。 - **不预热、分批测量基线和匹配器**:亚毫秒基线测量可能先进入优化后的代码,而匹配器新触及的路径还未完成优化;分批测量也会放大时钟频率漂移。逐负载预热、相邻配对和固定批次比较预热后的代码,降低短区间测量的噪声,同时保持文档和匹配器状态全新。 - **每次查询对每个格式各跑一个带量词的正则**:没有命中的正则每次都扫到文末,凭据多时平方增长;长度量词在失败时回溯。 diff --git a/.agents/skills/dsh-cua-macos-signing/SKILL.md b/.agents/skills/dsh-cua-macos-signing/SKILL.md new file mode 100644 index 0000000000..6d28147f29 --- /dev/null +++ b/.agents/skills/dsh-cua-macos-signing/SKILL.md @@ -0,0 +1,78 @@ +--- +name: dsh-cua-macos-signing +description: Proactively recommend a personal fixed signing key to macOS DSH developers using Computer Use from source, rebuilding the helper, or switching worktrees, even before they ask about repeated permissions. After they choose it, generate or reuse the key outside DSH home and configure its path. Also handles unlocking and removal; installed release users do not need this setup. +--- + +# macOS CUA development signing + +## Recognize developer use and recommend signing + +When a macOS developer uses DSH Computer Use from a source checkout, proactively recommend a reusable personal signing identity. Relevant signals include launching DSH from source, rebuilding the helper, preparing another worktree, or investigating repeated permission prompts. The developer does not need to be editing CUA code, and does not need to ask about certificates first. Before a cross-worktree permission-retention test, establish that both builds select the same fixed identity. + +Use the task context and available configuration to choose the recommendation. If an identity is already configured, recommend reusing it and unlock it when needed; do not propose another key for each worktree. If signing is unset or `ad-hoc`, briefly explain that code-hash changes can require fresh grants, recommend creating one local identity under `~/.cua-macos-keys/`, and offer to configure `cordis.patch.yml` to select it. Mention that the first macOS grant remains manual. Follow the authorization rule below before creating keys or editing configuration; a recommendation alone does not authorize setup. + +For example: “Since you are using Computer Use from source and switching worktrees, I recommend creating one personal signing identity and reusing it across builds to reduce repeated authorization. I can generate it under `~/.cua-macos-keys/` and configure this DSH home to use it. You will still grant macOS permissions once.” Adapt the wording to the user's language and task. + +Do not recommend developer keys merely because a user is using the installed DSH app, working on an unrelated repository, or developing without Computer Use. If they choose to keep ad-hoc signing, continue their task and do not repeat the recommendation without a relevant change in circumstances. + +## Understand the signing choices + +Source builds default to `sourceSigning: ad-hoc`. They need no developer certificate. An ad-hoc designated requirement includes the code hash, so changed binaries, clean rebuilds, or different worktrees can require fresh Accessibility and Screen Recording grants even when their sources are unchanged. For developers who want fewer repeated grants, configure one fixed certificate and use it across builds. The first grant is still manual; verify actual permission retention rather than claiming that matching signatures alone prove it. + +The [macOS provider](../../../packages/experimental/cua-macos/README.md#use-this-package) owns the two configuration choices: `ad-hoc`, or `{ identity, keychain }`. The build consumes an existing certificate; it never creates or unlocks one. Developer key material stays outside DSH home. DSH home stores only the configuration selecting it. + +## Create or reuse an identity + +Run from a repository checkout on macOS with the selected Apple command-line tools. An explicit request to set up developer signing or reduce repeated development grants authorizes setup and the corresponding local configuration edit. For advice-only requests, explain the choices without creating an identity. + +```sh +python3 .agents/skills/dsh-cua-macos-signing/scripts/signing_identity.py setup +``` + +The script creates or reuses `~/.cua-macos-keys/dev/signing.keychain-db`, stores its password and certificate fingerprint in the adjacent `identity.json` with mode `0600`, and keeps the directory private. It adds only its dedicated keychain to the user search list and enables `codesign` access without prompting for the login password. A sibling `dev.lock` serializes setup and removal across worktrees; custom directory names keep their full name before `.lock`. Supply `--directory ` when the developer chose another location outside DSH home and the checkout. + +Setup claims only a new or empty directory and writes `.dsh-cua-macos-signing` as its ownership marker. Setup and removal refuse directories without that marker, unexpected entries, or symlinked identity files before changing keychains or identity files. Preserve unmarked identities from earlier tooling; do not add a marker automatically. They can still be selected as existing user keychains. + +Setup prints only the certificate fingerprint and absolute keychain path as JSON. Keep the password record out of chat, logs, commits, and configuration. Other processes running as this user can read the stored password and use this development identity; this is the local developer setup's access model. + +If an existing record is damaged or its keychain/certificate is missing, setup stops. Do not silently replace that identity: replacement changes the certificate and can require new grants. Follow the reported remove/setup commands only when identity replacement is authorized. + +## Configure the source build + +Resolve the DSH home used by the developer (`DSH_HOME`, otherwise `~/.dsh`). Merge the two printed values into its `cordis.patch.yml`, targeting the entry that loads `@deepseek-ai/dsh-experimental-cua-macos`. The shipped entry ID is `cua`: + +```yaml +- id: cua + config: + sourceSigning: + identity: "" + keychain: "/Users//.cua-macos-keys/dev/signing.keychain-db" +``` + +Use the printed absolute path, not a literal `~`. Preserve unrelated patches and every existing field in this entry's effective config: a Cordis patch replaces the complete `config` object rather than deep-merging it. If the provider has `helperPath`, source signing is bypassed; remove that override only when the developer intends to test the source-built helper. Ensure the profile actually loads the provider before adding a patch for its ID. + +Launch through the developer's existing `dsh` profile. The home-level patch shares the selection across profiles and worktrees that use that home. Other DSH homes can explicitly select the same certificate. Record the paths changed and the selected public fingerprint, never the password. + +## Unlock and verify + +After a restart or a manual keychain lock, rerun `setup`; it unlocks and reuses the existing identity. Do not ask the developer for the login-keychain password or store one. A source build does not automatically unlock the keychain and can fail or prompt if it is locked. + +Grant Accessibility and Screen Recording to `DSH Computer Use Dev.app` in System Settings. Call `getCapabilities`, change and rebuild the Swift helper, then test another worktree with the same configuration. Report the observed permission flags for each step. Matching `codesign -d -r- ` requirements confirm signature continuity but do not replace the permission checks. + +For a temporary, permission-free signing mechanism check, run: + +```sh +python3 .agents/skills/dsh-cua-macos-signing/scripts/signing_e2e.py +``` + +It creates an isolated identity, tests reuse and unlocking, signs changed binaries, then removes its keychain and checks search-list restoration. Run it without concurrent keychain-list mutations. + +## Remove an identity + +First remove the selecting configuration or set `sourceSigning: ad-hoc` in each affected home. For an authorized removal, run: + +```sh +python3 .agents/skills/dsh-cua-macos-signing/scripts/signing_identity.py remove +``` + +Use the same `--directory` for a custom location. Removal deletes only the marked identity’s files and its search-list entry, then removes the empty directory; the empty sibling lock remains so waiting processes still coordinate. Deleting DSH home alone leaves the developer identity intact. System Settings permission entries are separate; remove them only when requested. diff --git a/.agents/skills/dsh-cua-macos-signing/scripts/signing_e2e.py b/.agents/skills/dsh-cua-macos-signing/scripts/signing_e2e.py new file mode 100644 index 0000000000..71c36752d5 --- /dev/null +++ b/.agents/skills/dsh-cua-macos-signing/scripts/signing_e2e.py @@ -0,0 +1,77 @@ +"""Real development signing keeps one designated requirement across changed builds. + +Creates a temporary development identity, adds its keychain to the user search list for the duration +of the check, and removes both afterwards. No system permission or password dialog is requested. +""" +import importlib.util +import json +from pathlib import Path +import plistlib +import subprocess +import sys +import tempfile + +NATIVE = Path(__file__).resolve().parents[4] / "packages/experimental/cua-macos/native" + + +def module(name): + spec = importlib.util.spec_from_file_location(name, NATIVE / "build_app.py" if name == "build_app" else Path(__file__).with_name("signing_identity.py")) + loaded = importlib.util.module_from_spec(spec) + spec.loader.exec_module(loaded) + return loaded + + +def requirement(application): + output = subprocess.run(["/usr/bin/codesign", "-d", "-r-", str(application)], check=True, capture_output=True, text=True) + return next(line.split("=> ", 1)[1] for line in (output.stdout + output.stderr).splitlines() if "designated =>" in line) + + +def main(): + builder, signing = module("build_app"), module("dev_signing") + before = signing.user_keychains() + with tempfile.TemporaryDirectory(prefix="cua-dev-signing-e2e-") as temporary: + root = Path(temporary) + directory = root / "keys/dev" + try: + command = [sys.executable, str(Path(__file__).with_name("signing_identity.py")), "setup", "--directory", str(directory)] + configuration = json.loads(subprocess.run(command, check=True, capture_output=True, text=True).stdout) + identity = signing.setup(directory) + assert configuration == {"identity": identity.sha1, "keychain": str(identity.keychain.resolve())} + assert signing.setup(directory) == identity + sha1, keychain = identity.sha1, identity.keychain + assert (directory / "identity.json").stat().st_mode & 0o777 == 0o600 + signing.security("lock-keychain", str(keychain)) + assert signing.setup(directory) == identity, "setup must unlock an existing developer keychain" + requirements = [] + for status in (0, 1): + (root / "main.c").write_text(f"int main(void) {{ return {status}; }}\n") + subprocess.run(["/usr/bin/xcrun", "clang", "-o", str(root / "helper"), str(root / "main.c")], check=True) + executable = builder.publish(root / "helper", root / f"checkout-{status}", sha1, NATIVE / "Info.plist", "dev", keychain) + application = executable.parents[2] + assert application.name == "DSH Computer Use Dev.app", application + subprocess.run(["/usr/bin/codesign", "--verify", "--strict", str(application)], check=True) + info = plistlib.loads((application / "Contents/Info.plist").read_bytes()) + assert info["CFBundleIdentifier"] == "com.deepseek.harness.computer-use.dev", info + requirements.append((application, requirement(application))) + (first, first_requirement), (second, second_requirement) = requirements + assert first != second, "changed builds must publish separate generations" + assert first_requirement == second_requirement, requirements + assert "certificate leaf" in first_requirement and "cdhash" not in first_requirement, first_requirement + subprocess.run(["/usr/bin/codesign", "--verify", f"-R={first_requirement}", str(second)], check=True) + selected = json.loads((root / "checkout-1/current.json").read_text())["bundle"] + assert second.parent.name == selected + for index, (certificate, selected_keychain) in enumerate(((sha1, keychain), (signing.COMMON_NAME, keychain))): + custom = builder.publish(root / "helper", root / f"custom-{index}", certificate, NATIVE / "Info.plist", "dev", selected_keychain) + assert requirement(custom.parents[2]) == first_requirement + print(f"PASS: developer setup reuses and unlocks its identity; configured signatures across changed builds share {first_requirement}") + finally: + try: + signing.remove(directory) + finally: + # This fixture owns the temporary directory even if ownership validation fails. + signing.clear_identity(directory) + assert signing.user_keychains() == before, "the user keychain search list was not restored" + + +if __name__ == "__main__": + main() diff --git a/.agents/skills/dsh-cua-macos-signing/scripts/signing_identity.py b/.agents/skills/dsh-cua-macos-signing/scripts/signing_identity.py new file mode 100644 index 0000000000..69ab639a9d --- /dev/null +++ b/.agents/skills/dsh-cua-macos-signing/scripts/signing_identity.py @@ -0,0 +1,258 @@ +"""Manage an explicitly requested developer identity outside DSH home. + +macOS keeps Accessibility and Screen Recording grants for a bundle identifier together with the +designated requirement of the signature that was granted. An ad-hoc signature's requirement is the +code hash, so every changed build loses the grant. A certificate signature's requirement names the +bundle identifier and the certificate, preserving the requirement across changed builds. + +The identity lives in a dedicated keychain whose password is stored beside it with owner-only +permissions, so developer setup can unlock it without a system dialog. Any process running as the same +user can read that password and sign code that inherits the development grants; the grants belong +to the development bundle identifier only, never to the release helper. +""" +import argparse +from contextlib import contextmanager +from dataclasses import dataclass +import fcntl +import json +import os +from pathlib import Path +import re +import secrets +import shlex +import subprocess +import sys +import tempfile + +COMMON_NAME = "DSH Computer Use Dev (local)" +OWNER_MARKER = ".dsh-cua-macos-signing" +OWNER_VERSION = "1\n" +SECURITY = "/usr/bin/security" +OPENSSL = "/usr/bin/openssl" +SETUP_COMMAND = "python3 .agents/skills/dsh-cua-macos-signing/scripts/signing_identity.py setup" +REMOVE_COMMAND = "python3 .agents/skills/dsh-cua-macos-signing/scripts/signing_identity.py remove" +CERTIFICATE_CONFIG = f"""[req] +distinguished_name = subject +x509_extensions = extensions +prompt = no +[subject] +CN = {COMMON_NAME} +[extensions] +basicConstraints = critical,CA:false +keyUsage = critical,digitalSignature +extendedKeyUsage = critical,codeSigning +""" + + +@dataclass(frozen=True) +class SigningIdentity: + """An unlocked certificate identity that codesign can use without user interaction.""" + sha1: str + keychain: Path + + +def private_command(command, **kwargs): + """Run signing tools without exposing secret arguments or their echoed output in diagnostics.""" + operation = f"{Path(command[0]).name} {command[1]}" + try: + return subprocess.run(command, check=True, capture_output=True, text=True, timeout=60, **kwargs).stdout + except subprocess.CalledProcessError as error: + raise RuntimeError(f"{operation} failed (exit {error.returncode})") from None + except subprocess.TimeoutExpired: + raise RuntimeError(f"{operation} timed out after 60 seconds") from None + except OSError as error: + raise RuntimeError(f"{operation} could not run (errno {error.errno})") from None + + +def security(*args): + return private_command([SECURITY, *args]) + + +@contextmanager +def identity_lock(directory): + """Serialize setup and removal across worktrees; the lock inode survives identity removal.""" + directory.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + with os.fdopen(os.open(directory.parent / f"{directory.name}.lock", os.O_WRONLY | os.O_CREAT, 0o600), "w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + yield + + +def user_keychains(): + """Read the user keychain search list that codesign consults for identities.""" + return [line.strip().strip('"') for line in security("list-keychains", "-d", "user").splitlines() if line.strip()] + + +def listed(keychain, keychains): + """Compare canonical paths; security reports /private/var for /var and resolves other links.""" + return any(os.path.realpath(path) == os.path.realpath(keychain) for path in keychains) + + +def record_path(directory): + return directory / "identity.json" + + +def keychain_path(directory): + return directory / "signing.keychain-db" + + +def default_directory(): + """Keep developer credentials independent of DSH profiles, homes, and worktrees.""" + return Path.home() / ".cua-macos-keys" / "dev" + + +def owned_directory(directory, create=False): + """Refuse unowned or mixed-content directories before changing files or keychains.""" + marker = directory / OWNER_MARKER + if create: + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + if not any(directory.iterdir()): + directory.chmod(0o700) + with os.fdopen(os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w") as file: + file.write(OWNER_VERSION) + if not directory.exists(): + return + if marker.is_symlink() or not marker.is_file() or marker.read_bytes() != OWNER_VERSION.encode(): + raise RuntimeError(f"Refusing unowned signing directory: {directory}; choose a new or empty dedicated directory") + allowed = {OWNER_MARKER, record_path(directory).name, record_path(directory).with_suffix(".tmp").name, keychain_path(directory).name} + for entry in directory.iterdir(): + # security creates empty .flXXXXXXXX locks and removes them with delete-keychain. + system_lock = re.fullmatch(r"\.fl[0-9A-Fa-f]{8}", entry.name) and entry.is_file() and entry.stat().st_size == 0 + if (entry.name not in allowed and not system_lock) or entry.is_symlink() or not entry.is_file(): + raise RuntimeError(f"Refusing signing directory with unrelated entries: {directory}; preserve those entries before retrying") + + +def load(directory): + """Unlock the configured identity before signing. + + Returns None when setup has not run. Raises when setup ran but its keychain is missing or no + longer on the search list, because signing would otherwise fail or prompt for a password. + """ + directory_argument = shlex.quote(str(directory)) + setup_command = f"{SETUP_COMMAND} --directory {directory_argument}" + remove_command = f"{REMOVE_COMMAND} --directory {directory_argument}" + record = record_path(directory) + if not record.exists(): + return None + try: + fields = json.loads(record.read_text()) + valid = isinstance(fields, dict) and isinstance(fields.get("password"), str) and isinstance(fields.get("sha1"), str) and bool(fields["password"]) and bool(fields["sha1"]) + except (ValueError, OSError): + valid = False + if not valid: + raise RuntimeError(f"Computer Use development signing record is invalid; run {remove_command}, then {setup_command}") + keychain = keychain_path(directory) + if not keychain.is_file(): + raise RuntimeError(f"Computer Use development signing keychain is missing: {keychain}; run {remove_command}, then {setup_command}") + if not listed(keychain, user_keychains()): + raise RuntimeError(f"Computer Use development signing keychain is not on the user keychain search list; run {setup_command}") + # A locked keychain makes codesign show a password dialog, so unlock it explicitly every time. + security("unlock-keychain", "-p", fields["password"], str(keychain)) + if fields["sha1"] not in security("find-identity", str(keychain)): + raise RuntimeError(f"Computer Use development signing certificate is missing; run {remove_command}, then {setup_command}") + return SigningIdentity(sha1=fields["sha1"], keychain=keychain) + + +def create_certificate(directory, password): + """Create the key pair inside the dedicated keychain and return the certificate SHA-1.""" + keychain = keychain_path(directory) + with tempfile.TemporaryDirectory(prefix=".certificate-", dir=directory) as temporary: + work = Path(temporary) + (work / "openssl.cnf").write_text(CERTIFICATE_CONFIG) + export_password = secrets.token_hex(16) + for command in ( + ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", "key.pem", "-out", "certificate.pem", + "-days", "3650", "-config", "openssl.cnf"], + ["pkcs12", "-export", "-inkey", "key.pem", "-in", "certificate.pem", "-out", "identity.p12", + "-passout", f"pass:{export_password}"], + ): + private_command([OPENSSL, *command], cwd=work) + fingerprint = private_command([OPENSSL, "x509", "-in", "certificate.pem", "-noout", "-fingerprint", "-sha1"], cwd=work) + security("create-keychain", "-p", password, str(keychain)) + security("unlock-keychain", "-p", password, str(keychain)) + # No arguments disables the inactivity timeout and lock on sleep. + security("set-keychain-settings", str(keychain)) + security("import", str(work / "identity.p12"), "-k", str(keychain), "-P", export_password, "-T", "/usr/bin/codesign") + # Without a partition list, codesign asks the user to allow access to the private key. + security("set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, str(keychain)) + match = re.search(r"=([0-9A-F:]+)", fingerprint) + if match is None: + raise RuntimeError("openssl returned no certificate fingerprint") + sha1 = match.group(1).replace(":", "") + if sha1 not in security("find-identity", str(keychain)): + raise RuntimeError("the imported development signing identity is not usable") + return sha1 + + +def setup(directory): + """Create an identity only without a record; preserve recorded identities when their keychain is missing.""" + with identity_lock(directory): + return setup_locked(directory) + + +def setup_locked(directory): + owned_directory(directory, create=True) + keychain = keychain_path(directory) + if record_path(directory).exists(): + if keychain.is_file(): + keychains = user_keychains() + if not listed(keychain, keychains): + security("list-keychains", "-d", "user", "-s", *keychains, str(keychain)) + return load(directory) + clear_identity(directory) + password = secrets.token_hex(32) + sha1 = create_certificate(directory, password) + security("list-keychains", "-d", "user", "-s", *user_keychains(), str(keychain)) + record = record_path(directory) + with os.fdopen(os.open(record.with_suffix(".tmp"), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w") as file: + json.dump({"sha1": sha1, "password": password}, file) + file.write("\n") + os.replace(record.with_suffix(".tmp"), record) + return load(directory) + + +def remove(directory): + """Delete the keychain, its search-list entry and the stored password.""" + with identity_lock(directory): + remove_locked(directory) + + +def remove_locked(directory): + owned_directory(directory) + clear_identity(directory) + if directory.exists(): + (directory / OWNER_MARKER).unlink() + directory.rmdir() + + +def clear_identity(directory): + """Remove only the identity files and its exact keychain search-list entry.""" + keychain = keychain_path(directory) + keychains = user_keychains() + if listed(keychain, keychains): + security("list-keychains", "-d", "user", "-s", *[path for path in keychains if not listed(keychain, [path])]) + if keychain.exists(): + security("delete-keychain", str(keychain)) + record_path(directory).unlink(missing_ok=True) + record_path(directory).with_suffix(".tmp").unlink(missing_ok=True) + + +def main(): + parser = argparse.ArgumentParser(description="Manage the local signing identity for source-built Computer Use applications.") + parser.add_argument("action", choices=["setup", "remove"]) + parser.add_argument("--directory", type=Path, default=default_directory(), help="identity directory; defaults to ~/.cua-macos-keys/dev") + args = parser.parse_args() + directory = args.directory.expanduser().resolve() + if args.action == "setup": + identity = setup(directory) + print(json.dumps({"identity": identity.sha1, "keychain": str(identity.keychain)}, indent=2)) + else: + remove(directory) + print("Removed the development signing identity; remove its sourceSigning configuration before the next build") + + +if __name__ == "__main__": + try: + main() + except RuntimeError as error: + print(error, file=sys.stderr) + sys.exit(1) diff --git a/.agents/skills/dsh-cua-macos-signing/scripts/test_signing_identity.py b/.agents/skills/dsh-cua-macos-signing/scripts/test_signing_identity.py new file mode 100644 index 0000000000..949926f98c --- /dev/null +++ b/.agents/skills/dsh-cua-macos-signing/scripts/test_signing_identity.py @@ -0,0 +1,335 @@ +"""Development signing setup keeps one unlocked identity on the user keychain search list.""" +import contextlib +import io +import importlib.util +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location("dev_signing", Path(__file__).with_name("signing_identity.py")) +signing = importlib.util.module_from_spec(spec) +spec.loader.exec_module(signing) + + +class FakeSecurity: + """Record security commands and model the user search list.""" + def __init__(self, keychains): + self.keychains = list(keychains) + self.commands = [] + + def __call__(self, *args): + self.commands.append(args) + if args[:3] == ("list-keychains", "-d", "user") and len(args) == 3: + return "".join(f' "{path}"\n' for path in self.keychains) + if args[:4] == ("list-keychains", "-d", "user", "-s"): + self.keychains = list(args[4:]) + if args[0] == "delete-keychain": + Path(args[1]).unlink() + if args[0] == "find-identity": + return "ABC123 NEW456" + return "" + + +class DevelopmentSigningTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="cua-dev-signing-") + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name).resolve() + self.directory = self.root / "signing" + self.keychain = self.directory / "signing.keychain-db" + self.login = "/Users/example/Library/Keychains/login.keychain-db" + + def configure(self, listed=True): + self.directory.mkdir(mode=0o700) + (self.directory / signing.OWNER_MARKER).write_text(signing.OWNER_VERSION) + self.keychain.write_bytes(b"keychain") + (self.directory / "identity.json").write_text(json.dumps({"sha1": "ABC123", "password": "secret"})) + return self.fake([self.login, str(self.keychain)] if listed else [self.login]) + + def fake(self, keychains): + security = FakeSecurity(keychains) + patcher = patch.object(signing, "security", security) + patcher.start() + self.addCleanup(patcher.stop) + return security + + def test_setup_and_remove_preserve_unowned_directory_contents(self): + security = self.fake([self.login]) + self.directory.mkdir() + unrelated = self.directory / "notes.txt" + unrelated.write_text("keep this document") + for operation in (signing.setup, signing.remove): + with self.subTest(operation=operation.__name__): + with self.assertRaisesRegex(RuntimeError, "Refusing unowned signing directory"): + operation(self.directory) + self.assertEqual(unrelated.read_text(), "keep this document") + self.assertEqual(list(self.directory.iterdir()), [unrelated]) + self.assertEqual(security.commands, []) + + def test_unrelated_files_in_an_owned_directory_block_setup_and_removal(self): + security = self.configure() + unrelated = self.directory / "notes.txt" + unrelated.write_text("keep this document") + before = {p.name: p.read_bytes() for p in self.directory.iterdir()} + for operation in (signing.setup, signing.remove): + with self.subTest(operation=operation.__name__): + with self.assertRaisesRegex(RuntimeError, "unrelated entries"): + operation(self.directory) + self.assertEqual({p.name: p.read_bytes() for p in self.directory.iterdir()}, before) + self.assertEqual(security.commands, []) + + def test_system_keychain_locks_are_removed_by_security(self): + security = self.configure() + lock = self.directory / ".flD3051040" + lock.touch() + def remove_keychain(*args): + result = security(*args) + if args[0] == "delete-keychain": + lock.unlink() + return result + with patch.object(signing, "security", side_effect=remove_keychain): + signing.remove(self.directory) + self.assertFalse(self.directory.exists()) + + def test_nonempty_files_with_a_system_lock_name_are_preserved(self): + security = self.configure() + lock = self.directory / ".flD3051040" + lock.write_text("unrelated contents") + for operation in (signing.setup, signing.remove): + with self.assertRaisesRegex(RuntimeError, "unrelated entries"): + operation(self.directory) + self.assertEqual(lock.read_text(), "unrelated contents") + self.assertEqual(security.commands, []) + + def test_missing_or_invalid_marker_cannot_adopt_an_existing_identity(self): + security = self.configure() + marker = self.directory / signing.OWNER_MARKER + for contents in (None, "different tool", "\ufffd"): + if contents is None: + marker.unlink() + else: + marker.write_text(contents) + for operation in (signing.setup, signing.remove): + with self.assertRaisesRegex(RuntimeError, "unowned signing directory"): + operation(self.directory) + self.assertTrue(self.keychain.is_file()) + self.assertEqual(security.commands, []) + + def test_symlinked_identity_files_do_not_delete_another_directorys_data(self): + security = self.configure() + outside = self.root / "outside.keychain-db" + outside.write_bytes(b"other identity") + self.keychain.unlink() + self.keychain.symlink_to(outside) + for operation in (signing.setup, signing.remove): + with self.assertRaisesRegex(RuntimeError, "unrelated entries"): + operation(self.directory) + self.assertEqual(outside.read_bytes(), b"other identity") + self.assertEqual(security.commands, []) + + def test_empty_directory_gets_an_ownership_marker_before_generation(self): + self.fake([self.login]) + self.directory.mkdir() + with patch.object(signing, "create_certificate", side_effect=RuntimeError("generation interrupted")): + with self.assertRaisesRegex(RuntimeError, "generation interrupted"): + signing.setup(self.directory) + self.assertEqual((self.directory / signing.OWNER_MARKER).read_text(), signing.OWNER_VERSION) + signing.remove(self.directory) + self.assertFalse(self.directory.exists()) + + def test_removing_the_only_search_entry_leaves_no_unrelated_entries_to_restore(self): + security = self.configure() + security.keychains = [str(self.keychain)] + signing.remove(self.directory) + self.assertEqual(security.keychains, []) + self.assertFalse(self.directory.exists()) + + def test_dotted_identity_directories_keep_separate_lock_files(self): + directories = [self.root / name for name in ("dev", "dev.keys", "keys.v1", "keys.v2")] + for directory in directories: + with signing.identity_lock(directory): + pass + self.assertEqual({p.name for p in self.root.iterdir()}, {f"{p.name}.lock" for p in directories}) + + def test_default_identity_is_outside_dsh_home(self): + home = self.root / "user" + with patch.object(Path, "home", return_value=home): + for value in ("", str(self.root / "custom-dsh-home")): + with patch.dict(signing.os.environ, {"DSH_HOME": value}): + self.assertEqual(signing.default_directory(), home / ".cua-macos-keys/dev") + self.assertFalse(home.exists()) + + def test_setup_and_remove_target_the_explicit_identity_directory(self): + directory = self.root / "keys/dev" + identity = signing.SigningIdentity("ABC123", directory / "signing.keychain-db") + for action in ("setup", "remove"): + with patch.object(signing.sys, "argv", ["signing_identity.py", action, "--directory", str(directory)]), \ + patch.object(signing, action, return_value=identity) as operation, contextlib.redirect_stdout(io.StringIO()) as output: + signing.main() + operation.assert_called_once_with(directory) + if action == "setup": + self.assertEqual(json.loads(output.getvalue()), {"identity": "ABC123", "keychain": str(identity.keychain)}) + + def test_missing_setup_leaves_builds_unconfigured(self): + self.fake([self.login]) + self.assertIsNone(signing.load(self.directory)) + + def test_load_unlocks_the_keychain_before_returning_the_identity(self): + security = self.configure() + identity = signing.load(self.directory) + self.assertEqual((identity.sha1, identity.keychain), ("ABC123", self.keychain)) + self.assertIn(("unlock-keychain", "-p", "secret", str(self.keychain)), security.commands) + + def test_listed_keychains_match_through_symbolic_links(self): + (self.root / "alias").symlink_to(self.root, target_is_directory=True) + self.configure() + signing.security.keychains = [self.login, str(self.root / "alias/signing/signing.keychain-db")] + self.assertEqual(signing.load(self.directory).sha1, "ABC123") + + def test_unlisted_or_missing_keychains_fail_with_the_setup_command(self): + security = self.configure(listed=False) + with self.assertRaisesRegex(RuntimeError, "search list; run python3"): + signing.load(self.directory) + self.keychain.unlink() + with self.assertRaisesRegex(RuntimeError, "keychain is missing"): + signing.load(self.directory) + self.assertFalse(any(command[0] == "unlock-keychain" for command in security.commands)) + + def test_setup_relists_an_existing_identity_without_creating_another(self): + security = self.configure(listed=False) + with patch.object(signing, "create_certificate") as create: + self.assertEqual(signing.setup(self.directory).sha1, "ABC123") + create.assert_not_called() + self.assertEqual(security.keychains, [self.login, str(self.keychain)]) + + def test_setup_replaces_only_an_incomplete_owned_identity(self): + security = self.configure() + (self.directory / "identity.json").unlink() + def create(directory, password): + self.keychain.write_bytes(b"new keychain") + return "NEW456" + with patch.object(signing, "create_certificate", side_effect=create): + identity = signing.setup(self.directory) + self.assertEqual(identity.sha1, "NEW456") + self.assertEqual(self.keychain.read_bytes(), b"new keychain") + self.assertEqual(oct((self.directory / "identity.json").stat().st_mode & 0o777), "0o600") + self.assertEqual(oct(self.directory.stat().st_mode & 0o777), "0o700") + self.assertEqual(security.keychains, [self.login, str(self.keychain)]) + + def test_setup_preserves_the_record_when_the_keychain_is_missing(self): + security = self.configure() + record = self.directory / "identity.json" + before = record.read_bytes() + self.keychain.unlink() + with patch.object(signing, "create_certificate") as create: + with self.assertRaisesRegex(RuntimeError, "keychain is missing: .*; run .*remove --directory .*, then .*setup --directory "): + signing.setup(self.directory) + create.assert_not_called() + self.assertEqual(record.read_bytes(), before) + self.assertFalse(self.keychain.exists()) + self.assertEqual(security.commands, []) + + def test_setup_cli_preserves_a_recorded_identity_missing_its_keychain(self): + directory = self.root / "keys/dev" + directory.mkdir(parents=True) + (directory / signing.OWNER_MARKER).write_text(signing.OWNER_VERSION) + record = directory / "identity.json" + before = json.dumps({"sha1": "ABC123", "password": "secret-password"}).encode() + record.write_bytes(before) + result = subprocess.run([sys.executable, str(Path(signing.__file__)), "setup", "--directory", str(directory)], + capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 1) + self.assertEqual(len(result.stderr.splitlines()), 1) + self.assertIn("keychain is missing", result.stderr) + self.assertIn(f"run {signing.REMOVE_COMMAND} --directory {directory}, then {signing.SETUP_COMMAND} --directory {directory}", result.stderr) + self.assertNotIn("Traceback", result.stderr) + self.assertNotIn("secret-password", result.stderr) + self.assertEqual(record.read_bytes(), before) + self.assertFalse(signing.keychain_path(directory).exists()) + + def test_remove_restores_the_other_search_list_entries_and_deletes_the_password(self): + security = self.configure() + signing.remove(self.directory) + self.assertEqual(security.keychains, [self.login]) + self.assertFalse(self.directory.exists()) + signing.remove(self.directory) + self.assertEqual(security.keychains, [self.login]) + + def test_missing_certificate_requires_explicit_removal_instead_of_changing_identity(self): + self.configure() + (self.directory / "identity.json").write_text(json.dumps({"sha1": "MISSING", "password": "secret"})) + with self.assertRaisesRegex(RuntimeError, "certificate is missing; run .*remove --directory .*, then .*setup --directory "): + signing.setup(self.directory) + self.assertTrue(self.keychain.is_file()) + + def test_invalid_identity_record_reports_recovery_without_its_contents(self): + self.configure() + (self.directory / "identity.json").write_text("secret invalid json") + with self.assertRaisesRegex(RuntimeError, "record is invalid; run .*remove") as error: + signing.setup(self.directory) + self.assertNotIn("secret", str(error.exception)) + + def test_private_command_failures_never_echo_arguments_or_output(self): + for tool, action in ((signing.SECURITY, "unlock-keychain"), (signing.OPENSSL, "pkcs12")): + command = [tool, action, "-p", "secret-password"] + for failure in (subprocess.TimeoutExpired(command, 60, stderr="secret-password"), + subprocess.CalledProcessError(1, command, stderr="secret-password"), + OSError(2, "secret-password")): + with self.subTest(tool=tool, failure=type(failure).__name__), \ + patch.object(signing.subprocess, "run", side_effect=failure): + with self.assertRaises(RuntimeError) as error: + signing.private_command(command) + self.assertNotIn("secret-password", str(error.exception)) + self.assertTrue(error.exception.__suppress_context__) + + def test_setup_and_remove_hold_the_same_cross_process_lock(self): + self.configure() + probe = """import fcntl, os, sys +with open(sys.argv[1], 'w') as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + sys.exit(0) + sys.exit(1) +""" + setup_locked, remove_locked = signing.setup_locked, signing.remove_locked + def check_lock(operation): + def run(directory): + result = subprocess.run([sys.executable, "-c", probe, str(directory.parent / f"{directory.name}.lock")], + capture_output=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + return operation(directory) + return run + with patch.object(signing, "setup_locked", side_effect=check_lock(setup_locked)), \ + patch.object(signing, "remove_locked", side_effect=check_lock(remove_locked)): + signing.setup(self.directory) + signing.remove(self.directory) + with signing.identity_lock(self.directory): + pass + + def test_setup_cli_reports_signing_timeout_without_traceback_or_password(self): + script = """import runpy, sys, subprocess +from unittest.mock import patch +from pathlib import Path +test_home = Path(sys.argv[2]) +Path.home = lambda: test_home +sys.argv = [sys.argv[1], 'setup', '--directory', sys.argv[2]] +with patch('subprocess.run', side_effect=subprocess.TimeoutExpired(['security', 'unlock-keychain', '-p', 'secret-password'], 60)): + runpy.run_path(sys.argv[0], run_name='__main__') +""" + # Fail at the first security command, before setup can modify any keychain. + result = subprocess.run([sys.executable, "-c", script, str(Path(signing.__file__)), str(self.root / "home")], + capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 1) + self.assertEqual(len(result.stderr.splitlines()), 1) + self.assertIn("timed out", result.stderr) + self.assertNotIn("secret-password", result.stderr) + self.assertNotIn("Traceback", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 57830f1af0..27823aed50 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -4,6 +4,6 @@ Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Win The manual [Windows packaging workflow](workflows/windows-package.yml) uses the dedicated self-hosted Windows x64 label `dsh-win-package-trial` under native `pwsh`; preserve this routing separately from the automatic CI lanes. -The merge checks run from three workflows named `Policy`, which run on `pull_request_target` so their definitions come from master, and call the `deepseek-harness-check` action directly: `pr-check-on-head-branch.yml` on pull-request updates gives `Policy / Master Change` and `Policy / Pre-Merge Check`, `pr-check-after-approve.yml` on pull-request updates and submitted or dismissed reviews gives `Policy / Approve Check`, which also publishes the review verdict as the check run `Policy / Reviewer Check`, and `pr-check-on-base-branch.yml` on master pushes re-runs the head-branch checks as `Policy / Recheck`. Branch rules require those check names, so keep the job names and the workflow name. Each job reads its mode from a repository variable: `MERGE_POLICY_MASTER_DIFF`, `MERGE_POLICY_PRE_MERGE`, and `MERGE_POLICY_HEAD_DIFF`, each `off` (skip), `report` (run without failing the job), or `block` (fail the job); a missing value or an unknown one counts as `report`. `MERGE_POLICY_HEAD_DIFF` decides whether `Policy / Approve Check` runs and fails; while it runs, `Policy / Reviewer Check` is published whatever that mode says, with `success` when every file that needs review has one and `action_required` with the reviewers to ask otherwise. `Policy / Pre-Merge Check` merges the commit `Policy / Master Change` reports, so it runs only after that job and stays off while `MERGE_POLICY_MASTER_DIFF` is `off`; a merge it cannot do and a build that fails both follow its own mode, so `report` warns and skips the build while `block` fails the job. `Policy / Pre-Merge Check` runs `scripts/pre-merge-check.ts` under plain Node and knows nothing else about the merged tree: that script checks the released manifests against the root version, installs, and builds the Host and Client contracts, so change it in one pull request together with the build it orchestrates. +The merge checks run from three workflows named `Policy`, which run on `pull_request_target` and cover pull requests into master or a `release/dsh-x.y.z` branch, so their definitions come from the base branch, and call the `deepseek-harness-check` action directly: `pr-check-on-head-branch.yml` on pull-request updates gives `Policy / Master Change` and `Policy / Pre-Merge Check`, `pr-check-after-approve.yml` on pull-request updates and submitted or dismissed reviews gives `Policy / Approve Check`, which also publishes the review verdict as the check run `Policy / Reviewer Check`, and `pr-check-on-base-branch.yml` on pushes to master or a release branch re-runs the head-branch checks as `Policy / Recheck`. Branch rules require those check names, so keep the job names and the workflow name. Each job reads its mode from a repository variable: `MERGE_POLICY_MASTER_DIFF`, `MERGE_POLICY_PRE_MERGE`, and `MERGE_POLICY_HEAD_DIFF`, each `off` (skip), `report` (run without failing the job), or `block` (fail the job); a missing value or an unknown one counts as `report`. `MERGE_POLICY_HEAD_DIFF` decides whether `Policy / Approve Check` runs and fails; while it runs, `Policy / Reviewer Check` is published whatever that mode says, with `success` when every file that needs review has one and `action_required` with the reviewers to ask otherwise. `Policy / Pre-Merge Check` merges the commit `Policy / Master Change` reports, so it runs only after that job and stays off while `MERGE_POLICY_MASTER_DIFF` is `off`; a merge it cannot do and a build that fails both follow its own mode, so `report` warns and skips the build while `block` fails the job. `Policy / Pre-Merge Check` runs `scripts/pre-merge-check.ts` under plain Node and knows nothing else about the merged tree: that script checks the released manifests against the root version, installs, and builds the Host and Client contracts, so change it in one pull request together with the build it orchestrates. The manual Desktop publication workflow selects a successful package run and attempt, then routes each retained build through `DESKTOP_PUBLISH_RUNNERS` to its original machine's unique label. Keep Windows run steps on native PowerShell and Mac run steps on the default POSIX shell; shared entrypoints are Node scripts. Test and production jobs use separate `desktop-test` and `desktop-production` approval Environments after a credential-free preparation job. 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 cc835b8933..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: @@ -127,6 +127,23 @@ jobs: args=() if [[ "$PACKAGE_SKIP_NOTARIZATION" = true ]]; then args+=(--skip-notarization); fi pnpm run package:desktop:mac:x64 --check --build-version "$PACKAGE_VERSION" "${args[@]}" + - name: Build and stage notarized Computer Use helpers + # A source whose dsh ships the Computer Use bundle packs the workspace provider into the Desktop runtime, + # which therefore needs the same notarized helper npm publication stages. + run: | + if ! node -e "process.exit(require('./apps/cli/package.json').dependencies?.['@deepseek-ai/dsh-cua-bundle'] ? 0 : 1)"; then + echo 'This source does not ship Computer Use in Desktop; no helper is staged.' + exit 0 + fi + if [[ ! -f scripts/cua-helper/package.ts ]]; then + echo '::warning::This source ships Computer Use but predates helper packaging; Computer Use cannot prepare its helper in this package.' + exit 0 + fi + mkdir -p dist + pnpm exec tsx scripts/cua-helper/package.ts --out dist/cua-helper + mkdir dist/cua-helper/extracted + tar -xzf dist/cua-helper/cua-helper.tgz -C dist/cua-helper/extracted + pnpm exec tsx scripts/cua-helper/stage.ts --from dist/cua-helper/extracted - name: Build signed installers env: PACKAGE_VERSION: ${{ steps.settings.outputs.version }} diff --git a/.github/workflows/macos-package.yml b/.github/workflows/macos-package.yml index 073f1e9541..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: @@ -124,6 +124,23 @@ jobs: args=() if [[ "$PACKAGE_SKIP_NOTARIZATION" = true ]]; then args+=(--skip-notarization); fi pnpm run package:desktop:mac:arm64 --check --build-version "$PACKAGE_VERSION" "${args[@]}" + - name: Build and stage notarized Computer Use helpers + # A source whose dsh ships the Computer Use bundle packs the workspace provider into the Desktop runtime, + # which therefore needs the same notarized helper npm publication stages. + run: | + if ! node -e "process.exit(require('./apps/cli/package.json').dependencies?.['@deepseek-ai/dsh-cua-bundle'] ? 0 : 1)"; then + echo 'This source does not ship Computer Use in Desktop; no helper is staged.' + exit 0 + fi + if [[ ! -f scripts/cua-helper/package.ts ]]; then + echo '::warning::This source ships Computer Use but predates helper packaging; Computer Use cannot prepare its helper in this package.' + exit 0 + fi + mkdir -p dist + pnpm exec tsx scripts/cua-helper/package.ts --out dist/cua-helper + mkdir dist/cua-helper/extracted + tar -xzf dist/cua-helper/cua-helper.tgz -C dist/cua-helper/extracted + pnpm exec tsx scripts/cua-helper/stage.ts --from dist/cua-helper/extracted - name: Build signed installers env: PACKAGE_VERSION: ${{ steps.settings.outputs.version }} diff --git a/.github/workflows/pr-check-after-approve.yml b/.github/workflows/pr-check-after-approve.yml index 092b5b957d..85e84e5c89 100644 --- a/.github/workflows/pr-check-after-approve.yml +++ b/.github/workflows/pr-check-after-approve.yml @@ -1,7 +1,7 @@ name: Policy on: pull_request_target: - branches: [master] + branches: [master, 'release/dsh-[0-9]+.[0-9]+.[0-9]+'] types: [opened, synchronize, reopened, ready_for_review] pull_request_review: types: [submitted, dismissed] diff --git a/.github/workflows/pr-check-on-base-branch.yml b/.github/workflows/pr-check-on-base-branch.yml index 32d4d46165..b4a9a474e6 100644 --- a/.github/workflows/pr-check-on-base-branch.yml +++ b/.github/workflows/pr-check-on-base-branch.yml @@ -1,7 +1,7 @@ name: Policy on: push: - branches: [master] + branches: [master, 'release/dsh-[0-9]+.[0-9]+.[0-9]+'] workflow_dispatch: permissions: {} concurrency: diff --git a/.github/workflows/pr-check-on-head-branch.yml b/.github/workflows/pr-check-on-head-branch.yml index adb884d62c..a926d97827 100644 --- a/.github/workflows/pr-check-on-head-branch.yml +++ b/.github/workflows/pr-check-on-head-branch.yml @@ -1,7 +1,7 @@ name: Policy on: pull_request_target: - branches: [master] + branches: [master, 'release/dsh-[0-9]+.[0-9]+.[0-9]+'] types: [opened, synchronize, reopened, ready_for_review, review_requested, auto_merge_enabled, labeled] permissions: contents: read 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/AGENTS.md b/AGENTS.md index 69dbd7d69e..db164319da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// compaction/ context compaction context/ request context subagent/ delegated agents + team/ shared task coordination jobs/ background jobs bundle/ profile bundles workflow/ workflow execution diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 21150774cc..96b711a333 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -140,6 +140,10 @@ flowchart LR cfg --> plugin_dsh_base_command_compact plugin_dsh_base_subagent["subagent
@deepseek-ai/dsh-subagent"] cfg --> plugin_dsh_base_subagent + plugin_dsh_base_agent_team["agent-team
@deepseek-ai/dsh-agent-team"] + cfg --> plugin_dsh_base_agent_team + plugin_dsh_base_tool_agent_team["tool-agent-team
@deepseek-ai/dsh-tool-agent-team"] + cfg --> plugin_dsh_base_tool_agent_team plugin_dsh_base_subagent_spawn_in_process["subagent-spawn-in-process
@deepseek-ai/dsh-subagent-spawn-in-process"] cfg --> plugin_dsh_base_subagent_spawn_in_process plugin_dsh_base_subagent_fork_in_process["subagent-fork-in-process
@deepseek-ai/dsh-subagent-fork-in-process"] @@ -272,6 +276,8 @@ flowchart LR | `compaction-basic` | `@deepseek-ai/dsh-compaction-basic` | | `command-compact` | `@deepseek-ai/dsh-command-compact` | | `subagent` | `@deepseek-ai/dsh-subagent` | +| `agent-team` | `@deepseek-ai/dsh-agent-team` | +| `tool-agent-team` | `@deepseek-ai/dsh-tool-agent-team` | | `subagent-spawn-in-process` | `@deepseek-ai/dsh-subagent-spawn-in-process` | | `subagent-fork-in-process` | `@deepseek-ai/dsh-subagent-fork-in-process` | | `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` | 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 a999d17c02..5bddabe27e 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -38,7 +38,6 @@ "@deepseek-ai/dsh-compaction-basic": "workspace:*", "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:*", "@deepseek-ai/dsh-cordis-client-runner": "workspace:*", - "@deepseek-ai/dsh-experimental-agent-team-profile": "workspace:*", "@deepseek-ai/dsh-experimental-inspector-profile": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-goal": "workspace:*", @@ -107,6 +106,7 @@ "@deepseek-ai/dsh-experimental-liveshare": "workspace:*", "@deepseek-ai/dsh-experimental-session-search": "workspace:*", "@deepseek-ai/dsh-experimental-tool-worktree": "workspace:*", + "@deepseek-ai/dsh-cua-bundle": "workspace:*", "@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*", "@deepseek-ai/dsh-personalized-recommendations": "workspace:*", "@deepseek-ai/dsh-experimental-forge-bundle": "workspace:*" @@ -127,9 +127,9 @@ "@deepseek-ai/dsh-bash-local": "workspace:*", "@deepseek-ai/dsh-credentials-local": "workspace:*", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:*", - "@deepseek-ai/dsh-experimental-agent-team": "workspace:*", + "@deepseek-ai/dsh-agent-team": "workspace:*", "@deepseek-ai/dsh-experimental-ptc-runtime-python": "workspace:*", - "@deepseek-ai/dsh-experimental-tool-agent-team": "workspace:*", + "@deepseek-ai/dsh-tool-agent-team": "workspace:*", "@deepseek-ai/dsh-fs-observation-policy": "workspace:*", "@deepseek-ai/dsh-fs-sandbox": "workspace:*", "@deepseek-ai/dsh-host-frontend-static": "workspace:*", 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/agent-team-headless.e2e.ts b/apps/cli/tests/agent-team-headless.e2e.ts index dd4e456252..e38a33777d 100644 --- a/apps/cli/tests/agent-team-headless.e2e.ts +++ b/apps/cli/tests/agent-team-headless.e2e.ts @@ -17,7 +17,7 @@ function records(content: string): Record[] { } describe('dsh run with Agent Teams enabled', () => { - it('runs two teammates, direct peer messages, dependent tasks, waiting, and final aggregation', async () => { + it('runs two teammates, sibling messages, dependent tasks, waiting, and final aggregation', async () => { const cwd = await mkdtemp(join(tmpdir(), 'dsh-agent-team-headless-')) try { const home = join(cwd, '.dsh') @@ -27,15 +27,12 @@ describe('dsh run with Agent Teams enabled', () => { await writeFile(join(profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-headless', private: true, - dependencies: { - '@deepseek-ai/dsh-experimental-agent-team-profile': 'workspace:^', - }, + dependencies: {}, dsh: { profile: { bundles: [ '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless', - '@deepseek-ai/dsh-experimental-agent-team-profile', ], }, }, @@ -91,8 +88,8 @@ describe('dsh run with Agent Teams enabled', () => { const workflowChild = parsed.find(log => log.some(record => record.type === 'user/message' && JSON.stringify((record.data as { content: unknown }).content) === JSON.stringify([{ type: 'text', text: 'TEAM_WORKFLOW_CHILD' }]))) expect(workflowChild).toBeDefined() - expect(workflowChild!.find(record => record.type === 'subagent/descriptor')?.data) - .toMatchObject({ mode: 'continuable', provider: 'spawn' }) + const workflowDescriptor = workflowChild!.find(record => record.type === 'subagent/descriptor')?.data as Record + expect(workflowDescriptor).toMatchObject({ mode: 'continuable', provider: 'spawn' }) expect(workflowChild!.filter(record => record.type === 'user/message' && (record.data as { source: { kind: string } }).source.kind === 'user').map(record => record.data)) .toEqual([expect.objectContaining({ content: [{ type: 'text', text: 'TEAM_WORKFLOW_CHILD' }] })]) @@ -102,7 +99,15 @@ describe('dsh run with Agent Teams enabled', () => { }) expect(root).toBeDefined() const eventTypes = root!.map(record => record.type) - expect(eventTypes.filter(type => type === 'team/member')).toHaveLength(4) + expect(eventTypes).not.toContain('team/member') + const catalog = root!.filter(record => record.type === 'subagent/catalog').map(record => record.data) + expect(catalog).toEqual(expect.arrayContaining([ + expect.objectContaining({ version: 0, childId: workflowChild![0]?.id }), + expect.objectContaining({ version: 0, label: 'Own deterministic implementation.' }), + expect.objectContaining({ version: 0, label: 'Own deterministic research.' }), + ])) + for (const entry of catalog) expect(entry).not.toHaveProperty('name') + expect(eventTypes).not.toContain('subagent-name/bound') expect(eventTypes).not.toContain('team/message/queued') expect(eventTypes).not.toContain('team/message/delivered') const peerMessages = parsed.flatMap(log => log.filter(record => record.type === 'user/message' 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/optional-bundle-transitions.e2e.ts b/apps/cli/tests/optional-bundle-transitions.e2e.ts index b5a5eecbaf..8db33a3a87 100644 --- a/apps/cli/tests/optional-bundle-transitions.e2e.ts +++ b/apps/cli/tests/optional-bundle-transitions.e2e.ts @@ -171,8 +171,8 @@ describe.skipIf(!existsSync(join(root, 'apps/cli/lib/bin.js')))('Official option it('keeps the default host and minimal tool catalogs while every optional bundle is off', async () => { const minimal = await agent('minimal') const standard = await agent() - expect(names()).toEqual(['working_directory']) - expect(names(minimal.agent)).toEqual([process.platform === 'win32' ? 'pwsh' : 'bash', 'read_image', 'working_directory']) + expect(names()).toEqual(['team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) + expect(names(minimal.agent)).toEqual([process.platform === 'win32' ? 'pwsh' : 'bash', 'read_image', 'team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) for (const name of ['str_replace_editor', 'session_search', 'terminal_open', 'ralph']) expect(names(standard.agent)).not.toContain(name) expect((await ctx.skills.list({ cwd: workspace, scope: standard.agent })).some(skill => skill.name === 'dsh-badge')).toBe(false) }) @@ -246,7 +246,7 @@ describe.skipIf(!existsSync(join(root, 'apps/cli/lib/bin.js')))('Official option expect(toolResult(owner.agent, 'badge-on')).toContain('dsh-badge.png') const minimal = await agent('minimal') expect((await ctx.skills.list({ cwd: workspace, scope: minimal.agent })).map(skill => skill.name)).toContain('dsh-badge') - expect(names(minimal.agent)).toEqual([process.platform === 'win32' ? 'pwsh' : 'bash', 'read_image', 'working_directory']) + expect(names(minimal.agent)).toEqual([process.platform === 'win32' ? 'pwsh' : 'bash', 'read_image', 'team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) await select(bundles.badge, false) expect((await load('badge-after')).isError).toBe(true) }) 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/acp/tests/goal-expected/goal-round-driver/session.expected.jsonl b/apps/cli/tests/profiles/acp/tests/goal-expected/goal-round-driver/session.expected.jsonl index 19495490be..8467970106 100644 --- a/apps/cli/tests/profiles/acp/tests/goal-expected/goal-round-driver/session.expected.jsonl +++ b/apps/cli/tests/profiles/acp/tests/goal-expected/goal-round-driver/session.expected.jsonl @@ -8,7 +8,7 @@ {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Create a durable two-round goal for the ACP snapshot, inspect it, then report readiness."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Create a durable two-round goal","messageSeqs":[8],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/acp/tests/goal-expected/goal-wrapup/session.expected.jsonl b/apps/cli/tests/profiles/acp/tests/goal-expected/goal-wrapup/session.expected.jsonl index 7e3a02fce9..a4ac54b8c1 100644 --- a/apps/cli/tests/profiles/acp/tests/goal-expected/goal-wrapup/session.expected.jsonl +++ b/apps/cli/tests/profiles/acp/tests/goal-expected/goal-wrapup/session.expected.jsonl @@ -8,7 +8,7 @@ {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Create a durable goal for the wrap-up snapshot, then report readiness."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Create a durable goal for","messageSeqs":[8],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts b/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts index 259ba7341e..63715e4638 100644 --- a/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts +++ b/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts @@ -117,6 +117,7 @@ it('pins Messages Files offload and inline fallback in assembled requests', asyn }, }) expect(result.stderr).toBe('') + if (result.sessionId === undefined) throw new Error('image-offload scenario did not create a session') expect(requests).toHaveLength(2) expect(fileRequests).toEqual([{ method: 'POST', path: filesPath, bytes: 69 }]) const attachmentDigest = 'b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640' @@ -139,10 +140,13 @@ it('pins Messages Files offload and inline fallback in assembled requests', asyn const fileImage = { type: 'image', source: { type: 'file', file_id: 'file-api-snapshot-1' } } const offloadedImage = `[image omitted to fit request image limits; ${attachmentId}.${normalizedAccess}]` const imageHandle = `Image ${attachmentId}; request preview 1x1px.${normalizedAccess}` - const runtimeContext = (cwd: string): string => 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n' + const runtimeContext = (cwd: string, sessionId: string): string => 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n' + `Current working directory: ${JSON.stringify(cwd)}.\n\n` + 'Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\n' - + 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).' + + 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\n' + + `You are the root agent. Your agent id is ${JSON.stringify(sessionId)}.\n\n` + + 'To message other agents, use send_message({ agent_id: "", message: "..." }).\n' + + 'Use list_agents({}) to find your collaborator agents and their ids.' const messages = requests[0]?.messages as { content?: unknown }[] | undefined const offloaded = messages?.find(message => JSON.stringify(message.content).includes('[image omitted')) expect(offloaded?.content).toEqual([ @@ -152,7 +156,7 @@ it('pins Messages Files offload and inline fallback in assembled requests', asyn { type: 'text', text: imageHandle }, fileImage, { type: 'text', text: ', then use read_image on red.png and reply with DONE.' }, - { type: 'text', text: runtimeContext(result.cwd) }, + { type: 'text', text: runtimeContext(result.cwd, result.sessionId) }, ]) const followup = requests[1]?.messages as Array<{ role: string; content: Array> }> @@ -188,6 +192,7 @@ it('pins Messages Files offload and inline fallback in assembled requests', asyn }, }) expect(fallback.stderr).toBe('') + if (fallback.sessionId === undefined) throw new Error('image-offload fallback scenario did not create a session') expect(fileRequests).toEqual([ { method: 'POST', path: filesPath, bytes: 69 }, { method: 'POST', path: filesPath, bytes: 69 }, @@ -203,7 +208,7 @@ it('pins Messages Files offload and inline fallback in assembled requests', asyn { type: 'text', text: `Image ${attachmentId}; request preview 1x1px.${fallbackAccess}` }, { type: 'image', source: { type: 'base64', media_type: 'image/png', data: image } }, { type: 'text', text: ', then use read_image on red.png and reply with DONE.' }, - { type: 'text', text: runtimeContext(fallback.cwd) }, + { type: 'text', text: runtimeContext(fallback.cwd, fallback.sessionId) }, ]) } finally { await new Promise(resolve => server.close(() => { resolve() })) diff --git a/apps/cli/tests/profiles/fixtures/official-installer-observer.mjs b/apps/cli/tests/profiles/fixtures/official-installer-observer.mjs index 5ca8db1ac6..4acea7354d 100644 --- a/apps/cli/tests/profiles/fixtures/official-installer-observer.mjs +++ b/apps/cli/tests/profiles/fixtures/official-installer-observer.mjs @@ -88,7 +88,7 @@ async function exercise(ctx, config) { // A transitive repository package the registry served, absent from npm at this version. const localDependency = JSON.parse(await readFile(join(config.profile, 'node_modules', config.localDependency, 'package.json'), 'utf8')).version const provider = name.endsWith('-codex') ? 'codex' : 'claude-code' - const providerEnabled = ctx.subagents.list().includes(provider) + const providerEnabled = ctx.subagents.getProvider(provider) !== undefined const nativeDir = join(config.profile, 'node_modules', config.nativePackages[name]) const binaryName = `${provider === 'codex' ? 'codex' : 'claude'}${process.platform === 'win32' ? '.exe' : ''}` const nativeBinary = (await readdir(nativeDir, { recursive: true })).find(path => basename(path) === binaryName) diff --git a/apps/cli/tests/profiles/headless/team-snapshot.patch.yml b/apps/cli/tests/profiles/headless/team-snapshot.patch.yml index dec80a9fe9..7a1d4c8a24 100644 --- a/apps/cli/tests/profiles/headless/team-snapshot.patch.yml +++ b/apps/cli/tests/profiles/headless/team-snapshot.patch.yml @@ -21,33 +21,7 @@ root: './.sessions' compression: none -- id: tool-subagent-control - disabled: true - -- id: tool-subagent-report - disabled: true - -- id: tool-subagent - disabled: true - -- id: tool-subagent-fork - disabled: true - - insert: - - id: agent-team - name: '@deepseek-ai/dsh-experimental-agent-team' - config: - maxMembers: 8 - maxTasks: 256 - maxMessageBytes: 65536 - disposalTimeoutMs: 5000 - - - id: tool-agent-team - name: '@deepseek-ai/dsh-experimental-tool-agent-team' - config: - freshProvider: spawn - forkProvider: fork - - id: team-fixture-llm name: './tests/fixtures/team-llm.mjs' diff --git a/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl index c54f84df0b..7779eb2181 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl @@ -4,7 +4,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Probe strict-schema fillers against missing-goal revision 1, then create a durable goal to finish the snapshot proof and inspect it."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"Probe strict-schema fillers against miss","messageSeqs":[8],"source":{"kind":"fallback"}}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl index d45d98dfd0..d4385b311d 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl @@ -8,7 +8,7 @@ {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Prove the product headless profile path with one real tool round trip."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"cli-mock","model":"cli-mock"}} {"type":"session/title","data":{"title":"Prove the product headless profile","messageSeqs":[8],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl index e747f996ad..6cebc92a87 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl @@ -4,7 +4,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"say pong","messageSeqs":[8],"source":{"kind":"fallback"}}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl index bc7c728199..8cc16706c1 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl @@ -4,7 +4,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"say pong","messageSeqs":[8],"source":{"kind":"fallback"}}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl index 5792ae3774..8488837003 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl @@ -4,7 +4,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"retry the transient provider failure"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"retry the transient provider failure","messageSeqs":[8],"source":{"kind":"fallback"}}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl index 11ae2ec827..c14e345b7f 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl @@ -18,7 +18,7 @@ {"type":"step/start","data":{"turn":2,"step":1}} {"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Continue safely from the interrupted operation."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Perform one side-effecting remote mutati","messageSeqs":[3],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl index 9da1c6379a..57f93111e7 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl @@ -16,13 +16,13 @@ {"type":"step/start","data":{"turn":2,"step":1}} {"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Call list_agents once and report what it shows."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Start a background job.","messageSeqs":[3],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"list-once","name":"list_agents","args":["{}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":2,"step":1,"callId":"list-once","name":"list_agents","arguments":"{}"}} -{"type":"tool/result","data":{"turn":2,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"list-once"},"toolCallId":"list-once","content":[{"type":"text","text":"{{sessionId}} [inactive] — descriptorless child"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":2,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"list-once"},"toolCallId":"list-once","content":[{"type":"text","text":"{{sessionId}} [running] — root agent\n{{sessionId}} [inactive] parent={{sessionId}} depth=1 — descriptorless child"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"step/start","data":{"turn":2,"step":2}} {"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The catalogued subagent is ready. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["The catalogued subagent is ready. PARENT_DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The catalogued subagent is ready. PARENT_DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl index b0372d96c6..27712bcd00 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl @@ -2,13 +2,13 @@ {"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Delegated write probe","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash","agentReasoningEffort":"low"}} {"type":"sandbox/mode","data":{"mode":"read-only","source":"delegation"}} {"type":"approval/policy","data":{"policy":"never","source":"delegation"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."},{"type":"text","text":"Your parent agent id is \"{{sessionId}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"{{sessionId}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."},{"type":"text","text":"Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. If your last action is a message to your parent, the completion notice omits your final answer; include the result and relevant evidence in that message."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."},{"type":"text","text":"Your parent agent id is \"{{sessionId}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"{{sessionId}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nDelegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.\n\nDelegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"tool:subagent:depth","text":"Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly."},{"name":"tool:subagent_fork:depth","text":"Delegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."},{"type":"text","text":"Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. If your last action is a message to your parent, the completion notice omits your final answer; include the result and relevant evidence in that message."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a subagent delegated by agent \"{{sessionId}}\".\nYour agent id is \"{{sessionId}}\".\nThe root agent of your collaboration tree is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids.\n\nDelegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.\n\nDelegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are a subagent delegated by agent \"{{sessionId}}\".\nYour agent id is \"{{sessionId}}\".\nThe root agent of your collaboration tree is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."},{"name":"tool:subagent:depth","text":"Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly."},{"name":"tool:subagent_fork:depth","text":"Delegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Use the write tool exactly","messageSeqs":[8],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl index b9bee477bd..f8796478f5 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl @@ -16,20 +16,20 @@ {"type":"step/start","data":{"turn":2,"step":1}} {"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","web_fetch","web_search","workflow","working_directory","write"]},"reason":"resume","startsSeries":true}} -{"type":"developer/message","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"apply_patch"},{"type":"tool-addition","toolName":"bash"},{"type":"tool-addition","toolName":"create_goal"},{"type":"tool-addition","toolName":"edit"},{"type":"tool-addition","toolName":"get_goal"},{"type":"tool-addition","toolName":"glob"},{"type":"tool-addition","toolName":"grep"},{"type":"tool-addition","toolName":"interrupt_agent"},{"type":"tool-addition","toolName":"job_kill"},{"type":"tool-addition","toolName":"job_list"},{"type":"tool-addition","toolName":"job_output"},{"type":"tool-addition","toolName":"list_agents"},{"type":"tool-addition","toolName":"read"},{"type":"tool-addition","toolName":"read_image"},{"type":"tool-addition","toolName":"send_message"},{"type":"tool-addition","toolName":"skill"},{"type":"tool-addition","toolName":"subagent"},{"type":"tool-addition","toolName":"subagent_fork"},{"type":"tool-addition","toolName":"todo_write"},{"type":"tool-addition","toolName":"update_goal"},{"type":"tool-addition","toolName":"web_fetch"},{"type":"tool-addition","toolName":"web_search"},{"type":"tool-addition","toolName":"workflow"},{"type":"tool-addition","toolName":"working_directory"},{"type":"tool-addition","toolName":"write"}],"role":"developer","id":"{{sessionId}}"},"headerSeq":18},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"resume","startsSeries":true}} +{"type":"developer/message","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"apply_patch"},{"type":"tool-addition","toolName":"bash"},{"type":"tool-addition","toolName":"create_goal"},{"type":"tool-addition","toolName":"edit"},{"type":"tool-addition","toolName":"get_goal"},{"type":"tool-addition","toolName":"glob"},{"type":"tool-addition","toolName":"grep"},{"type":"tool-addition","toolName":"interrupt_agent"},{"type":"tool-addition","toolName":"job_kill"},{"type":"tool-addition","toolName":"job_list"},{"type":"tool-addition","toolName":"job_output"},{"type":"tool-addition","toolName":"list_agents"},{"type":"tool-addition","toolName":"read"},{"type":"tool-addition","toolName":"read_image"},{"type":"tool-addition","toolName":"send_message"},{"type":"tool-addition","toolName":"skill"},{"type":"tool-addition","toolName":"subagent"},{"type":"tool-addition","toolName":"subagent_fork"},{"type":"tool-addition","toolName":"todo_write"},{"type":"tool-addition","toolName":"update_goal"},{"type":"tool-addition","toolName":"wait_agent"},{"type":"tool-addition","toolName":"web_fetch"},{"type":"tool-addition","toolName":"web_search"},{"type":"tool-addition","toolName":"workflow"},{"type":"tool-addition","toolName":"working_directory"},{"type":"tool-addition","toolName":"write"}],"role":"developer","id":"{{sessionId}}"},"headerSeq":18},"surfaceOp":"append"} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Tighten this session to read-only.","messageSeqs":[3],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"delegate-write","name":"subagent","args":["{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":2,"step":1,"callId":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}} {"type":"subagent/catalog","data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"continuable","label":"Delegated write probe"}} -{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}} -{"type":"tool/result","data":{"turn":2,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"delegate-write"},"toolCallId":"delegate-write","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[23],"surfaceOp":"append"} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","stopReason":"completed","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}} +{"type":"tool/result","data":{"turn":2,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"delegate-write"},"toolCallId":"delegate-write","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false,"id":"{{sessionId}}"},"meta":{"kind":"activation","subagentId":"{{sessionId}}"}},"sourceEventSeqs":[23],"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":2,"step":2}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","stopReason":"completed","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["The delegated child was denied by the sandbox. PARENT_DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":2}} {"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl index c07532bd7d..2305a8a7c2 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl @@ -2,13 +2,13 @@ {"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Return child result","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} {"type":"sandbox/mode","data":{"mode":"workspace-write","source":"delegation"}} {"type":"approval/policy","data":{"policy":"never","source":"delegation"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message."},{"type":"text","text":"Your parent agent id is \"{{sessionId}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"{{sessionId}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message."},{"type":"text","text":"Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. If your last action is a message to your parent, the completion notice omits your final answer; include the result and relevant evidence in that message."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message."},{"type":"text","text":"Your parent agent id is \"{{sessionId}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"{{sessionId}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nDelegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.\n\nDelegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"tool:subagent:depth","text":"Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly."},{"name":"tool:subagent_fork:depth","text":"Delegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message."},{"type":"text","text":"Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. If your last action is a message to your parent, the completion notice omits your final answer; include the result and relevant evidence in that message."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a subagent delegated by agent \"{{sessionId}}\".\nYour agent id is \"{{sessionId}}\".\nThe root agent of your collaboration tree is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids.\n\nDelegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.\n\nDelegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are a subagent delegated by agent \"{{sessionId}}\".\nYour agent id is \"{{sessionId}}\".\nThe root agent of your collaboration tree is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."},{"name":"tool:subagent:depth","text":"Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly."},{"name":"tool:subagent_fork:depth","text":"Delegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Reply with exactly CHILD_RESULT and","messageSeqs":[8],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl index 441cab1aa2..f05ae06cef 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl @@ -4,7 +4,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Start one continuable subagent and acknowledge its completion notice. Do not call list_agents or send_message."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"Start one continuable subagent and","messageSeqs":[8],"source":{"kind":"fallback"}}}} @@ -13,15 +13,15 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":16,"time":0,"data":{"title":"Subagent settlement","messageSeqs":[8],"source":{"kind":"provider","provider":"session-title-first-prompt-llm","model":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"subagent/catalog","seq":17,"time":0,"data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"continuable","label":"Return child result"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":18,"time":0,"data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"start-child"},"toolCallId":"start-child","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":18,"time":0,"data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"start-child"},"toolCallId":"start-child","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false,"id":"{{sessionId}}"},"meta":{"kind":"activation","subagentId":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":19,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":20,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":20,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","stopReason":"completed","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":21,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":22,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"STARTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["STARTED"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"STARTED"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":24,"time":0,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":26,"time":0,"data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":26,"time":0,"data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","stopReason":"completed","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":27,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_RECEIVED_SETTLEMENT_NOTICE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["PARENT_RECEIVED_SETTLEMENT_NOTICE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_RECEIVED_SETTLEMENT_NOTICE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":28,"time":0,"data":{"turn":1,"step":3}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":29,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl index cbb82cb603..56b127865a 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl @@ -17,7 +17,7 @@ {"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nUpdated instructions from: AGENTS.md\n\nThis file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.\n\nNew workspace instruction after offline edit.\n\n"}],"source":{"kind":"agent-instructions","form":"instructions","changes":[{"action":"replace","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"d8375b516f158718bd3463bc8eb7ed42c011b29f"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Remember the workspace instruction.","messageSeqs":[3],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl index 3bd9c55f95..dfe1d171a9 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl @@ -17,7 +17,7 @@ {"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nThis complete workspace instruction baseline replaces all earlier workspace instruction baselines. The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nCurrent AGENTS rule.\n\n\nInstructions from: CLAUDE.md\n\nCurrent CLAUDE rule.\n\n"}],"source":{"kind":"agent-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".git\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"7f53d2327837129750aef117f9754a001c46cf68"},{"action":"set","scope":".\u0000CLAUDE.md","path":"CLAUDE.md","digest":"5b1e9e3fd759eee6b43ceff899e47fb10c64701a"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Remember the workspace instruction.","messageSeqs":[3],"source":{"kind":"fallback"}}} 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/fixtures/team-llm.mjs b/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs index 9c1bedd00e..62751cd93f 100644 --- a/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs +++ b/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs @@ -15,6 +15,12 @@ function latestAssistantCalls(messages) { return assistant?.content.filter(block => block.type === 'tool-call').map(block => block.name) ?? [] } +function hasNewCoordinationInput(messages) { + const toolIndex = messages.findLastIndex(message => message.role === 'tool') + return messages.some((message, index) => index > toolIndex && message.role === 'user' + && ['agent-message', 'subagent-settled'].includes(message.source?.kind)) +} + function hasTaskAction(messages, action) { return messages.some(message => message.role === 'assistant' && message.content.some((block) => { @@ -63,6 +69,11 @@ function textChunks(text) { function researcher(messages) { const names = calls(messages) + if (!names.includes('list_agents')) return toolChunks([{ name: 'list_agents', args: {} }]) + const sibling = messages.filter(message => message.role === 'tool') + .flatMap(message => message.content.filter(block => block.type === 'text').flatMap(block => block.text.split('\n'))) + .find(line => line.endsWith('— Own deterministic implementation.'))?.split(' ')[0] + if (sibling === undefined) throw new Error('list_agents did not expose the implementation sibling') if (!names.includes('team_task_create')) { return toolChunks([{ name: 'team_task_create', args: { subject: 'Research', description: 'Collect the deterministic finding.', write_scopes: ['research'], @@ -76,13 +87,13 @@ function researcher(messages) { if (!names.includes('send_message')) { return toolChunks([ { name: 'team_task_update', args: { task_id: 'task-1', expected_revision: 2, action: 'complete' } }, - { name: 'send_message', args: { target: 'implementer', message: 'Research complete: use the deterministic finding.' } }, + { name: 'send_message', args: { agent_id: sibling, message: 'Research complete: use the deterministic finding.' } }, ]) } return textChunks('Research teammate complete.') } -function implementer(messages) { +function implementer(messages, parentId) { const names = calls(messages) const last = latestAssistantCalls(messages) const text = latestToolText(messages) @@ -98,7 +109,7 @@ function implementer(messages) { write_scopes: ['implementation'], } }]) } - if (last.includes('wait_agent')) { + if (last.includes('wait_agent') || hasNewCoordinationInput(messages)) { return toolChunks([{ name: 'team_task_get', args: { task_id: 'task-1' } }]) } return toolChunks([{ name: 'wait_agent', args: { timeout_ms: 10000 } }]) @@ -109,10 +120,10 @@ function implementer(messages) { task_id: 'task-2', expected_revision: 1, action: 'claim', } }]) } - if (last.includes('team_task_get')) { - return toolChunks([{ name: 'team_task_get', args: { task_id: 'task-1' } }]) + if (last.includes('team_task_get') && !hasNewCoordinationInput(messages)) { + return toolChunks([{ name: 'wait_agent', args: { timeout_ms: 10000 } }]) } - if (last.includes('wait_agent')) { + if (last.includes('wait_agent') || hasNewCoordinationInput(messages)) { return toolChunks([{ name: 'team_task_get', args: { task_id: 'task-1' } }]) } return toolChunks([{ name: 'team_task_get', args: { task_id: 'task-1' } }]) @@ -123,7 +134,7 @@ function implementer(messages) { if (!names.includes('send_message')) { return toolChunks([ { name: 'team_task_update', args: { task_id: 'task-2', expected_revision: 2, action: 'complete' } }, - { name: 'send_message', args: { target: 'lead', message: 'Implementation complete and verified.' } }, + { name: 'send_message', args: { agent_id: parentId, message: 'Implementation complete and verified.' } }, ]) } return textChunks('Implementation teammate complete.') @@ -138,26 +149,22 @@ function lead(messages) { script: 'return await agent("TEAM_WORKFLOW_CHILD");', } }]) } - const spawned = names.filter(name => name === 'spawn_teammate').length + const spawned = names.filter(name => name === 'subagent').length if (spawned === 0) { return toolChunks([{ - name: 'spawn_teammate', + name: 'subagent', args: { - name: 'implementer', description: 'Own deterministic implementation.', prompt: 'IMPLEMENTER_MARK: wait for research, complete dependent task 2, report to lead.', - context: 'fresh', }, }]) } if (spawned === 1) { return toolChunks([{ - name: 'spawn_teammate', + name: 'subagent', args: { - name: 'researcher', description: 'Own deterministic research.', prompt: 'RESEARCHER_MARK: complete research task 1, message implementer, then finish.', - context: 'fresh', }, }]) } @@ -165,35 +172,38 @@ function lead(messages) { if (last.includes('team_task_list')) { const completed = result.match(/"status":"completed"/gu)?.length ?? 0 if (completed >= 2) return toolChunks([{ name: 'list_agents', args: {} }]) - return toolChunks([{ name: 'team_task_list', args: {} }]) + return toolChunks([{ name: hasNewCoordinationInput(messages) ? 'team_task_list' : 'wait_agent', + args: hasNewCoordinationInput(messages) ? {} : { timeout_ms: 10000 } }]) } if (last.includes('list_agents')) { - const inactive = result.match(/"status":"inactive"/gu)?.length ?? 0 + const inactive = result.split('\n') + .filter(row => row.includes('— Own deterministic ') && row.includes('[inactive]')).length if (inactive >= 2) return textChunks('TEAM_WORKFLOW_OK: both teammates and dependent tasks completed.') - return toolChunks([{ name: 'list_agents', args: {} }]) + return toolChunks([{ name: hasNewCoordinationInput(messages) ? 'list_agents' : 'wait_agent', + args: hasNewCoordinationInput(messages) ? {} : { timeout_ms: 10000 } }]) } - if (last.includes('wait_agent')) return toolChunks([{ name: 'team_task_list', args: {} }]) + if (last.includes('wait_agent') || hasNewCoordinationInput(messages)) return toolChunks([{ name: 'team_task_list', args: {} }]) return toolChunks([{ name: 'wait_agent', args: { timeout_ms: 10000 } }]) } class TeamFixtureAdapter extends LlmAdapter { async * stream(options) { const tools = options.tools.map(tool => tool.name) - if (tools.includes('subagent') || tools.includes('subagent_fork')) { - throw new Error('Team profile exposes a direct subagent tool') - } const initial = options.messages.findLast(message => message.role === 'user' && message.source.kind === 'user') const workflowChild = initial?.content[0]?.text === 'TEAM_WORKFLOW_CHILD' const context = options.messages.findLast(message => message.source?.kind === 'runtime-context') - const identity = context?.source.sections?.find(section => section.name === 'team:identity')?.text - if (!workflowChild && (!tools.includes('spawn_teammate') || !tools.includes('workflow'))) { - throw new Error('Team profile is missing teammate or workflow tools') + const section = name => context?.source.sections?.find(candidate => candidate.name === name)?.text + const role = section('subagent:identity') + const parentId = role?.match(/You are a subagent delegated by agent "([^"]+)"/u)?.[1] + const task = initial?.content.filter(block => block.type === 'text').map(block => block.text).join('\n') + if (!workflowChild && (!tools.includes('subagent') || !tools.includes('workflow') || !tools.includes('team_task_create'))) { + throw new Error('Team profile is missing delegation, workflow, or task tools') } let chunks if (workflowChild) chunks = textChunks('Fresh workflow child complete.') - else if (identity?.startsWith('You are teammate "researcher".')) chunks = researcher(options.messages) - else if (identity?.startsWith('You are teammate "implementer".')) chunks = implementer(options.messages) - else if (identity?.startsWith('You are the Team Lead named "lead".')) chunks = lead(options.messages) + else if (task?.startsWith('RESEARCHER_MARK:')) chunks = researcher(options.messages) + else if (task?.startsWith('IMPLEMENTER_MARK:')) chunks = implementer(options.messages, parentId) + else if (role?.startsWith('You are the root agent. Your agent id is')) chunks = lead(options.messages) else throw new Error('Team runtime context is missing a recognized identity') for (const chunk of chunks) { options.signal?.throwIfAborted() @@ -205,9 +215,30 @@ class TeamFixtureAdapter extends LlmAdapter { /** Cordis plugin name. */ export const name = 'team-fixture-llm' /** LLM registry dependency. */ -export const inject = ['llm'] +export const inject = ['llm', 'tools', 'agentTeams'] /** Register the keyless adapter on the shipped default provider route. */ export function apply(ctx) { + ctx.on('agent/created', async ({ agent }) => { + if (agent.session.header.parentSession === undefined) await ctx.agentTeams.setEnabled(agent, true) + }) + const implementerWaiting = Promise.withResolvers() + const leadWaiting = Promise.withResolvers() + for (const gate of [implementerWaiting, leadWaiting]) void gate.promise.catch(() => undefined) + ctx.effect(() => () => { + implementerWaiting.reject(new Error('Team fixture disposed before implementer wait')) + leadWaiting.reject(new Error('Team fixture disposed before lead wait')) + }) + ctx.on('tools/execute', async (exec, next) => { + if (exec.name === 'team_task_create' && exec.arguments.subject === 'Research') { + await Promise.all([implementerWaiting.promise, leadWaiting.promise]) + } + const result = next() + if (exec.name === 'wait_agent') { + if (exec.agent.session.header.origin === 'subagent') implementerWaiting.resolve() + else leadWaiting.resolve() + } + return result + }) ctx.llm.registerAdapter(['deepseek-official'], new TeamFixtureAdapter()) } diff --git a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts index a2ae0c9f19..9f53fb980a 100644 --- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts @@ -1093,7 +1093,7 @@ describe('headless stream-json snapshots', () => { } }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('runs a keyless Agent Team with peer mail, dependent tasks, waiting, and Lead aggregation', async () => { + it('runs a keyless Agent Team with described teammates, sibling messages, dependent tasks, waiting, and Lead aggregation', async () => { let projection: unknown const result = await runLoaderSmoke({ label: 'Agent Teams headless snapshot', @@ -1125,8 +1125,8 @@ describe('headless stream-json snapshots', () => { expect(workflowRows.filter(row => row.type === 'user/message' && ((row.data as JsonObject).source as JsonObject).kind === 'user').map(row => row.data)) .toEqual([expect.objectContaining({ content: [{ type: 'text', text: 'TEAM_WORKFLOW_CHILD' }] })]) - const members = rows.filter(row => row.type === 'team/member') - .map(row => ((row.data as JsonObject).member as JsonObject)) + const teammates = rows.filter(row => row.type === 'subagent/catalog') + .flatMap(row => typeof (row.data as JsonObject).label === 'string' && ((row.data as JsonObject).label as string).startsWith('Own deterministic ') ? [(row.data as JsonObject).label as string] : []) const tasks = rows.filter(row => row.type === 'team/task') .map(row => ((row.data as JsonObject).task as JsonObject)) const latestTasks = Object.values(Object.fromEntries(tasks.map(task => [String(task.subject), task]))) @@ -1179,21 +1179,51 @@ describe('headless stream-json snapshots', () => { && ((row.data as JsonObject).source as JsonObject).kind === 'user') if (initial === undefined) throw new Error('Teammate Session has no initial task') const content = (initial.data as JsonObject).content as JsonObject[] - expect(content).toHaveLength(1) + // The task block is followed by the standard return guidance naming the Lead. + expect(content).toHaveLength(2) const context = parseJsonl(log.content).find(row => row.type === 'user/message' && ((row.data as JsonObject).source as JsonObject).kind === 'runtime-context') const source = (context?.data as JsonObject)?.source as JsonObject const sections = source?.sections as JsonObject[] | undefined - const identity = sections?.find(section => section.name === 'team:identity')?.text - if (typeof identity !== 'string') throw new Error('Teammate runtime context has no identity') - return [identity] + const leadId = parent.header.id as string + const text = (name: string) => { + const value = sections?.find(section => section.name === name)?.text + if (typeof value !== 'string') throw new Error(`Teammate runtime context has no ${name}`) + return value.replaceAll(log.header.id as string, '').replaceAll(leadId, '') + } + expect(sections?.some(section => section.name === 'team:identity')).toBe(false) + return [text('subagent:identity')] }).sort() + const allRows = logs.flatMap(log => parseJsonl(log.content)) + const messageSources = allRows.flatMap((row) => { + const data = row.data as JsonObject + if (row.type === 'user/message') return [(data.source as JsonObject).kind] + if (row.type === 'agent/inbox/spliced') { + return (data.inserted as JsonObject[]).map(message => (message.source as JsonObject).kind) + } + return [] + }) + expect(messageSources).not.toContain('team-task-change') + const waitIds = new Set(allRows.filter(row => row.type === 'tool/call' + && (row.data as JsonObject).name === 'wait_agent').map(row => (row.data as JsonObject).callId)) + const waitResults = allRows.filter((row) => { + if (row.type !== 'tool/result') return false + const message = (row.data as JsonObject).message as JsonObject + return waitIds.has((message.source as JsonObject).callId) + }) + expect(waitResults).toHaveLength(waitIds.size) + for (const row of waitResults) { + const message = (row.data as JsonObject).message as JsonObject + expect(message.isError).not.toBe(true) + const content = message.content as JsonObject[] + expect(JSON.parse(content.filter(block => block.type === 'text').map(block => block.text).join(''))) + .toEqual({ timedOut: false }) + } projection = { sessions: logs.length, workflowStopReason: (rows.find(row => row.type === 'tool-workflow/run-end')?.data as JsonObject)?.stopReason, - memberEdges: members.length, identities, - activeMembers: members.filter(member => member.phase === 'active').map(member => member.name).sort(), + teammates: [...teammates].sort(), tasks: latestTasks.map(task => ({ subject: task.subject, revision: task.revision, @@ -1203,7 +1233,7 @@ describe('headless stream-json snapshots', () => { deliveredMessages: rows.filter(row => row.type === 'team/message/delivered').length, waited: rows.some(row => row.type === 'tool/call' && (row.data as JsonObject).name === 'wait_agent'), - checkedRoster: rows.some(row => row.type === 'tool/call' + checkedAgents: rows.some(row => row.type === 'tool/call' && (row.data as JsonObject).name === 'list_agents'), steerEvidence: { nextStepInbox: steeredInboxIndex >= 0, @@ -1221,25 +1251,22 @@ describe('headless stream-json snapshots', () => { }) expect(projection).toMatchInlineSnapshot(` { - "activeMembers": [ - "implementer", - "researcher", - ], - "checkedRoster": true, + "checkedAgents": true, "deliveredMessages": 0, "identities": [ - "You are teammate "implementer". - Your Team Lead is named "lead". - To message your Team Lead, use send_message({ target: "lead", message: "..." }). - Use list_agents({}) to find your teammates and their names. - To message another teammate, use send_message({ target: "", message: "..." }).", - "You are teammate "researcher". - Your Team Lead is named "lead". - To message your Team Lead, use send_message({ target: "lead", message: "..." }). - Use list_agents({}) to find your teammates and their names. - To message another teammate, use send_message({ target: "", message: "..." }).", + "You are a subagent delegated by agent "". + Your agent id is "". + The root agent of your collaboration tree is "". + + To message other agents, use send_message({ agent_id: "", message: "..." }). + Use list_agents({}) to find your collaborator agents and their ids.", + "You are a subagent delegated by agent "". + Your agent id is "". + The root agent of your collaboration tree is "". + + To message other agents, use send_message({ agent_id: "", message: "..." }). + Use list_agents({}) to find your collaborator agents and their ids.", ], - "memberEdges": 4, "queuedMessages": 0, "sessions": 4, "steerEvidence": { @@ -1260,6 +1287,10 @@ describe('headless stream-json snapshots', () => { "subject": "Research", }, ], + "teammates": [ + "Own deterministic implementation.", + "Own deterministic research.", + ], "waited": true, "workflowStopReason": "completed", } 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/headless/tests/subagent-diagnostic.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts index 941d95c31d..3aff474038 100644 --- a/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts @@ -1,6 +1,6 @@ /** * Assembled-app regression: a parent-owned catalog event remains sufficient - * for direct discovery when the persisted child has no descriptor event. + * for descendant discovery when the persisted child has no descriptor event. */ import { readFile, readdir, writeFile } from 'node:fs/promises' @@ -42,7 +42,7 @@ async function expectSession(actual: string, expectedPath: string): Promise { const ctx = new Context() @@ -106,7 +106,7 @@ async function seedCataloguedDescriptorlessChild(root: string, cwd: string): Pro } describe('parent-owned cold child catalog snapshot', () => { - it('discovers a catalogued child without reading its descriptor through the assembled headless app', async () => { + it('discovers a descriptorless catalogued child with its tree position through the assembled headless app', async () => { let cwd = '' const result = await runLoaderSmoke({ label: 'subagent diagnostic headless stream-json snapshot', @@ -131,9 +131,8 @@ describe('parent-owned cold child catalog snapshot', () => { const parent = logs.find(content => content.includes('"subagent-diagnostic-parent"')) if (parent === undefined) throw new Error('missing persisted parent log') - // THE model-visible fact: the parent catalog supplies discovery even - // though consulting the descriptor-less child would classify it corrupt. - expect(parent).toContain(`${childId} [inactive] — descriptorless child`) + // Discovery reads child catalogs without requiring activation descriptors. + expect(parent).toContain(`${childId} [inactive] parent=${parentId} depth=1 — descriptorless child`) expect(parent).not.toContain('[diagnostic: corrupt]') const context: NormalizeContext = { sessionIds: [parentId, childId], cwd } diff --git a/apps/cli/tests/profiles/web/tests/default-web-process.ts b/apps/cli/tests/profiles/web/tests/default-web-process.ts index 537ac77702..155f8d6670 100644 --- a/apps/cli/tests/profiles/web/tests/default-web-process.ts +++ b/apps/cli/tests/profiles/web/tests/default-web-process.ts @@ -55,7 +55,7 @@ export async function withDefaultWeb( await writeFile(patch, JSON.stringify([{ insert: [{ id: 'runtime-roster-observer', name: pathToFileURL(join(root, 'fixtures/runtime-roster-observer.js')).href, - config: { negativeControl: pathToFileURL(join(repoRoot, 'packages/experimental/client-ui-agent-team/lib/index.js')).href }, + config: { negativeControl: pathToFileURL(join(repoRoot, 'packages/experimental/client-ui-voice-input/lib/index.js')).href }, }] }]) + '\n') const launch = resolveExampleLaunch({ srcBin: join(repoRoot, 'apps/cli/src/bin.ts'), diff --git a/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts index 7563a323aa..687afcb6d8 100644 --- a/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts +++ b/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts @@ -7,7 +7,7 @@ import { modulePackage } from './runtime-roster.ts' import { experimentalRuntimeReferences } from './runtime-roster-isolation.ts' import { withDefaultWeb, webGet } from './default-web-process.ts' -const experimentalName = '@deepseek-ai/dsh-experimental-client-ui-agent-team' +const experimentalName = '@deepseek-ai/dsh-experimental-client-ui-voice-input' it('boots default Web with the shipped Schedule rows, without experimental modules, time context, or an active built-in Browser', async (test) => { await withDefaultWeb(test, async ({ url, request }) => { @@ -37,6 +37,9 @@ it('boots default Web with the shipped Schedule rows, without experimental modul // The shipped composition mounts the Host Schedule service and its task // page; the clock stays preset-level, so neither plane carries a row for it. expect(roster.entries).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: '@deepseek-ai/dsh-agent-team', state: FiberState.ACTIVE }), + expect.objectContaining({ name: '@deepseek-ai/dsh-tool-agent-team', state: FiberState.ACTIVE }), + expect.objectContaining({ name: '@deepseek-ai/dsh-client-ui-agent-team', state: FiberState.ACTIVE }), expect.objectContaining({ name: '@deepseek-ai/dsh-schedule', state: FiberState.ACTIVE }), expect.objectContaining({ name: '@deepseek-ai/dsh-client-ui-schedule', state: FiberState.ACTIVE }), ])) @@ -50,7 +53,7 @@ it('boots default Web with the shipped Schedule rows, without experimental modul const mounted = contaminated.plugins.filter(plugin => plugin.modules.some(url => modulePackage(url) === experimentalName)) expect(mounted).toEqual([expect.objectContaining({ state: FiberState.ACTIVE })]) expect(experimentalRuntimeReferences(contaminated)).toEqual(expect.arrayContaining([ - expect.stringContaining('/packages/experimental/client-ui-agent-team/'), + expect.stringContaining('/packages/experimental/client-ui-voice-input/'), ])) }) }) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index e7aa033223..ff5fd57cae 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -248,9 +248,9 @@ beforeAll(async () => { }, 120_000) describe('the shipped Web composition', () => { - it('exposes only the directory tool in the global layer', () => { - // Directory selection is global; action tools belong to their Agent presets. - expect(toolNames(ctx)).toEqual(['working_directory']) + it('registers the directory and conditional Team tools in the global layer', () => { + // Team registrations are shared; session mode controls their step-time availability. + expect(toolNames(ctx)).toEqual(['team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) }) it('keeps the token meter and its context-meter projections on the host plane', async () => { @@ -304,7 +304,7 @@ describe('the shipped Web composition', () => { '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', - 'subagent', 'subagent_fork', 'todo_write', 'update_goal', 'web_fetch', 'web_search', + 'subagent', 'subagent_fork', 'team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'todo_write', 'update_goal', 'wait_agent', 'web_fetch', 'web_search', 'workflow', 'working_directory', 'write', ]) expect(ctx.commands.find(handle.agent, 'goal')).toBeDefined() @@ -408,7 +408,7 @@ describe('the shipped Web composition', () => { expect(assembly.sections).toEqual([ { name: 'deployment:persona-prefix', text: MINIMAL_PROMPT }, ]) - expect(assembly.tools.map(tool => tool.name)).toEqual(['bash', 'read_image', 'working_directory']) + expect(assembly.tools.map(tool => tool.name)).toEqual(['bash', 'read_image', 'team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) expect(assembly.tools.find(tool => tool.name === 'bash')?.description).toBe(MINIMAL_BASH_DESCRIPTION) expect(ctx.commands.find(handle.agent, 'goal')).toBeUndefined() // serviceFor reports preset-owned providers; unisolated consumers inherit the host fs. @@ -485,7 +485,7 @@ describe('the shipped Web composition', () => { const last = adapter.requests.at(-1) expect(last?.messages.flatMap(message => message.content) .some(block => block.type === 'text' && block.text.includes('Preserve the requested project task.'))).toBe(true) - expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'read_image', 'working_directory']) + expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'read_image', 'team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) } finally { await handle.dispose() } @@ -529,7 +529,7 @@ describe('the shipped Web composition', () => { setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), }) try { - expect(toolNames(ctx, minimal.agent)).toEqual(['bash', 'read_image', 'working_directory']) + expect(toolNames(ctx, minimal.agent)).toEqual(['bash', 'read_image', 'team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10) expect(ctx.agentPresets.serviceFor(minimal.agent, 'compaction')) .not.toBe(ctx.agentPresets.serviceFor(full.agent, 'compaction')) @@ -538,7 +538,7 @@ describe('the shipped Web composition', () => { // Tearing the minimal session down leaves the full one whole. expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10) - expect(toolNames(ctx)).toEqual(['working_directory']) + expect(toolNames(ctx)).toEqual(['team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) } finally { await full.dispose() } @@ -697,7 +697,7 @@ describe('the shipped Web composition', () => { // stays the preset's choice — minimal mounts no `tool-skill`, so its // tool table has no loader even though the global layer is readable. expect((await ctx.skills.list({ scope: handle.agent })).map(skill => skill.name)).toContain('dsh-badge') - expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'read_image', 'working_directory']) + expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'read_image', 'team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) } finally { await handle.dispose() } @@ -790,7 +790,7 @@ describe.each(['web', 'desktop'] as const)('%s native subagent bundles', (surfac const productCtx = await bootSelected(installed) const spawn = vi.spyOn(productCtx.subprocess, 'spawn') try { - expect(productCtx.subagents.list().filter(name => (products as readonly string[]).includes(name)).sort()) + expect(products.filter(name => productCtx.subagents.getProvider(name) !== undefined).sort()) .toEqual([...installed].sort()) const expected = installed.map(toolName).sort() for (const preset of ['standard', 'cordis', 'ptc', 'minimal']) { @@ -881,6 +881,8 @@ describe('a user preset declared from the shipped cordis rows', () => { // The `Tool` provider is the one built over the host context: it // answers with the shared registry's view of the REQUESTING agent, // so the copy sees its own preset's tools, not the host's empty set. + const spills = vi.spyOn(copyCtx.spillStore, 'saveText') + onTestFinished(() => { spills.mockRestore() }) const queried = await executeToolFixture(copyCtx, { callId: ToolCallId('copied-preset-inspect-tools'), name: 'cordis_inspect_query', @@ -889,11 +891,8 @@ describe('a user preset declared from the shipped cordis rows', () => { agent: copied.agent, }) expect(queried.isError).toBe(false) - // The whole-table answer can pass the inline token budget once the - // preset's tool table grows, and the spill policy then replaces its - // middle with a gap plus a spill footer. What this case proves is the - // provider's answer, so assert the names it must carry. - const queriedText = resultText(queried) + // The tool catalog may exceed the inline budget; assert its complete stored text. + const queriedText = spills.mock.calls.at(-1)?.[0].content ?? resultText(queried) for (const toolName of ['bash', 'cordis_inspect_list', 'cordis_inspect_query', 'plugin_manager']) { expect(queriedText).toContain(`"name": "${toolName}"`) } @@ -1088,7 +1087,7 @@ describe('the default preset as a user setting', () => { }) try { // An omitted preset id resolves the stored default instead of the composed default. - expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'read_image', 'working_directory']) + expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'read_image', 'team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) } finally { await handle.dispose() } @@ -1134,7 +1133,7 @@ describe('a profile patch stored before Developer tools owned preset selection', setup: agentCtx => legacy.agentPresets.mount(agentCtx).then(() => undefined), }) try { - expect(toolNames(legacy, handle.agent)).toEqual(['bash', 'read_image', 'working_directory']) + expect(toolNames(legacy, handle.agent)).toEqual(['bash', 'read_image', 'team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) } finally { await handle.dispose() } 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/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 3dfceaccd1..081d44f9f8 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -15,8 +15,8 @@ en: 127ea90553cb6af7 zh: 7b1c5aec8558c684 /deepseek-harness-desktop/application-overview: - en: 1667e3d6ffddea4a - zh: 4661a42b9ef4f673 + en: d4afa46403997884 + zh: 9b9fe126afda70f0 /deepseek-harness-desktop/terminal-command: en: 5f2d25a1d8472647 zh: 2acb5c5d7d1917e2 @@ -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 fe24b24568..335991f722 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -35,7 +35,7 @@ Electron caches the last confirmed UI language in `userData/desktop/locale-cache Desktop analytics follows the [product collection policy](../../packages/client/product-analytics/README.md), including its live application setting. Web usage is excluded. Update installation waits for its local analytics intake before locking API admission and stopping the Host. Intake has a one-second deadline; failure does not prevent installation, and collector delivery is not awaited. -The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host, dropping connection-level response headers (`transfer-encoding`, `connection`, `keep-alive`) that describe the Node fetch rather than the resource, and marking plugin bundle responses `no-store` because their per-launch revisions would only accumulate in Chromium's disk cache; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop listens on an OS-assigned port by default, so it cannot collide with Web’s `3080` or a reserved port; a `webserver.config.port` patch can override it. +The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host, dropping connection-level response headers (`transfer-encoding`, `connection`, `keep-alive`) that describe the Node fetch rather than the resource, and marking plugin bundle responses `no-store` because their per-launch revisions would only accumulate in Chromium's disk cache; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop listens on an OS-assigned port by default, so it cannot collide with Web’s `3080` or a reserved port; a `webserver.config.port` patch can override it. Native Host requests send the current Host's authentication cookie explicitly, and no Electron session stores it; each Host start removes `dsh-auth-*` cookies that earlier releases left in the default session, because cookies ignore ports and would otherwise accompany every request to `127.0.0.1`. Desktop binds `127.0.0.1`, the address Electron dials for the ready URL and the WebSocket credential filter. WebSocket streams use `ws:` or `wss:` according to the Host listener; credentials require matching authority and scheme. @@ -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 933e329ce1..82ccf994a4 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -35,7 +35,7 @@ Electron 在 `userData/desktop/locale-cache` 缓存最近确认的界面语言 桌面埋点遵循[产品采集策略](../../packages/client/product-analytics/README.zh.md)及其动态应用配置,不包含 Web 使用情况。安装更新会等待该操作的本地埋点接收请求结束,再锁定 API 准入并停止 Host。接收请求的时限为一秒,失败不会阻止安装,也不等待收集端完成发送。 -桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host,转发时丢弃描述 Node fetch 连接而非资源本身的响应头(`transfer-encoding`、`connection`、`keep-alive`),并把插件 bundle 响应标记为 `no-store`,因为其每次启动都变化的 revision 只会在 Chromium 磁盘缓存中累积;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认监听系统分配的端口,因此不会与 Web 的 `3080` 或系统保留端口冲突;可通过 `webserver.config.port` patch 覆盖。 +桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host,转发时丢弃描述 Node fetch 连接而非资源本身的响应头(`transfer-encoding`、`connection`、`keep-alive`),并把插件 bundle 响应标记为 `no-store`,因为其每次启动都变化的 revision 只会在 Chromium 磁盘缓存中累积;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认监听系统分配的端口,因此不会与 Web 的 `3080` 或系统保留端口冲突;可通过 `webserver.config.port` patch 覆盖。原生 Host 请求会显式发送当前 Host 的认证 Cookie,任何 Electron session 都不保存该 Cookie;每次启动 Host 时都会移除早期版本遗留在默认 session 中的 `dsh-auth-*` Cookie,因为 Cookie 不区分端口,否则这些 Cookie 会随每个发往 `127.0.0.1` 的请求一并发送。 Desktop 绑定 `127.0.0.1`,即 Electron 为就绪 URL 与 WebSocket 凭据过滤器拨号的地址。WebSocket 流按 Host 监听器使用 `ws:` 或 `wss:`;附加凭据要求 authority 与 scheme 均匹配。 @@ -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 5d4f00ebee..3f95b9aa8b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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 c39bb71bc3..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 @@ -27,17 +27,17 @@ en: 253e17998951931c zh: b582b377391d2ec2 /desktop-packaging/macos-self-hosted-packaging: - en: f4cc5209bdaeefba - zh: 1db959d1ed345fcc + en: b7e03e2e9807a62c + zh: e5b0c43910e085d8 /desktop-packaging/unsigned-windows-test-installer: en: f429bc0c280e2dfa zh: 4adbcc9e906bdf80 /desktop-packaging/local-macos-dmg-without-release-signing: - en: 2feaa0412247d81f - zh: 461dde8e13fd4734 + en: 1146bbc11be43960 + zh: 7c13543a0513c059 /desktop-packaging/macos-release-signing-and-diagnostics: - en: 29fa6bd8919f3ec0 - zh: 9e31e037fa3d9378 + en: a0b4aa1080ecf6fb + zh: 37d4b8934f89bee8 /desktop-packaging/directory-builds-and-preparation: en: 5860c306375ddd63 zh: a76e3cdbbe25c79e @@ -45,11 +45,11 @@ en: b42f103358d0dfd7 zh: a534164ad9eb00da /desktop-packaging/runtime-file-selection-and-installation-checks: - en: 83e1075417039410 - zh: 3088a7ae0a2cc3c5 + en: 44044a5c14b75533 + zh: 39d91a369c4ec2fa /desktop-packaging/desktop-runtime-preparation: - en: ab73b67d899e611d - zh: d77b0525c6b81e91 + en: 84be38ab3136fff7 + zh: bb4ed12ee0e1dfa0 /desktop-packaging/installation-and-release-verification: en: eb35eb973b796643 zh: c1c52311632b7296 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index 28aa9ff9c7..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). @@ -125,7 +125,7 @@ Installer uploads store the already-compressed release files without ZIP recompr ## macOS self-hosted packaging -The [Apple Silicon workflow](../../../.github/workflows/macos-package.yml) and [Intel workflow](../../../.github/workflows/macos-intel-package.yml) produce separate arm64 and x64 packages on the Apple Silicon runner labeled `dsh-mac-package-trial`. Both share one concurrency group. The Intel workflow checks Rosetta execution before dependency installation; its selected source must include the target-aware macOS CI adapter. Both select `test` or `production` and use the existing signing, notarization and packaged-runtime checks. Successful DMG/ZIP files and diagnostic logs are retained for 14 days without publishing a release. Installer uploads disable redundant ZIP compression. The operator must verify a complete Actions run and installation on the target architecture before release qualification. The `compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only ZIP and automatic-update downloads, not DMG. +The [Apple Silicon workflow](../../../.github/workflows/macos-package.yml) and [Intel workflow](../../../.github/workflows/macos-intel-package.yml) produce separate arm64 and x64 packages on the Apple Silicon runner labeled `dsh-mac-package-trial`. Both share one concurrency group. The Intel workflow checks Rosetta execution before dependency installation; its selected source must include the target-aware macOS CI adapter. Both select `test` or `production` and use the existing signing, notarization and packaged-runtime checks. When the selected source's dsh ships the Computer Use bundle, each workflow builds, notarizes and stages the helper for both architectures with `scripts/cua-helper/package.ts` and `stage.ts` after the configuration check, the steps npm publication uses; `skip_notarization` does not skip the helper's notarization. A source that predates helper packaging only emits a warning, and Computer Use cannot prepare its helper in that package. Successful DMG/ZIP files and diagnostic logs are retained for 14 days without publishing a release. Installer uploads disable redundant ZIP compression. The operator must verify a complete Actions run and installation on the target architecture before release qualification. The `compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only ZIP and automatic-update downloads, not DMG. All three platform forms put source, environment, update channel, version mode, manual version, test release ID and compression level first, with matching defaults. macOS notarization and Windows signing and cache measurement options follow these common fields. @@ -172,19 +172,21 @@ pnpm run package:desktop:mac:x64:unsigned Add `--check` to validate local configuration and tools without building, or `--dir` to stop at the unpacked application. The DMG is `apps/desktop/.desktop-build/targets//unsigned-artifacts/deepseek-harness--mac--unsigned.dmg`. After all checks pass, the command prints `DMG:` followed by its absolute path. Open it and copy the application to a local test directory. -These commands ignore `.env.macos` and inherited release settings, use `com.deepseek.harness` as the app ID, and omit automatic-update and mandatory-update configuration. Build tools, standard network proxies, and `DSH_DESKTOP_NPM_REGISTRY` remain available from the shell. They skip Developer ID signing, Apple notarization, temporary signing keychains, and notarization proxy changes. Electron receives a local ad-hoc signature without certificates so the modified executable can run; the DMG is unsigned. Both preparation and the assembled application still run runtime integrity and smoke checks. Local builds omit update feeds, ZIPs, and release completion records, and the upload commands cannot publish them. macOS may require approval in System Settings → Privacy & Security to open an unnotarized application. +These commands ignore `.env.macos` and inherited release settings, use `com.deepseek.harness` as the app ID, and omit automatic-update and mandatory-update configuration. Build tools, standard network proxies, and `DSH_DESKTOP_NPM_REGISTRY` remain available from the shell. They skip Developer ID signing, Apple notarization, temporary signing keychains, and notarization proxy changes. Electron receives a local ad-hoc signature without certificates so the modified executable can run; the DMG is unsigned. Both preparation and the assembled application still run runtime integrity and smoke checks. Local builds omit update feeds, ZIPs, and release completion records, and the upload commands cannot publish them. macOS may require approval in System Settings → Privacy & Security to open an unnotarized application. These builds stage no Computer Use helper, so Computer Use reports the missing helper when it prepares. ## macOS release signing and diagnostics -The signed macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. Signed macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. The signed fixed-target macOS installer commands create separate copies of the signed application and run two artifact lanes concurrently. One lane notarizes and staples the App before generating the ZIP and its update metadata. The other encloses its signed App copy in a signed DMG, then notarizes, staples, and verifies the DMG; its inner App has no individually stapled ticket. Both lanes must finish successfully before their artifacts reach the final directory and the release completion record is written. Signed directory-only commands also require notarization credentials and wait for Apple notarization and App stapling. The [parallel notarization decision](../../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md) owns copy isolation and container ticket semantics. `CSC_LINK` must name a local p12 containing the Developer ID Application certificate and private key; URLs and Base64 inputs are not supported. `CSC_KEY_PASSWORD` is its export password, not an Apple account or login password; an explicitly empty value supports an unencrypted p12. Before building, packaging creates and unlocks a private temporary keychain, imports the p12, authorizes signing, and signs a small probe. Runtime and App signing use this keychain explicitly; existing login keychains require no setup or manual unlocking. Child processes receive its path without the p12 password. The keychain is deleted after success or ordinary failure; CI must clean temporary credentials after forced termination. Ephemeral CI writes the certificate and `.env.macos` from its secret store, restricts file access, and deletes both after the job; self-hosted machine presets retain private files outside the checkout. Ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- `. +The signed macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. Signed macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file outside the notarized Computer Use helper bundles; for those bundles it verifies the deep signature, release identity, stapled ticket, and Gatekeeper acceptance without re-signing their files. A signed local build of a source that ships Computer Use therefore needs the notarized helper staged first with `scripts/cua-helper/package.ts` and `stage.ts`; an ad-hoc `build:native` output under `native/bin` fails this verification; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. The signed fixed-target macOS installer commands create separate copies of the signed application and run two artifact lanes concurrently. One lane notarizes and staples the App before generating the ZIP and its update metadata. The other encloses its signed App copy in a signed DMG, then notarizes, staples, and verifies the DMG; its inner App has no individually stapled ticket. Both lanes must finish successfully before their artifacts reach the final directory and the release completion record is written. Signed directory-only commands also require notarization credentials and wait for Apple notarization and App stapling. The [parallel notarization decision](../../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md) owns copy isolation and container ticket semantics. `CSC_LINK` must name a local p12 containing the Developer ID Application certificate and private key; URLs and Base64 inputs are not supported. `CSC_KEY_PASSWORD` is its export password, not an Apple account or login password; an explicitly empty value supports an unencrypted p12. Before building, packaging creates and unlocks a private temporary keychain, imports the p12, authorizes signing, and signs a small probe. Runtime and App signing use this keychain explicitly; existing login keychains require no setup or manual unlocking. Child processes receive its path without the p12 password. The keychain is deleted after success or ordinary failure; CI must clean temporary credentials after forced termination. Ephemeral CI writes the certificate and `.env.macos` from its secret store, restricts file access, and deletes both after the job; self-hosted machine presets retain private files outside the checkout. Ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- `. macOS signing visits real files without following Framework symlink aliases. PAK resources retain all shipped languages and are sealed by the enclosing Framework or application signature instead of receiving individual signatures. The [release policy](../../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the dependency patch and verification requirements. macOS runtime preparation caches verified single-architecture Mach-O signatures in `.desktop-build/targets//signature-cache` unless `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` selects external storage. An explicit path must be absolute and already exist, contain no linked ancestors, and be owned by the current account with mode `0700` and read/write/search access; invalid or empty settings fail preflight without fallback. Omit the field to retain target-local storage. Keys include input bytes and permissions, the signing probe’s actual leaf certificate, identifier, entitlement bytes, macOS version, and signing tools and policy. Git commits do not determine reuse: uncommitted byte changes invalidate the affected file. Every hit verifies cached bytes, the strict signature, certificate, identifier, entitlements, secure timestamp and hardened runtime before replacing an unchanged input. Universal binaries are signed each time. Runtime integrity and smoke checks, App signing, and Apple notarization still run. The cache requires trusted local build storage. Cache corruption or unsafe links fail the build; stop packaging before deleting the affected cache directory and retrying. Successful signing stages prune complete entries to a one-GiB payload budget; interrupted temporary entries require manual removal. Concurrent eviction can fail a reader safely. Logs report hit, miss and uncached counts. -Mac package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory under `apps/desktop/.desktop-build/packaging-runs/`. After reading local configuration, each run retains `run.json` (published version, product version, Git commit, dirty-worktree flag, target and Node version), `events.jsonl` (timestamped phases, durations, parallel output attribution and proxy restoration), redacted child `stdout.log` / `stderr.log`, and `result.json` (overall outcome, stage results and artifact directory). Events also record pack concurrency and whether each proxy is configured. Logs survive failures and later builds; no automatic deletion runs. Missing `result.json` means completion is unconfirmed. Known credential values are redacted; environments and notarytool authentication arguments are not recorded. Runtime preparation records package staging, installation, materialization, signing, manifests, smoke checks, descriptor verification and cleanup separately. Nested phase durations overlap and must not be added together. +Mac package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory under `apps/desktop/.desktop-build/packaging-runs/`. After reading local configuration, each run retains `run.json` (published version, product version, Git commit, dirty-worktree flag, target and Node version), `events.jsonl` (timestamped phases, durations, parallel output attribution and proxy restoration), redacted child `stdout.log` / `stderr.log`, and `result.json` (overall outcome, stage results and artifact directory). Events also record pack concurrency and whether each proxy is configured. Logs survive failures and later builds; no automatic deletion runs. Missing `result.json` means completion is unconfirmed. Known credential values are redacted, and environments are not recorded. Apple command failures retain their exit code, signal, stdout and stderr in redacted form; the adapter also writes these details to stderr when no journal is available. Runtime preparation records package staging, installation, materialization, signing, manifests, smoke checks, descriptor verification and cleanup separately. Nested phase durations overlap and must not be added together. + +CUA helper packaging prints `CUA_PACKAGING_RECORD` and creates its own record in the same directory before desktop installer startup. Its `run.json` identifies the helper target, product version and source commit; `events.jsonl` retains command and notarization phases, submission IDs and Apple results. `result.json` records success or failure. These records survive temporary payload cleanup, and the Desktop Actions diagnostic upload includes them even when helper preparation fails. App and DMG notarization each record a submission ID and separate `notarytool:upload:*` and `notarytool:wait:*` durations. Upload uses `submit --no-wait` and includes authentication, local validation, transfer and server acceptance; it is not pure transfer time. Waiting starts after that command returns and includes polling and remaining Apple processing; Apple can start processing before upload submission returns. Apple status and diagnostic logs are retained, including rejection; signature checks, acceptance checks and stapling remain owned by `@electron/notarize`. Directory-only package commands use this same measured App notarization path. Individual subprocess output remains live in the terminal, while phase failures and nested errors are retained in the journal. Both platforms record parent-process packaging failures and print redacted diagnostics, including proxy recovery instructions, to the terminal. The configuration-only `check:package` command creates no run log. @@ -246,7 +248,7 @@ Desktop packs workspace packages locally and installs external dependencies thro The [Electron shell file list](../scripts/electron-builder-config.mjs) excludes recognized JavaScript/CSS/TypeScript source maps and TypeScript build caches from the separate `resources/app.asar/node_modules` dependency collection. These exclusions preserve runtime code, native modules, WASM, data files (including ordinary `.map` assets), licenses, and notices; [shell archive tests](../tests/shell-file-selection.spec.ts) check retained bytes and exclusions. -The [Office conversion provider](../../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. The complete Office dependencies, including CLI, JavaScript libraries, and the selected engine’s executables, data, licenses, and notices, are unpacked under `resources/app.asar.unpacked/dsh/node_modules/`. The Desktop Host resolves engine manifests to these physical directories and supplies loaded skills with absolute standalone Node and unpacked CLI paths. Node lives in `resources/runtime/primary-runtime/dependencies/node/bin/`; the CLI is the unpacked `@deepseek-ai/libreoffice-kit/lib/cli.js`. On macOS, the native helper receives the JIT entitlement required by [LibreOffice’s UNO bridge](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in). +The [Office conversion provider](../../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. The complete Office dependencies, including CLI, JavaScript libraries, and the selected engine’s executables, data, licenses, and notices, are unpacked under `resources/app.asar.unpacked/dsh/node_modules/`. The Desktop Host resolves engine manifests to these physical directories and supplies loaded skills with absolute standalone Node and unpacked CLI paths. Node lives in `resources/runtime/primary-runtime/dependencies/node/bin/`; the CLI is the unpacked `@deepseek-ai/libreoffice-kit/lib/cli.js`. On macOS, the native helper receives the JIT entitlement required by [LibreOffice’s UNO bridge](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in). The Computer Use helper applications under `@deepseek-ai/dsh-experimental-cua-macos/native/bin` are also unpacked whole under `resources/app.asar.unpacked/dsh/node_modules/`, and the provider opens that copy. The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](../tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](../tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. The payload smoke resolves the search tool’s ripgrep executable and verifies text search and file enumeration. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. The [Host smoke](../scripts/smoke-runtime.ts) creates DOCX, XLSX, and PPTX inputs with bundled Python, converts each through the real Office provider, and checks PDF output. Every assembled application, including directory and unsigned builds, repeats the payload and Host checks against ASAR. Archive integrity checks compare the complete archived descriptor with preparation, file bytes and membership in both archive and unpacked trees, recorded executable flags for archived files, and physical permissions for unpacked files. The Host smoke also obtains CLI paths from the loaded skill and runs capabilities and DOCX conversion with an empty PATH. Conversion failures stop packaging before a release record is written; macOS DMG/ZIP builds run these checks before notarization. @@ -280,7 +282,7 @@ Desktop [patch policy](../scripts/runtime-patch-policy.ts) explicitly classifies Desktop dependency overrides are declared separately in the patch-policy module, not inferred from patch versions. The pi-ai constraint keeps its runtime version at the version qualified by the shared patch. Desktop still resolves a fresh runtime lockfile for each build, then installs with `--frozen-lockfile`; dependency resolution across separate builds is not yet reproducible. CLI packed-installation tests and other distribution flows retain their own configuration. -Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution plus primary-runtime with its shared pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. Signed macOS builds sign and verify native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Windows packaging checks every prepared PE against its ASAR unpacked entry and byte-identical disk copy, including unsigned builds. Builder glob rules match braces in PE filenames as single-character wildcards, so a matching neighboring file may also be unpacked. Prepared runtime smoke uses the verified target descriptor rather than the build host architecture. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment. +Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution plus primary-runtime with its shared pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. Signed macOS builds sign and verify native files, and verify the notarized Computer Use helper bundles without re-signing them, before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Windows packaging checks every prepared PE against its ASAR unpacked entry and byte-identical disk copy, including unsigned builds. Builder glob rules match braces in PE filenames as single-character wildcards, so a matching neighboring file may also be unpacked. Prepared runtime smoke uses the verified target descriptor rather than the build host architecture. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment. Windows runtime sealing reads at most eight files concurrently, before and after native signing. A read failure drains outstanding reads without replacing the descriptor; successful sealing still requires the subsequent complete integrity and smoke checks. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 2e87933826..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)。 @@ -125,7 +125,7 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s ## macOS 自托管打包 -[Apple Silicon 工作流](../../../.github/workflows/macos-package.yml)与 [Intel 工作流](../../../.github/workflows/macos-intel-package.yml)在带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner 上分别生成 arm64 和 x64 安装包,两者共用一个并发组。Intel 工作流在安装依赖前检查 Rosetta 能否执行;所选源码必须包含按目标架构校验的 macOS CI 适配脚本。两者均选择 `test` 或 `production`,并复用现有签名、公证和打包运行时检查。成功的 DMG/ZIP 与诊断日志保留 14 天,不发布 release;安装包上传关闭重复 ZIP 压缩。操作人员须验收完整 Actions 运行及目标架构上的安装,才能确认可交付。`compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。 +[Apple Silicon 工作流](../../../.github/workflows/macos-package.yml)与 [Intel 工作流](../../../.github/workflows/macos-intel-package.yml)在带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner 上分别生成 arm64 和 x64 安装包,两者共用一个并发组。Intel 工作流在安装依赖前检查 Rosetta 能否执行;所选源码必须包含按目标架构校验的 macOS CI 适配脚本。两者均选择 `test` 或 `production`,并复用现有签名、公证和打包运行时检查。所选源码的 dsh 随附 Computer Use 组合包时,两者都在配置检查之后用 `scripts/cua-helper/package.ts` 和 `stage.ts` 构建、公证并放入两种架构的 helper,与 npm 发布的步骤相同;`skip_notarization` 不会跳过 helper 的公证。源码早于 helper 打包支持时只输出警告,该安装包中的 Computer Use 无法准备 helper。成功的 DMG/ZIP 与诊断日志保留 14 天,不发布 release;安装包上传关闭重复 ZIP 压缩。操作人员须验收完整 Actions 运行及目标架构上的安装,才能确认可交付。`compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。 三个平台的表单均依次展示源码、环境、升级通道、版本模式、手动版本号、测试 release ID 和压缩等级,默认值一致。macOS 公证选项和 Windows 签名、缓存测量选项位于公共字段之后。 @@ -172,19 +172,21 @@ pnpm run package:desktop:mac:x64:unsigned 添加 `--check` 仅验证本地配置和工具而不构建,或添加 `--dir` 只生成解包应用。DMG 路径为 `apps/desktop/.desktop-build/targets//unsigned-artifacts/deepseek-harness--mac--unsigned.dmg`。所有检查通过后,命令打印 `DMG:` 及其绝对路径。打开后将应用复制到本地测试目录。 -这些命令忽略 `.env.macos` 和继承的发布配置,使用 `com.deepseek.harness` 作为应用 ID,并省略自动更新和强制更新配置。构建工具、标准网络代理与 `DSH_DESKTOP_NPM_REGISTRY` 仍可从 shell 提供。它们跳过 Developer ID 签名、Apple 公证、临时签名钥匙串和公证代理修改。Electron 使用无需证书的本地 ad-hoc 签名,以便修改后的可执行文件能够运行;DMG 不签名。准备阶段和组装后的应用仍执行运行时完整性与冒烟检查。本地构建不生成更新源、ZIP 或发布完成记录,上传命令无法发布这些产物。macOS 可能要求在“系统设置 → 隐私与安全性”中批准打开未经公证的应用。 +这些命令忽略 `.env.macos` 和继承的发布配置,使用 `com.deepseek.harness` 作为应用 ID,并省略自动更新和强制更新配置。构建工具、标准网络代理与 `DSH_DESKTOP_NPM_REGISTRY` 仍可从 shell 提供。它们跳过 Developer ID 签名、Apple 公证、临时签名钥匙串和公证代理修改。Electron 使用无需证书的本地 ad-hoc 签名,以便修改后的可执行文件能够运行;DMG 不签名。准备阶段和组装后的应用仍执行运行时完整性与冒烟检查。本地构建不生成更新源、ZIP 或发布完成记录,上传命令无法发布这些产物。macOS 可能要求在“系统设置 → 隐私与安全性”中批准打开未经公证的应用。这类构建不放入 Computer Use helper,Computer Use 准备时会报告 helper 缺失。 ## macOS 发布签名与诊断 -macOS 签名配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 签名打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。macOS 签名固定目标安装包命令为已签名应用创建独立副本,并发执行两条产物流。一路先公证 App 并钉票,再生成 ZIP 及其更新元数据。另一路把已签名 App 副本封装进签名 DMG,再公证 DMG、钉票并验证;其中的 App 不单独附加票据。只有两路均成功结束,产物才会移入最终目录并写入发布完成记录。签名的仅目录命令同样需要公证凭据,并等待 Apple 公证和 App 钉票完成。[并行公证决策](../../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md)负责副本隔离与容器票据语义。`CSC_LINK` 必须指向包含 Developer ID Application 证书及私钥的本地 p12,不支持 URL 或 Base64 输入。`CSC_KEY_PASSWORD` 是其导出密码,不是 Apple 账号或登录密码;未加密的 p12 可显式填写空值。构建前,打包流程自动创建并解锁私有临时钥匙串、导入 p12、授权签名并签署小型探针。运行时与 App 签名显式使用该钥匙串,无需预先配置或手动解锁登录钥匙串。子进程只接收钥匙串路径,不接收 p12 密码。成功或普通失败后删除临时钥匙串;强制终止后由 CI 清理临时凭据。临时 CI 从密钥存储生成证书文件和 `.env.macos`,限制文件访问权限,并在作业结束后删除二者;自托管机器 preset 在 checkout 外保留私有文件。环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- ` 重复应用检查时,也必须提供两个 macOS 身份变量。 +macOS 签名配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 签名打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到已公证 Computer Use helper 应用包之外的每个内嵌 Mach-O 文件;对这些应用包,则校验深度签名、发布身份、钉票和 Gatekeeper 评估结果,不重签其中的文件。因此,打包会随附 Computer Use 的源码时,本地签名构建需要先用 `scripts/cua-helper/package.ts` 和 `stage.ts` 放入已公证的 helper;`native/bin` 下由 `build:native` 生成的临时签名产物无法通过此校验;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。macOS 签名固定目标安装包命令为已签名应用创建独立副本,并发执行两条产物流。一路先公证 App 并钉票,再生成 ZIP 及其更新元数据。另一路把已签名 App 副本封装进签名 DMG,再公证 DMG、钉票并验证;其中的 App 不单独附加票据。只有两路均成功结束,产物才会移入最终目录并写入发布完成记录。签名的仅目录命令同样需要公证凭据,并等待 Apple 公证和 App 钉票完成。[并行公证决策](../../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md)负责副本隔离与容器票据语义。`CSC_LINK` 必须指向包含 Developer ID Application 证书及私钥的本地 p12,不支持 URL 或 Base64 输入。`CSC_KEY_PASSWORD` 是其导出密码,不是 Apple 账号或登录密码;未加密的 p12 可显式填写空值。构建前,打包流程自动创建并解锁私有临时钥匙串、导入 p12、授权签名并签署小型探针。运行时与 App 签名显式使用该钥匙串,无需预先配置或手动解锁登录钥匙串。子进程只接收钥匙串路径,不接收 p12 密码。成功或普通失败后删除临时钥匙串;强制终止后由 CI 清理临时凭据。临时 CI 从密钥存储生成证书文件和 `.env.macos`,限制文件访问权限,并在作业结束后删除二者;自托管机器 preset 在 checkout 外保留私有文件。环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- ` 重复应用检查时,也必须提供两个 macOS 身份变量。 macOS 签名遍历真实文件,不跟随 Framework 的软链接别名。PAK 资源保留全部随附语言,由外层 Framework 或应用签名记录完整性,不逐个签名。[发布策略](../../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责依赖补丁和验证要求。 macOS 运行时准备将已验证的单架构 Mach-O 签名缓存在 `.desktop-build/targets//signature-cache`,除非通过 `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` 选择外置存储。显式路径必须是已存在的绝对目录,祖先路径不能包含链接,目录须归当前账户所有、权限为 `0700` 且可读写及遍历;非法或空配置在预检时失败,不回退。省略该字段保留目标本地缓存。缓存键包含输入字节与权限、签名探针实际使用的叶证书、签名标识、entitlement 字节、macOS 版本,以及签名工具与策略。复用不取决于 Git 提交:未提交的字节变更会使对应文件失效。每次命中都验证缓存字节、严格签名、证书、标识、entitlements、安全时间戳和 hardened runtime,再替换未被并发修改的输入。通用二进制每次重新签名。运行时完整性与 smoke 检查、App 签名和 Apple 公证仍会执行。缓存要求受信任的本地构建存储。缓存损坏或不安全链接会令构建失败;停止打包后删除对应缓存目录再重试。成功的签名阶段会将完整缓存条目的内容总量裁剪至一 GiB;中断留下的临时条目需手动清理。并发淘汰可能使读取方安全失败。日志记录命中、未命中及未缓存数量。 -Mac 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `apps/desktop/.desktop-build/packaging-runs/` 下的唯一目录。读取本地配置后,每次运行保留 `run.json`(发布版本、产品版本、Git 提交、工作区是否有改动、目标及 Node 版本)、`events.jsonl`(带时间戳的阶段、耗时、并行输出归属及代理恢复状态)、脱敏后的子进程 `stdout.log` / `stderr.log`,以及 `result.json`(整体结果、阶段结果和产物目录)。事件还记录打包并发数及各代理是否配置。失败和后续打包不会清除日志;没有自动删除流程。缺少 `result.json` 表示完成状态未经确认。已知凭据值会被遮盖;不记录环境变量全集或 notarytool 认证参数。运行时准备分别记录包暂存、安装、依赖树复制、签名、清单生成、smoke 检查、描述文件校验和清理。嵌套阶段的耗时存在重叠,不能直接相加。 +Mac 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `apps/desktop/.desktop-build/packaging-runs/` 下的唯一目录。读取本地配置后,每次运行保留 `run.json`(发布版本、产品版本、Git 提交、工作区是否有改动、目标及 Node 版本)、`events.jsonl`(带时间戳的阶段、耗时、并行输出归属及代理恢复状态)、脱敏后的子进程 `stdout.log` / `stderr.log`,以及 `result.json`(整体结果、阶段结果和产物目录)。事件还记录打包并发数及各代理是否配置。失败和后续打包不会清除日志;没有自动删除流程。缺少 `result.json` 表示完成状态未经确认。已知凭据值会被遮盖,不记录环境变量全集。Apple 命令失败时保留脱敏后的退出码、信号、stdout 和 stderr;没有日志目录时,适配器也会将这些详情写入 stderr。运行时准备分别记录包暂存、安装、依赖树复制、签名、清单生成、smoke 检查、描述文件校验和清理。嵌套阶段的耗时存在重叠,不能直接相加。 + +CUA helper 打包通过 `CUA_PACKAGING_RECORD` 输出同一目录下的独立记录路径,在桌面安装包开始构建前创建。其 `run.json` 记录 helper 目标、产品版本和源码提交,`events.jsonl` 保留命令与公证阶段、已返回的提交 ID 和 Apple 处理结果,`result.json` 记录成功或失败。这些记录不会随临时 payload 清理而删除;即使 helper 准备失败,Desktop Actions 的诊断上传步骤也会收集它们。 App 和 DMG 公证分别记录 submission ID,以及独立的 `notarytool:upload:*` 和 `notarytool:wait:*` 耗时。上传使用 `submit --no-wait`,包含认证、本地校验、传输和服务端受理,并非纯传输时间。等待从该命令返回后开始,包含轮询及剩余的 Apple 处理时间;Apple 可能在上传提交命令返回前已开始处理。日志保留 Apple 状态与诊断信息,包括拒绝结果;签名检查、接受状态校验和 stapling 仍由 `@electron/notarize` 负责。仅生成目录的打包命令也使用同一条可计时的 App 公证路径。各子进程输出仍实时显示在终端,阶段失败及嵌套错误保留在事件日志中。两个平台均记录父进程打包失败,并在终端显示脱敏诊断,包括代理恢复操作指引。仅检查配置的 `check:package` 命令不创建运行日志。 @@ -246,7 +248,7 @@ Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安 [Electron 外壳文件清单](../scripts/electron-builder-config.mjs)从单独收集的 `resources/app.asar/node_modules` 依赖中排除已识别的 JavaScript/CSS/TypeScript source map 和 TypeScript 构建缓存。这些排除规则保留运行时代码、原生模块、WASM、数据文件(包括普通 `.map` 资源)、许可证和 notices;[外壳归档测试](../tests/shell-file-selection.spec.ts)检查保留文件的字节和排除项。 -[Office 转换提供方](../../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。完整 Office 依赖(CLI、JavaScript 库和选定引擎的可执行文件、数据、许可证及 notices)解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下。Desktop Host 将引擎清单解析到这些物理目录,并向加载的技能提供独立 Node 和解包后 CLI 的绝对路径。Node 位于 `resources/runtime/primary-runtime/dependencies/node/bin/`;CLI 位于解包后的 `@deepseek-ai/libreoffice-kit/lib/cli.js`。macOS 上的原生辅助程序获得 [LibreOffice UNO 桥](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in)所需的 JIT entitlement。 +[Office 转换提供方](../../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。完整 Office 依赖(CLI、JavaScript 库和选定引擎的可执行文件、数据、许可证及 notices)解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下。Desktop Host 将引擎清单解析到这些物理目录,并向加载的技能提供独立 Node 和解包后 CLI 的绝对路径。Node 位于 `resources/runtime/primary-runtime/dependencies/node/bin/`;CLI 位于解包后的 `@deepseek-ai/libreoffice-kit/lib/cli.js`。macOS 上的原生辅助程序获得 [LibreOffice UNO 桥](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in)所需的 JIT entitlement。`@deepseek-ai/dsh-experimental-cua-macos/native/bin` 下的 Computer Use helper 应用也整体解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下,提供方打开该副本。 打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](../tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](../tests/fixtures/runtime-payload-smoke.mjs) 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行。产物 smoke 解析搜索工具使用的 ripgrep 可执行文件,并验证文本搜索和文件枚举。Windows 签名构建在依赖签名后运行这些检查;其他构建在 `prepare:dsh` 中运行。[Host smoke](../scripts/smoke-runtime.ts) 使用捆绑的 Python 创建 DOCX、XLSX 和 PPTX 输入,通过真实 Office 提供方逐一转换并检查 PDF 输出。每个组装后的应用(包括目录包和未签名构建)都会针对 ASAR 重复产物和 Host 检查。归档完整性检查将归档内完整描述符与准备结果比对,并核对归档和解包目录中的文件内容与清单、归档内文件记录的执行标志,以及解包文件的物理权限。转换失败会在写入发布记录前终止打包;macOS DMG/ZIP 构建在公证前执行这些检查。 @@ -280,7 +282,7 @@ Desktop [补丁策略](../scripts/runtime-patch-policy.ts) 将每份工作区补 Desktop 依赖 overrides 在补丁策略模块中单独声明,不从补丁版本推导。pi-ai 约束使运行时版本保持在共享补丁已验证的版本。Desktop 仍会在每次构建时重新解析运行时锁文件,再通过 `--frozen-lockfile` 安装;不同构建之间的依赖解析尚不保证可复现。CLI 打包安装测试及其他交付流程保留各自的配置。 -每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包和包含共享 pnpm CLI 的 primary-runtime。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。macOS 签名构建先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。Windows 打包逐项检查准备好的 PE,确认其 ASAR 条目已标记为解包,且磁盘副本字节一致;未签名构建也执行此检查。Builder glob 规则用单字符通配符匹配 PE 文件名中的花括号,因此同目录中名称匹配的文件也可能被解包。准备好的运行时 smoke 沿用已验证的目标描述符,不使用构建宿主的架构。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。 +每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包和包含共享 pnpm CLI 的 primary-runtime。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。macOS 签名构建先签名并验证原生文件,并在不重签的前提下校验已公证的 Computer Use helper 应用包,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。Windows 打包逐项检查准备好的 PE,确认其 ASAR 条目已标记为解包,且磁盘副本字节一致;未签名构建也执行此检查。Builder glob 规则用单字符通配符匹配 PE 文件名中的花括号,因此同目录中名称匹配的文件也可能被解包。准备好的运行时 smoke 沿用已验证的目标描述符,不使用构建宿主的架构。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。 Windows 运行时在原生签名前后封存文件清单,每次最多并发读取八个文件。读取失败时会等待未完成的读取结束,不替换描述符;封存成功后仍须完成后续的完整性与 smoke 检查。 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 cf0c19e895..c2563a0edf 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -76,7 +76,10 @@ export function createElectronBuilderConfig( let dshDestination let windowsCode = [] const unpack = ['**/*.{node,dylib,dll,so,exe}', '**/*.so.*', '**/spawn-helper', '**/@vscode/ripgrep-*/bin/rg', - `**/node_modules/@deepseek-ai/libreoffice-kit-${resolvedPlatform}-${resolvedArch}/**/*`] + `**/node_modules/@deepseek-ai/libreoffice-kit-${resolvedPlatform}-${resolvedArch}/**/*`, + // LaunchServices opens the Computer Use helper as an application, so every file of its bundles stays on disk. + // Both architectures ship: they form one notarized payload whose manifest staging verifies as a whole. + '**/node_modules/@deepseek-ai/dsh-experimental-cua-macos/native/bin/**/*'] const windowsSigner = packagesWindows && !unsigned ? createWindowsTokenSigner({ certificateFile: env.DSH_DESKTOP_WINDOWS_CER_FILE, @@ -102,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/logged-notarytool.mjs b/apps/desktop/scripts/logged-notarytool.mjs index ae2a9bf055..a9ed964b3c 100755 --- a/apps/desktop/scripts/logged-notarytool.mjs +++ b/apps/desktop/scripts/logged-notarytool.mjs @@ -5,7 +5,7 @@ import { basename, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { recordPackagingEvent, packagingOutputRedactor } from './packaging-run.mjs' -import { packagingStep } from './packaging-step.mjs' +import { packagingErrorDetails, packagingStep } from './packaging-step.mjs' const exec = promisify(execFile) const AUTH_FLAGS = new Set(['--apple-id', '--password', '--team-id', '--key', '--key-id', '--issuer', '--keychain', '--keychain-profile']) @@ -17,7 +17,8 @@ async function execute(args) { } catch (error) { // Apple can report rejection with a nonzero exit; electron-notarize needs its JSON ID to retrieve diagnostics. if (args[0] === 'wait' && typeof error.code === 'number' && typeof error.stdout === 'string' && error.stdout.trim()) return error.stdout - throw error + throw new Error(`notarytool ${args[0]} failed (code: ${String(error.code ?? 'unknown')}, signal: ${String(error.signal ?? 'none')})\n` + + `stdout: ${error.stdout ?? ''}\nstderr: ${error.stderr ?? ''}`, { cause: error }) } } @@ -74,10 +75,13 @@ export async function runLoggedNotarytool(args, directory, invoke = execute) { } if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { - try { process.stdout.write(await runLoggedNotarytool(process.argv.slice(2), process.env.DSH_DESKTOP_PACKAGING_RUN_DIR)) } - catch { - // execFile errors include credential-bearing argv; detailed failures are redacted in the journal. - process.stderr.write('desktop notarization: Apple command failed; see packaging events.jsonl\n') + const args = process.argv.slice(2) + const directory = process.env.DSH_DESKTOP_PACKAGING_RUN_DIR + try { process.stdout.write(await runLoggedNotarytool(args, directory)) } + catch (error) { + const credentials = args.filter((_, index) => AUTH_FLAGS.has(args[index - 1])) + process.stderr.write(`desktop notarization: Apple command failed${directory ? `; see ${directory}/events.jsonl` : ''}\n` + + `${packagingErrorDetails(error, credentials)}\n`) process.exitCode = 1 } } 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/macos-runtime.ts b/apps/desktop/scripts/macos-runtime.ts index 1d4c27c0d5..16b8fe2a27 100644 --- a/apps/desktop/scripts/macos-runtime.ts +++ b/apps/desktop/scripts/macos-runtime.ts @@ -8,9 +8,15 @@ import { inventoryDesktopRuntime } from '../src/runtime-tree.ts' import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs' import { cachedMacOSSignature, pruneMacOSSignatureCache } from './macos-signature-cache.ts' import { macOSCachePolicy } from './macos-cache-policy.ts' -import { signMacOSRuntimeCode, verifyMacOSRuntimeCode } from './verify-macos-signature.mjs' +import { signMacOSRuntimeCode, verifyMacOSNotarizedApplication, verifyMacOSRuntimeCode } from './verify-macos-signature.mjs' const MACH_O_MAGICS = new Set(['cafebabe', 'cafebabf', 'cefaedfe', 'cffaedfe', 'feedface', 'feedfacf', 'bebafeca', 'bfbafeca']) +/** + * The notarized Computer Use helper bundle containing a runtime path, as staged by `scripts/cua-helper/stage.ts`: + * `native/bin/darwin-//.app`. Other bundles, such as LibreOffice's unsigned application, + * are not matched and keep per-file runtime signing. + */ +const CUA_HELPER_BUNDLE = /^(node_modules\/@deepseek-ai\/dsh-experimental-cua-macos\/native\/bin\/darwin-[^/]+\/[^/]+\/[^/]+\.app)\//u function magic(path: string): string { const descriptor = openSync(path, 'r') @@ -33,21 +39,32 @@ async function primaryRuntimeFiles(root: string): Promise { } /** - * Sign and verify every materialized Mach-O file, awaiting all signers on failure. + * Sign and verify every materialized Mach-O file, awaiting all signers on failure. Files inside the notarized Computer + * Use helper bundles are not re-signed, because signing them breaks the bundle seal and changes the identity macOS + * grants permissions to; each such bundle's deep signature, release identity, stapled ticket, and Gatekeeper + * acceptance are verified before any file is signed. * @param root - Self-contained production runtime. * @param appId - Release application identifier. * @param expected - Required signing identity. * @param arch - Target runtime architecture, independent of the signing host. * @param cacheDirectory - Optional content-addressed cache; requires the keychain-owned signing probe. * @param kind - Primary payloads admit validated Python aliases; Desktop application runtimes reject every link. - * @returns Number of signed native files. + * @returns Number of signed native files, excluding files inside verified Computer Use helper bundles. + * @throws When a helper bundle fails verification, or when any signer fails. */ export async function signMacOSRuntime( root: string, appId: string, expected: MacOSSigningEnvironment, arch: 'arm64' | 'x64', cacheDirectory?: string, kind: 'desktop' | 'primary' = 'desktop', ): Promise { const inventory = kind === 'primary' ? await primaryRuntimeFiles(root) : inventoryDesktopRuntime(root).map(file => file.path) - const files = inventory.filter(path => MACH_O_MAGICS.has(magic(join(root, path)))) + const bundles = new Set() + const files: string[] = [] + for (const path of inventory) { + const bundle = CUA_HELPER_BUNDLE.exec(path)?.[1] + if (bundle !== undefined) bundles.add(bundle) + else if (MACH_O_MAGICS.has(magic(join(root, path)))) files.push(path) + } + for (const bundle of [...bundles].sort()) verifyMacOSNotarizedApplication(join(root, bundle), expected) const policy = cacheDirectory === undefined ? undefined : macOSCachePolicy(process.env.DSH_DESKTOP_MACOS_SIGNING_PROBE ?? '') let hits = 0 let misses = 0 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/account-backend.ts b/apps/desktop/src/account-backend.ts index 862b8f81ed..19ae3fe796 100644 --- a/apps/desktop/src/account-backend.ts +++ b/apps/desktop/src/account-backend.ts @@ -80,10 +80,10 @@ export interface DesktopAccountBackend { * Connect native account operations to the standard authenticated Web backend. * @param origin - Host Web origin. * @param invoke - validated unary RPC caller. - * @param cookies - Electron session cookie reader. + * @param cookie - Host authentication Cookie header for the account WebSocket. * @returns account operations; watch callers own their subscriptions. */ -export function desktopAccountBackend(origin: string, invoke: AccountInvoke, cookies: () => Promise): DesktopAccountBackend { +export function desktopAccountBackend(origin: string, invoke: AccountInvoke, cookie: string): DesktopAccountBackend { const call = async (method: string, args: Record = {}): Promise => accountView(await invoke({ namespace: 'account', method, args })) return { @@ -98,34 +98,31 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo const streamId = randomUUID() const expiryStreamId = randomUUID() const analyticsPolicyStreamId = randomUUID() - void cookies().then((cookie) => { - if (closed) return - const url = new URL(REMOTE_STREAM_MUX_PATH, origin) - url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' - socket = new WebSocket(url, { headers: { cookie, origin }, maxPayload: 65_536 }) - socket.on('open', () => { - if (onAnalyticsEnabledChanged !== undefined) socket?.send(JSON.stringify({ type: 'open', streamId: analyticsPolicyStreamId, endpoint: 'productAnalytics/watchPolicy', payload: { args: {} } })) - socket?.send(JSON.stringify({ type: 'open', streamId: expiryStreamId, endpoint: 'account/watchExpiry', payload: { args: {} } })) - socket?.send(JSON.stringify({ type: 'open', streamId, endpoint: 'account/watch', payload: { args: {} } })) - }) - socket.on('message', (data) => { - try { - const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data) - const frame = parseRemoteStreamServerMessage(bytes.toString('utf8')) - if (frame.streamId === analyticsPolicyStreamId) { - if (frame.type === 'item' && typeof frame.value === 'boolean') onAnalyticsEnabledChanged?.(frame.value) - else onAnalyticsEnabledChanged?.(false) - return - } - if (frame.streamId === expiryStreamId && frame.type === 'item' && frame.value === 'session-expired') { expired(); return } - if (frame.streamId !== streamId) throw new Error('desktop account: unexpected stream') - if (frame.type === 'item') listener(accountView(frame.value)) - else socket?.close() - } catch { socket?.close() } - }) - socket.on('error', () => { socket?.close() }) - socket.on('close', () => { if (!closed) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } }) - }).catch(() => { if (!closed) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } }) + const url = new URL(REMOTE_STREAM_MUX_PATH, origin) + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + socket = new WebSocket(url, { headers: { cookie, origin }, maxPayload: 65_536 }) + socket.on('open', () => { + if (onAnalyticsEnabledChanged !== undefined) socket?.send(JSON.stringify({ type: 'open', streamId: analyticsPolicyStreamId, endpoint: 'productAnalytics/watchPolicy', payload: { args: {} } })) + socket?.send(JSON.stringify({ type: 'open', streamId: expiryStreamId, endpoint: 'account/watchExpiry', payload: { args: {} } })) + socket?.send(JSON.stringify({ type: 'open', streamId, endpoint: 'account/watch', payload: { args: {} } })) + }) + socket.on('message', (data) => { + try { + const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data) + const frame = parseRemoteStreamServerMessage(bytes.toString('utf8')) + if (frame.streamId === analyticsPolicyStreamId) { + if (frame.type === 'item' && typeof frame.value === 'boolean') onAnalyticsEnabledChanged?.(frame.value) + else onAnalyticsEnabledChanged?.(false) + return + } + if (frame.streamId === expiryStreamId && frame.type === 'item' && frame.value === 'session-expired') { expired(); return } + if (frame.streamId !== streamId) throw new Error('desktop account: unexpected stream') + if (frame.type === 'item') listener(accountView(frame.value)) + else socket?.close() + } catch { socket?.close() } + }) + socket.on('error', () => { socket?.close() }) + socket.on('close', () => { if (!closed) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } }) } connect() return () => { closed = true; onAnalyticsEnabledChanged?.(false); clearTimeout(retry); socket?.close() } 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 d8d0e30c4c..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' @@ -16,7 +20,6 @@ import { powerMonitor, nativeImage, nativeTheme, - net, protocol, session, shell, @@ -39,7 +42,7 @@ import { desktopUpdateChannel } from './update-channel.ts' import { DesktopUpdateCoordinator } from './update-coordinator.ts' import { prepareDesktopUpdateWindow } from './update-window.ts' import { DesktopCommandManager } from './command-management.ts' -import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts' +import { serveWebDocument, authenticateWebHost, forwardWebRequest, removeLegacyHostCookies } from './web-document.ts' import { DesktopFatalRecovery } from './fatal-recovery.ts' import { pruneCrashReports, RendererConsoleTail, writeCrashReport, type CrashReportSource } from './crash-report.ts' import { OperationJournal } from './operation-journal.ts' @@ -366,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()) @@ -422,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) @@ -443,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 }) } @@ -529,11 +535,14 @@ async function main(): Promise { return { start: async () => { const ready = await host.start() - hostCookie = await authenticateWebHost(ready.url) + const cookie = await authenticateWebHost(ready.url) + hostCookie = cookie hostUrl = ready.url + void removeLegacyHostCookies(session.defaultSession.cookies, ready.url) + .catch((error: unknown) => { console.error('desktop: legacy Host cookie cleanup failed', error) }) if (ready.injections === undefined) throw new Error('Desktop Host did not provide boot injections') injections = ready.injections - welcomeBackend = await connectDesktopWelcome(ready.url, (input, init) => net.fetch(input, init), async () => (await session.defaultSession.cookies.get({ url: ready.url })).map(cookie => `${cookie.name}=${cookie.value}`).join('; ')) + welcomeBackend = connectDesktopWelcome(ready.url, cookie) analyticsEnabled = await welcomeBackend.analyticsEnabled().catch(() => false) if (!reportedLaunch) { reportedLaunch = true; void track('desktop_app_launch', {}) } stopAccount?.() @@ -612,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 @@ -676,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 () => { @@ -737,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 @@ -750,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() @@ -867,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) @@ -895,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() } @@ -915,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) @@ -926,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') { @@ -937,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 } @@ -952,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() @@ -1409,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 @@ -1427,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/src/web-document.ts b/apps/desktop/src/web-document.ts index a74ea7c7ef..15f289e14b 100644 --- a/apps/desktop/src/web-document.ts +++ b/apps/desktop/src/web-document.ts @@ -49,6 +49,26 @@ export async function authenticateWebHost(url: string): Promise { return end < 0 ? cookie : cookie.slice(0, end) } +/** Cookie store operations used to remove legacy Host authentication cookies. */ +export interface HostCookieStore { + get(filter: { url: string }): Promise + remove(url: string, name: string): Promise +} + +/** + * Remove every `dsh-auth-*` cookie that earlier releases left in a cookie store for the Host's hostname. + * Cookies ignore ports, so cookies from earlier random-port Hosts would otherwise travel with every + * request to that hostname until they expire. + * @param cookies - Electron session cookie store. + * @param url - Host URL; cookies for its hostname on every port are removed. + */ +export async function removeLegacyHostCookies(cookies: HostCookieStore, url: string): Promise { + const origin = new URL(url).origin + for (const { name } of await cookies.get({ url: origin })) { + if (name.startsWith('dsh-auth-')) await cookies.remove(origin, name) + } +} + /** * Response headers not relayed to the renderer. `set-cookie` would hand the * Host's authentication cookie to the page's cookie jar, which the shell owns diff --git a/apps/desktop/src/welcome-backend.ts b/apps/desktop/src/welcome-backend.ts index 54ab46d85e..15b0932cb2 100644 --- a/apps/desktop/src/welcome-backend.ts +++ b/apps/desktop/src/welcome-backend.ts @@ -35,20 +35,18 @@ function record(value: unknown): value is Record { } /** - * Authenticate the native HTTP client through the Web application's launch URL. - * @param authenticatedUrl - URL supplied by the running Desktop Host. - * @param send - Electron session fetch, retaining the Web authentication cookie. + * Connect the native HTTP client to the Desktop Host; every request sends `cookie` explicitly and uses no cookie jar. + * @param hostUrl - URL of the running Desktop Host; only its origin is used. + * @param cookie - Cookie header returned by `authenticateWebHost` for this Host. + * @param send - HTTP client for Host requests. * @returns metadata reads and write-only credential operations over standard RPC. */ -export async function connectDesktopWelcome( - authenticatedUrl: string, - send: (input: string, init?: RequestInit) => Promise, - cookies: () => Promise = () => Promise.resolve(''), -): Promise { - const origin = new URL(authenticatedUrl).origin - const authenticated = await send(authenticatedUrl, { credentials: 'include' }) - await authenticated.body?.cancel() - if (!authenticated.ok) throw new Error('desktop welcome: Web authentication failed') +export function connectDesktopWelcome( + hostUrl: string, + cookie: string, + send: (input: string, init?: RequestInit) => Promise = fetch, +): DesktopWelcomeBackend { + const origin = new URL(hostUrl).origin const invoke = async ( request: { namespace: string; method: string; args: Record }, signal?: AbortSignal, @@ -56,8 +54,8 @@ export async function connectDesktopWelcome( const rpcId = randomUUID() const method = `${request.namespace}/${request.method}` const response = await send(new URL(`/api/${method}`, origin).href, { - method: 'POST', credentials: 'include', redirect: 'error', ...(signal === undefined ? {} : { signal }), - headers: { 'content-type': 'application/json' }, + method: 'POST', redirect: 'error', ...(signal === undefined ? {} : { signal }), + headers: { 'content-type': 'application/json', cookie }, body: JSON.stringify({ type: 'client-request', rpcId, method, payload: { args: request.args } }), }) if (!response.ok) throw new Error('desktop welcome: Web request failed') @@ -68,7 +66,7 @@ export async function connectDesktopWelcome( } return envelope.result.value } - const account = desktopAccountBackend(origin, invoke, cookies) + const account = desktopAccountBackend(origin, invoke, cookie) const settingsAndReference = async () => { const settings = await invoke({ namespace: 'settings', method: 'describe', args: {} }) if (!record(settings) || !Array.isArray(settings.namespaces)) throw new Error('desktop welcome: missing settings namespaces') 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/account-backend.spec.ts b/apps/desktop/tests/account-backend.spec.ts index c239336b22..ffdc59d340 100644 --- a/apps/desktop/tests/account-backend.spec.ts +++ b/apps/desktop/tests/account-backend.spec.ts @@ -1,4 +1,5 @@ import { once } from 'node:events' +import type { IncomingMessage } from 'node:http' import { WebSocketServer } from 'ws' import { expect, it, onTestFinished, vi } from 'vitest' import { accountView, desktopAccountBackend } from '../src/account-backend.ts' @@ -28,7 +29,7 @@ it('uses account Remote commands without returning additional wire fields', asyn const backend = desktopAccountBackend('http://127.0.0.1:1234', (request) => { requests.push(request) return Promise.resolve({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null, token: 'private' }) - }, () => Promise.resolve('')) + }, '') const client = { version: '1.2.3', locale: 'en', timezoneOffsetSeconds: 28_800 } expect(await backend.start(client)).toEqual({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null }) expect(await backend.signOut(client)).toMatchObject({ status: 'signed-out' }) @@ -59,12 +60,13 @@ it('receives live expiry separately from account snapshots', async () => { const snapshot = { status: 'signed-out', attempt: null, links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } } const received = Promise.withResolvers() - const backend = desktopAccountBackend(`http://127.0.0.1:${address.port}`, () => Promise.resolve(snapshot), () => Promise.resolve('')) + const backend = desktopAccountBackend(`http://127.0.0.1:${address.port}`, () => Promise.resolve(snapshot), 'dsh-auth-fixture=signed') const stop = backend.watch( (value) => { expect(value).toEqual(snapshot); received.resolve(undefined) }, vi.fn(), expired, onAnalyticsEnabledChanged, ) onTestFinished(stop) - await connected + const [, upgrade] = await connected as [unknown, IncomingMessage] + expect(upgrade.headers.cookie).toBe('dsh-auth-fixture=signed') // Logical streams share the socket; observe their opening frames before sending data. const streams = new Map() const opened = Promise.withResolvers() diff --git a/apps/desktop/tests/cua-optional-package.spec.ts b/apps/desktop/tests/cua-optional-package.spec.ts index 85ef15f0ee..a6b42cdcfd 100644 --- a/apps/desktop/tests/cua-optional-package.spec.ts +++ b/apps/desktop/tests/cua-optional-package.spec.ts @@ -1,4 +1,4 @@ -/** Cold pnpm requests exercise on-demand CUA dependency/platform metadata with inert package contents. */ +/** Cold pnpm requests exercise shipped CUA dependency/platform metadata with inert package contents. */ import { createHash } from 'node:crypto' import { createServer } from 'node:http' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' @@ -37,14 +37,14 @@ it('fetches the macOS optional tarball only for Darwin, with an empty cache for const cli = await readManifest('apps/cli') const bundle = await readManifest('packages/experimental/cua-bundle') const native = await readManifest('packages/experimental/cua-macos') - expect(cli.dependencies).not.toHaveProperty(bundleName) + expect(cli.dependencies).toHaveProperty(bundleName) expect(bundle.dependencies).not.toHaveProperty(providerName) expect(bundle.optionalDependencies).toEqual({ [providerName]: 'workspace:*' }) expect(native.os).toEqual(['darwin']) const version = '1.0.0' const bundleDependencies = Object.fromEntries(Object.keys(bundle.dependencies as Record).map(name => [name, version])) const manifests = [ - { name: cli.name, version }, + { name: cli.name, version, dependencies: { [bundleName]: version } }, { name: bundleName, version, dependencies: bundleDependencies, optionalDependencies: { [providerName]: version } }, ...Object.keys(bundleDependencies).map(name => ({ name, version })), { name: providerName, version, os: native.os, files: native.files }, @@ -102,13 +102,13 @@ it('fetches the macOS optional tarball only for Darwin, with an empty cache for await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) }) const address = server.address() if (!address || typeof address === 'string') throw new Error('fixture registry has no TCP address') - for (const target of ['darwin-base', 'darwin', 'win32', 'linux', 'linux-unrestricted']) { + for (const target of ['darwin', 'win32', 'linux', 'linux-unrestricted']) { unrestricted = target === 'linux-unrestricted' const project = join(root, target) await mkdir(project) - await writeFile(join(project, 'package.json'), JSON.stringify({ private: true, dependencies: { [target === 'darwin-base' ? String(cli.name) : bundleName]: version } })) + await writeFile(join(project, 'package.json'), JSON.stringify({ private: true, dependencies: { [String(cli.name)]: version } })) await writeFile(join(project, 'pnpm-workspace.yaml'), [ - 'autoInstallPeers: false', 'supportedArchitectures:', ` os: [${unrestricted ? 'linux' : target === 'darwin-base' ? 'darwin' : target}]`, + 'autoInstallPeers: false', 'supportedArchitectures:', ` os: [${unrestricted ? 'linux' : target}]`, ' cpu: [arm64]', 'minimumReleaseAge: 0', '', ].join('\n')) requests.length = 0 @@ -117,8 +117,8 @@ it('fetches the macOS optional tarball only for Darwin, with an empty cache for `--config.registry=http://127.0.0.1:${address.port}/`, `--config.store-dir=${join(project, 'store')}`, `--config.cache-dir=${join(project, 'cache')}`, `--config.state-dir=${join(project, 'state')}`, ], project) - expect(requests.includes(`/${bundleName}.tgz`)).toBe(target !== 'darwin-base') - expect(requests.includes('/@deepseek-ai/dsh-cua.tgz')).toBe(target !== 'darwin-base') + expect(requests).toContain(`/${bundleName}.tgz`) + expect(requests).toContain('/@deepseek-ai/dsh-cua.tgz') expect(requests.includes(`/${providerName}.tgz`), `${target}: ${JSON.stringify(requests)}\n${output}`) .toBe(target === 'darwin' || unrestricted) } 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/logged-notarytool-exit.spec.ts b/apps/desktop/tests/logged-notarytool-exit.spec.ts index 8deaf4ece4..82e14f232d 100644 --- a/apps/desktop/tests/logged-notarytool-exit.spec.ts +++ b/apps/desktop/tests/logged-notarytool-exit.spec.ts @@ -1,5 +1,10 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' import { expect, it, vi } from 'vitest' import { runLoggedNotarytool } from '../scripts/logged-notarytool.mjs' +import { createPackagingRun } from '../scripts/packaging-run.mjs' const command = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<{ stdout: string; stderr: string }>>()) vi.mock('node:child_process', async (importOriginal) => { @@ -28,6 +33,52 @@ it('does not treat failed upload output as a completed submission', async () => command.mockRejectedValueOnce(Object.assign(new Error('upload failed'), { code: 1, stdout: JSON.stringify({ id }), stderr: '', })) - await expect(runLoggedNotarytool(args)).rejects.toThrow('upload failed') + const work = runLoggedNotarytool(args) + await expect(work).rejects.toThrow('notarytool submit failed (code: 1') + await expect(work).rejects.toHaveProperty('cause.message', 'upload failed') expect(command).toHaveBeenCalledOnce() }) + +it.each([false, true])('prints the redacted Apple failure with a journal=%s and leaves stdout for protocol replies', async (recorded) => { + const root = await mkdtemp(join(tmpdir(), 'notary-cli-')) + const directory = recorded ? createPackagingRun(root, {}).directory : undefined + const savedArgv = process.argv + const savedExit = process.exitCode + const savedDirectory = process.env.DSH_DESKTOP_PACKAGING_RUN_DIR + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + try { + if (directory === undefined) delete process.env.DSH_DESKTOP_PACKAGING_RUN_DIR + else process.env.DSH_DESKTOP_PACKAGING_RUN_DIR = directory + process.argv = [process.execPath, fileURLToPath(new URL('../scripts/logged-notarytool.mjs', import.meta.url)), + 'submit', '/fixture/app.zip', '--apple-id', 'private@example.test', '--password', 'secret-sentinel', + '--team-id', 'private-team', '--wait', '--output-format', 'json'] + command.mockReset() + command.mockRejectedValueOnce(Object.assign(new Error('connection closed: private@example.test secret-sentinel private-team'), { + code: 69, signal: null, stdout: 'Apple response: secret-sentinel', stderr: 'HTTP 503 Service Unavailable', + })) + vi.resetModules() + await import('../scripts/logged-notarytool.mjs') + expect(command).toHaveBeenCalledOnce() + expect(process.exitCode).toBe(1) + expect(stdout).not.toHaveBeenCalled() + const output = stderr.mock.calls.map(([chunk]) => String(chunk)).join('') + const diagnostics = [output, ...directory === undefined ? [] : [await readFile(join(directory, 'events.jsonl'), 'utf8')]] + for (const diagnostic of diagnostics) { + expect(diagnostic).toContain('code: 69') + expect(diagnostic).toContain('Apple response: [REDACTED]') + expect(diagnostic).toContain('HTTP 503 Service Unavailable') + expect(diagnostic).toContain('connection closed') + for (const secret of ['private@example.test', 'secret-sentinel', 'private-team']) expect(diagnostic).not.toContain(secret) + } + expect(output.includes('events.jsonl')).toBe(recorded) + } finally { + process.argv = savedArgv + process.exitCode = savedExit + if (savedDirectory === undefined) delete process.env.DSH_DESKTOP_PACKAGING_RUN_DIR + else process.env.DSH_DESKTOP_PACKAGING_RUN_DIR = savedDirectory + stderr.mockRestore() + stdout.mockRestore() + await rm(root, { recursive: true, force: true }) + } +}) 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/macos-runtime.spec.ts b/apps/desktop/tests/macos-runtime.spec.ts index 4ad63e41fd..b5ac451827 100644 --- a/apps/desktop/tests/macos-runtime.spec.ts +++ b/apps/desktop/tests/macos-runtime.spec.ts @@ -3,9 +3,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' import { signMacOSRuntime } from '../scripts/macos-runtime.ts' -import { signMacOSRuntimeCode, verifyMacOSRuntimeCode } from '../scripts/verify-macos-signature.mjs' +import { signMacOSRuntimeCode, verifyMacOSNotarizedApplication, verifyMacOSRuntimeCode } from '../scripts/verify-macos-signature.mjs' -vi.mock('../scripts/verify-macos-signature.mjs', () => ({ signMacOSRuntimeCode: vi.fn(), verifyMacOSRuntimeCode: vi.fn() })) +vi.mock('../scripts/verify-macos-signature.mjs', () => ({ + signMacOSRuntimeCode: vi.fn(), verifyMacOSNotarizedApplication: vi.fn(), verifyMacOSRuntimeCode: vi.fn(), +})) const roots: string[] = [] function root(): string { const path = mkdtempSync(join(tmpdir(), 'desktop-signing-')) @@ -26,6 +28,34 @@ it('signs Mach-O files in their final locations and verifies each signature', as expect(verifyMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'addon.node'), identity) }) +const helperBundle = 'node_modules/@deepseek-ai/dsh-experimental-cua-macos/native/bin/darwin-arm64/generation/DSH Computer Use.app' +function machO(path: string): void { + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, Buffer.from('cffaedfe00000000', 'hex')) +} + +it('verifies the notarized Computer Use helper as a bundle and signs other bundles per file', async () => { + const path = root() + machO(join(path, helperBundle, 'Contents/MacOS/dsh-cua-native')) + writeFileSync(join(path, helperBundle, 'Contents/Info.plist'), '') + // LibreOffice ships an unsigned application bundle whose executables are signed in place. + const office = join(path, 'node_modules/@deepseek-ai/libreoffice-kit-darwin-arm64/program/LibreOfficeDev.app/Contents/MacOS/soffice') + machO(office) + machO(join(path, 'addon.node')) + await expect(signMacOSRuntime(path, 'com.example.app', identity, 'arm64')).resolves.toBe(2) + expect(verifyMacOSNotarizedApplication).toHaveBeenCalledExactlyOnceWith(join(path, helperBundle), identity) + expect(vi.mocked(signMacOSRuntimeCode).mock.calls.map(([file]) => file).sort()).toEqual([join(path, 'addon.node'), office].sort()) +}) + +it('rejects a helper bundle that fails notarized-application verification before signing any file', async () => { + const path = root() + machO(join(path, helperBundle, 'Contents/MacOS/dsh-cua-native')) + machO(join(path, 'addon.node')) + vi.mocked(verifyMacOSNotarizedApplication).mockImplementationOnce(() => { throw new Error('stapler validate exited with 65') }) + await expect(signMacOSRuntime(path, 'com.example.app', identity, 'arm64')).rejects.toThrow('stapler validate') + expect(signMacOSRuntimeCode).not.toHaveBeenCalled() +}) + // File symlinks in these macOS fixtures require privileges unavailable to ordinary Windows processes. it.skipIf(process.platform === 'win32')('signs a shared Python interpreter once and retains both executable aliases', async () => { const path = root() diff --git a/apps/desktop/tests/macos-signature.spec.ts b/apps/desktop/tests/macos-signature.spec.ts index 2e6a2bd48e..861521546d 100644 --- a/apps/desktop/tests/macos-signature.spec.ts +++ b/apps/desktop/tests/macos-signature.spec.ts @@ -73,6 +73,7 @@ describe('desktop macOS release signature', () => { expect(config.asarUnpack).toEqual(expect.arrayContaining([ '**/*.{node,dylib,dll,so,exe}', '**/@vscode/ripgrep-*/bin/rg', + '**/node_modules/@deepseek-ai/dsh-experimental-cua-macos/native/bin/**/*', ])) expect(config).toMatchObject({ appId: RELEASE_ENVIRONMENT.DSH_DESKTOP_APP_ID, diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 3b1e63e00c..cf3fd803c0 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -22,7 +22,10 @@ type InvokeHandler = (event: InvokeEvent, ...args: unknown[]) => unknown const fileDrag = vi.hoisted(() => ({ dispose: vi.fn<() => Promise>(), install: vi.fn() })) vi.mock('../src/file-drag.ts', () => ({ installDesktopFileDrag: () => { fileDrag.install(); return fileDrag.dispose } })) -vi.mock('../src/web-document.ts', () => ({ authenticateWebHost: async () => 'test-cookie', serveWebDocument: vi.fn(), forwardWebRequest: vi.fn() })) +vi.mock('../src/web-document.ts', () => ({ + authenticateWebHost: async () => 'test-cookie', serveWebDocument: vi.fn(), forwardWebRequest: vi.fn(), + removeLegacyHostCookies: harness.removeLegacyHostCookies, +})) vi.mock('../src/operation-report-job.ts', () => ({ runOperationReportJob: vi.fn(async () => ({ appended: false, timedOut: false, cancelled: false })) })) // Report persistence has its own unit tests; here it resolves within microtasks so the fatal // dialog never outlives the test that triggered it. @@ -69,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() }) @@ -215,7 +218,8 @@ const harness = await vi.hoisted(async () => { failWindow(error: Error) { windowFailure = error }, windows, views, hosts, handlers, app, FakeWindow, FakeView, FakeHost, powerMonitor, nativeTheme, analytics, trays, FakeTray, backgroundNotice, shellDialog, - menu, popup, socketHeaders: vi.fn(), loginShell, readLoginShell, updateCheck, updateDownload, updateInstall, + menu, popup, socketHeaders: vi.fn(), connectWelcome: vi.fn(), removeLegacyHostCookies: vi.fn(async () => {}), hostCookies: {}, + loginShell, readLoginShell, updateCheck, updateDownload, updateInstall, platformDispose, platformCloseAndWait, @@ -239,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 () => { @@ -304,9 +309,8 @@ 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, - net: { fetch: vi.fn() }, ipcMain: { on: harness.ipcOn, handle: (channel: string, handler: InvokeHandler) => { @@ -317,7 +321,7 @@ vi.mock('electron', () => ({ }, Menu: { setApplicationMenu: harness.menu.setApplicationMenu, buildFromTemplate: harness.menu }, session: { defaultSession: { - on: vi.fn(), + on: vi.fn(), cookies: harness.hostCookies, setPermissionCheckHandler: vi.fn(), setPermissionRequestHandler: vi.fn(), webRequest: { onBeforeSendHeaders: harness.socketHeaders }, } }, protocol: { registerSchemesAsPrivileged: vi.fn(), handle: harness.protocolHandle }, @@ -367,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 @@ -389,14 +411,17 @@ vi.mock('../src/platform-view.ts', async importOriginal => ({ }, })) vi.mock('../src/welcome-backend.ts', () => ({ - connectDesktopWelcome: async () => ({ - analyticsEnabled: async () => harness.analyticsEnabled, - report: harness.analytics, - readLocalePreference: async () => null, - read: async (): Promise => (await harness.hosts.at(-1)!.fetch()).json() as Promise, - save: async () => ({ ok: true }), - account: { watch: harness.watchAccount, state: async () => ({ status: 'signed-out', attempt: null }) }, - }), + connectDesktopWelcome: (...args: unknown[]) => { + harness.connectWelcome(...args) + return { + analyticsEnabled: async () => harness.analyticsEnabled, + report: harness.analytics, + readLocalePreference: async () => null, + read: async (): Promise => (await harness.hosts.at(-1)!.fetch()).json() as Promise, + save: async () => ({ ok: true }), + account: { watch: harness.watchAccount, state: async () => ({ status: 'signed-out', attempt: null }) }, + } + }, })) function configureTestAuthPopup(allow: boolean): void { @@ -487,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') @@ -816,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() }) @@ -875,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 [ @@ -1126,7 +1364,7 @@ describe('desktop main startup', () => { } finally { harness.app.name = originalName } }) - it.each(['http:', 'https:'] as const)('attaches %s Host socket credentials only to the owned application origin and window', async (protocol) => { + it.each(['http:', 'https:'] as const)('passes the %s Host cookie explicitly and attaches socket credentials only to the owned application origin and window', async (protocol) => { await import('../src/main.ts') await harness.preparing.promise harness.prepared.resolve() @@ -1134,6 +1372,8 @@ describe('desktop main startup', () => { harness.hosts[0]!.url = `${protocol}//127.0.0.1:3080/?token=test` harness.hosts[0]!.ready.resolve() await Promise.resolve(invoke(DESKTOP_IPC.boot)) + expect(harness.connectWelcome).toHaveBeenCalledWith(`${protocol}//127.0.0.1:3080/?token=test`, 'test-cookie') + expect(harness.removeLegacyHostCookies).toHaveBeenCalledWith(harness.hostCookies, `${protocol}//127.0.0.1:3080/?token=test`) const handler = harness.socketHeaders.mock.calls[0]![1] as ( details: { url: string; webContentsId: number; requestHeaders: Record }, callback: (result: unknown) => void, @@ -1155,6 +1395,19 @@ describe('desktop main startup', () => { expect(callback).toHaveBeenLastCalledWith({}) }) + it('logs a legacy Host cookie cleanup failure without blocking boot', async () => { + const failure = new Error('cookie store unavailable') + harness.removeLegacyHostCookies.mockRejectedValueOnce(failure) + await import('../src/main.ts') + await harness.preparing.promise + harness.prepared.resolve() + await harness.hostStarted.promise + harness.hosts[0]!.ready.resolve() + await Promise.resolve(invoke(DESKTOP_IPC.boot)) + await vi.waitFor(() => { expect(console.error).toHaveBeenCalledWith('desktop: legacy Host cookie cleanup failed', failure) }) + expect(harness.connectWelcome).toHaveBeenCalled() + }) + it('shares login key discovery with onboarding and rejects foreign renderers', async () => { await readyForUpdate() const window = harness.windows[0]! @@ -1556,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 () => { @@ -1756,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' }) @@ -1788,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 () => { @@ -1802,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 @@ -1889,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/web-document.spec.ts b/apps/desktop/tests/web-document.spec.ts index 0fd9b591b1..6507712ef6 100644 --- a/apps/desktop/tests/web-document.spec.ts +++ b/apps/desktop/tests/web-document.spec.ts @@ -2,7 +2,7 @@ import { afterEach, expect, it, vi } from 'vitest' import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { authenticateWebHost, forwardWebRequest, serveWebDocument } from '../src/web-document.ts' +import { authenticateWebHost, forwardWebRequest, removeLegacyHostCookies, serveWebDocument } from '../src/web-document.ts' const roots: string[] = [] afterEach(async () => { @@ -86,3 +86,14 @@ it('refuses another page origin without forwarding its request', async () => { expect(response.status).toBe(403) expect(fetch).not.toHaveBeenCalled() }) + +it('removes only legacy Host authentication cookies for the Host hostname', async () => { + const removed: [string, string][] = [] + const cookies = { + get: vi.fn(async () => [{ name: 'dsh-auth-old' }, { name: 'other-app' }, { name: 'dsh-auth-older' }]), + remove: vi.fn(async (url: string, name: string) => { removed.push([url, name]) }), + } + await removeLegacyHostCookies(cookies, 'http://127.0.0.1:1234/?token=secret') + expect(cookies.get).toHaveBeenCalledExactlyOnceWith({ url: 'http://127.0.0.1:1234' }) + expect(removed).toEqual([['http://127.0.0.1:1234', 'dsh-auth-old'], ['http://127.0.0.1:1234', 'dsh-auth-older']]) +}) diff --git a/apps/desktop/tests/welcome-flow.e2e.ts b/apps/desktop/tests/welcome-flow.e2e.ts index ce1dc896b2..08347912ec 100644 --- a/apps/desktop/tests/welcome-flow.e2e.ts +++ b/apps/desktop/tests/welcome-flow.e2e.ts @@ -12,6 +12,7 @@ import { DesktopProjectManager } from '../src/project-manager.ts' import { resolveDesktopPaths } from '../src/paths.ts' import { desktopClientMetadata } from '../src/client-metadata.ts' import { connectDesktopWelcome, type DesktopWelcomeBackend } from '../src/welcome-backend.ts' +import { authenticateWebHost } from '../src/web-document.ts' import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' import { prepareDevelopmentProject } from '../scripts/development-project.ts' @@ -129,17 +130,7 @@ describe.skipIf(!existsSync(builtHost))('built Desktop welcome flow', () => { host = new DesktopHostProcess(process.execPath, project, paths.profile) const { url } = await host.start() hostOrigin = new URL(url).origin - let cookie = '' - const send: typeof fetch = async (input, init) => { - const headers = new Headers(init?.headers) - if (cookie !== '') headers.set('cookie', cookie) - const response = await fetch(input, { ...init, headers, redirect: 'manual' }) - if (response.status !== 303) return response - cookie = response.headers.getSetCookie().map(value => value.split(';')[0]).join('; ') - await response.body?.cancel() - return fetch(new URL(response.headers.get('location')!, url), { headers: { cookie } }) - } - backend = await connectDesktopWelcome(url, send, () => Promise.resolve(cookie)) + backend = connectDesktopWelcome(url, await authenticateWebHost(url)) } const status = async () => backend.read() const fingerprint = (): string => createHash('sha256') diff --git a/apps/desktop/tests/welcome-host.spec.ts b/apps/desktop/tests/welcome-host.spec.ts index dea2905583..d5dfb480a2 100644 --- a/apps/desktop/tests/welcome-host.spec.ts +++ b/apps/desktop/tests/welcome-host.spec.ts @@ -9,9 +9,8 @@ function transport(preference?: string) { { ns: 'llm-pi-ai', value: { profiles: { example: { apiKeyEnv: 'EXAMPLE_API_KEY' } } } }, { ns: 'locale', value: preference === undefined ? {} : { preference } }, ] - const send = vi.fn[1]>(async (_input, init) => { - if (init?.method !== 'POST') return new Response('index') - const { rpcId, method, payload } = JSON.parse(init.body as string) as { + const send = vi.fn[2]>>(async (_input, init) => { + const { rpcId, method, payload } = JSON.parse(init!.body as string) as { rpcId: string method: string payload: { args: { ref: string; value: string; refs: string[] } } @@ -29,27 +28,28 @@ function transport(preference?: string) { return { send, keys, namespaces } } -const url = 'http://127.0.0.1:19387/?token=fixture' +const url = 'http://127.0.0.1:19387/' +const cookie = 'dsh-auth-fixture=signed' describe('desktop welcome Web operations', () => { - it('authenticates through Web and stores only through the configured credential reference', async () => { + it('authenticates with the explicit Host cookie and stores only through the configured credential reference', async () => { const host = transport() - const backend = await connectDesktopWelcome(url, host.send) - expect(host.send).toHaveBeenCalledExactlyOnceWith(url, { credentials: 'include' }) + const backend = connectDesktopWelcome(url, cookie, host.send) expect(await backend.analyticsEnabled()).toBe(true) expect(await backend.save('sk-example')).toEqual({ ok: true }) expect(host.keys.get('CUSTOM_DEEPSEEK_KEY')).toBe('sk-example') expect(await backend.read()).toEqual({ loggedIn: false, hasApiKey: true, writable: true, localePreference: null }) - for (const [input, init] of host.send.mock.calls.slice(1)) { + for (const [input, init] of host.send.mock.calls) { expect(input).toMatch(/^http:\/\/127\.0\.0\.1:19387\/api\//u) - expect(init).toMatchObject({ credentials: 'include', redirect: 'error' }) + expect(init).toMatchObject({ redirect: 'error', headers: { cookie } }) + expect(init).not.toHaveProperty('credentials') } }) it('reads the explicit language preference and recognizes another provider key', async () => { const host = transport('zh') host.keys.set('EXAMPLE_API_KEY', 'sk-other') - const backend = await connectDesktopWelcome(url, host.send) + const backend = connectDesktopWelcome(url, cookie, host.send) expect(await backend.read()).toMatchObject({ localePreference: 'zh', hasApiKey: true }) host.keys.clear() expect(await backend.read()).toMatchObject({ hasApiKey: false }) @@ -58,7 +58,7 @@ describe('desktop welcome Web operations', () => { it('reads language without querying account or model providers', async () => { const host = transport('zh') - const backend = await connectDesktopWelcome(url, host.send) + const backend = connectDesktopWelcome(url, cookie, host.send) host.send.mockClear() expect(await backend.readLocalePreference()).toBe('zh') expect(host.send).toHaveBeenCalledOnce() @@ -68,7 +68,7 @@ describe('desktop welcome Web operations', () => { it('allows profiles without the official provider and retains custom key detection', async () => { const host = transport() host.namespaces.splice(0, 1) - const backend = await connectDesktopWelcome(url, host.send) + const backend = connectDesktopWelcome(url, cookie, host.send) expect(await backend.read()).toMatchObject({ hasApiKey: false, writable: false }) host.keys.set('EXAMPLE_API_KEY', 'custom-key') expect(await backend.read()).toMatchObject({ hasApiKey: true, writable: false }) @@ -78,7 +78,7 @@ describe('desktop welcome Web operations', () => { it.each(['', 'bad key', 'key\n'])('rejects malformed keys without contacting a provider: %s', async (value) => { const host = transport() - const backend = await connectDesktopWelcome(url, host.send) + const backend = connectDesktopWelcome(url, cookie, host.send) host.send.mockClear() expect(await backend.save(value)).toEqual({ ok: false }) expect(host.send).not.toHaveBeenCalled() @@ -86,28 +86,22 @@ describe('desktop welcome Web operations', () => { it('keeps provider diagnostics out of failures and rejects unmatched RPC envelopes', async () => { const host = transport() - const backend = await connectDesktopWelcome(url, host.send) + const backend = connectDesktopWelcome(url, cookie, host.send) host.send.mockRejectedValueOnce(new Error('private credential sk-must-not-leak')) expect(await backend.save('sk-example')).toEqual({ ok: false }) host.send.mockResolvedValueOnce(Response.json({ type: 'server-response', rpcId: 'other', result: { ok: true } })) await expect(backend.read()).rejects.toThrow('Web RPC failed') }) - it('submits native analytics through authenticated RPC with a bounded request', async () => { const host = transport() - const backend = await connectDesktopWelcome(url, host.send) + const backend = connectDesktopWelcome(url, cookie, host.send) const event = { eventName: 'desktop_app_launch' as const, timestamp: 100, attributes: {} } await backend.report(event) const [input, init] = host.send.mock.calls.at(-1)! expect(input).toBe('http://127.0.0.1:19387/api/productAnalytics/report') - expect(init?.credentials).toBe('include') + expect(init?.headers).toMatchObject({ cookie }) expect(init?.signal).toBeInstanceOf(AbortSignal) expect(JSON.parse(init!.body as string)).toMatchObject({ payload: { args: { event } } }) }) - - it('refuses an unauthenticated Web launch', async () => { - const send = vi.fn[1]>(async () => new Response(null, { status: 401 })) - await expect(connectDesktopWelcome(url, send)).rejects.toThrow('Web authentication failed') - }) }) diff --git a/apps/desktop/tests/welcome-startup.spec.ts b/apps/desktop/tests/welcome-startup.spec.ts index 4ae8743e2e..e578c8a655 100644 --- a/apps/desktop/tests/welcome-startup.spec.ts +++ b/apps/desktop/tests/welcome-startup.spec.ts @@ -1,4 +1,6 @@ -vi.mock('../src/web-document.ts', () => ({ authenticateWebHost: async () => 'test-cookie', serveWebDocument: vi.fn(), forwardWebRequest: vi.fn() })) +vi.mock('../src/web-document.ts', () => ({ + authenticateWebHost: async () => 'test-cookie', serveWebDocument: vi.fn(), forwardWebRequest: vi.fn(), removeLegacyHostCookies: async () => {}, +})) /** Welcome startup uses the Host before transitioning to the workspace. */ import { afterEach, expect, it, vi } from 'vitest' @@ -84,10 +86,9 @@ vi.mock('electron', () => ({ showInactive = state.showInactiveWorkspace async loadURL(url: string) { state.contents = this.webContents; await state.loadWorkspace(url); this.ready?.() } }, - net: { fetch: vi.fn() }, nativeTheme: state.nativeTheme, session: { defaultSession: { - on: vi.fn(), + on: vi.fn(), cookies: {}, setPermissionCheckHandler: vi.fn(), setPermissionRequestHandler: vi.fn(), webRequest: { onBeforeSendHeaders: vi.fn() }, } }, protocol: { registerSchemesAsPrivileged: vi.fn(), handle: vi.fn() }, @@ -124,7 +125,7 @@ vi.mock('../src/host-process.ts', () => ({ }, })) vi.mock('../src/welcome-backend.ts', () => ({ - connectDesktopWelcome: async () => ({ + connectDesktopWelcome: () => ({ analyticsEnabled: async () => false, readLocalePreference: async () => state.preference, read: async () => { 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/web/package.json b/apps/web/package.json index 1443b12b77..8d48aff419 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -43,7 +43,7 @@ "@deepseek-ai/dsh-client-web": "workspace:*", "@deepseek-ai/dsh-cmdline": "workspace:*", "@deepseek-ai/dsh-pwsh-local": "workspace:*", - "@deepseek-ai/dsh-experimental-agent-team": "workspace:*", + "@deepseek-ai/dsh-agent-team": "workspace:*", "@deepseek-ai/dsh-experimental-webworker-packer": "workspace:*", "@deepseek-ai/dsh-experimental-webworker-runtime": "workspace:*", "@deepseek-ai/dsh-remote-mock": "workspace:*", 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/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts index f97978d346..bb9b3d2065 100644 --- a/apps/web/tests/access-confirmation.e2e.ts +++ b/apps/web/tests/access-confirmation.e2e.ts @@ -14,7 +14,7 @@ import { launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { - ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot, writeComposerDraft, + ZH_BROWSER_LOCALE, assertWarningDialogLayout, connectFreshWorkspaceZh, saveFailureShot, writeComposerDraft, } from './support.ts' import { AUTO_REVIEW_FIXTURE, captureAutoReviewState } from './auto-review-fixture.ts' @@ -154,6 +154,7 @@ describe('web e2e: experimental Auto and Full access confirmation', () => { await page.getByRole('menuitem', { name: '完全权限' }).click() const dialog = page.getByRole('dialog', { name: '确认启用完全权限?' }) await dialog.waitFor({ timeout: 10_000 }) + await assertWarningDialogLayout(dialog) const enable = dialog.getByRole('button', { name: '启用完全权限' }) expect(await enable.isDisabled()).toBe(true) @@ -163,7 +164,18 @@ describe('web e2e: experimental Auto and Full access confirmation', () => { const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) - await dialog.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }).check() + const acknowledgement = dialog.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }) + expect(await acknowledgement.evaluate((input) => { + const label = input.closest('label')! + const textStyle = getComputedStyle(label) + const inputStyle = getComputedStyle(input) + return { + focused: document.activeElement === input, + fontSize: textStyle.fontSize, lineHeight: textStyle.lineHeight, weight: textStyle.fontWeight, + gap: textStyle.gap, width: inputStyle.width, height: inputStyle.height, + } + })).toEqual({ focused: true, fontSize: '14px', lineHeight: '20px', weight: '400', gap: '6px', width: '14px', height: '14px' }) + await dialog.getByText('我已了解风险,并愿意继续', { exact: true }).click() expect(await enable.isEnabled()).toBe(true) await enable.click() await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 }) diff --git a/apps/web/tests/agent-team-panel.e2e.ts b/apps/web/tests/agent-team-panel.e2e.ts index 19d7924381..6da971cf03 100644 --- a/apps/web/tests/agent-team-panel.e2e.ts +++ b/apps/web/tests/agent-team-panel.e2e.ts @@ -1,79 +1,112 @@ -// Keyless assembled-browser coverage for the opt-in Agent Teams bundle +// Keyless assembled-browser coverage for per-Session agent teamwork // over the real Host projection frame flow. import { fileURLToPath } from 'node:url' import { join } from 'node:path' -import { readFileSync } from 'node:fs' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished, vi } from 'vitest' -import * as yaml from 'js-yaml' -import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' -import type {} from '@deepseek-ai/dsh-experimental-agent-team' +import type {} from '@deepseek-ai/dsh-agent-team' +import { SessionId } from '@deepseek-ai/dsh-session' +import { CommandId } from '@deepseek-ai/dsh-commands/brand' import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import { - assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { assertWarningDialogLayout, connectFreshWorkspace, connectFreshWorkspaceZh, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-team-panel', import.meta.url)) const PANEL_EXPECTED = join(SNAPSHOT_DIR, 'task.expected.md') const RESIZE_EXPECTED = join(SNAPSHOT_DIR, 'resize.expected.md') -const OVERLAY = fileURLToPath(new URL('./agent-team-panel.overlay.yml', import.meta.url)) -const TEAM_PATCH = fileURLToPath(new URL('../../../packages/experimental/agent-team-profile/cordis.patch.yml', import.meta.url)) -const INSTALL_ANCHORS = [ - fileURLToPath(new URL('../../../packages/experimental/agent-team-profile/package.json', import.meta.url)), -] const MODE = webSnapshotMode() +const RESEARCHER_DESCRIPTION = 'Researcher: investigate the construction and verify every bound before reporting the result. '.repeat(8).trim() +const RECEIPT_LOCALES = [ + { + language: 'English', code: 'en', browserLocale: 'en-US', connect: connectFreshWorkspace, + modelButton: /^Select model/u, toggle: 'Enable agent teamwork', title: 'Agent teamwork', + enabled: 'Enabled', disabled: 'Disabled', + suggestions: 'Trigger suggestions', commandLabel: 'Team', + failure: 'Failed: Use /team on or /team off.', + }, + { + language: 'Chinese', code: 'zh', browserLocale: 'zh-CN', connect: connectFreshWorkspaceZh, + modelButton: /^选择模型/u, toggle: '启用智能体团队协作', title: '智能体团队协作', + enabled: '已开启', disabled: '已停用', + suggestions: '触发候选建议', commandLabel: '智能体团队协作', + failure: '执行失败:Use /team on or /team off.', + }, +] as const -function profileEntries(path: string): unknown[] { - const parsed = yaml.load(readFileSync(path, 'utf8'), { schema: entryListSchema }) - if (!Array.isArray(parsed)) throw new Error(`profile layer at ${path} must be a list`) - return parsed +type ReceiptLocale = typeof RECEIPT_LOCALES[number] + +async function seedTeamConversation(page: Page, scaffold: WebScaffold): Promise { + const agent = scaffold.ctx.agents.list()[0] + if (agent === undefined) throw new Error('connected Team workspace did not create an Agent') + agent.session.append('turn/start', { turn: 1 }) + agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Open the task board.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + agent.session.append('step/start', { turn: 1, step: 1 }) + agent.session.append('assistant/message', { + stream: [], + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ type: 'text', text: 'Ready.' }], + source: { kind: 'model', provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + agent.session.append('step/end', { turn: 1, step: 1 }) + agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await scaffold.ctx.sessions.flush(agent.session) + await page.getByText('Ready.').waitFor({ timeout: 10_000 }) } -describe('Agent Teams panel overlay', () => { - it('matches the shipped Agent Teams bundle', () => { - expect(profileEntries(OVERLAY)).toEqual(profileEntries(TEAM_PATCH)) - }) -}) +async function withTeamReceiptPage( + locale: ReceiptLocale, + run: (page: Page, scaffold: WebScaffold, tripwire: ReturnType) => Promise, +): Promise { + const scaffold = await launchWebScaffold() + let browser: Browser | undefined + try { + browser = await chromium.launch() + const page = await browser.newPage({ + viewport: { width: 1680, height: 1000 }, locale: locale.browserLocale, timezoneId: 'Asia/Shanghai', + }) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await locale.connect(page, scaffold.workspaceCwd) + try { + await seedTeamConversation(page, scaffold) + await run(page, scaffold, tripwire) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } catch (error) { + await saveFailureShot(page, `web-e2e-team-command-${locale.code}`) + throw error + } + } finally { + try { await browser?.close() } finally { await scaffold.close() } + } +} -describe('web e2e: Agent Teams panel', () => { +describe('web e2e: agent teamwork panel', () => { let scaffold: WebScaffold let browser: Browser let page: Page let tripwire: ReturnType beforeAll(async () => { - scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, extraInstallAnchors: INSTALL_ANCHORS }) + scaffold = await launchWebScaffold() browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) - const agent = scaffold.ctx.agents.list()[0] - if (agent === undefined) throw new Error('connected Team workspace did not create an Agent') - agent.session.append('turn/start', { turn: 1 }) - agent.session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'Open the Agent Team controls.' }], - source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - agent.session.append('step/start', { turn: 1, step: 1 }) - agent.session.append('assistant/message', { - stream: [], - turn: 1, - step: 1, - message: createMessage({ - role: 'assistant', - content: [{ type: 'text', text: 'Ready.' }], - source: { kind: 'model', provider: 'fixture', model: 'fixture' }, - }), - }, { surfaceOp: 'append' }) - agent.session.append('step/end', { turn: 1, step: 1 }) - agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await scaffold.ctx.sessions.flush(agent.session) - await page.getByText('Ready.').waitFor({ timeout: 10_000 }) + await seedTeamConversation(page, scaffold) }, 120_000) afterAll(async () => { @@ -81,35 +114,318 @@ describe('web e2e: Agent Teams panel', () => { await scaffold?.close() }) - it('displays agent-owned task changes through a read-only board without a refresh action', async () => { + it('explains agent teamwork on hover and focus without changing the Session selection', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-team-info')) + await page.getByRole('button', { name: /^Select model/u }).click() + const info = page.getByRole('button', { name: 'About agent teamwork', exact: true }) + const toggle = page.getByRole('switch', { name: 'Enable agent teamwork', exact: true }) + const tooltip = page.getByRole('tooltip') + await info.hover() + await tooltip.waitFor({ state: 'visible' }) + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'info.expected.md'), await captureStableAria(page, '[role="tooltip"]', scaffold.workspaceCwd), MODE) + expect(await toggle.getAttribute('aria-checked')).toBe('false') + await page.getByRole('button', { name: /^Select model/u }).hover() + await tooltip.waitFor({ state: 'hidden' }) + await page.keyboard.press('Shift') + await info.focus() + await tooltip.waitFor({ state: 'visible' }) + await page.keyboard.press('Escape') + await tooltip.waitFor({ state: 'hidden' }) + expect(await toggle.isVisible()).toBe(true) + await info.click() + await tooltip.waitFor({ state: 'visible' }) + expect(await info.getAttribute('aria-describedby')).toBe(await tooltip.getAttribute('id')) + expect(await toggle.getAttribute('aria-checked')).toBe('false') + await page.keyboard.press('Escape') + await tooltip.waitFor({ state: 'hidden' }) + await page.keyboard.press('Escape') + expect(tripwire.pageErrors).toEqual([]) + }) + + it('toggles from the row label and empty space while keeping the information button independent', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-team-row')) + await page.getByRole('button', { name: /^Select model/u }).click() + const toggle = page.getByRole('switch', { name: 'Enable agent teamwork', exact: true }) + const info = page.getByRole('button', { name: 'About agent teamwork', exact: true }) + const label = page.getByText('Enable agent teamwork', { exact: true }) + const labelBounds = await label.boundingBox() + if (labelBounds === null) throw new Error('Team label is not visible') + const labelPoint = { x: labelBounds.x + labelBounds.width / 2, y: labelBounds.y + labelBounds.height / 2 } + await page.mouse.move(labelPoint.x, labelPoint.y) + expect(await toggle.evaluate((element) => { + const row = element.parentElement! + return getComputedStyle(row).backgroundColor !== 'rgba(0, 0, 0, 0)' + })).toBe(true) + await page.mouse.click(labelPoint.x, labelPoint.y) + await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') + await info.click() + await page.getByRole('tooltip').waitFor({ state: 'visible' }) + expect(await toggle.getAttribute('aria-checked')).toBe('true') + await page.keyboard.press('Escape') + const gap = await toggle.evaluate((element) => { + const control = element.getBoundingClientRect() + const help = element.parentElement!.querySelector('button:not([role="switch"])')!.getBoundingClientRect() + return { x: (help.right + control.left) / 2, y: control.top + control.height / 2 } + }) + await page.mouse.click(gap.x, gap.y) + await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false') + expect(await page.getByRole('tooltip').count()).toBe(0) + await page.keyboard.press('Escape') + expect(tripwire.pageErrors).toEqual([]) + }) + + it('defaults off and saves a Session selection in the model menu', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-team-mode')) + expect(await page.locator('[data-subagent-action]').count()).toBe(0) + await page.getByRole('button', { name: /^Select model/u }).click() + const toggle = page.getByRole('switch', { name: 'Enable agent teamwork', exact: true }) + await toggle.waitFor() + expect(await toggle.getAttribute('aria-checked')).toBe('false') + await toggle.focus() + await page.keyboard.press('ArrowUp') + expect(await toggle.evaluate(element => element === document.activeElement)).toBe(false) + await page.keyboard.press('ArrowDown') + expect(await toggle.evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('Space') + await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') + await page.keyboard.press('Escape') + expect(await page.locator('[data-subagent-action]').count()).toBe(0) + const agent = scaffold.ctx.agents.list()[0]! + expect(scaffold.ctx.commands.list(agent).some(command => command.name === 'team')).toBe(false) + expect(agent.session.ownEvents().filter(event => event.type === 'command/run' || event.type === 'command/done')).toEqual([]) + const other = await scaffold.ctx.agents.create({ sessionId: SessionId('other-team-mode'), agentOptions: agent.options }) + expect(scaffold.ctx.agentTeams.isEnabled(other.agent)).toBe(false) + await other.dispose() + const beforeReload = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await page.getByRole('button', { name: /^Select model/u }).click() + expect(await page.locator('[data-subagent-action]').count()).toBe(0) + expect(await toggle.getAttribute('aria-checked')).toBe('true') + await page.keyboard.press('Escape') + acknowledgeReloadConnectionLoss(tripwire, beforeReload) + }, 60_000) + + it('toggles from the model menu and keeps text editing usable after refocusing', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-team-command-input')) + const input = page.locator('[data-composer-input]').first() + const agent = scaffold.ctx.agents.list()[0]! + for (const enabled of [false, true]) { + await page.getByRole('button', { name: /^Select model/u }).click() + const toggle = page.getByRole('switch', { name: 'Enable agent teamwork', exact: true }) + await toggle.click() + await expect.poll(() => toggle.getAttribute('aria-checked')).toBe(String(enabled)) + await page.keyboard.press('Escape') + await expect.poll(() => scaffold.ctx.agentTeams.isEnabled(agent)).toBe(enabled) + await expect.poll(() => input.textContent()).toBe('') + await page.getByRole('button', { name: 'Account menu', exact: true }).focus() + await input.click() + await page.keyboard.insertText('asdas') + await expect.poll(() => input.textContent()).toBe('asdas') + expect(await input.evaluate((element) => { + const selection = window.getSelection() + return document.activeElement === element && selection?.isCollapsed === true + && selection.anchorNode?.textContent === 'asdas' && selection.anchorOffset === 5 + })).toBe(true) + await input.press('Backspace') + await expect.poll(() => input.textContent()).toBe('asda') + await writeComposerDraft(page, input, '') + } + expect(tripwire.pageErrors).toEqual([]) + }) + + it('shows tasks without children or a divider and hides the entry after the last task is deleted', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-team-task-only')) + const agent = scaffold.ctx.agents.list()[0]! + expect(await page.locator('[data-subagent-action]').count()).toBe(0) + const task = await scaffold.ctx.agentTeams.createTask(agent, { + subject: 'Plan before delegation', description: 'Prepare shared work before creating subagents', + }) + await page.getByRole('button', { name: '0 subagents', exact: true }).click() + const board = page.locator('[data-team-panel]') + await board.getByText(task.subject, { exact: true }).waitFor() + const panel = page.locator('[data-subagent-panel]') + expect(await panel.getByRole('treeitem').count()).toBe(0) + await panel.getByText('No subagents', { exact: true }).waitFor() + expect(await panel.locator('[data-subagent-section-header]').textContent()).toBe('Subagents') + expect(await panel.locator('[data-subagent-section-header] button, h3 button').count()).toBe(0) + expect(await panel.evaluate(element => element.getBoundingClientRect().width)).toBe(360) + expect(await board.locator('[data-team-task-details]').count()).toBe(0) + expect(await board.getByRole('button', { expanded: false }).count()).toBe(1) + expect(await board.evaluate((element) => { + const style = getComputedStyle(element) + return { border: style.borderTopWidth, margin: style.marginTop } + })).toEqual({ border: '0px', margin: '0px' }) + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'task-only.expected.md'), + await captureStableAria(page, '[data-subagent-panel]', scaffold.workspaceCwd), MODE) + await scaffold.ctx.agentTeams.updateTask(agent, { taskId: task.id, expectedRevision: task.revision, action: 'delete' }) + await page.locator('[data-subagent-action]').waitFor({ state: 'hidden' }) + await page.locator('[data-subagent-panel]').waitFor({ state: 'hidden' }) + expect(tripwire.pageErrors).toEqual([]) + }) + + it('opens the member tree and live read-only tasks in one panel', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-team-panel')) const projectionReads = vi.spyOn(scaffold.ctx.sessionController, 'projections') onTestFinished(() => { projectionReads.mockRestore() }) - await page.locator('[data-team-action]').getByRole('button', { name: 'Agent team', exact: true }).click() - const action = page.getByRole('dialog', { name: 'Agent team', exact: true }) - await action.getByText('No shared tasks yet. Create them through the conversation.').waitFor() - await action.getByText('lead').waitFor() + const agent = scaffold.ctx.agents.list()[0]! + const memberId = SessionId('team-panel-researcher') + agent.session.append('subagent/catalog', { + version: 2, childId: memberId, childCreatedAt: Date.now(), mode: 'external', + label: RESEARCHER_DESCRIPTION, + }) + const countAction = page.locator('[data-subagent-action]') + await countAction.waitFor() + await page.mouse.move(2, 2) + expect(await countAction.evaluate((element) => { + const header = element.closest('[data-slot="conversation.session.header.actions"]')!.parentElement! + const preset = header.querySelector('[data-icon="dsw-icon-agent-preset-outline-regular"]')! + const users = element.querySelector('[data-icon="dsw-icon-users-outline-regular"]')! + return { + display: getComputedStyle(header).display, gap: getComputedStyle(header).gap, + presetOpacity: getComputedStyle(preset).opacity, + presetParentOpacity: getComputedStyle(preset.parentElement!).opacity, + sameColor: getComputedStyle(preset).color === getComputedStyle(users).color, + } + })).toEqual({ display: 'flex', gap: '10px', presetOpacity: '1', presetParentOpacity: '1', sameColor: true }) + const countTrigger = countAction.getByRole('button', { name: /subagents?$/u }) + await countTrigger.click() + await countTrigger.click() + await page.locator('[data-subagent-panel]').waitFor({ state: 'detached' }) + expect(await countTrigger.evaluate(element => element === document.activeElement)).toBe(true) + await countTrigger.click() + const action = page.locator('[data-subagent-panel]') + await action.getByRole('treeitem', { name: /Researcher/u }).waitFor() + expect(await action.locator('[data-team-panel], [data-subagent-section-header]').count()).toBe(0) + expect(await action.getByText('No shared tasks yet. Create them through the conversation.').count()).toBe(0) + expect(await action.evaluate(element => element.getBoundingClientRect().width)).toBe(360) + expect(await action.locator('[data-subagent-section-header] button, h3 button').count()).toBe(0) + expect(await action.getByRole('heading', { name: 'Members', exact: true }).count()).toBe(0) expect(projectionReads).not.toHaveBeenCalled() expect(await action.getByRole('button', { name: 'New task' }).count()).toBe(0) expect(await action.getByRole('button', { name: /Refresh/u }).count()).toBe(0) - // Host-side Team mutations reach the open panel through projection frames only. - const agent = scaffold.ctx.agents.list()[0]! + await action.getByRole('treeitem', { name: /Researcher/u }).waitFor() + expect(await page.locator('[data-subagent-panel]').count()).toBe(1) + expect(await page.getByRole('button', { name: 'Task board', exact: true }).count()).toBe(0) const task = await scaffold.ctx.agentTeams.createTask(agent, { - subject: 'Agent task', description: 'Created by the Team Lead', writeScopes: ['src/web'], + subject: 'Agent task', description: 'Created by the root agent', writeScopes: ['src/web'], }) - await action.getByText('Agent task', { exact: true }).waitFor() - await action.getByText('Owner: Unowned', { exact: true }).waitFor() + const row = action.locator(`[data-team-task="${task.id}"]`) + await row.getByRole('button', { name: 'Pending Agent task', exact: true, expanded: false }).waitFor() + expect(await action.locator('[data-subagent-section-header]').textContent()).toBe('Subagents1 external') + expect(await action.locator('[data-subagent-section-header] button, h3 button').count()).toBe(0) + expect(await action.locator('[data-team-panel]').evaluate((element) => { + const style = getComputedStyle(element) + return { border: style.borderTopWidth, margin: style.marginTop } + })).toEqual({ border: '0px', margin: '4px' }) + expect(await action.evaluate((element) => { + const tree = element.querySelector('[role="tree"]')! + const board = element.querySelector('[data-team-panel]')! + return Boolean(tree.compareDocumentPosition(board) & Node.DOCUMENT_POSITION_FOLLOWING) + })).toBe(true) + expect(await row.evaluate(element => ({ tag: element.tagName, parent: element.parentElement?.tagName }))) + .toEqual({ tag: 'LI', parent: 'UL' }) + expect(await action.locator('[data-team-task-details], article').count()).toBe(0) + expect(await action.evaluate(element => element.getBoundingClientRect().width)).toBe(360) + await action.getByRole('heading', { name: 'Shared tasks 1 pending', exact: true }).waitFor() + expect(await action.evaluate(element => getComputedStyle(element).padding)).toBe('6px') + expect(await action.locator('[data-subagent-section-header]').evaluate((element) => { + const style = getComputedStyle(element) + return { padding: style.padding, gap: style.gap } + })).toEqual({ padding: '4px 7px 8px', gap: '7px' }) + expect(await action.locator('[data-team-panel]').getByRole('heading') + .evaluate(element => getComputedStyle(element).columnGap)).toBe('7px') + for (const [title, progress] of [ + [action.locator('[data-subagent-section-header]').getByRole('heading'), action.getByText('1 external', { exact: true })], + [action.locator('[data-team-panel]').getByText('Shared tasks', { exact: true }), action.getByText('1 pending', { exact: true })], + ] as const) { + expect(await progress.evaluate(element => getComputedStyle(element).marginInlineStart)).toBe('4px') + const titleRight = await title.evaluate(element => element.getBoundingClientRect().right) + const progressLeft = await progress.evaluate(element => element.getBoundingClientRect().left) + expect(Math.abs(progressLeft - titleRight - 11)).toBeLessThanOrEqual(1) + } + const member = action.getByRole('treeitem', { name: /Researcher/u }) + const memberTitle = member.getByText(RESEARCHER_DESCRIPTION, { exact: true }) + const taskTitle = row.getByText('Agent task', { exact: true }) + const titleLefts = await Promise.all([ + memberTitle, + action.locator('[data-subagent-section-header]').getByRole('heading'), + action.locator('[data-team-panel]').getByText('Shared tasks', { exact: true }), + taskTitle, + ].map(locator => locator.evaluate(element => element.getBoundingClientRect().left))) + expect(Math.max(...titleLefts) - Math.min(...titleLefts)).toBeLessThanOrEqual(1) + const primaryStyles = await Promise.all([memberTitle, taskTitle].map(locator => locator.evaluate((element) => { + const style = getComputedStyle(element) + return { fontSize: style.fontSize, color: style.color } + }))) + expect(primaryStyles.map(style => style.fontSize)).toEqual(['13px', '13px']) + expect(primaryStyles[1]).toEqual(primaryStyles[0]) + expect(await member.getByText('team-panel-researcher', { exact: true }) + .evaluate(element => getComputedStyle(element).fontSize)).toBe('12px') + const memberHover = member.locator(':scope > div') + for (const target of [member, memberHover]) { + expect(await target.evaluate((element) => { + const style = getComputedStyle(element) + return [style.paddingTop, style.paddingBottom] + })).toEqual(['4px', '4px']) + } + expect(await member.evaluate(element => getComputedStyle(element).minHeight)).toBe('40px') + expect(await member.evaluate((element) => { + const style = getComputedStyle(element) + return { left: style.paddingLeft, right: style.paddingRight, gap: style.gap } + })).toEqual({ left: '9px', right: '6px', gap: '7px' }) + expect(await memberHover.evaluate((element) => { + const style = getComputedStyle(element) + return { left: style.paddingLeft, right: style.paddingRight, gap: style.gap, margin: style.margin } + })).toEqual({ left: '6px', right: '6px', gap: '7px', margin: '-4px -6px' }) + const taskHover = row.getByRole('button') + expect(await taskHover.evaluate((element) => { + const style = getComputedStyle(element) + return [style.paddingTop, style.paddingBottom] + })).toEqual(['7px', '7px']) + expect(await taskHover.evaluate(element => element.getBoundingClientRect().height)).toBe(31) + expect(await taskHover.evaluate((element) => { + const style = getComputedStyle(element) + return { left: style.paddingLeft, right: style.paddingRight, gap: style.gap, margin: style.margin } + })).toEqual({ left: '6px', right: '6px', gap: '7px', margin: '0px -6px' }) + expect(await taskHover.evaluate(element => element.getBoundingClientRect().width + - element.parentElement!.getBoundingClientRect().width)).toBe(12) + const hoverStyles = [] + for (const target of [memberHover, taskHover]) { + await target.hover() + hoverStyles.push(await target.evaluate((element) => { + const bounds = element.getBoundingClientRect() + const style = getComputedStyle(element) + return { left: bounds.left, right: bounds.right, radius: style.borderRadius, background: style.backgroundColor } + })) + } + expect(Math.abs(hoverStyles[0]!.left - hoverStyles[1]!.left)).toBeLessThanOrEqual(1) + expect(Math.abs(hoverStyles[0]!.right - hoverStyles[1]!.right)).toBeLessThanOrEqual(1) + expect(hoverStyles[1]!.radius).toBe(hoverStyles[0]!.radius) + expect(hoverStyles[1]!.background).toBe(hoverStyles[0]!.background) + expect(hoverStyles[1]!.background).not.toBe('rgba(0, 0, 0, 0)') + await row.getByRole('button').click() + await row.locator('[data-team-task-owner]').filter({ hasText: 'Unowned' }).waitFor() + await row.getByText('Created by the root agent', { exact: true }).waitFor() + expect(await row.locator('[data-team-task-details]').evaluate((element) => { + const style = getComputedStyle(element) + return { fontSize: style.fontSize, indent: style.paddingLeft } + })).toEqual({ fontSize: '12px', indent: '21px' }) + expect(await row.locator('[data-team-task-details] p, [data-team-task-owner]') + .evaluateAll(elements => elements.every(element => getComputedStyle(element).fontSize === '12px'))).toBe(true) + expect(await row.getByRole('button').getAttribute('aria-expanded')).toBe('true') + expect(await action.evaluate(element => element.getBoundingClientRect().width)).toBe(360) await scaffold.ctx.agentTeams.updateTask(agent, { taskId: task.id, expectedRevision: task.revision, action: 'claim', }) - await action.getByText('In progress', { exact: true }).waitFor() - await action.getByText('Owner: lead', { exact: true }).waitFor() + await row.getByRole('img', { name: 'In progress', exact: true }).waitFor() + await row.locator('[data-team-task-owner]').filter({ hasText: agent.id }).waitFor() + await action.getByRole('heading', { name: 'Shared tasks 1 in progress', exact: true }).waitFor() expect(await action.getByRole('button', { name: /^(New task|Edit|Complete|Reopen|Delete)$/u }).count()).toBe(0) expect(await action.locator('input, select, textarea').count()).toBe(0) - const snapshot = await captureStableAria(page, '[data-team-panel]', scaffold.workspaceCwd) + const snapshot = await captureStableAria(page, '[data-subagent-panel]', scaffold.workspaceCwd) await compareOrRefreshGolden(PANEL_EXPECTED, snapshot, MODE) expect(projectionReads).not.toHaveBeenCalled() expect(tripwire.pageErrors).toEqual([]) @@ -117,15 +433,113 @@ describe('web e2e: Agent Teams panel', () => { await page.keyboard.press('Escape') }, 60_000) + it('hides the board while disabled and restores its tasks when re-enabled', async () => { + await page.getByRole('button', { name: /^Select model/u }).click() + const toggle = page.getByRole('switch', { name: 'Enable agent teamwork', exact: true }) + await toggle.click() + await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false') + expect(await page.locator('[data-subagent-action]').count()).toBe(1) + await page.keyboard.press('Escape') + await page.locator('[data-subagent-action]').getByRole('button', { name: '1 subagent', exact: true }).click() + await page.getByRole('treeitem', { name: /Researcher/u }).waitFor() + expect(await page.locator('[data-team-panel]').count()).toBe(0) + expect(await page.locator('[data-subagent-panel]').evaluate(element => element.getBoundingClientRect().width)).toBe(360) + expect(await page.locator('[data-subagent-section-header]').count()).toBe(0) + await page.keyboard.press('Escape') + await page.getByRole('button', { name: /^Select model/u }).click() + await toggle.click() + await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') + await page.keyboard.press('Escape') + await page.locator('[data-subagent-action]').getByRole('button', { name: /subagents?$/u }).click() + await page.locator('[data-subagent-panel]').getByText('Agent task', { exact: true }).waitFor() + await page.keyboard.press('Escape') + }) + + it('keeps the narrow Users trigger focused and opens its catalog only on explicit activation', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-team-narrow-trigger')) + const trigger = page.locator('[data-subagent-action]').getByRole('button', { name: '1 subagent', exact: true }) + const panel = page.locator('[data-subagent-panel]') + const count = trigger.locator('[data-subagent-count]') + const viewport = page.viewportSize()! + expect(await count.isVisible()).toBe(true) + expect(await trigger.locator('[data-icon="dsw-icon-users-outline-regular"]').count()).toBe(1) + expect(await trigger.locator('[data-icon="dsw-icon-users-outline-regular"]').evaluate((element) => { + const bounds = element.getBoundingClientRect() + return { width: bounds.width, height: bounds.height } + })).toEqual({ width: 14, height: 14 }) + expect(await trigger.locator('[data-icon="dsw-icon-chevron-down-outline-regular"]').count()).toBe(0) + expect(await trigger.locator('svg').count()).toBe(1) + await trigger.focus() + const original = await trigger.elementHandle() + if (original === null) throw new Error('Subagent trigger is missing') + try { + await page.setViewportSize({ width: 390, height: 700 }) + await expect.poll(() => count.evaluate(element => getComputedStyle(element).display)).toBe('none') + expect(await original.evaluate(element => element.isConnected && element === document.activeElement)).toBe(true) + const expandSidebar = page.locator('[data-sidebar-right-expand]') + const compactStyles = await Promise.all([trigger, expandSidebar].map(control => control.evaluate((element) => { + const bounds = element.getBoundingClientRect() + const icon = element.querySelector('svg')!.getBoundingClientRect() + const style = getComputedStyle(element) + return { width: bounds.width, height: bounds.height, padding: style.padding, radius: style.borderRadius, + color: style.color, iconWidth: icon.width, iconHeight: icon.height } + }))) + expect(compactStyles[0]).toEqual(compactStyles[1]) + expect(compactStyles[0]).toMatchObject({ width: 28, height: 28, padding: '0px', iconWidth: 15, iconHeight: 15 }) + expect(await trigger.evaluate(element => getComputedStyle(element.closest('[data-slot="conversation.session.header.actions"]')!.parentElement!).gap)).toBe('8px') + await expandSidebar.hover() + const expandHover = await expandSidebar.evaluate((element) => { + const style = getComputedStyle(element) + return { background: style.backgroundColor, color: style.color } + }) + await page.mouse.move(1, 1) + await page.keyboard.press('Tab') + const countTooltip = page.getByRole('tooltip', { name: '1 subagent', exact: true }) + await countTooltip.waitFor({ state: 'hidden' }) + await trigger.hover() + expect(await trigger.evaluate((element) => { + const style = getComputedStyle(element) + return { background: style.backgroundColor, color: style.color } + })).toEqual(expandHover) + expect(expandHover.background).not.toBe('rgba(0, 0, 0, 0)') + // Observe beyond the wide trigger's 150ms hover-open deadline. + await page.waitForTimeout(200) + expect(await panel.count()).toBe(0) + await countTooltip.waitFor({ state: 'visible' }) + expect(await panel.count()).toBe(0) + await trigger.click() + await panel.waitFor() + expect(await trigger.getAttribute('aria-expanded')).toBe('true') + expect(await page.getByRole('tooltip', { name: '1 subagent', exact: true }).count()).toBe(0) + await panel.getByRole('treeitem', { name: /Researcher/u }).waitFor() + await panel.locator('[data-team-task]').getByRole('button', { expanded: false }).waitFor() + await trigger.click() + await panel.waitFor({ state: 'detached' }) + expect(await trigger.getAttribute('aria-expanded')).toBe('false') + await trigger.press('ArrowDown') + await panel.waitFor() + await page.mouse.move(1, 1) + // Keyboard activation stays pinned beyond the 120ms pointer-leave grace. + await page.waitForTimeout(180) + expect(await panel.count()).toBe(1) + await page.keyboard.press('Escape') + await panel.waitFor({ state: 'detached' }) + } finally { + await page.keyboard.press('Escape') + await page.setViewportSize(viewport) + await original.dispose() + } + }) + it('keeps the panel inside the viewport and closes on outside click or Escape', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-team-panel-keyboard')) - const panel = page.getByRole('dialog', { name: 'Agent team', exact: true }) - const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent team', exact: true }) + const panel = page.locator('[data-subagent-panel]') + const trigger = page.locator('[data-subagent-action]').getByRole('button', { name: /subagents?$/u }) const viewport = page.viewportSize()! await trigger.click() - await panel.getByText('lead', { exact: true }).waitFor() + await panel.getByRole('heading', { name: /Shared tasks/u }).waitFor() try { - for (const size of [{ width: 760, height: 540 }, { width: 600, height: 420 }]) { + for (const size of [{ width: 760, height: 540 }, { width: 600, height: 420 }, { width: 390, height: 700 }]) { await page.setViewportSize(size) await expect.poll(() => panel.evaluate((element) => { const rect = element.getBoundingClientRect() @@ -136,6 +550,30 @@ describe('web e2e: Agent Teams panel', () => { .every(([x, y]) => element.contains(document.elementFromPoint(x!, y!))) })).toBe(true) } + const row = panel.getByRole('treeitem', { name: /Researcher/u }) + const primary = row.getByText(RESEARCHER_DESCRIPTION, { exact: true }) + const secondary = row.getByText('team-panel-researcher', { exact: true }) + expect(await row.getByRole('button').count()).toBe(0) + expect(await primary.evaluate((element) => { + const style = getComputedStyle(element) + return { + tag: element.tagName, clipped: element.scrollWidth > element.clientWidth || element.scrollHeight > element.clientHeight, + whiteSpace: style.whiteSpace, overflowWrap: style.overflowWrap, + } + })).toEqual({ tag: 'SPAN', clipped: false, whiteSpace: 'normal', overflowWrap: 'anywhere' }) + const primaryBounds = await primary.boundingBox() + const secondaryBounds = await secondary.boundingBox() + expect(primaryBounds).not.toBeNull() + expect(secondaryBounds).not.toBeNull() + expect(primaryBounds!.y + primaryBounds!.height).toBeLessThanOrEqual(secondaryBounds!.y) + const beforeClick = page.url() + await primary.click() + expect(page.url()).toBe(beforeClick) + expect(await page.getByRole('tooltip', { name: RESEARCHER_DESCRIPTION, exact: true }).count()).toBe(0) + expect(await panel.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + expect(await secondary.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + await secondary.hover() + expect(await page.getByRole('tooltip').count()).toBe(0) await panel.getByRole('heading', { name: /Shared tasks/u }).click() expect(await panel.count()).toBe(1) await page.mouse.click(2, 2) @@ -166,8 +604,8 @@ describe('web e2e: Agent Teams panel', () => { await saveFailureShot(page, 'web-e2e-agent-team-hover') console.error('Agent Team hover diagnostics', JSON.stringify({ errors: tripwire.pageErrors, warnings: tripwire.warnings })) }) - const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent team', exact: true }) - const panel = page.getByRole('dialog', { name: 'Agent team', exact: true }) + const trigger = page.locator('[data-subagent-action]').getByRole('button', { name: /subagents?$/u }) + const panel = page.locator('[data-subagent-panel]') await trigger.hover() await panel.waitFor() await panel.hover() @@ -178,37 +616,412 @@ describe('web e2e: Agent Teams panel', () => { await page.keyboard.press('Escape') }) - it('offers expansion when resizing makes a task description exceed two lines', async () => { + it('keeps compact tasks ordered, opens one full detail at a time, and scrolls only the fixed-width menu', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-team-compact-details')) const agent = scaffold.ctx.agents.list()[0]! - await scaffold.ctx.agentTeams.createTask(agent, { - subject: 'Responsive task', - description: 'Check the task description before you start work. '.repeat(3), - writeScopes: [], + const completed = await scaffold.ctx.agentTeams.createTask(agent, { + subject: 'Completed preparation', description: 'Preparation is complete.', writeScopes: [], + }) + const claimed = await scaffold.ctx.agentTeams.updateTask(agent, { + taskId: completed.id, expectedRevision: completed.revision, action: 'claim', + }) + await scaffold.ctx.agentTeams.updateTask(agent, { + taskId: completed.id, expectedRevision: claimed.revision, action: 'complete', + }) + const blocker = await scaffold.ctx.agentTeams.createTask(agent, { + subject: 'Dependency task', description: 'Finish this prerequisite first.', writeScopes: [], + }) + const subject = 'Responsive task with a long title that remains fully readable when expanded at the minimum supported viewport' + const description = 'Check every dependency and write scope before you start work.\n'.repeat(16).trim() + const task = await scaffold.ctx.agentTeams.createTask(agent, { + subject, description, blockedBy: [blocker.id], writeScopes: ['src/web/components'], }) const viewport = page.viewportSize()! - const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent team', exact: true }) - const panel = page.getByRole('dialog', { name: 'Agent team', exact: true }) - const card = panel.locator('article').filter({ hasText: 'Responsive task' }) + const trigger = page.locator('[data-subagent-action]').getByRole('button', { name: /subagents?$/u }) + const panel = page.locator('[data-subagent-panel]') + const board = panel.locator('[data-team-panel]') + const row = board.locator(`[data-team-task="${task.id}"]`) + const emptyRow = board.locator(`[data-team-task="${completed.id}"]`) + onTestFinished(() => page.setViewportSize(viewport)) + // Constrain the menu so closed and expanded task rows both exercise its scrollbar. + await page.setViewportSize({ width: viewport.width, height: 540 }) await trigger.click() - await card.waitFor() - expect(await card.getByRole('button', { name: 'Show more' }).count()).toBe(0) + await row.waitFor() + expect(await panel.evaluate(element => element.getBoundingClientRect().width)).toBe(360) + expect(await board.locator('[data-team-task-details], article').count()).toBe(0) + expect(await board.getByRole('button', { expanded: false }).count()).toBe(4) + expect(await board.locator('[data-team-task]').evaluateAll(elements => elements.map(element => element.getAttribute('data-status')))) + .toEqual(['in_progress', 'completed', 'pending', 'pending']) + const counts = board.getByText('1 completed\u2002·\u20021 in progress\u2002·\u20022 pending', { exact: true }) + await counts.waitFor() + expect(await counts.textContent()).toBe('1 completed\u2002·\u20021 in progress\u2002·\u20022 pending') + expect(await panel.locator('[data-subagent-section-header] button, h3 button').count()).toBe(0) + expect(await panel.getByRole('button', { name: /^Show (more|less)$/u }).count()).toBe(0) + await row.getByRole('img', { name: 'Blocked by dependencies', exact: true }).waitFor() + const measureScroll = async () => panel.evaluate((element) => { + const style = getComputedStyle(element) + const bounds = element.getBoundingClientRect() + const scroller = element.querySelector('[role="tree"]')!.parentElement! + const scrollStyle = getComputedStyle(scroller) + const scrollBounds = scroller.getBoundingClientRect() + const taskBounds = element.querySelector('[data-team-task] button')!.getBoundingClientRect() + const scrollbarWidth = Number.parseFloat(getComputedStyle(scroller, '::-webkit-scrollbar').width) + return { + innerWidth: bounds.width - Number.parseFloat(style.paddingLeft) - Number.parseFloat(style.paddingRight) + - Number.parseFloat(style.borderLeftWidth) - Number.parseFloat(style.borderRightWidth), + clientWidth: scroller.clientWidth, clientHeight: scroller.clientHeight, scrollHeight: scroller.scrollHeight, + overflowing: scroller.scrollHeight > scroller.clientHeight, + gutter: scrollStyle.scrollbarGutter, marginRight: scrollStyle.marginRight, + trackMargin: scrollStyle.getPropertyValue('--dsh-scrollbar-track-margin').trim(), + taskLeft: taskBounds.left, taskRight: taskBounds.right, + trackLeft: scrollBounds.right - scrollbarWidth, trackRight: scrollBounds.right, panelRight: bounds.right, + arrowsBeforeTrack: [...element.querySelectorAll('[data-team-task] button svg')] + .every(arrow => arrow.getBoundingClientRect().right <= scrollBounds.right - scrollbarWidth), + } + }) + const closedScroll = await measureScroll() + expect(closedScroll.overflowing, JSON.stringify(closedScroll)).toBe(true) + expect(Math.abs(closedScroll.clientWidth - closedScroll.innerWidth), JSON.stringify(closedScroll)).toBeLessThanOrEqual(1) + expect(closedScroll.gutter).toBe('stable') + expect(closedScroll.marginRight).toBe('-5px') + expect(closedScroll.trackMargin).toBe('6px') + await row.getByRole('button').click() + const expandedScroll = await measureScroll() + expect(expandedScroll.overflowing).toBe(true) + expect(expandedScroll.clientWidth).toBe(closedScroll.clientWidth) + expect(expandedScroll.taskLeft).toBe(closedScroll.taskLeft) + expect(expandedScroll.taskRight).toBe(closedScroll.taskRight) + expect(expandedScroll.arrowsBeforeTrack).toBe(true) + expect(expandedScroll.trackLeft).toBeGreaterThanOrEqual(expandedScroll.panelRight - 6) + expect(expandedScroll.trackRight).toBeLessThanOrEqual(expandedScroll.panelRight - 1) + expect(await panel.evaluate(element => element.getBoundingClientRect().width)).toBe(360) + const details = row.locator('[data-team-task-details]') + expect(await details.locator('p:not([data-team-task-owner])').textContent()).toBe(description) + expect(await details.locator('[data-team-task-owner]').textContent()).toBe('Owner: Unowned') + expect(await details.locator(':scope > *').evaluateAll(elements => elements.map(element => + element.hasAttribute('data-team-task-owner') ? 'owner' : element.tagName))).toEqual(['P', 'owner']) + expect(await details.locator('dl, dt, dd').count()).toBe(0) + for (const omitted of [task.id, blocker.subject, 'Readiness', 'Write scopes', 'Write scope warnings', 'src/web/components']) { + expect(await details.textContent()).not.toContain(omitted) + } + expect(await row.getByRole('button').getAttribute('aria-controls')).toBe(await details.getAttribute('id')) try { - await page.setViewportSize({ width: 360, height: 700 }) - await card.getByRole('button', { name: 'Show more' }).waitFor() - expect(await card.locator('p').evaluate(p => p.scrollHeight > p.clientHeight)).toBe(true) - await compareOrRefreshGolden(RESIZE_EXPECTED, await captureStableAria(page, '[data-team-panel]', scaffold.workspaceCwd), MODE) - await card.getByRole('button', { name: 'Show more' }).click() - expect(await card.locator('p').evaluate(p => p.scrollHeight === p.clientHeight)).toBe(true) - await card.getByRole('button', { name: 'Show less' }).click() + await page.setViewportSize({ width: 390, height: 700 }) + await expect.poll(() => panel.evaluate((element) => { + const rect = element.getBoundingClientRect() + return rect.width === Math.min(360, innerWidth - 32) && rect.left >= 16 && rect.right <= innerWidth - 16 + && rect.top >= 16 && rect.bottom <= innerHeight - 16 + && element.scrollWidth <= element.clientWidth + })).toBe(true) + expect(await panel.locator('*').evaluateAll((elements) => { + const scrollers = elements.filter(element => + ['auto', 'scroll'].includes(getComputedStyle(element).overflowY) + && element.scrollHeight > element.clientHeight) + return scrollers.map(element => ({ + hasHeader: element.querySelector('[data-subagent-section-header]') !== null, + hasTree: element.querySelector('[role="tree"]') !== null, + hasBoard: element.querySelector('[data-team-panel]') !== null, + fitsWidth: element.scrollWidth <= element.clientWidth, + })) + })).toEqual([{ hasHeader: true, hasTree: true, hasBoard: true, fitsWidth: true }]) + const narrowScroll = await measureScroll() + expect(Math.abs(narrowScroll.clientWidth - narrowScroll.innerWidth), JSON.stringify(narrowScroll)).toBeLessThanOrEqual(1) + expect(narrowScroll.arrowsBeforeTrack).toBe(true) + const scroller = panel.getByRole('tree').locator('..') + await scroller.hover({ position: { x: 20, y: 20 } }) + await Promise.all([ + scroller.evaluate(element => new Promise((resolve) => { + element.addEventListener('scrollend', () => { resolve() }, { once: true }) + })), + page.mouse.wheel(0, 180), + ]) + await expect.poll(() => scroller.evaluate(element => element.scrollTop)).toBeGreaterThan(0) + await scroller.evaluate((element) => { element.scrollTo({ top: 0 }) }) + await expect.poll(() => scroller.evaluate(element => element.scrollTop)).toBe(0) + expect(await row.getByText(subject, { exact: true }).evaluate(element => + element.scrollWidth <= element.clientWidth && getComputedStyle(element).whiteSpace === 'pre-wrap')).toBe(true) + expect(await details.locator('p:not([data-team-task-owner])').evaluate(element => element.scrollHeight === element.clientHeight)).toBe(true) + const ownerBounds = await details.locator('[data-team-task-owner]').boundingBox() + const descriptionBounds = await details.locator('p:not([data-team-task-owner])').boundingBox() + expect(ownerBounds).not.toBeNull() + expect(descriptionBounds).not.toBeNull() + expect(ownerBounds!.height).toBeLessThanOrEqual(18) + expect(ownerBounds!.y).toBeGreaterThanOrEqual(descriptionBounds!.y + descriptionBounds!.height) + const detailColor = await details.evaluate(element => getComputedStyle(element).color) + expect(await details.locator('[data-team-task-owner], [data-team-task-owner] span') + .evaluateAll(elements => elements.map(element => getComputedStyle(element).color))) + .toEqual([detailColor, detailColor, detailColor]) + expect(await counts.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + const titleBounds = await board.getByText('Shared tasks', { exact: true }).boundingBox() + const countBounds = await counts.boundingBox() + expect(titleBounds).not.toBeNull() + expect(countBounds).not.toBeNull() + expect(countBounds!.y).toBeGreaterThanOrEqual(titleBounds!.y + titleBounds!.height) + await compareOrRefreshGolden(RESIZE_EXPECTED, await captureStableAria(page, '[data-subagent-panel]', scaffold.workspaceCwd), MODE) + await emptyRow.getByRole('button').click() + expect(await row.getAttribute('data-expanded')).toBe('false') + expect(await row.locator('[data-team-task-details]').count()).toBe(0) + expect(await board.locator('[data-team-task-details]').count()).toBe(1) + expect(await emptyRow.locator('[data-team-task-owner]').textContent()).toBe(`Owner: ${agent.id}`) + expect(await emptyRow.locator('[data-team-task-details] p:not([data-team-task-owner])').textContent()).toBe(completed.description) + expect(await emptyRow.locator('dl, dt, dd').count()).toBe(0) + expect(await emptyRow.locator('[data-team-task-details]').textContent()).not.toContain(completed.id) + await emptyRow.getByRole('button').click() + expect(await board.locator('[data-team-task-details]').count()).toBe(0) await page.setViewportSize(viewport) - await expect.poll(() => card.getByRole('button', { name: 'Show more' }).count()).toBe(0) + expect(await panel.evaluate(element => element.getBoundingClientRect().width)).toBe(360) } finally { await page.keyboard.press('Escape') await page.setViewportSize(viewport) } }) + it('confirms disabling after a recorded model request and keeps cancellation side-effect free', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-team-disable-confirmation')) + const agent = scaffold.ctx.agents.list()[0]! + const selection = agent.model.current() + if (selection === undefined) throw new Error('Team fixture has no selected model') + agent.session.append('request/header', { header: { config: selection }, reason: 'initial' }) + await scaffold.ctx.sessions.flush(agent.session) + const openConfirmation = async (): Promise => { + await page.getByRole('button', { name: /^Select model/u }).click() + await page.getByRole('switch', { name: 'Enable agent teamwork', exact: true }).click() + await page.getByRole('dialog', { name: 'Disable agent teamwork?', exact: true }).waitFor() + } + await openConfirmation() + const dialog = page.getByRole('dialog', { name: 'Disable agent teamwork?', exact: true }) + await assertWarningDialogLayout(dialog) + expect(await page.getByRole('menu', { name: 'Model and reasoning effort', exact: true }).count()).toBe(0) + expect(await dialog.getByRole('checkbox').count()).toBe(0) + expect(scaffold.ctx.agentTeams.isEnabled(agent)).toBe(true) + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'disable-confirmation.expected.md'), + await captureStableAria(page, '[aria-modal="true"]', scaffold.workspaceCwd), MODE) + await dialog.getByRole('button', { name: 'Cancel', exact: true }).click() + await dialog.waitFor({ state: 'hidden' }) + await expect.poll(() => page.getByRole('button', { name: /^Select model/u }).evaluate(element => element === document.activeElement)).toBe(true) + expect(scaffold.ctx.agentTeams.isEnabled(agent)).toBe(true) + await openConfirmation() + await page.keyboard.press('Escape') + await dialog.waitFor({ state: 'hidden' }) + await expect.poll(() => page.getByRole('button', { name: /^Select model/u }).evaluate(element => element === document.activeElement)).toBe(true) + expect(scaffold.ctx.agentTeams.isEnabled(agent)).toBe(true) + await openConfirmation() + await dialog.getByRole('button', { name: 'Disable', exact: true }).click() + await dialog.waitFor({ state: 'hidden' }) + await expect.poll(() => page.getByRole('button', { name: /^Select model/u }).evaluate(element => element === document.activeElement)).toBe(true) + await expect.poll(() => scaffold.ctx.agentTeams.isEnabled(agent)).toBe(false) + await page.getByRole('button', { name: /^Select model/u }).click() + await page.getByRole('switch', { name: 'Enable agent teamwork', exact: true }).click() + await expect.poll(() => scaffold.ctx.agentTeams.isEnabled(agent)).toBe(true) + expect(await dialog.count()).toBe(0) + await page.keyboard.press('Escape') + expect(agent.session.ownEvents().filter(event => event.type === 'command/run' || event.type === 'command/done')).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + }) + + it('removes both section headings and the board divider when the last shared task is deleted', async () => { + const agent = scaffold.ctx.agents.list()[0]! + await page.getByRole('button', { name: '1 subagent', exact: true }).click() + const panel = page.locator('[data-subagent-panel]') + await panel.locator('[data-team-panel]').waitFor() + for (const task of scaffold.ctx.agentTeams.listTasks(agent).toReversed()) { + if (task.status === 'deleted') continue + await scaffold.ctx.agentTeams.updateTask(agent, { + taskId: task.id, expectedRevision: task.revision, action: 'delete', + }) + } + await panel.locator('[data-team-panel]').waitFor({ state: 'detached' }) + expect(await panel.locator('[data-subagent-section-header]').count()).toBe(0) + expect(await panel.getByRole('heading').count()).toBe(0) + expect(await panel.getByText('No shared tasks yet. Create them through the conversation.').count()).toBe(0) + expect(await panel.getByRole('treeitem', { name: /Researcher/u }).count()).toBe(1) + expect(await panel.evaluate(element => element.getBoundingClientRect().width)).toBe(360) + await page.keyboard.press('Escape') + }) + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['task.expected.md', 'resize.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, [ + 'task.expected.md', 'resize.expected.md', 'info.expected.md', 'task-only.expected.md', + 'disable-confirmation.expected.md', 'collaboration.zh.expected.md', + 'command-error.en.expected.md', 'command-error.zh.expected.md', + ]) }) }) + +it.each(RECEIPT_LOCALES)('toggles teamwork in $language without command records or Chat changes after reload', async (locale) => { + await withTeamReceiptPage(locale, async (page, scaffold, tripwire) => { + const agent = scaffold.ctx.agents.list()[0] + if (agent === undefined) throw new Error('Team receipt fixture has no Agent') + const chat = page.locator('[data-chat-flow]').first() + const recordedReady = await captureStableAria(page, '[data-chat-flow]', scaffold.workspaceCwd) + expect(recordedReady).toContain('Ready.') + expect(recordedReady).toContain('Open the task board.') + const captureLayout = () => chat.evaluate((element) => { + const flow = element.getBoundingClientRect() + return { + height: flow.height, + rows: [...element.querySelectorAll('[data-chat-flow-key]')].map((row) => { + const bounds = row.getBoundingClientRect() + return { kind: row.getAttribute('data-chat-flow-kind'), top: bounds.top - flow.top, height: bounds.height } + }), + } + }) + const recordedLayout = await captureLayout() + const commandEvents = () => agent.session.snapshotEvents() + .filter(event => event.type === 'command/run' || event.type === 'command/done') + expect(commandEvents()).toEqual([]) + const input = page.locator('[data-composer-input]').first() + await writeComposerDraft(page, input, '/') + const suggestions = page.getByRole('listbox', { name: locale.suggestions, exact: true }) + await suggestions.getByRole('option').first().waitFor() + const candidate = suggestions.getByRole('option').filter({ hasText: locale.commandLabel }) + expect(await candidate.count()).toBe(0) + await writeComposerDraft(page, input, '/tea') + await expect.poll(() => suggestions.getByRole('option').count()).toBe(0) + expect(await candidate.count()).toBe(0) + await input.press('Escape') + await suggestions.waitFor({ state: 'hidden' }) + await writeComposerDraft(page, input, '') + expect(commandEvents()).toEqual([]) + expect(scaffold.ctx.agentTeams.isEnabled(agent)).toBe(false) + const assertChatUnchanged = async (): Promise => { + await chat.getByText('Ready.', { exact: true }).waitFor() + expect(await page.locator('[data-team-command], [data-chat-flow-kind="command"], [data-command-input]').count()).toBe(0) + expect(await captureStableAria(page, '[data-chat-flow]', scaffold.workspaceCwd)).toBe(recordedReady) + await expect.poll(captureLayout).toEqual(recordedLayout) + } + for (const enabled of [true, false]) { + await page.getByRole('button', { name: locale.modelButton }).click() + const toggle = page.getByRole('switch', { name: locale.toggle, exact: true }) + await toggle.click() + await expect.poll(() => toggle.getAttribute('aria-checked')).toBe(String(enabled)) + await expect.poll(() => scaffold.ctx.agentTeams.isEnabled(agent)).toBe(enabled) + expect(scaffold.ctx.commands.list(agent).some(command => command.name === 'team')).toBe(false) + expect(commandEvents()).toEqual([]) + await page.keyboard.press('Escape') + await assertChatUnchanged() + } + await scaffold.ctx.sessions.flush(agent.session) + const recordedCommands = commandEvents() + expect(recordedCommands).toEqual([]) + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await assertChatUnchanged() + expect(commandEvents()).toEqual(recordedCommands) + expect(scaffold.ctx.agentTeams.isEnabled(agent)).toBe(false) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + + // Historical receipts are read-only fixtures, not invocations of a registered command. + for (const args of [' on', ' off']) { + const commandId = CommandId(`historical-team-${args.trim()}`) + agent.session.append('command/run', { commandId, name: 'team', args, source: { kind: 'user' } }) + agent.session.append('command/done', { commandId, kind: 'success', text: 'Team mode saved.' }) + } + agent.session.append('command/run', { + commandId: CommandId('historical-team-pending'), name: 'team', args: ' on', source: { kind: 'user' }, + }) + await scaffold.ctx.sessions.flush(agent.session) + const historicalCommands = commandEvents() + expect(historicalCommands.map(event => event.type)).toEqual([ + 'command/run', 'command/done', 'command/run', 'command/done', 'command/run', + ]) + const historicalWarningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await assertChatUnchanged() + expect(commandEvents()).toEqual(historicalCommands) + expect(scaffold.ctx.agentTeams.isEnabled(agent)).toBe(false) + acknowledgeReloadConnectionLoss(tripwire, historicalWarningStart) + }) +}) + +it.each(RECEIPT_LOCALES)('renders historical Team failures in $language without a success receipt after reload', async (locale) => { + await withTeamReceiptPage(locale, async (page, scaffold, tripwire) => { + const agent = scaffold.ctx.agents.list()[0] + if (agent === undefined) throw new Error('Team failure fixture has no Agent') + const commandId = CommandId('historical-team-error') + agent.session.append('command/run', { commandId, name: 'team', args: ' invalid', source: { kind: 'user' } }) + agent.session.append('command/done', { commandId, kind: 'error', text: 'Use /team on or /team off.' }) + await scaffold.ctx.sessions.flush(agent.session) + const row = page.locator('[data-team-command]') + const captureFailure = async (): Promise => { + await row.getByText(locale.failure, { exact: true }).waitFor() + expect(await row.count()).toBe(1) + expect(await row.getAttribute('data-state')).toBe('error') + expect(await row.getByText(locale.title, { exact: true }).count()).toBe(1) + expect(await row.getByText(locale.enabled, { exact: true }).count()).toBe(0) + expect(await row.getByText(locale.disabled, { exact: true }).count()).toBe(0) + expect(scaffold.ctx.agentTeams.isEnabled(agent)).toBe(false) + const collapsed = await captureStableAria(page, '[data-team-command]', scaffold.workspaceCwd) + const disclosure = row.getByRole('button', { expanded: false }) + await disclosure.click() + await row.getByRole('button', { expanded: true }).waitFor() + await row.getByText('Use /team on or /team off.', { exact: true }).waitFor() + const expanded = await captureStableAria(page, '[data-team-command]', scaffold.workspaceCwd) + return `## Failed receipt\n\n${collapsed}\n\n## Raw error\n\n${expanded}` + } + const failure = await captureFailure() + await compareOrRefreshGolden(join(SNAPSHOT_DIR, `command-error.${locale.code}.expected.md`), failure, MODE) + await scaffold.ctx.sessions.flush(agent.session) + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + expect(await captureFailure()).toBe(failure) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + }) +}) + +it('shows the Chinese empty subagent section above compact shared tasks', async () => { + await withTeamReceiptPage(RECEIPT_LOCALES[1], async (page, scaffold) => { + const agent = scaffold.ctx.agents.list()[0]! + await scaffold.ctx.agentTeams.setEnabled(agent, true) + await scaffold.ctx.agentTeams.createTask(agent, { + subject: '准备协作', description: '先准备共享任务,再创建子智能体。', + }) + await page.getByRole('button', { name: '0 个子智能体', exact: true }).click() + const panel = page.locator('[data-subagent-panel]') + await panel.getByText('暂无子智能体', { exact: true }).waitFor() + expect(await panel.locator('[data-subagent-section-header]').textContent()).toBe('子智能体') + expect(await panel.locator('[data-subagent-section-header] button, h3 button').count()).toBe(0) + expect(await panel.evaluate(element => element.getBoundingClientRect().width)).toBe(360) + await panel.getByRole('heading', { name: '共享任务 1 待处理', exact: true }).waitFor() + expect(await panel.locator('[data-team-task-details]').count()).toBe(0) + }) +}) + +it('uses the Chinese collaboration name in the switch and disable confirmation', async () => { + const scaffold = await launchWebScaffold() + let browser: Browser | undefined + try { + browser = await chromium.launch() + const page = await browser.newPage({ locale: 'zh-CN', timezoneId: 'Asia/Shanghai' }) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await connectFreshWorkspaceZh(page, scaffold.workspaceCwd) + const agent = scaffold.ctx.agents.list()[0] + if (agent === undefined) throw new Error('Chinese Team fixture has no Agent') + await scaffold.ctx.agentTeams.setEnabled(agent, true) + const selection = agent.model.current() + if (selection === undefined) throw new Error('Chinese Team fixture has no model') + agent.session.append('request/header', { header: { config: selection }, reason: 'initial' }) + await scaffold.ctx.sessions.flush(agent.session) + await page.getByRole('button', { name: /^选择模型/u }).click() + const toggle = page.getByRole('switch', { name: '启用智能体团队协作', exact: true }) + await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') + await page.getByRole('button', { name: '关于智能体团队协作', exact: true }).waitFor() + expect(await toggle.evaluate(element => element.parentElement!.scrollWidth <= element.parentElement!.clientWidth)).toBe(true) + const menu = await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd) + await toggle.click() + const dialog = page.getByRole('dialog', { name: '确认停用智能体团队协作?', exact: true }) + await dialog.waitFor() + await dialog.getByText('对话过程中停用智能体团队协作,将停止共享任务工具和团队协调指令。', { exact: true }).waitFor() + await assertWarningDialogLayout(dialog) + const confirmation = await captureStableAria(page, '[aria-modal="true"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'collaboration.zh.expected.md'), + `## Menu\n\n${menu}\n\n## Confirmation\n\n${confirmation}`, MODE) + await dialog.getByRole('button', { name: '取消', exact: true }).click() + expect(scaffold.ctx.agentTeams.isEnabled(agent)).toBe(true) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } finally { + try { await browser?.close() } finally { await scaffold.close() } + } +}) diff --git a/apps/web/tests/agent-team-panel.overlay.yml b/apps/web/tests/agent-team-panel.overlay.yml deleted file mode 100644 index bdcf49e256..0000000000 --- a/apps/web/tests/agent-team-panel.overlay.yml +++ /dev/null @@ -1,27 +0,0 @@ -# Equivalent to the Agent Teams bundle over the stable base and Web bundles. -- id: tool-subagent-control - disabled: true - -- id: tool-subagent - disabled: true - -- id: tool-subagent-fork - disabled: true - -- insert: - - id: agent-team - name: '@deepseek-ai/dsh-experimental-agent-team' - config: - maxMembers: 8 - maxTasks: 256 - maxMessageBytes: 65536 - disposalTimeoutMs: 5000 - - - id: tool-agent-team - name: '@deepseek-ai/dsh-experimental-tool-agent-team' - config: - freshProvider: spawn - forkProvider: fork - - - id: ui-agent-team - name: '@deepseek-ai/dsh-experimental-client-ui-agent-team' 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/assembled-remote.ts b/apps/web/tests/assembled-remote.ts index 4d60b8749e..08a226a835 100644 --- a/apps/web/tests/assembled-remote.ts +++ b/apps/web/tests/assembled-remote.ts @@ -158,7 +158,6 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass 'agentPresets/list': structuredClone(fixture.agentPresets), 'commands/list': structuredClone(fixture.commands), 'settings/openSettingsDocument': ok({ opened: true }), - 'subagents/list': ok({ entries: [], parentAvailable: true }), 'terminal/list': ok([]), 'skills/list': ok({ skills: [] }), 'session/canOpenWorkspacePath': ok(true), diff --git a/apps/web/tests/background-job-list.e2e.ts b/apps/web/tests/background-job-list.e2e.ts index fab978232b..899cda61c4 100644 --- a/apps/web/tests/background-job-list.e2e.ts +++ b/apps/web/tests/background-job-list.e2e.ts @@ -85,7 +85,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { let phase = 'running' onTestFailed(() => saveFailureShot(page, `web-e2e-background-job-${phase}`)) // Polling for zero would pass at t=0 before delivery and prove nothing. - const trigger = page.getByRole('button', { name: '1 background job running' }) + const trigger = page.getByRole('button', { name: '1 background job', exact: true }) expect(await trigger.count()).toBe(0) const started = await executeToolFixture(scaffold.ctx, { @@ -101,10 +101,28 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { const jobId = JobId(matched[0]) await trigger.waitFor({ timeout: 15_000 }) - await trigger.click() - const row = page.getByRole('list', { name: 'Background jobs' }).getByRole('listitem').first() + expect(await trigger.locator('[data-state="ongoing"]').count()).toBe(1) + expect(await trigger.locator('[data-icon="dsw-icon-chevron-down-outline-regular"]').count()).toBe(0) + await trigger.hover() + const list = page.getByRole('list', { name: 'Background jobs' }) + const row = list.getByRole('listitem').first() await row.waitFor({ timeout: 10_000 }) await expect.poll(() => row.textContent()).toContain(COMMAND) + expect(await trigger.getAttribute('aria-expanded')).toBe('true') + await row.hover() + await row.getByRole('button', { name: /Show live output/ }).focus() + await page.mouse.move(2, 2) + // Focusing a menu control pins it beyond the 120ms hover-leave deadline. + await page.waitForTimeout(180) + expect(await list.count()).toBe(1) + await trigger.click() + await list.waitFor({ state: 'detached' }) + await trigger.click() + await list.waitFor() + await page.mouse.move(2, 2) + await page.waitForTimeout(180) + expect(await list.count()).toBe(1) + expect(await trigger.getAttribute('aria-label')).toBe('1 background job') const running = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd, { runningJobs: 'keep' }) await compareOrRefreshGolden(RUNNING_EXPECTED, running, MODE) @@ -120,9 +138,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { await expect.poll(() => stop.getAttribute('data-kill-state')).toBe('armed') await stop.click() - // Exact: the running trigger's name ('1 background job running') contains this label. - const idle = page.getByRole('button', { name: '1 background job', exact: true }) - await idle.waitFor({ timeout: 20_000 }) + expect(await trigger.getAttribute('aria-label')).toBe('1 background job') // The unclaimed report's reason lands in the settled row's detail. await expect.poll( () => page.getByRole('list', { name: 'Background jobs' }).textContent(), @@ -185,6 +201,82 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { expect(await trigger.evaluate(node => node === document.activeElement)).toBe(true) }, 60_000) + it('keeps the compact Layers trigger focused and opens jobs only on click or keyboard', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-compact-trigger')) + const trigger = page.getByRole('button', { name: '1 background job', exact: true }) + const list = page.getByRole('list', { name: 'Background jobs' }) + const count = trigger.locator('[data-job-count]') + const viewport = page.viewportSize()! + await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click() + await expect.poll(() => page.locator('[data-rightbar-col]') + .evaluate(column => column.parentElement?.hasAttribute('data-animating'))).toBe(false) + await page.getByRole('button', { name: 'Open right sidebar', exact: true }).waitFor() + await page.mouse.move(2, 2) + expect(await count.isVisible()).toBe(true) + expect(await trigger.locator('[data-icon="dsw-icon-layers-outline-regular"]').count()).toBe(1) + expect(await trigger.locator('svg').count()).toBe(1) + expect(await trigger.locator('svg').evaluate((element) => { + const bounds = element.getBoundingClientRect() + return [bounds.width, bounds.height] + })).toEqual([14, 14]) + await trigger.focus() + const original = await trigger.elementHandle() + if (original === null) throw new Error('Background jobs trigger is missing') + try { + await page.setViewportSize({ width: 390, height: 700 }) + await expect.poll(() => count.evaluate(element => getComputedStyle(element).display)).toBe('none') + expect(await original.evaluate(element => element.isConnected && element === document.activeElement)).toBe(true) + const expandSidebar = page.locator('[data-sidebar-right-expand]') + const compactStyles = await Promise.all([trigger, expandSidebar].map(control => control.evaluate((element) => { + const style = getComputedStyle(element) + const bounds = element.getBoundingClientRect() + const icon = element.querySelector('svg')!.getBoundingClientRect() + return { width: bounds.width, height: bounds.height, padding: style.padding, radius: style.borderRadius, + color: style.color, iconWidth: icon.width, iconHeight: icon.height } + }))) + expect(compactStyles[0]).toEqual(compactStyles[1]) + expect(compactStyles[0]).toMatchObject({ width: 28, height: 28, padding: '0px', iconWidth: 15, iconHeight: 15 }) + expect(await trigger.evaluate(element => getComputedStyle(element.closest('[data-slot="conversation.session.header.actions"]')!.parentElement!).gap)).toBe('8px') + await expandSidebar.hover() + const expandHover = await expandSidebar.evaluate((element) => { + const style = getComputedStyle(element) + return { background: style.backgroundColor, color: style.color } + }) + await expect.poll(() => trigger.evaluate((element) => { + const bounds = element.getBoundingClientRect() + return element.contains(document.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + bounds.height / 2)) + })).toBe(true) + await trigger.hover() + expect(await trigger.evaluate((element) => { + const style = getComputedStyle(element) + return { background: style.backgroundColor, color: style.color } + })).toEqual(expandHover) + expect(expandHover.background).not.toBe('rgba(0, 0, 0, 0)') + // Compact hover stays closed beyond the wide trigger's 150ms open deadline. + await page.waitForTimeout(200) + expect(await list.count()).toBe(0) + await trigger.click() + await list.waitFor() + await list.getByRole('button', { name: 'Finished 1', exact: true }).waitFor() + await trigger.click() + await list.waitFor({ state: 'detached' }) + await trigger.press('ArrowDown') + await list.waitFor() + expect(await list.getByRole('button').first().evaluate(element => element === document.activeElement)).toBe(true) + await page.mouse.move(2, 2) + // Keyboard activation remains pinned after leaving both trigger and menu. + await page.waitForTimeout(180) + expect(await list.count()).toBe(1) + await page.mouse.click(2, 2) + await list.waitFor({ state: 'detached' }) + expect(await trigger.getAttribute('aria-expanded')).toBe('false') + } finally { + await page.keyboard.press('Escape') + await page.setViewportSize(viewport) + await original.dispose() + } + }) + it('keeps its snapshot inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, ['running.expected.md', 'settled.expected.md']) }) diff --git a/apps/web/tests/bundle-platform.e2e.ts b/apps/web/tests/bundle-platform.e2e.ts index 55d22be94d..2f3c673a3c 100644 --- a/apps/web/tests/bundle-platform.e2e.ts +++ b/apps/web/tests/bundle-platform.e2e.ts @@ -44,7 +44,7 @@ it('disables unsupported bundles, shows the Host restriction, and refuses direct const names = [name] await page.locator('[data-plugin-panel]').getByRole('button', { name: '更多', exact: true }).click() const cua = page.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]') - expect(await cua.count()).toBe(0) + expect(await cua.count()).toBe(process.platform === 'darwin' ? 1 : 0) if (process.platform !== 'darwin') { expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === '@deepseek-ai/dsh-cua-bundle')) .toMatchObject({ listedByDefault: false, enabled: false, error: { code: 'unsupported-platform' } }) diff --git a/apps/web/tests/cua-optin.e2e.ts b/apps/web/tests/cua-optin.e2e.ts index c5900519c6..77ec920da5 100644 --- a/apps/web/tests/cua-optin.e2e.ts +++ b/apps/web/tests/cua-optin.e2e.ts @@ -53,22 +53,12 @@ it.skipIf(process.platform !== 'darwin').each([ await plugins.getByRole('button', { name: '更多', exact: true }).waitFor() expect(await page.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]').count()).toBe(0) await plugins.getByRole('button', { name: '更多', exact: true }).click() + // The installation ships the bundle off; the Experimental plugins list offers it without an installation step. const card = page.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]') - expect(await card.count()).toBe(0) - expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === '@deepseek-ai/dsh-cua-bundle')) - .toMatchObject({ listedByDefault: false, enabled: false, installed: false }) - await plugins.getByRole('button', { name: '返回插件', exact: true }).click() - const offerCua = async () => { - await plugins.getByRole('button', { name: '添加插件', exact: true }).click() - const dialog = page.getByRole('dialog', { name: '添加插件', exact: true }) - await dialog.getByRole('textbox', { name: '包名或地址', exact: true }).fill('@deepseek-ai/dsh-cua-bundle') - await dialog.getByRole('button', { name: '安装', exact: true }).click() - const ready = page.getByRole('dialog', { name: '已安装', exact: true }) - await ready.waitFor() - expect(await ready.getByText('无需下载,启用即可使用', { exact: true }).count()).toBe(0) - return ready.getByRole('button', { name: '立即启用', exact: true }) - } - const enableNow = await offerCua() + await plugins.locator('[data-plugin-group="more"] [data-plugin-package="@deepseek-ai/dsh-cua-bundle"]').waitFor() + const listed = (await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === '@deepseek-ai/dsh-cua-bundle') + expect(listed).toMatchObject({ availability: 'installation', optional: true, enabled: false, installed: false }) + expect(listed).not.toHaveProperty('listedByDefault') let toggle = card.getByRole('switch', { name: '启用 Computer Use', exact: true }) const originalAgent = scaffold.ctx.agents.list()[0]! const ptcAgent = (await scaffold.ctx.agents.create({ @@ -97,12 +87,10 @@ it.skipIf(process.platform !== 'darwin').each([ return applying }) try { - await enableNow.click() - await expect.poll(() => enableNow.isEnabled()).toBe(false) - expect(await enableNow.getAttribute('aria-busy')).toBe('true') - expect(await card.count()).toBe(0) + await toggle.click() + await card.getByRole('status').getByText('正在应用 Computer Use 的更改', { exact: true }).waitFor() await compareOrRefreshGolden(fileURLToPath(new URL('./expected/cua-permissions/enabling.expected.md', import.meta.url)), - await captureStableAria(page, '[data-install-phase="done"] button[aria-busy]', scaffold.workspaceCwd), webSnapshotMode()) + await captureStableAria(page, '[data-plugin-package="@deepseek-ai/dsh-cua-bundle"] [role="status"]', scaffold.workspaceCwd), webSnapshotMode()) } finally { enabling.resolve(undefined) enableCall.mockRestore() @@ -145,7 +133,7 @@ it.skipIf(process.platform !== 'darwin').each([ expect(scaffold.ctx.tools.schemas(originalAgent).some(tool => tool.name === 'cua')).toBe(preset === 'ptc') } const child = await scaffold.ctx.agents.create({ - sessionId: SessionId('cua-child'), parentAgent: ptcAgent, + sessionId: SessionId('cua-child'), setup: (ctx) => { scaffold.ctx.agentPresets.composeFrom(ctx, ptcAgent.ctx) }, }) expect(scaffold.ctx.tools.schemas(child.agent).some(tool => tool.name === 'cua')).toBe(true) diff --git a/apps/web/tests/cua-permissions.e2e.ts b/apps/web/tests/cua-permissions.e2e.ts index 68925fc144..d4c872ac7b 100644 --- a/apps/web/tests/cua-permissions.e2e.ts +++ b/apps/web/tests/cua-permissions.e2e.ts @@ -42,7 +42,7 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr }) resources.scaffold = scaffold expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === '@deepseek-ai/dsh-cua-bundle')) - .toMatchObject({ listedByDefault: false, enabled: true, installed: true }) + .toMatchObject({ availability: 'installation', enabled: true, installed: true }) const browser = await chromium.launch() resources.browser = browser const page = await newEnglishPage(browser), tripwire = watchConsole(page) diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index 03a22e3c41..afd89b3c77 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -170,7 +170,8 @@ describe('web e2e: the composer model switch is the default for later sessions', expect(popupBounds!.width).toBeCloseTo(commandMenuBounds!.width) expect(popupBounds!.x).toBeCloseTo(commandMenuBounds!.x) expect(await search.getAttribute('placeholder')).toBe('搜索模型…') - await page.getByRole('option').first().waitFor() + // Command menu height changes where the pointer lands in the model popup. + await page.getByRole('option', { name: 'Acme Large', exact: true }).hover() await compareOrRefreshGolden( fileURLToPath(new URL('./expected/default-model/command-picker.expected.md', import.meta.url)), await captureStableAria(page, '[aria-label="/model 选项"]', scaffold.workspaceCwd), diff --git a/apps/web/tests/default-product-isolation.e2e.ts b/apps/web/tests/default-product-isolation.e2e.ts index 4b535448e1..603a35753b 100644 --- a/apps/web/tests/default-product-isolation.e2e.ts +++ b/apps/web/tests/default-product-isolation.e2e.ts @@ -94,7 +94,7 @@ it('activates the actual default Client registry without experimental packages', expect(roster.plugins.some(plugin => plugin.modules.includes('@deepseek-ai/dsh-client-ui-layout'))).toBe(true) expect(experimentalClientReferences(roster)).toEqual([]) const contaminatedHost = await request('mount-experimental-entry') - const experimentalName = '@deepseek-ai/dsh-experimental-client-ui-agent-team' + const experimentalName = '@deepseek-ai/dsh-experimental-client-ui-voice-input' expect(contaminatedHost.client.entries.map(entry => entry.id)).toContain(experimentalName) await page.reload() await expect.poll(async () => { 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-onboarding.e2e.ts b/apps/web/tests/desktop-onboarding.e2e.ts index 24c3ada01e..417edf8bba 100644 --- a/apps/web/tests/desktop-onboarding.e2e.ts +++ b/apps/web/tests/desktop-onboarding.e2e.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { connectDesktopWelcome } from '../../desktop/src/welcome-backend.ts' +import { authenticateWebHost } from '../../desktop/src/web-document.ts' import { chromium, type Browser, type Page } from 'playwright' import { afterEach, beforeEach, describe, expect, it, onTestFailed } from 'vitest' import { credentialKey, credentialRef } from '@deepseek-ai/dsh-credentials' @@ -107,7 +108,7 @@ describe.skipIf(MODE === 'record')('web e2e: App-only desktop onboarding', () => async function desktopPage(locale = ZH_BROWSER_LOCALE): Promise { const opened = await browser.newPage({ viewport: { width: 1280, height: 840 }, locale, reducedMotion: 'reduce' }) - const welcome = await connectDesktopWelcome(scaffold.authenticatedUrl, (input, init) => scaffold.hostFetch(input, init)) + const welcome = connectDesktopWelcome(scaffold.baseUrl, await authenticateWebHost(scaffold.authenticatedUrl)) await opened.exposeFunction('__onboardingHasApiKey', async () => (await welcome.read()).hasApiKey) await opened.addInitScript(() => { Object.defineProperty(globalThis, 'dshOnboarding', { value: { 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/cua-permissions/enabling.expected.md b/apps/web/tests/expected/cua-permissions/enabling.expected.md index b9d6557e3e..44fc57e3e3 100644 --- a/apps/web/tests/expected/cua-permissions/enabling.expected.md +++ b/apps/web/tests/expected/cua-permissions/enabling.expected.md @@ -1 +1 @@ -- button "立即启用" [disabled] +- status: 正在应用 Computer Use 的更改 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/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md index 70fbddd1f1..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 派发工具调用的方式。 @@ -22,11 +22,6 @@ - heading "实验性插件" [level=3] - button "更多" - list: - - listitem: - - button "查看 智能体团队": 智能体团队 - - text: 实验性 让与你对话的智能体把工作分给有名字的队友,在共享任务看板上跟踪进度,并汇总队友的结果向你汇报。 - - status - - switch "启用 智能体团队" - listitem: - button "查看 语音输入": 语音输入 - text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。 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/icons.expected.md b/apps/web/tests/expected/plugin-manager/icons.expected.md index 4510accc6c..f42be11885 100644 --- a/apps/web/tests/expected/plugin-manager/icons.expected.md +++ b/apps/web/tests/expected/plugin-manager/icons.expected.md @@ -1,6 +1,6 @@ Third-party bundle card: image, 36×36, decoded -Disabled Agent Teams card: image, 36×36, decoded -Agent Teams detail: image, 36×36, decoded +Disabled Voice Input card: image, 36×36, decoded +Voice Input detail: image, 36×36, decoded Third-party bundle detail: image, 36×36, decoded Independent search row: image, 30×30, decoded Independent review row: generic artwork 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 5f30f09e75..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 派发工具调用的方式。 @@ -22,11 +22,6 @@ - heading "实验性插件" [level=3] - button "更多" - list: - - listitem: - - button "查看 智能体团队": 智能体团队 - - text: 实验性 让与你对话的智能体把工作分给有名字的队友,在共享任务看板上跟踪进度,并汇总队友的结果向你汇报。 - - status - - switch "启用 智能体团队" - listitem: - button "查看 语音输入": 语音输入 - text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。 diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md index 69c0998762..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 派发工具调用的方式。 @@ -22,11 +22,6 @@ - heading "实验性插件" [level=3] - button "更多" - list: - - listitem: - - button "查看 智能体团队": 智能体团队 - - text: 实验性 让与你对话的智能体把工作分给有名字的队友,在共享任务看板上跟踪进度,并汇总队友的结果向你汇报。 - - status - - switch "启用 智能体团队" - listitem: - button "查看 语音输入": 语音输入 - text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。 diff --git a/apps/web/tests/expected/plugin-manager/refresh.expected.md b/apps/web/tests/expected/plugin-manager/refresh.expected.md index 85d666fd3e..75dda78845 100644 --- a/apps/web/tests/expected/plugin-manager/refresh.expected.md +++ b/apps/web/tests/expected/plugin-manager/refresh.expected.md @@ -6,7 +6,7 @@ Focus at 500ms: - tooltip "刷新" failure: pending - button "刷新" [disabled] -{"busy":"true","spinners":1,"cards":12,"skeletons":0,"alerts":[],"retry":0,"toasts":[]} +{"busy":"true","spinners":1,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":[]} failure: settled - button "刷新" -{"busy":"false","spinners":0,"cards":12,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]} +{"busy":"false","spinners":0,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]} diff --git a/apps/web/tests/expected/sidebar-subagent-activity/compact-running.expected.md b/apps/web/tests/expected/sidebar-subagent-activity/compact-running.expected.md new file mode 100644 index 0000000000..7a26f1bae7 --- /dev/null +++ b/apps/web/tests/expected/sidebar-subagent-activity/compact-running.expected.md @@ -0,0 +1,10 @@ +- banner: + - navigation "Session hierarchy": Delegate a background job. + - button "1 subagent" + - button "1 background job" + - button "More actions" + - button "Open right sidebar" + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" + - button "More conversation views" diff --git a/apps/web/tests/expected/subagent-conversation/migration-required-child.expected.md b/apps/web/tests/expected/subagent-conversation/migration-required-child.expected.md index 0e4f8f018b..b60114ef0f 100644 --- a/apps/web/tests/expected/subagent-conversation/migration-required-child.expected.md +++ b/apps/web/tests/expected/subagent-conversation/migration-required-child.expected.md @@ -1,13 +1,13 @@ # Before opening the child - tree "Subagent sessions": - - treeitem "historical researcher Migration required · not running" [level=1]: - - text: historical researcher Migration required · not running + - treeitem "historical researcher migration-catalog-child · Migration required · not running" [level=1]: + - text: historical researcher migration-catalog-child Migration required - button "Open historical researcher in sidebar" # After opening the child - tree "Subagent sessions": - - treeitem "historical editor Migration required · not running" [level=1]: - - text: historical editor Migration required · not running + - treeitem "historical editor migration-catalog-grandchild · Migration required · not running" [level=1]: + - text: historical editor migration-catalog-grandchild Migration required - button "Open historical editor in sidebar" diff --git a/apps/web/tests/expected/subagent-conversation/navigation-only.expected.md b/apps/web/tests/expected/subagent-conversation/navigation-only.expected.md new file mode 100644 index 0000000000..76df6f6c57 --- /dev/null +++ b/apps/web/tests/expected/subagent-conversation/navigation-only.expected.md @@ -0,0 +1,8 @@ +- dialog "Subagent sessions": + - tree "Subagent sessions": + - treeitem "event-sourcing researcher {{uuid}} · continuable · not running 15.7K tok · {{duration}}" [level=1]: + - button "Expand event-sourcing researcher descendants" + - text: event-sourcing researcher {{uuid}} 15.7K tok {{duration}} + - treeitem "event-sourcing reviewer recorded-one-shot · one-shot · completed 0 tok · {{duration}}" [level=1]: + - text: event-sourcing reviewer recorded-one-shot 0 tok ~6mo 12d + - button "Open event-sourcing reviewer in sidebar" diff --git a/apps/web/tests/expected/subagent-result-notice/en.expected.md b/apps/web/tests/expected/subagent-result-notice/en.expected.md new file mode 100644 index 0000000000..a44d176b78 --- /dev/null +++ b/apps/web/tests/expected/subagent-result-notice/en.expected.md @@ -0,0 +1,41 @@ +- button "Subagent run completed {{clock}}" [expanded]: + - text: Subagent run completed + - time: {{clock}} +- paragraph: This notification triggered this response. +- text: Background subagent notice-result-child finished and will do no further work unless you send it more.Its closing message:## Research result The original child reply stays available. + +- button "Subagent run stopped {{clock}}" [expanded]: + - text: Subagent run stopped + - time: {{clock}} +- paragraph: This notification triggered this response. +- text: Background subagent notice-result-child was stopped before it finished.Its closing message:## Research result The original child reply stays available. + +- button "Subagent run failed {{clock}}" [expanded]: + - text: Subagent run failed + - time: {{clock}} +- paragraph: This notification triggered this response. +- text: Background subagent notice-result-child failed before it finished.Its closing message:## Research result The original child reply stays available. + +- button "Subagent reached the token limit {{clock}}" [expanded]: + - text: Subagent reached the token limit + - time: {{clock}} +- paragraph: This notification triggered this response. +- text: Background subagent notice-result-child ran out of room before it finished.Its closing message:## Research result The original child reply stays available. + +- button "Subagent declined the task {{clock}}" [expanded]: + - text: Subagent declined the task + - time: {{clock}} +- paragraph: This notification triggered this response. +- text: Background subagent notice-result-child declined the task.Its closing message:## Research result The original child reply stays available. + +- button "Subagent run completed {{clock}}" [expanded]: + - text: Subagent run completed + - time: {{clock}} +- paragraph: This notification triggered this response. +- text: Background subagent notice-result-child finished and will do no further work unless you send it more.It left no closing message. + +- button "Subtask status updated {{clock}}" [expanded]: + - text: Subtask status updated + - time: {{clock}} +- paragraph: This notification triggered this response. +- text: Background subagent finished. diff --git a/apps/web/tests/expected/subagent-result-notice/zh.expected.md b/apps/web/tests/expected/subagent-result-notice/zh.expected.md new file mode 100644 index 0000000000..d26df1f590 --- /dev/null +++ b/apps/web/tests/expected/subagent-result-notice/zh.expected.md @@ -0,0 +1,41 @@ +- button "子智能体已完成任务 {{clock}}" [expanded]: + - text: 子智能体已完成任务 + - time: {{clock}} +- paragraph: 这条通知触发了本轮回复。 +- text: Background subagent notice-result-child finished and will do no further work unless you send it more.Its closing message:## Research result The original child reply stays available. + +- button "子智能体任务已停止 {{clock}}" [expanded]: + - text: 子智能体任务已停止 + - time: {{clock}} +- paragraph: 这条通知触发了本轮回复。 +- text: Background subagent notice-result-child was stopped before it finished.Its closing message:## Research result The original child reply stays available. + +- button "子智能体执行失败 {{clock}}" [expanded]: + - text: 子智能体执行失败 + - time: {{clock}} +- paragraph: 这条通知触发了本轮回复。 +- text: Background subagent notice-result-child failed before it finished.Its closing message:## Research result The original child reply stays available. + +- button "子智能体已达 token 上限 {{clock}}" [expanded]: + - text: 子智能体已达 token 上限 + - time: {{clock}} +- paragraph: 这条通知触发了本轮回复。 +- text: Background subagent notice-result-child ran out of room before it finished.Its closing message:## Research result The original child reply stays available. + +- button "子智能体拒绝执行 {{clock}}" [expanded]: + - text: 子智能体拒绝执行 + - time: {{clock}} +- paragraph: 这条通知触发了本轮回复。 +- text: Background subagent notice-result-child declined the task.Its closing message:## Research result The original child reply stays available. + +- button "子智能体已完成任务 {{clock}}" [expanded]: + - text: 子智能体已完成任务 + - time: {{clock}} +- paragraph: 这条通知触发了本轮回复。 +- text: Background subagent notice-result-child finished and will do no further work unless you send it more.It left no closing message. + +- button "子任务状态更新 {{clock}}" [expanded]: + - text: 子任务状态更新 + - time: {{clock}} +- paragraph: 这条通知触发了本轮回复。 +- text: Background subagent finished. diff --git a/apps/web/tests/fold-blank-matrix.e2e.ts b/apps/web/tests/fold-blank-matrix.e2e.ts index c46a62c711..920534b6ca 100644 --- a/apps/web/tests/fold-blank-matrix.e2e.ts +++ b/apps/web/tests/fold-blank-matrix.e2e.ts @@ -623,8 +623,10 @@ async function prepareWorld(browser: Browser, scenario: Case, scaffold: WebScaff const trigger = page.getByRole('button', { name: '1 subagent', exact: true }) await trigger.waitFor({ timeout: 15_000 }) await trigger.hover() - await page.getByRole('treeitem', { name: new RegExp(CHILD_LABEL) }) - .getByRole('button', { name: `Open ${CHILD_LABEL} in sidebar`, exact: true }).click() + const openAside = page.getByRole('treeitem', { name: new RegExp(CHILD_LABEL) }) + .getByRole('button', { name: /^Open .+ in sidebar$/u }) + await expect.poll(() => openAside.count()).toBe(1) + await openAside.click() await page.locator(SIDE_ROOT).getByText('FOLDMX_BODY', { exact: false }).last().waitFor({ timeout: 30_000 }) await page.mouse.move(0, 0) return { diff --git a/apps/web/tests/jobs-menu-layering.e2e.ts b/apps/web/tests/jobs-menu-layering.e2e.ts index 7388c9f4b3..ca2f53e227 100644 --- a/apps/web/tests/jobs-menu-layering.e2e.ts +++ b/apps/web/tests/jobs-menu-layering.e2e.ts @@ -28,7 +28,7 @@ const COMMAND = `printf 'jobs-menu-%s\\n' output; for _ in $(seq 1 3000); do [ - function placement(page: Page) { return page.evaluate(() => { const list = document.querySelector('ul[aria-label="Background jobs"]') - const anchor = document.querySelector('button[aria-label="1 background job running"]') + const anchor = document.querySelector('button[aria-label="1 background job"]') const frame = document.querySelector('[data-sidebar-browser-frame]') const dock = document.querySelector('[data-sidebar-right-panel][data-sidebar-right-open]') if (list === null || anchor === null || frame === null || dock === null) { @@ -105,6 +105,7 @@ describe.skipIf(MODE === 'record')('web e2e: jobs menu layering', () => { it('keeps the list and live output above the Browser dock while anchored and dismissable', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-jobs-menu-layering')) + const initialViewport = page.viewportSize()! const session = page.locator(`[data-row-key="session:${SEED_ID}"]`) await expect.poll(async () => { if (await session.isVisible()) return true @@ -148,7 +149,7 @@ describe.skipIf(MODE === 'record')('web e2e: jobs menu layering', () => { await page.frameLocator('[data-sidebar-browser-frame]') .getByRole('heading', { name: 'Jobs menu browser fixture' }).waitFor() - const trigger = page.getByRole('button', { name: '1 background job running', exact: true }) + const trigger = page.getByRole('button', { name: '1 background job', exact: true }) await trigger.waitFor({ timeout: 15_000 }) await trigger.focus() await page.keyboard.press('Enter') @@ -184,6 +185,13 @@ describe.skipIf(MODE === 'record')('web e2e: jobs menu layering', () => { await list.waitFor({ state: 'detached' }) const outsideDismisses = await trigger.getAttribute('aria-expanded') === 'false' expect(outsideDismisses).toBe(true) + await page.setViewportSize(initialViewport) + await trigger.hover() + await list.waitFor() + expect(await address.evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('Escape') + await list.waitFor({ state: 'detached' }) + expect(await address.evaluate(element => element === document.activeElement)).toBe(true) expect(scaffold.ctx.jobs.get(jobId, agent.id).status).toBe('running') expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) diff --git a/apps/web/tests/live-job-stream.e2e.ts b/apps/web/tests/live-job-stream.e2e.ts index 4bffdb8a62..b51d55ba99 100644 --- a/apps/web/tests/live-job-stream.e2e.ts +++ b/apps/web/tests/live-job-stream.e2e.ts @@ -85,7 +85,7 @@ describe.skipIf(MODE === 'record')('web e2e: live job stream', () => { it('streams a running job\'s real output into the expanded panel, then settles it in place on kill', async () => { let phase = 'streaming' onTestFailed(() => saveFailureShot(page, `web-e2e-live-job-${phase}`)) - const trigger = page.getByRole('button', { name: '1 background job running' }) + const trigger = page.getByRole('button', { name: '1 background job', exact: true }) expect(await trigger.count()).toBe(0) const started = await executeToolFixture(scaffold.ctx, { @@ -99,6 +99,14 @@ describe.skipIf(MODE === 'record')('web e2e: live job stream', () => { const matched = /\bbash-[0-9a-f]{16}\b/.exec(reported) if (matched === null) throw new Error(`background bash reported no job id: ${reported}`) const jobId = JobId(matched[0]) + await expect.poll(() => { + const job = scaffold.ctx.jobs.get(jobId, agent.id) + const output = scaffold.ctx.jobs.readAt(jobId, 0, agent.id).chunks.map(chunk => chunk.text).join('') + return job.status !== 'running' || output.includes('streamed-marker-line') + }, { timeout: 15_000 }).toBe(true) + const readyJob = scaffold.ctx.jobs.get(jobId, agent.id) + const readyOutput = scaffold.ctx.jobs.readAt(jobId, 0, agent.id).chunks.map(chunk => chunk.text).join('') + expect(readyJob.status, `${readyJob.detail ?? ''}\n${readyOutput}`).toBe('running') // The job row reaches the header over the job roster stream. await trigger.waitFor({ timeout: 15_000 }) @@ -116,13 +124,24 @@ describe.skipIf(MODE === 'record')('web e2e: live job stream', () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) + await trigger.click() + await page.getByRole('list', { name: 'Background jobs' }).waitFor({ state: 'detached' }) + expect(await page.getByText('streamed-marker-line', { exact: true }).count()).toBe(0) + await page.mouse.move(2, 2) + await trigger.hover() + await page.getByRole('list', { name: 'Background jobs' }).waitFor() + await page.getByRole('button', { name: /Hide live output/ }).waitFor() + await page.getByText('streamed-marker-line', { exact: true }).waitFor() + expect(scaffold.ctx.jobs.get(jobId, agent.id).status).toBe('running') + phase = 'settled' expect(scaffold.ctx.jobs.kill(jobId, agent.id, 'web e2e cancellation')).toBe('requested') - // Settlement arrives on the observation stream itself, so the open panel - // flips without any further interaction; the roster trigger follows. - const idle = page.getByRole('button', { name: '1 background job', exact: true }) - await idle.waitFor({ timeout: 20_000 }) + // The unchanged count label is not a settlement signal; the output row owns that state. + await page.getByRole('button', { name: 'Finished 1', exact: true }).waitFor({ timeout: 20_000 }) + await expect.poll(() => page.getByRole('list', { name: 'Background jobs' }).textContent()).toContain('web e2e cancellation') + expect(await trigger.getAttribute('aria-label')).toBe('1 background job') + expect(scaffold.ctx.jobs.get(jobId, agent.id).status).toBe('killed') await page.getByText('streamed-marker-line').waitFor({ timeout: 10_000 }) const settled = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) diff --git a/apps/web/tests/math-rendering.e2e.ts b/apps/web/tests/math-rendering.e2e.ts index d7fa2b8873..7f31d0a3fe 100644 --- a/apps/web/tests/math-rendering.e2e.ts +++ b/apps/web/tests/math-rendering.e2e.ts @@ -102,6 +102,13 @@ describe('web e2e: settled Markdown math rendering', () => { tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await page.getByText(DONE, { exact: true }).waitFor({ timeout: 15_000 }) }, 120_000) afterAll(async () => { @@ -111,14 +118,6 @@ describe('web e2e: settled Markdown math rendering', () => { it.skipIf(MODE === 'record')('renders the settled reply without KaTeX errors', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-math-rendering')) - const groupRow = page.locator('[role="treeitem"]').first() - await groupRow.waitFor({ timeout: 15_000 }) - await groupRow.click() - const sessionRow = page.locator('[role="treeitem"]').nth(1) - await sessionRow.waitFor({ timeout: 10_000 }) - await sessionRow.click() - await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1) - await expect.poll(() => page.locator('.katex').count(), { timeout: 10_000 }).toBe(6) await expect.poll(() => page.locator('.katex-display').count(), { timeout: 10_000 }).toBe(2) expect(await page.locator('.katex-error').count()).toBe(0) @@ -141,4 +140,27 @@ describe('web e2e: settled Markdown math rendering', () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) }, 60_000) + + it.skipIf(MODE === 'record')('copies a selection with formulas as TeX source', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-math-copy')) + await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin }) + const sentinel = 'clipboard-before-copy' + await page.evaluate(value => navigator.clipboard.writeText(value), sentinel) + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(sentinel) + await page.evaluate(() => { + const paragraph = [...document.querySelectorAll('p')].find(item => item.textContent.startsWith('Inline dollar'))! + const visual = paragraph.parentElement!.querySelector('.katex-display .katex-html')! + const range = document.createRange() + range.setStart(paragraph.firstChild!, 0) + range.setEnd(visual.firstChild!, 1) + document.getSelection()!.removeAllRanges() + document.getSelection()!.addRange(range) + }) + await page.keyboard.press('ControlOrMeta+C') + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe( + 'Inline dollar $\\theta$ and backslash $\\frac{1}{5}$.\n\n$$\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}$$', + ) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 30_000) }) diff --git a/apps/web/tests/minimal-preset.snapshot.ts b/apps/web/tests/minimal-preset.snapshot.ts index ee65497459..8a131ed29b 100644 --- a/apps/web/tests/minimal-preset.snapshot.ts +++ b/apps/web/tests/minimal-preset.snapshot.ts @@ -142,8 +142,11 @@ describe('minimal agent preset', () => { ], } `) + const requestedTools = new Set(requestHeader.tools?.map(tool => tool.name)) expect(requestHeader.tools?.toSorted((left, right) => left.name.localeCompare(right.name))) - .toEqual(scaffold.ctx.tools.schemas(agentHandle.agent).toSorted((left, right) => left.name.localeCompare(right.name))) + .toEqual(scaffold.ctx.tools.schemas(agentHandle.agent) + .filter(tool => requestedTools.has(tool.name)) + .toSorted((left, right) => left.name.localeCompare(right.name))) }) it.skipIf(MODE === 'record')('expands the completed persistent Bash call in the Web conversation', async () => { diff --git a/apps/web/tests/notice-wake-tail.e2e.ts b/apps/web/tests/notice-wake-tail.e2e.ts index 884adcb9d3..a45a5b85d7 100644 --- a/apps/web/tests/notice-wake-tail.e2e.ts +++ b/apps/web/tests/notice-wake-tail.e2e.ts @@ -170,6 +170,7 @@ describe('web e2e: notice Turn after a long Detailed Turn', () => { kind: 'subagent-settled', form: 'notice', summary: 'Background subagent finished.', + stopReason: 'completed', senderSessionId: SessionId('notice-wake-child'), } const notice = createUserMessage({ diff --git a/apps/web/tests/official-subagent-bundle.e2e.ts b/apps/web/tests/official-subagent-bundle.e2e.ts index 8a21671b29..ef6268cd42 100644 --- a/apps/web/tests/official-subagent-bundle.e2e.ts +++ b/apps/web/tests/official-subagent-bundle.e2e.ts @@ -7,12 +7,13 @@ import { fileURLToPath } from 'node:url' import { chromium, type Browser, type Page } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { startMockLlmServer, type MockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' +import { parseSessionHeader } from '@deepseek-ai/dsh-llm-replay' import type {} from '@deepseek-ai/dsh-plugin-manager' import type {} from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-subagent' import { assertFinalWorkspaceSnapshot, assertFixtureInventory, captureExpandedTurnProcessAria, compareOrRefreshGolden, - fixtureUserPrompts, launchWebScaffold, selectedSessionFixture, watchConsole, webSnapshotMode, type WebScaffold, + fixtureUserPrompts, launchWebScaffold, seedSession, selectedSessionFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -137,6 +138,14 @@ describe.skipIf(MODE === 'record')('web snapshot: Official native subagent bundl const sessionId = await settled const agent = scaffold.ctx.agents.get(sessionId) if (agent === undefined) throw new Error('Native delegation Session has no live Agent') + const firstTurnEvents = agent.session.snapshotEvents() + const toolResults = firstTurnEvents.filter(event => event.type === 'tool/result').map(event => event.data) + expect(toolResults, `PTC execution must start the native child before waiting for its result: ${JSON.stringify(toolResults)}`) + .toMatchObject([{ message: { isError: false } }]) + const dispatches = firstTurnEvents.filter(event => event.type === 'tool/ptc-dispatch').map(event => event.data) + expect(dispatches).toMatchObject([{ name: 'subagent_claude_code', isError: false }]) + const catalog = firstTurnEvents.filter(event => event.type === 'subagent/catalog').map(event => event.data) + expect(catalog).toMatchObject([{ mode: 'external', label: 'Read the fixture response' }]) expect(server.requests).toHaveLength(0) const completed = scaffold.whenTurnSettled(120_000) nativeEndpoint.release() @@ -148,10 +157,6 @@ describe.skipIf(MODE === 'record')('web snapshot: Official native subagent bundl expect(system).toContain('subagent_claude_code') const events = agent.session.snapshotEvents() expect(events.filter(event => event.type === 'tool/call').map(event => event.data.name)).toEqual(['run_code']) - const dispatches = events.filter(event => event.type === 'tool/ptc-dispatch').map(event => event.data) - expect(dispatches).toMatchObject([{ name: 'subagent_claude_code', isError: false }]) - const catalog = events.filter(event => event.type === 'subagent/catalog').map(event => event.data) - expect(catalog).toMatchObject([{ mode: 'external', label: 'Read the fixture response' }]) const notice = events.filter(event => event.type === 'user/message').find(event => event.data.source.kind === 'subagent-settled') if (notice?.data.source.kind !== 'subagent-settled') throw new Error('Native completion was not delivered to the parent') expect(notice.data.source.senderSessionId).toBe(catalog[0]?.childId) @@ -178,3 +183,31 @@ describe.skipIf(MODE === 'record')('web snapshot: Official native subagent bundl ]) }) }) + +it.skipIf(MODE === 'record')('renders recorded official notice titles after reopening the Session', async () => { + const scaffold = await launchWebScaffold({ developerTools: true }) + let browser: Browser | undefined + try { + const fixture = await readFile(await selectedSessionFixture(FIXTURE), 'utf8') + await seedSession(scaffold, fixture, 'official-subagent-recorded-ui') + browser = await chromium.launch() + const page = await newEnglishPage(browser) + await page.clock.setFixedTime(parseSessionHeader(fixture).createdAt) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + const workspace = page.locator('[role="treeitem"]').first() + await workspace.waitFor() + await workspace.click() + const session = page.locator('[role="treeitem"]').nth(1) + await session.waitFor() + await session.click() + const heading = page.locator('[data-turn-trigger] > button > span').nth(1) + await expect.poll(() => heading.textContent()).toBe('Subagent run completed') + await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), + await captureExpandedTurnProcessAria(page, '[data-chat-flow]', scaffold.workspaceCwd), MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } finally { + try { await browser?.close() } finally { await scaffold.close() } + } +}) diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index 7152ad1ef8..2b317f4ec3 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -35,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() @@ -92,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. @@ -142,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 527a1bcc73..66bd3cfb1d 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -9,7 +9,6 @@ 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 { createUserMessage } from '@deepseek-ai/dsh-llm' 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' @@ -857,9 +856,12 @@ describe('web e2e: plugin manager', () => { .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. + const cuaSupported = process.platform === 'darwin' expect(bundles.find(bundle => bundle.name === '@deepseek-ai/dsh-cua-bundle')) - .toMatchObject({ enabled: false, optional: false, availability: 'missing', listedByDefault: false }) - expect(await panel.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]').count()).toBe(0) + .toMatchObject({ enabled: false, optional: true, availability: 'installation', ...cuaSupported ? {} : { listedByDefault: false } }) + expect(await panel.locator('[data-plugin-group="more"] [data-plugin-package="@deepseek-ai/dsh-cua-bundle"]').count()) + .toBe(cuaSupported ? 1 : 0) for (const name of ['@deepseek-ai/dsh-experimental-session-search', '@deepseek-ai/dsh-experimental-ralph-bundle']) { expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === name)) .toMatchObject({ enabled: false, optional: true, availability: 'installation', listedByDefault: false }) @@ -870,13 +872,13 @@ describe('web e2e: plugin manager', () => { await card.getAttribute('data-plugin-package'), await card.getByText('实验性', { exact: true }).count(), ]))).toEqual([ - ['@deepseek-ai/dsh-experimental-agent-team-profile', 1], ['@deepseek-ai/dsh-experimental-voice-input-bundle', 1], ['@deepseek-ai/dsh-experimental-plugin-market-bundle', 1], ['@deepseek-ai/dsh-experimental-auto-review', 1], ['@deepseek-ai/dsh-experimental-forge-bundle', 1], ['@deepseek-ai/dsh-personalized-recommendations', 1], ['@deepseek-ai/dsh-experimental-inspector-profile', 1], + ...cuaSupported ? [['@deepseek-ai/dsh-cua-bundle', 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], @@ -973,7 +975,7 @@ describe('web e2e: plugin manager', () => { onTestFinished(closeSettings) const fixtureIcon = `data:image/svg+xml;base64,${(await readFile(join(FIXTURE_PLUGINS, 'fixture-bundle/icon.svg'))).toString('base64')}` const fallbackIcon = `data:image/svg+xml;base64,${(await readFile(join(FIXTURE_PLUGINS, 'fixture-bundle/fallback-icon.svg'))).toString('base64')}` - const teamIcon = `data:image/svg+xml;base64,${(await readFile(fileURLToPath(new URL('../../../packages/experimental/agent-team-profile/icon.svg', import.meta.url)))).toString('base64')}` + const voiceIcon = `data:image/svg+xml;base64,${(await readFile(fileURLToPath(new URL('../../../packages/experimental/voice-input-bundle/icon.svg', import.meta.url)))).toString('base64')}` const images: string[] = [] const checkImage = async (selector: string, source: string, label: string) => { const image = panel.locator(`${selector} img`).first() @@ -987,9 +989,9 @@ describe('web e2e: plugin manager', () => { images.push(`${label}: image, ${size.width}×${size.height}, decoded`) } await checkImage('[data-plugin-package="@fixture/bundle"]', fixtureIcon, 'Third-party bundle card') - const team = panel.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-agent-team-profile"]') + const team = panel.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-voice-input-bundle"]') expect(await team.getByRole('switch').getAttribute('aria-checked')).toBe('false') - await checkImage('[data-plugin-package="@deepseek-ai/dsh-experimental-agent-team-profile"]', teamIcon, 'Disabled Agent Teams card') + await checkImage('[data-plugin-package="@deepseek-ai/dsh-experimental-voice-input-bundle"]', voiceIcon, 'Disabled Voice Input card') try { for (const colorScheme of ['light', 'dark'] as const) { await page.emulateMedia({ colorScheme }) @@ -1003,8 +1005,8 @@ describe('web e2e: plugin manager', () => { } finally { await page.emulateMedia({ colorScheme: null }) } - await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).click() - await checkImage('[data-plugin-detail]', teamIcon, 'Agent Teams detail') + await panel.getByRole('button', { name: '查看 语音输入', exact: true }).click() + await checkImage('[data-plugin-detail]', voiceIcon, 'Voice Input detail') await panel.getByRole('button', { name: '返回插件' }).click() await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).click() await checkImage('[data-plugin-detail]', fixtureIcon, 'Third-party bundle detail') @@ -1149,95 +1151,56 @@ describe('web e2e: plugin manager', () => { onTestFinished(closeSettings) onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-locale')) const panel = await openPluginsPanel() - await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).click() + await panel.getByRole('button', { name: '查看 语音输入', exact: true }).click() const packageName = panel.locator('[data-plugin-name]') - expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile') - expect(await panel.getByText(experimentalBundleDescription('agent-team-profile', 'zh')).count()).toBe(1) - const child = panel.locator('[data-plugin-row]', { hasText: 'tool-agent-team' }) - await child.getByText('团队工具', { exact: true }).waitFor() - expect(await child.getByText('为智能体提供成员协调、消息通信和共享任务管理工具。', { exact: true }).count()).toBe(1) + expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-voice-input-bundle') + expect(await panel.getByText('在本机使用 SenseVoice 转写录音,首次使用需安装依赖。').count()).toBe(1) try { await setLanguage('en') - await panel.getByRole('heading', { name: 'Agent teams', exact: true }).waitFor() - expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile') - expect(await panel.getByText(experimentalBundleDescription('agent-team-profile', 'en')).count()).toBe(1) - await child.getByText('Team tools', { exact: true }).waitFor() - expect(await child.getByText('Give agents tools to coordinate members, exchange messages, and manage shared tasks.', { exact: true }).count()).toBe(1) + await panel.getByRole('heading', { name: 'Voice input', exact: true }).waitFor() + expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-voice-input-bundle') + expect(await panel.getByText('Transcribe recordings locally with SenseVoice; first use requires installing dependencies').count()).toBe(1) await panel.getByRole('button', { name: 'Back to plugins' }).click() - await panel.getByRole('button', { name: 'View Agent teams', exact: true }).waitFor() - expect(await panel.getByRole('switch', { name: 'Enable Agent teams', exact: true }).count()).toBe(1) + await panel.getByRole('button', { name: 'View Voice input', exact: true }).waitFor() + expect(await panel.getByRole('switch', { name: 'Enable Voice input', exact: true }).count()).toBe(1) // 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 { await setLanguage('zh') } - await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).waitFor() + await panel.getByRole('button', { name: '查看 语音输入', exact: true }).waitFor() expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('enables the Team tools and browser plugin with one bundle switch', async () => { + it('provides Team mode per Session without an experimental plugin switch', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-team')) + onTestFinished(closeSettings) const panel = await openPluginsPanel() - const toggle = panel.getByRole('switch', { name: '启用 智能体团队', exact: true }) - const teamRows = () => [...scaffold.ctx.loader.entries()] - .filter(entry => ['agent-team', 'tool-agent-team', 'ui-agent-team'].includes(entry.options.id)) + expect(await panel.getByRole('switch', { name: '启用 智能体团队', exact: true }).count()).toBe(0) + expect(await panel.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-agent-team-profile"]').count()).toBe(0) + expect([...scaffold.ctx.loader.entries()].filter(entry => + ['agent-team', 'tool-agent-team', 'ui-agent-team'].includes(entry.options.id) + && entry.fiber?.state === FiberState.ACTIVE)).toHaveLength(3) const teamPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) const teamTripwire = watchConsole(teamPage) try { await teamPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await connectFreshWorkspaceZh(teamPage, scaffold.workspaceCwd) - const agent = scaffold.ctx.agents.list()[0] - if (agent === undefined) throw new Error('connected Team workspace did not create an Agent') - // Session actions render only after the conversation leaves its blank state. - agent.session.append('turn/start', { turn: 1 }) - agent.session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'Team UI lifecycle' }], source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await scaffold.ctx.sessions.flush(agent.session) - // The current crumb renders as plain text inside the zh-labeled hierarchy nav. - await teamPage.getByRole('navigation', { name: '会话层级' }) - .getByText('Team UI lifecycle', { exact: true }).waitFor() - const action = teamPage.locator('[data-team-action]') - expect(await action.count()).toBe(0) - await toggle.click() - try { - await expect.poll(() => teamRows().filter(entry => entry.fiber?.state === FiberState.ACTIVE).length, { timeout: 20_000 }).toBe(3) - await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('true') - await action.waitFor({ timeout: 20_000 }) - await action.getByRole('button', { name: '智能体团队', exact: true }).click() - const teamPanel = teamPage.getByRole('dialog', { name: '智能体团队', exact: true }) - await teamPanel.getByText('Team 暂不可用', { exact: true }).waitFor() - await teamPage.reload({ waitUntil: 'load' }) - await action.getByRole('button', { name: '智能体团队', exact: true }).click() - await teamPanel.getByText('暂无共享任务,可以通过对话创建').waitFor() - await teamPanel.getByText('lead', { exact: true }).waitFor() - const manifest = JSON.parse(await homeFile('profiles', 'scaffold', 'package.json')) as { - dsh: { profile: { bundles: string[] } } - } - expect(manifest.dsh.profile.bundles).toEqual([...SCAFFOLD_BUNDLES, '@deepseek-ai/dsh-experimental-agent-team-profile']) - await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).click() - for (const id of ['agent-team', 'tool-agent-team', 'ui-agent-team']) { - await panel.locator('[data-plugin-row]', { hasText: id }).first().waitFor() - } - await panel.getByRole('button', { name: '返回插件' }).click() - } finally { - const back = panel.getByRole('button', { name: '返回插件' }) - if (await back.count() > 0) await back.click() - if (await toggle.getAttribute('aria-checked') === 'true') await toggle.click() - await expect.poll(() => teamRows().filter(entry => entry.fiber?.state === FiberState.ACTIVE).length, { timeout: 20_000 }).toBe(0) - await expect.poll(() => action.count(), { timeout: 20_000 }).toBe(0) - } + await teamPage.getByRole('button', { name: /^选择模型/u }).click() + const mode = teamPage.getByRole('switch', { name: '启用智能体团队协作', exact: true }) + await mode.waitFor() + expect(await mode.getAttribute('aria-checked')).toBe('false') + await mode.click() + await expect.poll(() => mode.getAttribute('aria-checked')).toBe('true') expect(teamTripwire.pageErrors).toEqual([]) expect(teamTripwire.warnings).toEqual([]) } finally { await teamPage.close() } - expect(tripwire.pageErrors).toEqual([]) }, 60_000) it('mounts the delivered Automation tasks rows without a bundle switch', async () => { @@ -1519,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. @@ -1538,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(), { @@ -1552,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(), { diff --git a/apps/web/tests/preset-migration.snapshot.ts b/apps/web/tests/preset-migration.snapshot.ts index 302aea3fdc..cf2aac31b8 100644 --- a/apps/web/tests/preset-migration.snapshot.ts +++ b/apps/web/tests/preset-migration.snapshot.ts @@ -76,10 +76,13 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t expect(resolved.agent.session.snapshotEvents() .filter(event => event.type === 'agent-preset/selected') .map(event => event.data.agentPreset)).toEqual(withSelections ? ['ptc', 'standard', 'ptc'] : []) - expect(await scaffold.ctx.subagents.listChildren(id)).toEqual([ - { id: brokenId, createdAt: childFixture[0]?.['createdAt'], mode: 'unknown' }, - { id: childId, createdAt: childFixture[0]?.['createdAt'], mode: 'one-shot', label: 'historical child' }, - ]) + { + using observed = await scaffold.ctx.sessionQuery.observeSession(id) + expect(observed.projections?.values.subagentCatalog).toEqual([ + { id: brokenId, createdAt: childFixture[0]?.['createdAt'], mode: 'unknown' }, + { id: childId, createdAt: childFixture[0]?.['createdAt'], mode: 'one-shot', label: 'historical child' }, + ]) + } const childSuccessor = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, childId, SESSION_FORMAT_VERSION, 'zstd') await expect(readFile(childSuccessor)).rejects.toMatchObject({ code: 'ENOENT' }) diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index 3cf7695139..f1ea40b25c 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -694,7 +694,6 @@ async function toggleOptionalBundles(page: Page): Promise { }) } return { - cua: bundles.filter(entry => entry.name === '@deepseek-ai/dsh-cua-bundle'), optional: bundles.filter(entry => entry.optional).map(entry => ({ name: entry.name, enabled: entry.enabled, availability: entry.availability, error: entry.error ?? null, title: typeof entry.meta?.title, icon: typeof entry.meta?.icon, metaError: entry.meta?.error ?? null, @@ -706,16 +705,19 @@ async function toggleOptionalBundles(page: Page): Promise { }) expect(result.optional.map(entry => entry.name).sort()).toEqual([...OPTIONAL_BUNDLES].sort()) expect(result.optional.filter(entry => entry.enabled || entry.availability !== 'installation')).toEqual([]) - expect(result.optional.filter(entry => entry.error !== null)).toEqual([]) - expect(result.cua).toHaveLength(1) - expect(result.cua[0]).toMatchObject({ name: '@deepseek-ai/dsh-cua-bundle', optional: false, - availability: 'missing', error: { code: 'unsupported-platform' } }) + expect(result.optional.filter(entry => entry.error !== null)).toEqual([ + expect.objectContaining({ name: '@deepseek-ai/dsh-cua-bundle', error: { code: 'unsupported-platform' } }), + ]) // Metadata resolves through the Worker's Node internal modules: every optional bundle shows its title and icon. expect(result.optional.filter(entry => entry.metaError !== null || entry.title !== 'object' || entry.icon !== 'string')).toEqual([]) expect(result.inventoryErrors).toEqual([]) console.info(`preview boot: optional bundle toggles ${JSON.stringify(result.toggles.map(({ name, enable, disable, error }) => ({ name, enable, disable, error })))}`) - expect(result.toggles.find(toggle => toggle.name === '@deepseek-ai/dsh-cua-bundle')).toBeUndefined() - const supported = result.toggles + expect(result.toggles.find(toggle => toggle.name === '@deepseek-ai/dsh-cua-bundle')).toMatchObject({ + enable: 'failed', disable: 'applied', error: { code: 'unsupported-platform' }, + changed: 0, inactive: [], restored: true, + }) + // Computer Use refuses enablement on this non-macOS worker Host, as asserted above, so the generic check omits it. + const supported = result.toggles.filter(toggle => toggle.name !== '@deepseek-ai/dsh-cua-bundle') expect(supported.filter(toggle => toggle.enable !== 'applied' || toggle.disable !== 'applied') .map(({ name, enable, disable }) => ({ name, enable, disable }))).toEqual([]) // Each bundle changes Loader rows, every enabled row it changes runs, and switching it off restores the prior rows. diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 407f29883e..2b173325e4 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -396,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?: { @@ -690,27 +686,25 @@ 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 }, ] @@ -1776,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[] @@ -1795,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/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 3ea259d0e3..1e0ae9dfad 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -497,8 +497,13 @@ const EXPECTED_TOOLS = [ 'skill', 'subagent', 'subagent_fork', + 'team_task_create', + 'team_task_get', + 'team_task_list', + 'team_task_update', 'todo_write', 'update_goal', + 'wait_agent', 'web_fetch', 'web_search', 'workflow', @@ -592,8 +597,10 @@ it('assembles the shipped Web transport, catalog, guidance, and defaults', async "mode": "always", } `) - // The directory tool is global; the deployment's default preset supplies the agent's remaining tools. - expect(ctx.tools.schemas().map(schema => schema.name)).toEqual(['working_directory']) + // Directory and conditional Team tools are global; presets supply the remaining registrations. + expect(ctx.tools.schemas().map(schema => schema.name)).toEqual([ + 'working_directory', 'team_task_create', 'team_task_list', 'team_task_get', 'team_task_update', + ]) const handle = await ctx.agents.create({ sessionId: SessionId('shipped-composition'), setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined), 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/sidebar-subagent-activity.e2e.ts b/apps/web/tests/sidebar-subagent-activity.e2e.ts index f2c8eb3434..7ae28dd478 100644 --- a/apps/web/tests/sidebar-subagent-activity.e2e.ts +++ b/apps/web/tests/sidebar-subagent-activity.e2e.ts @@ -101,10 +101,8 @@ describe('web e2e: sidebar subagent activity', () => { provider: 'spawn', label: 'sidebar activity child', signal: new AbortController().signal, - request: { - prompt: [{ type: 'text', text: 'Hold this delegated task open.' }], - parent: parentHandle.agent, - }, + prompt: [{ type: 'text', text: 'Hold this delegated task open.' }], + parent: parentHandle.agent, }) childId = started.childId await waitForRunningChild(scaffold, adapter, childId) @@ -147,10 +145,72 @@ describe('web e2e: sidebar subagent activity', () => { ) expect(await ownerRow.locator('[data-state="ongoing"]').count()).toBe(1) await ownerRow.click() - const runningTrigger = page.getByRole('button', { name: '1 subagent running' }) + const runningTrigger = page.getByRole('button', { name: '1 subagent', exact: true }) await runningTrigger.waitFor({ timeout: 10_000 }) expect(await runningTrigger.locator('[data-state="ongoing"]').count()).toBe(1) - await assertFixtureInventory(SNAPSHOT_DIR, ['owner-running.expected.md']) + // This directly created owner has no tool preset; the fixture collects its own job. + scaffold.ctx.effect(() => scaffold.ctx.jobs.attachController('compact header fixture'), 'compact header job controller') + const completion = Promise.withResolvers<{ status: 'completed' }>() + const ownerId = parentHandle.agent.id + const jobId = scaffold.ctx.jobs.start({ + kind: 'bash', label: 'Concurrent background work', owner: ownerId, + run(handle) { + handle.append('Background work is running.\n') + return { done: completion.promise, cancel: () => { completion.resolve({ status: 'completed' }) } } + }, + }) + const viewport = page.viewportSize()! + try { + const jobTrigger = page.getByRole('button', { name: '1 background job', exact: true }) + await jobTrigger.waitFor() + expect(await jobTrigger.evaluate(element => document.getElementById(element.getAttribute('aria-describedby')!)?.textContent)).toBe('Running') + const controls = [ + { trigger: runningTrigger, icon: '[data-icon="dsw-icon-users-outline-regular"]', count: '[data-subagent-count]' }, + { trigger: jobTrigger, icon: '[data-icon="dsw-icon-layers-outline-regular"]', count: '[data-job-count]' }, + ] + for (const { trigger, icon, count } of controls) { + expect(await trigger.locator(count).isVisible()).toBe(true) + expect(await trigger.locator('[data-state="ongoing"]').isVisible()).toBe(true) + expect(await trigger.locator(icon).isVisible()).toBe(false) + } + await runningTrigger.focus() + await page.setViewportSize({ width: 390, height: 700 }) + for (const { trigger, icon, count } of controls) { + await expect.poll(() => trigger.locator(count).isVisible()).toBe(false) + expect(await trigger.locator(icon).isVisible()).toBe(true) + expect(await trigger.locator('[data-state="ongoing"]').isVisible()).toBe(false) + } + expect(await runningTrigger.evaluate(element => element === document.activeElement)).toBe(true) + expect(await jobTrigger.evaluate(element => document.getElementById(element.getAttribute('aria-describedby')!)?.textContent)).toBe('Running') + await compareOrRefreshGolden( + join(SNAPSHOT_DIR, 'compact-running.expected.md'), + await captureStableAria(page, 'header:has([data-subagent-action])', scaffold.workspaceCwd), + MODE, + ) + await saveFailureShot(page, 'web-e2e-compact-running-icons') + await runningTrigger.click() + const members = page.locator('[data-subagent-panel]') + await members.waitFor() + expect(await members.getByRole('treeitem').locator('[data-state="ongoing"]').isVisible()).toBe(true) + await page.keyboard.press('Escape') + await jobTrigger.click() + const jobs = page.getByRole('list', { name: 'Background jobs', exact: true }) + await jobs.waitFor() + expect(await jobs.locator('[data-state="ongoing"]').isVisible()).toBe(true) + await page.keyboard.press('Escape') + await page.setViewportSize(viewport) + for (const { trigger, icon, count } of controls) { + await expect.poll(() => trigger.locator(count).isVisible()).toBe(true) + expect(await trigger.locator('[data-state="ongoing"]').isVisible()).toBe(true) + expect(await trigger.locator(icon).isVisible()).toBe(false) + } + } finally { + completion.resolve({ status: 'completed' }) + await scaffold.ctx.jobs.wait(jobId, 5_000, ownerId) + await page.keyboard.press('Escape') + await page.setViewportSize(viewport) + } + await assertFixtureInventory(SNAPSHOT_DIR, ['owner-running.expected.md', 'compact-running.expected.md']) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }) diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index a980c2d02d..e2992e2a63 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -606,6 +606,7 @@ describe('dsh web keyless CLI smoke', () => { it.each(['native', 'ptc'] as const)('selects the %s executor through profile overlays and ignores the retired environment selector', async (mode) => { requireDist() const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-ptc-')) + const promptMarker = `WEB_EXECUTOR_${mode}_${randomUUID()}` interface PtcModeProviderRequest { system?: string @@ -621,7 +622,11 @@ describe('dsh web keyless CLI smoke', () => { request.setEncoding('utf8') request.on('data', (chunk: string) => { body += chunk }) request.on('end', () => { - resolveProviderRequest(JSON.parse(body) as PtcModeProviderRequest) + const parsed = JSON.parse(body) as PtcModeProviderRequest + const messages = JSON.stringify(parsed.messages ?? []) + if (messages.includes(promptMarker) && !messages.includes(TITLE_PROMPT_MARKER)) { + resolveProviderRequest(parsed) + } response.writeHead(200, { 'content-type': 'text/event-stream' }) response.end(messagesResponse('done')) }) @@ -653,11 +658,19 @@ describe('dsh web keyless CLI smoke', () => { try { const baseUrl = await waitForReadyLine(child) const created = await remoteRpc<{ sessionId: string }>(baseUrl, 'session/create', { request: {} }) + // Title requests share the provider; exercise auxiliary-first arrival explicitly. + await fetch(`http://127.0.0.1:${address.port}`, { + method: 'POST', + body: JSON.stringify({ messages: [{ + role: 'user', + content: [{ type: 'text', text: `${TITLE_PROMPT_MARKER}\n${JSON.stringify([promptMarker])}` }], + }] }), + }).then(response => response.text()) await remoteRpc<{ accepted: true }>(baseUrl, 'session/prompt', { request: { requestId: randomUUID(), sessionId: created.sessionId, mode: 'queue', - content: [{ type: 'text', text: 'go' }], + content: [{ type: 'text', text: promptMarker }], } }) const captured = await Promise.race([ providerRequest, @@ -693,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() @@ -729,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) @@ -747,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/agent-team-panel/collaboration.zh.expected.md b/apps/web/tests/snapshots/agent-team-panel/collaboration.zh.expected.md new file mode 100644 index 0000000000..07a6ac96dc --- /dev/null +++ b/apps/web/tests/snapshots/agent-team-panel/collaboration.zh.expected.md @@ -0,0 +1,17 @@ +## Menu + +- menu "模型与推理等级": + - menuitem "模型 DeepSeek-V4-Flash" + - text: 启用智能体团队协作 + - button "关于智能体团队协作" + - switch "启用智能体团队协作" [checked] + +## Confirmation + +- dialog "确认停用智能体团队协作?": + - heading "确认停用智能体团队协作?" [level=2] + - button "关闭" + - paragraph: 对话过程中停用智能体团队协作,将停止共享任务工具和团队协调指令。 + - paragraph: 停用后可能影响缓存复用、增加后续 token 用量与开销,并影响当前任务的协作效果。 + - button "取消" + - button "确认停用" diff --git a/apps/web/tests/snapshots/agent-team-panel/command-error.en.expected.md b/apps/web/tests/snapshots/agent-team-panel/command-error.en.expected.md new file mode 100644 index 0000000000..f636b75b13 --- /dev/null +++ b/apps/web/tests/snapshots/agent-team-panel/command-error.en.expected.md @@ -0,0 +1,8 @@ +## Failed receipt + +- 'button "Agent teamwork Failed: Use /team on or /team off."' + +## Raw error + +- 'button "Agent teamwork Failed: Use /team on or /team off." [expanded]' +- text: Use /team on or /team off. diff --git a/apps/web/tests/snapshots/agent-team-panel/command-error.zh.expected.md b/apps/web/tests/snapshots/agent-team-panel/command-error.zh.expected.md new file mode 100644 index 0000000000..198f3edb33 --- /dev/null +++ b/apps/web/tests/snapshots/agent-team-panel/command-error.zh.expected.md @@ -0,0 +1,8 @@ +## Failed receipt + +- button "智能体团队协作 执行失败:Use /team on or /team off." + +## Raw error + +- button "智能体团队协作 执行失败:Use /team on or /team off." [expanded] +- text: Use /team on or /team off. diff --git a/apps/web/tests/snapshots/agent-team-panel/disable-confirmation.expected.md b/apps/web/tests/snapshots/agent-team-panel/disable-confirmation.expected.md new file mode 100644 index 0000000000..ae29ea02b2 --- /dev/null +++ b/apps/web/tests/snapshots/agent-team-panel/disable-confirmation.expected.md @@ -0,0 +1,7 @@ +- dialog "Disable agent teamwork?": + - heading "Disable agent teamwork?" [level=2] + - button "Close" + - paragraph: Disabling agent teamwork during a conversation stops the use of shared task tools and team coordination instructions. + - paragraph: Disabling it may affect cache reuse, increase subsequent token usage and costs, and affect collaboration on the current task. + - button "Cancel" + - button "Disable" diff --git a/apps/web/tests/snapshots/agent-team-panel/info.expected.md b/apps/web/tests/snapshots/agent-team-panel/info.expected.md new file mode 100644 index 0000000000..5e675607a5 --- /dev/null +++ b/apps/web/tests/snapshots/agent-team-panel/info.expected.md @@ -0,0 +1 @@ +- tooltip "Coordinate work and progress across agents with a shared task board for better results on complex tasks, with correspondingly higher token costs." diff --git a/apps/web/tests/snapshots/agent-team-panel/resize.expected.md b/apps/web/tests/snapshots/agent-team-panel/resize.expected.md index 57fbab1f84..3e8f7521b2 100644 --- a/apps/web/tests/snapshots/agent-team-panel/resize.expected.md +++ b/apps/web/tests/snapshots/agent-team-panel/resize.expected.md @@ -1,15 +1,26 @@ -- dialog "Agent team": - - heading "Members" [level=3] - - button "lead Current chat Inactive" [disabled] - - heading "Shared tasks 2" [level=3] - - article: - - strong: Agent task - - text: In progress - - paragraph: Created by the Team Lead - - text: "task-1Owner: leadWrite scopes: src/web" - - article: - - strong: Responsive task - - text: Pending - - paragraph: Check the task description before you start work. Check the task description before you start work. Check the task description before you start work. - - button "Show more" - - text: "task-2Owner: UnownedReady" +- dialog "Subagent sessions": + - heading "Subagents" [level=3] + - text: 1 external + - tree "Subagent sessions": + - 'treeitem "Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. team-panel-researcher · External task" [disabled] [level=1]': "Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. team-panel-researcher" + - region "Task board": + - heading "Shared tasks 1 completed · 1 in progress · 2 pending" [level=3] + - list: + - listitem: + - button "In progress Agent task": + - img "In progress" + - text: Agent task + - listitem: + - button "Completed Completed preparation": + - img "Completed" + - text: Completed preparation + - listitem: + - button "Pending Dependency task": + - img "Pending" + - text: Dependency task + - listitem: + - button "Blocked by dependencies Responsive task with a long title that remains fully readable when expanded at the minimum supported viewport" [expanded]: + - img "Blocked by dependencies" + - text: Responsive task with a long title that remains fully readable when expanded at the minimum supported viewport + - paragraph: Check every dependency and write scope before you start work. Check every dependency and write scope before you start work. Check every dependency and write scope before you start work. Check every dependency and write scope before you start work. Check every dependency and write scope before you start work. Check every dependency and write scope before you start work. Check every dependency and write scope before you start work. Check every dependency and write scope before you start work. Check every dependency and write scope before you start work. Check every dependency and write scope before you start work. Check every dependency and write scope before you start work. Check every dependency and write scope before you start work. Check every dependency and write scope before you start work. Check every dependency and write scope before you start work. Check every dependency and write scope before you start work. Check every dependency and write scope before you start work. + - paragraph: "Owner: Unowned" diff --git a/apps/web/tests/snapshots/agent-team-panel/task-only.expected.md b/apps/web/tests/snapshots/agent-team-panel/task-only.expected.md new file mode 100644 index 0000000000..6a663176b6 --- /dev/null +++ b/apps/web/tests/snapshots/agent-team-panel/task-only.expected.md @@ -0,0 +1,11 @@ +- dialog "Subagent sessions": + - heading "Subagents" [level=3] + - paragraph: No subagents + - tree "Subagent sessions" + - region "Task board": + - heading "Shared tasks 1 pending" [level=3] + - list: + - listitem: + - button "Pending Plan before delegation": + - img "Pending" + - text: Plan before delegation diff --git a/apps/web/tests/snapshots/agent-team-panel/task.expected.md b/apps/web/tests/snapshots/agent-team-panel/task.expected.md index d5593a069b..9032c3413b 100644 --- a/apps/web/tests/snapshots/agent-team-panel/task.expected.md +++ b/apps/web/tests/snapshots/agent-team-panel/task.expected.md @@ -1,9 +1,14 @@ -- dialog "Agent team": - - heading "Members" [level=3] - - button "lead Current chat Inactive" [disabled] - - heading "Shared tasks 1" [level=3] - - article: - - strong: Agent task - - text: In progress - - paragraph: Created by the Team Lead - - text: "task-1Owner: leadWrite scopes: src/web" +- dialog "Subagent sessions": + - heading "Subagents" [level=3] + - text: 1 external + - tree "Subagent sessions": + - 'treeitem "Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. team-panel-researcher · External task" [disabled] [level=1]': "Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. team-panel-researcher" + - region "Task board": + - heading "Shared tasks 1 in progress" [level=3] + - list: + - listitem: + - button "In progress Agent task" [expanded]: + - img "In progress" + - text: Agent task + - paragraph: Created by the root agent + - paragraph: "Owner: session-{{uuid}}" diff --git a/apps/web/tests/snapshots/subagent-summary/fork.expected.md b/apps/web/tests/snapshots/subagent-summary/fork.expected.md new file mode 100644 index 0000000000..c94fd252dd --- /dev/null +++ b/apps/web/tests/snapshots/subagent-summary/fork.expected.md @@ -0,0 +1,40 @@ +## Running + +- text: Running +- button "Fork subagent Review inherited conversation context" + +## Creation receipt and complete task + +- button "Fork subagent Review inherited conversation context" [expanded] +- text: Recorded result +- list: + - listitem: + - text: Started + - term: Agent task description + - definition: Review inherited conversation context + - term: ID + - definition: fork-worker + - group: + - text: Task + - list: + - listitem: + - paragraph: Summarize the earlier agreed requirements. Do not modify files. +- button "Inspect" + +## Restored creation receipt + +- button "Fork subagent Review inherited conversation context" [expanded] +- text: Recorded result +- list: + - listitem: + - text: Started + - term: Agent task description + - definition: Review inherited conversation context + - term: ID + - definition: fork-worker + - group: + - text: Task + - list: + - listitem: + - paragraph: Summarize the earlier agreed requirements. Do not modify files. +- button "Inspect" diff --git a/apps/web/tests/snapshots/subagent-summary/lifecycle.expected.md b/apps/web/tests/snapshots/subagent-summary/lifecycle.expected.md new file mode 100644 index 0000000000..105e201da9 --- /dev/null +++ b/apps/web/tests/snapshots/subagent-summary/lifecycle.expected.md @@ -0,0 +1,52 @@ +## Preparing without description + +- text: Preparing tool call Create subagent + +## Preparing with partial description + +- text: Preparing tool call Create subagent Review subagent task summaries + +## Running + +- text: Running +- button "Create subagent Review subagent task summaries" + +## Created + +- button "Create subagent Review subagent task summaries" + +## Creation receipt and complete task + +- button "Create subagent Review subagent task summaries" [expanded] +- text: Recorded result +- list: + - listitem: + - text: Started + - term: Agent task description + - definition: Review subagent task summaries Additional description context + - term: ID + - definition: child-foo + - group: + - text: Task + - list: + - listitem: + - paragraph: Inspect the subagent tool card across its lifecycle. Check streaming arguments, creation receipts, and restored history. Return findings without changing unrelated files. +- button "Inspect" + +## Restored creation receipt + +- button "Create subagent Review subagent task summaries" [expanded] +- text: Recorded result +- list: + - listitem: + - text: Started + - term: Agent task description + - definition: Review subagent task summaries Additional description context + - term: ID + - definition: child-foo + - group: + - text: Task + - list: + - listitem: + - paragraph: Inspect the subagent tool card across its lifecycle. Check streaming arguments, creation receipts, and restored history. Return findings without changing unrelated files. +- button "Inspect" diff --git a/apps/web/tests/snapshots/subagent-summary/variants.expected.md b/apps/web/tests/snapshots/subagent-summary/variants.expected.md new file mode 100644 index 0000000000..b79b9010c4 --- /dev/null +++ b/apps/web/tests/snapshots/subagent-summary/variants.expected.md @@ -0,0 +1,39 @@ +## fallback + +- button "Create subagent Review the fallback summary." [expanded] +- text: Recorded result +- list: + - listitem: + - text: Started + - term: ID + - definition: fallback-worker + - group: + - text: Task + - list: + - listitem: + - paragraph: Review the fallback summary. Keep the complete task in details. +- button "Inspect" + +## reply + +- button "Create subagent Check synchronous agent reply" [expanded] +- text: Recorded result +- list: + - listitem: + - text: Agent response + - paragraph: The synchronous agent found two issues. Both are documented in the report. + - group: + - text: Task + - list: + - listitem: + - paragraph: Review the synchronous report. Keep every instruction available. +- button "Inspect" + +## title-only + +- button "Create subagent" + +## error + +- text: Failed +- button "Create subagent Subagent capacity exceeded" diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 56d7f0e66b..2b9cae7b84 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -5,7 +5,7 @@ import { dirname, join } from 'node:path' import { zstdCompressSync } from 'node:zlib' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished, vi } from 'vitest' import { prepareSessionSnapshotFixtureForComparison } from '@deepseek-ai/dsh-llm-replay' import { SESSION_FORMAT_VERSION, SessionId as sessionId, SessionLogOffset, SessionSeq, @@ -32,7 +32,7 @@ const TREE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-con const BRANCHLESS_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/branchless.expected.md', import.meta.url)) const SIDEBAR_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/sidebar.expected.md', import.meta.url)) const SIDEBAR_CHAT_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/sidebar-chat.expected.md', import.meta.url)) -const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/nested.expected.md', import.meta.url)) +const GRANDCHILD_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/nested.expected.md', import.meta.url)) const FORK_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/fork.expected.md', import.meta.url)) const MIGRATING_CHILD_EXPECTED = fileURLToPath(new URL( './expected/subagent-conversation/migration-required-child.expected.md', import.meta.url, @@ -102,6 +102,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = let browser: Browser let page: Page let sidecarRoot: string + let parentId: SessionId let childId: SessionId let oneShotId: SessionId let grandchildId: SessionId @@ -139,8 +140,9 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) - const parent = scaffold.ctx.agents.roots()[0] + const parent = scaffold.ctx.agents.list().filter(agent => agent.session.header.origin !== 'subagent')[0] if (parent === undefined) throw new Error('fresh workspace did not publish its parent Agent') + parentId = parent.id const parentSettled = scaffold.whenTurnSettled() const parentInput = page.locator('[data-composer-input][contenteditable="true"]').first() await parentInput.fill(PARENT_PROMPT) @@ -152,10 +154,8 @@ describe('web e2e: persisted subagent conversation and human continuation', () = provider: 'spawn', label: LABEL, signal: new AbortController().signal, - request: { - prompt: [{ type: 'text', text: INITIAL_PROMPT }], - parent, - }, + prompt: [{ type: 'text', text: INITIAL_PROMPT }], + parent, }) childId = started.childId await waitForAgentToSettle(scaffold, childId) @@ -309,25 +309,23 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect(scaffold.ctx.agents.get(childId)).toBeUndefined() expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined() expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined() - expect(await scaffold.ctx.subagents.listChildren(parent.id)).toMatchObject([ - { - id: childId, mode: 'continuable', label: LABEL, - }, - { - id: oneShotId, mode: 'one-shot', label: ONE_SHOT_LABEL, - }, - ]) + { + using observed = await scaffold.ctx.sessionQuery.observeSession(parent.id) + expect(observed.projections?.values.subagentCatalog).toMatchObject([ + { + id: childId, mode: 'continuable', label: LABEL, + }, + { + id: oneShotId, mode: 'one-shot', label: ONE_SHOT_LABEL, + }, + ]) + } { using observed = await scaffold.ctx.sessionQuery.observeSession(childId) expect(observed.source).toBe('prepared') expect(observed.projections?.values.subagentCatalog).toEqual([ { id: grandchildId, createdAt: authoredAt, mode: 'continuable', label: NESTED_LABEL }, ]) - expect(await scaffold.ctx.subagents.listChildren(childId)).toMatchObject([ - { - id: grandchildId, mode: 'continuable', label: NESTED_LABEL, - }, - ]) } await waitForCacheRow(scaffold, childHeader) const cache = scaffold.ctx.storageDomain.get(projectionCacheDomainSpec.name) @@ -379,6 +377,65 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await writeComposerDraft(page, input, '') }) + it('enters the member tree after pointer or keyboard activation of its trigger', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-trigger-focus')) + const trigger = page.getByRole('button', { name: '2 subagents', exact: true }) + const panel = page.locator('[data-subagent-panel]') + try { + for (const activation of ['click', 'Enter', 'Space']) { + if (activation === 'click') await trigger.click() + else await trigger.press(activation) + await expect.poll(() => panel.evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('ArrowDown') + expect(await panel.locator('[role="treeitem"]:not([aria-disabled="true"])').first() + .evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('Escape') + await panel.waitFor({ state: 'detached' }) + expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true) + } + } finally { + await page.locator('[data-composer-input]').first().hover() + } + }) + + it('shows complete descriptions and IDs without hover at desktop and touch widths', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-catalog-labels')) + const originalViewport = page.viewportSize()! + const originalUrl = page.url() + try { + for (const width of [originalViewport.width, 390]) { + await page.setViewportSize({ ...originalViewport, width }) + const trigger = page.getByRole('button', { name: '2 subagents', exact: true }) + await trigger.click() + const panel = page.locator('[data-subagent-panel]') + const row = panel.getByRole('treeitem', { name: new RegExp(LABEL) }) + const primary = row.getByText(LABEL, { exact: true }) + const secondary = row.getByText(childId, { exact: true }) + const primaryBounds = await primary.boundingBox() + const secondaryBounds = await secondary.boundingBox() + expect(primaryBounds).not.toBeNull() + expect(secondaryBounds).not.toBeNull() + expect(primaryBounds!.y + primaryBounds!.height).toBeLessThanOrEqual(secondaryBounds!.y) + for (const label of [primary, secondary]) { + expect(await label.evaluate(element => element.scrollWidth <= element.clientWidth + && element.scrollHeight <= element.clientHeight)).toBe(true) + } + expect(await row.getAttribute('aria-label')).toContain(`${LABEL} ${childId} `) + expect(await row.getByRole('button', { name: /description/u }).count()).toBe(0) + expect(await row.getByRole('button', { name: `Expand ${LABEL} descendants`, exact: true }).count()).toBe(1) + expect(await row.getByRole('button', { name: `Open ${LABEL} in sidebar`, exact: true }).count()).toBe(1) + expect(page.url()).toBe(originalUrl) + expect(await page.getByRole('tooltip').count()).toBe(0) + expect(scaffold.ctx.agents.get(childId)).toBeUndefined() + await page.keyboard.press('Escape') + await panel.waitFor({ state: 'detached' }) + } + } finally { + await page.setViewportSize(originalViewport) + await page.locator('[data-composer-input]').first().hover() + } + }) + it('recovers a current-format cold child catalog cache miss without activating either level', async () => { onTestFailed(() => saveFailureShot(page, 'screenshots/non-migrating-session-reads/web-e2e-subagent-tree')) await page.getByRole('button', { name: '2 subagents' }).hover() @@ -388,6 +445,8 @@ describe('web e2e: persisted subagent conversation and human continuation', () = const hit = document.elementFromPoint(rect.left + 8, rect.top + 8) return hit !== null && element.contains(hit) })).toBe(true) + const sectionHeader = page.locator('[data-subagent-section-header]') + expect(await sectionHeader.count()).toBe(0) const oneShotDisclosure = page.getByRole('button', { name: `Expand ${ONE_SHOT_LABEL} descendants`, }) @@ -396,22 +455,112 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect(await oneShotRow.locator('[data-state="done"]').count()).toBe(1) expect(await oneShotRow.getByText('~6mo 12d', { exact: true }).count()).toBe(1) expect(await oneShotRow.getAttribute('aria-label')).toContain('192d 00h 00m 00s') + const childRow = page.getByRole('treeitem', { name: new RegExp(LABEL) }) + const disclosure = childRow.getByRole('button', { name: new RegExp(`^(Expand|Collapse) ${LABEL} descendants$`, 'u') }) + const beforeDisclosureUrl = page.url() + const clickDisclosureGutter = async (offset: number): Promise => { + const geometry = await disclosure.evaluate((button, xOffset) => { + const row = button.closest('[role="treeitem"]')! + const rowBounds = row.getBoundingClientRect() + const buttonBounds = button.getBoundingClientRect() + const glyph = getComputedStyle(button.querySelector('svg')!) + const target = { x: rowBounds.left + xOffset, y: rowBounds.bottom - 3 } + const pseudo = getComputedStyle(button, '::after') + const status = row.querySelector('[data-state]')!.getBoundingClientRect() + return { + target, + outsideButton: target.x < buttonBounds.left || target.x > buttonBounds.right + || target.y < buttonBounds.top || target.y > buttonBounds.bottom, + pseudo: { width: pseudo.width, height: pseudo.height, top: pseudo.top, bottom: pseudo.bottom, left: pseudo.left }, + rowHeight: rowBounds.height, + glyph: { width: glyph.width, height: glyph.height }, + transform: getComputedStyle(button).transform, + hitsDisclosure: document.elementFromPoint(target.x, target.y) === button, + missesNextRow: document.elementFromPoint(target.x, rowBounds.bottom + 1) !== button, + endsBeforeStatus: rowBounds.left + 28 <= status.left, + missesStatus: document.elementFromPoint(status.left + status.width / 2, status.top + status.height / 2) !== button, + } + }, offset) + expect(geometry.outsideButton, JSON.stringify(geometry)).toBe(true) + expect(geometry.pseudo).toEqual({ width: '28px', height: `${geometry.rowHeight}px`, top: '0px', bottom: '0px', left: '0px' }) + expect(geometry.glyph).toEqual({ width: '12px', height: '12px' }) + expect(geometry.transform).toBe('none') + expect(geometry.hitsDisclosure).toBe(true) + expect(geometry.missesNextRow).toBe(true) + expect(geometry.endsBeforeStatus).toBe(true) + expect(geometry.missesStatus).toBe(true) + const body = childRow.locator(':scope > div') + await childRow.getByText(LABEL, { exact: true }).hover() + expect(await body.evaluate(element => getComputedStyle(element).backgroundColor)).not.toBe('rgba(0, 0, 0, 0)') + await page.mouse.move(geometry.target.x, geometry.target.y) + expect(await body.evaluate(element => getComputedStyle(element).backgroundColor)).toBe('rgba(0, 0, 0, 0)') + await page.mouse.click(geometry.target.x, geometry.target.y) + expect(page.url()).toBe(beforeDisclosureUrl) + expect(await page.getByText(PARENT_PROMPT, { exact: true }).isVisible()).toBe(true) + expect(await page.locator('[data-sidebar-chat]').count()).toBe(0) + } const observe = vi.spyOn(scaffold.ctx.sessionQuery, 'observeSession') try { const projections = page.waitForResponse(response => new URL(response.url()).pathname === '/api/session/projections') - await page.getByRole('button', { name: `Expand ${LABEL} descendants` }).click() + await clickDisclosureGutter(3) await projections await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor({ timeout: 15_000 }) + expect(await sectionHeader.count()).toBe(0) expect(observe.mock.calls.some(([id]) => id === childId)).toBe(true) expect(observe.mock.calls.some(([id]) => id === grandchildId)).toBe(false) } finally { observe.mockRestore() } - const childRow = page.getByRole('treeitem', { name: new RegExp(LABEL) }) + await clickDisclosureGutter(26) + await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor({ state: 'detached' }) + expect(await childRow.getAttribute('aria-expanded')).toBe('false') + await clickDisclosureGutter(3) + await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor() + expect(await childRow.getAttribute('aria-expanded')).toBe('true') + const connector = await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).evaluate((row) => { + const rowBounds = row.getBoundingClientRect() + const dot = row.querySelector('[data-state]')! + const dotBounds = dot.getBoundingClientRect() + const slot = dot.parentElement!.getBoundingClientRect() + const horizontal = getComputedStyle(row, '::before') + const node = row.parentElement! + const spine = getComputedStyle(node, '::before') + const stroke = Number.parseFloat(horizontal.borderTopWidth) + const colorProbe = document.createElement('span') + colorProbe.style.cssText = 'display:none;color:var(--dsw-alias-border-l3)' + row.append(colorProbe) + let expectedColor: string + try { + expectedColor = getComputedStyle(colorProbe).color + } finally { + colorProbe.remove() + } + return { + rowTop: rowBounds.top, + dotCenter: dotBounds.top + dotBounds.height / 2, + lineCenter: rowBounds.top + Number.parseFloat(horizontal.top) + stroke / 2, + lineEnd: rowBounds.left + Number.parseFloat(horizontal.left) + Number.parseFloat(horizontal.width), + slotLeft: slot.left, + spineEnd: node.getBoundingClientRect().top + Number.parseFloat(spine.top) + Number.parseFloat(spine.height), + lastSibling: node.nextElementSibling === null, + stroke, + color: horizontal.borderTopColor, + spineColor: spine.borderLeftColor, + expectedColor, + } + }) + expect(connector.lastSibling).toBe(true) + expect(Math.abs(connector.lineCenter - connector.dotCenter), JSON.stringify(connector)).toBeLessThanOrEqual(0.5) + expect(Math.abs(connector.spineEnd - connector.dotCenter), JSON.stringify(connector)).toBeLessThanOrEqual(0.5) + expect(Math.abs(connector.lineEnd - connector.slotLeft), JSON.stringify(connector)).toBeLessThanOrEqual(0.5) + expect(connector.stroke).toBeGreaterThanOrEqual(0.5) + expect(connector.stroke).toBeLessThanOrEqual(1) + expect(connector.color).toBe(connector.expectedColor) + expect(connector.spineColor).toBe(connector.expectedColor) const childLabel = await childRow.getAttribute('aria-label') await page.waitForTimeout(1_100) expect(await childRow.getAttribute('aria-label')).toBe(childLabel) - expect(await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).textContent()).toContain('continuable') + expect(await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).getAttribute('aria-label')).toContain('continuable') expect(scaffold.ctx.agents.get(childId)).toBeUndefined() expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined() const snapshot = await captureStableAria( @@ -420,7 +569,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = scaffold.workspaceCwd, ) await compareOrRefreshGolden(TREE_EXPECTED, snapshot, MODE) - await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).click() + await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).getByText(grandchildId, { exact: true }).click() await page.getByText(NESTED_PROMPT).waitFor() const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' }) await hierarchy.getByRole('button', { name: `Switch subagent: ${LABEL}`, exact: true }).waitFor() @@ -435,7 +584,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await page.getByRole('button', { name: '2 subagents' }).hover() await page.getByRole('treeitem', { name: new RegExp(LABEL) }).getByRole('button', { name: `Open ${LABEL} in sidebar`, exact: true }).click() const sidebarChat = page.locator('[data-sidebar-chat]') - await sidebarChat.getByText(/^Explain event sourcing in one sentence\.Your parent agent id is /).waitFor({ timeout: 15_000 }) + await sidebarChat.getByText(/^Explain event sourcing in one sentence\./).waitFor({ timeout: 15_000 }) await compareOrRefreshGolden( SIDEBAR_CHAT_EXPECTED, await captureStableAria(page, '[data-sidebar-chat]', scaffold.workspaceCwd), @@ -456,16 +605,34 @@ describe('web e2e: persisted subagent conversation and human continuation', () = it('opens the completed child from persistence without activating it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-open')) await page.getByRole('button', { name: '2 subagents' }).hover() - await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() + await page.getByRole('treeitem', { name: new RegExp(LABEL) }).getByText(LABEL, { exact: true }).click() await expect.poll( - () => page.getByText(/^Explain event sourcing in one sentence\.Your parent agent id is /).count(), + () => page.getByText(/^Explain event sourcing in one sentence\./).count(), { timeout: 15_000 }, ).toBe(1) if (scaffold.ctx.agents.get(childId) !== undefined) { throw new Error(`viewing the child activated it; API calls: ${apiCalls.join(', ')}`) } const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' }) - await hierarchy.getByRole('button', { name: `Switch subagent: ${LABEL}` }).waitFor() + const currentCrumb = hierarchy.getByRole('button', { name: `Switch subagent: ${LABEL}` }) + await currentCrumb.waitFor() + const crumbGeometry = await Promise.all([hierarchy.getByRole('button').first(), currentCrumb].map(crumb => crumb.evaluate((element) => { + const style = getComputedStyle(element) + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT) + let text = walker.nextNode() + while (text !== null && text.textContent?.trim() === '') text = walker.nextNode() + if (text === null) throw new Error('Breadcrumb has no title text') + const range = document.createRange() + range.selectNodeContents(text) + const bounds = range.getBoundingClientRect() + return { fontSize: style.fontSize, lineHeight: style.lineHeight, top: bounds.top, bottom: bounds.bottom } + }))) + expect(crumbGeometry.map(({ fontSize, lineHeight }) => ({ fontSize, lineHeight }))) + .toEqual([{ fontSize: '14px', lineHeight: '20px' }, { fontSize: '14px', lineHeight: '20px' }]) + expect(Math.abs(crumbGeometry[0]!.top - crumbGeometry[1]!.top), JSON.stringify(crumbGeometry)).toBeLessThanOrEqual(1) + expect(Math.abs(crumbGeometry[0]!.bottom - crumbGeometry[1]!.bottom), JSON.stringify(crumbGeometry)).toBeLessThanOrEqual(1) + expect(await hierarchy.getByRole('button', { name: '1 subagent', exact: true }) + .evaluate(element => getComputedStyle(element).fontSize)).toBe('12px') const sidebar = await captureStableAria( page, '[role="tree"][aria-label="Sessions"]', @@ -494,7 +661,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await expect.poll(() => requested, { timeout: 15_000 }).toBe(true) expect(await page.getByText('This subagent is read-only for now', { exact: true }).count()).toBe(0) expect(await page.getByRole('navigation', { name: 'Session hierarchy' }).count()).toBe(0) - expect(await page.getByText(/^Explain event sourcing in one sentence\.Your parent agent id is /).count()).toBe(0) + expect(await page.getByText(/^Explain event sourcing in one sentence\./).count()).toBe(0) releaseCatalog() await page.getByRole('button', { name: `Switch subagent: ${LABEL}` }).waitFor({ timeout: 15_000 }) const input = page.getByRole('textbox', { name: 'Message or run a task, / commands, @ files or sessions' }) @@ -549,7 +716,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect(tripwire.warnings).toEqual([]) }) - it('opens an unavailable persisted grandchild after recording the available child', async () => { + it('opens a persisted grandchild with input enabled while its creator is offline', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-grandchild')) await page.getByRole('button', { name: '1 subagent' }).hover() const tree = page.getByRole('tree', { name: 'Subagent sessions' }) @@ -562,7 +729,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = const clickArea = nestedRow.locator(':scope > div') expect(await clickArea.count()).toBe(1) const [menuBox, clickAreaBox] = await Promise.all([ - tree.locator('..').boundingBox(), + page.locator('[data-subagent-panel]').boundingBox(), clickArea.boundingBox(), ]) expect(menuBox).not.toBeNull() @@ -570,27 +737,23 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect([ Math.round(clickAreaBox!.x - menuBox!.x), Math.round(menuBox!.x + menuBox!.width - clickAreaBox!.x - clickAreaBox!.width), - // The outer menu padding insets the scrollable tree rows. - ]).toEqual([4, 4]) + // Menu padding plus the branchless row's one-pixel leading inset. + ]).toEqual([7, 6]) await compareOrRefreshGolden( BRANCHLESS_EXPECTED, await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd), MODE, ) - await nestedRow.click() - await page.getByText('The parent session is offline; reopen it to continue sending messages.').waitFor() - // The offline banner renders from the descriptor alone, so it says nothing - // about the transcript below it. The golden pins that transcript, and - // `captureStableAria` calls two identical polls stable — including two of - // "Loading history…". Wait for the message the golden asserts. + await nestedRow.getByText(grandchildId, { exact: true }).click() await page.getByText(NESTED_PROMPT).waitFor() + expect(await page.getByRole('textbox', { name: 'Message or run a task, / commands, @ files or sessions' }).isEnabled()).toBe(true) const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' }) const crumbs = await hierarchy.getByRole('button').allTextContents() expect(crumbs.slice(-2)).toEqual([LABEL, NESTED_LABEL]) expect(scaffold.ctx.agents.get(childId)).toBeUndefined() expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined() await compareOrRefreshGolden( - UNAVAILABLE_GRANDCHILD_EXPECTED, + GRANDCHILD_EXPECTED, await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), MODE, ) @@ -602,7 +765,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = .getByRole('treeitem', { name: /Ask a research subagent to/ }) await parentSession.click() await page.getByRole('button', { name: '2 subagents' }).hover() - await page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }).click() + await page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }).getByText(oneShotId, { exact: true }).click() await page.getByText('One-shot tasks do not accept follow-ups; review the full execution record here.').waitFor() expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined() }) @@ -613,7 +776,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = .getByRole('treeitem', { name: /Ask a research subagent to/ }) .click() await page.getByRole('button', { name: '2 subagents' }).hover() - await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() + await page.getByRole('treeitem', { name: new RegExp(LABEL) }).getByText(childId, { exact: true }).click() await page.getByRole('textbox', { name: 'Message or run a task, / commands, @ files or sessions' }).waitFor() const forkResponse = page.waitForResponse(response => new URL(response.url()).pathname === '/api/session/fork') @@ -642,7 +805,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = const sessions = page.getByRole('tree', { name: 'Sessions' }) await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click() await page.getByRole('button', { name: '2 subagents' }).hover() - await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() + await page.getByRole('treeitem', { name: new RegExp(LABEL) }).getByText(childId, { exact: true }).click() await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor() expect(scaffold.ctx.agents.get(childId)).toBeUndefined() @@ -659,7 +822,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click() await page.getByRole('button', { name: '2 subagents' }).press('ArrowDown') - await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() + await page.getByRole('treeitem', { name: new RegExp(LABEL) }).getByText(childId, { exact: true }).click() const input = page.locator('[data-composer-input][contenteditable="true"]').first() await input.waitFor() const promptResponse = page.waitForResponse(response => @@ -683,6 +846,56 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect(scaffold.ctx.agents.get(forkId)).not.toBeUndefined() await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 10_000 }).toBeUndefined() }) + + it('shows shared tasks only in the root conversation while keeping child navigation', async () => { + const parent = scaffold.ctx.agents.get(parentId) + if (parent === undefined) throw new Error('task-board fixture requires the live parent') + const enabled = scaffold.ctx.agentTeams.isEnabled(parent) + onTestFinished(() => scaffold.ctx.agentTeams.setEnabled(parent, enabled)) + await scaffold.ctx.agentTeams.setEnabled(parent, true) + await scaffold.ctx.agentTeams.createTask(parent, { + subject: 'Root-only shared task', description: 'Inspect from the root conversation.', writeScopes: [], + }) + await page.getByRole('tree', { name: 'Sessions' }) + .getByRole('treeitem', { name: /Ask a research subagent to/ }).click() + await page.getByRole('button', { name: '2 subagents', exact: true }).click() + await page.locator('[data-team-panel]').getByText('Root-only shared task', { exact: true }).waitFor() + const rootHeader = page.locator('[data-subagent-section-header]') + expect(await rootHeader.textContent()).toBe('Subagents1 completed\u2002·\u20021 not running') + expect(await rootHeader.getByRole('button').count()).toBe(0) + expect(await rootHeader.locator('[data-icon="dsw-icon-users-outline-regular"]').count()).toBe(1) + const progress = await rootHeader.textContent() + await page.getByRole('button', { name: `Expand ${LABEL} descendants`, exact: true }).click() + await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor() + expect(await rootHeader.textContent()).toBe(progress) + await page.getByRole('treeitem', { name: new RegExp(LABEL) }).getByText(childId, { exact: true }).click() + const switcher = page.getByRole('button', { name: `Switch subagent: ${LABEL}`, exact: true }) + await switcher.click() + const panel = page.locator('[data-subagent-panel]') + await panel.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }).waitFor() + expect(await panel.locator('[data-team-panel]').count()).toBe(0) + expect(await panel.evaluate(element => element.getBoundingClientRect().width)).toBe(360) + const header = panel.locator('[data-subagent-section-header]') + expect(await header.count()).toBe(0) + expect(await switcher.textContent()).toBe(LABEL) + const currentRow = panel.getByRole('treeitem', { name: new RegExp(LABEL) }) + const currentCheck = currentRow.locator('[data-subagent-current]') + expect(await currentRow.getAttribute('aria-current')).toBe('true') + expect(await currentCheck.getAttribute('aria-hidden')).toBe('true') + expect(await currentCheck.evaluate(element => element.tagName)).toBe('SPAN') + expect(await currentCheck.locator('button, [role="button"], [tabindex]').count()).toBe(0) + expect(await currentRow.getByRole('button', { name: `Open ${LABEL} in sidebar`, exact: true }).count()).toBe(0) + const otherRow = panel.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }) + expect(await otherRow.locator('[data-subagent-current]').count()).toBe(0) + expect(await otherRow.getByRole('button', { name: `Open ${ONE_SHOT_LABEL} in sidebar`, exact: true }).isEnabled()).toBe(true) + await compareOrRefreshGolden( + fileURLToPath(new URL('./expected/subagent-conversation/navigation-only.expected.md', import.meta.url)), + await captureStableAria(page, '[data-subagent-panel]', scaffold.workspaceCwd), MODE, + ) + await page.keyboard.press('Escape') + }) + + }) describe('web e2e: migration-required subagent catalog', () => { @@ -795,7 +1008,7 @@ describe('web e2e: migration-required subagent catalog', () => { expect(observe.mock.calls.some(([id]) => id === childId || id === grandchildId)).toBe(false) expect(projections).not.toHaveBeenCalled() - await childRow.click() + await childRow.getByText(childId, { exact: true }).click() await page.getByRole('button', { name: `Switch subagent: ${childLabel}`, exact: true }).waitFor() await page.getByRole('button', { name: '1 subagent', exact: true }).hover() const grandchildRow = tree.getByRole('treeitem', { name: new RegExp(grandchildLabel) }) diff --git a/apps/web/tests/subagent-fork-schedule.snapshot.ts b/apps/web/tests/subagent-fork-schedule.snapshot.ts index 1a674ab30b..00678b48f4 100644 --- a/apps/web/tests/subagent-fork-schedule.snapshot.ts +++ b/apps/web/tests/subagent-fork-schedule.snapshot.ts @@ -48,7 +48,7 @@ it.skipIf(MODE === 'record')('replays inherited schedule tools and refusals in a const activation = await scaffold.ctx.subagents.startActivation({ provider: 'fork', label: 'Check child reminder refusal', delivery: 'caller', signal: new AbortController().signal, - request: { parent, prompt: [...childPrompt.data.content] }, + parent, prompt: [...childPrompt.data.content], }) try { await activation.result diff --git a/apps/web/tests/subagent-interrupt-ui.e2e.ts b/apps/web/tests/subagent-interrupt-ui.e2e.ts index 7afeaa9a10..8c5f8fb1ea 100644 --- a/apps/web/tests/subagent-interrupt-ui.e2e.ts +++ b/apps/web/tests/subagent-interrupt-ui.e2e.ts @@ -1,7 +1,7 @@ // Web e2e scenario: the composer's independent Stop interrupts a running // continuable child. The child holds its model turn open through a replay -// hang entry; the browser proves Send and Stop coexist, the parent-offline -// disabled-Send-with-Stop composer, the subagents/interruptByParent +// hang entry; the browser proves Send and Stop coexist regardless of the +// creator's list availability, the subagents/interruptByUser // (never session.cancel) transport, the parked follow-up, and the FIFO resume // on a waking send. // @@ -144,7 +144,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) - const root = scaffold.ctx.agents.roots()[0] + const root = scaffold.ctx.agents.list().filter(agent => agent.session.header.origin !== 'subagent')[0] if (root === undefined) throw new Error('fresh workspace did not publish its parent Agent') parent = root // The child's first model call claims the primary override and holds. @@ -153,7 +153,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co provider: 'spawn', label: LABEL, signal: new AbortController().signal, - request: { prompt: [{ type: 'text', text: INITIAL }], parent }, + prompt: [{ type: 'text', text: INITIAL }], parent, }) childId = started.childId await waitFor(() => existsSync(readyFile), 'the held child turn to open') @@ -188,7 +188,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co if (failures.length > 1) throw new AggregateError(failures, 'subagent interrupt UI teardown failed') }) - it('interrupts the live child through the parent-offline composer', async () => { + it('queues and interrupts the live child despite its creator being listed offline', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-interrupt-offline')) // The parent is unavailable in the Session list while the child stays live. const pattern = '**/api/session/list' @@ -210,31 +210,22 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) acknowledgeReloadConnectionLoss(tripwire, warningStart) await page.getByRole('button', { name: /1 subagent/ }).click() - await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() - const input = page.getByRole('textbox', { - name: 'Parent session offline; sending is unavailable but you can still stop the run', - }) + await page.getByRole('treeitem', { name: new RegExp(LABEL) }).press('Enter') + const input = page.locator('[data-composer-input]').first() await input.waitFor({ timeout: 15_000 }) - await page.getByText(/^Explain event sourcing in one sentence\.Your parent agent id is /) + await page.getByText(/^Explain event sourcing in one sentence\./) .waitFor({ timeout: 15_000 }) - expect(await input.isDisabled()).toBe(true) + expect(await input.isDisabled()).toBe(false) const stop = page.getByRole('button', { name: 'Stop generating' }) expect(await stop.count()).toBe(1) expect(await stop.isEnabled()).toBe(true) - const send = page.getByRole('button', { name: 'Send message' }) - expect(await send.count()).toBe(1) - expect(await send.isDisabled()).toBe(true) - // Keep the continuable Activation resident after this first abort. The - // direct setup queue also proves the ordinary row controls remain - // available while this parent-offline composer cannot submit new input. - await scaffold.ctx.subagents.prompt({ - requestId: 'interrupt-ui-rearm' as SubagentPromptRequestId, - parentSessionId: parent.id, - childSessionId: childId, - mode: 'continuable', - delivery: 'queue', - content: [{ type: 'text', text: REARM }], - }, new AbortController().signal) + const promptResponse = page.waitForResponse(response => + new URL(response.url()).pathname === '/api/subagents/prompt') + await input.fill(REARM) + const send = page.getByRole('button', { name: 'Queue message' }) + expect(await send.isEnabled()).toBe(true) + await send.click() + expect(((await (await promptResponse).json()) as { result: { ok: boolean } }).result).toMatchObject({ ok: true }) await page.getByRole('button', { name: 'Edit queued message' }).waitFor({ timeout: 15_000 }) expect(await page.getByRole('button', { name: 'Remove queued message' }).count()).toBe(1) expect(await page.getByRole('button', { name: 'Steer queued message' }).count()).toBe(1) @@ -246,7 +237,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co const aborted = waitForAbortedTurn(scaffold, childId) const interruptResponse = page.waitForResponse(response => - new URL(response.url()).pathname === '/api/subagents/interruptByParent') + new URL(response.url()).pathname === '/api/subagents/interruptByUser') await stop.click() expect(((await (await interruptResponse).json()) as { result: { ok: boolean; value?: { accepted: boolean } } @@ -256,12 +247,10 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co await expect.poll(() => scaffold.ctx.agents.get(childId)?.status, { timeout: 15_000 }).toBe('idle') // Wake the parked setup message only after cancellation converges. A - // second hang keeps the parent-available case independent from this stop. + // second hang keeps the next interrupt case independent from this stop. await scaffold.ctx.subagents.prompt({ requestId: 'interrupt-ui-rearm-wake' as SubagentPromptRequestId, - parentSessionId: parent.id, childSessionId: childId, - mode: 'continuable', delivery: 'queue', content: [{ type: 'text', text: REARM_WAKE }], }, new AbortController().signal) @@ -272,9 +261,8 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co } }, 60_000) - it('interrupts through subagents/interruptByParent, parks the follow-up, and resumes it FIFO', async () => { + it('interrupts through subagents/interruptByUser, parks the follow-up, and resumes it FIFO', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-interrupt-flow')) - // A new connection reloads the truthful parent-availability hint. const warningStart = tripwire.warnings.length await page.reload({ waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) @@ -282,7 +270,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co await page.getByRole('navigation', { name: 'Session hierarchy' }) .getByRole('button').first().click() await page.getByRole('button', { name: /1 subagent/ }).click() - await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() + await page.getByRole('treeitem', { name: new RegExp(LABEL) }).press('Enter') // A live continuable child advertises the ordinary steer-all gesture, so // that placeholder is the composer's accessible name in this window. It // changes back as the queue drains, so later interactions address the @@ -343,7 +331,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co const stop = page.getByRole('button', { name: 'Stop generating' }) expect(await stop.count()).toBe(1) const interruptResponse = page.waitForResponse(response => - new URL(response.url()).pathname === '/api/subagents/interruptByParent') + new URL(response.url()).pathname === '/api/subagents/interruptByUser') await stop.click() expect(((await (await interruptResponse).json()) as { result: { ok: boolean; value?: { accepted: boolean } } @@ -378,7 +366,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []) expect(userTexts[0]).toBe(INITIAL) - expect(userTexts[1]).toMatch(/^Your parent agent id is .+send_message\(\{ agent_id: /) + expect(userTexts[1]).toMatch(/^Your final answer is delivered automatically if your parent is still online\./) expect(userTexts.slice(2)).toEqual([REARM, REARM_WAKE, EDITED_FOLLOWUP, WAKING]) const imageMessage = events.find(event => event.type === 'user/message' && event.data.content.some(block => block.type === 'image')) diff --git a/apps/web/tests/subagent-interrupt.e2e.ts b/apps/web/tests/subagent-interrupt.e2e.ts index a1b1284a19..526e115437 100644 --- a/apps/web/tests/subagent-interrupt.e2e.ts +++ b/apps/web/tests/subagent-interrupt.e2e.ts @@ -70,7 +70,7 @@ function textCompletion(text: string): object { } } -describe.skipIf(MODE === 'record')('web e2e: subagents/interruptByParent over the real composition', () => { +describe.skipIf(MODE === 'record')('web e2e: subagents/interruptByUser over the real composition', () => { let scaffold: WebScaffold let sidecarRoot: string let readyFile: string @@ -113,7 +113,7 @@ describe.skipIf(MODE === 'record')('web e2e: subagents/interruptByParent over th provider: 'spawn', label: 'event-sourcing researcher', signal: new AbortController().signal, - request: { prompt: [{ type: 'text', text: INITIAL }], parent }, + prompt: [{ type: 'text', text: INITIAL }], parent, }) childId = started.childId // The hang entry writes readyFile after its prefix chunks, immediately @@ -134,9 +134,7 @@ describe.skipIf(MODE === 'record')('web e2e: subagents/interruptByParent over th const queued = await remote<{ messageId: string }>(scaffold, 'subagents/prompt', { request: { requestId: randomUUID(), - parentSessionId: parentId, childSessionId: childId, - mode: 'continuable', delivery: 'queue', content: [{ type: 'text', text: FOLLOWUP }], }, @@ -144,10 +142,8 @@ describe.skipIf(MODE === 'record')('web e2e: subagents/interruptByParent over th expect(queued).toMatchObject({ ok: true }) const settled = scaffold.whenTurnSettled() - const interrupted = await remote<{ accepted: true }>(scaffold, 'subagents/interruptByParent', { + const interrupted = await remote<{ accepted: true }>(scaffold, 'subagents/interruptByUser', { childSessionId: childId, - parentSessionId: parentId, - mode: 'continuable', }) expect(interrupted).toMatchObject({ ok: true, value: { accepted: true } }) // accepted acknowledges the admitted cancel, not quiescence: wait for the @@ -169,9 +165,7 @@ describe.skipIf(MODE === 'record')('web e2e: subagents/interruptByParent over th const waking = await remote<{ messageId: string }>(scaffold, 'subagents/prompt', { request: { requestId: randomUUID(), - parentSessionId: parentId, childSessionId: childId, - mode: 'continuable', delivery: 'queue', content: [{ type: 'text', text: WAKING }], }, @@ -189,7 +183,7 @@ describe.skipIf(MODE === 'record')('web e2e: subagents/interruptByParent over th ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []) expect(userTexts[0]).toBe(INITIAL) - expect(userTexts[1]).toMatch(/^Your parent agent id is .+send_message\(\{ agent_id: /) + expect(userTexts[1]).toMatch(/^Your final answer is delivered automatically if your parent is still online\./) expect(userTexts.slice(2)).toEqual([FOLLOWUP, WAKING]) const turnEndKinds = events .filter(event => event.type === 'turn/end') diff --git a/apps/web/tests/subagent-result-notice.e2e.ts b/apps/web/tests/subagent-result-notice.e2e.ts new file mode 100644 index 0000000000..cff9482035 --- /dev/null +++ b/apps/web/tests/subagent-result-notice.e2e.ts @@ -0,0 +1,129 @@ +/** Keyless browser coverage of structured subagent outcomes and historical notice fallback. */ +import { mkdir } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium, type Browser, type BrowserContext } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { createSettlementMessage } from '@deepseek-ai/dsh-subagent/src/continuation-messages.ts' +import type { SubagentStopReason } from '@deepseek-ai/dsh-subagent' +import { + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, +} from './scaffold.ts' +import { connectFreshWorkspace, connectFreshWorkspaceZh } from './support.ts' + +const DIR = fileURLToPath(new URL('./expected/subagent-result-notice/', import.meta.url)) +const MODE = webSnapshotMode() +const OUTPUT = '## Research result\nThe original child reply stays available.' +const OUTCOMES = [ + { reason: 'completed', en: 'Subagent run completed', zh: '子智能体已完成任务' }, + { reason: 'aborted', en: 'Subagent run stopped', zh: '子智能体任务已停止' }, + { reason: 'error', en: 'Subagent run failed', zh: '子智能体执行失败' }, + { reason: 'max-tokens', en: 'Subagent reached the token limit', zh: '子智能体已达 token 上限' }, + { reason: 'refusal', en: 'Subagent declined the task', zh: '子智能体拒绝执行' }, +] as const satisfies readonly { reason: SubagentStopReason; en: string; zh: string }[] + +describe.skipIf(MODE === 'record')('web e2e: subagent result notices', () => { + let browser: Browser + beforeAll(async () => { browser = await chromium.launch() }) + afterAll(async () => { await browser?.close() }) + + it.each(['en', 'zh'] as const)('shows recorded outcomes and preserves notice bodies in %s', async (locale) => { + const scaffold = await launchWebScaffold() + let context: BrowserContext | undefined + try { + context = await browser.newContext({ + locale: locale === 'en' ? 'en-US' : 'zh-CN', timezoneId: 'Asia/Shanghai', viewport: { width: 1280, height: 900 }, + }) + const page = await context.newPage() + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await (locale === 'en' ? connectFreshWorkspace : connectFreshWorkspaceZh)(page, scaffold.workspaceCwd) + const agent = scaffold.ctx.agents.list()[0] + if (agent === undefined) throw new Error('Notice fixture has no connected Agent') + const session = agent.session + const appendTurn = (turn: number, message: ReturnType): void => { + const waking = message.source.kind !== 'user' + if (waking) session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [message] }) + session.append('turn/start', { turn }) + if (waking) session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }) + session.append('step/start', { turn, step: 1 }) + session.append('user/message', message, { surfaceOp: 'append' }) + session.append('assistant/message', { + turn, step: 1, stream: [], message: createAssistantMessage({ + content: [{ type: 'text', text: `Acknowledged ${turn}.` }], source: { provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn, step: 1 }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } + appendTurn(1, createUserMessage({ content: [{ type: 'text', text: 'Inspect child execution results.' }], source: { kind: 'user' } })) + const childId = SessionId('notice-result-child') + const notices: { title: string; message: ReturnType }[] = OUTCOMES.map(outcome => ({ + title: outcome[locale], + message: createSettlementMessage(childId, { + stopReason: outcome.reason, output: [{ type: 'text', text: OUTPUT }], + }), + })) + notices.push({ + title: OUTCOMES[0][locale], + message: createSettlementMessage(childId, { stopReason: 'completed', output: [] }), + }) + notices.push({ + title: locale === 'en' ? 'Subtask status updated' : '子任务状态更新', + message: createUserMessage({ + source: { kind: 'subagent-settled', form: 'notice', senderSessionId: childId, summary: 'Background subagent finished.' }, + content: [{ type: 'text', text: 'Background subagent finished.' }], + }), + }) + for (const [index, notice] of notices.entries()) appendTurn(index + 2, notice.message) + await scaffold.ctx.sessions.flush(session) + const snapshots: string[] = [] + for (const [index, notice] of notices.entries()) { + const selector = `[data-chat-turn="${index + 2}"] [data-turn-trigger]` + const card = page.locator(selector) + await card.waitFor({ state: 'attached' }) + const header = card.locator('button').first() + await expect.poll(() => header.locator(':scope > span').nth(1).textContent()).toBe(notice.title) + await header.click() + const original = notice.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('') + expect(await card.locator('[data-context-text]').textContent()).toBe(original) + snapshots.push(await captureStableAria(page, selector, scaffold.workspaceCwd)) + await header.click() + } + await page.setViewportSize({ width: 390, height: 844 }) + for (const card of await page.locator('[data-turn-trigger]').all()) { + const geometry = await card.evaluate((element) => { + const header = element.querySelector('button')! + const title = header.querySelectorAll('span')[1]! + const time = header.querySelector('time')! + return { + fits: element.scrollWidth <= element.clientWidth, + separate: title.getBoundingClientRect().right <= time.getBoundingClientRect().left, + } + }) + expect(geometry).toEqual({ fits: true, separate: true }) + } + const warningCount = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await page.locator('[data-turn-trigger]').first().waitFor({ state: 'attached' }) + for (const [index, notice] of notices.entries()) { + await expect.poll(() => page.locator(`[data-chat-turn="${index + 2}"] [data-turn-trigger] > button`).textContent()) + .toContain(notice.title) + } + acknowledgeReloadConnectionLoss(tripwire, warningCount) + if (MODE === 'refresh') await mkdir(DIR, { recursive: true }) + await compareOrRefreshGolden(join(DIR, `${locale}.expected.md`), snapshots.join('\n\n'), MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } finally { + try { await context?.close() } finally { await scaffold.close() } + } + }) + + it('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(DIR, ['en.expected.md', 'zh.expected.md']) + }) +}) diff --git a/apps/web/tests/subagent-summary.e2e.ts b/apps/web/tests/subagent-summary.e2e.ts new file mode 100644 index 0000000000..5d3c564b09 --- /dev/null +++ b/apps/web/tests/subagent-summary.e2e.ts @@ -0,0 +1,271 @@ +/** Keyless browser coverage of subagent task summaries and durable creation receipts. */ +import { mkdir } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Agent, AssistantStreamFrame } from '@deepseek-ai/dsh-agent' +import { + AssistantStreamAccumulator, LlmAttemptId, ToolCallId, + createAssistantMessage, createToolResultMessage, createUserMessage, + type StreamChunk, +} from '@deepseek-ai/dsh-llm' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { Browser, Locator, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/subagent-summary/', import.meta.url)) +const MODE = webSnapshotMode() +const DESCRIPTION = 'Review subagent task summaries' +const PROMPT = 'Inspect the subagent tool card across its lifecycle.\nCheck streaming arguments, creation receipts, and restored history.\nReturn findings without changing unrelated files.' +const SOURCE = { provider: 'deepseek-official', model: 'deepseek-v4-flash' } + +async function snapshotGolden(name: string, actual: string): Promise { + if (MODE === 'refresh') await mkdir(SNAPSHOT_DIR, { recursive: true }) + await compareOrRefreshGolden(join(SNAPSHOT_DIR, `${name}.expected.md`), actual, MODE) +} + +function startTurn(agent: Agent, turn: number): void { + agent.session.append('turn/start', { turn }) + agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: `Inspect subagent summary case ${turn}.` }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + agent.session.append('step/start', { turn, step: 1 }) +} + +function appendCall(agent: Agent, turn: number, callId: ToolCallId, args: string, name = 'subagent'): SessionEvent<'tool/call'> { + agent.session.append('assistant/message', { + turn, step: 1, stream: [], + message: createAssistantMessage({ + content: [{ type: 'tool-call', id: callId, name, arguments: args }], source: SOURCE, + }), + }, { surfaceOp: 'append' }) + return agent.session.append('tool/call', { turn, step: 1, callId, name, arguments: args }) +} + +function finishCall(agent: Agent, call: SessionEvent<'tool/call'>, text: string, isError = false, meta?: SessionEvent<'tool/result'>['data']['meta']): void { + const { turn, step, callId } = call.data + agent.session.append('tool/result', { + turn, step, message: createToolResultMessage({ callId, content: [{ type: 'text', text }], isError }), + ...meta === undefined ? {} : { meta }, + }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] }) + agent.session.append('step/end', { turn, step }) + agent.session.append('turn/end', { turn, reason: { kind: 'completed' } }) +} + +function toolSelector(callId: ToolCallId): string { + return `[data-chat-call-id="${callId}"] [data-tool="subagent"]` +} + +async function expectSummary(card: Locator, text: string): Promise { + const header = card.locator('[data-disclosure-row]').first() + await expect.poll(() => header.textContent(), { timeout: 10_000 }).toBe(`Create subagent${text}`) +} + +async function expandTask(card: Locator, prompt: string): Promise { + await expandOwningTurnProcess(card.page(), card) + const toggle = card.locator('[data-expandable]').first() + if (await toggle.getAttribute('aria-expanded') !== 'true') await toggle.click() + const task = card.locator('details').filter({ has: card.page().locator('summary', { hasText: /^Task$/u }) }) + await task.waitFor() + if (await task.getAttribute('open') === null) await task.locator('summary').click() + await expect.poll(() => task.locator('p').textContent()).toBe(prompt) +} + +describe.skipIf(MODE === 'record')('web e2e: subagent task summary', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let agent: Agent + + beforeAll(async () => { + scaffold = await launchWebScaffold() + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.locator('[class*="frame"]').waitFor() + await connectFreshWorkspace(page, scaffold.workspaceCwd) + const connected = scaffold.ctx.agents.list()[0] + if (connected === undefined) throw new Error('Subagent summary fixture has no connected Agent') + agent = connected + }) + + afterAll(async () => { + try { await browser?.close() } finally { await scaffold?.close() } + }) + + it('streams the description and preserves it through creation and restored history', async () => { + const callId = ToolCallId('subagent-summary-described') + const selector = toolSelector(callId) + const card = page.locator(selector) + const snapshots: string[] = [] + const capture = async (stage: string): Promise => { + await expandOwningTurnProcess(page, card) + snapshots.push(`## ${stage}\n\n${await captureStableAria(page, selector, scaffold.workspaceCwd)}`) + } + startTurn(agent, 1) + await scaffold.ctx.sessions.flush(agent.session) + await page.locator('[data-chat-turn="1"]').getByText('Inspect subagent summary case 1.', { exact: true }).waitFor() + + const attemptId = LlmAttemptId('subagent-summary-stream') + const stream = new AssistantStreamAccumulator() + let revision = 0 + let index = 0 + const emit = (frame: AssistantStreamFrame): void => { scaffold.ctx.emit('agent/assistant-stream', { agent, frame }) } + const push = (chunk: StreamChunk): void => { + const time = Date.now() + stream.push({ time, chunk }) + emit({ type: 'chunk', attemptId, revision: ++revision, index: index++, time, chunk }) + } + emit({ type: 'start', attemptId, revision: ++revision, turn: 1, step: 1 }) + push({ type: 'block-start', index: 0, blockType: 'tool-call' }) + push({ type: 'tool-call-delta', index: 0, id: callId, name: 'subagent', argumentsDelta: `{"prompt":${JSON.stringify(PROMPT)},` }) + await card.waitFor({ state: 'attached' }) + await expandOwningTurnProcess(page, card) + await expect.poll(() => card.getAttribute('data-state')).toBe('preparing') + await expectSummary(card, '') + await capture('Preparing without description') + push({ type: 'tool-call-delta', index: 0, id: callId, argumentsDelta: '"description":"Review subagent' }) + await expectSummary(card, 'Review subagent') + push({ type: 'tool-call-delta', index: 0, id: callId, argumentsDelta: ' task summaries\\nAdditional description context' }) + await expectSummary(card, DESCRIPTION) + await capture('Preparing with partial description') + push({ type: 'tool-call-delta', index: 0, id: callId, argumentsDelta: '"}' }) + const args = JSON.stringify({ prompt: PROMPT, description: `${DESCRIPTION}\nAdditional description context` }) + const block = { type: 'tool-call' as const, id: callId, name: 'subagent', arguments: args } + push({ type: 'block-end', index: 0, block }) + push({ type: 'finish', reason: { kind: 'tool-calls' } }) + const message = agent.session.append('assistant/message', { + turn: 1, step: 1, stream: [...stream.snapshot()], + message: createAssistantMessage({ content: [block], source: SOURCE }), + }, { surfaceOp: 'append' }) + emit({ type: 'end', attemptId, revision: ++revision, index, outcome: { kind: 'committed', eventType: 'assistant/message', seq: message.seq } }) + const call = agent.session.append('tool/call', { turn: 1, step: 1, callId, name: 'subagent', arguments: args }) + await scaffold.ctx.sessions.flush(agent.session) + await expect.poll(() => card.getAttribute('data-state')).toBe('running') + await expectSummary(card, DESCRIPTION) + await capture('Running') + + finishCall(agent, call, 'Delegation accepted.', false, { kind: 'activation', subagentId: 'child-foo' }) + await scaffold.ctx.sessions.flush(agent.session) + await expect.poll(() => card.getAttribute('data-state')).toBe('ok') + await expectSummary(card, DESCRIPTION) + await capture('Created') + await expandTask(card, PROMPT) + await expectSummary(card, DESCRIPTION) + expect(await card.getByText('Agent response', { exact: true }).count()).toBe(0) + await card.getByText('Started', { exact: true }).waitFor() + expect(await card.locator('dt').allTextContents()).toEqual(['Agent task description', 'ID']) + expect(await card.locator('dd').allTextContents()).toEqual([`${DESCRIPTION}\nAdditional description context`, 'child-foo']) + await capture('Creation receipt and complete task') + + const beforeReload = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await card.waitFor({ state: 'attached' }) + await expandOwningTurnProcess(page, card) + await expectSummary(card, DESCRIPTION) + await expandTask(card, PROMPT) + await expectSummary(card, DESCRIPTION) + expect(await card.getByText('Agent response', { exact: true }).count()).toBe(0) + await capture('Restored creation receipt') + acknowledgeReloadConnectionLoss(tripwire, beforeReload) + await snapshotGolden('lifecycle', snapshots.join('\n\n')) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + + it('falls back to the prompt, keeps synchronous replies in details, and preserves errors', async () => { + const cases = [ + { id: 'fallback', args: { prompt: 'Review the fallback summary.\nKeep the complete task in details.' }, summary: 'Review the fallback summary.', result: 'started subagent fallback-worker' }, + { id: 'reply', args: { description: 'Check synchronous agent reply', prompt: 'Review the synchronous report.\nKeep every instruction available.' }, summary: 'Check synchronous agent reply', result: 'The synchronous agent found two issues.\nBoth are documented in the report.' }, + { id: 'title-only', args: {}, summary: '', result: 'started subagent empty-worker' }, + { id: 'error', args: { description: 'Delegate a failing task', prompt: 'Attempt the requested delegation.' }, summary: 'Subagent capacity exceeded', result: 'Subagent capacity exceeded\nWait for an active agent to finish.', error: true }, + ] + const snapshots: string[] = [] + for (const [index, fixture] of cases.entries()) { + const turn = index + 2 + const callId = ToolCallId(`subagent-summary-${fixture.id}`) + const selector = toolSelector(callId) + const card = page.locator(selector) + startTurn(agent, turn) + const call = appendCall(agent, turn, callId, JSON.stringify(fixture.args)) + await scaffold.ctx.sessions.flush(agent.session) + await card.waitFor({ state: 'attached' }) + await expandOwningTurnProcess(page, card) + await expectSummary(card, fixture.args.description ?? fixture.args.prompt?.split('\n')[0] ?? '') + finishCall(agent, call, fixture.result, fixture.error) + await scaffold.ctx.sessions.flush(agent.session) + await expect.poll(() => card.getAttribute('data-state')).toBe(fixture.error === true ? 'error' : 'ok') + await expectSummary(card, fixture.summary) + if (fixture.args.prompt !== undefined && fixture.error !== true) { + await expandTask(card, fixture.args.prompt) + await expectSummary(card, fixture.summary) + if (fixture.id === 'reply') { + await card.getByText('Agent response', { exact: true }).waitFor() + await card.getByText('The synchronous agent found two issues.', { exact: false }).waitFor() + await card.getByText('Both are documented in the report.', { exact: false }).waitFor() + expect(await card.getByText('Started', { exact: true }).count()).toBe(0) + } else expect(await card.getByText('Agent response', { exact: true }).count()).toBe(0) + } + await expandOwningTurnProcess(page, card) + snapshots.push(`## ${fixture.id}\n\n${await captureStableAria(page, selector, scaffold.workspaceCwd)}`) + } + await snapshotGolden('variants', snapshots.join('\n\n')) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + + it('presents fork creation with a distinct title, task summary, and restored receipt', async () => { + const name = 'subagent_fork' + const callId = ToolCallId('subagent-fork-summary') + const selector = `[data-chat-call-id="${callId}"] [data-tool="${name}"]` + const card = page.locator(selector) + const description = 'Review inherited conversation context' + const prompt = 'Summarize the earlier agreed requirements.\nDo not modify files.' + const snapshots: string[] = [] + const capture = async (stage: string): Promise => { + snapshots.push(`## ${stage}\n\n${await captureStableAria(page, selector, scaffold.workspaceCwd)}`) + } + const expectForkSummary = async (): Promise => { + await expect.poll(() => card.locator('[data-disclosure-row]').first().textContent()) + .toBe(`Fork subagent${description}`) + } + startTurn(agent, 6) + const call = appendCall(agent, 6, callId, JSON.stringify({ description, prompt }), name) + await scaffold.ctx.sessions.flush(agent.session) + await card.waitFor({ state: 'attached' }) + await expandOwningTurnProcess(page, card) + await expect.poll(() => card.getAttribute('data-state')).toBe('running') + await expectForkSummary() + await capture('Running') + finishCall(agent, call, 'started subagent fork-worker') + await scaffold.ctx.sessions.flush(agent.session) + await expect.poll(() => card.getAttribute('data-state')).toBe('ok') + await expandTask(card, prompt) + await expectForkSummary() + expect(await card.locator('dt').allTextContents()).toEqual(['Agent task description', 'ID']) + expect(await card.locator('dd').allTextContents()).toEqual([description, 'fork-worker']) + expect(await card.getByText('Agent response', { exact: true }).count()).toBe(0) + await capture('Creation receipt and complete task') + const beforeReload = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await card.waitFor({ state: 'attached' }) + await expandTask(card, prompt) + await expectForkSummary() + await capture('Restored creation receipt') + acknowledgeReloadConnectionLoss(tripwire, beforeReload) + await snapshotGolden('fork', snapshots.join('\n\n')) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + + it('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['lifecycle.expected.md', 'variants.expected.md', 'fork.expected.md']) + }) +}) diff --git a/apps/web/tests/support-timezone.e2e.ts b/apps/web/tests/support-timezone.e2e.ts index 37e4de79b1..706c96a49d 100644 --- a/apps/web/tests/support-timezone.e2e.ts +++ b/apps/web/tests/support-timezone.e2e.ts @@ -1,6 +1,6 @@ import { chromium } from 'playwright' import { expect, it } from 'vitest' -import { newEnglishPage } from './support.ts' +import { newEnglishPage, pinBrowserClock, pinHostClock, WEB_FIXTURE_TIME } from './support.ts' it.each(['UTC', 'America/Los_Angeles'])('isolates the recorded browser timezone from %s', async (hostTimeZone) => { const browser = await chromium.launch({ env: { ...process.env, TZ: hostTimeZone } }) @@ -20,3 +20,40 @@ it.each(['UTC', 'America/Los_Angeles'])('isolates the recorded browser timezone await browser.close() } }) + +it('keeps browser timers and animation frames live with the Host date pinned, including reload', async () => { + const originalNow = Date.now + const hostClock = pinHostClock() + try { + const browser = await chromium.launch() + try { + const page = await newEnglishPage(browser) + await pinBrowserClock(page) + await page.goto('data:text/html,clock') + let previousTime = WEB_FIXTURE_TIME + for (const reload of [false, true]) { + if (reload) await page.reload() + const now = await page.evaluate(() => Date.now()) + if (reload) expect(now).toBeGreaterThan(previousTime) + else expect(now).toBeGreaterThanOrEqual(previousTime) + expect(now).toBeLessThan(WEB_FIXTURE_TIME + 60_000) + previousTime = now + await page.evaluate(() => { + setTimeout(() => { document.body.dataset.timer = 'fired' }, 0) + requestAnimationFrame(() => { document.body.dataset.frame = 'fired' }) + }) + await expect.poll(() => page.evaluate(() => ({ + timer: document.body.dataset.timer, frame: document.body.dataset.frame, + })), { timeout: 5_000 }).toEqual({ timer: 'fired', frame: 'fired' }) + } + await page.close() + const nextPage = await newEnglishPage(browser) + expect(await nextPage.evaluate(() => Math.abs(Date.now() - new Date().getTime()))).toBeLessThan(100) + } finally { + await browser.close() + } + } finally { + hostClock.mockRestore() + } + expect(Date.now).toBe(originalNow) +}) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index ad741f474d..048e3e1f6d 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -25,6 +25,36 @@ export function experimentalBundleDescription(bundle: string, locale: string): s const installationRequire = createRequire(join(REPO_ROOT, 'apps/cli/package.json')) +/** + * Check the shared warning title, theme color, and compact body alignment. + * @param dialog - visible warning dialog under test. + */ +export async function assertWarningDialogLayout(dialog: Locator): Promise { + const layout = await dialog.evaluate((element) => { + const title = element.querySelector('h2')! + const icon = title.querySelector('svg')! + const paragraph = element.querySelector('p')! + const titleRect = title.getBoundingClientRect() + const bodyRect = paragraph.getBoundingClientRect() + const color = getComputedStyle(icon).color + const previous = icon.style.color + icon.style.color = 'var(--dsw-alias-state-warn-primary)' + const warningColor = getComputedStyle(icon).color + icon.style.color = previous + return { + decorative: icon.getAttribute('aria-hidden'), + matchesWarningColor: color === warningColor, + leftGap: Math.abs(bodyRect.left - titleRect.left), + topGap: bodyRect.top - titleRect.bottom, + } + }) + expect(layout.decorative).toBe('true') + expect(layout.matchesWarningColor).toBe(true) + expect(layout.leftGap).toBeLessThan(1) + expect(layout.topGap).toBeGreaterThanOrEqual(0) + expect(layout.topGap).toBeLessThanOrEqual(16) +} + /** * The built copy of a workspace package, as the dsh installation resolves it. * The Host plugins a scaffold profile loads run from built packages through 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/workflow-run.e2e.ts b/apps/web/tests/workflow-run.e2e.ts index e43a7c7fb8..7fbd53a759 100644 --- a/apps/web/tests/workflow-run.e2e.ts +++ b/apps/web/tests/workflow-run.e2e.ts @@ -220,7 +220,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = await page.getByRole('button', { name: '1 subagent', exact: true }).hover() const catalog = page.getByRole('tree', { name: 'Subagent sessions' }) - await catalog.getByRole('treeitem', { name: /snapshot-flow child 1/ }).click() + await catalog.getByRole('treeitem', { name: /snapshot-flow child 1/ }).press('Enter') await page.getByText(CHILD_PROMPT, { exact: true }).waitFor({ timeout: 15_000 }) expect(await page.locator('[data-composer-input][contenteditable="true"]').count()).toBe(1) }, 60_000) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 47acf9c48c..9e08211588 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -49,6 +49,8 @@ "tests/code-card-layout.e2e.ts", "tests/code-language.snapshot.ts", "tests/scaffold.ts", + "tests/subagent-summary.e2e.ts", + "tests/subagent-result-notice.e2e.ts", "tests/clock.e2e.ts", "tests/connection-recovery.e2e.ts", "tests/server-restart.e2e.ts", @@ -131,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/benchmarks/agent-continuation/child-catalog.worker.ts b/benchmarks/agent-continuation/child-catalog.worker.ts index aedb279937..34808c2d85 100644 --- a/benchmarks/agent-continuation/child-catalog.worker.ts +++ b/benchmarks/agent-continuation/child-catalog.worker.ts @@ -81,7 +81,12 @@ async function run(root: string, mode: string): Promise ctx.subagents.listChildren(PARENT_ID) + const readCatalog = async () => { + using observation = await ctx.sessionQuery.observeSession(PARENT_ID) + const catalog = observation.projections?.values.subagentCatalog + if (catalog === undefined) throw new Error('child-catalog projection is unavailable') + return catalog + } const cpuStart = process.cpuUsage() const start = performance.now() const first = await readCatalog() diff --git a/benchmarks/user-text/user-text.bench.client.ts b/benchmarks/user-text/user-text.bench.client.ts index c52a6e8c65..d5d2ce677d 100644 --- a/benchmarks/user-text/user-text.bench.client.ts +++ b/benchmarks/user-text/user-text.bench.client.ts @@ -92,6 +92,25 @@ async function worker(args: readonly string[], semiSpaceMb?: number): Pr return run.report } +/** Compare adjacent scaling samples before aggregating, so unrelated fastest rounds cannot set the ratio. */ +function doublingRatio(half: readonly number[], full: readonly number[]): number { + const ratios = half.map((duration, index) => full[index]! / duration).sort((a, b) => a - b) + return ratios[Math.floor(ratios.length / 2)]! +} + +describe('credential-matcher scaling measurement', () => { + it('compares adjacent rounds when the runner changes speed', () => { + const half = [1, 2, 2, 2, 2] + const full = [4, 4, 4, 4, 4] + expect(Math.min(...full) / Math.min(...half)).toBeGreaterThan(MAX_DOUBLING_SCALING) + expect(doublingRatio(half, full)).toBe(2) + }) + + it('rejects superlinear growth even when one round is faster', () => { + expect(doublingRatio([1, 2, 2, 2, 2], [2, 6, 6, 6, 6])).toBeGreaterThan(MAX_DOUBLING_SCALING) + }) +}) + describe('sent user text with the credential matcher', () => { let timings: ReadonlyMap let allocations: ReadonlyMap @@ -115,6 +134,11 @@ describe('sent user text with the credential matcher', () => { measures: `${benchmark.measures} Minimum of ${ATTEMPTS} per-sample means of ${measured.evaluationsPerSample} fresh evaluations after warm-up.`, }, { withSecretMs: { ms: measured.withSecretMs, cpuMs: measured.withSecretCpuMs } }, { withSecretMs: budgetMs }) } + const expectLinear = (half: UserTextWorkloadTiming, full: UserTextWorkloadTiming): void => { + const ratio = doublingRatio(half.samples.map(sample => sample.withSecretMs), full.samples.map(sample => sample.withSecretMs)) + console.log(JSON.stringify({ benchmark: `user-text/${full.id}/scaling`, ratio, budget: MAX_DOUBLING_SCALING })) + expect(ratio).toBeLessThanOrEqual(MAX_DOUBLING_SCALING) + } it.each(Object.entries(UNMATCHED_EXPECTED))('evaluates %s without credentials within its budget and ratio', (id, expected) => { const measured = timing(id) @@ -139,7 +163,7 @@ describe('sent user text with the credential matcher', () => { const half = timing('env-keys-1000') const full = timing('env-keys-2000') expect([half.secrets, full.secrets]).toEqual([1_000, 2_000]) - expect(full.withSecretMs / half.withSecretMs).toBeLessThanOrEqual(MAX_DOUBLING_SCALING) + expectLinear(half, full) }) it.each(ADVERSARIAL)('scans adversarial %s input linearly without backtracking', (name) => { @@ -148,7 +172,7 @@ describe('sent user text with the credential matcher', () => { const budgetMs = ciTimeBudget(ADVERSARIAL_EXPECTED_MS) record(benchmarkCase(`adversarial-${name}`, `One 128 KiB document repeating a ${name} unit.`), full, budgetMs) expect(full.withSecretMs).toBeLessThanOrEqual(budgetMs) - expect(full.withSecretMs / half.withSecretMs).toBeLessThanOrEqual(MAX_DOUBLING_SCALING) + expectLinear(half, full) }) it.each(workloadIds())('allocates only result nodes and masks for %s', (id) => { diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 72dc6b08a6..19453e15a9 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -21,8 +21,8 @@ en: 20ef882e44b06a10 zh: c10c3934ed5570e9 /deepseek-harness-architecture/events: - en: 693407efb233f961 - zh: 37b9dd0677d4405c + en: ddebac7cfd41ec73 + zh: 51e6d0866a9e7839 /deepseek-harness-architecture/turn-flow: en: 1a79a70b62c04cdb zh: c86d765e21430405 @@ -30,8 +30,8 @@ en: b3b63d8fe7cab9fe zh: 3703fcf30b1639d0 /deepseek-harness-architecture/capability-seams: - en: a5bc20c3f2a1b370 - zh: 91773a281410b2f4 + en: 1d425c445bfcc931 + zh: 834d486f88e68edf /deepseek-harness-architecture/where-new-behavior-goes: en: ee73b1861378b099 zh: 9a182be609485f67 diff --git a/docs/architecture.md b/docs/architecture.md index e3f5cd24a3..446c50e5bd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -66,15 +66,17 @@ Electron starts the private Desktop Host in Electron Node mode. The Host invokes ## Events -Events are the extension points, and picking the right domain is the first decision in most changes. +Events provide extension points in three domains. - **Session events** are durable facts appended to the log and broadcast through `session/event`. Use one when the fact must survive a reload. - **Agent events** (`agent/*`) carry a live `Agent`: inbox, step, status, request, validation, continuation. Use one to observe or intercept work in flight. - **Capability events** attach policy and adapters to a seam (`fs/*`, `tools/*`, `telemetry/*`) without importing the loop. -AgentLoop initializes Agents with rollback and awaits serial `agent/created` before queued work. Unload retains resources through Agent teardown; [agent-loop](../packages/core/agent-loop/README.md#understand-the-implementation) defines ordering. +Subagent Activations own runtime tree membership. -The [event map](event-producer-consumer.md) lists every event's producers and consumers. +AgentLoop rolls back failed initialization and awaits serial `agent/created` before queued work; unload retains resources through teardown ([ordering](../packages/core/agent-loop/README.md#understand-the-implementation)). + +[Event producers and consumers](event-producer-consumer.md). ## Turn flow @@ -137,7 +139,7 @@ A **seam** is a swappable capability with three roles: a **Service Definition** Seams are why one provider swap changes the whole product. Filesystem and subprocess providers share one execution world, so pointing them at a remote sandbox moves Bash, PTY, and LSP with them, with no provider forks. [Subagent providers](subsystems/subagent.md) vary just as widely behind one interface, from a fresh child agent to a delegated turn in another product. -[Experimental Agent Teams](subsystems/agent-team.md) exposes opt-in `ctx.agentTeams` coordination with durable roster and task state plus direct inbox messaging over continuable subagents. +[Agent Teams](subsystems/agent-team.md) adds task policy and a shared task board when enabled for a root Session. Ordinary subagent controls own identity, messaging, discovery, interruption, and inbox waiting. ## Where new behavior goes diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 4ab73a419d..275e1c863d 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -68,15 +68,17 @@ Electron 使用 Electron Node 模式启动私有 Desktop Host。Host 调用共 ## 事件 -事件就是扩展点,而选对事件域是大多数改动的第一个决定。 +事件在三个域内提供扩展点。 - **会话事件**是追加到日志并通过 `session/event` 广播的持久事实。当某个事实必须在重新加载后仍然存在时,使用它。 - **Agent 事件**(`agent/*`)携带活跃 `Agent`:inbox、步骤、状态、请求、验证、续跑。要观察或拦截进行中的工作时,使用它。 - **能力事件**无需导入循环即可向某个 seam(`fs/*`、`tools/*`、`telemetry/*`)附加策略和适配器。 -AgentLoop 初始化 Agent,失败则回滚,并在启动已排队工作前等待串行 `agent/created`。卸载在 Agent teardown 期间保留所需资源;[agent-loop](../packages/core/agent-loop/README.zh.md#understand-the-implementation)定义顺序。 +Subagent Activation 拥有运行时树成员关系。 -[事件映射](event-producer-consumer.zh.md)列出每个事件的生产方与消费方。 +AgentLoop 回滚失败的初始化,并在启动已排队工作前等待串行 `agent/created`;卸载在 teardown 期间保留所需资源([顺序](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。 + +[事件生产者与消费者](event-producer-consumer.zh.md)。 @@ -141,7 +143,7 @@ Session 消费方只了解当前逻辑格式。仅 header 的 `stat` 与 `list` seam 正是替换一个提供方就能改变整个产品的原因。文件系统与进程提供方共享同一个执行世界,因此把它们指向远程沙箱,也就把 Bash、PTY 和 LSP 一并搬了过去,无需提供方专用 fork。[subagent 提供方](subsystems/subagent.zh.md)在同一个接口之后同样千差万别,从新建一个子 agent,到把一个轮次委派给另一个产品。 -[实验性 Agent Teams](subsystems/agent-team.zh.md) 通过显式启用的 `ctx.agentTeams`,在可继续 subagent 之上提供持久 roster、任务状态和直接 inbox 消息协作。 +[Agent Teams](subsystems/agent-team.zh.md)在根 Session 开启后提供任务策略和共享任务板。普通 subagent 控制工具负责身份、通信、发现、中断和收件箱等待。 ## 新行为的归属位置 diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 07016423f1..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: 45dd67234744e29d - zh: a250e73f5b159175 + en: 81ada544b91db085 + zh: 4442cc22a114987d diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 9da61dd3e7..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"] @@ -267,9 +269,9 @@ flowchart LR pkg_experimental_speech_to_text["experimental-speech-to-text"] svc_speechToText["ctx.speechToText
Experimental speech recognition providers"] pkg_experimental_speech_to_text_sensevoice["experimental-speech-to-text-sensevoice"] - pkg_experimental_agent_team["experimental-agent-team"] + pkg_agent_team["agent-team"] svc_agentTeams["ctx.agentTeams
Agent Teams coordination domain"] - pkg_experimental_tool_agent_team["experimental-tool-agent-team"] + pkg_tool_agent_team["tool-agent-team"] pkg_experimental_claude_code_mods["experimental-claude-code-mods"] svc_claudeCodeMods["ctx.claudeCodeMods
Claude Code mods bridge"] pkg_inspector["inspector"] @@ -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"] @@ -320,6 +320,7 @@ flowchart LR pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop pkg_agent_preset_registry --> svc_agentPresets + pkg_agent_team --> svc_agentTeams pkg_api_gateway --> svc_typertGateway pkg_api_job_controller --> svc_jobController pkg_api_remote_access --> svc_remoteAccess @@ -359,7 +360,6 @@ flowchart LR pkg_deepseek_account --> svc_deepseekAccount pkg_deepseek_account_platform --> svc_deepseekAccount pkg_deepseek_llm_api_extensions --> svc_deepseekLlmApiExtensions - pkg_experimental_agent_team --> svc_agentTeams pkg_experimental_api_speech_to_text --> svc_speechController pkg_experimental_browser_use_chrome_devtools_mcp --> svc_browserUse pkg_experimental_browser_use_playwright_mcp --> svc_browserUse @@ -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 @@ -488,7 +487,7 @@ flowchart LR svc_agentDefaultModel --> pkg_headless svc_agentLoop --> pkg_base svc_agentLoop --> pkg_sdk_minimal - svc_agentTeams --> pkg_experimental_tool_agent_team + svc_agentTeams --> pkg_tool_agent_team svc_agents --> pkg_acp svc_agents --> pkg_agent_loop svc_agents --> pkg_subagent @@ -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. | @@ -742,11 +742,11 @@ flowchart LR | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; there is no model-facing compact tool. | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/experimental/tool-ralph) | - | Providers prepare local children or execute external tasks; one activation manager owns both lifecycles. The delegation tool selects a backend, control tools deliver local follow-ups, and workflows await activation results. | | `ctx.speechToText` | `seam` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | - | Routes explicit recognizers; the browser uses the authenticated Remote and keeps transcripts in the draft until submission. | -| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | - | Owns the implicit-root roster, direct peer inbox delivery, shared task DAG, and continuable-child lifecycle; tool-agent-team contributes model controls. | +| `ctx.agentTeams` | `core` | [`agent-team`](../packages/team/agent-team) | - | [`tool-agent-team`](../packages/team/tool-agent-team) | - | Owns the shared task DAG for a subagent tree; tool-agent-team contributes task controls. | | `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 f2036d67fb..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"] @@ -269,9 +271,9 @@ flowchart LR pkg_experimental_speech_to_text["experimental-speech-to-text"] svc_speechToText["ctx.speechToText
Experimental speech recognition providers"] pkg_experimental_speech_to_text_sensevoice["experimental-speech-to-text-sensevoice"] - pkg_experimental_agent_team["experimental-agent-team"] + pkg_agent_team["agent-team"] svc_agentTeams["ctx.agentTeams
Agent Teams coordination domain"] - pkg_experimental_tool_agent_team["experimental-tool-agent-team"] + pkg_tool_agent_team["tool-agent-team"] pkg_experimental_claude_code_mods["experimental-claude-code-mods"] svc_claudeCodeMods["ctx.claudeCodeMods
Claude Code mods bridge"] pkg_inspector["inspector"] @@ -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"] @@ -322,6 +322,7 @@ flowchart LR pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop pkg_agent_preset_registry --> svc_agentPresets + pkg_agent_team --> svc_agentTeams pkg_api_gateway --> svc_typertGateway pkg_api_job_controller --> svc_jobController pkg_api_remote_access --> svc_remoteAccess @@ -361,7 +362,6 @@ flowchart LR pkg_deepseek_account --> svc_deepseekAccount pkg_deepseek_account_platform --> svc_deepseekAccount pkg_deepseek_llm_api_extensions --> svc_deepseekLlmApiExtensions - pkg_experimental_agent_team --> svc_agentTeams pkg_experimental_api_speech_to_text --> svc_speechController pkg_experimental_browser_use_chrome_devtools_mcp --> svc_browserUse pkg_experimental_browser_use_playwright_mcp --> svc_browserUse @@ -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 @@ -490,7 +489,7 @@ flowchart LR svc_agentDefaultModel --> pkg_headless svc_agentLoop --> pkg_base svc_agentLoop --> pkg_sdk_minimal - svc_agentTeams --> pkg_experimental_tool_agent_team + svc_agentTeams --> pkg_tool_agent_team svc_agents --> pkg_acp svc_agents --> pkg_agent_loop svc_agents --> pkg_subagent @@ -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 连接、已安装辅助程序身份、独立程序流,以及配套远端提供方的断连清理。 | @@ -744,11 +744,11 @@ flowchart LR | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 基础后端消费步骤后的压力事件和请求错误恢复事件;不存在面向模型的压缩工具。 | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/experimental/tool-ralph) | - | 提供方准备本地子级或执行外部任务;一个 activation 管理器持有两类生命周期。委派工具选择后端,控制工具投递本地后续消息,工作流等待 activation 结果。 | | `ctx.speechToText` | `seam` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | - | 路由显式选择的识别器;浏览器使用带认证的 Remote,并在提交前将转写保留在草稿中。 | -| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | - | 负责隐式 Root roster、直接 peer inbox 投递、共享任务 DAG 与 continuable child 生命周期;tool-agent-team 提供模型控制工具。 | +| `ctx.agentTeams` | `core` | [`agent-team`](../packages/team/agent-team) | - | [`tool-agent-team`](../packages/team/tool-agent-team) | - | 负责子代理树共享的任务 DAG;tool-agent-team 提供任务控制工具。 | | `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 a1423c4bfd..a54718d219 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -111,7 +111,7 @@ export interface Config { - `inject`: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.md) · [`SessionId`](subsystems/core.md) · `Volatile` (`@deepseek-ai/cosmokit`) -- `source`: [`packages/core/agent-loop/src/index.ts:253`](../packages/core/agent-loop/src/index.ts) +- `source`: [`packages/core/agent-loop/src/index.ts:252`](../packages/core/agent-loop/src/index.ts) ```ts config-catalog /** Agent-loop plugin configuration. */ @@ -171,6 +171,25 @@ export interface Config { ``` + + + +## `@deepseek-ai/dsh-agent-team` + +- `inject`: `agents` · `sessions` · `sessionPersistence` · `sessionProjections` · `subagents` +- `source`: [`packages/team/agent-team/src/types.ts:132`](../packages/team/agent-team/src/types.ts) + +```ts config-catalog +/** 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 +} +``` + + @@ -875,29 +894,6 @@ export interface Config { ``` - - - -## `@deepseek-ai/dsh-experimental-agent-team` - -- `inject`: `agents` · `sessions` · `sessionPersistence` · `sessionProjections` · `subagents` -- `source`: [`packages/experimental/agent-team/src/types.ts:153`](../packages/experimental/agent-team/src/types.ts) - -```ts config-catalog -/** Team-service deployment limits. */ -export interface Config { - /** Maximum immutable teammate names retained by one Team. */ - readonly maxMembers?: number - /** Maximum non-deleted tasks retained by one Team. */ - readonly maxTasks?: number - /** Maximum UTF-8 bytes in one complete sender-framed delivery. */ - readonly maxMessageBytes?: number - /** Maximum milliseconds for shared operation settlement and for each Team child drain. */ - readonly disposalTimeoutMs?: number -} -``` - - @@ -1116,10 +1112,24 @@ export interface Config { - `source`: [`packages/experimental/cua-macos/src/index.ts:18`](../packages/experimental/cua-macos/src/index.ts) ```ts config-catalog -/** Native preparation, operation bounds, and profile-local application grants. */ +/** Native preparation, source-build signing, operation bounds, and profile-local application grants. */ export interface Config extends CuaConfig { /** Interval in milliseconds between permission and position checks while the native guide is open. */ permissionPollIntervalMs: number + /** + * Source-build signing: `ad-hoc` (default) binds grants to the code hash; + * an object selects an existing certificate in an explicitly configured keychain. + * Installed packages and `helperPath` keep their own signature. + */ + sourceSigning: SourceSigning +} + +/** Signature selected for source builds; certificates are created and unlocked by the developer. */ +export type SourceSigning = 'ad-hoc' | { + /** Existing codesign certificate name or SHA-1 fingerprint, without a leading dash or whitespace. */ + identity: string + /** Absolute keychain path. */ + keychain: string } ``` @@ -1433,27 +1443,6 @@ export interface Config { ``` - - - -## `@deepseek-ai/dsh-experimental-tool-agent-team` - -- `inject`: `agents` · `agentTeams` · `tools` · `systemPrompt` -- `source`: [`packages/experimental/tool-agent-team/src/index.ts:17`](../packages/experimental/tool-agent-team/src/index.ts) - -```ts config-catalog -/** Tool routing configuration. */ -export interface Config { - /** Register `interrupt_agent` and its policy guidance. Defaults to true; false leaves the domain API available. */ - readonly interrupt?: boolean - /** Continuable-subagent provider used for fresh teammates. */ - readonly freshProvider?: string - /** Continuable-subagent provider used for completed-prefix fork teammates. */ - readonly forkProvider?: string -} -``` - - @@ -2389,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. */ @@ -2402,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. */ @@ -2415,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). */ @@ -2430,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. */ @@ -2439,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). */ @@ -2461,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 +} +``` + + @@ -3572,7 +3624,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' - `inject`: `workingDirectory` · `sessionProjections` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/subagent/subagent/src/index.ts:186`](../packages/subagent/subagent/src/index.ts) +- `source`: [`packages/subagent/subagent/src/index.ts:181`](../packages/subagent/subagent/src/index.ts) ```ts config-catalog /** Host configuration for continuable subagent capacity. */ @@ -3788,7 +3840,7 @@ export interface Config { ## `@deepseek-ai/dsh-system-prompt` -- `source`: [`packages/core/system-prompt/src/index.ts:265`](../packages/core/system-prompt/src/index.ts) +- `source`: [`packages/core/system-prompt/src/index.ts:264`](../packages/core/system-prompt/src/index.ts) ```ts config-catalog /** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.personaPrefix} for its contract). */ @@ -3927,6 +3979,20 @@ export type TokenMeterConfig = Record ``` + + + +## `@deepseek-ai/dsh-tool-agent-team` + +- `inject`: `agentTeams` · `subagents` · `tools` · `systemPrompt` +- `source`: [`packages/team/tool-agent-team/src/index.ts:18`](../packages/team/tool-agent-team/src/index.ts) + +```ts config-catalog +/** The Team tool plugin takes no configuration. */ +export type Config = Record +``` + + @@ -4322,7 +4388,7 @@ export interface Config { - `inject`: `tools` · `subagents` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.md) -- `source`: [`packages/subagent/tool-subagent/src/index.ts:46`](../packages/subagent/tool-subagent/src/index.ts) +- `source`: [`packages/subagent/tool-subagent/src/index.ts:45`](../packages/subagent/tool-subagent/src/index.ts) ```ts config-catalog /** Config: which registered provider this tool delegates to, plus child defaults. */ @@ -4373,6 +4439,23 @@ export interface PresetSelectionConfig { ``` + + + +## `@deepseek-ai/dsh-tool-subagent-control` + +- `inject`: `tools` · `subagents` · `agents` · `systemPrompt` +- `source`: [`packages/subagent/tool-subagent-control/src/index.ts:26`](../packages/subagent/tool-subagent-control/src/index.ts) + +```ts config-catalog +/** Optional subagent control tools. */ +export interface Config { + /** Register `interrupt_agent`. Defaults to true; false leaves the service API available. */ + interrupt?: boolean +} +``` + + @@ -4430,9 +4513,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 @@ -4553,7 +4636,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 /** @@ -4612,21 +4695,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 } ``` @@ -4663,7 +4765,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). */ @@ -4686,56 +4788,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' -} -``` - - @@ -4849,6 +4901,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-client-resources` | — | [`packages/client/resources/src/index.ts`](../packages/client/resources/src/index.ts) | | `@deepseek-ai/dsh-client-ui-action-cards` | — | [`packages/experimental/client-ui-action-cards/src/index.ts`](../packages/experimental/client-ui-action-cards/src/index.ts) | | `@deepseek-ai/dsh-client-ui-agent-preset` | — | [`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts) | +| `@deepseek-ai/dsh-client-ui-agent-team` | — | [`packages/client/ui-agent-team/src/index.ts`](../packages/client/ui-agent-team/src/index.ts) | | `@deepseek-ai/dsh-client-ui-approval` | — | [`packages/client/ui-approval/src/index.ts`](../packages/client/ui-approval/src/index.ts) | | `@deepseek-ai/dsh-client-ui-attachment` | — | [`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts) | | `@deepseek-ai/dsh-client-ui-brand-official` | — | [`packages/client/ui-brand-official/src/index.ts`](../packages/client/ui-brand-official/src/index.ts) | @@ -4914,7 +4967,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-deepseek-llm-api-extensions` | — | [`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts) | | `@deepseek-ai/dsh-deliverable-file-references` | `systemPrompt` | [`packages/deliverables/deliverable-file-references/src/index.ts`](../packages/deliverables/deliverable-file-references/src/index.ts) | | `@deepseek-ai/dsh-experimental-auto-review` | `approval` · `llm` · `permissionPresets` · `sessions` · `tools` · `workingDirectory` | [`packages/experimental/auto-review/src/index.ts`](../packages/experimental/auto-review/src/index.ts) | -| `@deepseek-ai/dsh-experimental-client-ui-agent-team` | — | [`packages/experimental/client-ui-agent-team/src/index.ts`](../packages/experimental/client-ui-agent-team/src/index.ts) | | `@deepseek-ai/dsh-experimental-client-ui-claude-code-mods` | — | [`packages/experimental/client-ui-claude-code-mods/src/index.ts`](../packages/experimental/client-ui-claude-code-mods/src/index.ts) | | `@deepseek-ai/dsh-experimental-client-ui-stats` | — | [`packages/experimental/client-ui-stats/src/index.ts`](../packages/experimental/client-ui-stats/src/index.ts) | | `@deepseek-ai/dsh-experimental-client-ui-voice-input` | — | [`packages/experimental/client-ui-voice-input/src/index.ts`](../packages/experimental/client-ui-voice-input/src/index.ts) | @@ -4950,7 +5002,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-tool-cordis` | `tools` · `cordisInspect` | [`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts) | | `@deepseek-ai/dsh-tool-executor-native` | `tools` | [`packages/core/tool-executor-native/src/index.ts`](../packages/core/tool-executor-native/src/index.ts) | | `@deepseek-ai/dsh-tool-schedule` | `tools` · `systemPrompt` | [`packages/schedule/tool-schedule/src/index.ts`](../packages/schedule/tool-schedule/src/index.ts) | -| `@deepseek-ai/dsh-tool-subagent-control` | `tools` · `subagents` · `agents` | [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts) | | `@deepseek-ai/dsh-tool-working-directory` | `tools` · `workingDirectory` | [`packages/session/tool-working-directory/src/index.ts`](../packages/session/tool-working-directory/src/index.ts) | | `@deepseek-ai/dsh-tools` | `systemPrompt` | [`packages/core/tools/src/index.ts`](../packages/core/tools/src/index.ts) | | `@deepseek-ai/dsh-user-questions` | — | [`packages/interaction/user-questions/src/index.ts`](../packages/interaction/user-questions/src/index.ts) | @@ -5006,7 +5057,6 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. | `@deepseek-ai/dsh-cmdline` | — | [`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts) | | `@deepseek-ai/dsh-cua-bundle` | — | [`packages/experimental/cua-bundle/src/index.ts`](../packages/experimental/cua-bundle/src/index.ts) | | `@deepseek-ai/dsh-deque` | — | [`packages/util/deque/src/index.ts`](../packages/util/deque/src/index.ts) | -| `@deepseek-ai/dsh-experimental-agent-team-profile` | — | [`packages/experimental/agent-team-profile/src/index.ts`](../packages/experimental/agent-team-profile/src/index.ts) | | `@deepseek-ai/dsh-experimental-badge-skill-bundle` | — | [`packages/experimental/badge-skill-bundle/src/index.ts`](../packages/experimental/badge-skill-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-browser-use-runtime` | — | [`packages/experimental/browser-use-runtime/src/index.ts`](../packages/experimental/browser-use-runtime/src/index.ts) | | `@deepseek-ai/dsh-experimental-cot-translation-bundle` | — | [`packages/experimental/cot-translation-bundle/src/index.ts`](../packages/experimental/cot-translation-bundle/src/index.ts) | diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index adf1f1ef02..b0fff21dbe 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -113,7 +113,7 @@ export interface Config { - `inject`: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.zh.md) · [`SessionId`](subsystems/core.zh.md) · `Volatile` (`@deepseek-ai/cosmokit`) -- `source`: [`packages/core/agent-loop/src/index.ts:253`](../packages/core/agent-loop/src/index.ts) +- `source`: [`packages/core/agent-loop/src/index.ts:252`](../packages/core/agent-loop/src/index.ts) ```ts config-catalog /** Agent-loop plugin configuration. */ @@ -173,6 +173,25 @@ export interface Config { ``` + + + +## `@deepseek-ai/dsh-agent-team` + +- `inject`: `agents` · `sessions` · `sessionPersistence` · `sessionProjections` · `subagents` +- `source`: [`packages/team/agent-team/src/types.ts:132`](../packages/team/agent-team/src/types.ts) + +```ts config-catalog +/** 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 +} +``` + + @@ -877,29 +896,6 @@ export interface Config { ``` - - - -## `@deepseek-ai/dsh-experimental-agent-team` - -- `inject`: `agents` · `sessions` · `sessionPersistence` · `sessionProjections` · `subagents` -- `source`: [`packages/experimental/agent-team/src/types.ts:153`](../packages/experimental/agent-team/src/types.ts) - -```ts config-catalog -/** Team-service deployment limits. */ -export interface Config { - /** Maximum immutable teammate names retained by one Team. */ - readonly maxMembers?: number - /** Maximum non-deleted tasks retained by one Team. */ - readonly maxTasks?: number - /** Maximum UTF-8 bytes in one complete sender-framed delivery. */ - readonly maxMessageBytes?: number - /** Maximum milliseconds for shared operation settlement and for each Team child drain. */ - readonly disposalTimeoutMs?: number -} -``` - - @@ -1118,10 +1114,24 @@ export interface Config { - `source`: [`packages/experimental/cua-macos/src/index.ts:18`](../packages/experimental/cua-macos/src/index.ts) ```ts config-catalog -/** Native preparation, operation bounds, and profile-local application grants. */ +/** Native preparation, source-build signing, operation bounds, and profile-local application grants. */ export interface Config extends CuaConfig { /** Interval in milliseconds between permission and position checks while the native guide is open. */ permissionPollIntervalMs: number + /** + * Source-build signing: `ad-hoc` (default) binds grants to the code hash; + * an object selects an existing certificate in an explicitly configured keychain. + * Installed packages and `helperPath` keep their own signature. + */ + sourceSigning: SourceSigning +} + +/** Signature selected for source builds; certificates are created and unlocked by the developer. */ +export type SourceSigning = 'ad-hoc' | { + /** Existing codesign certificate name or SHA-1 fingerprint, without a leading dash or whitespace. */ + identity: string + /** Absolute keychain path. */ + keychain: string } ``` @@ -1435,27 +1445,6 @@ export interface Config { ``` - - - -## `@deepseek-ai/dsh-experimental-tool-agent-team` - -- `inject`: `agents` · `agentTeams` · `tools` · `systemPrompt` -- `source`: [`packages/experimental/tool-agent-team/src/index.ts:17`](../packages/experimental/tool-agent-team/src/index.ts) - -```ts config-catalog -/** Tool routing configuration. */ -export interface Config { - /** Register `interrupt_agent` and its policy guidance. Defaults to true; false leaves the domain API available. */ - readonly interrupt?: boolean - /** Continuable-subagent provider used for fresh teammates. */ - readonly freshProvider?: string - /** Continuable-subagent provider used for completed-prefix fork teammates. */ - readonly forkProvider?: string -} -``` - - @@ -2391,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. */ @@ -2404,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. */ @@ -2417,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). */ @@ -2432,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. */ @@ -2441,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). */ @@ -2463,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 +} +``` + + @@ -3574,7 +3626,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' - `inject`: `workingDirectory` · `sessionProjections` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/subagent/subagent/src/index.ts:186`](../packages/subagent/subagent/src/index.ts) +- `source`: [`packages/subagent/subagent/src/index.ts:181`](../packages/subagent/subagent/src/index.ts) ```ts config-catalog /** Host configuration for continuable subagent capacity. */ @@ -3790,7 +3842,7 @@ export interface Config { ## `@deepseek-ai/dsh-system-prompt` -- `source`: [`packages/core/system-prompt/src/index.ts:265`](../packages/core/system-prompt/src/index.ts) +- `source`: [`packages/core/system-prompt/src/index.ts:264`](../packages/core/system-prompt/src/index.ts) ```ts config-catalog /** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.personaPrefix} for its contract). */ @@ -3929,6 +3981,20 @@ export type TokenMeterConfig = Record ``` + + + +## `@deepseek-ai/dsh-tool-agent-team` + +- `inject`: `agentTeams` · `subagents` · `tools` · `systemPrompt` +- `source`: [`packages/team/tool-agent-team/src/index.ts:18`](../packages/team/tool-agent-team/src/index.ts) + +```ts config-catalog +/** The Team tool plugin takes no configuration. */ +export type Config = Record +``` + + @@ -4324,7 +4390,7 @@ export interface Config { - `inject`: `tools` · `subagents` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.zh.md) -- `source`: [`packages/subagent/tool-subagent/src/index.ts:46`](../packages/subagent/tool-subagent/src/index.ts) +- `source`: [`packages/subagent/tool-subagent/src/index.ts:45`](../packages/subagent/tool-subagent/src/index.ts) ```ts config-catalog /** Config: which registered provider this tool delegates to, plus child defaults. */ @@ -4375,6 +4441,23 @@ export interface PresetSelectionConfig { ``` + + + +## `@deepseek-ai/dsh-tool-subagent-control` + +- `inject`: `tools` · `subagents` · `agents` · `systemPrompt` +- `source`: [`packages/subagent/tool-subagent-control/src/index.ts:26`](../packages/subagent/tool-subagent-control/src/index.ts) + +```ts config-catalog +/** Optional subagent control tools. */ +export interface Config { + /** Register `interrupt_agent`. Defaults to true; false leaves the service API available. */ + interrupt?: boolean +} +``` + + @@ -4432,9 +4515,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 @@ -4555,7 +4638,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 /** @@ -4614,21 +4697,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 } ``` @@ -4665,7 +4767,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). */ @@ -4688,56 +4790,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' -} -``` - - @@ -4851,6 +4903,7 @@ export interface Config { | `@deepseek-ai/dsh-client-resources` | — | [`packages/client/resources/src/index.ts`](../packages/client/resources/src/index.ts) | | `@deepseek-ai/dsh-client-ui-action-cards` | — | [`packages/experimental/client-ui-action-cards/src/index.ts`](../packages/experimental/client-ui-action-cards/src/index.ts) | | `@deepseek-ai/dsh-client-ui-agent-preset` | — | [`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts) | +| `@deepseek-ai/dsh-client-ui-agent-team` | — | [`packages/client/ui-agent-team/src/index.ts`](../packages/client/ui-agent-team/src/index.ts) | | `@deepseek-ai/dsh-client-ui-approval` | — | [`packages/client/ui-approval/src/index.ts`](../packages/client/ui-approval/src/index.ts) | | `@deepseek-ai/dsh-client-ui-attachment` | — | [`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts) | | `@deepseek-ai/dsh-client-ui-brand-official` | — | [`packages/client/ui-brand-official/src/index.ts`](../packages/client/ui-brand-official/src/index.ts) | @@ -4916,7 +4969,6 @@ export interface Config { | `@deepseek-ai/dsh-deepseek-llm-api-extensions` | — | [`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts) | | `@deepseek-ai/dsh-deliverable-file-references` | `systemPrompt` | [`packages/deliverables/deliverable-file-references/src/index.ts`](../packages/deliverables/deliverable-file-references/src/index.ts) | | `@deepseek-ai/dsh-experimental-auto-review` | `approval` · `llm` · `permissionPresets` · `sessions` · `tools` · `workingDirectory` | [`packages/experimental/auto-review/src/index.ts`](../packages/experimental/auto-review/src/index.ts) | -| `@deepseek-ai/dsh-experimental-client-ui-agent-team` | — | [`packages/experimental/client-ui-agent-team/src/index.ts`](../packages/experimental/client-ui-agent-team/src/index.ts) | | `@deepseek-ai/dsh-experimental-client-ui-claude-code-mods` | — | [`packages/experimental/client-ui-claude-code-mods/src/index.ts`](../packages/experimental/client-ui-claude-code-mods/src/index.ts) | | `@deepseek-ai/dsh-experimental-client-ui-stats` | — | [`packages/experimental/client-ui-stats/src/index.ts`](../packages/experimental/client-ui-stats/src/index.ts) | | `@deepseek-ai/dsh-experimental-client-ui-voice-input` | — | [`packages/experimental/client-ui-voice-input/src/index.ts`](../packages/experimental/client-ui-voice-input/src/index.ts) | @@ -4952,7 +5004,6 @@ export interface Config { | `@deepseek-ai/dsh-tool-cordis` | `tools` · `cordisInspect` | [`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts) | | `@deepseek-ai/dsh-tool-executor-native` | `tools` | [`packages/core/tool-executor-native/src/index.ts`](../packages/core/tool-executor-native/src/index.ts) | | `@deepseek-ai/dsh-tool-schedule` | `tools` · `systemPrompt` | [`packages/schedule/tool-schedule/src/index.ts`](../packages/schedule/tool-schedule/src/index.ts) | -| `@deepseek-ai/dsh-tool-subagent-control` | `tools` · `subagents` · `agents` | [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts) | | `@deepseek-ai/dsh-tool-working-directory` | `tools` · `workingDirectory` | [`packages/session/tool-working-directory/src/index.ts`](../packages/session/tool-working-directory/src/index.ts) | | `@deepseek-ai/dsh-tools` | `systemPrompt` | [`packages/core/tools/src/index.ts`](../packages/core/tools/src/index.ts) | | `@deepseek-ai/dsh-user-questions` | — | [`packages/interaction/user-questions/src/index.ts`](../packages/interaction/user-questions/src/index.ts) | @@ -5008,7 +5059,6 @@ export interface Config { | `@deepseek-ai/dsh-cmdline` | — | [`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts) | | `@deepseek-ai/dsh-cua-bundle` | — | [`packages/experimental/cua-bundle/src/index.ts`](../packages/experimental/cua-bundle/src/index.ts) | | `@deepseek-ai/dsh-deque` | — | [`packages/util/deque/src/index.ts`](../packages/util/deque/src/index.ts) | -| `@deepseek-ai/dsh-experimental-agent-team-profile` | — | [`packages/experimental/agent-team-profile/src/index.ts`](../packages/experimental/agent-team-profile/src/index.ts) | | `@deepseek-ai/dsh-experimental-badge-skill-bundle` | — | [`packages/experimental/badge-skill-bundle/src/index.ts`](../packages/experimental/badge-skill-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-browser-use-runtime` | — | [`packages/experimental/browser-use-runtime/src/index.ts`](../packages/experimental/browser-use-runtime/src/index.ts) | | `@deepseek-ai/dsh-experimental-cot-translation-bundle` | — | [`packages/experimental/cot-translation-bundle/src/index.ts`](../packages/experimental/cot-translation-bundle/src/index.ts) | diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 682746fcee..591bcdd5ce 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -21,8 +21,8 @@ en: 3221d1076554001f zh: 29f166be6a5c04d9 /development-guide/contributor-reference/typescript-project-layout: - en: eb79b6f8ea745a9f - zh: de676c78231c823a + en: 16d67911ecaa50d3 + zh: f5259b0fbf022db3 /development-guide/contributor-reference/environment-variables: en: dba2c72ee6ffa930 zh: b65c27fbb9129ec8 @@ -45,5 +45,5 @@ en: 85b38f78d87fc88f zh: 00d5eb625e7dfb61 /development-guide/contributor-reference/documenting-types-verbatim-ts-type-equiv: - en: 9d578b6797be921b - zh: 823cbfcfd9a75c17 + en: 6b9b948e0b80cf76 + zh: 2642f4127ed9b719 diff --git a/docs/development.md b/docs/development.md index 18130990d5..9aec6e601d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -89,7 +89,7 @@ tsdown --env.DSH_BUILD_FACE client pnpm run build:web ``` -`pnpm run build:check` runs only `tsc -p tsconfig.host.json` and `tsc -p tsconfig.client.json` concurrently. Project mode checks each aggregate's repository-wide test and script program against the package declarations already on disk and never compiles a reference, so `build:check` neither detects nor rebuilds missing or stale `build:artifacts` output; missing output surfaces as compiler errors. `pnpm run build` runs both step sets as one [scheduled](../scripts/run-gates.ts) dependency graph: each aggregate check starts once its own face's package compilation finishes and overlaps the remaining artifact steps. In all three commands `DSH_GATE_CONCURRENCY` bounds concurrent steps (`1` runs them in dependency order) and `DSH_GATE_FAIL_FAST=1` stops the remaining steps at the first failure; otherwise a failure skips only its dependents, so independent errors are all reported. A failed step prints its own output, and any failure exits non-zero without writing the client build record. +`pnpm run build:check` runs only `tsc -p tsconfig.host.json` and `tsc -p tsconfig.client.json` concurrently. Project mode checks each aggregate's repository-wide test and script program against the package declarations already on disk and never compiles a reference, so `build:check` neither detects nor rebuilds missing or stale `build:artifacts` output; missing output surfaces as compiler errors. `pnpm run build` runs both step sets as one [scheduled](../scripts/run-gates.ts) dependency graph: each aggregate check starts once its own face's package compilation finishes and overlaps the remaining artifact steps. In all three commands `DSH_GATE_CONCURRENCY` bounds concurrent steps (`1` runs them in dependency order) and `DSH_GATE_FAIL_FAST=1` stops the remaining steps at the first failure; otherwise a failure skips only its dependents, so independent errors are all reported. A failed step prints its own output, and any failure exits non-zero without writing the client build record. Host TypeScript compiler processes receive an 8 GiB V8 old-space limit for the package graph and cold aggregate checks. Choose the command by what the change needs: @@ -218,4 +218,4 @@ The [subsystems](subsystems/README.md) pages paste source-equivalent declaration { "doc": "docs/subsystems/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" } ``` -`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `"projection": "public-api"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact type definition. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change. +`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `"projection": "public-api"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact type definition. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. Upgrade guides in version directories older than the root package version are frozen; their examples are excluded from current-API compilation. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change. diff --git a/docs/development.zh.md b/docs/development.zh.md index 47428b923b..92f0b71b99 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -93,7 +93,7 @@ tsdown --env.DSH_BUILD_FACE client pnpm run build:web ``` -`pnpm run build:check` 只并发运行 `tsc -p tsconfig.host.json` 与 `tsc -p tsconfig.client.json`。project mode 依据磁盘上已有的包声明检查各 aggregate 的仓库级测试和脚本 program,从不编译 reference,因此 `build:check` 既不检测也不重建缺失或过期的 `build:artifacts` 产物;产物缺失时表现为编译器错误。`pnpm run build` 把两组步骤作为一张[调度](../scripts/run-gates.ts)依赖图运行:每个 aggregate 检查在本 face 的包编译完成后立即开始,与其余产物步骤重叠执行。三个命令中,`DSH_GATE_CONCURRENCY` 限制并发步骤数(`1` 按依赖顺序执行),`DSH_GATE_FAIL_FAST=1` 在第一个失败时停止其余步骤;否则失败只跳过其依赖方,因此会报告所有相互独立的错误。失败步骤打印自身输出,任一失败都以非零状态退出且不写 client build record。 +`pnpm run build:check` 只并发运行 `tsc -p tsconfig.host.json` 与 `tsc -p tsconfig.client.json`。project mode 依据磁盘上已有的包声明检查各 aggregate 的仓库级测试和脚本 program,从不编译 reference,因此 `build:check` 既不检测也不重建缺失或过期的 `build:artifacts` 产物;产物缺失时表现为编译器错误。`pnpm run build` 把两组步骤作为一张[调度](../scripts/run-gates.ts)依赖图运行:每个 aggregate 检查在本 face 的包编译完成后立即开始,与其余产物步骤重叠执行。三个命令中,`DSH_GATE_CONCURRENCY` 限制并发步骤数(`1` 按依赖顺序执行),`DSH_GATE_FAIL_FAST=1` 在第一个失败时停止其余步骤;否则失败只跳过其依赖方,因此会报告所有相互独立的错误。失败步骤打印自身输出,任一失败都以非零状态退出且不写 client build record。 Host TypeScript 编译进程的 V8 old-space 上限为 8 GiB,用于包依赖图和无缓存的 aggregate 检查。 按改动需要选择命令: @@ -225,4 +225,4 @@ Web 命令后面的参数会传给 `dsh web`,例如 `pnpm run dev:web --no-ope { "doc": "docs/subsystems/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" } ``` -`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `"projection": "public-api"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码约定和确切类型定义。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例的计算之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。 +`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `"projection": "public-api"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码约定和确切类型定义。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例的计算之外。版本目录早于根包版本的升级指南已冻结,其中的示例不参与当前 API 的编译检查。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index f2f81a5362..d344748ffd 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -10,18 +10,18 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:199`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | +| `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`) | `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-agent-team`, [`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-agent-team` | +| `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) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:336`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `client-ui-forge`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `client-ui-forge`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | | `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), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | +| `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: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` | @@ -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` | @@ -71,15 +72,15 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:71`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `forge-session`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), `ui-trajectory`, [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), `ui-trajectory`, [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `file-upload`, `forge-session`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), `ui-trajectory`, [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), `ui-trajectory`, [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:166`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), `server` | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:161`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), `server` | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:137`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:143`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | `browser-use-runtime`, [`working-directory`](../packages/session/working-directory) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/events.ts:56`](../packages/core/tools/src/events.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime` | @@ -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 8cfc85b875..cee3960282 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -12,18 +12,18 @@ | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:199`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | +| `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`) | `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-agent-team`, [`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-agent-team` | +| `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) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:336`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `client-ui-forge`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `client-ui-forge`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | | `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), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | +| `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: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` | @@ -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` | @@ -73,15 +74,15 @@ | `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:71`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `forge-session`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), `ui-trajectory`, [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), `ui-trajectory`, [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `file-upload`, `forge-session`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), `ui-trajectory`, [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), `ui-trajectory`, [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:166`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), `server` | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:161`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), `server` | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:137`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:143`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | `browser-use-runtime`, [`working-directory`](../packages/session/working-directory) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/events.ts:56`](../packages/core/tools/src/events.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime` | @@ -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 e73a0bb594..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"] @@ -161,6 +159,7 @@ flowchart TD pkg_client_shortcuts["client-shortcuts"] pkg_client_store["client-store"] pkg_client_ui_agent_preset["client-ui-agent-preset"] + pkg_client_ui_agent_team["client-ui-agent-team"] pkg_client_ui_approval["client-ui-approval"] pkg_client_ui_attachment["client-ui-attachment"] pkg_client_ui_brand_official["client-ui-brand-official"] @@ -263,8 +262,6 @@ flowchart TD pkg_creation_recommendations["creation-recommendations"] pkg_cua["cua"] pkg_cua_bundle["cua-bundle"] - pkg_experimental_agent_team["experimental-agent-team"] - pkg_experimental_agent_team_profile["experimental-agent-team-profile"] pkg_experimental_api_speech_to_text["experimental-api-speech-to-text"] pkg_experimental_auto_review["experimental-auto-review"] pkg_experimental_badge_skill_bundle["experimental-badge-skill-bundle"] @@ -273,7 +270,6 @@ flowchart TD pkg_experimental_browser_use_runtime["experimental-browser-use-runtime"] pkg_experimental_browser_use_stagehand_native["experimental-browser-use-stagehand-native"] pkg_experimental_claude_code_mods["experimental-claude-code-mods"] - pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"] pkg_experimental_client_ui_claude_code_mods["experimental-client-ui-claude-code-mods"] pkg_experimental_client_ui_cot_translation["experimental-client-ui-cot-translation"] pkg_experimental_client_ui_forge["experimental-client-ui-forge"] @@ -301,7 +297,6 @@ flowchart TD pkg_experimental_speech_to_text_sensevoice["experimental-speech-to-text-sensevoice"] pkg_experimental_subagent_oar["experimental-subagent-oar"] pkg_experimental_terminal_bundle["experimental-terminal-bundle"] - pkg_experimental_tool_agent_team["experimental-tool-agent-team"] pkg_experimental_tool_subagent_oar["experimental-tool-subagent-oar"] pkg_experimental_tool_worktree["experimental-tool-worktree"] pkg_experimental_translator["experimental-translator"] @@ -369,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"] @@ -452,6 +448,10 @@ flowchart TD pkg_subprocess_local["subprocess-local"] pkg_win32_process["win32-process"] end + subgraph group_team["packages/team"] + pkg_agent_team["agent-team"] + pkg_tool_agent_team["tool-agent-team"] + end subgraph group_telemetry["packages/telemetry"] pkg_otel["otel"] end @@ -516,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 @@ -766,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 @@ -903,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 @@ -1155,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 @@ -1292,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 @@ -1329,6 +1335,7 @@ flowchart TD pkg_tool_subagent_control --> pkg_llm pkg_tool_subagent_control --> pkg_session pkg_tool_subagent_control --> pkg_subagent + pkg_tool_subagent_control --> pkg_system_prompt pkg_tool_subagent_control --> pkg_tools pkg_api_session_controller --> pkg_agent pkg_api_session_controller --> pkg_agent_default_model @@ -1395,12 +1402,6 @@ flowchart TD pkg_session_reference --> pkg_subagent pkg_session_reference --> pkg_system_prompt pkg_session_reference --> pkg_typert_protocol - pkg_experimental_agent_team --> pkg_agent - pkg_experimental_agent_team --> pkg_llm - pkg_experimental_agent_team --> pkg_session - pkg_experimental_agent_team --> pkg_session_persistence - pkg_experimental_agent_team --> pkg_session_projection - pkg_experimental_agent_team --> pkg_subagent pkg_experimental_subagent_oar --> pkg_llm pkg_experimental_subagent_oar --> pkg_session pkg_experimental_subagent_oar --> pkg_subagent @@ -1425,6 +1426,13 @@ flowchart TD pkg_sdk_protocol --> pkg_session pkg_sdk_protocol --> pkg_subagent pkg_sdk_protocol --> pkg_user_questions + pkg_agent_team --> pkg_agent + pkg_agent_team --> pkg_llm + pkg_agent_team --> pkg_session + pkg_agent_team --> pkg_session_persistence + pkg_agent_team --> pkg_session_projection + pkg_agent_team --> pkg_subagent + pkg_agent_team --> pkg_typert_protocol pkg_workflow_ptc --> pkg_agent pkg_workflow_ptc --> pkg_llm pkg_workflow_ptc --> pkg_ptc_runtime @@ -1435,11 +1443,6 @@ flowchart TD pkg_workflow_ptc --> pkg_tools pkg_workflow_ptc --> pkg_workflow pkg_workflow_ptc --> pkg_working_directory - pkg_experimental_tool_agent_team --> pkg_agent - pkg_experimental_tool_agent_team --> pkg_experimental_agent_team - pkg_experimental_tool_agent_team --> pkg_session - pkg_experimental_tool_agent_team --> pkg_system_prompt - pkg_experimental_tool_agent_team --> pkg_tools pkg_schedule --> pkg_agent pkg_schedule --> pkg_api_session_controller pkg_schedule --> pkg_brand @@ -1456,6 +1459,12 @@ flowchart TD pkg_sdk_client --> pkg_llm pkg_sdk_client --> pkg_sdk_protocol pkg_sdk_client --> pkg_session + pkg_tool_agent_team --> pkg_agent + pkg_tool_agent_team --> pkg_agent_team + pkg_tool_agent_team --> pkg_session + pkg_tool_agent_team --> pkg_subagent + pkg_tool_agent_team --> pkg_system_prompt + pkg_tool_agent_team --> pkg_tools pkg_client_test_runtime --> pkg_api_gateway pkg_client_test_runtime --> pkg_api_session_controller pkg_client_test_runtime --> pkg_api_workspace_controller @@ -1549,6 +1558,7 @@ flowchart TD | [`client-shortcuts`](../packages/client/shortcuts) | `client` | — | | [`client-store`](../packages/client/store) | `client` | — | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | — | +| [`client-ui-agent-team`](../packages/client/ui-agent-team) | `client` | — | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | — | | [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | — | | [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | — | @@ -1614,9 +1624,7 @@ flowchart TD | [`client-ui-creation-recommendations`](../packages/experimental/client-ui-creation-recommendations) | `experimental` | — | | [`client-ui-plugin-recommendations`](../packages/experimental/client-ui-plugin-recommendations) | `experimental` | — | | [`cua-bundle`](../packages/experimental/cua-bundle) | `experimental` | — | -| [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — | | [`experimental-badge-skill-bundle`](../packages/experimental/badge-skill-bundle) | `experimental` | — | -| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | — | | [`experimental-client-ui-claude-code-mods`](../packages/experimental/client-ui-claude-code-mods) | `experimental` | — | | [`experimental-client-ui-stats`](../packages/experimental/client-ui-stats) | `experimental` | — | | [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | — | @@ -1678,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) | @@ -1756,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) | @@ -1782,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) | @@ -1832,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) | @@ -1851,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) | @@ -1858,22 +1865,22 @@ flowchart TD | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | +| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) | | [`plugin-market`](../packages/boot/plugin-market) | `boot` | [`plugin-manager`](../packages/boot/plugin-manager), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | -| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent) | | [`experimental-subagent-oar`](../packages/experimental/subagent-oar) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`experimental-tool-subagent-oar`](../packages/experimental/tool-subagent-oar) | `experimental` | [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-ralph`](../packages/experimental/tool-ralph) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`commands`](../packages/interaction/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | +| [`agent-team`](../packages/team/agent-team) | `team` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow), [`working-directory`](../packages/session/working-directory) | -| [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`util-time`](../packages/util/time), [`workspace`](../packages/workspace/workspace) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | +| [`tool-agent-team`](../packages/team/tool-agent-team) | `team` | [`agent`](../packages/core/agent), [`agent-team`](../packages/team/agent-team), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-context-menu`](../packages/client/ui-context-menu), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`tool-schedule`](../packages/schedule/tool-schedule) | `schedule` | [`llm`](../packages/llm/llm), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 818130b1fe..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"] @@ -163,6 +161,7 @@ flowchart TD pkg_client_shortcuts["client-shortcuts"] pkg_client_store["client-store"] pkg_client_ui_agent_preset["client-ui-agent-preset"] + pkg_client_ui_agent_team["client-ui-agent-team"] pkg_client_ui_approval["client-ui-approval"] pkg_client_ui_attachment["client-ui-attachment"] pkg_client_ui_brand_official["client-ui-brand-official"] @@ -265,8 +264,6 @@ flowchart TD pkg_creation_recommendations["creation-recommendations"] pkg_cua["cua"] pkg_cua_bundle["cua-bundle"] - pkg_experimental_agent_team["experimental-agent-team"] - pkg_experimental_agent_team_profile["experimental-agent-team-profile"] pkg_experimental_api_speech_to_text["experimental-api-speech-to-text"] pkg_experimental_auto_review["experimental-auto-review"] pkg_experimental_badge_skill_bundle["experimental-badge-skill-bundle"] @@ -275,7 +272,6 @@ flowchart TD pkg_experimental_browser_use_runtime["experimental-browser-use-runtime"] pkg_experimental_browser_use_stagehand_native["experimental-browser-use-stagehand-native"] pkg_experimental_claude_code_mods["experimental-claude-code-mods"] - pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"] pkg_experimental_client_ui_claude_code_mods["experimental-client-ui-claude-code-mods"] pkg_experimental_client_ui_cot_translation["experimental-client-ui-cot-translation"] pkg_experimental_client_ui_forge["experimental-client-ui-forge"] @@ -303,7 +299,6 @@ flowchart TD pkg_experimental_speech_to_text_sensevoice["experimental-speech-to-text-sensevoice"] pkg_experimental_subagent_oar["experimental-subagent-oar"] pkg_experimental_terminal_bundle["experimental-terminal-bundle"] - pkg_experimental_tool_agent_team["experimental-tool-agent-team"] pkg_experimental_tool_subagent_oar["experimental-tool-subagent-oar"] pkg_experimental_tool_worktree["experimental-tool-worktree"] pkg_experimental_translator["experimental-translator"] @@ -371,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"] @@ -454,6 +450,10 @@ flowchart TD pkg_subprocess_local["subprocess-local"] pkg_win32_process["win32-process"] end + subgraph group_team["packages/team"] + pkg_agent_team["agent-team"] + pkg_tool_agent_team["tool-agent-team"] + end subgraph group_telemetry["packages/telemetry"] pkg_otel["otel"] end @@ -518,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 @@ -768,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 @@ -905,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 @@ -1157,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 @@ -1294,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 @@ -1331,6 +1337,7 @@ flowchart TD pkg_tool_subagent_control --> pkg_llm pkg_tool_subagent_control --> pkg_session pkg_tool_subagent_control --> pkg_subagent + pkg_tool_subagent_control --> pkg_system_prompt pkg_tool_subagent_control --> pkg_tools pkg_api_session_controller --> pkg_agent pkg_api_session_controller --> pkg_agent_default_model @@ -1397,12 +1404,6 @@ flowchart TD pkg_session_reference --> pkg_subagent pkg_session_reference --> pkg_system_prompt pkg_session_reference --> pkg_typert_protocol - pkg_experimental_agent_team --> pkg_agent - pkg_experimental_agent_team --> pkg_llm - pkg_experimental_agent_team --> pkg_session - pkg_experimental_agent_team --> pkg_session_persistence - pkg_experimental_agent_team --> pkg_session_projection - pkg_experimental_agent_team --> pkg_subagent pkg_experimental_subagent_oar --> pkg_llm pkg_experimental_subagent_oar --> pkg_session pkg_experimental_subagent_oar --> pkg_subagent @@ -1427,6 +1428,13 @@ flowchart TD pkg_sdk_protocol --> pkg_session pkg_sdk_protocol --> pkg_subagent pkg_sdk_protocol --> pkg_user_questions + pkg_agent_team --> pkg_agent + pkg_agent_team --> pkg_llm + pkg_agent_team --> pkg_session + pkg_agent_team --> pkg_session_persistence + pkg_agent_team --> pkg_session_projection + pkg_agent_team --> pkg_subagent + pkg_agent_team --> pkg_typert_protocol pkg_workflow_ptc --> pkg_agent pkg_workflow_ptc --> pkg_llm pkg_workflow_ptc --> pkg_ptc_runtime @@ -1437,11 +1445,6 @@ flowchart TD pkg_workflow_ptc --> pkg_tools pkg_workflow_ptc --> pkg_workflow pkg_workflow_ptc --> pkg_working_directory - pkg_experimental_tool_agent_team --> pkg_agent - pkg_experimental_tool_agent_team --> pkg_experimental_agent_team - pkg_experimental_tool_agent_team --> pkg_session - pkg_experimental_tool_agent_team --> pkg_system_prompt - pkg_experimental_tool_agent_team --> pkg_tools pkg_schedule --> pkg_agent pkg_schedule --> pkg_api_session_controller pkg_schedule --> pkg_brand @@ -1458,6 +1461,12 @@ flowchart TD pkg_sdk_client --> pkg_llm pkg_sdk_client --> pkg_sdk_protocol pkg_sdk_client --> pkg_session + pkg_tool_agent_team --> pkg_agent + pkg_tool_agent_team --> pkg_agent_team + pkg_tool_agent_team --> pkg_session + pkg_tool_agent_team --> pkg_subagent + pkg_tool_agent_team --> pkg_system_prompt + pkg_tool_agent_team --> pkg_tools pkg_client_test_runtime --> pkg_api_gateway pkg_client_test_runtime --> pkg_api_session_controller pkg_client_test_runtime --> pkg_api_workspace_controller @@ -1551,6 +1560,7 @@ flowchart TD | [`client-shortcuts`](../packages/client/shortcuts) | `client` | — | | [`client-store`](../packages/client/store) | `client` | — | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | — | +| [`client-ui-agent-team`](../packages/client/ui-agent-team) | `client` | — | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | — | | [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | — | | [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | — | @@ -1616,9 +1626,7 @@ flowchart TD | [`client-ui-creation-recommendations`](../packages/experimental/client-ui-creation-recommendations) | `experimental` | — | | [`client-ui-plugin-recommendations`](../packages/experimental/client-ui-plugin-recommendations) | `experimental` | — | | [`cua-bundle`](../packages/experimental/cua-bundle) | `experimental` | — | -| [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — | | [`experimental-badge-skill-bundle`](../packages/experimental/badge-skill-bundle) | `experimental` | — | -| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | — | | [`experimental-client-ui-claude-code-mods`](../packages/experimental/client-ui-claude-code-mods) | `experimental` | — | | [`experimental-client-ui-stats`](../packages/experimental/client-ui-stats) | `experimental` | — | | [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | — | @@ -1680,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) | @@ -1758,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) | @@ -1784,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) | @@ -1834,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) | @@ -1853,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) | @@ -1860,22 +1867,22 @@ flowchart TD | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | +| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) | | [`plugin-market`](../packages/boot/plugin-market) | `boot` | [`plugin-manager`](../packages/boot/plugin-manager), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | -| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent) | | [`experimental-subagent-oar`](../packages/experimental/subagent-oar) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`experimental-tool-subagent-oar`](../packages/experimental/tool-subagent-oar) | `experimental` | [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-ralph`](../packages/experimental/tool-ralph) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`commands`](../packages/interaction/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | +| [`agent-team`](../packages/team/agent-team) | `team` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow), [`working-directory`](../packages/session/working-directory) | -| [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`util-time`](../packages/util/time), [`workspace`](../packages/workspace/workspace) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | +| [`tool-agent-team`](../packages/team/tool-agent-team) | `team` | [`agent`](../packages/core/agent), [`agent-team`](../packages/team/agent-team), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-context-menu`](../packages/client/ui-context-menu), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`tool-schedule`](../packages/schedule/tool-schedule) | `schedule` | [`llm`](../packages/llm/llm), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 53c7d45680..338dcc1299 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -9,8 +9,8 @@ en: 1387c4505dde2faf zh: 48570fa1c13d6910 /session-persistence-event-catalog/persistence-type-fingerprints: - en: 31c6dab0878b05a8 - zh: 176d0f7cbdc3fce3 + en: d334560286daab57 + zh: ddece2494dd2131c /session-persistence-event-catalog/event-envelope: en: 56414ffd00cdc32a zh: 6dc11eb013e95e41 @@ -27,8 +27,8 @@ en: 49891ca9be5ac123 zh: 49891ca9be5ac123 /session-persistence-event-catalog/events/agent-preset/agent-preset-selected-log-only: - en: 9eb4e8ec6b503d83 - zh: 6d96288a30d92b81 + en: 50a6b116a29bc6b0 + zh: 7fce128060557296 /session-persistence-event-catalog/events/approval: en: 9d2ec4c4c3664b9f zh: 9d2ec4c4c3664b9f @@ -225,17 +225,20 @@ en: a640800a38b3c874 zh: a640800a38b3c874 /session-persistence-event-catalog/events/team/team-member-log-only: - en: a2fe5b8582ef5f46 - zh: 42bfffcde3f3d886 + en: b52b572664da0f8e + zh: 1c8fd9d1634ec6e1 /session-persistence-event-catalog/events/team/team-message-delivered-log-only: - en: 4a716b14e33eab18 - zh: dcde26e4dc40b62e + en: 6c9a217924277caa + zh: 417f47f16980f494 /session-persistence-event-catalog/events/team/team-message-queued-log-only: - en: 05bf873244099da5 - zh: 8a0b62912087f4aa + en: 1cf7f8d33840ab23 + zh: 6b52612308519ddc +/session-persistence-event-catalog/events/team/team-mode-log-only: + en: a3d97b5c076edb80 + zh: b45b64d87c16479a /session-persistence-event-catalog/events/team/team-task-log-only: - en: 5ffeb64225feda2a - zh: d58129e189a726fb + en: 36e28e9681653ab9 + zh: 3db7addbba6899e4 /session-persistence-event-catalog/events/todo: en: 308d4bb4b6b96e35 zh: 308d4bb4b6b96e35 @@ -684,8 +687,11 @@ en: 49351fa0be662888 zh: 49351fa0be662888 /session-persistence-event-catalog/resolved-persistence-types/recall: - en: 5c5a7a7e6517bdf0 - zh: 5c5a7a7e6517bdf0 + en: c04491605decd774 + zh: c04491605decd774 +/session-persistence-event-catalog/resolved-persistence-types/refusal: + en: e55aa9d6983aecde + zh: e55aa9d6983aecde /session-persistence-event-catalog/resolved-persistence-types/rejected: en: b982810b4d04978d zh: b982810b4d04978d @@ -816,8 +822,11 @@ en: 6795a23aa9bc8d2e zh: 6795a23aa9bc8d2e /session-persistence-event-catalog/resolved-persistence-types/team-message-queued: - en: d683235390472024 - zh: d683235390472024 + en: efe4678972a1a64e + zh: efe4678972a1a64e +/session-persistence-event-catalog/resolved-persistence-types/team-mode: + en: 91a387e367e07f73 + zh: 91a387e367e07f73 /session-persistence-event-catalog/resolved-persistence-types/team-task: en: 7aa9286372341561 zh: 7aa9286372341561 @@ -1011,11 +1020,11 @@ en: a6a2e38fdd6d127a zh: 1fe82356d89f5769 /session-persistence-event-catalog/resolved-persistence-types/deepseeksearchllmrequest: - en: fd75d1f4b9bd643c - zh: dde892ea9752f298 + en: 054dbd6367e1403d + zh: 5b071d10ae1ec245 /session-persistence-event-catalog/resolved-persistence-types/developermessage: - en: c6171fd8144d74b1 - zh: 2f3dd34cc8d7ed92 + en: 58bc64eb0f7fb589 + zh: 8cc8279eb5786ef5 /session-persistence-event-catalog/resolved-persistence-types/epochheader: en: 3bdc8ecd0bdf0c9c zh: 0fe5b75c174ebab4 @@ -1125,11 +1134,11 @@ en: 6dbe099c62f7c226 zh: 6fd788f63c038c91 /session-persistence-event-catalog/resolved-persistence-types/llmretrystartedeventdata: - en: 06b510217b6056c4 - zh: 5b3a1e022f96de57 + en: a3754a186db0e1bb + zh: e9467b50a27889d5 /session-persistence-event-catalog/resolved-persistence-types/message: - en: 7a8ec632289bc7f9 - zh: 5c90cb4f3d5d0a67 + en: 0be581a4095c3493 + zh: 2c815426f3d3717a /session-persistence-event-catalog/resolved-persistence-types/messagefeedbackdelete: en: c582953270d4ca44 zh: 966ccfe2e7660a2a @@ -1140,14 +1149,14 @@ en: f58575a1fc96df42 zh: 9f0c6ed9033e5c9d /session-persistence-event-catalog/resolved-persistence-types/messagefeedbackrating: - en: 65fb012fcc0fa7db - zh: b829c586714e45e2 + en: 702995c31653ab91 + zh: cf60875749663bd3 /session-persistence-event-catalog/resolved-persistence-types/messagesource: - en: ddeed90cb6112496 - zh: cefb41765c45b22f + en: 1ec5fd7b1b8050a9 + zh: ee4a3f61be48cd17 /session-persistence-event-catalog/resolved-persistence-types/message~2: - en: 8e96551640079569 - zh: a06d587638d42237 + en: 9e25f2f1513f7b15 + zh: 2723b6aa3d8b723e /session-persistence-event-catalog/resolved-persistence-types/modelmessagesource: en: 5f9154ca34235ab9 zh: a833105910ebfa82 @@ -1206,11 +1215,11 @@ en: 9a54c4478ad3b620 zh: 02c4c1fd7e252770 /session-persistence-event-catalog/resolved-persistence-types/sessiontitleeventdata: - en: 1f9f610fa0e05abf - zh: 11560710a1b871de + en: a537c3568f3f07db + zh: 20c97f3c3fd82e8b /session-persistence-event-catalog/resolved-persistence-types/sessiontitlellmrequesteventdata: - en: 465fe144e60dd62f - zh: 29e1029426106c3b + en: efa083dfdcf04208 + zh: eed525395ff9f2e8 /session-persistence-event-catalog/resolved-persistence-types/sessiontitlemodelidentity: en: 9531468264499498 zh: 9da23c0ad2776563 @@ -1236,11 +1245,11 @@ en: 725539dcd5c335ed zh: 5ca08d3667f84ff9 /session-persistence-event-catalog/resolved-persistence-types/subagentdescriptordata: - en: c56821e57987ae9e - zh: 0364f59a472d51da + en: 2b9c68682eec6f78 + zh: 8ef5e64326c1eae6 /session-persistence-event-catalog/resolved-persistence-types/subagentsettledmessagesource: - en: d34acab659e74957 - zh: 25cabc6c9fb44a7e + en: e6d9113dc09f4098 + zh: 3426e4f2f3cc5612 /session-persistence-event-catalog/resolved-persistence-types/surfaceop: en: 737806b6ec54263d zh: b4e44293950ddf8c @@ -1248,26 +1257,26 @@ en: 94876079d6696df9 zh: d25c7ad9f5054eb7 /session-persistence-event-catalog/resolved-persistence-types/systempromptmessagesource: - en: a3eabd0e9fc565dd - zh: fa763aefdbc1bdb8 + en: 967c82dcda03d0d4 + zh: ad590611be701d97 /session-persistence-event-catalog/resolved-persistence-types/teammemberphase: - en: 2bf8f06fe328ab33 - zh: ded602f18e0deadf + en: bf78ae1a14d64469 + zh: 0d5c34aff23a133c /session-persistence-event-catalog/resolved-persistence-types/teammembersnapshot: - en: 8a37845006830548 - zh: b8e7caf6189b92df + en: 1815ad87650025e6 + zh: d2a0dceabcc2090e /session-persistence-event-catalog/resolved-persistence-types/teammessagesnapshot: - en: 9b94faac43ba068f - zh: a83582be6a6a3c1a + en: d7ba2fe5b24c90cb + zh: b26f7e1ef0eccce0 /session-persistence-event-catalog/resolved-persistence-types/teammessagesource: - en: 269f0a4fcbea1c0c - zh: 5f2c50c03c951995 + en: 2ca6c43fc056fd37 + zh: 3d5b5f2917f326fc /session-persistence-event-catalog/resolved-persistence-types/teamtasksnapshot: - en: cfa8230ef5d661b2 - zh: cb40ce86d6778f09 + en: 0c6aac89e3dcb25c + zh: d772017667ffc2a1 /session-persistence-event-catalog/resolved-persistence-types/teamtaskstatus: - en: 9bff57038f472770 - zh: 4eec0e793ae16d53 + en: b6bd8e9986773217 + zh: ed177c2f090964af /session-persistence-event-catalog/resolved-persistence-types/textblock: en: 7ad0f4915397b511 zh: bcaa8bd449927989 @@ -1320,14 +1329,14 @@ en: 778debd61e0284a2 zh: 2cc82ad9e80f18b1 /session-persistence-event-catalog/resolved-persistence-types/turnendreason: - en: 953127f7cf1e0866 - zh: 3613ef1aafd8191a + en: bcd450bc3ade2a1a + zh: e442be61e4391383 /session-persistence-event-catalog/resolved-persistence-types/usermessage: - en: a15a08beb2b824da - zh: 4fd8dc6b9680c4cf + en: 6f29a4e108379f9a + zh: 2bf0f494b8fcd2b2 /session-persistence-event-catalog/resolved-persistence-types/usermessage~2: - en: 85e537b82f1da7d8 - zh: 73a1b9d4b5d29ab0 + en: e4fc10ce549b51ad + zh: cb29ffcb8003b355 /session-persistence-event-catalog/resolved-persistence-types/workflowagentoutcome: en: d0a3f9aab18c3de5 zh: 76ac69a835ca4e29 @@ -1350,11 +1359,11 @@ en: 0223d7ffb67ac272 zh: c2a8221caab6d350 /session-persistence-event-catalog/resolved-persistence-types/number~2: - en: 2786fef560851bb4 - zh: 5d787475356b2606 + en: 4147b170f52814af + zh: e78991ac1c41ebec /session-persistence-event-catalog/resolved-persistence-types/string: - en: 0179cc082ac8637c - zh: e740ade45db5c263 + en: caa52cd38cec3aee + zh: 3ff0f1fceaac2eb1 /session-persistence-event-catalog/resolved-persistence-types/string~2: en: 0089620bf4638263 zh: 1922bef1713094bb @@ -1392,8 +1401,11 @@ en: 9e296094df7462ba zh: e7e3255a3ada9279 /session-persistence-event-catalog/resolved-persistence-types/union-3-variants~4: - en: 5fc89a7ec1cecd78 - zh: dc66aa8e4aae1847 + en: 4ac5570e2b5181be + zh: 447832dc281d0caf +/session-persistence-event-catalog/resolved-persistence-types/union-5-variants: + en: f60d7d997e33f12d + zh: ec049a1c1ab9f8b3 /session-persistence-event-catalog/resolved-persistence-types/union-6-variants: en: ca99faddb359b6bb zh: 5c7ac9b445d9e5be @@ -1410,8 +1422,8 @@ en: c861888d6239b700 zh: a9b2271d3c6b8737 /session-persistence-event-catalog/resolved-persistence-types/agentpreset: - en: 1afbe8196f161601 - zh: a320a36a8928817c + en: 1282a0b3d5fa8d4d + zh: aa533aea643dbefb /session-persistence-event-catalog/resolved-persistence-types/allowedmodels: en: 6178321110ceb69d zh: 0a9d94cab9d57487 @@ -1491,8 +1503,11 @@ en: 9748b4f0facfe9f8 zh: 83ae2cd2cd02b804 /session-persistence-event-catalog/resolved-persistence-types/displayname-providerid-subjectid-tenantid: - en: d2c98ed9b98325ae - zh: bf8c2f7967cf9cc8 + en: c6fbe4268a4430db + zh: 8fec9d6def3c775c +/session-persistence-event-catalog/resolved-persistence-types/enabled: + en: 413aaf647388ee21 + zh: f36d0158e587bab3 /session-persistence-event-catalog/resolved-persistence-types/end-start: en: 4b7a8793663d3fef zh: 249459c825d330c3 @@ -1512,11 +1527,11 @@ en: 4cb1c48095df98dc zh: 5a03e44761af559e /session-persistence-event-catalog/resolved-persistence-types/header-reason-startsseries: - en: 7d1610d20e987988 - zh: 1297cba0f2a2c73d + en: c21561adcf9398a5 + zh: fac8b21392750aae /session-persistence-event-catalog/resolved-persistence-types/headerseq-message-step-turn: - en: 4f9635604ed5b84d - zh: 74810a2d049907e6 + en: 081d257a87d8fb2d + zh: 49bdba3da8d5f7e4 /session-persistence-event-catalog/resolved-persistence-types/height-width: en: a5df50710083ac25 zh: aad3ccf6f6de70e4 @@ -1533,11 +1548,11 @@ en: 41f73c58a77dfa28 zh: af77f4aee3cb4a40 /session-persistence-event-catalog/resolved-persistence-types/inherited: - en: 8b5c65ce7b57514f - zh: 231494d71e5d635a + en: 7c2c00c4635970ca + zh: 19818a2c43b4c57d /session-persistence-event-catalog/resolved-persistence-types/inserted-outcome-removedcount-start: - en: 5a3be677a8dc51ff - zh: c5fc8b80f73e72d9 + en: 67b7eda689a3a115 + zh: 5ffdf39f3ee061b2 /session-persistence-event-catalog/resolved-persistence-types/interrupted-message-step-stream: en: 8e86cd838e3e4a49 zh: f5fec2a8adf1f3cc @@ -1905,17 +1920,17 @@ en: 3216b23c131d0070 zh: 73ca171c6014f16e /session-persistence-event-catalog/resolved-persistence-types/member-teamid-version: - en: 82b0989dad6bbfc0 - zh: 0789b63f1d59b2ed + 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: f778769acb264dd5 - zh: eba9b81d04e9f57b + en: 4a479e13089129e3 + zh: 657d71530c8c36c3 /session-persistence-event-catalog/resolved-persistence-types/messageid-targetid-teamid-version: - en: 91a948ede28ebcb5 - zh: 8a2b0ac6bb68a09e + en: bdbf80439923b458 + zh: 624ee595a4bd994a /session-persistence-event-catalog/resolved-persistence-types/mode-source: en: 3028b222670ce5ad zh: cae4c9437e560d61 @@ -1947,8 +1962,8 @@ en: 5360af614fe753e3 zh: 96427c489cc71de6 /session-persistence-event-catalog/resolved-persistence-types/task-teamid-version: - en: a2fcd46e54b14120 - zh: d9a6e0e50bbff9ad + en: 0922cbe4e7059d58 + zh: 9a7840f818d0a746 /session-persistence-event-catalog/resolved-persistence-types/todos: en: 8ab873d30e062fd5 zh: a138832c6e299ebb @@ -1956,11 +1971,11 @@ en: 374b8adee8d571fa zh: b21057efc58e6490 /session-persistence-event-catalog/resolved-persistence-types/type-agent-preset-selected: - en: da6e084997b20cdc - zh: 1780f6fcf3496de1 + en: 2ce26699bb57cf69 + zh: a5fc9ea510a3d458 /session-persistence-event-catalog/resolved-persistence-types/type-agent-inbox-spliced: - en: 1a79ed1a97145a14 - zh: d6f658a85c1e65f7 + en: ae0b65d2268c758b + zh: 8244a6d65cfb01f3 /session-persistence-event-catalog/resolved-persistence-types/type-approval-asked: en: 9c77f18b0dad22f0 zh: 8894cf2878fe90db @@ -2004,11 +2019,11 @@ en: ba73fb10b7e2ffc9 zh: 8d5c97e63e5f16da /session-persistence-event-catalog/resolved-persistence-types/type-deliverables-presented: - en: b9c35ef9a9e1d585 - zh: 7b8ad008c8b7389c + en: f5c7b6c1d3610861 + zh: 2baec8a404127ce8 /session-persistence-event-catalog/resolved-persistence-types/type-developer-message: - en: b0a3c504326f42f9 - zh: f13a1c1ca28f7da3 + en: fe3390ea4e6769e3 + zh: 5a25cbdf990ef85f /session-persistence-event-catalog/resolved-persistence-types/type-disabled: en: 585627a50ece4e5f zh: 157837aee3da0ac3 @@ -2082,11 +2097,11 @@ en: acf7e21e4d21ebff zh: fb83a853634d204a /session-persistence-event-catalog/resolved-persistence-types/type-session-title: - en: 5a42c1482248e385 - zh: c7ba7d071e4756de + en: d26d1cd97d87f4a6 + zh: 992f1290578da1f4 /session-persistence-event-catalog/resolved-persistence-types/type-session-title-llm-request: - en: d88fd740ff167f04 - zh: fe6598a1bf4cd9bf + en: 211b5793ff00bed6 + zh: e8382b4f9adb08fd /session-persistence-event-catalog/resolved-persistence-types/type-step-end: en: 7fc233c4a569dae2 zh: bc2b3fed4a397c00 @@ -2112,8 +2127,11 @@ en: 2a00c9dbe70dcd8e zh: 96573157cd613c91 /session-persistence-event-catalog/resolved-persistence-types/type-team-message-queued: - en: 3baf69fe52f2d751 - zh: 185be1b973fac77e + en: 735e6acfb6180948 + zh: de7626c523ae57a2 +/session-persistence-event-catalog/resolved-persistence-types/type-team-mode: + en: 0b50dd6b99450d8e + zh: a9f8d2b85e2092af /session-persistence-event-catalog/resolved-persistence-types/type-team-task: en: 872aad5b748d70d5 zh: 396f986a703b76fd @@ -2163,11 +2181,11 @@ en: 1b045de96172be4b zh: 8a15c6f35b576b37 /session-persistence-event-catalog/resolved-persistence-types/type-usage: - en: a1d9300dd7a7b043 - zh: 3cb1e0e6834e9765 + en: 1f43a7944fa86bcf + zh: faa883c372923440 /session-persistence-event-catalog/resolved-persistence-types/type-user-message: - en: 44035d460a9d926b - zh: 8006b6dbfc58d5ae + en: 28e1108b02b392dc + zh: 0dde507cef7d790e /session-persistence-event-catalog/resolved-persistence-types/type-web-deepseek-search-llm-request: en: 99c8837344178a47 zh: bf746f5678346596 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 94f5b932b6..f4c69dd93e 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -23,7 +23,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `JsonlHeaderLine` | header | `18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f` | [`HeaderLine`](#persistence-type-sha256-18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f) | | `SessionEventEnvelope` | envelope | `5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc` | [`union (3 variants)`](#persistence-type-sha256-5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc) | | `event:agent-preset/selected` | event | `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | [`{ type: "agent-preset/selected" }`](#persistence-type-sha256-a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b) | -| `event:agent/inbox/spliced` | event | `cdf988172b589b230f2bce868489def1bb575eb0501076ac757a8f369ac5c64e` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-cdf988172b589b230f2bce868489def1bb575eb0501076ac757a8f369ac5c64e) | +| `event:agent/inbox/spliced` | event | `ddce54a3873dc57113b0cd38ad9b23b3c259186139e8847e5e5f415d86f52482` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-ddce54a3873dc57113b0cd38ad9b23b3c259186139e8847e5e5f415d86f52482) | | `event:approval/asked` | event | `3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1` | [`{ type: "approval/asked" }`](#persistence-type-sha256-3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1) | | `event:approval/decided` | event | `bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3` | [`{ type: "approval/decided" }`](#persistence-type-sha256-bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3) | | `event:approval/policy` | event | `26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78` | [`{ type: "approval/policy" }`](#persistence-type-sha256-26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78) | @@ -36,7 +36,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:compaction/start` | event | `db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965` | [`{ type: "compaction/start" }`](#persistence-type-sha256-db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965) | | `event:compaction/summary` | event | `e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602` | [`{ type: "compaction/summary" }`](#persistence-type-sha256-e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602) | | `event:deliverables/presented` | event | `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | [`{ type: "deliverables/presented" }`](#persistence-type-sha256-13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176) | -| `event:developer/message` | event | `70d2acdec1ebcfea56d02f7233ebb9ec0f7f494447fb952884c22c3afdc01d6f` | [`{ type: "developer/message" }`](#persistence-type-sha256-70d2acdec1ebcfea56d02f7233ebb9ec0f7f494447fb952884c22c3afdc01d6f) | +| `event:developer/message` | event | `70ea8e7943c5e2d76905bcc2e694e8c3d5b138aaae3609f3e261b8d369a6e35a` | [`{ type: "developer/message" }`](#persistence-type-sha256-70ea8e7943c5e2d76905bcc2e694e8c3d5b138aaae3609f3e261b8d369a6e35a) | | `event:feedback/message-delete` | event | `3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796` | [`{ type: "feedback/message-delete" }`](#persistence-type-sha256-3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796) | | `event:feedback/message-put` | event | `b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf` | [`{ type: "feedback/message-put" }`](#persistence-type-sha256-b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf) | | `event:feedback/record` | event | `b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8` | [`{ type: "feedback/record" }`](#persistence-type-sha256-b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8) | @@ -58,7 +58,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:session-log-deepseek/delivery-accepted` | event | `d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9` | [`{ type: "session-log-deepseek/delivery-accepted" }`](#persistence-type-sha256-d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9) | | `event:session/end-seed` | event | `5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e` | [`{ type: "session/end-seed" }`](#persistence-type-sha256-5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e) | | `event:session/title` | event | `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | [`{ type: "session/title" }`](#persistence-type-sha256-1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5) | -| `event:session/title-llm-request` | event | `2e505098f9209e52eebd577f1a6f9d4e773ec4d78987aff28a7c786442b9f54c` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-2e505098f9209e52eebd577f1a6f9d4e773ec4d78987aff28a7c786442b9f54c) | +| `event:session/title-llm-request` | event | `d95c624eddecdacf1ee74356196c09d2f3fcd625714be5e852a6b643fa12a7ca` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-d95c624eddecdacf1ee74356196c09d2f3fcd625714be5e852a6b643fa12a7ca) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | | `event:subagent/catalog` | event | `53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac) | @@ -68,6 +68,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:team/member` | event | `4fb59762612c3e3ac3a3bd4f84c9c148d3c3893bd422ba2b201cc039fabd49bc` | [`{ type: "team/member" }`](#persistence-type-sha256-4fb59762612c3e3ac3a3bd4f84c9c148d3c3893bd422ba2b201cc039fabd49bc) | | `event:team/message/delivered` | event | `48f9c19417a1abbedfa59f4667bba36b93ac2db407adf5e84cb3ba0de30942cb` | [`{ type: "team/message/delivered" }`](#persistence-type-sha256-48f9c19417a1abbedfa59f4667bba36b93ac2db407adf5e84cb3ba0de30942cb) | | `event:team/message/queued` | event | `21fb6a90d5068f6a0003b7ab316ed2f56342477146a65c00db0f13c4d8df667d` | [`{ type: "team/message/queued" }`](#persistence-type-sha256-21fb6a90d5068f6a0003b7ab316ed2f56342477146a65c00db0f13c4d8df667d) | +| `event:team/mode` | event | `920d87e22b7409bcc7224f8b1dc7f5101c33574c75627c65baab12b85cc96e75` | [`{ type: "team/mode" }`](#persistence-type-sha256-920d87e22b7409bcc7224f8b1dc7f5101c33574c75627c65baab12b85cc96e75) | | `event:team/task` | event | `d595ec73b32b016a6055333c67a5d646032b22e672da1d4a09c5b1ae398a093a` | [`{ type: "team/task" }`](#persistence-type-sha256-d595ec73b32b016a6055333c67a5d646032b22e672da1d4a09c5b1ae398a093a) | | `event:todo/write` | event | `b978cff734e62143eb56c9125423ec275405eda969802d42aaf73ecb987d3b26` | [`{ type: "todo/write" }`](#persistence-type-sha256-b978cff734e62143eb56c9125423ec275405eda969802d42aaf73ecb987d3b26) | | `event:tool-workflow/agent-end` | event | `babf9ee4d1af62bf6c3a8103737f7a5e4e78ce179be835ce05a38803e15884b7` | [`{ type: "tool-workflow/agent-end" }`](#persistence-type-sha256-babf9ee4d1af62bf6c3a8103737f7a5e4e78ce179be835ce05a38803e15884b7) | @@ -80,7 +81,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:tool/result` | event | `7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397` | [`{ type: "tool/result" }`](#persistence-type-sha256-7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397) | | `event:turn/end` | event | `0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce` | [`{ type: "turn/end" }`](#persistence-type-sha256-0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce) | | `event:turn/start` | event | `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` | [`{ type: "turn/start" }`](#persistence-type-sha256-aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8) | -| `event:user/message` | event | `5bb231bc5afdb532a6898a20604e76bf0c1db438bfcdc17d4847dceaddc02451` | [`{ type: "user/message" }`](#persistence-type-sha256-5bb231bc5afdb532a6898a20604e76bf0c1db438bfcdc17d4847dceaddc02451) | +| `event:user/message` | event | `dcb07e04496586d2664d72dab0e94312917da56927b2a0dc3ee53d6a33e32450` | [`{ type: "user/message" }`](#persistence-type-sha256-dcb07e04496586d2664d72dab0e94312917da56927b2a0dc3ee53d6a33e32450) | | `event:web/deepseek-search-llm-request` | event | `0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52` | [`{ type: "web/deepseek-search-llm-request" }`](#persistence-type-sha256-0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52) | | `event:working-directory/change` | event | `124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3` | [`{ type: "working-directory/change" }`](#persistence-type-sha256-124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3) | | `event:workspace/changes` | event | `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72` | [`{ type: "workspace/changes" }`](#persistence-type-sha256-e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72) | @@ -198,15 +199,14 @@ Source: [`packages/core/agent/src/types.ts:110`](../packages/core/agent/src/type * The session's agent preset was chosen after creation, while the session * was still blank. Log-only: it records the composition later turns ran * under, so a resumed or forked session rebuilds the same one instead of - * the header's creation-time value. In a child Session, an occurrence - * before its first own `subagent/descriptor` marks an independent spawn - * selection that cold resume restores instead of joining the parent's - * composition (`dsh-subagent` README, "Restoring child composition"). + * the header's creation-time value. Subagent restoration reads this saved + * selection independently of its creator (`dsh-subagent` README, + * "Restoring child composition"). */ 'agent-preset/selected': { agentPreset: string } ``` -Source: [`packages/preset/agent-preset-registry/src/session.ts:31`](../packages/preset/agent-preset-registry/src/session.ts) +Source: [`packages/preset/agent-preset-registry/src/session.ts:30`](../packages/preset/agent-preset-registry/src/session.ts) ### `approval/*` @@ -1009,13 +1009,13 @@ Source: [`packages/core/session/src/types.ts:338`](../packages/core/session/src/ #### `team/member` — log-only ```ts persistence-catalog -/** Whole teammate lifecycle value, stored only in the Team Lead Session. */ +/** Historical teammate lifecycle value; new teammates are continuable entries in the Lead's subagent catalog. */ 'team/member': { version: 2; teamId: TeamId; member: TeamMemberSnapshot } ``` Types: [TeamId](subsystems/agent-team.md) · [TeamMemberSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:230`](../packages/experimental/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/experimental/agent-team/src/types.ts:230`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamMessageId](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:236`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/team/agent-team/src/types.ts:181`](../packages/team/agent-team/src/types.ts) @@ -1046,7 +1046,18 @@ Source: [`packages/experimental/agent-team/src/types.ts:236`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamMessageSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:234`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/team/agent-team/src/types.ts:179`](../packages/team/agent-team/src/types.ts) + + + +#### `team/mode` — log-only + +```ts persistence-catalog +/** User-selected Team mode; child Agents use the selection of their runtime root. */ +'team/mode': { enabled: boolean } +``` + +Source: [`packages/team/agent-team/src/types.ts:173`](../packages/team/agent-team/src/types.ts) @@ -1059,7 +1070,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:234`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamTaskSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:232`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/team/agent-team/src/types.ts:177`](../packages/team/agent-team/src/types.ts) ### `todo/*` @@ -2355,6 +2366,14 @@ SHA-256: `383cf73ae4748e5beb281ae74857ba1ffb92ae01075b9fbf51af78b666007a8f` `"recall"` + + +### `"refusal"` + +SHA-256: `4a561c111a78d4520e850c93795369ea169ff86abd082aee86c8fd38121a65aa` + +`"refusal"` + ### `"rejected"` @@ -2707,6 +2726,14 @@ SHA-256: `4e011753f8263f436efaabfbb3b4db53a98fc55342c5ded2daf7208b217be159` `"team/message/queued"` + + +### `"team/mode"` + +SHA-256: `d8a476d2c6fd30df6ec0e799f91716d1518c780d23b4121dce545abfcfb7c5a5` + +`"team/mode"` + ### `"team/task"` @@ -3383,7 +3410,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:55`](../packages/web | `body` | required | [`{ max_tokens, messages, model, thinking?, … }`](#persistence-type-sha256-9c001ecb5e1d3c3894410db2a1e383842e5f8454fb52182338686158abc1b3ee) | | `endpoint` | required | `string` | - + @@ -3391,7 +3418,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:55`](../packages/web ### `DeveloperMessage` -SHA-256: `64ef542b1bfc988382d337f2ea85aab8ad76ea55341cf082163919d315688428` +SHA-256: `01c161379a3c3394a642807843e108ef911a8b7dbffd6d7de43f4484c88b0796` Sources: [`packages/llm/llm/src/message.ts:157`](../packages/llm/llm/src/message.ts) @@ -3400,7 +3427,7 @@ Sources: [`packages/llm/llm/src/message.ts:157`](../packages/llm/llm/src/message | `content` | required | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | required | `string` | | `role` | required | `"developer"` | -| `source` | required | [`MessageSource`](#persistence-type-sha256-7e9f1072c17f7aa86547c9c8b02766c1006d866a1859a3d677a336e932f70d1a) | +| `source` | required | [`MessageSource`](#persistence-type-sha256-b120362fd216a16a40fe787f6d3286086aa131266322bab79c026a130ccd0f72) | Source compatibility: `source` — `session-source-attribution` v1; `session.developer-message.source`; `kind`; `preserve`. @@ -4099,7 +4126,7 @@ Sources: [`packages/llm/llm-retry/src/types.ts:43`](../packages/llm/llm-retry/sr | `step` | required | `number` | | `turn` | required | `number` | - + @@ -4107,15 +4134,15 @@ Sources: [`packages/llm/llm-retry/src/types.ts:43`](../packages/llm/llm-retry/sr ### `Message` -SHA-256: `c78b9097bfae83cd08f0a98a92730236ba80ac61d712046959e4fc7f28aa1f36` +SHA-256: `e272493e3ea0b33b2e1d64b6a80e9d8bd3d1d466cc115f2facbdd598f8644a39` Sources: [`packages/llm/llm/src/message.ts:196`](../packages/llm/llm/src/message.ts) One of: - [`ToolResultMessage`](#persistence-type-sha256-4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731) -- [`DeveloperMessage`](#persistence-type-sha256-64ef542b1bfc988382d337f2ea85aab8ad76ea55341cf082163919d315688428) -- [`UserMessage`](#persistence-type-sha256-4035903993dfee62cca2142c0ca51ab57075909ddee2817d0620daa97ac4d4ce) +- [`DeveloperMessage`](#persistence-type-sha256-01c161379a3c3394a642807843e108ef911a8b7dbffd6d7de43f4484c88b0796) +- [`UserMessage`](#persistence-type-sha256-613660455704fb82a825b5b766e000aa64b5b4d44b4f10e55cffe968d4ec6ab1) - [`SystemMessage`](#persistence-type-sha256-09f3f8dc417f278257202714132f98ba7b52957d7b558c4a04456603890eb363) - [`AssistantMessage`](#persistence-type-sha256-fa87537f4a9c1e25602990a8bf686b383e9e00352ec8946e16ae24ba7c1625be) @@ -4192,7 +4219,7 @@ One of: - `"negative"` - `"positive"` - + @@ -4200,7 +4227,7 @@ One of: ### `MessageSource` -SHA-256: `7e9f1072c17f7aa86547c9c8b02766c1006d866a1859a3d677a336e932f70d1a` +SHA-256: `b120362fd216a16a40fe787f6d3286086aa131266322bab79c026a130ccd0f72` Sources: [`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message.ts) @@ -4279,7 +4306,7 @@ Sources: [`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message | `session-reference` | `"recall"` | `references`: [`{ capturedFormatVersion?, capturedThroughSeq, compacted, inputIndex, … }[]`](#persistence-type-sha256-df843efd87c78cf8de98f8e0d0216956cd568d0d0e56a22c853663e274aa1a18); `version`: `1` | [`SessionReferenceSource`](#persistence-type-sha256-f56c5b59ca0161310c338d31dd16cf14287ccd602ff4181058df178c1c286d7f) | | `skill-catalog` | `"catalog"` | `entries`: [`{ description, name }[]`](#persistence-type-sha256-aba175fb20ffc71112c55efe70458ff5d7dbfb83501892b4ba8d077f0ea5d7ba) | [`SkillCatalogSource`](#persistence-type-sha256-65d583e66518c5236015e0846470fb32efcf92375ed306aef14e50a9d2cd2099) | | `skill-invocation` | `"instructions"` | `name`: `string` | [`SkillInvocationSource`](#persistence-type-sha256-672c4aa2a9b736e3d6e5da6346d2e2ed5e3573f2d0baafa18e7407c9f3fee3db) | -| `subagent-settled` | `"notice"` | `senderSessionId`: `string`; `summary`: `string` | [`SubagentSettledMessageSource`](#persistence-type-sha256-4e97c3d85c0fc817ee58873c38c25a2474abaaa0af4839311b50b68db3b8cf1a) | +| `subagent-settled` | `"notice"` | `senderSessionId`: `string`; `summary`: `string` | [`SubagentSettledMessageSource`](#persistence-type-sha256-848639f8787f035b480ec53cb5686850897c5e8006d87ca231cd8a9ee8634936) | | `system-prompt` | not declared | none | [`SystemPromptMessageSource`](#persistence-type-sha256-65a8400e451e667e5b6e34df72ceb81fca8c12330330fa72083ad40279b189ad) | | `team-message` | not declared | `messageId`: `string`; `senderId`: `string`; `senderName`: `string`; `teamId`: `string` | [`TeamMessageSource`](#persistence-type-sha256-2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0) | | `time-context` | `"catalog"` | none | [`{ kind: "time-context", form: "catalog" }`](#persistence-type-sha256-85fe96bf15d5c7559eec3bae4d6cf1a76fda9f5be05a29c2830cff560cab0c0d) | @@ -4325,13 +4352,13 @@ Sources: [`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message | `webhook` | `"notice"` | `deliveryId`: `string`; `provider`: `string`; `ruleId`: `string`; `source`: `string`; `summary`: `string` | [`{ kind: "webhook", form: "notice" }`](#persistence-type-sha256-9edc162949abf93bd5ff049191b345454ba104f583937ce5580fb50f067d2b94) | | `working-directory` | not declared | none | [`{ kind: "working-directory" }`](#persistence-type-sha256-2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5) | - + ### `Message[]` -SHA-256: `ff2cbb53582d1827b02622482246979942f59e07e0393b76a161251423549e72` +SHA-256: `95b5cf90ea1f57da3319184d6f5f3811d0f651aa9113591c10e81c3d1e9b9f70` -Array of [`Message`](#persistence-type-sha256-c78b9097bfae83cd08f0a98a92730236ba80ac61d712046959e4fc7f28aa1f36). +Array of [`Message`](#persistence-type-sha256-e272493e3ea0b33b2e1d64b6a80e9d8bd3d1d466cc115f2facbdd598f8644a39). @@ -4699,7 +4726,7 @@ Sources: [`packages/session/session-title/src/types.ts:41`](../packages/session/ | `source` | required | [`SessionTitleSource`](#persistence-type-sha256-30d5a537b296685770b1241c4b4bbde19d5a31ff81aa23df73fab5e90b8fc65f) | | `title` | required | `string` | - + @@ -4707,7 +4734,7 @@ Sources: [`packages/session/session-title/src/types.ts:41`](../packages/session/ ### `SessionTitleLlmRequestEventData` -SHA-256: `7ff62f6599c083ceb5a0d7a8940ff85ba80dc1d7ddd6ba9339903e93f74cba54` +SHA-256: `601327bedca5f98a0fa4c27133bd35749f3af2baaaacf53badf074e59cea5d25` Sources: [`packages/session/session-title-llm/src/index.ts:43`](../packages/session/session-title-llm/src/index.ts) @@ -4715,7 +4742,7 @@ Sources: [`packages/session/session-title-llm/src/index.ts:43`](../packages/sess |---|---|---| | `maxTokens` | required | `number` | | `messageSeqs` | required | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | -| `messages` | required | [`Message[]`](#persistence-type-sha256-ff2cbb53582d1827b02622482246979942f59e07e0393b76a161251423549e72) | +| `messages` | required | [`Message[]`](#persistence-type-sha256-95b5cf90ea1f57da3319184d6f5f3811d0f651aa9113591c10e81c3d1e9b9f70) | | `reasoningEffort` | optional | `string` | | `route` | required | [`SessionTitleModelIdentity`](#persistence-type-sha256-07e1a58c58b593f507b8e7f25723bb0aa42baae6e00f5ecfddf28cb1e64d3235) | | `system` | required | `string` | @@ -4884,7 +4911,7 @@ One of: - [`ContinuableSubagentDescriptorData`](#persistence-type-sha256-6487b10ba7542f3c447b1e41a1c6a590b7bfafe0b9b66b49f34028da85bb970f) - [`OneShotSubagentDescriptorData`](#persistence-type-sha256-5f758f3de5f00c18968a7c6d347083aac5c9ae13f6a79c78f6611b23c4973564) - + @@ -4892,7 +4919,7 @@ One of: ### `SubagentSettledMessageSource` -SHA-256: `4e97c3d85c0fc817ee58873c38c25a2474abaaa0af4839311b50b68db3b8cf1a` +SHA-256: `848639f8787f035b480ec53cb5686850897c5e8006d87ca231cd8a9ee8634936` Sources: [`packages/subagent/subagent/src/continuation-messages.ts:29`](../packages/subagent/subagent/src/continuation-messages.ts) @@ -4901,6 +4928,7 @@ Sources: [`packages/subagent/subagent/src/continuation-messages.ts:29`](../packa | `form` | required | `"notice"` | | `kind` | required | `"subagent-settled"` | | `senderSessionId` | required | `string` | +| `stopReason` | optional | [`union (5 variants)`](#persistence-type-sha256-866f7b2dbdc5b421eab3e629312d1c839b75c259633b4b43b50d23bac44d92a0) | | `summary` | required | `string` | @@ -4957,7 +4985,7 @@ Sources: [`packages/llm/llm/src/message.ts:35`](../packages/llm/llm/src/message. - + @@ -4965,7 +4993,7 @@ Sources: [`packages/llm/llm/src/message.ts:35`](../packages/llm/llm/src/message. SHA-256: `dfce5db4aa42b7ebe0bb79eca92553587287e18c06b9eba85cbfb9d332e26acd` -Sources: [`packages/experimental/agent-team/src/types.ts:45`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:47`](../packages/team/agent-team/src/types.ts) One of: @@ -4975,7 +5003,7 @@ One of: - + @@ -4983,7 +5011,7 @@ One of: SHA-256: `6598cd6da253948d6b207c6374a45335804c79b576866d6215649c2d1bc85e25` -Sources: [`packages/experimental/agent-team/src/types.ts:48`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:50`](../packages/team/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4997,7 +5025,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:48`](../packages/experi - + @@ -5005,7 +5033,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:48`](../packages/experi SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e` -Sources: [`packages/experimental/agent-team/src/types.ts:129`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:108`](../packages/team/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -5017,7 +5045,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:129`](../packages/exper - + @@ -5025,7 +5053,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:129`](../packages/exper SHA-256: `2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0` -Sources: [`packages/experimental/agent-team/src/types.ts:138`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:117`](../packages/team/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -5037,7 +5065,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:138`](../packages/exper - + @@ -5045,7 +5073,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:138`](../packages/exper SHA-256: `51edec100659da3a9bd9c3f6ba1180f5e64ec9a1585e5057080df45c180bd357` -Sources: [`packages/experimental/agent-team/src/types.ts:75`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:64`](../packages/team/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -5060,7 +5088,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:75`](../packages/experi - + @@ -5068,7 +5096,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:75`](../packages/experi SHA-256: `b2d2a01e51341a3bfec809198952c8c830a7899f4cb6ecd3c1dc0b5d9a2af943` -Sources: [`packages/experimental/agent-team/src/types.ts:72`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:61`](../packages/team/agent-team/src/types.ts) One of: @@ -5390,7 +5418,7 @@ Sources: [`packages/core/session/src/types.ts:238`](../packages/core/session/src | `interrupted` | not declared | none | [`{ kind: "interrupted" }`](#persistence-type-sha256-56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23) | | `max-tokens` | not declared | none | [`{ kind: "max-tokens" }`](#persistence-type-sha256-ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7) | - + @@ -5398,7 +5426,7 @@ Sources: [`packages/core/session/src/types.ts:238`](../packages/core/session/src ### `UserMessage` -SHA-256: `4035903993dfee62cca2142c0ca51ab57075909ddee2817d0620daa97ac4d4ce` +SHA-256: `613660455704fb82a825b5b766e000aa64b5b4d44b4f10e55cffe968d4ec6ab1` Sources: [`packages/llm/llm/src/message.ts:162`](../packages/llm/llm/src/message.ts) @@ -5407,19 +5435,19 @@ Sources: [`packages/llm/llm/src/message.ts:162`](../packages/llm/llm/src/message | `content` | required | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | required | `string` | | `role` | required | `"user"` | -| `source` | required | [`MessageSource`](#persistence-type-sha256-7e9f1072c17f7aa86547c9c8b02766c1006d866a1859a3d677a336e932f70d1a) | +| `source` | required | [`MessageSource`](#persistence-type-sha256-b120362fd216a16a40fe787f6d3286086aa131266322bab79c026a130ccd0f72) | Source compatibility: `source` — `session-source-attribution` v1; `session.user-message.source`; `kind`; `preserve`. Attribution-only additions: `action-cards`, `tmux-context`, `user-question-reply`, `working-directory`. - + ### `UserMessage[]` -SHA-256: `7c203f3d4dfa6638ff2601247cc7ca5d46731d2c614aa61217475b9eb1fbd0b3` +SHA-256: `690583582246babf6120f16eefd849dd760c5ec84619014951af403a1b3032c0` -Array of [`UserMessage`](#persistence-type-sha256-4035903993dfee62cca2142c0ca51ab57075909ddee2817d0620daa97ac4d4ce). +Array of [`UserMessage`](#persistence-type-sha256-613660455704fb82a825b5b766e000aa64b5b4d44b4f10e55cffe968d4ec6ab1). @@ -5537,12 +5565,6 @@ Array of `number`. - - - - - - @@ -5575,6 +5597,12 @@ Array of `number`. + + + + + + @@ -5611,7 +5639,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -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/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/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/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` @@ -5755,6 +5783,20 @@ One of: - `"in_progress"` - `"pending"` + + +### `union (5 variants)` + +SHA-256: `866f7b2dbdc5b421eab3e629312d1c839b75c259633b4b43b50d23bac44d92a0` + +One of: + +- `"aborted"` +- `"completed"` +- `"error"` +- `"max-tokens"` +- `"refusal"` + ### `union (6 variants)` @@ -5820,7 +5862,7 @@ Sources: [`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/ SHA-256: `6e52dd55a1d48954ed5464ec46fe34d9eb65f709baabc9561e7a279910b0c406` -Sources: [`packages/preset/agent-preset-registry/src/session.ts:31`](../packages/preset/agent-preset-registry/src/session.ts) +Sources: [`packages/preset/agent-preset-registry/src/session.ts:30`](../packages/preset/agent-preset-registry/src/session.ts) | Property | Presence | Type | |---|---|---| @@ -6234,6 +6276,18 @@ Sources: [`packages/experimental/liveshare/src/host/viewer-principal.ts:29`](../ | `subjectId` | required | `string` | | `tenantId` | required | `string` | + + +### `{ enabled }` + +SHA-256: `c2a13242efc3bea4b36a843ddf1524ff4d33c765a71b582fa1fdbfa4fa148434` + +Sources: [`packages/team/agent-team/src/types.ts:173`](../packages/team/agent-team/src/types.ts) + +| Property | Presence | Type | +|---|---|---| +| `enabled` | required | `boolean` | + ### `{ end, start }` @@ -6330,18 +6384,18 @@ Sources: [`packages/core/session/src/types.ts:398`](../packages/core/session/src | `reason` | required | [`RequestHeaderReason`](#persistence-type-sha256-530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6) | | `startsSeries` | optional | `true` | - + ### `{ headerSeq?, message, step, turn }` -SHA-256: `53d4f8256bc58109468ea358ac54d437760cd765012360cafe72a401e6a41d4a` +SHA-256: `65377a76c6a58f785d95b89f89f2e13d289cb88a70ea74939d0ec08f429f0a6e` Sources: [`packages/core/session/src/types.ts:317`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| | `headerSeq` | optional | `number` | -| `message` | required | [`DeveloperMessage`](#persistence-type-sha256-64ef542b1bfc988382d337f2ea85aab8ad76ea55341cf082163919d315688428) | +| `message` | required | [`DeveloperMessage`](#persistence-type-sha256-01c161379a3c3394a642807843e108ef911a8b7dbffd6d7de43f4484c88b0796) | | `step` | required | `number` | | `turn` | required | `number` | @@ -6425,17 +6479,17 @@ Sources: [`packages/core/session/src/types.ts:435`](../packages/core/session/src |---|---|---| | `inherited` | optional | `true` | - + ### `{ inserted, outcome?, removedCount?, start, … }` -SHA-256: `d4d960185026266ed7bd127be1e252234689e8b4890c7c2677a644ba58f35401` +SHA-256: `e1880c67e735e228f3555df8a55a492db2c0b0c71bc8859e938e184a46c7db51` Sources: [`packages/core/agent/src/types.ts:110`](../packages/core/agent/src/types.ts) | Property | Presence | Type | |---|---|---| -| `inserted` | required | [`UserMessage[]`](#persistence-type-sha256-7c203f3d4dfa6638ff2601247cc7ca5d46731d2c614aa61217475b9eb1fbd0b3) | +| `inserted` | required | [`UserMessage[]`](#persistence-type-sha256-690583582246babf6120f16eefd849dd760c5ec84619014951af403a1b3032c0) | | `outcome` | optional | `"canceled"` | | `removedCount` | optional | `number` | | `start` | required | `number` | @@ -7858,7 +7912,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:61`](../packages/web SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1` -Sources: [`packages/experimental/agent-team/src/types.ts:230`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:175`](../packages/team/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7886,7 +7940,7 @@ Sources: [`packages/core/session/src/types.ts:338`](../packages/core/session/src SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` -Sources: [`packages/experimental/agent-team/src/types.ts:234`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:179`](../packages/team/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7900,7 +7954,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:234`](../packages/exper SHA-256: `0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1` -Sources: [`packages/experimental/agent-team/src/types.ts:236`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:181`](../packages/team/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -8046,7 +8100,7 @@ Sources: [`packages/compaction/compaction-image-offload/src/projection.ts:25`](. SHA-256: `a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c` -Sources: [`packages/experimental/agent-team/src/types.ts:232`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:177`](../packages/team/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -8094,17 +8148,17 @@ SHA-256: `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | `time` | required | `number` | | `type` | required | `"agent-preset/selected"` | - + ### `{ type: "agent/inbox/spliced" }` -SHA-256: `cdf988172b589b230f2bce868489def1bb575eb0501076ac757a8f369ac5c64e` +SHA-256: `ddce54a3873dc57113b0cd38ad9b23b3c259186139e8847e5e5f415d86f52482` | Property | Presence | Type | |---|---|---| -| `data` | required | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-d4d960185026266ed7bd127be1e252234689e8b4890c7c2677a644ba58f35401) | +| `data` | required | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-e1880c67e735e228f3555df8a55a492db2c0b0c71bc8859e938e184a46c7db51) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `time` | required | `number` | @@ -8345,17 +8399,17 @@ SHA-256: `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | `time` | required | `number` | | `type` | required | `"deliverables/presented"` | - + ### `{ type: "developer/message" }` -SHA-256: `70d2acdec1ebcfea56d02f7233ebb9ec0f7f494447fb952884c22c3afdc01d6f` +SHA-256: `70ea8e7943c5e2d76905bcc2e694e8c3d5b138aaae3609f3e261b8d369a6e35a` | Property | Presence | Type | |---|---|---| -| `data` | required | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-53d4f8256bc58109468ea358ac54d437760cd765012360cafe72a401e6a41d4a) | +| `data` | required | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-65377a76c6a58f785d95b89f89f2e13d289cb88a70ea74939d0ec08f429f0a6e) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `sourceEventSeqs` | optional | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | @@ -8755,17 +8809,17 @@ SHA-256: `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | `time` | required | `number` | | `type` | required | `"session/title"` | - + ### `{ type: "session/title-llm-request" }` -SHA-256: `2e505098f9209e52eebd577f1a6f9d4e773ec4d78987aff28a7c786442b9f54c` +SHA-256: `d95c624eddecdacf1ee74356196c09d2f3fcd625714be5e852a6b643fa12a7ca` | Property | Presence | Type | |---|---|---| -| `data` | required | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-7ff62f6599c083ceb5a0d7a8940ff85ba80dc1d7ddd6ba9339903e93f74cba54) | +| `data` | required | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-601327bedca5f98a0fa4c27133bd35749f3af2baaaacf53badf074e59cea5d25) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `time` | required | `number` | @@ -8917,6 +8971,22 @@ SHA-256: `21fb6a90d5068f6a0003b7ab316ed2f56342477146a65c00db0f13c4d8df667d` | `time` | required | `number` | | `type` | required | `"team/message/queued"` | + + + + +### `{ type: "team/mode" }` + +SHA-256: `920d87e22b7409bcc7224f8b1dc7f5101c33574c75627c65baab12b85cc96e75` + +| Property | Presence | Type | +|---|---|---| +| `data` | required | [`{ enabled }`](#persistence-type-sha256-c2a13242efc3bea4b36a843ddf1524ff4d33c765a71b582fa1fdbfa4fa148434) | +| `ignorable` | optional | `true` | +| `seq` | required | `number` | +| `time` | required | `number` | +| `type` | required | `"team/mode"` | + @@ -9188,17 +9258,17 @@ Sources: [`packages/llm/llm/src/types.ts:502`](../packages/llm/llm/src/types.ts) | `type` | required | `"usage"` | | `usage` | required | [`TokenUsage`](#persistence-type-sha256-fec5442b9ee4122e10514d81d5a43648003fe8bfb7ee2ba50c0cdbdd383ed394) | - + ### `{ type: "user/message" }` -SHA-256: `5bb231bc5afdb532a6898a20604e76bf0c1db438bfcdc17d4847dceaddc02451` +SHA-256: `dcb07e04496586d2664d72dab0e94312917da56927b2a0dc3ee53d6a33e32450` | Property | Presence | Type | |---|---|---| -| `data` | required | [`UserMessage`](#persistence-type-sha256-4035903993dfee62cca2142c0ca51ab57075909ddee2817d0620daa97ac4d4ce) | +| `data` | required | [`UserMessage`](#persistence-type-sha256-613660455704fb82a825b5b766e000aa64b5b4d44b4f10e55cffe968d4ec6ab1) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `sourceEventSeqs` | optional | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 4a9ff92ca4..6b26e80ae8 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -25,7 +25,7 @@ | `JsonlHeaderLine` | header | `18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f` | [`HeaderLine`](#persistence-type-sha256-18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f) | | `SessionEventEnvelope` | envelope | `5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc` | [`union (3 variants)`](#persistence-type-sha256-5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc) | | `event:agent-preset/selected` | event | `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | [`{ type: "agent-preset/selected" }`](#persistence-type-sha256-a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b) | -| `event:agent/inbox/spliced` | event | `cdf988172b589b230f2bce868489def1bb575eb0501076ac757a8f369ac5c64e` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-cdf988172b589b230f2bce868489def1bb575eb0501076ac757a8f369ac5c64e) | +| `event:agent/inbox/spliced` | event | `ddce54a3873dc57113b0cd38ad9b23b3c259186139e8847e5e5f415d86f52482` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-ddce54a3873dc57113b0cd38ad9b23b3c259186139e8847e5e5f415d86f52482) | | `event:approval/asked` | event | `3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1` | [`{ type: "approval/asked" }`](#persistence-type-sha256-3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1) | | `event:approval/decided` | event | `bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3` | [`{ type: "approval/decided" }`](#persistence-type-sha256-bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3) | | `event:approval/policy` | event | `26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78` | [`{ type: "approval/policy" }`](#persistence-type-sha256-26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78) | @@ -38,7 +38,7 @@ | `event:compaction/start` | event | `db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965` | [`{ type: "compaction/start" }`](#persistence-type-sha256-db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965) | | `event:compaction/summary` | event | `e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602` | [`{ type: "compaction/summary" }`](#persistence-type-sha256-e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602) | | `event:deliverables/presented` | event | `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | [`{ type: "deliverables/presented" }`](#persistence-type-sha256-13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176) | -| `event:developer/message` | event | `70d2acdec1ebcfea56d02f7233ebb9ec0f7f494447fb952884c22c3afdc01d6f` | [`{ type: "developer/message" }`](#persistence-type-sha256-70d2acdec1ebcfea56d02f7233ebb9ec0f7f494447fb952884c22c3afdc01d6f) | +| `event:developer/message` | event | `70ea8e7943c5e2d76905bcc2e694e8c3d5b138aaae3609f3e261b8d369a6e35a` | [`{ type: "developer/message" }`](#persistence-type-sha256-70ea8e7943c5e2d76905bcc2e694e8c3d5b138aaae3609f3e261b8d369a6e35a) | | `event:feedback/message-delete` | event | `3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796` | [`{ type: "feedback/message-delete" }`](#persistence-type-sha256-3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796) | | `event:feedback/message-put` | event | `b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf` | [`{ type: "feedback/message-put" }`](#persistence-type-sha256-b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf) | | `event:feedback/record` | event | `b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8` | [`{ type: "feedback/record" }`](#persistence-type-sha256-b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8) | @@ -60,7 +60,7 @@ | `event:session-log-deepseek/delivery-accepted` | event | `d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9` | [`{ type: "session-log-deepseek/delivery-accepted" }`](#persistence-type-sha256-d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9) | | `event:session/end-seed` | event | `5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e` | [`{ type: "session/end-seed" }`](#persistence-type-sha256-5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e) | | `event:session/title` | event | `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | [`{ type: "session/title" }`](#persistence-type-sha256-1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5) | -| `event:session/title-llm-request` | event | `2e505098f9209e52eebd577f1a6f9d4e773ec4d78987aff28a7c786442b9f54c` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-2e505098f9209e52eebd577f1a6f9d4e773ec4d78987aff28a7c786442b9f54c) | +| `event:session/title-llm-request` | event | `d95c624eddecdacf1ee74356196c09d2f3fcd625714be5e852a6b643fa12a7ca` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-d95c624eddecdacf1ee74356196c09d2f3fcd625714be5e852a6b643fa12a7ca) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | | `event:subagent/catalog` | event | `53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac) | @@ -70,6 +70,7 @@ | `event:team/member` | event | `4fb59762612c3e3ac3a3bd4f84c9c148d3c3893bd422ba2b201cc039fabd49bc` | [`{ type: "team/member" }`](#persistence-type-sha256-4fb59762612c3e3ac3a3bd4f84c9c148d3c3893bd422ba2b201cc039fabd49bc) | | `event:team/message/delivered` | event | `48f9c19417a1abbedfa59f4667bba36b93ac2db407adf5e84cb3ba0de30942cb` | [`{ type: "team/message/delivered" }`](#persistence-type-sha256-48f9c19417a1abbedfa59f4667bba36b93ac2db407adf5e84cb3ba0de30942cb) | | `event:team/message/queued` | event | `21fb6a90d5068f6a0003b7ab316ed2f56342477146a65c00db0f13c4d8df667d` | [`{ type: "team/message/queued" }`](#persistence-type-sha256-21fb6a90d5068f6a0003b7ab316ed2f56342477146a65c00db0f13c4d8df667d) | +| `event:team/mode` | event | `920d87e22b7409bcc7224f8b1dc7f5101c33574c75627c65baab12b85cc96e75` | [`{ type: "team/mode" }`](#persistence-type-sha256-920d87e22b7409bcc7224f8b1dc7f5101c33574c75627c65baab12b85cc96e75) | | `event:team/task` | event | `d595ec73b32b016a6055333c67a5d646032b22e672da1d4a09c5b1ae398a093a` | [`{ type: "team/task" }`](#persistence-type-sha256-d595ec73b32b016a6055333c67a5d646032b22e672da1d4a09c5b1ae398a093a) | | `event:todo/write` | event | `b978cff734e62143eb56c9125423ec275405eda969802d42aaf73ecb987d3b26` | [`{ type: "todo/write" }`](#persistence-type-sha256-b978cff734e62143eb56c9125423ec275405eda969802d42aaf73ecb987d3b26) | | `event:tool-workflow/agent-end` | event | `babf9ee4d1af62bf6c3a8103737f7a5e4e78ce179be835ce05a38803e15884b7` | [`{ type: "tool-workflow/agent-end" }`](#persistence-type-sha256-babf9ee4d1af62bf6c3a8103737f7a5e4e78ce179be835ce05a38803e15884b7) | @@ -82,7 +83,7 @@ | `event:tool/result` | event | `7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397` | [`{ type: "tool/result" }`](#persistence-type-sha256-7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397) | | `event:turn/end` | event | `0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce` | [`{ type: "turn/end" }`](#persistence-type-sha256-0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce) | | `event:turn/start` | event | `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` | [`{ type: "turn/start" }`](#persistence-type-sha256-aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8) | -| `event:user/message` | event | `5bb231bc5afdb532a6898a20604e76bf0c1db438bfcdc17d4847dceaddc02451` | [`{ type: "user/message" }`](#persistence-type-sha256-5bb231bc5afdb532a6898a20604e76bf0c1db438bfcdc17d4847dceaddc02451) | +| `event:user/message` | event | `dcb07e04496586d2664d72dab0e94312917da56927b2a0dc3ee53d6a33e32450` | [`{ type: "user/message" }`](#persistence-type-sha256-dcb07e04496586d2664d72dab0e94312917da56927b2a0dc3ee53d6a33e32450) | | `event:web/deepseek-search-llm-request` | event | `0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52` | [`{ type: "web/deepseek-search-llm-request" }`](#persistence-type-sha256-0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52) | | `event:working-directory/change` | event | `124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3` | [`{ type: "working-directory/change" }`](#persistence-type-sha256-124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3) | | `event:workspace/changes` | event | `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72` | [`{ type: "workspace/changes" }`](#persistence-type-sha256-e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72) | @@ -200,15 +201,14 @@ export type SessionEvent = { * The session's agent preset was chosen after creation, while the session * was still blank. Log-only: it records the composition later turns ran * under, so a resumed or forked session rebuilds the same one instead of - * the header's creation-time value. In a child Session, an occurrence - * before its first own `subagent/descriptor` marks an independent spawn - * selection that cold resume restores instead of joining the parent's - * composition (`dsh-subagent` README, "Restoring child composition"). + * the header's creation-time value. Subagent restoration reads this saved + * selection independently of its creator (`dsh-subagent` README, + * "Restoring child composition"). */ 'agent-preset/selected': { agentPreset: string } ``` -来源:[`packages/preset/agent-preset-registry/src/session.ts:31`](../packages/preset/agent-preset-registry/src/session.ts) +来源:[`packages/preset/agent-preset-registry/src/session.ts:30`](../packages/preset/agent-preset-registry/src/session.ts) ### `approval/*` @@ -1011,13 +1011,13 @@ export type SessionEvent = { #### `team/member` — log-only ```ts persistence-catalog -/** Whole teammate lifecycle value, stored only in the Team Lead Session. */ +/** Historical teammate lifecycle value; new teammates are continuable entries in the Lead's subagent catalog. */ 'team/member': { version: 2; teamId: TeamId; member: TeamMemberSnapshot } ``` 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMemberSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:230`](../packages/experimental/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/experimental/agent-team/src/types.ts:236`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:181`](../packages/team/agent-team/src/types.ts) @@ -1048,7 +1048,18 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:234`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:179`](../packages/team/agent-team/src/types.ts) + + + +#### `team/mode` — log-only + +```ts persistence-catalog +/** User-selected Team mode; child Agents use the selection of their runtime root. */ +'team/mode': { enabled: boolean } +``` + +来源:[`packages/team/agent-team/src/types.ts:173`](../packages/team/agent-team/src/types.ts) @@ -1061,7 +1072,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamTaskSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:232`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:177`](../packages/team/agent-team/src/types.ts) ### `todo/*` @@ -2357,6 +2368,14 @@ SHA-256: `383cf73ae4748e5beb281ae74857ba1ffb92ae01075b9fbf51af78b666007a8f` `"recall"` + + +### `"refusal"` + +SHA-256: `4a561c111a78d4520e850c93795369ea169ff86abd082aee86c8fd38121a65aa` + +`"refusal"` + ### `"rejected"` @@ -2709,6 +2728,14 @@ SHA-256: `4e011753f8263f436efaabfbb3b4db53a98fc55342c5ded2daf7208b217be159` `"team/message/queued"` + + +### `"team/mode"` + +SHA-256: `d8a476d2c6fd30df6ec0e799f91716d1518c780d23b4121dce545abfcfb7c5a5` + +`"team/mode"` + ### `"team/task"` @@ -3385,7 +3412,7 @@ SHA-256: `abd6ca62a5ab166553848324a23fef90eed7f59c8021bd63e7ea5bc6fd666b0c` | `body` | 必需 | [`{ max_tokens, messages, model, thinking?, … }`](#persistence-type-sha256-9c001ecb5e1d3c3894410db2a1e383842e5f8454fb52182338686158abc1b3ee) | | `endpoint` | 必需 | `string` | - + @@ -3393,7 +3420,7 @@ SHA-256: `abd6ca62a5ab166553848324a23fef90eed7f59c8021bd63e7ea5bc6fd666b0c` ### `DeveloperMessage` -SHA-256: `64ef542b1bfc988382d337f2ea85aab8ad76ea55341cf082163919d315688428` +SHA-256: `01c161379a3c3394a642807843e108ef911a8b7dbffd6d7de43f4484c88b0796` 来源:[`packages/llm/llm/src/message.ts:157`](../packages/llm/llm/src/message.ts) @@ -3402,7 +3429,7 @@ SHA-256: `64ef542b1bfc988382d337f2ea85aab8ad76ea55341cf082163919d315688428` | `content` | 必需 | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | 必需 | `string` | | `role` | 必需 | `"developer"` | -| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-7e9f1072c17f7aa86547c9c8b02766c1006d866a1859a3d677a336e932f70d1a) | +| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-b120362fd216a16a40fe787f6d3286086aa131266322bab79c026a130ccd0f72) | 消息源兼容策略:`source` — `session-source-attribution` v1; `session.developer-message.source`; `kind`; `preserve`. @@ -4101,7 +4128,7 @@ SHA-256: `6f72ece8f317cd080c4f20a93f1bf709965f5b81ac4669528f5a023191cfcf20` | `step` | 必需 | `number` | | `turn` | 必需 | `number` | - + @@ -4109,15 +4136,15 @@ SHA-256: `6f72ece8f317cd080c4f20a93f1bf709965f5b81ac4669528f5a023191cfcf20` ### `Message` -SHA-256: `c78b9097bfae83cd08f0a98a92730236ba80ac61d712046959e4fc7f28aa1f36` +SHA-256: `e272493e3ea0b33b2e1d64b6a80e9d8bd3d1d466cc115f2facbdd598f8644a39` 来源:[`packages/llm/llm/src/message.ts:196`](../packages/llm/llm/src/message.ts) 以下类型之一: - [`ToolResultMessage`](#persistence-type-sha256-4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731) -- [`DeveloperMessage`](#persistence-type-sha256-64ef542b1bfc988382d337f2ea85aab8ad76ea55341cf082163919d315688428) -- [`UserMessage`](#persistence-type-sha256-4035903993dfee62cca2142c0ca51ab57075909ddee2817d0620daa97ac4d4ce) +- [`DeveloperMessage`](#persistence-type-sha256-01c161379a3c3394a642807843e108ef911a8b7dbffd6d7de43f4484c88b0796) +- [`UserMessage`](#persistence-type-sha256-613660455704fb82a825b5b766e000aa64b5b4d44b4f10e55cffe968d4ec6ab1) - [`SystemMessage`](#persistence-type-sha256-09f3f8dc417f278257202714132f98ba7b52957d7b558c4a04456603890eb363) - [`AssistantMessage`](#persistence-type-sha256-fa87537f4a9c1e25602990a8bf686b383e9e00352ec8946e16ae24ba7c1625be) @@ -4194,7 +4221,7 @@ SHA-256: `390eed1028aba02774f14408647acec10ec5516e97d4f15ecde2b9989d5a69f2` - `"negative"` - `"positive"` - + @@ -4202,7 +4229,7 @@ SHA-256: `390eed1028aba02774f14408647acec10ec5516e97d4f15ecde2b9989d5a69f2` ### `MessageSource` -SHA-256: `7e9f1072c17f7aa86547c9c8b02766c1006d866a1859a3d677a336e932f70d1a` +SHA-256: `b120362fd216a16a40fe787f6d3286086aa131266322bab79c026a130ccd0f72` 来源:[`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message.ts) @@ -4281,7 +4308,7 @@ SHA-256: `7e9f1072c17f7aa86547c9c8b02766c1006d866a1859a3d677a336e932f70d1a` | `session-reference` | `"recall"` | `references`: [`{ capturedFormatVersion?, capturedThroughSeq, compacted, inputIndex, … }[]`](#persistence-type-sha256-df843efd87c78cf8de98f8e0d0216956cd568d0d0e56a22c853663e274aa1a18); `version`: `1` | [`SessionReferenceSource`](#persistence-type-sha256-f56c5b59ca0161310c338d31dd16cf14287ccd602ff4181058df178c1c286d7f) | | `skill-catalog` | `"catalog"` | `entries`: [`{ description, name }[]`](#persistence-type-sha256-aba175fb20ffc71112c55efe70458ff5d7dbfb83501892b4ba8d077f0ea5d7ba) | [`SkillCatalogSource`](#persistence-type-sha256-65d583e66518c5236015e0846470fb32efcf92375ed306aef14e50a9d2cd2099) | | `skill-invocation` | `"instructions"` | `name`: `string` | [`SkillInvocationSource`](#persistence-type-sha256-672c4aa2a9b736e3d6e5da6346d2e2ed5e3573f2d0baafa18e7407c9f3fee3db) | -| `subagent-settled` | `"notice"` | `senderSessionId`: `string`; `summary`: `string` | [`SubagentSettledMessageSource`](#persistence-type-sha256-4e97c3d85c0fc817ee58873c38c25a2474abaaa0af4839311b50b68db3b8cf1a) | +| `subagent-settled` | `"notice"` | `senderSessionId`: `string`; `summary`: `string` | [`SubagentSettledMessageSource`](#persistence-type-sha256-848639f8787f035b480ec53cb5686850897c5e8006d87ca231cd8a9ee8634936) | | `system-prompt` | 未声明 | 无 | [`SystemPromptMessageSource`](#persistence-type-sha256-65a8400e451e667e5b6e34df72ceb81fca8c12330330fa72083ad40279b189ad) | | `team-message` | 未声明 | `messageId`: `string`; `senderId`: `string`; `senderName`: `string`; `teamId`: `string` | [`TeamMessageSource`](#persistence-type-sha256-2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0) | | `time-context` | `"catalog"` | 无 | [`{ kind: "time-context", form: "catalog" }`](#persistence-type-sha256-85fe96bf15d5c7559eec3bae4d6cf1a76fda9f5be05a29c2830cff560cab0c0d) | @@ -4327,13 +4354,13 @@ SHA-256: `7e9f1072c17f7aa86547c9c8b02766c1006d866a1859a3d677a336e932f70d1a` | `webhook` | `"notice"` | `deliveryId`: `string`; `provider`: `string`; `ruleId`: `string`; `source`: `string`; `summary`: `string` | [`{ kind: "webhook", form: "notice" }`](#persistence-type-sha256-9edc162949abf93bd5ff049191b345454ba104f583937ce5580fb50f067d2b94) | | `working-directory` | 未声明 | 无 | [`{ kind: "working-directory" }`](#persistence-type-sha256-2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5) | - + ### `Message[]` -SHA-256: `ff2cbb53582d1827b02622482246979942f59e07e0393b76a161251423549e72` +SHA-256: `95b5cf90ea1f57da3319184d6f5f3811d0f651aa9113591c10e81c3d1e9b9f70` -[`Message`](#persistence-type-sha256-c78b9097bfae83cd08f0a98a92730236ba80ac61d712046959e4fc7f28aa1f36) 的数组。 +[`Message`](#persistence-type-sha256-e272493e3ea0b33b2e1d64b6a80e9d8bd3d1d466cc115f2facbdd598f8644a39) 的数组。 @@ -4701,7 +4728,7 @@ SHA-256: `b56f6a885da3dc394c69a3e3cc5c0cfc4da15601b63fda4989eee8f40efc5be4` | `source` | 必需 | [`SessionTitleSource`](#persistence-type-sha256-30d5a537b296685770b1241c4b4bbde19d5a31ff81aa23df73fab5e90b8fc65f) | | `title` | 必需 | `string` | - + @@ -4709,7 +4736,7 @@ SHA-256: `b56f6a885da3dc394c69a3e3cc5c0cfc4da15601b63fda4989eee8f40efc5be4` ### `SessionTitleLlmRequestEventData` -SHA-256: `7ff62f6599c083ceb5a0d7a8940ff85ba80dc1d7ddd6ba9339903e93f74cba54` +SHA-256: `601327bedca5f98a0fa4c27133bd35749f3af2baaaacf53badf074e59cea5d25` 来源:[`packages/session/session-title-llm/src/index.ts:43`](../packages/session/session-title-llm/src/index.ts) @@ -4717,7 +4744,7 @@ SHA-256: `7ff62f6599c083ceb5a0d7a8940ff85ba80dc1d7ddd6ba9339903e93f74cba54` |---|---|---| | `maxTokens` | 必需 | `number` | | `messageSeqs` | 必需 | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | -| `messages` | 必需 | [`Message[]`](#persistence-type-sha256-ff2cbb53582d1827b02622482246979942f59e07e0393b76a161251423549e72) | +| `messages` | 必需 | [`Message[]`](#persistence-type-sha256-95b5cf90ea1f57da3319184d6f5f3811d0f651aa9113591c10e81c3d1e9b9f70) | | `reasoningEffort` | 可选 | `string` | | `route` | 必需 | [`SessionTitleModelIdentity`](#persistence-type-sha256-07e1a58c58b593f507b8e7f25723bb0aa42baae6e00f5ecfddf28cb1e64d3235) | | `system` | 必需 | `string` | @@ -4886,7 +4913,7 @@ SHA-256: `86d1d0e9cfebb240421187e3dc37fb71cc38a1501885cf32f8f9adef7b0a2bde` - [`ContinuableSubagentDescriptorData`](#persistence-type-sha256-6487b10ba7542f3c447b1e41a1c6a590b7bfafe0b9b66b49f34028da85bb970f) - [`OneShotSubagentDescriptorData`](#persistence-type-sha256-5f758f3de5f00c18968a7c6d347083aac5c9ae13f6a79c78f6611b23c4973564) - + @@ -4894,7 +4921,7 @@ SHA-256: `86d1d0e9cfebb240421187e3dc37fb71cc38a1501885cf32f8f9adef7b0a2bde` ### `SubagentSettledMessageSource` -SHA-256: `4e97c3d85c0fc817ee58873c38c25a2474abaaa0af4839311b50b68db3b8cf1a` +SHA-256: `848639f8787f035b480ec53cb5686850897c5e8006d87ca231cd8a9ee8634936` 来源:[`packages/subagent/subagent/src/continuation-messages.ts:29`](../packages/subagent/subagent/src/continuation-messages.ts) @@ -4903,6 +4930,7 @@ SHA-256: `4e97c3d85c0fc817ee58873c38c25a2474abaaa0af4839311b50b68db3b8cf1a` | `form` | 必需 | `"notice"` | | `kind` | 必需 | `"subagent-settled"` | | `senderSessionId` | 必需 | `string` | +| `stopReason` | 可选 | [`union (5 variants)`](#persistence-type-sha256-866f7b2dbdc5b421eab3e629312d1c839b75c259633b4b43b50d23bac44d92a0) | | `summary` | 必需 | `string` | @@ -4959,7 +4987,7 @@ SHA-256: `65a8400e451e667e5b6e34df72ceb81fca8c12330330fa72083ad40279b189ad` - + @@ -4967,7 +4995,7 @@ SHA-256: `65a8400e451e667e5b6e34df72ceb81fca8c12330330fa72083ad40279b189ad` SHA-256: `dfce5db4aa42b7ebe0bb79eca92553587287e18c06b9eba85cbfb9d332e26acd` -来源:[`packages/experimental/agent-team/src/types.ts:45`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:47`](../packages/team/agent-team/src/types.ts) 以下类型之一: @@ -4977,7 +5005,7 @@ SHA-256: `dfce5db4aa42b7ebe0bb79eca92553587287e18c06b9eba85cbfb9d332e26acd` - + @@ -4985,7 +5013,7 @@ SHA-256: `dfce5db4aa42b7ebe0bb79eca92553587287e18c06b9eba85cbfb9d332e26acd` SHA-256: `6598cd6da253948d6b207c6374a45335804c79b576866d6215649c2d1bc85e25` -来源:[`packages/experimental/agent-team/src/types.ts:48`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:50`](../packages/team/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4999,7 +5027,7 @@ SHA-256: `6598cd6da253948d6b207c6374a45335804c79b576866d6215649c2d1bc85e25` - + @@ -5007,7 +5035,7 @@ SHA-256: `6598cd6da253948d6b207c6374a45335804c79b576866d6215649c2d1bc85e25` SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e` -来源:[`packages/experimental/agent-team/src/types.ts:129`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:108`](../packages/team/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5019,7 +5047,7 @@ SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e` - + @@ -5027,7 +5055,7 @@ SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e` SHA-256: `2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0` -来源:[`packages/experimental/agent-team/src/types.ts:138`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:117`](../packages/team/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5039,7 +5067,7 @@ SHA-256: `2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0` - + @@ -5047,7 +5075,7 @@ SHA-256: `2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0` SHA-256: `51edec100659da3a9bd9c3f6ba1180f5e64ec9a1585e5057080df45c180bd357` -来源:[`packages/experimental/agent-team/src/types.ts:75`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:64`](../packages/team/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5062,7 +5090,7 @@ SHA-256: `51edec100659da3a9bd9c3f6ba1180f5e64ec9a1585e5057080df45c180bd357` - + @@ -5070,7 +5098,7 @@ SHA-256: `51edec100659da3a9bd9c3f6ba1180f5e64ec9a1585e5057080df45c180bd357` SHA-256: `b2d2a01e51341a3bfec809198952c8c830a7899f4cb6ecd3c1dc0b5d9a2af943` -来源:[`packages/experimental/agent-team/src/types.ts:72`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:61`](../packages/team/agent-team/src/types.ts) 以下类型之一: @@ -5392,7 +5420,7 @@ SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402` | `interrupted` | 未声明 | 无 | [`{ kind: "interrupted" }`](#persistence-type-sha256-56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23) | | `max-tokens` | 未声明 | 无 | [`{ kind: "max-tokens" }`](#persistence-type-sha256-ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7) | - + @@ -5400,7 +5428,7 @@ SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402` ### `UserMessage` -SHA-256: `4035903993dfee62cca2142c0ca51ab57075909ddee2817d0620daa97ac4d4ce` +SHA-256: `613660455704fb82a825b5b766e000aa64b5b4d44b4f10e55cffe968d4ec6ab1` 来源:[`packages/llm/llm/src/message.ts:162`](../packages/llm/llm/src/message.ts) @@ -5409,19 +5437,19 @@ SHA-256: `4035903993dfee62cca2142c0ca51ab57075909ddee2817d0620daa97ac4d4ce` | `content` | 必需 | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | 必需 | `string` | | `role` | 必需 | `"user"` | -| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-7e9f1072c17f7aa86547c9c8b02766c1006d866a1859a3d677a336e932f70d1a) | +| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-b120362fd216a16a40fe787f6d3286086aa131266322bab79c026a130ccd0f72) | 消息源兼容策略:`source` — `session-source-attribution` v1; `session.user-message.source`; `kind`; `preserve`. 仅表示归属的新增 kind:`action-cards`, `tmux-context`, `user-question-reply`, `working-directory`. - + ### `UserMessage[]` -SHA-256: `7c203f3d4dfa6638ff2601247cc7ca5d46731d2c614aa61217475b9eb1fbd0b3` +SHA-256: `690583582246babf6120f16eefd849dd760c5ec84619014951af403a1b3032c0` -[`UserMessage`](#persistence-type-sha256-4035903993dfee62cca2142c0ca51ab57075909ddee2817d0620daa97ac4d4ce) 的数组。 +[`UserMessage`](#persistence-type-sha256-613660455704fb82a825b5b766e000aa64b5b4d44b4f10e55cffe968d4ec6ab1) 的数组。 @@ -5539,12 +5567,6 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` - - - - - - @@ -5577,6 +5599,12 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` + + + + + + @@ -5613,7 +5641,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`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/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/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/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` @@ -5757,6 +5785,20 @@ SHA-256: `fd0d91a1f9dd2efb8959bdf5e07f8f0a98b1cdfa38ce8d4732ebde0b44c3abd4` - `"in_progress"` - `"pending"` + + +### `union (5 variants)` + +SHA-256: `866f7b2dbdc5b421eab3e629312d1c839b75c259633b4b43b50d23bac44d92a0` + +以下类型之一: + +- `"aborted"` +- `"completed"` +- `"error"` +- `"max-tokens"` +- `"refusal"` + ### `union (6 variants)` @@ -5822,7 +5864,7 @@ SHA-256: `20a57544bbf204dacd1ae6beb2ab29444365c3c869f2afdf489dcdcbc3d9c8c5` SHA-256: `6e52dd55a1d48954ed5464ec46fe34d9eb65f709baabc9561e7a279910b0c406` -来源:[`packages/preset/agent-preset-registry/src/session.ts:31`](../packages/preset/agent-preset-registry/src/session.ts) +来源:[`packages/preset/agent-preset-registry/src/session.ts:30`](../packages/preset/agent-preset-registry/src/session.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6236,6 +6278,18 @@ SHA-256: `0a92c5bdfa06c2e2615b777728f3aa95f408d43b9012e7b754568311251db623` | `subjectId` | 必需 | `string` | | `tenantId` | 必需 | `string` | + + +### `{ enabled }` + +SHA-256: `c2a13242efc3bea4b36a843ddf1524ff4d33c765a71b582fa1fdbfa4fa148434` + +来源:[`packages/team/agent-team/src/types.ts:173`](../packages/team/agent-team/src/types.ts) + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `enabled` | 必需 | `boolean` | + ### `{ end, start }` @@ -6332,18 +6386,18 @@ SHA-256: `ad0c38a0adb82a94c925456df41586d9b5f9202fca18593eae2213d4445b4585` | `reason` | 必需 | [`RequestHeaderReason`](#persistence-type-sha256-530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6) | | `startsSeries` | 可选 | `true` | - + ### `{ headerSeq?, message, step, turn }` -SHA-256: `53d4f8256bc58109468ea358ac54d437760cd765012360cafe72a401e6a41d4a` +SHA-256: `65377a76c6a58f785d95b89f89f2e13d289cb88a70ea74939d0ec08f429f0a6e` 来源:[`packages/core/session/src/types.ts:317`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| | `headerSeq` | 可选 | `number` | -| `message` | 必需 | [`DeveloperMessage`](#persistence-type-sha256-64ef542b1bfc988382d337f2ea85aab8ad76ea55341cf082163919d315688428) | +| `message` | 必需 | [`DeveloperMessage`](#persistence-type-sha256-01c161379a3c3394a642807843e108ef911a8b7dbffd6d7de43f4484c88b0796) | | `step` | 必需 | `number` | | `turn` | 必需 | `number` | @@ -6427,17 +6481,17 @@ SHA-256: `17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3` |---|---|---| | `inherited` | 可选 | `true` | - + ### `{ inserted, outcome?, removedCount?, start, … }` -SHA-256: `d4d960185026266ed7bd127be1e252234689e8b4890c7c2677a644ba58f35401` +SHA-256: `e1880c67e735e228f3555df8a55a492db2c0b0c71bc8859e938e184a46c7db51` 来源:[`packages/core/agent/src/types.ts:110`](../packages/core/agent/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| -| `inserted` | 必需 | [`UserMessage[]`](#persistence-type-sha256-7c203f3d4dfa6638ff2601247cc7ca5d46731d2c614aa61217475b9eb1fbd0b3) | +| `inserted` | 必需 | [`UserMessage[]`](#persistence-type-sha256-690583582246babf6120f16eefd849dd760c5ec84619014951af403a1b3032c0) | | `outcome` | 可选 | `"canceled"` | | `removedCount` | 可选 | `number` | | `start` | 必需 | `number` | @@ -7860,7 +7914,7 @@ SHA-256: `9c001ecb5e1d3c3894410db2a1e383842e5f8454fb52182338686158abc1b3ee` SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1` -来源:[`packages/experimental/agent-team/src/types.ts:230`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:175`](../packages/team/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7888,7 +7942,7 @@ SHA-256: `6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5` SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` -来源:[`packages/experimental/agent-team/src/types.ts:234`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:179`](../packages/team/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7902,7 +7956,7 @@ SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` SHA-256: `0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1` -来源:[`packages/experimental/agent-team/src/types.ts:236`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:181`](../packages/team/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -8048,7 +8102,7 @@ SHA-256: `477614bca595a9e7adb5a1870be446b9a8684f3ba8950259d6289f2d3b74f028` SHA-256: `a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c` -来源:[`packages/experimental/agent-team/src/types.ts:232`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:177`](../packages/team/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -8096,17 +8150,17 @@ SHA-256: `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | `time` | 必需 | `number` | | `type` | 必需 | `"agent-preset/selected"` | - + ### `{ type: "agent/inbox/spliced" }` -SHA-256: `cdf988172b589b230f2bce868489def1bb575eb0501076ac757a8f369ac5c64e` +SHA-256: `ddce54a3873dc57113b0cd38ad9b23b3c259186139e8847e5e5f415d86f52482` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-d4d960185026266ed7bd127be1e252234689e8b4890c7c2677a644ba58f35401) | +| `data` | 必需 | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-e1880c67e735e228f3555df8a55a492db2c0b0c71bc8859e938e184a46c7db51) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `time` | 必需 | `number` | @@ -8347,17 +8401,17 @@ SHA-256: `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | `time` | 必需 | `number` | | `type` | 必需 | `"deliverables/presented"` | - + ### `{ type: "developer/message" }` -SHA-256: `70d2acdec1ebcfea56d02f7233ebb9ec0f7f494447fb952884c22c3afdc01d6f` +SHA-256: `70ea8e7943c5e2d76905bcc2e694e8c3d5b138aaae3609f3e261b8d369a6e35a` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-53d4f8256bc58109468ea358ac54d437760cd765012360cafe72a401e6a41d4a) | +| `data` | 必需 | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-65377a76c6a58f785d95b89f89f2e13d289cb88a70ea74939d0ec08f429f0a6e) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `sourceEventSeqs` | 可选 | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | @@ -8757,17 +8811,17 @@ SHA-256: `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | `time` | 必需 | `number` | | `type` | 必需 | `"session/title"` | - + ### `{ type: "session/title-llm-request" }` -SHA-256: `2e505098f9209e52eebd577f1a6f9d4e773ec4d78987aff28a7c786442b9f54c` +SHA-256: `d95c624eddecdacf1ee74356196c09d2f3fcd625714be5e852a6b643fa12a7ca` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-7ff62f6599c083ceb5a0d7a8940ff85ba80dc1d7ddd6ba9339903e93f74cba54) | +| `data` | 必需 | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-601327bedca5f98a0fa4c27133bd35749f3af2baaaacf53badf074e59cea5d25) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `time` | 必需 | `number` | @@ -8919,6 +8973,22 @@ SHA-256: `21fb6a90d5068f6a0003b7ab316ed2f56342477146a65c00db0f13c4d8df667d` | `time` | 必需 | `number` | | `type` | 必需 | `"team/message/queued"` | + + + + +### `{ type: "team/mode" }` + +SHA-256: `920d87e22b7409bcc7224f8b1dc7f5101c33574c75627c65baab12b85cc96e75` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `data` | 必需 | [`{ enabled }`](#persistence-type-sha256-c2a13242efc3bea4b36a843ddf1524ff4d33c765a71b582fa1fdbfa4fa148434) | +| `ignorable` | 可选 | `true` | +| `seq` | 必需 | `number` | +| `time` | 必需 | `number` | +| `type` | 必需 | `"team/mode"` | + @@ -9190,17 +9260,17 @@ SHA-256: `f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515` | `type` | 必需 | `"usage"` | | `usage` | 必需 | [`TokenUsage`](#persistence-type-sha256-fec5442b9ee4122e10514d81d5a43648003fe8bfb7ee2ba50c0cdbdd383ed394) | - + ### `{ type: "user/message" }` -SHA-256: `5bb231bc5afdb532a6898a20604e76bf0c1db438bfcdc17d4847dceaddc02451` +SHA-256: `dcb07e04496586d2664d72dab0e94312917da56927b2a0dc3ee53d6a33e32450` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`UserMessage`](#persistence-type-sha256-4035903993dfee62cca2142c0ca51ab57075909ddee2817d0620daa97ac4d4ce) | +| `data` | 必需 | [`UserMessage`](#persistence-type-sha256-613660455704fb82a825b5b766e000aa64b5b4d44b4f10e55cffe968d4ec6ab1) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `sourceEventSeqs` | 可选 | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | diff --git a/docs/persistence-changes/2026-10-19-session-team-mode.i18n.yaml b/docs/persistence-changes/2026-10-19-session-team-mode.i18n.yaml new file mode 100644 index 0000000000..d69a435040 --- /dev/null +++ b/docs/persistence-changes/2026-10-19-session-team-mode.i18n.yaml @@ -0,0 +1,28 @@ +# Bilingual-pair consistency record for 2026-10-19-session-team-mode.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/persistence-changes/2026-10-19-session-team-mode.md +/: + en: 3d5a424ae7d7e46b + zh: c22a8e4a62d8cc1a +/2026-10-19-session-team-mode: + en: a882cc1bc383bf80 + zh: 33f40913b70ad97b +/2026-10-19-session-team-mode/summary: + en: 665465829d68c487 + zh: 2d5bd37335c79aad +/2026-10-19-session-team-mode/table-of-contents: + en: 6cf314a069ceb451 + zh: 9adff678e9ecab3b +/2026-10-19-session-team-mode/declaration: + en: 6907136f28e48c90 + zh: 02f8298485f3d8e7 +/2026-10-19-session-team-mode/compatibility: + en: c0ca565e19ad32a7 + zh: 548c334b7e15b464 +/2026-10-19-session-team-mode/verification: + en: 7f3a3e4b1ffb9f85 + zh: dd9258af01f40937 +/2026-10-19-session-team-mode/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/docs/persistence-changes/2026-10-19-session-team-mode.md b/docs/persistence-changes/2026-10-19-session-team-mode.md new file mode 100644 index 0000000000..ca350836a8 --- /dev/null +++ b/docs/persistence-changes/2026-10-19-session-team-mode.md @@ -0,0 +1,48 @@ +--- +description: "Records a persistence type transition and its compatibility acknowledgement." +kind: persistence-change +--- + +# 2026-10-19-session-team-mode + +English | [中文](2026-10-19-session-team-mode.zh.md) + +## Summary + +Records the root Session’s Agent Teams selection with the required team/mode event. + +## Table of Contents + +- [Declaration](#declaration) +- [Compatibility](#compatibility) +- [Verification](#verification) +- [Dev Note](#dev-note) + + +## Declaration + +```yaml persistence-change +schemaVersion: 1 +id: 2026-10-19-session-team-mode +baseline: false +changes: + - root: "event:team/mode" + previous: null + after: "920d87e22b7409bcc7224f8b1dc7f5101c33574c75627c65baab12b85cc96e75" + decision: same-version +``` + + +## Compatibility + +Existing V4 logs remain readable. Without a selection, historical tasks enable Team; without historical tasks it remains disabled. Readers predating team/mode refuse the unknown required discriminator, so they cannot silently restore the wrong mode. Ordinary forks ignore inherited mode selections and start disabled. No existing payload or Session header version changes. + + +## Verification + +197 focused Team, tool, model-menu and client tests passed; the Web mode and task-board scenario passed 8 tests. The authored team-session-mode headless and TypeScript SDK snapshots exercise on/off commands; Python SDK raw-event projection retains both selections. + + +## Dev Note + +None. diff --git a/docs/persistence-changes/2026-10-19-session-team-mode.schema.json b/docs/persistence-changes/2026-10-19-session-team-mode.schema.json new file mode 100644 index 0000000000..4e854f51d3 --- /dev/null +++ b/docs/persistence-changes/2026-10-19-session-team-mode.schema.json @@ -0,0 +1,15 @@ +{ + "formatVersion": 2, + "roots": [ + {"key":"event:team/mode","kind":"event","event":"team/mode","surface":false,"digest":"920d87e22b7409bcc7224f8b1dc7f5101c33574c75627c65baab12b85cc96e75"} + ], + "types": [], + "nodes": { + "0657d3ca5ac4e7bf": {"kind":"primitive","type":"number"}, + "920d87e22b7409bc": {"kind":"object","properties":[{"name":"data","type":"c2a13242efc3bea4","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"d8a476d2c6fd30df","optional":false}],"indices":[]}, + "aa93010d0e521ad7": {"kind":"primitive","type":"boolean"}, + "c2a13242efc3bea4": {"kind":"object","properties":[{"name":"enabled","type":"aa93010d0e521ad7","optional":false}],"indices":[]}, + "d8a476d2c6fd30df": {"kind":"literal","value":"team/mode"}, + "dacf3d5f3e4ff619": {"kind":"literal","value":true} + } +} diff --git a/docs/persistence-changes/2026-10-19-session-team-mode.zh.md b/docs/persistence-changes/2026-10-19-session-team-mode.zh.md new file mode 100644 index 0000000000..6ea99093a6 --- /dev/null +++ b/docs/persistence-changes/2026-10-19-session-team-mode.zh.md @@ -0,0 +1,48 @@ +--- +description: "记录持久化类型更改及其兼容性确认。" +kind: persistence-change +--- + +# 2026-10-19-session-team-mode + +[English](2026-10-19-session-team-mode.md) | 中文 + +## 概述 + +通过必读的 team/mode 事件记录根 Session 的智能体团队选择。 + +## 目录 + +- [声明](#declaration) +- [兼容性](#compatibility) +- [验证](#verification) +- [开发备注](#dev-note) + + +## 声明 + +```yaml persistence-change +schemaVersion: 1 +id: 2026-10-19-session-team-mode +baseline: false +changes: + - root: "event:team/mode" + previous: null + after: "920d87e22b7409bcc7224f8b1dc7f5101c33574c75627c65baab12b85cc96e75" + decision: same-version +``` + + +## 兼容性 + +已有 V4 日志仍可读取。没有显式选择时,历史任务使 Team 开启;没有历史任务时保持关闭。旧读取器拒绝未知的必读 team/mode 判别值,避免静默恢复错误模式。普通分叉忽略继承的模式选择,初始保持关闭。不修改已有载荷或 Session 头部版本。 + + +## 验证 + +197 个 Team、工具、模型菜单和客户端定向测试通过;Web 开关与任务板场景的 8 个测试通过。team-session-mode 的 headless 和 TypeScript SDK 人工编写快照覆盖开关命令;Python SDK 原始事件投影保留两次选择。 + + +## 开发备注 + +无。 diff --git a/docs/persistence-changes/2026-10-27-subagent-settlement-attribution.i18n.yaml b/docs/persistence-changes/2026-10-27-subagent-settlement-attribution.i18n.yaml new file mode 100644 index 0000000000..e1d1916155 --- /dev/null +++ b/docs/persistence-changes/2026-10-27-subagent-settlement-attribution.i18n.yaml @@ -0,0 +1,28 @@ +# Bilingual-pair consistency record for 2026-10-27-subagent-settlement-attribution.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/persistence-changes/2026-10-27-subagent-settlement-attribution.md +/: + en: 3d5a424ae7d7e46b + zh: c22a8e4a62d8cc1a +/2026-10-27-subagent-settlement-attribution: + en: d2a2da526a11227c + zh: 047e8cc6da20118f +/2026-10-27-subagent-settlement-attribution/summary: + en: fb422315a6c3e9e7 + zh: bce866c511613c73 +/2026-10-27-subagent-settlement-attribution/table-of-contents: + en: 6cf314a069ceb451 + zh: 9adff678e9ecab3b +/2026-10-27-subagent-settlement-attribution/declaration: + en: 6907136f28e48c90 + zh: 02f8298485f3d8e7 +/2026-10-27-subagent-settlement-attribution/compatibility: + en: e18e26f07dcc315e + zh: 7120bc3f3e9b90b5 +/2026-10-27-subagent-settlement-attribution/verification: + en: 42d56d2378276e50 + zh: 16c96c5c3d018964 +/2026-10-27-subagent-settlement-attribution/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/docs/persistence-changes/2026-10-27-subagent-settlement-attribution.md b/docs/persistence-changes/2026-10-27-subagent-settlement-attribution.md new file mode 100644 index 0000000000..0f5994a749 --- /dev/null +++ b/docs/persistence-changes/2026-10-27-subagent-settlement-attribution.md @@ -0,0 +1,60 @@ +--- +description: "Records a persistence type transition and its compatibility acknowledgement." +kind: persistence-change +--- + +# 2026-10-27-subagent-settlement-attribution + +English | [中文](2026-10-27-subagent-settlement-attribution.zh.md) + +## Summary + +Adds optional stopReason attribution to persisted subagent settlement notices while keeping the Session format version unchanged. + +## Table of Contents + +- [Declaration](#declaration) +- [Compatibility](#compatibility) +- [Verification](#verification) +- [Dev Note](#dev-note) + + +## Declaration + +```yaml persistence-change +schemaVersion: 1 +id: 2026-10-27-subagent-settlement-attribution +baseline: false +changes: + - root: "event:agent/inbox/spliced" + previous: "2026-10-13-liveshare-share-viewer-principal" + after: "ddce54a3873dc57113b0cd38ad9b23b3c259186139e8847e5e5f415d86f52482" + decision: same-version + - root: "event:developer/message" + previous: "2026-10-13-liveshare-share-viewer-principal" + after: "70ea8e7943c5e2d76905bcc2e694e8c3d5b138aaae3609f3e261b8d369a6e35a" + decision: same-version + - root: "event:session/title-llm-request" + previous: "2026-10-13-liveshare-share-viewer-principal" + after: "d95c624eddecdacf1ee74356196c09d2f3fcd625714be5e852a6b643fa12a7ca" + decision: same-version + - root: "event:user/message" + previous: "2026-10-13-liveshare-share-viewer-principal" + after: "dcb07e04496586d2664d72dab0e94312917da56927b2a0dc3ee53d6a33e32450" + decision: same-version +``` + + +## Compatibility + +New readers accept historical notices without stopReason and must not infer it from the English summary. Writers always record the exact captured execution stopReason. Older readers preserve additional source metadata and continue using the existing kind, notice form, summary, senderSessionId, and unchanged content; native V4 source admission validates the producer kind rather than rejecting additional attribution fields. The new metadata affects presentation only, not replay, authority, or recovery. No migration or Session version bump is required. + + +## Verification + +Focused Vitest runs passed: continuation-messages.spec.ts (14 tests), external-activation.spec.ts (19 tests), continuation-messages-adapter.spec.ts (1 test), and continuation.spec.ts (166 tests), all under packages/subagent/subagent/tests. Command: node node_modules/vitest/vitest.mjs run followed by the listed owner paths. Coverage includes all five terminal reasons, historical attribution without inference, external cleanup failure retaining the captured execution reason, cold resume, and unchanged DeepSeek Messages wire content. Historical Session generations remain unchanged; affected current-format snapshot expectations include stopReason. + + +## Dev Note + +None. diff --git a/docs/persistence-changes/2026-10-27-subagent-settlement-attribution.schema.json b/docs/persistence-changes/2026-10-27-subagent-settlement-attribution.schema.json new file mode 100644 index 0000000000..3299e5d126 --- /dev/null +++ b/docs/persistence-changes/2026-10-27-subagent-settlement-attribution.schema.json @@ -0,0 +1,259 @@ +{ + "formatVersion": 2, + "roots": [ + {"key":"event:agent/inbox/spliced","kind":"event","event":"agent/inbox/spliced","surface":false,"digest":"ddce54a3873dc57113b0cd38ad9b23b3c259186139e8847e5e5f415d86f52482"}, + {"key":"event:developer/message","kind":"event","event":"developer/message","surface":true,"digest":"70ea8e7943c5e2d76905bcc2e694e8c3d5b138aaae3609f3e261b8d369a6e35a"}, + {"key":"event:session/title-llm-request","kind":"event","event":"session/title-llm-request","surface":false,"digest":"d95c624eddecdacf1ee74356196c09d2f3fcd625714be5e852a6b643fa12a7ca"}, + {"key":"event:user/message","kind":"event","event":"user/message","surface":true,"digest":"dcb07e04496586d2664d72dab0e94312917da56927b2a0dc3ee53d6a33e32450"} + ], + "types": [], + "nodes": { + "01c161379a3c3394": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"589d0d91239681f8","optional":false},{"name":"source","type":"b120362fd216a16a","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.developer-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]}, + "0203243b72fc867a": {"kind":"object","properties":[{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, + "022623b9ef98ec47": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, + "02b2c23f2d94b028": {"kind":"object","properties":[{"name":"kind","type":"c5687b1313206a6f","optional":false}],"indices":[]}, + "05205074b24d8340": {"kind":"literal","value":"action-cards"}, + "0657d3ca5ac4e7bf": {"kind":"primitive","type":"number"}, + "07e1a58c58b593f5": {"kind":"object","properties":[{"name":"model","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "09655b34d7f3cf1e": {"kind":"literal","value":"system-prompt"}, + "09f3f8dc417f2782": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"8af841fc9b265ade","optional":false},{"name":"source","type":"65a8400e451e667e","optional":false}],"indices":[]}, + "0a92c5bdfa06c2e2": {"kind":"object","properties":[{"name":"displayName","type":"4cd48f3e5108bd6e","optional":true},{"name":"providerId","type":"4cd48f3e5108bd6e","optional":false},{"name":"subjectId","type":"4cd48f3e5108bd6e","optional":false},{"name":"tenantId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "0ed8d1108fe7a5e1": {"kind":"object","properties":[{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]}, + "0eeb17e1df89b1eb": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, + "0f46153645c29784": {"kind":"object","properties":[{"name":"height","type":"0657d3ca5ac4e7bf","optional":false},{"name":"width","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "0fe52387f6505270": {"kind":"literal","value":"plan-mode"}, + "11638dc58a31496f": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "12d5808fac1996b9": {"kind":"object","properties":[{"name":"attachment","type":"b0b0eb1b16490f6b","optional":false},{"name":"offloaded","type":"dacf3d5f3e4ff619","optional":true},{"name":"type","type":"fb8b6a2e53cb9cf4","optional":false}],"indices":[]}, + "1379f0b72275994d": {"kind":"literal","value":"developer/message"}, + "16d0507cb94b4cfc": {"kind":"literal","value":"relay"}, + "1922cd79a70806c1": {"kind":"literal","value":"skill-invocation"}, + "1938c8c20f1cba7c": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]}, + "1e0b0b66b6f0c53f": {"kind":"literal","value":"aborted"}, + "1e709346005826eb": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]}, + "1ebc6f9ff3aa9e3a": {"kind":"literal","value":"completed"}, + "1f0133217eeeffd8": {"kind":"literal","value":"tool-call"}, + "1f1385dbc3e875c0": {"kind":"literal","value":"repeat-tool-reminder"}, + "1fdad624334d3548": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]}, + "2152caf1a71e588e": {"kind":"object","properties":[{"name":"kind","type":"2e4debca12f2c449","optional":false},{"name":"messageId","type":"4cd48f3e5108bd6e","optional":false},{"name":"senderId","type":"4cd48f3e5108bd6e","optional":false},{"name":"senderName","type":"4cd48f3e5108bd6e","optional":false},{"name":"teamId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "2235d957bf4b7c6c": {"kind":"object","properties":[{"name":"kind","type":"7b1ce60193fa8b69","optional":false}],"indices":[]}, + "224aa11d4d5f88c4": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, + "23af3759678a30de": {"kind":"literal","value":"dsh-session-title-llm"}, + "24b10f7cdae64928": {"kind":"literal","value":"runtime-context"}, + "24ce31aa528272e5": {"kind":"object","properties":[{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, + "260523b412c19ad8": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]}, + "2765e9119edf5816": {"kind":"literal","value":"model"}, + "277b61f9c6f81704": {"kind":"literal","value":"file"}, + "28936c614bd4e7e2": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "290f32bfd81ff85a": {"kind":"object","properties":[{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, + "297fbd8067d7815a": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]}, + "2aad1179f9576314": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "2ab12a0e345658f9": {"kind":"array","element":"83da72c6857601c7"}, + "2e4debca12f2c449": {"kind":"literal","value":"team-message"}, + "2f1e6a7ebace43d7": {"kind":"union","types":["3f74ba74acd10644","58f94390b982f8a5","de30b1fa12c75f81","ebb768d85ec87fb6"]}, + "2f3f201c8846e19c": {"kind":"literal","value":"tool-goal"}, + "312aee2f0c42aabb": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "31907de956375182": {"kind":"object","properties":[{"name":"compactionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"6b23ed9ce1b397e6","optional":false},{"name":"sourceCommandId","type":"4cd48f3e5108bd6e","optional":true}],"indices":[]}, + "3247e566f4340029": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "335e242de1fcc17b": {"kind":"union","types":["bcf0caf62d964b2f","e438a9d4fb883b3e"]}, + "383cf73ae4748e5b": {"kind":"literal","value":"recall"}, + "3b04790e08b35241": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, + "3b4f2c9ef2c1217e": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]}, + "3bd652ebfa8726b3": {"kind":"union","types":["0657d3ca5ac4e7bf","938f7c7357bc403e"]}, + "3bdb8dc6989655b0": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "3c2535ca0a2062f1": {"kind":"literal","value":"reasoning"}, + "3e2ece4967cfebe9": {"kind":"literal","value":"next-step"}, + "3f74ba74acd10644": {"kind":"literal","value":"image/jpeg"}, + "408927267422a7b2": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, + "43e311235aa537df": {"kind":"object","properties":[{"name":"toolName","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"83d6916021eede8f","optional":false}],"indices":[]}, + "45f005e6bbb76452": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "4991dfc415c9efeb": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, + "49dab019d00e8c26": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "4a561c111a78d452": {"kind":"literal","value":"refusal"}, + "4a9bef9f27a94252": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"82470020b5bfcb1a","optional":false},{"name":"senderSessionId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "4b259f7f8f0a44ad": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"isError","type":"aa93010d0e521ad7","optional":true},{"name":"role","type":"a13d8f379222ece8","optional":false},{"name":"source","type":"d9c462b9f62fdd3e","optional":false},{"name":"toolCallId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "4cd48f3e5108bd6e": {"kind":"primitive","type":"string"}, + "4d60dddaa65d3da8": {"kind":"object","properties":[{"name":"actor","type":"0a92c5bdfa06c2e2","optional":false},{"name":"kind","type":"6ba7edb6447b6a3e","optional":false},{"name":"shareId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "4e44c9118152f682": {"kind":"literal","value":"text"}, + "4e88776858652856": {"kind":"object","properties":[{"name":"text","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"4e44c9118152f682","optional":false}],"indices":[]}, + "4edec1b770e8ec4c": {"kind":"literal","value":"ptc-mode"}, + "4f91f187baeabef5": {"kind":"object","properties":[{"name":"capturedFormatVersion","type":"0657d3ca5ac4e7bf","optional":true},{"name":"capturedThroughSeq","type":"3bd652ebfa8726b3","optional":false},{"name":"compacted","type":"aa93010d0e521ad7","optional":false},{"name":"inputIndex","type":"0657d3ca5ac4e7bf","optional":false},{"name":"label","type":"4cd48f3e5108bd6e","optional":false},{"name":"omittedBytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"omittedMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"originalMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"retainedMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sessionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"truncated","type":"aa93010d0e521ad7","optional":false}],"indices":[]}, + "52c6113cb0e53f34": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "53cf1704ae0709c7": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, + "5654685d7557ec74": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "58015da1c1950a66": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, + "587bb11da730e06a": {"kind":"literal","value":"session/title-llm-request"}, + "589d0d91239681f8": {"kind":"literal","value":"developer"}, + "58f94390b982f8a5": {"kind":"literal","value":"image/png"}, + "5bb69b88517f6214": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "5bc66269cfaf614c": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]}, + "5cdbf342b7748d15": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, + "5d03ba38734809bc": {"kind":"array","element":"0657d3ca5ac4e7bf"}, + "5d7a64ed6aac5551": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]}, + "601327bedca5f98a": {"kind":"object","properties":[{"name":"maxTokens","type":"0657d3ca5ac4e7bf","optional":false},{"name":"messageSeqs","type":"5d03ba38734809bc","optional":false},{"name":"messages","type":"95b5cf90ea1f57da","optional":false},{"name":"reasoningEffort","type":"4cd48f3e5108bd6e","optional":true},{"name":"route","type":"07e1a58c58b593f5","optional":false},{"name":"system","type":"4cd48f3e5108bd6e","optional":false},{"name":"titleProvider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "6085488d223aeffd": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]}, + "613660455704fb82": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"68f44f59e91c4139","optional":false},{"name":"source","type":"b120362fd216a16a","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.user-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]}, + "6292bcbedaa97fea": {"kind":"object","properties":[{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, + "62ecb62dc0fe1b5e": {"kind":"literal","value":"next-turn"}, + "65377a76c6a58f78": {"kind":"object","properties":[{"name":"headerSeq","type":"0657d3ca5ac4e7bf","optional":true},{"name":"message","type":"01c161379a3c3394","optional":false},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "65a8400e451e667e": {"kind":"object","properties":[{"name":"kind","type":"09655b34d7f3cf1e","optional":false}],"indices":[]}, + "65d583e66518c523": {"kind":"object","properties":[{"name":"entries","type":"aba175fb20ffc711","optional":false},{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"incomplete","type":"dacf3d5f3e4ff619","optional":true},{"name":"kind","type":"6ebcc2dbec9f2dab","optional":false},{"name":"update","type":"dacf3d5f3e4ff619","optional":true}],"indices":[]}, + "672c4aa2a9b736e3": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"1922cd79a70806c1","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "68141cbe3994a5ff": {"kind":"literal","value":"max-tokens"}, + "68f44f59e91c4139": {"kind":"literal","value":"user"}, + "690583582246babf": {"kind":"array","element":"613660455704fb82"}, + "6b23ed9ce1b397e6": {"kind":"literal","value":"compact-checkpoint"}, + "6ba7edb6447b6a3e": {"kind":"literal","value":"share-viewer"}, + "6ccb6bc423fb3952": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "6ea608bdc8a7a63d": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]}, + "6ebcc2dbec9f2dab": {"kind":"literal","value":"skill-catalog"}, + "6f22a9d02b9a1f11": {"kind":"object","properties":[{"name":"baseline","type":"dacf3d5f3e4ff619","optional":true},{"name":"baselineIdentity","type":"4cd48f3e5108bd6e","optional":true},{"name":"changes","type":"2ab12a0e345658f9","optional":false},{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"fbd2c74b346a1595","optional":false}],"indices":[]}, + "6f44f32be9bcd9a8": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, + "6f9e00383836275e": {"kind":"literal","value":"snapshot"}, + "70ea8e7943c5e2d7": {"kind":"object","properties":[{"name":"data","type":"65377a76c6a58f78","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1379f0b72275994d","optional":false}],"indices":[]}, + "719c51f67074f970": {"kind":"object","properties":[{"name":"kind","type":"2765e9119edf5816","optional":false},{"name":"model","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"replayState","type":"bbc5696e3f13c758","optional":true}],"indices":[]}, + "7382c4fc95fe8c5d": {"kind":"literal","value":"remove"}, + "7554565be416b034": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]}, + "756b5e23a5cb2c7f": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]}, + "78a9be340090b735": {"kind":"literal","value":"tmux-context"}, + "7b1ce60193fa8b69": {"kind":"literal","value":"working-directory"}, + "7df62978a2b7de85": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]}, + "7eaef7a763ba5b94": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, + "7f13860ee89a2d35": {"kind":"literal","value":"session-reference"}, + "7f959241c24128b4": {"kind":"object","properties":[{"name":"goalId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"b815d7b91cdd9fc6","optional":false},{"name":"revision","type":"0657d3ca5ac4e7bf","optional":false},{"name":"round","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "8064c03c87c357fd": {"kind":"union","types":["12d5808fac1996b9","43e311235aa537df","4e88776858652856","814d9434e1ddf907","8e611bf46821e500","c1f0f17b6493b85d","f2da0f27c1e6add5"]}, + "80677c8c8b804189": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, + "8067b0d232c350f1": {"kind":"literal","value":"time-context"}, + "814d9434e1ddf907": {"kind":"object","properties":[{"name":"text","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"3c2535ca0a2062f1","optional":false}],"indices":[]}, + "82470020b5bfcb1a": {"kind":"literal","value":"agent-message"}, + "824a4aad44a54e24": {"kind":"union","types":["4d60dddaa65d3da8","deebfc7c62177ea8"]}, + "825ca23416b1ea9d": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, + "829a2fb3be6efd4b": {"kind":"literal","value":"agent/inbox/spliced"}, + "83079c8a3f733ac3": {"kind":"object","properties":[{"name":"description","type":"4cd48f3e5108bd6e","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "8338ee303e2bd448": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "83d6916021eede8f": {"kind":"literal","value":"tool-removal"}, + "83da72c6857601c7": {"kind":"object","properties":[{"name":"action","type":"904bf499d98be4a3","optional":false},{"name":"digest","type":"4cd48f3e5108bd6e","optional":true},{"name":"path","type":"4cd48f3e5108bd6e","optional":false},{"name":"scope","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "848639f8787f035b": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"bf16d2e98a7f1389","optional":false},{"name":"senderSessionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"stopReason","type":"866f7b2dbdc5b421","optional":true},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "854db3efdfb0cd39": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "85d6d96ff8842974": {"kind":"literal","value":"answered"}, + "85f9b505b7bdff1f": {"kind":"literal","value":"tool-addition"}, + "85fe96bf15d5c755": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]}, + "8620189c8e335827": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, + "866f7b2dbdc5b421": {"kind":"union","types":["1e0b0b66b6f0c53f","1ebc6f9ff3aa9e3a","4a561c111a78d452","68141cbe3994a5ff","a2be0f0cb59f0d4a"]}, + "86e1e384495ee09a": {"kind":"object","properties":[{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"text","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "87b1a33564b9a4d6": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]}, + "88841202942c7683": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "8a046b52331959aa": {"kind":"object","properties":[{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]}, + "8af841fc9b265ade": {"kind":"literal","value":"system"}, + "8c1c4f72755a78ea": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "8c627d7a45fa406c": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "8e611bf46821e500": {"kind":"object","properties":[{"name":"tool","type":"fcf427290c6213c2","optional":true},{"name":"toolName","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"85f9b505b7bdff1f","optional":false}],"indices":[]}, + "8f324cea3916c051": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]}, + "8fcb0b44a575e998": {"kind":"object","properties":[{"name":"kind","type":"a720757f704a2512","optional":false}],"indices":[]}, + "904bf499d98be4a3": {"kind":"union","types":["7382c4fc95fe8c5d","b765ee31033cea46","f6cb462f808309e3"]}, + "938f7c7357bc403e": {"kind":"primitive","type":"null"}, + "95a3ffccbf3c8afc": {"kind":"literal","value":"owner"}, + "95b5cf90ea1f57da": {"kind":"array","element":"e272493e3ea0b33b"}, + "95c0e88fcf0bafb2": {"kind":"literal","value":"assistant"}, + "9645cf93769a6dfb": {"kind":"literal","value":"user-question-reply"}, + "96bfb916a727cd44": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, + "96f4a9c81fc0f940": {"kind":"union","types":["3e2ece4967cfebe9","62ecb62dc0fe1b5e"]}, + "99135d8a39379779": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]}, + "99e7e7d7fed55ae6": {"kind":"literal","value":"tool-jobs"}, + "99f0e98184709ad1": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]}, + "9ac52f0f84fec2eb": {"kind":"literal","value":"model-selection"}, + "9edc162949abf93b": {"kind":"object","properties":[{"name":"deliveryId","type":"4cd48f3e5108bd6e","optional":false},{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"db6c5246911d47a7","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"ruleId","type":"4cd48f3e5108bd6e","optional":false},{"name":"source","type":"4cd48f3e5108bd6e","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "9f65c43e340bba31": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]}, + "a13d8f379222ece8": {"kind":"literal","value":"tool"}, + "a15a19ef63cf5d1b": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]}, + "a16e567c8cd2c55c": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]}, + "a2066bd52f61218b": {"kind":"object","properties":[{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]}, + "a2be0f0cb59f0d4a": {"kind":"literal","value":"error"}, + "a3c6f1ea38e9a61d": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "a4afec59b75aad5d": {"kind":"object","properties":[{"name":"clientTimeZone","type":"4cd48f3e5108bd6e","optional":true},{"name":"kind","type":"68f44f59e91c4139","optional":false},{"name":"principal","type":"824a4aad44a54e24","optional":true},{"name":"rpcId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "a720757f704a2512": {"kind":"literal","value":"tool-registry"}, + "a9059a81430b3207": {"kind":"literal","value":"hooks-claude-code"}, + "aa2bf9e82b9fdeb9": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, + "aa93010d0e521ad7": {"kind":"primitive","type":"boolean"}, + "aae343bc3d663c83": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]}, + "aba175fb20ffc711": {"kind":"array","element":"83079c8a3f733ac3"}, + "ada310bf0bdb8fed": {"kind":"object","properties":[{"name":"kind","type":"68f44f59e91c4139","optional":false}],"indices":[]}, + "ade057f4fdc387ae": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, + "aec150797b655c5f": {"kind":"literal","value":"catalog"}, + "b062078e899958f9": {"kind":"object","properties":[{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, + "b0b0eb1b16490f6b": {"kind":"object","properties":[{"name":"attachmentId","type":"4cd48f3e5108bd6e","optional":false},{"name":"bytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"height","type":"0657d3ca5ac4e7bf","optional":false},{"name":"mediaType","type":"2f1e6a7ebace43d7","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":true},{"name":"originalDimensions","type":"0f46153645c29784","optional":true},{"name":"width","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "b120362fd216a16a": {"kind":"union","types":["0203243b72fc867a","022623b9ef98ec47","02b2c23f2d94b028","0ed8d1108fe7a5e1","0eeb17e1df89b1eb","11638dc58a31496f","1938c8c20f1cba7c","1e709346005826eb","1fdad624334d3548","2152caf1a71e588e","2235d957bf4b7c6c","224aa11d4d5f88c4","24ce31aa528272e5","260523b412c19ad8","28936c614bd4e7e2","290f32bfd81ff85a","297fbd8067d7815a","2aad1179f9576314","312aee2f0c42aabb","31907de956375182","3247e566f4340029","3b04790e08b35241","3b4f2c9ef2c1217e","3bdb8dc6989655b0","408927267422a7b2","45f005e6bbb76452","4991dfc415c9efeb","49dab019d00e8c26","4a9bef9f27a94252","52c6113cb0e53f34","53cf1704ae0709c7","5654685d7557ec74","58015da1c1950a66","5bb69b88517f6214","5bc66269cfaf614c","5cdbf342b7748d15","5d7a64ed6aac5551","6085488d223aeffd","6292bcbedaa97fea","65a8400e451e667e","65d583e66518c523","672c4aa2a9b736e3","6ccb6bc423fb3952","6ea608bdc8a7a63d","6f22a9d02b9a1f11","6f44f32be9bcd9a8","719c51f67074f970","7554565be416b034","756b5e23a5cb2c7f","7df62978a2b7de85","7eaef7a763ba5b94","7f959241c24128b4","80677c8c8b804189","825ca23416b1ea9d","8338ee303e2bd448","848639f8787f035b","854db3efdfb0cd39","85fe96bf15d5c755","8620189c8e335827","87b1a33564b9a4d6","88841202942c7683","8a046b52331959aa","8c1c4f72755a78ea","8c627d7a45fa406c","8f324cea3916c051","8fcb0b44a575e998","96bfb916a727cd44","99135d8a39379779","99f0e98184709ad1","9edc162949abf93b","9f65c43e340bba31","a15a19ef63cf5d1b","a16e567c8cd2c55c","a2066bd52f61218b","a3c6f1ea38e9a61d","a4afec59b75aad5d","aa2bf9e82b9fdeb9","aae343bc3d663c83","ada310bf0bdb8fed","ade057f4fdc387ae","b062078e899958f9","b2a5c8c008a31e3d","b3681c026a0b98e9","b4ec0af39fd52ed9","b613f83572fe2fbf","b6f94c17dd9bbd7c","b70f3e81fe11b7a5","b79641a9d25e7566","ba2a1a8f194b1bb2","ba4c40b4381e0dcd","baccc97ec34be6c4","c5228b7a55498d84","c55827fba7018198","cb1c8959e1726f82","cda2345a9be9d139","d85590a5675c6e2f","d9c462b9f62fdd3e","db06ef35b27a3842","dba54c09c6e7aafc","dd00cdccd0ba487b","dd5ee8762bd8a14b","dd6cc2bb2a8f2824","dde0cdf9d426768b","deaae5cd30987d4f","e7f4cbb3a57f4cbd","edbbfff1128099fd","ee577a0cc18664c9","f230c0b583e23b25","f29d63336e8266d8","f4a66f654996c093","f56c5b59ca016131","f6329fb30c7ea801","f6605d6c57933aea","f6afe41e3926f422","f7283223aacdc39b","f97f47b0eb87905e","fcf81d3c58c13593","fe1a7acdeed4d0f5"]}, + "b2a5c8c008a31e3d": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, + "b3681c026a0b98e9": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "b4ec0af39fd52ed9": {"kind":"object","properties":[{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]}, + "b52f215a6cd69105": {"kind":"array","element":"8064c03c87c357fd"}, + "b613f83572fe2fbf": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "b6f94c17dd9bbd7c": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]}, + "b70f3e81fe11b7a5": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]}, + "b765ee31033cea46": {"kind":"literal","value":"set"}, + "b79641a9d25e7566": {"kind":"object","properties":[{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]}, + "b815d7b91cdd9fc6": {"kind":"literal","value":"goal"}, + "ba2a1a8f194b1bb2": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "ba4c40b4381e0dcd": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "baccc97ec34be6c4": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "bb94f3217aa56c88": {"kind":"literal","value":"hooks-codex"}, + "bbc5696e3f13c758": {"kind":"opaque","reason":"unknown"}, + "bcf0caf62d964b2f": {"kind":"object","properties":[{"name":"endSeq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"op","type":"f6cb462f808309e3","optional":false},{"name":"startSeq","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "bf16d2e98a7f1389": {"kind":"literal","value":"subagent-settled"}, + "c1f0f17b6493b85d": {"kind":"object","properties":[{"name":"arguments","type":"4cd48f3e5108bd6e","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"1f0133217eeeffd8","optional":false}],"indices":[]}, + "c5228b7a55498d84": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "c55827fba7018198": {"kind":"object","properties":[{"name":"callId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"9645cf93769a6dfb","optional":false},{"name":"outcome","type":"85d6d96ff8842974","optional":false}],"indices":[]}, + "c5687b1313206a6f": {"kind":"literal","value":"cordis-host-runner"}, + "c748fb42c80a1200": {"kind":"array","element":"86e1e384495ee09a"}, + "cb1c8959e1726f82": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, + "cda2345a9be9d139": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "d4e4d575abeb4b72": {"kind":"literal","value":"canceled"}, + "d85590a5675c6e2f": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, + "d95c624eddecdacf": {"kind":"object","properties":[{"name":"data","type":"601327bedca5f98a","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"587bb11da730e06a","optional":false}],"indices":[]}, + "d9c462b9f62fdd3e": {"kind":"object","properties":[{"name":"callId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"a13d8f379222ece8","optional":false}],"indices":[]}, + "d9edf1862e4471d6": {"kind":"literal","value":"schedule"}, + "dacf3d5f3e4ff619": {"kind":"literal","value":true}, + "db06ef35b27a3842": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "db6c5246911d47a7": {"kind":"literal","value":"webhook"}, + "dba54c09c6e7aafc": {"kind":"object","properties":[{"name":"kind","type":"4edec1b770e8ec4c","optional":false}],"indices":[]}, + "dcb07e04496586d2": {"kind":"object","properties":[{"name":"data","type":"613660455704fb82","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"de25352d2dc41963","optional":false}],"indices":[]}, + "dd00cdccd0ba487b": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "dd5ee8762bd8a14b": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, + "dd6cc2bb2a8f2824": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, + "ddce54a3873dc571": {"kind":"object","properties":[{"name":"data","type":"e1880c67e735e228","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"829a2fb3be6efd4b","optional":false}],"indices":[]}, + "dde0cdf9d426768b": {"kind":"object","properties":[{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "de25352d2dc41963": {"kind":"literal","value":"user/message"}, + "de30b1fa12c75f81": {"kind":"literal","value":"image/gif"}, + "deaae5cd30987d4f": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, + "deebfc7c62177ea8": {"kind":"object","properties":[{"name":"kind","type":"95a3ffccbf3c8afc","optional":false}],"indices":[]}, + "df843efd87c78cf8": {"kind":"array","element":"4f91f187baeabef5"}, + "e1880c67e735e228": {"kind":"object","properties":[{"name":"inserted","type":"690583582246babf","optional":false},{"name":"outcome","type":"d4e4d575abeb4b72","optional":true},{"name":"removedCount","type":"0657d3ca5ac4e7bf","optional":true},{"name":"start","type":"0657d3ca5ac4e7bf","optional":false},{"name":"target","type":"96f4a9c81fc0f940","optional":false}],"indices":[]}, + "e272493e3ea0b33b": {"kind":"union","types":["01c161379a3c3394","09f3f8dc417f2782","4b259f7f8f0a44ad","613660455704fb82","fa87537f4a9c1e25"]}, + "e438a9d4fb883b3e": {"kind":"literal","value":"append"}, + "e7f4cbb3a57f4cbd": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "e8de0f457fed5b9f": {"kind":"literal","value":"notice"}, + "ebb768d85ec87fb6": {"kind":"literal","value":"image/webp"}, + "edbbfff1128099fd": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "ee577a0cc18664c9": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "f19bee84bf3d2090": {"kind":"object","properties":[{"name":"attachmentId","type":"4cd48f3e5108bd6e","optional":false},{"name":"bytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "f230c0b583e23b25": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "f29d63336e8266d8": {"kind":"object","properties":[{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]}, + "f2da0f27c1e6add5": {"kind":"object","properties":[{"name":"attachment","type":"f19bee84bf3d2090","optional":false},{"name":"type","type":"277b61f9c6f81704","optional":false}],"indices":[]}, + "f4a66f654996c093": {"kind":"object","properties":[{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, + "f56c5b59ca016131": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"7f13860ee89a2d35","optional":false},{"name":"references","type":"df843efd87c78cf8","optional":false},{"name":"version","type":"f88f1b7b6bb733f6","optional":false}],"indices":[]}, + "f6329fb30c7ea801": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, + "f6605d6c57933aea": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]}, + "f68c335306535a00": {"kind":"literal","value":"user-approval"}, + "f6afe41e3926f422": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]}, + "f6cb462f808309e3": {"kind":"literal","value":"replace"}, + "f7283223aacdc39b": {"kind":"object","properties":[{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]}, + "f88f1b7b6bb733f6": {"kind":"literal","value":1}, + "f97f47b0eb87905e": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]}, + "fa87537f4a9c1e25": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"95c0e88fcf0bafb2","optional":false},{"name":"source","type":"719c51f67074f970","optional":false}],"indices":[]}, + "fb8b6a2e53cb9cf4": {"kind":"literal","value":"image"}, + "fbd2c74b346a1595": {"kind":"literal","value":"agent-instructions"}, + "fcf427290c6213c2": {"kind":"primitive","type":"never"}, + "fcf81d3c58c13593": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]}, + "fe1a7acdeed4d0f5": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "fee1caf8ab6a65c1": {"kind":"literal","value":"instructions"} + } +} diff --git a/docs/persistence-changes/2026-10-27-subagent-settlement-attribution.zh.md b/docs/persistence-changes/2026-10-27-subagent-settlement-attribution.zh.md new file mode 100644 index 0000000000..4ea60a4c94 --- /dev/null +++ b/docs/persistence-changes/2026-10-27-subagent-settlement-attribution.zh.md @@ -0,0 +1,60 @@ +--- +description: "记录持久化类型更改及其兼容性确认。" +kind: persistence-change +--- + +# 2026-10-27-subagent-settlement-attribution + +[English](2026-10-27-subagent-settlement-attribution.md) | 中文 + +## 概述 + +为持久化的子代理结算通知增加可选 stopReason 归属信息,保持 Session 格式版本不变。 + +## 目录 + +- [声明](#declaration) +- [兼容性](#compatibility) +- [验证](#verification) +- [开发备注](#dev-note) + + +## 声明 + +```yaml persistence-change +schemaVersion: 1 +id: 2026-10-27-subagent-settlement-attribution +baseline: false +changes: + - root: "event:agent/inbox/spliced" + previous: "2026-10-13-liveshare-share-viewer-principal" + after: "ddce54a3873dc57113b0cd38ad9b23b3c259186139e8847e5e5f415d86f52482" + decision: same-version + - root: "event:developer/message" + previous: "2026-10-13-liveshare-share-viewer-principal" + after: "70ea8e7943c5e2d76905bcc2e694e8c3d5b138aaae3609f3e261b8d369a6e35a" + decision: same-version + - root: "event:session/title-llm-request" + previous: "2026-10-13-liveshare-share-viewer-principal" + after: "d95c624eddecdacf1ee74356196c09d2f3fcd625714be5e852a6b643fa12a7ca" + decision: same-version + - root: "event:user/message" + previous: "2026-10-13-liveshare-share-viewer-principal" + after: "dcb07e04496586d2664d72dab0e94312917da56927b2a0dc3ee53d6a33e32450" + decision: same-version +``` + + +## 兼容性 + +新读取方接受缺少 stopReason 的历史通知,不得从英文摘要推断该字段。写入方始终记录已捕获执行的确切 stopReason。旧读取方保留额外的 source 元数据,继续使用已有 kind、notice form、summary、senderSessionId 和未改变的内容;原生 V4 source 准入校验生产方 kind,不拒绝额外归属字段。新元数据只影响展示,不影响回放、权限或恢复。无需迁移或提升 Session 版本。 + + +## 验证 + +定向 Vitest 运行通过:continuation-messages.spec.ts(14 个测试)、external-activation.spec.ts(19 个测试)、continuation-messages-adapter.spec.ts(1 个测试)和 continuation.spec.ts(166 个测试),均位于 packages/subagent/subagent/tests。命令为 node node_modules/vitest/vitest.mjs run,后接上述测试文件路径。覆盖全部五种终止原因、历史归属信息不作推断、外部清理失败保留已捕获的执行原因、冷恢复,以及不变的 DeepSeek Messages 线协议内容。历史 Session 代际保持不变;受影响的当前格式快照预期包含 stopReason。 + + +## 开发备注 + +无。 diff --git a/docs/persistence-changes/historical-formats/README.i18n.yaml b/docs/persistence-changes/historical-formats/README.i18n.yaml index e9e8287e17..fcd95ef300 100644 --- a/docs/persistence-changes/historical-formats/README.i18n.yaml +++ b/docs/persistence-changes/historical-formats/README.i18n.yaml @@ -15,8 +15,8 @@ en: 14c57f30e676f6e3 zh: b1a0ccf26fab3727 /session-persistence-formats/format-references: - en: 5912b107ada0bfc4 - zh: b997083d6fed3f07 + en: a660985fd2941e51 + zh: 0d4aed6be93be922 /session-persistence-formats/scope-and-evidence: en: b220062139ddac1e zh: 7c4c0efe05dafa9c diff --git a/docs/persistence-changes/historical-formats/README.md b/docs/persistence-changes/historical-formats/README.md index e0a594594d..24b42751fa 100644 --- a/docs/persistence-changes/historical-formats/README.md +++ b/docs/persistence-changes/historical-formats/README.md @@ -32,7 +32,7 @@ The index is generated from validated snapshots and the current writer constant. | 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 65 / 626 | +| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 66 / 631 | diff --git a/docs/persistence-changes/historical-formats/README.zh.md b/docs/persistence-changes/historical-formats/README.zh.md index 4202dfcc4e..ef48b880a4 100644 --- a/docs/persistence-changes/historical-formats/README.zh.md +++ b/docs/persistence-changes/historical-formats/README.zh.md @@ -32,7 +32,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式 | 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.zh.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 65 / 626 | +| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 66 / 631 | diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 3563fb4cf5..a025a3f846 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -5,7 +5,7 @@ {"key":"JsonlHeaderLine","kind":"header","digest":"18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f"}, {"key":"SessionEventEnvelope","kind":"envelope","digest":"5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc"}, {"key":"event:agent-preset/selected","kind":"event","event":"agent-preset/selected","surface":false,"digest":"a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b"}, - {"key":"event:agent/inbox/spliced","kind":"event","event":"agent/inbox/spliced","surface":false,"digest":"cdf988172b589b230f2bce868489def1bb575eb0501076ac757a8f369ac5c64e"}, + {"key":"event:agent/inbox/spliced","kind":"event","event":"agent/inbox/spliced","surface":false,"digest":"ddce54a3873dc57113b0cd38ad9b23b3c259186139e8847e5e5f415d86f52482"}, {"key":"event:approval/asked","kind":"event","event":"approval/asked","surface":false,"digest":"3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1"}, {"key":"event:approval/decided","kind":"event","event":"approval/decided","surface":false,"digest":"bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3"}, {"key":"event:approval/policy","kind":"event","event":"approval/policy","surface":false,"digest":"26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78"}, @@ -18,7 +18,7 @@ {"key":"event:compaction/start","kind":"event","event":"compaction/start","surface":false,"digest":"db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965"}, {"key":"event:compaction/summary","kind":"event","event":"compaction/summary","surface":false,"digest":"e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602"}, {"key":"event:deliverables/presented","kind":"event","event":"deliverables/presented","surface":false,"digest":"13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176"}, - {"key":"event:developer/message","kind":"event","event":"developer/message","surface":true,"digest":"70d2acdec1ebcfea56d02f7233ebb9ec0f7f494447fb952884c22c3afdc01d6f"}, + {"key":"event:developer/message","kind":"event","event":"developer/message","surface":true,"digest":"70ea8e7943c5e2d76905bcc2e694e8c3d5b138aaae3609f3e261b8d369a6e35a"}, {"key":"event:feedback/message-delete","kind":"event","event":"feedback/message-delete","surface":false,"digest":"3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796"}, {"key":"event:feedback/message-put","kind":"event","event":"feedback/message-put","surface":false,"digest":"b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf"}, {"key":"event:feedback/record","kind":"event","event":"feedback/record","surface":false,"digest":"b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8"}, @@ -40,7 +40,7 @@ {"key":"event:session-log-deepseek/delivery-accepted","kind":"event","event":"session-log-deepseek/delivery-accepted","surface":false,"digest":"d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9"}, {"key":"event:session/end-seed","kind":"event","event":"session/end-seed","surface":false,"digest":"5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e"}, {"key":"event:session/title","kind":"event","event":"session/title","surface":false,"digest":"1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5"}, - {"key":"event:session/title-llm-request","kind":"event","event":"session/title-llm-request","surface":false,"digest":"2e505098f9209e52eebd577f1a6f9d4e773ec4d78987aff28a7c786442b9f54c"}, + {"key":"event:session/title-llm-request","kind":"event","event":"session/title-llm-request","surface":false,"digest":"d95c624eddecdacf1ee74356196c09d2f3fcd625714be5e852a6b643fa12a7ca"}, {"key":"event:step/end","kind":"event","event":"step/end","surface":false,"digest":"e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05"}, {"key":"event:step/start","kind":"event","event":"step/start","surface":false,"digest":"4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986"}, {"key":"event:subagent/catalog","kind":"event","event":"subagent/catalog","surface":false,"digest":"53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac"}, @@ -50,6 +50,7 @@ {"key":"event:team/member","kind":"event","event":"team/member","surface":false,"digest":"4fb59762612c3e3ac3a3bd4f84c9c148d3c3893bd422ba2b201cc039fabd49bc"}, {"key":"event:team/message/delivered","kind":"event","event":"team/message/delivered","surface":false,"digest":"48f9c19417a1abbedfa59f4667bba36b93ac2db407adf5e84cb3ba0de30942cb"}, {"key":"event:team/message/queued","kind":"event","event":"team/message/queued","surface":false,"digest":"21fb6a90d5068f6a0003b7ab316ed2f56342477146a65c00db0f13c4d8df667d"}, + {"key":"event:team/mode","kind":"event","event":"team/mode","surface":false,"digest":"920d87e22b7409bcc7224f8b1dc7f5101c33574c75627c65baab12b85cc96e75"}, {"key":"event:team/task","kind":"event","event":"team/task","surface":false,"digest":"d595ec73b32b016a6055333c67a5d646032b22e672da1d4a09c5b1ae398a093a"}, {"key":"event:todo/write","kind":"event","event":"todo/write","surface":false,"digest":"b978cff734e62143eb56c9125423ec275405eda969802d42aaf73ecb987d3b26"}, {"key":"event:tool-workflow/agent-end","kind":"event","event":"tool-workflow/agent-end","surface":false,"digest":"babf9ee4d1af62bf6c3a8103737f7a5e4e78ce179be835ce05a38803e15884b7"}, @@ -62,13 +63,14 @@ {"key":"event:tool/result","kind":"event","event":"tool/result","surface":true,"digest":"7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397"}, {"key":"event:turn/end","kind":"event","event":"turn/end","surface":false,"digest":"0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce"}, {"key":"event:turn/start","kind":"event","event":"turn/start","surface":false,"digest":"aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8"}, - {"key":"event:user/message","kind":"event","event":"user/message","surface":true,"digest":"5bb231bc5afdb532a6898a20604e76bf0c1db438bfcdc17d4847dceaddc02451"}, + {"key":"event:user/message","kind":"event","event":"user/message","surface":true,"digest":"dcb07e04496586d2664d72dab0e94312917da56927b2a0dc3ee53d6a33e32450"}, {"key":"event:web/deepseek-search-llm-request","kind":"event","event":"web/deepseek-search-llm-request","surface":false,"digest":"0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52"}, {"key":"event:working-directory/change","kind":"event","event":"working-directory/change","surface":false,"digest":"124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3"}, {"key":"event:workspace/changes","kind":"event","event":"workspace/changes","surface":false,"digest":"e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72"} ], "types": [ {"digest":"0116b1f89c05221d56b0ba35c83d302bf4857bc7277d247e9bbaf74b27d2c66e","names":[],"sources":[]}, + {"digest":"01c161379a3c3394a642807843e108ef911a8b7dbffd6d7de43f4484c88b0796","names":["packages/llm/llm/src/message.ts#DeveloperMessage"],"sources":["packages/llm/llm/src/message.ts:157"]}, {"digest":"0203243b72fc867afc81aff5f4cf2167e564ed342534eb612fc92f295cf07789","names":[],"sources":[]}, {"digest":"022623b9ef98ec47ec3c3bae0c51027c70d2601141c5e92e26a0d83d1b4cef06","names":[],"sources":[]}, {"digest":"023a355b2be0d688fb6e4df8388dadb942761d46cfe96d0f499aae9631a159d4","names":[],"sources":["packages/core/session/src/types.ts:211"]}, @@ -90,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/experimental/agent-team/src/types.ts:236"]}, + {"digest":"0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1","names":[],"sources":["packages/team/agent-team/src/types.ts:181"]}, {"digest":"0bdaf9cb63594be4ef034b68901fffe792fe09d6d5f6bac42d7f56f6e7834fb9","names":[],"sources":[]}, {"digest":"0c4205180f75b9ee1e0d878bd9a9851628b5dc18448486389d9c5fdc3f1ccda3","names":[],"sources":[]}, {"digest":"0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52","names":[],"sources":[]}, @@ -153,7 +155,7 @@ {"digest":"1fdad624334d354820dfb3a26e28d0bc9fc1b62a33f87bab63f715b3f4391bfd","names":[],"sources":[]}, {"digest":"20a57544bbf204dacd1ae6beb2ab29444365c3c869f2afdf489dcdcbc3d9c8c5","names":[],"sources":["packages/plan/plan-mode/src/index.ts:53"]}, {"digest":"2142e391b9ccb60d487afcf373ce74db98d952f69e8289eddb352a7e16e996c5","names":[],"sources":[]}, - {"digest":"2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0","names":["packages/experimental/agent-team/src/types.ts#TeamMessageSource"],"sources":["packages/experimental/agent-team/src/types.ts:138"]}, + {"digest":"2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0","names":["packages/team/agent-team/src/types.ts#TeamMessageSource"],"sources":["packages/team/agent-team/src/types.ts:117"]}, {"digest":"21fb6a90d5068f6a0003b7ab316ed2f56342477146a65c00db0f13c4d8df667d","names":[],"sources":[]}, {"digest":"2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5","names":[],"sources":["packages/session/working-directory/src/index.ts:29"]}, {"digest":"224aa11d4d5f88c450822d01b99e6ca5e3a9c7f26a5ede10bae33384a09ef4dc","names":[],"sources":[]}, @@ -183,8 +185,7 @@ {"digest":"2da725e554cd89301d829ed9dc45a572c3cd1b93b5e91d0d71f007976d8a0863","names":[],"sources":[]}, {"digest":"2e3523541e942ef6cc9fbc5b91f242e0ca4c6ada479b79b96025b797f6e41f3f","names":["packages/schedule/schedule/src/types.ts#ScheduleChange"],"sources":["packages/schedule/schedule/src/types.ts:232"]}, {"digest":"2e4debca12f2c449f96dc6574f3fbf2fb346193443114a241d01a236362ec48c","names":[],"sources":[]}, - {"digest":"2e505098f9209e52eebd577f1a6f9d4e773ec4d78987aff28a7c786442b9f54c","names":[],"sources":[]}, - {"digest":"2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e","names":["packages/experimental/agent-team/src/types.ts#TeamMessageSnapshot"],"sources":["packages/experimental/agent-team/src/types.ts:129"]}, + {"digest":"2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e","names":["packages/team/agent-team/src/types.ts#TeamMessageSnapshot"],"sources":["packages/team/agent-team/src/types.ts:108"]}, {"digest":"2f1e6a7ebace43d727ae138ddcd352eb3813960009e5805b8c5ac84300c97716","names":["packages/attachment/attachment/src/types.ts#ImageMediaType"],"sources":["packages/attachment/attachment/src/types.ts:8"]}, {"digest":"2f3f201c8846e19c20bb75c02280e3d4eb3c57509e4715f0c0a4d1d4f91a7a06","names":[],"sources":[]}, {"digest":"2fe5026daf14bd4ff2b7bfb7cf9791c302d9cbba06802178b6dfec00b892698f","names":["packages/core/tool-executor-ptc/src/types.ts#PtcDispatchStartEventData"],"sources":["packages/core/tool-executor-ptc/src/types.ts:12"]}, @@ -226,7 +227,6 @@ {"digest":"3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796","names":[],"sources":[]}, {"digest":"3f74ba74acd10644c1ed6947a411d8088bd2cfac74f61bb8540f96d68e31f004","names":[],"sources":[]}, {"digest":"3fdf9582c3b5597df104d32d97dab6c6ee3a93794f3034ec6ada6272404ea27f","names":["packages/schedule/schedule/src/types.ts#LegacyAfterScheduleRecord"],"sources":["packages/schedule/schedule/src/types.ts:161"]}, - {"digest":"4035903993dfee62cca2142c0ca51ab57075909ddee2817d0620daa97ac4d4ce","names":["packages/llm/llm/src/message.ts#UserMessage"],"sources":["packages/llm/llm/src/message.ts:162"]}, {"digest":"408927267422a7b2beb6df354ff7c1406d929a5646554f48adf85eb8372f811c","names":[],"sources":[]}, {"digest":"4208123b50df5006b181481ab45fcf1cde807b88d3fd4d340090bc2e202fac41","names":[],"sources":[]}, {"digest":"42e0916e0dda5f6d1e7bb05d8514717147c036a79c9f36085683469516c1fd3f","names":[],"sources":[]}, @@ -245,6 +245,7 @@ {"digest":"48f9c19417a1abbedfa59f4667bba36b93ac2db407adf5e84cb3ba0de30942cb","names":[],"sources":[]}, {"digest":"4991dfc415c9efebc36fa6969e1798c7d2e42396cc1e6e0ac7d7c89c5c5c4aea","names":[],"sources":[]}, {"digest":"49dab019d00e8c26f27ad2ccff7b604d69e323ae4d17e0d2a7b6b8a830b1ade2","names":[],"sources":[]}, + {"digest":"4a561c111a78d4520e850c93795369ea169ff86abd082aee86c8fd38121a65aa","names":[],"sources":[]}, {"digest":"4a9bef9f27a942520cc2279ca7dd3082556e4bff543f66b5c0333e1bbed1cd86","names":["packages/subagent/subagent/src/continuation-messages.ts#AgentMessageSource"],"sources":["packages/subagent/subagent/src/continuation-messages.ts:14"]}, {"digest":"4ae007ed190a72c92138b22d059b83208079c7551ca13fb59ae8cf5e23693964","names":[],"sources":[]}, {"digest":"4aed17ec726b5397c29f6cb4bcd7905dc1a12bb6140f1657f073cd24380f2af8","names":[],"sources":[]}, @@ -252,12 +253,11 @@ {"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/agent-team/src/types.ts#TeamId","packages/experimental/agent-team/src/types.ts#TeamMessageId","packages/experimental/agent-team/src/types.ts#TeamTaskId","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/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/agent-team/src/types.ts:21","packages/experimental/agent-team/src/types.ts:33","packages/experimental/agent-team/src/types.ts:9","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/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":[]}, {"digest":"4e887768586528565381dadbddee6cef555874148089f4579e0b4b1ad096fc9c","names":["packages/llm/llm/src/types.ts#TextBlock"],"sources":["packages/llm/llm/src/types.ts:69","packages/web/web-search-deepseek/src/provider.ts:66"]}, - {"digest":"4e97c3d85c0fc817ee58873c38c25a2474abaaa0af4839311b50b68db3b8cf1a","names":["packages/subagent/subagent/src/continuation-messages.ts#SubagentSettledMessageSource"],"sources":["packages/subagent/subagent/src/continuation-messages.ts:29"]}, {"digest":"4edec1b770e8ec4ce1220bf42edc5ac46713a1b48d4caa778dcfc98bad1f28a3","names":[],"sources":[]}, {"digest":"4f507598cf476214aa7646ce7841a7064a9dcb05d0a1eaaefaf79c2b738f160f","names":[],"sources":[]}, {"digest":"4f91f187baeabef5c7950c4780d31bbe39f3345dff591a6cf26ac7e135ac1cf4","names":[],"sources":["packages/context/session-reference/src/types.ts:18"]}, @@ -265,7 +265,7 @@ {"digest":"50cf02d875f8a1f1411f4a6fbcffab9120ccef5f74a47331fbf29480e5f68f48","names":[],"sources":[]}, {"digest":"511801b0a6470d98566e8385d3dffddb2b67f79fcf73ed6056f46c3cc0a245a7","names":[],"sources":[]}, {"digest":"516da4cdd6d2f1e5ce488e648578ca51f40e458f707b803e5b24de870e799415","names":[],"sources":[]}, - {"digest":"51edec100659da3a9bd9c3f6ba1180f5e64ec9a1585e5057080df45c180bd357","names":["packages/experimental/agent-team/src/types.ts#TeamTaskSnapshot"],"sources":["packages/experimental/agent-team/src/types.ts:75"]}, + {"digest":"51edec100659da3a9bd9c3f6ba1180f5e64ec9a1585e5057080df45c180bd357","names":["packages/team/agent-team/src/types.ts#TeamTaskSnapshot"],"sources":["packages/team/agent-team/src/types.ts:64"]}, {"digest":"525254db03b1d1e6b74cf55aced52817568331ac1f96c0818728910b6692e336","names":[],"sources":[]}, {"digest":"529e0ccd0e34079ab4ff21ed87f810cee0388246263bf22e8aba5986b56fae05","names":[],"sources":["packages/util/values/src/index.ts:4"]}, {"digest":"52c6113cb0e53f345324bf84f01928bea5d5df57ee80bc0dd65407b8bc5a3734","names":[],"sources":[]}, @@ -273,7 +273,6 @@ {"digest":"530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6","names":["packages/core/session/src/types.ts#RequestHeaderReason"],"sources":["packages/core/session/src/types.ts:279"]}, {"digest":"53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac","names":[],"sources":[]}, {"digest":"53cf1704ae0709c7678bcf070c83c619fc48ca1b5c173b74cfa8702f22a8e492","names":[],"sources":[]}, - {"digest":"53d4f8256bc58109468ea358ac54d437760cd765012360cafe72a401e6a41d4a","names":[],"sources":["packages/core/session/src/types.ts:317"]}, {"digest":"54f9276a1617d7c12594722c89cfe864bcbe4517e9a6f9c98bab14f199a135e0","names":[],"sources":["packages/core/session/src/types.ts:363"]}, {"digest":"557e148d30b79ee48645850832b30c717d7c3b64641deb5d6133b8b4e91ee0b8","names":[],"sources":[]}, {"digest":"5654685d7557ec747e74887237065e626a5bdfa426ca43f7fbb0b6fee9b0916c","names":[],"sources":[]}, @@ -290,7 +289,6 @@ {"digest":"5abab3a27fd2736660ecddd94b28d6bbf114986dd894880153bd1be3ccacb563","names":[],"sources":[]}, {"digest":"5b232a9b9bfcc0fc6794dab7865bcf203a65785c5de5aa647a51228bff628413","names":[],"sources":[]}, {"digest":"5b6fb1f226ff56402db08a6aae82af76100b14056271875f69ec526a1ae05d51","names":[],"sources":["packages/interaction/commands/src/types.ts:112"]}, - {"digest":"5bb231bc5afdb532a6898a20604e76bf0c1db438bfcdc17d4847dceaddc02451","names":[],"sources":[]}, {"digest":"5bb69b88517f6214f2af4d1a6d2b771e271ad3ad695cc9c2c272069ae698c9bc","names":[],"sources":[]}, {"digest":"5bc66269cfaf614c0ecda8ecb469f23101a7609793fc2b693ac23c4aecdb575e","names":[],"sources":[]}, {"digest":"5bd310104cfe448e530beb11422d2f791e2e32e8e95fdc528a15f9ca693dae75","names":[],"sources":[]}, @@ -307,16 +305,18 @@ {"digest":"5f2117d1856a5ab43c28371faa978ebfaa48f8c7d734ea3fc94842210df83dca","names":["packages/llm/llm/src/types.ts#LlmFailure"],"sources":["packages/llm/llm/src/types.ts:48"]}, {"digest":"5f26a6c20b37632f8f57729d171c671def4683d994ac6257a8dffcd855101627","names":[],"sources":[]}, {"digest":"5f758f3de5f00c18968a7c6d347083aac5c9ae13f6a79c78f6611b23c4973564","names":["packages/subagent/subagent/src/descriptor.ts#OneShotSubagentDescriptorData"],"sources":["packages/subagent/subagent/src/descriptor.ts:61"]}, + {"digest":"601327bedca5f98a0fa4c27133bd35749f3af2baaaacf53badf074e59cea5d25","names":["packages/session/session-title-llm/src/index.ts#SessionTitleLlmRequestEventData"],"sources":["packages/session/session-title-llm/src/index.ts:43"]}, {"digest":"6085488d223aeffdef51d7ae17655fc26ab22ee13cbed82a50e62e2b6fb5b918","names":[],"sources":[]}, {"digest":"611cae9a3539733aa0c42f66035a7d90f19dcbbdfb5d9e3b628b13705253942d","names":[],"sources":[]}, {"digest":"611d82d2dc50df706cf08a0fc04bc8eaf78083391fe6f10313de943e615dd2da","names":["packages/jobs/jobs/src/types.ts#JobSettledData"],"sources":["packages/jobs/jobs/src/types.ts:40"]}, + {"digest":"613660455704fb82a825b5b766e000aa64b5b4d44b4f10e55cffe968d4ec6ab1","names":["packages/llm/llm/src/message.ts#UserMessage"],"sources":["packages/llm/llm/src/message.ts:162"]}, {"digest":"616f93a5047de668e1c30d57b1786abd76beeb4123599637ec1dc5290320ede5","names":[],"sources":[]}, {"digest":"6292bcbedaa97feaec5b48a03bb8144b581b93309d9d6c905f26da7a6a7a0a4f","names":[],"sources":[]}, {"digest":"62e6a429ff3f390a46ac39fb468692032f42936ca0db19b53454184189401379","names":[],"sources":[]}, {"digest":"62ecb62dc0fe1b5e70217f8a80c39256a40ee3a1917be14e5ca899d20c35f7fd","names":[],"sources":[]}, {"digest":"6487b10ba7542f3c447b1e41a1c6a590b7bfafe0b9b66b49f34028da85bb970f","names":["packages/subagent/subagent/src/descriptor.ts#ContinuableSubagentDescriptorData"],"sources":["packages/subagent/subagent/src/descriptor.ts:72"]}, - {"digest":"64ef542b1bfc988382d337f2ea85aab8ad76ea55341cf082163919d315688428","names":["packages/llm/llm/src/message.ts#DeveloperMessage"],"sources":["packages/llm/llm/src/message.ts:157"]}, - {"digest":"6598cd6da253948d6b207c6374a45335804c79b576866d6215649c2d1bc85e25","names":["packages/experimental/agent-team/src/types.ts#TeamMemberSnapshot"],"sources":["packages/experimental/agent-team/src/types.ts:48"]}, + {"digest":"65377a76c6a58f785d95b89f89f2e13d289cb88a70ea74939d0ec08f429f0a6e","names":[],"sources":["packages/core/session/src/types.ts:317"]}, + {"digest":"6598cd6da253948d6b207c6374a45335804c79b576866d6215649c2d1bc85e25","names":["packages/team/agent-team/src/types.ts#TeamMemberSnapshot"],"sources":["packages/team/agent-team/src/types.ts:50"]}, {"digest":"65a8400e451e667e5b6e34df72ceb81fca8c12330330fa72083ad40279b189ad","names":["packages/llm/llm/src/message.ts#SystemPromptMessageSource"],"sources":["packages/llm/llm/src/message.ts:35"]}, {"digest":"65d583e66518c5236015e0846470fb32efcf92375ed306aef14e50a9d2cd2099","names":["packages/skill/tool-skill/src/index.ts#SkillCatalogSource"],"sources":["packages/skill/tool-skill/src/index.ts:35"]}, {"digest":"66ba70f6f068da443f969a0b947a013c3addd776b9b60a0a393759c19825e9a2","names":["packages/llm/llm/src/types.ts#ToolSchema"],"sources":["packages/llm/llm/src/types.ts:517"]}, @@ -325,6 +325,7 @@ {"digest":"68141cbe3994a5ffcd064c07c75996538437fd9dbf77ce8a2927e89ec577fabe","names":[],"sources":[]}, {"digest":"688e853dc149f0d30b31765c12ec7e3be74169bdf1a4c6463a10b945d50cc1b8","names":[],"sources":[]}, {"digest":"68f44f59e91c41395076cb77265981a601680ec8a381bbbeb6ab754ffdc94573","names":[],"sources":[]}, + {"digest":"690583582246babf6120f16eefd849dd760c5ec84619014951af403a1b3032c0","names":[],"sources":[]}, {"digest":"69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1","names":[],"sources":[]}, {"digest":"693f4952fb5565da96c06bace8924b098ac2fbc915fde0ff8019701183abc057","names":[],"sources":["packages/llm/llm/src/types.ts:168"]}, {"digest":"69d238a6e9b08d67f601b1825962963d8d3523cb69ebf6208c697dc5d058c199","names":[],"sources":[]}, @@ -339,7 +340,7 @@ {"digest":"6ccb6bc423fb3952458abf9c1b1c3284208b7e660344b612cc55da773d61afe4","names":[],"sources":[]}, {"digest":"6cda0d6d3b4336d83998fe580628426df81c2afbab5d768b8813b56d569120e8","names":[],"sources":[]}, {"digest":"6cf082d0fb6034dea61636787ff0ddf43b6e59aad71e644435a479bc9b83ad65","names":[],"sources":[]}, - {"digest":"6e52dd55a1d48954ed5464ec46fe34d9eb65f709baabc9561e7a279910b0c406","names":[],"sources":["packages/preset/agent-preset-registry/src/session.ts:31"]}, + {"digest":"6e52dd55a1d48954ed5464ec46fe34d9eb65f709baabc9561e7a279910b0c406","names":[],"sources":["packages/preset/agent-preset-registry/src/session.ts:30"]}, {"digest":"6ea608bdc8a7a63d3d23b84c49721325094db88d136482e43a820945295eee3e","names":[],"sources":[]}, {"digest":"6ebcc2dbec9f2dabca9f668ae6d39e877899b180740570f453409db339221df3","names":[],"sources":[]}, {"digest":"6f091fe32ab24407236090ca9d710bbaf59bb57466e9f3b8f1f6e4862bc863c6","names":[],"sources":[]}, @@ -349,7 +350,7 @@ {"digest":"6f72ece8f317cd080c4f20a93f1bf709965f5b81ac4669528f5a023191cfcf20","names":["packages/llm/llm-retry/src/types.ts#LlmRetryStartedEventData"],"sources":["packages/llm/llm-retry/src/types.ts:43"]}, {"digest":"6f9e00383836275e46c0a9915767f9bb561be5fc30aae490174908a93e99a912","names":[],"sources":[]}, {"digest":"70b082fe78991edf13cf66e1b4d06cd2dcff9cacfd76f8cfb996bfb61939e7b2","names":[],"sources":[]}, - {"digest":"70d2acdec1ebcfea56d02f7233ebb9ec0f7f494447fb952884c22c3afdc01d6f","names":[],"sources":[]}, + {"digest":"70ea8e7943c5e2d76905bcc2e694e8c3d5b138aaae3609f3e261b8d369a6e35a","names":[],"sources":[]}, {"digest":"7180844e1066230c5e891f1b9ccd942423b0fe48aac6ded7eef2a285b288a732","names":[],"sources":[]}, {"digest":"719c51f67074f97034a6c94297fe1f4b5d3c8d392c57586dab732aa5398c7a91","names":["packages/llm/llm/src/message.ts#ModelMessageSource"],"sources":["packages/llm/llm/src/message.ts:24"]}, {"digest":"71bb3254ec8f1e491ce63b762a7b5f18709de23aa4e8abcc8b886f4b6ce051dd","names":[],"sources":["packages/llm/llm/src/assistant-stream.ts:28"]}, @@ -367,20 +368,17 @@ {"digest":"7a651f3b672c52fb615f22c0ce9cb3cc5b22aad92e6bbeefb0808fd7b9233a50","names":[],"sources":[]}, {"digest":"7af85bf70d4eafce63e739adae38ea85501e68df3a0e22f97f3a0be90fd9e4cb","names":[],"sources":[]}, {"digest":"7b1ce60193fa8b69292e5843858d9cee7dc565dcb40d33c136e5d7e8b8d4296a","names":[],"sources":[]}, - {"digest":"7c203f3d4dfa6638ff2601247cc7ca5d46731d2c614aa61217475b9eb1fbd0b3","names":[],"sources":[]}, {"digest":"7c6129b6961cf4b2a030f36c78d2b0a5be38617651a9b81322e054ee512bee06","names":["packages/feedback/message-feedback/src/types.ts#MessageFeedbackDelete"],"sources":["packages/feedback/message-feedback/src/types.ts:46"]}, {"digest":"7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397","names":[],"sources":[]}, {"digest":"7d226b9fd95b4ba213d4237c22e930a91c3c769c95ca7f92977b9d625ba13438","names":["packages/goal/goal/src/domain.ts#GoalChangeMeta"],"sources":["packages/goal/goal/src/domain.ts:44"]}, {"digest":"7d54a5a5e629e2e7783bfac72683bcc5e969e9f43426b9289826aaf294ff6344","names":["packages/feedback/command-feedback/src/types.ts#FeedbackRecord"],"sources":["packages/feedback/command-feedback/src/types.ts:27"]}, {"digest":"7d65acf0a3a43a8f445ce5d3320b42eca492739fc931b2a7516bfb8368dc2c68","names":["packages/llm/llm/src/types.ts#ContentBlockType"],"sources":["packages/llm/llm/src/types.ts:155"]}, {"digest":"7df62978a2b7de8540e714152297743e0a622209a46a5ac9513fc0fad73ca23b","names":[],"sources":[]}, - {"digest":"7e9f1072c17f7aa86547c9c8b02766c1006d866a1859a3d677a336e932f70d1a","names":["packages/llm/llm/src/message.ts#MessageSource"],"sources":["packages/llm/llm/src/message.ts:136"]}, {"digest":"7eaef7a763ba5b949b8464bc8add8980a3a5ab566014b69787aeba71e5dfe07b","names":[],"sources":[]}, {"digest":"7f13860ee89a2d35ce09f958e85a04566cd59cb1de1d73a9619a9b631961dc71","names":[],"sources":[]}, {"digest":"7f7fd5a6b0064f597534b29ff62ef26e786dffccf5e14f654a7d4fcea2c35f04","names":[],"sources":[]}, {"digest":"7f9365bae9fcb73e1e34061a1acfe0422c1a5fd351848cace9bebbe806f28462","names":["packages/workflow/tool-workflow/src/types.ts#ToolWorkflowRunEndData"],"sources":["packages/workflow/tool-workflow/src/types.ts:36"]}, {"digest":"7f959241c24128b4983b559984811861dc59774b85905c8fa57c6cb0148a3dbc","names":["packages/goal/goal/src/domain.ts#GoalMessageSource"],"sources":["packages/goal/goal/src/domain.ts:47"]}, - {"digest":"7ff62f6599c083ceb5a0d7a8940ff85ba80dc1d7ddd6ba9339903e93f74cba54","names":["packages/session/session-title-llm/src/index.ts#SessionTitleLlmRequestEventData"],"sources":["packages/session/session-title-llm/src/index.ts:43"]}, {"digest":"8064c03c87c357fd1575cb9395ce86e6dcbe2d275bf7717864a02cddf01827d5","names":["packages/llm/llm/src/types.ts#ContentBlock"],"sources":["packages/llm/llm/src/types.ts:157"]}, {"digest":"80677c8c8b804189a0fde8aa70dd4ad7af8bb12dfd03027e8e023c8592c838bc","names":[],"sources":[]}, {"digest":"8067b0d232c350f189738e60ee3c9939d46ca3b32b45db5525237b077893f649","names":[],"sources":[]}, @@ -395,6 +393,7 @@ {"digest":"83d6916021eede8f2d83a1872716caa1efdec39a3681890996c55147187fdd12","names":[],"sources":[]}, {"digest":"83da72c6857601c75da11212ef847f57d457b9c99f0a82246dcdff3e26700239","names":["packages/context/agent-instructions/src/render.ts#AgentInstructionChange"],"sources":["packages/context/agent-instructions/src/render.ts:62"]}, {"digest":"8465ece5e9d9007c038b357015d849c15262cb0bc25abc59e47692d87ee01a15","names":["packages/workflow/workflow/src/types.ts#WorkflowStopReason"],"sources":["packages/workflow/workflow/src/types.ts:63"]}, + {"digest":"848639f8787f035b480ec53cb5686850897c5e8006d87ca231cd8a9ee8634936","names":["packages/subagent/subagent/src/continuation-messages.ts#SubagentSettledMessageSource"],"sources":["packages/subagent/subagent/src/continuation-messages.ts:29"]}, {"digest":"84ac8c1a66ffc697262e181f42e851259eae14038c113ca329fb4700ccdb1f7c","names":["packages/goal/goal/src/types.ts#GoalBlockReason"],"sources":["packages/goal/goal/src/types.ts:52"]}, {"digest":"84bbfdce7d2eab5b0c53e72ff7406db856c8bfd621b965b72fd86598ba493bf7","names":[],"sources":[]}, {"digest":"854db3efdfb0cd39d30953b28703e519c121f1115cebfffc40369fe86c406dd1","names":[],"sources":[]}, @@ -402,6 +401,7 @@ {"digest":"85f9b505b7bdff1f61845c4cea8f36fd0fde1b62facbe87d8924d1d27e616f3a","names":[],"sources":[]}, {"digest":"85fe96bf15d5c7559eec3bae4d6cf1a76fda9f5be05a29c2830cff560cab0c0d","names":[],"sources":[]}, {"digest":"8620189c8e33582708ece48fd2d20dc4d57912486a17bdbb9aea4c44f7018062","names":[],"sources":[]}, + {"digest":"866f7b2dbdc5b421eab3e629312d1c839b75c259633b4b43b50d23bac44d92a0","names":[],"sources":[]}, {"digest":"86d1d0e9cfebb240421187e3dc37fb71cc38a1501885cf32f8f9adef7b0a2bde","names":["packages/subagent/subagent/src/descriptor.ts#SubagentDescriptorData"],"sources":["packages/subagent/subagent/src/descriptor.ts:89"]}, {"digest":"86e1e384495ee09ac2d60c9db3610f1f1fddac2d248f6f1d6000e78054680219","names":["packages/llm/llm/src/message.ts#ContextSnapshotSection"],"sources":["packages/llm/llm/src/message.ts:70"]}, {"digest":"871bfd6f7bb91061246768d3377f3fd58d6b8ea7c31f44c714ae7ab215650d62","names":[],"sources":[]}, @@ -425,6 +425,7 @@ {"digest":"8f6d609794bf5afc01d7ccf3e03811d32f4b8047f58a2c9a98db0154c2ad7cb9","names":[],"sources":[]}, {"digest":"8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543","names":[],"sources":["packages/core/tools/src/index.ts:28"]}, {"digest":"904bf499d98be4a3a5f836fcac533b6c4899569b018071e6604e1d32657d00fd","names":[],"sources":[]}, + {"digest":"920d87e22b7409bcc7224f8b1dc7f5101c33574c75627c65baab12b85cc96e75","names":[],"sources":[]}, {"digest":"930c1cd289def119d443e19dd90008916bf035f0a4d23e07241e7f050f2b89b7","names":["packages/interaction/user-approval/src/types.ts#ApprovalOutcome"],"sources":["packages/interaction/user-approval/src/types.ts:32"]}, {"digest":"935c8d486292b13c87b57ca280eb8c58b5b2f5239be86fcd4334c33fdeb87682","names":[],"sources":[]}, {"digest":"938f7c7357bc403ea3b281a9dd016939df7a0880fc8db12612585be574d0d615","names":[],"sources":[]}, @@ -434,6 +435,7 @@ {"digest":"94c56aeb1564ad735eb773d88636b40761d063efa1610f1308004d32923f5378","names":[],"sources":[]}, {"digest":"95080295115fbd83544520ba247b68a03790131f6a2eac323d388758cb542f65","names":[],"sources":["packages/llm/llm/src/types.ts:498"]}, {"digest":"95a3ffccbf3c8afcf9a2f3b355f724da8e07e98dc47ee741166dde7f9ea058bd","names":[],"sources":[]}, + {"digest":"95b5cf90ea1f57da3319184d6f5f3811d0f651aa9113591c10e81c3d1e9b9f70","names":[],"sources":[]}, {"digest":"95c0e88fcf0bafb2087f912ad9e2369de6ae6530cc0c4c2772c96205b57f7f60","names":[],"sources":[]}, {"digest":"9645cf93769a6dfb227bd400bd6cb9fa7bab9140f4a851e4ae92514ade6e3114","names":[],"sources":[]}, {"digest":"96bfb916a727cd4445a594df264c523fc039749a31bfed924b8cc7bb308a5bb0","names":[],"sources":[]}, @@ -471,7 +473,7 @@ {"digest":"a3a259a1aa5401af786ce08c4a8419681d3000d73467991b5fc563389bc4ebd2","names":[],"sources":[]}, {"digest":"a3c6f1ea38e9a61d7a9706057ba65aabe02dfe6d9dd90d4736aa62325d874531","names":[],"sources":[]}, {"digest":"a3edd1efd1fef21b72c55e213c8a8b24196b091abde2b89db52be0b2aaae3cfa","names":[],"sources":[]}, - {"digest":"a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c","names":[],"sources":["packages/experimental/agent-team/src/types.ts:232"]}, + {"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"]}, @@ -508,14 +510,15 @@ {"digest":"b0127044ab31a702bddfd785d345f5abd7a70876746e895ce443afa3e60ddf2d","names":[],"sources":[]}, {"digest":"b062078e899958f99ae464514578b8c093924dfe34bd9a835932b420ff2150b1","names":[],"sources":[]}, {"digest":"b0b0eb1b16490f6bee135b2377e8f8c39227974c799ad7d49ff418af23ecbc35","names":["packages/attachment/attachment/src/types.ts#ImageAttachmentRef"],"sources":["packages/attachment/attachment/src/types.ts:11"]}, + {"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/experimental/agent-team/src/types.ts:230"]}, + {"digest":"b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1","names":[],"sources":["packages/team/agent-team/src/types.ts:175"]}, {"digest":"b1f62380f6746fedd629e71065e3583cef83334875b499ac9665c2eb87acfbf5","names":[],"sources":[]}, {"digest":"b222069eea2d768065161c1147b1f2c78c1b54328c84b3586ae5c8f91b8ed35e","names":[],"sources":[]}, {"digest":"b2245a906e13aa94f2d3cc6cf7b04cfa0267f7b7f4d468de1b00b1a314e54626","names":[],"sources":[]}, {"digest":"b2a5c8c008a31e3dc6c3013502f2356aca13894b31c8e70601fa92f2d40d1dba","names":[],"sources":[]}, - {"digest":"b2d2a01e51341a3bfec809198952c8c830a7899f4cb6ecd3c1dc0b5d9a2af943","names":["packages/experimental/agent-team/src/types.ts#TeamTaskStatus"],"sources":["packages/experimental/agent-team/src/types.ts:72"]}, + {"digest":"b2d2a01e51341a3bfec809198952c8c830a7899f4cb6ecd3c1dc0b5d9a2af943","names":["packages/team/agent-team/src/types.ts#TeamTaskStatus"],"sources":["packages/team/agent-team/src/types.ts:61"]}, {"digest":"b3681c026a0b98e98b103f057dfd217fbe904129c090efd668253ec9aea7fece","names":[],"sources":[]}, {"digest":"b38f206d59bad891fa58c89f97a0036987b3defe7685acbd318fa55eb85f9751","names":[],"sources":[]}, {"digest":"b4ec0af39fd52ed971990e42f04e5be3204882e555c409eb330f8238eec21569","names":[],"sources":[]}, @@ -558,6 +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: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":[]}, @@ -567,7 +571,6 @@ {"digest":"c589e938cc0e4989df8b82abef6339de621f0dc4fe62c230d145b94996af7f92","names":[],"sources":[]}, {"digest":"c592ce75aab73fcab19c1d7845684c72cf402b78d2e1f2833a58ecf9f3598ed6","names":["packages/util/values/src/index.ts#JsonValue"],"sources":["packages/util/values/src/index.ts:4"]}, {"digest":"c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262","names":[],"sources":[]}, - {"digest":"c78b9097bfae83cd08f0a98a92730236ba80ac61d712046959e4fc7f28aa1f36","names":["packages/llm/llm/src/message.ts#Message"],"sources":["packages/llm/llm/src/message.ts:196"]}, {"digest":"c8e6f48878cc2a2f0dc59682280d81f407943fb503f32c64bf9ff3d9393c3eff","names":[],"sources":[]}, {"digest":"c99e20441bf15341e9db54385e558e5c03d21dff2eb1ac08ccbf47c2d8747954","names":[],"sources":[]}, {"digest":"ca085dba2c877feceebf3c62f9d39c6c2a4d2314d97b94b3927a780fe8fe2bb2","names":[],"sources":[]}, @@ -579,21 +582,21 @@ {"digest":"cda2345a9be9d139df49ccbd16dcba716f3440178bf20ff8b801fda1f5eb77dc","names":[],"sources":[]}, {"digest":"cdca80b8dad24d4e7b1d5a288a8475ba8d8d46be385bd2da85f122ee836f1670","names":[],"sources":[]}, {"digest":"cdee10e87dbed8440d8b6935888624884878efc7af95402e94171b29f173dced","names":[],"sources":["packages/llm/llm-retry/src/types.ts:17"]}, - {"digest":"cdf988172b589b230f2bce868489def1bb575eb0501076ac757a8f369ac5c64e","names":[],"sources":[]}, {"digest":"cef171adf021a1867d244618a3dbbaafc22beb17c7f3df3ee203393271c8c6f6","names":["packages/core/tool-executor-ptc/src/types.ts#PtcDispatchEventData"],"sources":["packages/core/tool-executor-ptc/src/types.ts:21"]}, {"digest":"cffcdc19881ab61ec0a9ce18743996f7ac44ac8889f34dfac36ae745574aa22f","names":["packages/goal/goal/src/domain.ts#GoalClearChangeMeta"],"sources":["packages/goal/goal/src/domain.ts:35"]}, {"digest":"d0fbb0ca1bbba059830826f33f801da1aced35a2504376d61e020bdfec8d50c0","names":[],"sources":[]}, {"digest":"d28765f87d6b6ab408e0b2f3e7e297791b004b806d8e302c49903ebae815feb2","names":[],"sources":[]}, {"digest":"d4b739491689cc6bb13d07ffac1a32239875abda364c0d5edd8f74faf5e9c00d","names":[],"sources":["packages/llm/llm/src/types.ts:501"]}, {"digest":"d4c799e3d51cae1040362ba4968efc484739e14416c9c1df20c2f108b4ef2eb5","names":[],"sources":[]}, - {"digest":"d4d960185026266ed7bd127be1e252234689e8b4890c7c2677a644ba58f35401","names":[],"sources":["packages/core/agent/src/types.ts:110"]}, {"digest":"d4e4d575abeb4b72d616dc65c17ac0eaa943cc135609395044c38d5afcb10e31","names":[],"sources":[]}, {"digest":"d595ec73b32b016a6055333c67a5d646032b22e672da1d4a09c5b1ae398a093a","names":[],"sources":[]}, {"digest":"d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9","names":[],"sources":[]}, {"digest":"d66e11dddf5dfa3c71547e2cbd7b0608824cd8d6243fc16f532017ff28e8fba5","names":[],"sources":[]}, {"digest":"d6e654b88b584fcdf290a56802b1730bc7b9847ae86baa4265ab7769eb48990c","names":[],"sources":[]}, {"digest":"d85590a5675c6e2fd8164e5792a6cceac714605b8ac228adae5ee668facb1490","names":[],"sources":[]}, + {"digest":"d8a476d2c6fd30df6ec0e799f91716d1518c780d23b4121dce545abfcfb7c5a5","names":[],"sources":[]}, {"digest":"d9196086e41e4dd5c2ce87f5c25529c8112a1731d258cf0728747d41a9ee3241","names":[],"sources":[]}, + {"digest":"d95c624eddecdacf1ee74356196c09d2f3fcd625714be5e852a6b643fa12a7ca","names":[],"sources":[]}, {"digest":"d9c462b9f62fdd3efc76a44b9524dc8781f6b91cd9c7e584b17d971defafebab","names":["packages/llm/llm/src/message.ts#ToolMessageSource"],"sources":["packages/llm/llm/src/message.ts:29"]}, {"digest":"d9edf1862e4471d6ad8a106a3f4160e0643874368707d50ecd8e27e5ee7a2567","names":[],"sources":[]}, {"digest":"da60ed58a8fa23d7aa4920ab5c1f4e3d28109cb0797ef9e5c8b125721b847582","names":[],"sources":["packages/session/working-directory/src/index.ts:36"]}, @@ -603,10 +606,12 @@ {"digest":"db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965","names":[],"sources":[]}, {"digest":"dba54c09c6e7aafc9acbed7792ecc920d99f92d0784aeeed10af55f7fe0bc8cd","names":[],"sources":["packages/core/tool-executor-ptc/src/types.ts:68"]}, {"digest":"dbdafceb9a900183cccc80596498d386f3bf7bddead2b8cfde68453aa4739edc","names":[],"sources":[]}, + {"digest":"dcb07e04496586d2664d72dab0e94312917da56927b2a0dc3ee53d6a33e32450","names":[],"sources":[]}, {"digest":"dd00cdccd0ba487b92362ef67db58cd264f48c1bfd368291424a1da9a899489a","names":[],"sources":[]}, {"digest":"dd2780e7e07684332f7a9ab047237798d260e7eaffb1321cb38eca695d184c3d","names":[],"sources":[]}, {"digest":"dd5ee8762bd8a14b6c6bffedd1753e4e78505bb0f9ea1621018d03a54eef59d5","names":[],"sources":[]}, {"digest":"dd6cc2bb2a8f28248347021ad93565360b196a042360b4cd16b569d3a2a91f2d","names":[],"sources":[]}, + {"digest":"ddce54a3873dc57113b0cd38ad9b23b3c259186139e8847e5e5f415d86f52482","names":[],"sources":[]}, {"digest":"dde0cdf9d426768b649b5a9783d2144e7f900c35ef65229cd89036dbf411d1b0","names":[],"sources":[]}, {"digest":"de25352d2dc4196309bd84b5bac6319f6d5c898ea0e82bb5ae66c5738d0d426f","names":[],"sources":[]}, {"digest":"de30b1fa12c75f81df6aa4096e1595296d2d04e2be14a7c29849cf8028b79048","names":[],"sources":[]}, @@ -614,8 +619,10 @@ {"digest":"deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5","names":[],"sources":["packages/typert/protocol/src/types.ts:457"]}, {"digest":"df5d9709b7db2a72510dc52052a23f0c38f39730af1504bab8bccabc14fae528","names":[],"sources":[]}, {"digest":"df843efd87c78cf8de98f8e0d0216956cd568d0d0e56a22c853663e274aa1a18","names":[],"sources":[]}, - {"digest":"dfce5db4aa42b7ebe0bb79eca92553587287e18c06b9eba85cbfb9d332e26acd","names":["packages/experimental/agent-team/src/types.ts#TeamMemberPhase"],"sources":["packages/experimental/agent-team/src/types.ts:45"]}, + {"digest":"dfce5db4aa42b7ebe0bb79eca92553587287e18c06b9eba85cbfb9d332e26acd","names":["packages/team/agent-team/src/types.ts#TeamMemberPhase"],"sources":["packages/team/agent-team/src/types.ts:47"]}, {"digest":"e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05","names":[],"sources":[]}, + {"digest":"e1880c67e735e228f3555df8a55a492db2c0b0c71bc8859e938e184a46c7db51","names":[],"sources":["packages/core/agent/src/types.ts:110"]}, + {"digest":"e272493e3ea0b33b2e1d64b6a80e9d8bd3d1d466cc115f2facbdd598f8644a39","names":["packages/llm/llm/src/message.ts#Message"],"sources":["packages/llm/llm/src/message.ts:196"]}, {"digest":"e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187","names":[],"sources":[]}, {"digest":"e2d0367e5716bc188435e51718ebff8ce873190625fa93b1c5331102d98758fa","names":[],"sources":[]}, {"digest":"e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602","names":[],"sources":[]}, @@ -640,7 +647,7 @@ {"digest":"e7f4cbb3a57f4cbdb61ea41e95bcba626ad1fff6bb9b1e063b1d50a32da6e864","names":[],"sources":[]}, {"digest":"e8de0f457fed5b9ff94dc4950b59ef75fd8ba8af665cc7899485924a62653367","names":[],"sources":[]}, {"digest":"e95f957154b2b1b58692af53d4131180b6dd19e2264869f876dbaa959991494e","names":[],"sources":[]}, - {"digest":"ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403","names":[],"sources":["packages/experimental/agent-team/src/types.ts:234"]}, + {"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":[]}, @@ -692,11 +699,11 @@ {"digest":"fe1a7acdeed4d0f5ab483396eaa5b3dc97263b7c10eb4da2f0028cb01728bfc9","names":[],"sources":[]}, {"digest":"fe792a6cd430494503837e21e79f91677704ccdd9e870c679aabd7d43ed47ddd","names":[],"sources":[]}, {"digest":"fec5442b9ee4122e10514d81d5a43648003fe8bfb7ee2ba50c0cdbdd383ed394","names":["packages/llm/llm/src/types.ts#TokenUsage"],"sources":["packages/llm/llm/src/types.ts:182"]}, - {"digest":"fee1caf8ab6a65c1bc981f5a1730164490f22a2a89ffa3d0b76f1f432c44c8b5","names":[],"sources":[]}, - {"digest":"ff2cbb53582d1827b02622482246979942f59e07e0393b76a161251423549e72","names":[],"sources":[]} + {"digest":"fee1caf8ab6a65c1bc981f5a1730164490f22a2a89ffa3d0b76f1f432c44c8b5","names":[],"sources":[]} ], "nodes": { "0116b1f89c05221d": {"kind":"literal","value":"web_search_20250305"}, + "01c161379a3c3394": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"589d0d91239681f8","optional":false},{"name":"source","type":"b120362fd216a16a","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.developer-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]}, "0203243b72fc867a": {"kind":"object","properties":[{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, "022623b9ef98ec47": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, "023a355b2be0d688": {"kind":"object","properties":[{"name":"kind","type":"254dce209e735f9c","optional":false}],"indices":[]}, @@ -811,7 +818,6 @@ "2da725e554cd8930": {"kind":"literal","value":"fork"}, "2e3523541e942ef6": {"kind":"union","types":["18dc21f12bbac8ad","8e8e91646a5cae3f","e6063d0451dc0202","eea687d437880bd5"]}, "2e4debca12f2c449": {"kind":"literal","value":"team-message"}, - "2e505098f9209e52": {"kind":"object","properties":[{"name":"data","type":"7ff62f6599c083ce","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"587bb11da730e06a","optional":false}],"indices":[]}, "2eafefdc6f79a492": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"senderId","type":"4cd48f3e5108bd6e","optional":false},{"name":"senderName","type":"4cd48f3e5108bd6e","optional":false},{"name":"targetId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "2f1e6a7ebace43d7": {"kind":"union","types":["3f74ba74acd10644","58f94390b982f8a5","de30b1fa12c75f81","ebb768d85ec87fb6"]}, "2f3f201c8846e19c": {"kind":"literal","value":"tool-goal"}, @@ -854,7 +860,6 @@ "3ee93b06f3a12585": {"kind":"object","properties":[{"name":"data","type":"7c6129b6961cf4b2","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"44818000e56acb97","optional":false}],"indices":[]}, "3f74ba74acd10644": {"kind":"literal","value":"image/jpeg"}, "3fdf9582c3b5597d": {"kind":"object","properties":[{"name":"afterSeconds","type":"0657d3ca5ac4e7bf","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"5b232a9b9bfcc0fc","optional":false},{"name":"prompt","type":"4cd48f3e5108bd6e","optional":false},{"name":"scheduledAt","type":"4cd48f3e5108bd6e","optional":false},{"name":"title","type":"4cd48f3e5108bd6e","optional":true}],"indices":[]}, - "4035903993dfee62": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"68f44f59e91c4139","optional":false},{"name":"source","type":"7e9f1072c17f7aa8","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.user-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]}, "408927267422a7b2": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, "4208123b50df5006": {"kind":"object","properties":[{"name":"data","type":"ad0c38a0adb82a94","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"3e0eff5946255ff0","optional":false}],"indices":[]}, "42e0916e0dda5f6d": {"kind":"object","properties":[{"name":"data","type":"7f9365bae9fcb73e","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"fe792a6cd4304945","optional":false}],"indices":[]}, @@ -873,6 +878,7 @@ "48f9c19417a1abbe": {"kind":"object","properties":[{"name":"data","type":"0bcfaf5566d27102","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"a8a2b9011fc06786","optional":false}],"indices":[]}, "4991dfc415c9efeb": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, "49dab019d00e8c26": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "4a561c111a78d452": {"kind":"literal","value":"refusal"}, "4a9bef9f27a94252": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"82470020b5bfcb1a","optional":false},{"name":"senderSessionId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "4ae007ed190a72c9": {"kind":"literal","value":"llm/retry-started"}, "4aed17ec726b5397": {"kind":"tuple","elements":[{"type":"2d11ca7b0d4493e2","optional":false,"rest":false}]}, @@ -885,7 +891,6 @@ "4e011753f8263f43": {"kind":"literal","value":"team/message/queued"}, "4e44c9118152f682": {"kind":"literal","value":"text"}, "4e88776858652856": {"kind":"object","properties":[{"name":"text","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"4e44c9118152f682","optional":false}],"indices":[]}, - "4e97c3d85c0fc817": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"bf16d2e98a7f1389","optional":false},{"name":"senderSessionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "4edec1b770e8ec4c": {"kind":"literal","value":"ptc-mode"}, "4f507598cf476214": {"kind":"literal","value":"series"}, "4f91f187baeabef5": {"kind":"object","properties":[{"name":"capturedFormatVersion","type":"0657d3ca5ac4e7bf","optional":true},{"name":"capturedThroughSeq","type":"3bd652ebfa8726b3","optional":false},{"name":"compacted","type":"aa93010d0e521ad7","optional":false},{"name":"inputIndex","type":"0657d3ca5ac4e7bf","optional":false},{"name":"label","type":"4cd48f3e5108bd6e","optional":false},{"name":"omittedBytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"omittedMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"originalMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"retainedMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sessionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"truncated","type":"aa93010d0e521ad7","optional":false}],"indices":[]}, @@ -901,7 +906,6 @@ "530da6c0267813bb": {"kind":"union","types":["34d1ab5c5df37818","4f507598cf476214","e7ce49f12953583f","f789af5ab844827e"]}, "53750b9abf61efb7": {"kind":"object","properties":[{"name":"data","type":"f731ffe80810b028","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"44c8c77aa9aa100b","optional":false}],"indices":[]}, "53cf1704ae0709c7": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, - "53d4f8256bc58109": {"kind":"object","properties":[{"name":"headerSeq","type":"0657d3ca5ac4e7bf","optional":true},{"name":"message","type":"64ef542b1bfc9883","optional":false},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "54f9276a1617d7c1": {"kind":"object","properties":[{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"stream","type":"84bbfdce7d2eab5b","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "557e148d30b79ee4": {"kind":"literal","value":"agent-preset/selected"}, "5654685d7557ec74": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, @@ -918,7 +922,6 @@ "5abab3a27fd27366": {"kind":"literal","value":"text-delta"}, "5b232a9b9bfcc0fc": {"kind":"literal","value":"after"}, "5b6fb1f226ff5640": {"kind":"object","properties":[{"name":"commandId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"1ab2ef1296f157ba","optional":false},{"name":"sourceEventSeq","type":"0657d3ca5ac4e7bf","optional":true},{"name":"text","type":"4cd48f3e5108bd6e","optional":true}],"indices":[]}, - "5bb231bc5afdb532": {"kind":"object","properties":[{"name":"data","type":"4035903993dfee62","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"de25352d2dc41963","optional":false}],"indices":[]}, "5bb69b88517f6214": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "5bc66269cfaf614c": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]}, "5bd310104cfe448e": {"kind":"literal","value":"resource-cost"}, @@ -935,15 +938,17 @@ "5f2117d1856a5ab4": {"kind":"object","properties":[{"name":"code","type":"4cd48f3e5108bd6e","optional":false},{"name":"message","type":"4cd48f3e5108bd6e","optional":false},{"name":"offloadImages","type":"0657d3ca5ac4e7bf","optional":true},{"name":"providerRetryAfterMs","type":"0657d3ca5ac4e7bf","optional":true},{"name":"requestId","type":"4cd48f3e5108bd6e","optional":true},{"name":"status","type":"0657d3ca5ac4e7bf","optional":true}],"indices":[]}, "5f26a6c20b37632f": {"kind":"object","properties":[{"name":"data","type":"9a410ddf56792b2b","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"ad92f473a03ae725","optional":false}],"indices":[]}, "5f758f3de5f00c18": {"kind":"object","properties":[{"name":"label","type":"4cd48f3e5108bd6e","optional":true},{"name":"mode","type":"d28765f87d6b6ab4","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"version","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "601327bedca5f98a": {"kind":"object","properties":[{"name":"maxTokens","type":"0657d3ca5ac4e7bf","optional":false},{"name":"messageSeqs","type":"5d03ba38734809bc","optional":false},{"name":"messages","type":"95b5cf90ea1f57da","optional":false},{"name":"reasoningEffort","type":"4cd48f3e5108bd6e","optional":true},{"name":"route","type":"07e1a58c58b593f5","optional":false},{"name":"system","type":"4cd48f3e5108bd6e","optional":false},{"name":"titleProvider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "6085488d223aeffd": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]}, "611cae9a3539733a": {"kind":"literal","value":"llm/retry"}, "611d82d2dc50df70": {"kind":"object","properties":[{"name":"cause","type":"750a2b129d67704d","optional":false},{"name":"detail","type":"4cd48f3e5108bd6e","optional":true},{"name":"finishedAt","type":"0657d3ca5ac4e7bf","optional":false},{"name":"jobId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"4cd48f3e5108bd6e","optional":false},{"name":"label","type":"4cd48f3e5108bd6e","optional":false},{"name":"owner","type":"4cd48f3e5108bd6e","optional":false},{"name":"producerReported","type":"aa93010d0e521ad7","optional":false},{"name":"startedAt","type":"0657d3ca5ac4e7bf","optional":false},{"name":"status","type":"c2f49004d9ae38a9","optional":false},{"name":"toolCall","type":"39cf2d3ff8bac81c","optional":true}],"indices":[]}, + "613660455704fb82": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"68f44f59e91c4139","optional":false},{"name":"source","type":"b120362fd216a16a","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.user-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]}, "616f93a5047de668": {"kind":"literal","value":"tool-call-chunks"}, "6292bcbedaa97fea": {"kind":"object","properties":[{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, "62e6a429ff3f390a": {"kind":"union","types":["1d8798b1a5e8c545","1e346446e183ee76","34d1ab5c5df37818","7624ca28c61ca0ea","ad22a5ca5bbaab2e","d9196086e41e4dd5"]}, "62ecb62dc0fe1b5e": {"kind":"literal","value":"next-turn"}, "6487b10ba7542f3c": {"kind":"object","properties":[{"name":"agentModel","type":"4cd48f3e5108bd6e","optional":true},{"name":"agentProvider","type":"4cd48f3e5108bd6e","optional":true},{"name":"agentReasoningEffort","type":"4cd48f3e5108bd6e","optional":true},{"name":"label","type":"4cd48f3e5108bd6e","optional":false},{"name":"mode","type":"e5d3828df1ec3e2a","optional":false},{"name":"persona","type":"4cd48f3e5108bd6e","optional":true},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"toolFilter","type":"3cfc3a56502da1f8","optional":true},{"name":"version","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, - "64ef542b1bfc9883": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"589d0d91239681f8","optional":false},{"name":"source","type":"7e9f1072c17f7aa8","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.developer-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]}, + "65377a76c6a58f78": {"kind":"object","properties":[{"name":"headerSeq","type":"0657d3ca5ac4e7bf","optional":true},{"name":"message","type":"01c161379a3c3394","optional":false},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "6598cd6da253948d": {"kind":"object","properties":[{"name":"context","type":"50cf02d875f8a1f1","optional":false},{"name":"description","type":"4cd48f3e5108bd6e","optional":false},{"name":"error","type":"4cd48f3e5108bd6e","optional":true},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"phase","type":"dfce5db4aa42b7eb","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "65a8400e451e667e": {"kind":"object","properties":[{"name":"kind","type":"09655b34d7f3cf1e","optional":false}],"indices":[]}, "65d583e66518c523": {"kind":"object","properties":[{"name":"entries","type":"aba175fb20ffc711","optional":false},{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"incomplete","type":"dacf3d5f3e4ff619","optional":true},{"name":"kind","type":"6ebcc2dbec9f2dab","optional":false},{"name":"update","type":"dacf3d5f3e4ff619","optional":true}],"indices":[]}, @@ -953,6 +958,7 @@ "68141cbe3994a5ff": {"kind":"literal","value":"max-tokens"}, "688e853dc149f0d3": {"kind":"literal","value":"reasoning-chunks"}, "68f44f59e91c4139": {"kind":"literal","value":"user"}, + "690583582246babf": {"kind":"array","element":"613660455704fb82"}, "69081694be231d56": {"kind":"object","properties":[{"name":"data","type":"6bc8e51124d0874b","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1c5a58f4599bd4a9","optional":false}],"indices":[]}, "693f4952fb5565da": {"kind":"object","properties":[{"name":"failure","type":"5f2117d1856a5ab4","optional":false},{"name":"kind","type":"a2be0f0cb59f0d4a","optional":false}],"indices":[]}, "69d238a6e9b08d67": {"kind":"literal","value":"interrupted"}, @@ -977,7 +983,7 @@ "6f72ece8f317cd08": {"kind":"object","properties":[{"name":"retry","type":"0657d3ca5ac4e7bf","optional":false},{"name":"retryId","type":"4cd48f3e5108bd6e","optional":false},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "6f9e00383836275e": {"kind":"literal","value":"snapshot"}, "70b082fe78991edf": {"kind":"literal","value":"request/context"}, - "70d2acdec1ebcfea": {"kind":"object","properties":[{"name":"data","type":"53d4f8256bc58109","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1379f0b72275994d","optional":false}],"indices":[]}, + "70ea8e7943c5e2d7": {"kind":"object","properties":[{"name":"data","type":"65377a76c6a58f78","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1379f0b72275994d","optional":false}],"indices":[]}, "7180844e1066230c": {"kind":"literal","value":"service-stability"}, "719c51f67074f970": {"kind":"object","properties":[{"name":"kind","type":"2765e9119edf5816","optional":false},{"name":"model","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"replayState","type":"bbc5696e3f13c758","optional":true}],"indices":[]}, "71bb3254ec8f1e49": {"kind":"object","properties":[{"name":"dt","type":"5d03ba38734809bc","optional":false},{"name":"index","type":"0657d3ca5ac4e7bf","optional":false},{"name":"texts","type":"93c33d9687613293","optional":false},{"name":"time0","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"688e853dc149f0d3","optional":false}],"indices":[]}, @@ -995,20 +1001,17 @@ "7a651f3b672c52fb": {"kind":"literal","value":"session/title"}, "7af85bf70d4eafce": {"kind":"object","properties":[{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "7b1ce60193fa8b69": {"kind":"literal","value":"working-directory"}, - "7c203f3d4dfa6638": {"kind":"array","element":"4035903993dfee62"}, "7c6129b6961cf4b2": {"kind":"object","properties":[{"name":"messageId","type":"4cd48f3e5108bd6e","optional":false},{"name":"sessionId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "7c9f44e90a0058f4": {"kind":"object","properties":[{"name":"data","type":"5302e76315127761","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"570e850224346133","optional":false}],"indices":[]}, "7d226b9fd95b4ba2": {"kind":"union","types":["1df34a6c086866ff","cffcdc19881ab61e"]}, "7d54a5a5e629e2e7": {"kind":"object","properties":[{"name":"category","type":"3305a4bb6b6c29b4","optional":true},{"name":"text","type":"4cd48f3e5108bd6e","optional":true}],"indices":[]}, "7d65acf0a3a43a8f": {"kind":"union","types":["1f0133217eeeffd8","277b61f9c6f81704","3c2535ca0a2062f1","4e44c9118152f682","83d6916021eede8f","85f9b505b7bdff1f","fb8b6a2e53cb9cf4"]}, "7df62978a2b7de85": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]}, - "7e9f1072c17f7aa8": {"kind":"union","types":["0203243b72fc867a","022623b9ef98ec47","02b2c23f2d94b028","0ed8d1108fe7a5e1","0eeb17e1df89b1eb","11638dc58a31496f","1938c8c20f1cba7c","1e709346005826eb","1fdad624334d3548","2152caf1a71e588e","2235d957bf4b7c6c","224aa11d4d5f88c4","24ce31aa528272e5","260523b412c19ad8","28936c614bd4e7e2","290f32bfd81ff85a","297fbd8067d7815a","2aad1179f9576314","312aee2f0c42aabb","31907de956375182","3247e566f4340029","3b04790e08b35241","3b4f2c9ef2c1217e","3bdb8dc6989655b0","408927267422a7b2","45f005e6bbb76452","4991dfc415c9efeb","49dab019d00e8c26","4a9bef9f27a94252","4e97c3d85c0fc817","52c6113cb0e53f34","53cf1704ae0709c7","5654685d7557ec74","58015da1c1950a66","5bb69b88517f6214","5bc66269cfaf614c","5cdbf342b7748d15","5d7a64ed6aac5551","6085488d223aeffd","6292bcbedaa97fea","65a8400e451e667e","65d583e66518c523","672c4aa2a9b736e3","6ccb6bc423fb3952","6ea608bdc8a7a63d","6f22a9d02b9a1f11","6f44f32be9bcd9a8","719c51f67074f970","7554565be416b034","756b5e23a5cb2c7f","7df62978a2b7de85","7eaef7a763ba5b94","7f959241c24128b4","80677c8c8b804189","825ca23416b1ea9d","8338ee303e2bd448","854db3efdfb0cd39","85fe96bf15d5c755","8620189c8e335827","87b1a33564b9a4d6","88841202942c7683","8a046b52331959aa","8c1c4f72755a78ea","8c627d7a45fa406c","8f324cea3916c051","8fcb0b44a575e998","96bfb916a727cd44","99135d8a39379779","99f0e98184709ad1","9edc162949abf93b","9f65c43e340bba31","a15a19ef63cf5d1b","a16e567c8cd2c55c","a2066bd52f61218b","a3c6f1ea38e9a61d","a4afec59b75aad5d","aa2bf9e82b9fdeb9","aae343bc3d663c83","ada310bf0bdb8fed","ade057f4fdc387ae","b062078e899958f9","b2a5c8c008a31e3d","b3681c026a0b98e9","b4ec0af39fd52ed9","b613f83572fe2fbf","b6f94c17dd9bbd7c","b70f3e81fe11b7a5","b79641a9d25e7566","ba2a1a8f194b1bb2","ba4c40b4381e0dcd","baccc97ec34be6c4","c5228b7a55498d84","c55827fba7018198","cb1c8959e1726f82","cda2345a9be9d139","d85590a5675c6e2f","d9c462b9f62fdd3e","db06ef35b27a3842","dba54c09c6e7aafc","dd00cdccd0ba487b","dd5ee8762bd8a14b","dd6cc2bb2a8f2824","dde0cdf9d426768b","deaae5cd30987d4f","e7f4cbb3a57f4cbd","edbbfff1128099fd","ee577a0cc18664c9","f230c0b583e23b25","f29d63336e8266d8","f4a66f654996c093","f56c5b59ca016131","f6329fb30c7ea801","f6605d6c57933aea","f6afe41e3926f422","f7283223aacdc39b","f97f47b0eb87905e","fcf81d3c58c13593","fe1a7acdeed4d0f5"]}, "7eaef7a763ba5b94": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, "7f13860ee89a2d35": {"kind":"literal","value":"session-reference"}, "7f7fd5a6b0064f59": {"kind":"object","properties":[{"name":"data","type":"5dd766ccd1702a11","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"10e386801903f9d4","optional":false}],"indices":[]}, "7f9365bae9fcb73e": {"kind":"object","properties":[{"name":"runId","type":"4cd48f3e5108bd6e","optional":false},{"name":"stopReason","type":"8465ece5e9d9007c","optional":false}],"indices":[]}, "7f959241c24128b4": {"kind":"object","properties":[{"name":"goalId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"b815d7b91cdd9fc6","optional":false},{"name":"revision","type":"0657d3ca5ac4e7bf","optional":false},{"name":"round","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, - "7ff62f6599c083ce": {"kind":"object","properties":[{"name":"maxTokens","type":"0657d3ca5ac4e7bf","optional":false},{"name":"messageSeqs","type":"5d03ba38734809bc","optional":false},{"name":"messages","type":"ff2cbb53582d1827","optional":false},{"name":"reasoningEffort","type":"4cd48f3e5108bd6e","optional":true},{"name":"route","type":"07e1a58c58b593f5","optional":false},{"name":"system","type":"4cd48f3e5108bd6e","optional":false},{"name":"titleProvider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "8064c03c87c357fd": {"kind":"union","types":["12d5808fac1996b9","43e311235aa537df","4e88776858652856","814d9434e1ddf907","8e611bf46821e500","c1f0f17b6493b85d","f2da0f27c1e6add5"]}, "80677c8c8b804189": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, "8067b0d232c350f1": {"kind":"literal","value":"time-context"}, @@ -1023,6 +1026,7 @@ "83d6916021eede8f": {"kind":"literal","value":"tool-removal"}, "83da72c6857601c7": {"kind":"object","properties":[{"name":"action","type":"904bf499d98be4a3","optional":false},{"name":"digest","type":"4cd48f3e5108bd6e","optional":true},{"name":"path","type":"4cd48f3e5108bd6e","optional":false},{"name":"scope","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "8465ece5e9d9007c": {"kind":"union","types":["1ebc6f9ff3aa9e3a","2142e391b9ccb60d","a2be0f0cb59f0d4a"]}, + "848639f8787f035b": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"bf16d2e98a7f1389","optional":false},{"name":"senderSessionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"stopReason","type":"866f7b2dbdc5b421","optional":true},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "84ac8c1a66ffc697": {"kind":"object","properties":[{"name":"code","type":"4cd48f3e5108bd6e","optional":false},{"name":"message","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "84bbfdce7d2eab5b": {"kind":"array","element":"33341aac09312300"}, "854db3efdfb0cd39": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, @@ -1030,6 +1034,7 @@ "85f9b505b7bdff1f": {"kind":"literal","value":"tool-addition"}, "85fe96bf15d5c755": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]}, "8620189c8e335827": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, + "866f7b2dbdc5b421": {"kind":"union","types":["1e0b0b66b6f0c53f","1ebc6f9ff3aa9e3a","4a561c111a78d452","68141cbe3994a5ff","a2be0f0cb59f0d4a"]}, "86d1d0e9cfebb240": {"kind":"union","types":["5f758f3de5f00c18","6487b10ba7542f3c"]}, "86e1e384495ee09a": {"kind":"object","properties":[{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"text","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "871bfd6f7bb91061": {"kind":"literal","value":"provisioning"}, @@ -1053,6 +1058,7 @@ "8f6d609794bf5afc": {"kind":"tuple","elements":[{"type":"4e88776858652856","optional":false,"rest":false}]}, "8fcb0b44a575e998": {"kind":"object","properties":[{"name":"kind","type":"a720757f704a2512","optional":false}],"indices":[]}, "904bf499d98be4a3": {"kind":"union","types":["7382c4fc95fe8c5d","b765ee31033cea46","f6cb462f808309e3"]}, + "920d87e22b7409bc": {"kind":"object","properties":[{"name":"data","type":"c2a13242efc3bea4","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"d8a476d2c6fd30df","optional":false}],"indices":[]}, "930c1cd289def119": {"kind":"union","types":["2142e391b9ccb60d","2c4c21f19f426a58","811229eedcec5797","fab35c4520883c0d"]}, "935c8d486292b13c": {"kind":"literal","value":"web_search"}, "938f7c7357bc403e": {"kind":"primitive","type":"null"}, @@ -1062,6 +1068,7 @@ "94c56aeb1564ad73": {"kind":"literal","value":"in_progress"}, "95080295115fbd83": {"kind":"object","properties":[{"name":"index","type":"0657d3ca5ac4e7bf","optional":false},{"name":"text","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"5abab3a27fd27366","optional":false}],"indices":[]}, "95a3ffccbf3c8afc": {"kind":"literal","value":"owner"}, + "95b5cf90ea1f57da": {"kind":"array","element":"e272493e3ea0b33b"}, "95c0e88fcf0bafb2": {"kind":"literal","value":"assistant"}, "9645cf93769a6dfb": {"kind":"literal","value":"user-question-reply"}, "96bfb916a727cd44": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, @@ -1136,6 +1143,7 @@ "b0127044ab31a702": {"kind":"object","properties":[{"name":"data","type":"ca59530d1a4f3e19","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1986c2664009c991","optional":false}],"indices":[]}, "b062078e899958f9": {"kind":"object","properties":[{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, "b0b0eb1b16490f6b": {"kind":"object","properties":[{"name":"attachmentId","type":"4cd48f3e5108bd6e","optional":false},{"name":"bytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"height","type":"0657d3ca5ac4e7bf","optional":false},{"name":"mediaType","type":"2f1e6a7ebace43d7","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":true},{"name":"originalDimensions","type":"0f46153645c29784","optional":true},{"name":"width","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "b120362fd216a16a": {"kind":"union","types":["0203243b72fc867a","022623b9ef98ec47","02b2c23f2d94b028","0ed8d1108fe7a5e1","0eeb17e1df89b1eb","11638dc58a31496f","1938c8c20f1cba7c","1e709346005826eb","1fdad624334d3548","2152caf1a71e588e","2235d957bf4b7c6c","224aa11d4d5f88c4","24ce31aa528272e5","260523b412c19ad8","28936c614bd4e7e2","290f32bfd81ff85a","297fbd8067d7815a","2aad1179f9576314","312aee2f0c42aabb","31907de956375182","3247e566f4340029","3b04790e08b35241","3b4f2c9ef2c1217e","3bdb8dc6989655b0","408927267422a7b2","45f005e6bbb76452","4991dfc415c9efeb","49dab019d00e8c26","4a9bef9f27a94252","52c6113cb0e53f34","53cf1704ae0709c7","5654685d7557ec74","58015da1c1950a66","5bb69b88517f6214","5bc66269cfaf614c","5cdbf342b7748d15","5d7a64ed6aac5551","6085488d223aeffd","6292bcbedaa97fea","65a8400e451e667e","65d583e66518c523","672c4aa2a9b736e3","6ccb6bc423fb3952","6ea608bdc8a7a63d","6f22a9d02b9a1f11","6f44f32be9bcd9a8","719c51f67074f970","7554565be416b034","756b5e23a5cb2c7f","7df62978a2b7de85","7eaef7a763ba5b94","7f959241c24128b4","80677c8c8b804189","825ca23416b1ea9d","8338ee303e2bd448","848639f8787f035b","854db3efdfb0cd39","85fe96bf15d5c755","8620189c8e335827","87b1a33564b9a4d6","88841202942c7683","8a046b52331959aa","8c1c4f72755a78ea","8c627d7a45fa406c","8f324cea3916c051","8fcb0b44a575e998","96bfb916a727cd44","99135d8a39379779","99f0e98184709ad1","9edc162949abf93b","9f65c43e340bba31","a15a19ef63cf5d1b","a16e567c8cd2c55c","a2066bd52f61218b","a3c6f1ea38e9a61d","a4afec59b75aad5d","aa2bf9e82b9fdeb9","aae343bc3d663c83","ada310bf0bdb8fed","ade057f4fdc387ae","b062078e899958f9","b2a5c8c008a31e3d","b3681c026a0b98e9","b4ec0af39fd52ed9","b613f83572fe2fbf","b6f94c17dd9bbd7c","b70f3e81fe11b7a5","b79641a9d25e7566","ba2a1a8f194b1bb2","ba4c40b4381e0dcd","baccc97ec34be6c4","c5228b7a55498d84","c55827fba7018198","cb1c8959e1726f82","cda2345a9be9d139","d85590a5675c6e2f","d9c462b9f62fdd3e","db06ef35b27a3842","dba54c09c6e7aafc","dd00cdccd0ba487b","dd5ee8762bd8a14b","dd6cc2bb2a8f2824","dde0cdf9d426768b","deaae5cd30987d4f","e7f4cbb3a57f4cbd","edbbfff1128099fd","ee577a0cc18664c9","f230c0b583e23b25","f29d63336e8266d8","f4a66f654996c093","f56c5b59ca016131","f6329fb30c7ea801","f6605d6c57933aea","f6afe41e3926f422","f7283223aacdc39b","f97f47b0eb87905e","fcf81d3c58c13593","fe1a7acdeed4d0f5"]}, "b158f3ff69237148": {"kind":"object","properties":[{"name":"contextWindow","type":"0657d3ca5ac4e7bf","optional":true},{"name":"model","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"systemPromptUpdate","type":"f311671e07bc7fa6","optional":true}],"indices":[]}, "b1664fa89f8eab49": {"kind":"array","element":"bbc5696e3f13c758"}, "b1ebd5cda47d90d9": {"kind":"object","properties":[{"name":"member","type":"6598cd6da253948d","optional":false},{"name":"teamId","type":"4cd48f3e5108bd6e","optional":false},{"name":"version","type":"a0f906856fc06f1c","optional":false}],"indices":[]}, @@ -1186,6 +1194,7 @@ "c1f0f17b6493b85d": {"kind":"object","properties":[{"name":"arguments","type":"4cd48f3e5108bd6e","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"1f0133217eeeffd8","optional":false}],"indices":[]}, "c1f9e0405de6d18c": {"kind":"object","properties":[{"name":"data","type":"a6da9ad5d42ae83a","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"cdca80b8dad24d4e","optional":false}],"indices":[]}, "c219efbdfd53b836": {"kind":"literal","value":"step/end"}, + "c2a13242efc3bea4": {"kind":"object","properties":[{"name":"enabled","type":"aa93010d0e521ad7","optional":false}],"indices":[]}, "c2deb7c9183735d4": {"kind":"union","types":["1ebc6f9ff3aa9e3a","2142e391b9ccb60d","b57a2889408de36e"]}, "c2f49004d9ae38a9": {"kind":"union","types":["1ebc6f9ff3aa9e3a","b57a2889408de36e","ef226a90ddac6cd8"]}, "c43b119ce579b3e9": {"kind":"literal","value":"paused"}, @@ -1195,7 +1204,6 @@ "c589e938cc0e4989": {"kind":"literal","value":"tool/call"}, "c592ce75aab73fca": {"kind":"union","types":["0657d3ca5ac4e7bf","4cd48f3e5108bd6e","529e0ccd0e34079a","72ec79127a9c0d02","938f7c7357bc403e","aa93010d0e521ad7"]}, "c748fb42c80a1200": {"kind":"array","element":"86e1e384495ee09a"}, - "c78b9097bfae83cd": {"kind":"union","types":["09f3f8dc417f2782","4035903993dfee62","4b259f7f8f0a44ad","64ef542b1bfc9883","fa87537f4a9c1e25"]}, "c8e6f48878cc2a2f": {"kind":"literal","value":"active"}, "c99e20441bf15341": {"kind":"literal","value":"feedback/record"}, "ca085dba2c877fec": {"kind":"literal","value":"sandbox/mode"}, @@ -1207,21 +1215,21 @@ "cda2345a9be9d139": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "cdca80b8dad24d4e": {"kind":"literal","value":"tool-workflow/run-start"}, "cdee10e87dbed844": {"kind":"object","properties":[{"name":"delayMs","type":"0657d3ca5ac4e7bf","optional":false},{"name":"failure","type":"5f2117d1856a5ab4","optional":false},{"name":"maxRetries","type":"0657d3ca5ac4e7bf","optional":false},{"name":"mode","type":"3bc287e765d58dc3","optional":false},{"name":"policyKey","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"retry","type":"0657d3ca5ac4e7bf","optional":false},{"name":"retryId","type":"4cd48f3e5108bd6e","optional":false},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, - "cdf988172b589b23": {"kind":"object","properties":[{"name":"data","type":"d4d960185026266e","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"829a2fb3be6efd4b","optional":false}],"indices":[]}, "cef171adf021a186": {"kind":"object","properties":[{"name":"arguments","type":"bbc5696e3f13c758","optional":false},{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"error","type":"a81059ca06999da1","optional":true},{"name":"isError","type":"aa93010d0e521ad7","optional":false},{"name":"meta","type":"c592ce75aab73fca","optional":true},{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"parentCallId","type":"4cd48f3e5108bd6e","optional":false},{"name":"rootCallId","type":"4cd48f3e5108bd6e","optional":false},{"name":"subCallId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "cffcdc19881ab61e": {"kind":"object","properties":[{"name":"cleared","type":"0b6d718c693fdf7f","optional":false},{"name":"clearedAt","type":"0657d3ca5ac4e7bf","optional":false},{"name":"kind","type":"237c004c73b6b288","optional":false},{"name":"operation","type":"e7e154d47d5fbc53","optional":false},{"name":"version","type":"f88f1b7b6bb733f6","optional":false}],"indices":[]}, "d0fbb0ca1bbba059": {"kind":"literal","value":"delete"}, "d28765f87d6b6ab4": {"kind":"literal","value":"one-shot"}, "d4b739491689cc6b": {"kind":"object","properties":[{"name":"block","type":"8064c03c87c357fd","optional":false},{"name":"index","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"eefbea819caeeea9","optional":false}],"indices":[]}, "d4c799e3d51cae10": {"kind":"literal","value":"working-directory/change"}, - "d4d960185026266e": {"kind":"object","properties":[{"name":"inserted","type":"7c203f3d4dfa6638","optional":false},{"name":"outcome","type":"d4e4d575abeb4b72","optional":true},{"name":"removedCount","type":"0657d3ca5ac4e7bf","optional":true},{"name":"start","type":"0657d3ca5ac4e7bf","optional":false},{"name":"target","type":"96f4a9c81fc0f940","optional":false}],"indices":[]}, "d4e4d575abeb4b72": {"kind":"literal","value":"canceled"}, "d595ec73b32b016a": {"kind":"object","properties":[{"name":"data","type":"a40d12070f6f4a12","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"b57ae1b37e52bee0","optional":false}],"indices":[]}, "d63b8b8ffad9c02f": {"kind":"object","properties":[{"name":"data","type":"8bb8c3751a6ce64b","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"47e5d84f1a93450a","optional":false}],"indices":[]}, "d66e11dddf5dfa3c": {"kind":"object","properties":[{"name":"data","type":"cef171adf021a186","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"fb5e0517050d2c6d","optional":false}],"indices":[]}, "d6e654b88b584fcd": {"kind":"literal","value":"fallback"}, "d85590a5675c6e2f": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, + "d8a476d2c6fd30df": {"kind":"literal","value":"team/mode"}, "d9196086e41e4dd5": {"kind":"literal","value":"complete"}, + "d95c624eddecdacf": {"kind":"object","properties":[{"name":"data","type":"601327bedca5f98a","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"587bb11da730e06a","optional":false}],"indices":[]}, "d9c462b9f62fdd3e": {"kind":"object","properties":[{"name":"callId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"a13d8f379222ece8","optional":false}],"indices":[]}, "d9edf1862e4471d6": {"kind":"literal","value":"schedule"}, "da60ed58a8fa23d7": {"kind":"object","properties":[{"name":"cwd","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, @@ -1231,10 +1239,12 @@ "db874d463b0fdec7": {"kind":"object","properties":[{"name":"data","type":"3e4e7683b0192c9f","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"e2d0367e5716bc18","optional":false}],"indices":[]}, "dba54c09c6e7aafc": {"kind":"object","properties":[{"name":"kind","type":"4edec1b770e8ec4c","optional":false}],"indices":[]}, "dbdafceb9a900183": {"kind":"literal","value":"hook"}, + "dcb07e04496586d2": {"kind":"object","properties":[{"name":"data","type":"613660455704fb82","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"de25352d2dc41963","optional":false}],"indices":[]}, "dd00cdccd0ba487b": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "dd2780e7e0768433": {"kind":"literal","value":"fresh"}, "dd5ee8762bd8a14b": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, "dd6cc2bb2a8f2824": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, + "ddce54a3873dc571": {"kind":"object","properties":[{"name":"data","type":"e1880c67e735e228","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"829a2fb3be6efd4b","optional":false}],"indices":[]}, "dde0cdf9d426768b": {"kind":"object","properties":[{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "de25352d2dc41963": {"kind":"literal","value":"user/message"}, "de30b1fa12c75f81": {"kind":"literal","value":"image/gif"}, @@ -1244,6 +1254,8 @@ "df843efd87c78cf8": {"kind":"array","element":"4f91f187baeabef5"}, "dfce5db4aa42b7eb": {"kind":"union","types":["871bfd6f7bb91061","b57a2889408de36e","c8e6f48878cc2a2f"]}, "e0a787e6ec76c7c9": {"kind":"object","properties":[{"name":"data","type":"ef11a3b7fa2033ae","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"c219efbdfd53b836","optional":false}],"indices":[]}, + "e1880c67e735e228": {"kind":"object","properties":[{"name":"inserted","type":"690583582246babf","optional":false},{"name":"outcome","type":"d4e4d575abeb4b72","optional":true},{"name":"removedCount","type":"0657d3ca5ac4e7bf","optional":true},{"name":"start","type":"0657d3ca5ac4e7bf","optional":false},{"name":"target","type":"96f4a9c81fc0f940","optional":false}],"indices":[]}, + "e272493e3ea0b33b": {"kind":"union","types":["01c161379a3c3394","09f3f8dc417f2782","4b259f7f8f0a44ad","613660455704fb82","fa87537f4a9c1e25"]}, "e2ad8f5bcd813f26": {"kind":"object","properties":[{"name":"childCreatedAt","type":"0657d3ca5ac4e7bf","optional":false},{"name":"childId","type":"4cd48f3e5108bd6e","optional":false},{"name":"label","type":"4cd48f3e5108bd6e","optional":false},{"name":"mode","type":"e5d3828df1ec3e2a","optional":false},{"name":"version","type":"f88f1b7b6bb733f6","optional":false}],"indices":[]}, "e2d0367e5716bc18": {"kind":"literal","value":"compaction/start"}, "e2f9a41e0989f54e": {"kind":"object","properties":[{"name":"data","type":"9947aa5938fe19a8","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"ef4f00923b9edbae","optional":false}],"indices":[]}, @@ -1320,7 +1332,6 @@ "fe1a7acdeed4d0f5": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "fe792a6cd4304945": {"kind":"literal","value":"tool-workflow/run-end"}, "fec5442b9ee4122e": {"kind":"object","properties":[{"name":"cacheReadTokens","type":"0657d3ca5ac4e7bf","optional":true},{"name":"cacheWriteTokens","type":"0657d3ca5ac4e7bf","optional":true},{"name":"inputTokens","type":"0657d3ca5ac4e7bf","optional":false},{"name":"outputTokens","type":"0657d3ca5ac4e7bf","optional":false},{"name":"reasoningTokens","type":"0657d3ca5ac4e7bf","optional":true},{"name":"totalTokens","type":"0657d3ca5ac4e7bf","optional":true}],"indices":[]}, - "fee1caf8ab6a65c1": {"kind":"literal","value":"instructions"}, - "ff2cbb53582d1827": {"kind":"array","element":"c78b9097bfae83cd"} + "fee1caf8ab6a65c1": {"kind":"literal","value":"instructions"} } } diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index 069f78cc33..9bd04642b1 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/README.i18n.yaml @@ -3,5 +3,5 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/README.md /subsystems: - en: 81b2c48d922de5a9 - zh: cf0db50b34d2feb3 + en: e95b837b8f47eac8 + zh: bd69de97c80fe2ae diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index 86e30a5519..7d66caa4dd 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -51,7 +51,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [subagent.md](subagent.md) | the subagent seam: the named-provider registry, `SubagentStartRequest`/`Result`/`Run`, the start-time-vs-runtime capability split | | [voice-input.md](voice-input.md) | experimental named recognizers, transient audio and revision-guarded draft insertion | | [translation.md](translation.md) | experimental anonymous text translation and explicit provider requests | -| [agent-team.md](agent-team.md) | Agent Teams: implicit Lead identity, named continuable teammates, direct peer inbox delivery, and shared task DAG | +| [agent-team.md](agent-team.md) | Agent Teams: shared task DAG and task ownership for a subagent tree | | [liveshare.md](liveshare.md) | the opt-in LiveShare bundle: nested share modes, a fork's ceiling and durability, enforcement installation, and the constraint a shared Session's externally driven turns run under | | [claude-code-mods.md](claude-code-mods.md) | Claude Code mods through the experimental bridge: every event, `$` member, chain rule, drawing feature, and example mod that differs from Claude Code | | [web.md](web.md) | the web access seam: `WebSearchRequest`/`Result`, `WebFetchRequest`/`Result`, `WebFetchBody`, provider availability, `WebError` | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index 289a6a71f3..7358fe0203 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -51,7 +51,7 @@ | [subagent.md](subagent.zh.md) | subagent seam:命名提供方注册表、`SubagentStartRequest`/`Result`/`Run`、启动时与运行时能力拆分 | | [voice-input.md](voice-input.zh.md) | 实验性具名语音识别、临时音频和受版本保护的草稿插入 | | [translation.md](translation.zh.md) | 实验性免登录文本翻译与显式 Provider 请求 | -| [agent-team.md](agent-team.zh.md) | Agent Teams:隐式 Lead 身份、具名 continuable teammate、直接 peer inbox 投递 与共享任务 DAG | +| [agent-team.md](agent-team.zh.md) | Agent Teams:子代理树共享的任务 DAG 与任务归属 | | [liveshare.md](liveshare.zh.md) | 可选的 LiveShare bundle:嵌套分享模式、fork 的上限与持久性、上限安装,以及共享会话外部驱动轮次所受的约束 | | [claude-code-mods.md](claude-code-mods.zh.md) | 经实验性桥接运行的 Claude Code 模组:与 Claude Code 不同的每个事件、`$` 成员、链规则、绘制特性与示例模组 | | [web.md](web.zh.md) | Web 访问 seam:`WebSearchRequest`/`Result`、`WebFetchRequest`/`Result`、`WebFetchBody`、提供方可用性、`WebError` | diff --git a/docs/subsystems/agent-team.i18n.yaml b/docs/subsystems/agent-team.i18n.yaml index a4c599562f..435e1b6bbb 100644 --- a/docs/subsystems/agent-team.i18n.yaml +++ b/docs/subsystems/agent-team.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/agent-team.md /agent-teams: - en: f571e223220bb616 - zh: dd7e37fdf359eeae -/agent-teams/identity-and-roster: - en: df72d9409b89714d - zh: 982934dd18864ca2 + en: 152195aeb3b5dea1 + zh: 1cf6b3b36bd6f02c +/agent-teams/identity-and-historical-roster: + en: 3a574287e378ecf9 + zh: 818667289271c1af /agent-teams/messages-and-historical-mailbox: - en: 2aed7dd369f47747 - zh: 4f080e50c584ad37 + en: e50b723dfffd6758 + zh: 6e4d5891b5d913fc /agent-teams/shared-task-dag: - en: 4515005d07e4d2a3 - zh: 26811b31f69009d6 + en: c70f0fd199fc7db5 + zh: c6b77cb5a5cfd1c2 /agent-teams/web-projection: - en: 6af99eff8036597a - zh: f172f994e01f12c1 + en: 059d4e5d6a9ddc71 + zh: cb3fd92096144e19 /agent-teams/replay: - en: fe32f71c6d799ebc - zh: aa2b6ccddba8cfd5 + en: 448d32856f87b067 + zh: 7fafd96314c7e2c5 diff --git a/docs/subsystems/agent-team.md b/docs/subsystems/agent-team.md index c5c6a0f775..49b27ea176 100644 --- a/docs/subsystems/agent-team.md +++ b/docs/subsystems/agent-team.md @@ -2,14 +2,14 @@ English | [中文](agent-team.zh.md) -Types shared by the experimental implicit-root Team domain, model tools, and host adapters. The [Agent Teams Agent Note](../../.agents/notes/implemented/feature/2026-08-05-agent-teams.md) owns identity, mailbox, task, and shared-checkout decisions; this page records the durable and client-visible forms from [`packages/experimental/agent-team/src/types.ts`](../../packages/experimental/agent-team/src/types.ts). +Types shared by the shared task board, model tools, and host adapters. The [task-board wait decision](../../.agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.md) owns coordination semantics; this page records durable and client-visible forms from [`packages/team/agent-team/src/types.ts`](../../packages/team/agent-team/src/types.ts). -## Identity and roster +## Identity and historical roster -`TeamId` is the root `SessionId` under a distinct [brand](core.md#branded-ids). `TeamTaskId` is allocated as `task-`. New sends return the target inbox `MessageId`; `TeamMessageId` is retained only for historical mailbox records. Teammate names are immutable labels. +`TeamId` brands the root `SessionId`; `TeamTaskId` uses `task-`. `TeamMessageId` and roster records remain for historical decoding. The subagent runtime resolves the tree and discovers eligible task owners at arbitrary depth; historical roster entries grant no authority. ```ts type-equiv -/** Whole durable value written on every teammate lifecycle change. */ +/** Historical roster value written by earlier Team builds on every teammate lifecycle change. */ interface TeamMemberSnapshot { readonly id: SessionId readonly name: string @@ -21,11 +21,11 @@ interface TeamMemberSnapshot { } ``` -Every member starts in `provisioning` and reaches exactly one terminal roster phase, `active` or `failed`. Roster `running`/`inactive` status is derived separately and never rewrites this record. +Historical records retain their recorded `provisioning`, `active`, or `failed` phase. Current turn activity belongs to ordinary subagent discovery and Session status, independently of task completion. ## Messages and historical mailbox -New sends return inbox acceptance or reject. The target stores the existing `agent-message` source with the real sender Session id and a sender-name content prefix. Team has no new-message outbox or resend deduplication. The [package contract](../../packages/experimental/agent-team/README.md#understand-the-implementation) defines persistence. The following mailbox types remain readable so historical Lead logs still replay; Team does not deliver their pending records. +Members exchange ordinary [subagent messages](subagent.md), retaining the real sender Session id. Task changes do not end inbox waits, add inbox messages, or start idle agents. Send an explicit message when an agent needs to act on a task update. The following historical mailbox types remain readable; Team never delivers their pending records. ```ts type-equiv /** One peer message recorded by the historical Team mailbox. */ @@ -38,8 +38,6 @@ interface TeamMessageSnapshot { } ``` -Every message attempts Steer delivery. A running target receives it at the nearest step boundary; an inactive target starts a turn if loaded or cold-resumes otherwise. Scheduling is not stored in the durable record because callers cannot select another mode. - Historical `team-message` sources retain the old message id and sender attribution; the client uses them for the message title and icon. ```ts type-equiv @@ -71,25 +69,15 @@ interface TeamTaskSnapshot { } ``` -`pending` is unstarted or released, `in_progress` carries an owner, `completed` satisfies blockers, and `deleted` is a retained tombstone. Views add owner name, readiness, and write-scope overlap warnings without changing the durable snapshot. +`pending` is unstarted or released, `in_progress` carries an owner, `completed` satisfies blockers, and `deleted` is a retained tombstone. Views expose `ownerId`, readiness, and advisory write-scope overlap without changing the durable snapshot. ## Web projection -The Lead Session publishes `SessionProjectionMap.agentTeam` with durable roster rows and non-deleted task views. `failure` reports a rejected persisted record beside the last valid state. Member activity comes from Session status; model labels come from each member's `modelSelection` projection. +`agentTeam.enabled` exposes the root Session’s saved selection. New Sessions default to false; `team/mode` records explicit selections. Historical tasks imply enabled until an explicit selection. Only the root can call `setEnabled`; descendants share that mode. The Web menu calls the same method through `remote.agentTeams.setEnabled`, with the root Session id resolved to its live Agent by the Gateway. Mode writes take effect before awaiting persistence. Tools resolve availability at step preparation; prompt providers read the selection when evaluated. Task mutations check the current mode when their queued operation starts; admitted mutations may finish. Mode changes do not end inbox waits. A failed flush leaves the appended selection active; retrying the same selection flushes it without a duplicate event. -```ts type-equiv -/** One durable roster row published through the `agentTeam` Session projection. */ -interface TeamMemberProjection { - readonly id: SessionId - readonly name: string - readonly role: 'lead' | 'teammate' - /** Durable lifecycle; the Lead row is always `active`. Turn activity comes from Session status. */ - readonly phase: TeamMemberPhase - readonly error?: string -} -``` +The root Session publishes non-deleted task views through `SessionProjectionMap.agentTeam`. `failure` reports a rejected persisted task or mode record beside the last valid state. Historical roster and mailbox records are not projected. The task panel identifies every owner by its Session id. ```ts type-equiv /** Runtime-enriched task view returned to tools and hosts. */ @@ -101,7 +89,7 @@ interface TeamTaskView { readonly status: TeamTaskStatus readonly blockedBy: TeamTaskId[] readonly writeScopes: string[] - readonly ownerName?: string + readonly ownerId?: SessionId readonly ready: boolean readonly writeScopeWarnings: string[] } @@ -111,10 +99,10 @@ interface TeamTaskView { /** * Durable Team state published to browser clients through the Lead Session's * `agentTeam` projection. `failure` names the first rejected persisted Team - * record; members and tasks then stay at the last valid state. + * record; tasks then stay at the last valid state. */ interface TeamProjection { - readonly members: TeamMemberProjection[] + readonly enabled: boolean readonly tasks: TeamTaskView[] readonly failure?: string } @@ -122,7 +110,7 @@ interface TeamProjection { ## Replay -The `agentTeam` Session projection replays one root Session into the roster, task board, and historical queued-minus-delivered mailbox. It selects records by `TeamId`, so events inherited by an ordinary fork retain the ancestor id and never enter the new root's state. Session event `seq` and `time` remain the ordering and timing record; Team snapshots do not duplicate them. Roster and task reads reach callers as views; pending mail is not exposed to callers. The package [README](../../packages/experimental/agent-team/README.md) owns operation, authorization, recovery, and limit behavior. +The `agentTeam` projection replays task records selected by `TeamId` and the Session’s own mode selections. Subagent discovery owns current tree membership and ignores inherited catalog authority. Session event `seq` and `time` own ordering and timing. Committed durable generations are preserved. The [package README](../../packages/team/agent-team/README.md) defines task operations and limits. @@ -140,42 +128,35 @@ Agent Teams service backed by the exact live Lead Session log. ```ts cordis-catalog /** - * Resolve one exact live Agent's Team role. - * @param agent - exact live Agent used as the authority credential. - * @returns its root, Team identity, role, and model-facing name. + * Read the Team selection shared by the caller's subagent tree. + * @param caller - exact live root or descendant. + * @returns whether Team tools and guidance are enabled. */ -membership(agent: Agent): TeamMembership +isEnabled(caller: Agent): boolean /** - * List the runtime-enriched roster visible to one Team member. - * @param agent - exact live Team member. - * @returns Lead and teammate rows in creation order. + * Save a root Session's Team selection without stopping children or deleting tasks. + * The selection takes effect before awaiting persistence. Tools resolve availability at + * step preparation; prompt providers read the selection when evaluated. Task mutations + * check the current mode when their queued operation starts; admitted operations finish. + * @param agent - exact live root whose tree receives the selection. + * @param enabled - selected Team mode. + * @param signal - cancellation checked before changing the selection. + * @returns fulfillment after the selection flushes. + * @throws when called by a descendant or persistence fails. An appended selection remains active; + * retrying the same selection flushes without a duplicate event. */ -listMembers(agent: Agent): TeamMemberView[] - -/** - * Create one named, continuable direct child of the Team Lead. - * @param caller - exact live Lead Agent. - * @param request - immutable name, description, prompt, context mode, provider, and cancellation. - * @returns the active roster row. - */ -async spawnTeammate(caller: Agent, request: SpawnTeammateRequest): Promise - -/** - * Steer one peer message into the target inbox or reject the attempt. - * @param caller - exact live sending Team member. - * @param request - target name, content, and cancellation before acceptance. - * @returns accepted inbox identity; acceptance follows normal Agent persistence and does not await model processing. - */ -async sendMessage(caller: Agent, request: SendTeamMessageRequest): Promise +@Remote async setEnabled(agent: Agent, enabled: boolean, signal?: AbortSignal): Promise /** * Create one unowned pending task in the Team Lead log. * @param caller - exact live Team member creating the task. * @param request - task text, blockers, and advisory write scopes. + * @param signal - cancellation before the task event is appended; omitted means no cancellation. * @returns the revision-one task view. + * @throws TEAM_TASK_PERSISTENCE_UNCONFIRMED when the task is visible but durability is unknown; read it before retrying. */ -async createTask(caller: Agent, request: CreateTeamTaskRequest): Promise +async createTask(caller: Agent, request: CreateTeamTaskRequest, signal?: AbortSignal): Promise /** * Return one task, including a deleted tombstone. @@ -196,36 +177,14 @@ listTasks(caller: Agent): TeamTaskView[] * Compare-and-set one authorized task transition. * @param caller - exact live Team member authorizing the mutation. * @param request - task identity, expected revision, action, and action fields. + * @param signal - cancellation before the task event is appended; omitted means no cancellation. * @returns the committed next task revision. + * @throws TEAM_TASK_PERSISTENCE_UNCONFIRMED when the task is visible but durability is unknown; read it before retrying. */ -async updateTask(caller: Agent, request: UpdateTeamTaskRequest): Promise - -/** - * Wait for the next Team-domain or member-status change. - * @param caller - exact live Team member waiting for activity. - * @param timeoutMs - bounded wait duration from ten seconds through one hour. - * @param signal - caller cancellation for the wait only. - * @returns one observed change or a timeout result. - */ -async waitForChange(caller: Agent, timeoutMs: number, signal: AbortSignal): Promise - -/** - * Interrupt one live teammate turn without clearing its pending inbox. - * @param caller - exact live Lead Agent. - * @param targetName - durable teammate name. - * @returns the target status sampled before cancellation. - */ -interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'inactive' } - -/** - * Resolve a caller without throwing, used by scoped-tool installation and observers. - * @param agent - candidate exact live Agent. - * @returns Team membership, or undefined for non-Team subagents and stale identities. - */ -tryMembership(agent: Agent): TeamMembership | undefined +async updateTask(caller: Agent, request: UpdateTeamTaskRequest, signal?: AbortSignal): Promise ``` Types: [Agent](core.md) -Source: [`packages/experimental/agent-team/src/index.ts`](../../packages/experimental/agent-team/src/index.ts) +Source: [`packages/team/agent-team/src/index.ts`](../../packages/team/agent-team/src/index.ts) diff --git a/docs/subsystems/agent-team.zh.md b/docs/subsystems/agent-team.zh.md index 2e3c0b040e..fb52d49be4 100644 --- a/docs/subsystems/agent-team.zh.md +++ b/docs/subsystems/agent-team.zh.md @@ -2,14 +2,14 @@ [English](agent-team.md) | 中文 -实验性隐式 Root Team 领域、模型工具与宿主适配器共享的类型。[Agent Teams Agent Note](../../.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md)负责身份、mailbox、task 与共享 checkout 决策;本页记录 [`packages/experimental/agent-team/src/types.ts`](../../packages/experimental/agent-team/src/types.ts) 中的持久与客户端可见形式。 +共享任务板、模型工具和 host 适配器共享的类型。[任务板等待决策](../../.agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.zh.md)负责协调语义;本页记录 [`packages/team/agent-team/src/types.ts`](../../packages/team/agent-team/src/types.ts) 中的持久化和客户端可见形式。 -## 身份与 roster +## 身份与历史名册 -`TeamId` 是使用独立[品牌类型](core.zh.md#branded-ids)的 root `SessionId`。`TeamTaskId` 按 `task-` 分配。新发送返回目标 inbox 的 `MessageId`;`TeamMessageId` 仅供历史 mailbox 记录保留。teammate 名称是不可变标签。 +`TeamId` 品牌化根 `SessionId`;`TeamTaskId` 使用 `task-`。`TeamMessageId` 和名册记录为历史解码保留。subagent 运行时解析树,并发现任意深度的有效任务所有者;历史名册条目不授予权限。 ```ts type-equiv -/** Whole durable value written on every teammate lifecycle change. */ +/** Historical roster value written by earlier Team builds on every teammate lifecycle change. */ interface TeamMemberSnapshot { readonly id: SessionId readonly name: string @@ -21,11 +21,11 @@ interface TeamMemberSnapshot { } ``` -每个 member 都从 `provisioning` 开始,并且只到达一个终态 roster phase:`active` 或 `failed`。roster 的 `running`/`inactive` 状态单独派生,绝不会重写该记录。 +历史记录保留其记录的 `provisioning`、`active` 或 `failed` 阶段。当前轮次活动属于普通 subagent 发现和 Session 状态,与任务完成相互独立。 ## 消息与历史 mailbox -新发送返回 inbox 接收结果或拒绝。目标保存现有 `agent-message` source,包含真实发送者 Session id 和发送者名称正文前缀。Team 不为新消息提供 outbox 或重发去重。[包约定](../../packages/experimental/agent-team/README.zh.md#understand-the-implementation)定义持久化。以下 mailbox 类型继续可读,以便历史 Lead 日志仍能回放;Team 不投递其中的 pending 记录。 +成员交换普通 [subagent 消息](subagent.zh.md),保留真实发送方 Session id。任务变更不会结束收件箱等待、添加收件箱消息或启动空闲 agent。需要 agent 处理任务更新时,发送明确消息。下列历史邮箱类型保持可读;Team 绝不投递其待处理记录。 ```ts type-equiv /** One peer message recorded by the historical Team mailbox. */ @@ -38,8 +38,6 @@ interface TeamMessageSnapshot { } ``` -每条消息都会尝试 Steer 投递。running target 在最近的步骤边界收到消息,inactive target 在已加载时启动一个轮次,否则冷恢复。调用方不能选择其他模式,因此持久记录不存储调度方式。 - 历史 `team-message` source 保留旧消息 id 与发送者归属;客户端用它们展示消息标题和图标。 ```ts type-equiv @@ -71,25 +69,15 @@ interface TeamTaskSnapshot { } ``` -`pending` 表示尚未开始或已经释放,`in_progress` 携带 owner,`completed` 满足 blocker,`deleted` 是保留的 tombstone。view 会添加 owner name、readiness 和 write-scope 重叠警告,但不会改变持久快照。 +`pending` 表示未开始或已释放,`in_progress` 携带负责人,`completed` 满足依赖,`deleted` 是保留的墓碑记录。视图提供 `ownerId`、就绪性和写入范围重叠提示,不改变持久快照。 ## Web 投影 -Lead Session 通过 `SessionProjectionMap.agentTeam` 发布持久 roster 行与未删除任务视图。`failure` 在最后有效状态旁报告被拒绝的持久记录。成员活动来自 Session 状态;模型标签来自各成员的 `modelSelection` 投影。 +`agentTeam.enabled` 提供根 Session 保存的选择。新 Session 默认为 false;`team/mode` 记录显式选择。历史任务在显式选择前视为开启。仅根可调用 `setEnabled`;后代共享该模式。Web 菜单通过 `remote.agentTeams.setEnabled` 调用同一方法,Gateway 将根 Session id 解析为其存活 Agent。模式写入先立即生效,再等待刷盘。工具在步骤准备时确定可用性;提示词提供方在求值时读取选择。任务修改在排队操作开始时检查当前模式;已获准的修改可继续完成。模式变更不会结束收件箱等待。刷盘失败时已追加的选择仍然生效;重试相同选择会再次刷盘,不重复追加事件。 -```ts type-equiv -/** One durable roster row published through the `agentTeam` Session projection. */ -interface TeamMemberProjection { - readonly id: SessionId - readonly name: string - readonly role: 'lead' | 'teammate' - /** Durable lifecycle; the Lead row is always `active`. Turn activity comes from Session status. */ - readonly phase: TeamMemberPhase - readonly error?: string -} -``` +根 Session 通过 `SessionProjectionMap.agentTeam` 发布未删除任务视图。`failure` 在最后有效状态旁报告被拒绝的持久化任务或模式记录。历史名册与邮箱记录不参与投影。任务面板以 Session id 标识每个负责人。 ```ts type-equiv /** Runtime-enriched task view returned to tools and hosts. */ @@ -101,7 +89,7 @@ interface TeamTaskView { readonly status: TeamTaskStatus readonly blockedBy: TeamTaskId[] readonly writeScopes: string[] - readonly ownerName?: string + readonly ownerId?: SessionId readonly ready: boolean readonly writeScopeWarnings: string[] } @@ -111,10 +99,10 @@ interface TeamTaskView { /** * Durable Team state published to browser clients through the Lead Session's * `agentTeam` projection. `failure` names the first rejected persisted Team - * record; members and tasks then stay at the last valid state. + * record; tasks then stay at the last valid state. */ interface TeamProjection { - readonly members: TeamMemberProjection[] + readonly enabled: boolean readonly tasks: TeamTaskView[] readonly failure?: string } @@ -122,7 +110,7 @@ interface TeamProjection { ## 回放 -`agentTeam` Session 投影把一个 Root Session 回放成roster、任务板与历史 queued-minus-delivered mailbox。它按 `TeamId` 选取记录,因此普通 fork 继承的 event 保留 ancestor id,绝不会进入新 Root 的状态。Session event 的 `seq` 与 `time` 继续负责顺序和时间记录,Team snapshot 不再重复保存它们。roster 与 task 读取以 view 形式到达调用方,而 pending 邮件不对调用方公开。包 [README](../../packages/experimental/agent-team/README.zh.md)负责 operation、authorization、recovery 和限制行为。 +`agentTeam` 投影按 `TeamId` 重放任务记录,并重放 Session 自身的模式选择。subagent 发现负责当前树成员关系,不将继承目录作为权限来源。Session 事件的 `seq` 和 `time` 负责排序和时间。已提交的持久化代次保持不变。[包 README](../../packages/team/agent-team/README.zh.md)定义任务操作和限制。 @@ -140,42 +128,35 @@ Agent Teams service backed by the exact live Lead Session log. ```ts cordis-catalog /** - * Resolve one exact live Agent's Team role. - * @param agent - exact live Agent used as the authority credential. - * @returns its root, Team identity, role, and model-facing name. + * Read the Team selection shared by the caller's subagent tree. + * @param caller - exact live root or descendant. + * @returns whether Team tools and guidance are enabled. */ -membership(agent: Agent): TeamMembership +isEnabled(caller: Agent): boolean /** - * List the runtime-enriched roster visible to one Team member. - * @param agent - exact live Team member. - * @returns Lead and teammate rows in creation order. + * Save a root Session's Team selection without stopping children or deleting tasks. + * The selection takes effect before awaiting persistence. Tools resolve availability at + * step preparation; prompt providers read the selection when evaluated. Task mutations + * check the current mode when their queued operation starts; admitted operations finish. + * @param agent - exact live root whose tree receives the selection. + * @param enabled - selected Team mode. + * @param signal - cancellation checked before changing the selection. + * @returns fulfillment after the selection flushes. + * @throws when called by a descendant or persistence fails. An appended selection remains active; + * retrying the same selection flushes without a duplicate event. */ -listMembers(agent: Agent): TeamMemberView[] - -/** - * Create one named, continuable direct child of the Team Lead. - * @param caller - exact live Lead Agent. - * @param request - immutable name, description, prompt, context mode, provider, and cancellation. - * @returns the active roster row. - */ -async spawnTeammate(caller: Agent, request: SpawnTeammateRequest): Promise - -/** - * Steer one peer message into the target inbox or reject the attempt. - * @param caller - exact live sending Team member. - * @param request - target name, content, and cancellation before acceptance. - * @returns accepted inbox identity; acceptance follows normal Agent persistence and does not await model processing. - */ -async sendMessage(caller: Agent, request: SendTeamMessageRequest): Promise +@Remote async setEnabled(agent: Agent, enabled: boolean, signal?: AbortSignal): Promise /** * Create one unowned pending task in the Team Lead log. * @param caller - exact live Team member creating the task. * @param request - task text, blockers, and advisory write scopes. + * @param signal - cancellation before the task event is appended; omitted means no cancellation. * @returns the revision-one task view. + * @throws TEAM_TASK_PERSISTENCE_UNCONFIRMED when the task is visible but durability is unknown; read it before retrying. */ -async createTask(caller: Agent, request: CreateTeamTaskRequest): Promise +async createTask(caller: Agent, request: CreateTeamTaskRequest, signal?: AbortSignal): Promise /** * Return one task, including a deleted tombstone. @@ -196,36 +177,14 @@ listTasks(caller: Agent): TeamTaskView[] * Compare-and-set one authorized task transition. * @param caller - exact live Team member authorizing the mutation. * @param request - task identity, expected revision, action, and action fields. + * @param signal - cancellation before the task event is appended; omitted means no cancellation. * @returns the committed next task revision. + * @throws TEAM_TASK_PERSISTENCE_UNCONFIRMED when the task is visible but durability is unknown; read it before retrying. */ -async updateTask(caller: Agent, request: UpdateTeamTaskRequest): Promise - -/** - * Wait for the next Team-domain or member-status change. - * @param caller - exact live Team member waiting for activity. - * @param timeoutMs - bounded wait duration from ten seconds through one hour. - * @param signal - caller cancellation for the wait only. - * @returns one observed change or a timeout result. - */ -async waitForChange(caller: Agent, timeoutMs: number, signal: AbortSignal): Promise - -/** - * Interrupt one live teammate turn without clearing its pending inbox. - * @param caller - exact live Lead Agent. - * @param targetName - durable teammate name. - * @returns the target status sampled before cancellation. - */ -interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'inactive' } - -/** - * Resolve a caller without throwing, used by scoped-tool installation and observers. - * @param agent - candidate exact live Agent. - * @returns Team membership, or undefined for non-Team subagents and stale identities. - */ -tryMembership(agent: Agent): TeamMembership | undefined +async updateTask(caller: Agent, request: UpdateTeamTaskRequest, signal?: AbortSignal): Promise ``` Types: [Agent](core.zh.md) -Source: [`packages/experimental/agent-team/src/index.ts`](../../packages/experimental/agent-team/src/index.ts) +Source: [`packages/team/agent-team/src/index.ts`](../../packages/team/agent-team/src/index.ts) diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index a2b168f3fd..363584a5fd 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -9,8 +9,8 @@ en: c90cb23528ddb6c8 zh: 62724b7255b586b6 /core/creation-and-ownership: - en: e516e14228c6bd35 - zh: dba25d7da1d5a44e + en: d71c669c1a36bf52 + zh: 9b335f90c84a894e /core/the-agent-handle: en: 6ee15fec81bc0686 zh: b6f3fcd48823ea75 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 48ff2ce633..40264ec898 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -46,9 +46,9 @@ interface AgentHandle { } ``` -`CreateAgentOptions` carries the shared identity and everything a fresh agent needs before publication: an optional live `parentAgent`, session metadata (`meta` — validated `cwd`, fork lineage, the `isSeeded` marker, origin classification, delegation depth, and `agentPreset`), the exact fork cut in sibling field `inheritedEventCount`, an optional `seed` replay prefix, per-agent `AgentOptions`, a creation-only cancellation `signal`, and `setup`. `ResumeAgentOptions` is the persisted-identity counterpart: `resumeSessionId`, `parentAgent`, `agentOptions`, `signal`, and `setup`. The `setup` callback (`AgentSetup`) receives `(agentCtx, agent)` after Session store entry and completed `session/created`, before Agent registration: the context owns scoped registrations, while the explicit Agent supplies the exact child Session without a reverse property on the Context. Everything registered through `agentCtx` exists before `agent/created` and the first prompt assembly. Setup may return a synchronous commit invoked immediately before publication; a setup rejection, commit throw, or owner disposal unwinds the transaction, removes the Session, and preserves restored pending input and already stored events. +`CreateAgentOptions` carries the shared identity and everything a fresh agent needs before publication: session metadata (`meta` — validated `cwd`, fork lineage, the `isSeeded` marker, origin classification, delegation depth, and `agentPreset`), the exact fork cut in sibling field `inheritedEventCount`, an optional `seed` replay prefix, per-agent `AgentOptions`, a creation-only cancellation `signal`, and `setup`. `ResumeAgentOptions` is the persisted-identity counterpart: `resumeSessionId`, `agentOptions`, `signal`, and `setup`. The `setup` callback (`AgentSetup`) receives `(agentCtx, agent)` after Session store entry and completed `session/created`, before Agent registration: the context owns scoped registrations, while the explicit Agent supplies the exact child Session without a reverse property on the Context. Everything registered through `agentCtx` exists before `agent/created` and the first prompt assembly. Setup may return a synchronous commit invoked immediately before publication; a setup rejection, commit throw, or owner disposal unwinds the transaction, removes the Session, and preserves restored pending input and already stored events. -`AgentFactory` is the creation interface behind the registry: the loop registers its factory via `ctx.agents.setFactory()`, so consumers use `ctx.agents` without depending on the concrete loop package. A runtime child creator sets `options.parentAgent`; the registry passes the options and caller Context to the factory without deriving one from the other. The exact `create`/`resume` signatures and rollback contracts are in the [generated section](#ctxagents--agentregistry) below. +`AgentFactory` is the creation interface behind the registry: the loop registers its factory via `ctx.agents.setFactory()`, so consumers use `ctx.agents` without depending on the concrete loop package. The registry passes creation options and the caller Context to the factory; the caller Context owns the returned handle's lifetime. The exact `create`/`resume` signatures and rollback contracts are in the [generated section](#ctxagents--agentregistry) below. ## The agent handle @@ -764,7 +764,7 @@ setFactory(factory: AgentFactory): () => void * agent): this constructs the agent and its session. Rejects if no factory is * registered or creation/setup fails. The resolved {@link AgentHandle} lets * the owner tear down exactly this agent. - * @param options - shared identity, optional live parent, session seed/metadata, and agent options. + * @param options - shared identity, session seed/metadata, and agent options. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ async create(options: CreateAgentOptions): Promise @@ -773,7 +773,7 @@ async create(options: CreateAgentOptions): Promise * Load a persisted session and resume an agent on it through the registered * factory. Rejects if no factory is registered; the factory rejects if * session persistence is not configured or persistence/setup fails. - * @param options - persisted identity, optional live parent, configuration, and setup. + * @param options - persisted identity, configuration, and setup. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ async resume(options: ResumeAgentOptions): Promise @@ -785,8 +785,7 @@ async resume(options: ResumeAgentOptions): Promise * (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the * emits are scope-filtered regardless of which context invoked `register` * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always - * requires passing the carrier). The entry is a runtime root; factory-backed - * creation uses `options.parentAgent` for child ownership. Await the registration before using the agent. + * requires passing the carrier). Await registration before using the agent. * @param agent - the already-constructed agent to record in the store. * @returns the awaitable Cordis effect disposer (single-shot; a repeat call * returns undefined without awaiting an in-flight teardown). Exact @@ -806,15 +805,12 @@ register(agent: Agent): ReturnType * returned detach closure into its pre-installed composite teardown before * calling {@link announce}. Ordinary callers use {@link register}. * @param agent - the prepared, unpublished agent. - * @param owner - explicitly supplied live runtime owner, or - * undefined for a top-level runtime root. This is runtime ownership, not - * the resumed session's durable parent lineage. * @returns an idempotent closure that removes this exact entry and emits * `agent/disposed` with listener failures contained. When called from a * `agent/created` listener, removal and disposal wait until the serial * creation dispatch settles. */ -enter(agent: Agent, owner: Agent | undefined): () => void +enter(agent: Agent): () => void /** * Announce an agent previously inserted with {@link enter}. @@ -835,29 +831,11 @@ async announce(agent: Agent, source: SessionStartSource, signal?: AbortSignal): */ get(id: SessionId): Agent | undefined -/** - * Test whether a live agent was created through one exact parent agent's - * scoped context. Runtime ownership is independent of durable session - * lineage and remains unambiguous when unrelated providers reuse an id. - * @param id - the candidate child agent's shared agent/session id. - * @param owner - the expected runtime creator agent. - * @returns true only while the exact child entry is live under that owner. - */ -isOwnedBy(id: SessionId, owner: Agent): boolean - /** * All live agents, in registration order. * @returns a fresh array; mutating it does not affect the registry. */ list(): Agent[] - -/** - * All live top-level agents in registration order. A top-level agent was - * created without an owning agent context; durable session lineage does not - * affect this runtime relation, so a resumed fork may still be a root. - * @returns a fresh array; mutating it does not affect the registry. - */ -roots(): Agent[] ``` Types: [Session](session.md) diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 19acf51b51..ff1d3b579e 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -48,9 +48,9 @@ interface AgentHandle { } ``` -`CreateAgentOptions` 携带共享标识以及新 agent 发布前所需的一切:可选的存活 `parentAgent`、会话元数据(`meta`——已校验的 `cwd`、fork 谱系、`isSeeded` 标记、来源分类、委派深度与 `agentPreset`)、同级字段 `inheritedEventCount` 所表示的精确 fork cut、可选的 `seed` 回放前缀、按 agent 的 `AgentOptions`、仅创建期有效的取消 `signal`,以及 `setup`。`ResumeAgentOptions` 是持久标识的对应项:`resumeSessionId`、`parentAgent`、`agentOptions`、`signal` 与 `setup`。`setup` 回调(`AgentSetup`)在 Session 入库且 `session/created` 完成后、Agent 注册前接收 `(agentCtx, agent)`:上下文拥有作用域注册,显式 Agent 提供确切的子 Session,Context 无需反向属性。凡经 `agentCtx` 注册的内容都先于 `agent/created` 与第一次提示词组装存在。Setup 可以返回在发布前一刻调用的同步 commit;setup 拒绝、commit 抛出或所有者 dispose(资源释放)都会撤销事务并移除 Session,同时保留恢复出的待处理输入及已存储事件。 +`CreateAgentOptions` 携带共享标识以及新 agent 发布前所需的一切:会话元数据(`meta`——已校验的 `cwd`、fork 谱系、`isSeeded` 标记、来源分类、委派深度与 `agentPreset`)、同级字段 `inheritedEventCount` 所表示的精确 fork cut、可选的 `seed` 回放前缀、按 agent 的 `AgentOptions`、仅创建期有效的取消 `signal`,以及 `setup`。`ResumeAgentOptions` 是持久标识的对应项:`resumeSessionId`、`agentOptions`、`signal` 与 `setup`。`setup` 回调(`AgentSetup`)在 Session 入库且 `session/created` 完成后、Agent 注册前接收 `(agentCtx, agent)`:上下文拥有作用域注册,显式 Agent 提供确切的子 Session,Context 无需反向属性。凡经 `agentCtx` 注册的内容都先于 `agent/created` 与第一次提示词组装存在。Setup 可以返回在发布前一刻调用的同步 commit;setup 拒绝、commit 抛出或所有者 dispose(资源释放)都会撤销事务并移除 Session,同时保留恢复出的待处理输入及已存储事件。 -`AgentFactory` 是注册表背后的创建接口:循环经 `ctx.agents.setFactory()` 注册其工厂,因此消费方使用 `ctx.agents` 时无需依赖具体循环包。运行时子 Agent 的创建方设置 `options.parentAgent`;注册表把 options 与调用方 Context 传给工厂,不从其中一项推导另一项。确切的 `create`/`resume` 签名及回滚约定见下方[生成区块](#ctxagents--agentregistry)。 +`AgentFactory` 是注册表背后的创建接口:循环经 `ctx.agents.setFactory()` 注册其工厂,因此消费方使用 `ctx.agents` 时无需依赖具体循环包。注册表把创建选项和调用方 Context 传给工厂;调用方 Context 拥有返回句柄的生命周期。确切的 `create`/`resume` 签名及回滚约定见下方[生成区块](#ctxagents--agentregistry)。 @@ -776,7 +776,7 @@ setFactory(factory: AgentFactory): () => void * agent): this constructs the agent and its session. Rejects if no factory is * registered or creation/setup fails. The resolved {@link AgentHandle} lets * the owner tear down exactly this agent. - * @param options - shared identity, optional live parent, session seed/metadata, and agent options. + * @param options - shared identity, session seed/metadata, and agent options. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ async create(options: CreateAgentOptions): Promise @@ -785,7 +785,7 @@ async create(options: CreateAgentOptions): Promise * Load a persisted session and resume an agent on it through the registered * factory. Rejects if no factory is registered; the factory rejects if * session persistence is not configured or persistence/setup fails. - * @param options - persisted identity, optional live parent, configuration, and setup. + * @param options - persisted identity, configuration, and setup. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ async resume(options: ResumeAgentOptions): Promise @@ -797,8 +797,7 @@ async resume(options: ResumeAgentOptions): Promise * (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the * emits are scope-filtered regardless of which context invoked `register` * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always - * requires passing the carrier). The entry is a runtime root; factory-backed - * creation uses `options.parentAgent` for child ownership. Await the registration before using the agent. + * requires passing the carrier). Await registration before using the agent. * @param agent - the already-constructed agent to record in the store. * @returns the awaitable Cordis effect disposer (single-shot; a repeat call * returns undefined without awaiting an in-flight teardown). Exact @@ -818,15 +817,12 @@ register(agent: Agent): ReturnType * returned detach closure into its pre-installed composite teardown before * calling {@link announce}. Ordinary callers use {@link register}. * @param agent - the prepared, unpublished agent. - * @param owner - explicitly supplied live runtime owner, or - * undefined for a top-level runtime root. This is runtime ownership, not - * the resumed session's durable parent lineage. * @returns an idempotent closure that removes this exact entry and emits * `agent/disposed` with listener failures contained. When called from a * `agent/created` listener, removal and disposal wait until the serial * creation dispatch settles. */ -enter(agent: Agent, owner: Agent | undefined): () => void +enter(agent: Agent): () => void /** * Announce an agent previously inserted with {@link enter}. @@ -847,29 +843,11 @@ async announce(agent: Agent, source: SessionStartSource, signal?: AbortSignal): */ get(id: SessionId): Agent | undefined -/** - * Test whether a live agent was created through one exact parent agent's - * scoped context. Runtime ownership is independent of durable session - * lineage and remains unambiguous when unrelated providers reuse an id. - * @param id - the candidate child agent's shared agent/session id. - * @param owner - the expected runtime creator agent. - * @returns true only while the exact child entry is live under that owner. - */ -isOwnedBy(id: SessionId, owner: Agent): boolean - /** * All live agents, in registration order. * @returns a fresh array; mutating it does not affect the registry. */ list(): Agent[] - -/** - * All live top-level agents in registration order. A top-level agent was - * created without an owning agent context; durable session lineage does not - * affect this runtime relation, so a resumed fork may still be a root. - * @returns a fresh array; mutating it does not affect the registry. - */ -roots(): Agent[] ``` Types: [Session](session.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/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index e5b5c959fc..3397485d88 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.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/slots.md /web-client-slots: - en: 9df94e585e2e4809 - zh: ecc4f05a2cc54b31 + en: 15c49af3ed8b57ff + zh: e7f9189de6f4334c /web-client-slots/declaration-and-lifecycle: en: f2d2c0d3b8f14ec7 zh: 441ed13ff3f81d2a diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index 87f36a0951..1e551c2e51 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -10,7 +10,7 @@ This page documents slot ownership, component inputs, extension APIs, and the sh The plugin catalog declares `plugins.add.actions`, a root-scoped list for `MenuItemButton` rows after installation in the Add plugin menu. Its owner supplies `onDismiss()` to close the menu before starting an action. The slot follows the owning Plugins page declaration lifetime. -The root-scoped lists `sidebar.panellist.actions`, `conversation.session.header.views.actions`, and `sidebar.right.tab.guide.actions` supply the rows of the More menus after the panel rows, the View tabs, and the right-sidebar guide cards. A region draws its More button and menu only while its list has registrations, and passes the customizable entries with their pin state, `onOpen`, `onPinnedChange`, and `onDismiss`. Panel and View entries also carry optional `active` state for the current selection, independently of pin state. Each region keeps its own unpinned entry ids in memory; [`ui-entry-customization`](../../packages/client/ui-entry-customization/README.md) registers the shipped rows. +The root-scoped lists `sidebar.panellist.actions`, `conversation.session.header.views.actions`, and `sidebar.right.tab.guide.actions` supply the rows of the More menus after the panel rows, the View tabs, and the right-sidebar guide cards. A region draws its More button and menu only while its list has registrations, and passes the customizable entries with their pin state, `onOpen`, `onPinnedChange`, and `onDismiss`. Panel and View entries also carry optional `active` state for the current selection, independently of pin state. The `sidebar.panel.icon` root-scoped factory owns the `sidebar.panellist` list and renders its registered icon by `id`, `size`, and `active` for both sidebar rows and More entries. Each region keeps its own unpinned entry ids in memory; [`ui-entry-customization`](../../packages/client/ui-entry-customization/README.md) registers the shipped rows. [ui-workspace](../../packages/client/ui-workspace/README.md#understand-the-implementation) documents Session-row extension positions, visibility, and keyboard behavior. diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index 8b70a28100..8260a34075 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -10,7 +10,7 @@ Slots 是 Web Client 的类型化 React 组合系统。[`dsh-client-ui-slots`](. 插件目录声明 `plugins.add.actions`,它是 root 作用域的 list,在添加插件菜单的安装项之后提供 `MenuItemButton` 行。owner 提供 `onDismiss()`,用于开始动作前关闭菜单。该 slot 遵循所属插件页面声明的生命周期。 -root 作用域的 list `sidebar.panellist.actions`、`conversation.session.header.views.actions` 和 `sidebar.right.tab.guide.actions` 提供面板行、View 标签和右侧栏引导卡片之后“更多”菜单的菜单行。区域只在其 list 有注册项时画“更多”按钮与菜单,并传入可自定义的入口及其 Pin 状态、`onOpen`、`onPinnedChange` 与 `onDismiss`。面板和 View 入口还通过可选的 `active` 状态标识当前选中项,该状态独立于 Pin 状态。各区域在内存中维护已收起的入口 id;[`ui-entry-customization`](../../packages/client/ui-entry-customization/README.zh.md) 注册随产品提供的菜单行。 +root 作用域的 list `sidebar.panellist.actions`、`conversation.session.header.views.actions` 和 `sidebar.right.tab.guide.actions` 提供面板行、View 标签和右侧栏引导卡片之后“更多”菜单的菜单行。区域只在其 list 有注册项时画“更多”按钮与菜单,并传入可自定义的入口及其 Pin 状态、`onOpen`、`onPinnedChange` 与 `onDismiss`。面板和 View 入口还通过可选的 `active` 状态标识当前选中项,该状态独立于 Pin 状态。root 作用域的 `sidebar.panel.icon` factory 拥有 `sidebar.panellist` list,按 `id`、`size` 与 `active` 为侧栏行和更多菜单入口渲染已注册图标。各区域在内存中维护已收起的入口 id;[`ui-entry-customization`](../../packages/client/ui-entry-customization/README.zh.md) 注册随产品提供的菜单行。 [ui-workspace](../../packages/client/ui-workspace/README.zh.md#understand-the-implementation) 记录 Session 行的扩展位置、显示条件和键盘行为。 diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index 21fa5dbbde..b2d7d88b37 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.i18n.yaml @@ -3,26 +3,26 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/subagent.md /subagents: - en: 9d48a27a49585a3d - zh: 89da32c8a16c290a + en: 3d828004dee084c6 + zh: f71296b677691151 /subagents/capabilities-and-activation-requests: - en: 8c92b131f67997f6 - zh: c2dc606a0859420c + en: f09270fcd7bfa26a + zh: 082664920b3c65e2 /subagents/local-children-and-activations: en: 6f87231d18d82916 zh: 9f064b23058f8766 /subagents/messages-and-interruption: - en: 623b06b3c12540bc - zh: f56c6341e1c62768 + en: 9befacb931520842 + zh: c214b4fffa779b06 /subagents/durable-enumeration: - en: 30c3607a969696ce - zh: e6359cdf22b1e235 + en: e02e1703b183594c + zh: 130d475a4304f453 /subagents/results-and-backend-handles: en: 69ea5bed7d9fb64a zh: b187a822b813e80a /subagents/the-provider-contract-subagentprovider: en: 226ba8094a8cf96c zh: 719678662905d1eb -/subagents/durable-enumeration-listchildren-listdescendants-and-their-entries: - en: d8ce7969b65ad86e - zh: d39dac6592681bf2 +/subagents/durable-enumeration-listdescendants-and-catalog-entries: + en: 8000138f84fd15fa + zh: 32fa91d3aebd3d01 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index b54ce57175..0e3235cf91 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -2,13 +2,13 @@ English | [中文](subagent.zh.md) -The unified activation API manages local conversations and external executions. +The unified activation API manages local conversations and external executions. Creation ancestry, runtime ownership, and communication belong to the subagent tree. [Subagent controls](../../packages/subagent/tool-subagent-control/README.md) own agent identity and exact-caller inbox waiting; Team tools add shared tasks and task policy. ## Capabilities and activation requests Every backend uses startActivation. Capability flags validate request options; prepareContinuable selects local Agent creation, while start selects one external execution. The model-facing tool uses parent delivery. Workflow and code mode consumers can await caller results without injecting another parent message. Historical children with unavailable descriptors remain discoverable as `mode: 'unknown'` without granting continuation capabilities; the [package README](../../packages/subagent/subagent/README.md) defines catalog persistence. -Optional `request.cwd` selects the child's initial directory; relative paths resolve against the parent's current effective directory. Omitting it captures that directory at admission. Local child Sessions retain the parent's original project, and cold resume retains their logged directory state. +Optional `cwd` selects the child's initial directory; relative paths resolve against the parent's current effective directory. Omitting it captures that directory at admission. Local child Sessions retain the parent's original project, and cold resume retains their logged directory state. ```ts type-equiv /** @@ -28,15 +28,13 @@ interface SubagentCapabilities { ```ts type-equiv /** One managed child execution and its result delivery policy. */ -interface SubagentActivationSpec { +interface SubagentActivationSpec extends Omit { /** Registered backend to use. */ readonly provider: string /** Short task label retained in cataloged child membership. */ readonly label: string /** Optional reserved identity for a local child; external backends allocate their own ids. */ readonly childId?: SessionId - /** Task, parent, and backend-supported execution options. */ - readonly request: Omit /** Cancellation before publication; callers own later cancellation through dispose. */ readonly signal: AbortSignal /** Parent delivery notifies the model; caller delivery only returns the result. Local children always enter the parent catalog. */ @@ -56,16 +54,15 @@ interface SubagentActivation { * Teardown failures are reported by dispose() without replacing a captured result. */ readonly result: Promise - /** Stop and release this activation and its owned descendants; rejects on teardown failure. */ + /** Stop and release only this activation; rejects on teardown failure. */ dispose(): Promise } ``` ```ts type-equiv /** - * Task and optional capabilities supplied to a backend. startActivation carries - * these fields in request while owning its label, cancellation, and delivery - * policy separately. + * Task and optional capabilities supplied to a backend. The activation spec + * shares these task options and defines its own label and cancellation policy. */ interface SubagentStartRequest { /** Initial child directory; relative paths resolve against the parent's current directory. Omitted inherits that directory at start. */ @@ -173,7 +170,7 @@ interface ContinuableCreateSpec { ## Messages and interruption -sendMessage authorizes the exact live sender and permits only adjacent local agents. Running children receive steering input; absent continuable children resume from persistence. External executions reject follow-ups. Both local and external executions automatically report their final text, structured result, diagnostic, and status in the parent settlement notice. Local children can also send messages through send_message. caller delivery suppresses settlement notices. drainDescendants and drainChildren await owned work teardown for local and external activations. +`sendMessage` requires the exact live sender and a target Session id in the same root tree. Root and continuable members can message across branches; admission revalidates the sender and root. Running targets receive Steer; only an absent target cold-resumes, while its creation ancestors may remain offline. External executions reject follow-ups. Local and external executions report final text, structured output, diagnostics, and status to their direct creator only while it is online; caller delivery suppresses those notices. `drainDescendants` awaits whole-tree teardown; an activation receipt’s `dispose()` releases only that execution. ```ts type-equiv /** Durable attribution for one model-authored message between Agents. */ @@ -200,13 +197,15 @@ interface SubagentSettledMessageSource { readonly form: 'notice' /** One-line account of how the child ended. */ readonly summary: string + /** Exact execution stop reason; absent on historical notices. Writers always include it. */ + readonly stopReason?: SubagentResult['stopReason'] /** Session id of the child that settled. */ readonly senderSessionId: SessionId } ``` ```ts type-equiv -/** Options for one model-authored message between adjacent Agents. */ +/** Options for one model-authored message within a subagent tree. */ interface SubagentSendMessageOptions { /** Caller cancellation, owning the operation only until inbox acceptance. */ readonly signal: AbortSignal @@ -215,18 +214,18 @@ interface SubagentSendMessageOptions { ```ts type-equiv /** - * Authority under which one interrupt request is admitted. `user` carries the - * durable direct-parent address a human client presented; `ancestor` carries - * the exact live Agent object whose recorded lineage must contain the caller. + * Authority under which one interrupt request is admitted. `user` is a trusted + * host operation; Remote callers need permission on the target Session before + * invoking it. `ancestor` carries an exact live Agent in the target's creation ancestry. */ type SubagentInterruptAuthority = - | { readonly kind: 'user'; readonly parentSessionId: SessionId } + | { readonly kind: 'user' } | { readonly kind: 'ancestor'; readonly agent: Agent } ``` ## Durable enumeration -`listChildren` reads the parent-owned catalog of all local children and parent-delivery external children. Caller-delivery external executions leave membership and result collection to their caller; local workflow children remain discoverable after completion. External entries carry `mode: 'external'` and cannot open a child Session or accept follow-ups. Catalog entries record membership at creation without execution status. `list_agents` exposes direct continuable children with `running` or `inactive` activity; see the [control tool](../../packages/subagent/tool-subagent-control/README.md#list_agents). +The parent-owned `subagentCatalog` projection lists all local children and parent-delivery external children. Caller-delivery external executions leave membership and result collection to their caller; local workflow children remain discoverable after completion. External entries carry `mode: 'external'` and cannot open a child Session or accept follow-ups. Catalog entries record membership at creation without execution status. `list_agents` exposes the root and reachable continuable descendants with `running` or `inactive` activity; see the [control tool](../../packages/subagent/tool-subagent-control/README.md#list_agents). ## Results and backend handles @@ -372,11 +371,11 @@ interface SubagentProvider { } ``` -## Durable enumeration: `listChildren()`, `listDescendants()`, and their entries +## Durable enumeration: `listDescendants()` and catalog entries The model-facing `list_agents` adapter reports current activity as `running` or `inactive`. These values do not describe task completion or guarantee that `send_message` will succeed. -`SubagentRuntime.listChildren(parentSessionId, signal?)` reads the parent's `subagentCatalog` view through a live-preferred Session observation and releases that observation on success or failure. It returns direct-child entries in parent event order without reading child logs or enumerating the Session corpus. Query failures propagate; a missing catalog projection fails explicitly. Browser rows derive membership from the shared projection store and add activity from Session status; the control stream pushes complete catalog updates. `listDescendants()` recursively reads those catalogs and derives `hasChildren` from each child catalog. [The parent-catalog Agent Note](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns creation, fork isolation, ordering, and persistence costs. +Direct-child entries are available as `observation.projections?.values.subagentCatalog` through `ctx.sessionQuery.observeSession(parentSessionId, { signal })`; callers release the observation with `using`. Entries follow parent event order without reading child logs or enumerating the Session corpus. Query failures propagate; a missing catalog projection means discovery is unavailable. Browser rows derive membership from the shared projection store and add activity from Session status; the control stream pushes complete catalog updates. `listDescendants()` recursively reads those catalogs and derives `hasChildren` from each child catalog. [The parent-catalog Agent Note](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns creation, fork isolation, ordering, and persistence costs. `SubagentRuntime.listDescendants(rootSessionId)` recursively calls the same catalog reader in stable pre-order, preserving each parent's event order. External entries are leaves without local Sessions. One-shot and unknown-mode entries remain traversal nodes; unknown modes produce `unsupported` diagnostics. An unreadable child catalog produces `corrupt` or `unavailable` and stops only that branch. Root read failures, missing services or projections, and cancellation reject the listing. Each reachable catalog is observed once and released before the next read; repeated ids and cycles are skipped. Sessions absent from reachable catalogs are not discovered, including ordinary Session forks and any subagents below those forks. Each row carries its catalog parent and root-relative depth: @@ -437,32 +436,39 @@ startActivation(spec: SubagentActivationSpec & { readonly childId: SessionId }): startActivation(spec: SubagentActivationSpec): Promise /** - * Join progressing descendants without cancelling them. Idle descendants whose - * inboxes require a later wake stay resident and do not delay host completion. - * @param parent - the exact parent whose descendant work is observed. - * @returns whether work was joined; hosts recheck parent idle after true. + * Resolve the root shared by an Agent and its other agents in its subagent tree. + * @param caller - exact live Agent whose subagent ancestry selects the tree. + * @returns the live root; ordinary Session forks lead independent trees. + * @throws when the caller or its runtime owner is unavailable. */ -async waitForChildren(parent: Agent): Promise +rootOf(caller: Agent): Agent /** - * Steer one model-authored message to the sender's direct parent or direct - * continuable child. A running target admits it at the nearest step boundary; - * an idle target starts a turn, and an absent direct child cold-resumes from - * persistence. The service derives durable sender attribution from the exact - * live sender. Caller cancellation stops only pre-acceptance work. - * @param sender - exact live Agent authorizing and originating the message. - * @param targetId - durable direct-parent or direct-child session id. + * Join progressing subagent tree work for its root. Idle members with parked input + * stay resident without delaying host completion. Ordinary members return false. + * @param root - exact live subagent tree root whose member work is awaited. + * @returns whether work was joined; hosts recheck root idle after true. + */ +async waitForTreeWork(root: Agent): Promise + +/** + * Steer one model-authored message to another member of the same subagent tree. + * A running target receives it at its next step; an idle target starts a turn. + * A cold target resumes under its runtime subagent tree root without restoring + * its creation ancestors. Sender identity is rechecked before inbox acceptance; cancellation + * stops only pre-acceptance work and never retracts an accepted message. + * @param sender - exact live root or resident continuable Agent originating the message. + * @param targetId - another root or continuable member's durable Session id. * @param content - model-authored content to deliver. * @param options - caller cancellation before inbox acceptance. * @returns the accepted message's inbox id. - * @throws when continuation services are unavailable, adjacency is rejected, - * or the message was not admitted. + * @throws when the target is outside the tree, cannot resume, or rejects input. */ async sendMessage( sender: Agent, targetId: SessionId, content: ContentBlock[], options: SubagentSendMessageOptions, ): Promise /** - * Interrupt one live child's current execution under a human parent - * address or an exact live ancestor Agent. Fire-and-return: the cancel + * Interrupt one live child's current execution under a trusted user + * operation or an exact live ancestor Agent. Fire-and-return: the cancel * signal is issued before this returns, but the target may keep running * until it observes the signal. Unclaimed pending inbox work, the Activation, * and published descendants are preserved; claimed work is not requeued. @@ -471,47 +477,22 @@ async sendMessage( sender: Agent, targetId: SessionId, content: ContentBlock[], * is an accepted no-op, as is a manager-less composition, which cannot own a * live Activation. * @param targetSessionId - the durable child session id to interrupt. - * @param authority - the human parent address or exact live ancestor Agent. + * @param authority - a trusted user operation or exact live ancestor Agent. * @throws {SubagentError} `UNAUTHORIZED` when the authority does not own the * live target. */ interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void /** - * Close subagent admission below exact live parent Agents, stop only their - * visible descendant Activations synchronously, then await admitted scoped - * materializations and release those forests child-first. The scoped cutoff - * lasts until each exact parent leaves the registry; unrelated parent trees - * remain live. - * @param parents - exact host-owned parent Agents entering teardown. - * @returns once every retained descendant activation released its execution handle. + * Close admission and stop all instances in the selected subagent trees, then await + * pending startup and cleanup. Ordinary members are ignored. The cutoff lasts + * until each exact root leaves the registry; unrelated subagent trees remain live. + * @param parents - exact live subagent tree roots entering teardown. + * @returns once all admitted startup and member cleanup have settled. * @throws an aggregate error after all branches settle when any failed. */ async drainDescendants(parents: readonly Agent[]): Promise -/** - * Release selected resident direct children of one exact live - * parent. Other children of the same parent remain admitted and resident. - * Absent targets and a manager-less composition are accepted no-ops. - * @param parent - exact live direct parent authorizing the selected release. - * @param childIds - durable direct-child ids to release when resident. - * @returns once every selected activation released its execution handle. - * @throws {SubagentError} `UNAUTHORIZED` when a resident target belongs to a - * different parent or the supplied parent identity is stale. - */ -async drainChildren(parent: Agent, childIds: readonly SessionId[]): Promise - -/** - * Read the parent's durable direct-child catalog without loading or resuming an Agent. - * The service owns and releases the live-preferred Session observation. - * @param parentSessionId - parent whose direct children are requested. - * @param signal - cancellation forwarded to the Session query. - * @returns catalog children in parent event order. - * @throws {@link SubagentError} when query or catalog projection is unavailable. - * @throws SessionQueryError when the parent cannot be read or the query is cancelled. - */ -listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise - /** * Recursively list reachable parent catalogs in stable pre-order, preserving * each catalog's event order. Each row carries its catalog parent and depth; @@ -531,8 +512,8 @@ listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise /** - * Deliver one browser-authored message to a continuable child through the - * exact live direct parent, retaining the caller-minted request identity and + * Deliver one browser-authored message to a continuable child through its + * live subagent tree root, retaining the caller-minted request identity and * validated browser zone on the accepted message. Success identifies the * message the child's inbox accepted; later execution is independent of this * call. Queue delivery targets a later turn; steer delivery targets the @@ -540,31 +521,27 @@ listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise +@Remote({ name: 'prompt', resources: { session: request => request.childSessionId } }) async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise /** - * Remote face of {@link interrupt} under one durable parent address. No - * catalog, history, persistence, or parent Agent lookup runs: the core - * primitive alone authorizes the address against the live Activation, which - * is what keeps a live child interruptible while its parent Agent is offline. + * Remote face of {@link interrupt}, authorized on the target Session by the Gateway. + * No catalog, history, persistence, or parent Agent lookup runs. * Absent, idle, and already-completed targets are accepted no-ops there. * @param childSessionId - durable child session id to interrupt. - * @param parentSessionId - durable direct parent whose authority is claimed. - * @param mode - required continuable-address discriminator. * @returns acknowledgement that the cancel signal was admitted, not that the target is quiescent. * @throws {RemoteError} `gateway/bad-request` for an empty id, - * `subagent/unauthorized` when the address does not own the live target, * otherwise `gateway/internal`. */ -@Remote('interruptByParent') interruptByParent( childSessionId: SessionId, parentSessionId: SessionId, mode: 'continuable', ): SubagentInterruptReceipt +@Remote({ name: 'interruptByUser', resources: { session: childSessionId => childSessionId } }) interruptByUser(childSessionId: SessionId): SubagentInterruptReceipt /** * Register a provider under its name. Registration is effect-scoped and HMR @@ -582,12 +559,6 @@ registerProvider(provider: SubagentProvider): () => void * @returns the provider, or undefined when absent. */ getProvider(name: string): SubagentProvider | undefined - -/** - * List registered provider names in insertion order. - * @returns the registered names. - */ -list(): string[] ``` Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) · [MessageId](llm-streaming.md) · [SessionId](core.md) @@ -602,14 +573,14 @@ Source: [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/suba #### `subagent/end` — emit -A published child settled. Scope-filtered dispatch uses the same delegating parent carrier as `subagent/start`, so the lifecycle pair reaches the same scoped audience. +A published child settled. Scope-filtered dispatch uses the same subagent tree-root carrier as `subagent/start`, so the lifecycle pair reaches the same scoped audience. ```ts cordis-catalog /** - * A published child settled. Scope-filtered dispatch uses the same delegating - * parent carrier as `subagent/start`, so the lifecycle pair reaches the + * A published child settled. Scope-filtered dispatch uses the same subagent tree-root + * carrier as `subagent/start`, so the lifecycle pair reaches the * same scoped audience. - * @param info - the run identity and terminal outcome. + * @param info - run identity, durable direct creator `parentSessionId`, and terminal outcome. * @mode emit */ 'subagent/end'(this: Scoped, info: SubagentRunEndInfo): void @@ -657,16 +628,16 @@ Source: [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/suba #### `subagent/start` — emit -A provider established a published child. For in-process providers, `ctx.agents.get(info.id)` resolves during this notification. Scope-filtered dispatch keys the carrier by the delegating parent, so a parent-scoped listener observes only its own delegations. Paired with `subagent/end`. +A provider established a published child. For in-process providers, `ctx.agents.get(info.id)` resolves during this notification. Scope-filtered dispatch keys the carrier by the subagent tree root, so a root-scoped listener observes all subagent tree activations. Paired with `subagent/end`. ```ts cordis-catalog /** * A provider established a published child. For in-process providers, * `ctx.agents.get(info.id)` resolves during this notification. - * Scope-filtered dispatch keys the carrier by the delegating parent, so a - * parent-scoped listener observes only its own delegations. Paired with + * Scope-filtered dispatch keys the carrier by the subagent tree root, so a + * root-scoped listener observes all subagent tree activations. Paired with * `subagent/end`. - * @param info - the provider and published child identity. + * @param info - provider, child identity, and durable direct creator `parentSessionId`. * @mode emit */ 'subagent/start'(this: Scoped, info: SubagentRunInfo): void diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 0ca87c8503..785644d1cf 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -2,13 +2,13 @@ [English](subagent.md) | 中文 -统一的 activation API 管理本地会话和外部执行。 +统一的 activation API 管理本地对话和外部执行。创建祖先关系、运行归属和通信由 subagent 树管理。[subagent 控制工具](../../packages/subagent/tool-subagent-control/README.zh.md)负责 agent 身份和当前调用者的收件箱等待;Team 工具添加共享任务与任务策略。 ## 能力与 activation 请求 所有后端都使用 startActivation。能力标记校验请求选项;prepareContinuable 选择本地 Agent 创建,start 选择一次外部执行。面向模型的工具采用 parent 回传。Workflow 与 code mode 消费者可以等待 caller 结果,不额外向父代理注入消息。历史子会话的 descriptor 不可用时,以 `mode: 'unknown'` 保持可发现性,但不授予继续执行能力;[包 README](../../packages/subagent/subagent/README.zh.md) 定义目录持久化语义。 -可选的 `request.cwd` 指定子级的初始目录;相对路径以父级当前有效目录为基准解析。省略时,在准入阶段捕获该目录。本地子 Session 保留父级的原始项目,冷恢复保留其日志中的目录状态。 +可选的 `cwd` 指定子级的初始目录;相对路径以父级当前有效目录为基准解析。省略时,在准入阶段捕获该目录。本地子 Session 保留父级的原始项目,冷恢复保留其日志中的目录状态。 ```ts type-equiv /** @@ -28,15 +28,13 @@ interface SubagentCapabilities { ```ts type-equiv /** One managed child execution and its result delivery policy. */ -interface SubagentActivationSpec { +interface SubagentActivationSpec extends Omit { /** Registered backend to use. */ readonly provider: string /** Short task label retained in cataloged child membership. */ readonly label: string /** Optional reserved identity for a local child; external backends allocate their own ids. */ readonly childId?: SessionId - /** Task, parent, and backend-supported execution options. */ - readonly request: Omit /** Cancellation before publication; callers own later cancellation through dispose. */ readonly signal: AbortSignal /** Parent delivery notifies the model; caller delivery only returns the result. Local children always enter the parent catalog. */ @@ -56,16 +54,15 @@ interface SubagentActivation { * Teardown failures are reported by dispose() without replacing a captured result. */ readonly result: Promise - /** Stop and release this activation and its owned descendants; rejects on teardown failure. */ + /** Stop and release only this activation; rejects on teardown failure. */ dispose(): Promise } ``` ```ts type-equiv /** - * Task and optional capabilities supplied to a backend. startActivation carries - * these fields in request while owning its label, cancellation, and delivery - * policy separately. + * Task and optional capabilities supplied to a backend. The activation spec + * shares these task options and defines its own label and cancellation policy. */ interface SubagentStartRequest { /** Initial child directory; relative paths resolve against the parent's current directory. Omitted inherits that directory at start. */ @@ -173,7 +170,7 @@ interface ContinuableCreateSpec { ## 消息与中断 -sendMessage 根据确切的在线发送者实例授权,仅允许相邻本地代理通信。运行中的子代理接收引导输入;不在线的可继续子代理从持久化恢复。外部执行拒绝后续消息。本地与外部执行都会通过父级结束通知自动回传最终文本、结构化结果、诊断和状态。本地子代理也可以通过 send_message 发送消息。caller 回传会抑制结束通知。drainDescendants 与 drainChildren 对本地和外部 activation 都等待所属任务清理完成。 +`sendMessage` 要求确切在线发送方和同一根树中的目标 Session id。根和可继续成员可跨分支发消息;接纳时重新校验发送方和根。运行中目标接收 Steer;只冷恢复不在线的目标,其创建祖先可以保持离线。外部执行拒绝后续输入。本地和外部执行仅向仍在线的直接创建者报告最终文本、结构化输出、诊断和状态;caller 投递抑制这些通知。`drainDescendants` 等待整棵树释放;activation 回执的 `dispose()` 仅释放那一次执行。 ```ts type-equiv /** Durable attribution for one model-authored message between Agents. */ @@ -200,13 +197,15 @@ interface SubagentSettledMessageSource { readonly form: 'notice' /** One-line account of how the child ended. */ readonly summary: string + /** Exact execution stop reason; absent on historical notices. Writers always include it. */ + readonly stopReason?: SubagentResult['stopReason'] /** Session id of the child that settled. */ readonly senderSessionId: SessionId } ``` ```ts type-equiv -/** Options for one model-authored message between adjacent Agents. */ +/** Options for one model-authored message within a subagent tree. */ interface SubagentSendMessageOptions { /** Caller cancellation, owning the operation only until inbox acceptance. */ readonly signal: AbortSignal @@ -215,18 +214,18 @@ interface SubagentSendMessageOptions { ```ts type-equiv /** - * Authority under which one interrupt request is admitted. `user` carries the - * durable direct-parent address a human client presented; `ancestor` carries - * the exact live Agent object whose recorded lineage must contain the caller. + * Authority under which one interrupt request is admitted. `user` is a trusted + * host operation; Remote callers need permission on the target Session before + * invoking it. `ancestor` carries an exact live Agent in the target's creation ancestry. */ type SubagentInterruptAuthority = - | { readonly kind: 'user'; readonly parentSessionId: SessionId } + | { readonly kind: 'user' } | { readonly kind: 'ancestor'; readonly agent: Agent } ``` ## 持久枚举 -`listChildren` 读取父级拥有的目录,其中包含所有本地子级及 parent 投递的外部子级。caller 投递的外部执行由调用方负责成员关系与结果收集;本地工作流子级在完成后仍可被发现。外部条目携带 `mode: 'external'`,不能打开子 Session 或接受后续消息。目录在创建时记录成员关系,不携带执行状态。`list_agents` 展示可继续的直接子级,其活跃状态为 `running` 或 `inactive`;参见[控制工具](../../packages/subagent/tool-subagent-control/README.zh.md#list_agents)。 +父级拥有的 `subagentCatalog` 投影列出所有本地子级及 parent 投递的外部子级。caller 投递的外部执行由调用方负责成员关系与结果收集;本地工作流子级在完成后仍可被发现。外部条目携带 `mode: 'external'`,不能打开子 Session 或接受后续消息。目录在创建时记录成员关系,不携带执行状态。`list_agents` 展示根和可达的可继续后代,其活跃状态为 `running` 或 `inactive`;参见[控制工具](../../packages/subagent/tool-subagent-control/README.zh.md#list_agents)。 ## 结果与后端句柄 @@ -372,11 +371,11 @@ interface SubagentProvider { } ``` -## 持久化枚举:`listChildren()`、`listDescendants()` 与其条目 +## 持久化枚举:`listDescendants()` 与目录条目 模型侧的 `list_agents` 适配器将当前活动表示为 `running` 或 `inactive`。这些值不描述任务完成情况,也不保证 `send_message` 会成功。 -`SubagentRuntime.listChildren(parentSessionId, signal?)` 通过优先使用在线 Session 的观察读取父会话的 `subagentCatalog` 视图,并在成功或失败时释放观察。它按父会话事件顺序返回直接子级条目,不读取子级日志,也不枚举 Session 语料库。查询失败直接传播;缺少目录投影时显式失败。浏览器条目从共享 projection store 派生成员关系,并从 Session 状态补充活动状态;control stream 推送完整目录更新。`listDescendants()` 递归读取这些目录,并从每个子级目录推导 `hasChildren`。[父目录 Agent Note](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明创建、fork 隔离、排序和持久化成本。 +通过 `ctx.sessionQuery.observeSession(parentSessionId, { signal })`,可从 `observation.projections?.values.subagentCatalog` 获取直接子级条目;调用方使用 `using` 释放观察对象。条目遵循父会话事件顺序,不读取子级日志,也不枚举 Session 语料库。查询失败直接传播;缺少目录投影意味着发现功能不可用。浏览器条目从共享 projection store 派生成员关系,并从 Session 状态补充活动状态;control stream 推送完整目录更新。`listDescendants()` 递归读取这些目录,并从每个子级目录推导 `hasChildren`。[父目录 Agent Note](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明创建、fork 隔离、排序和持久化成本。 `SubagentRuntime.listDescendants(rootSessionId)` 以稳定前序递归调用同一目录读取函数,保留每个父级的事件顺序。外部条目是没有本地 Session 的叶节点。一次性和未知模式条目仍是遍历节点;未知模式产生 `unsupported` 诊断。无法读取的子级目录产生 `corrupt` 或 `unavailable`,只停止该分支。根读取失败、服务或投影缺失、取消会使整个查询失败。每个可达目录只观察一次,并在下一次读取前释放;重复 id 和循环引用会被跳过。可达目录中不存在的 Session 不会被发现,包括普通 Session fork 及其下的子 agent。每行携带目录中的父级和相对根的深度: @@ -439,32 +438,39 @@ startActivation(spec: SubagentActivationSpec & { readonly childId: SessionId }): startActivation(spec: SubagentActivationSpec): Promise /** - * Join progressing descendants without cancelling them. Idle descendants whose - * inboxes require a later wake stay resident and do not delay host completion. - * @param parent - the exact parent whose descendant work is observed. - * @returns whether work was joined; hosts recheck parent idle after true. + * Resolve the root shared by an Agent and its other agents in its subagent tree. + * @param caller - exact live Agent whose subagent ancestry selects the tree. + * @returns the live root; ordinary Session forks lead independent trees. + * @throws when the caller or its runtime owner is unavailable. */ -async waitForChildren(parent: Agent): Promise +rootOf(caller: Agent): Agent /** - * Steer one model-authored message to the sender's direct parent or direct - * continuable child. A running target admits it at the nearest step boundary; - * an idle target starts a turn, and an absent direct child cold-resumes from - * persistence. The service derives durable sender attribution from the exact - * live sender. Caller cancellation stops only pre-acceptance work. - * @param sender - exact live Agent authorizing and originating the message. - * @param targetId - durable direct-parent or direct-child session id. + * Join progressing subagent tree work for its root. Idle members with parked input + * stay resident without delaying host completion. Ordinary members return false. + * @param root - exact live subagent tree root whose member work is awaited. + * @returns whether work was joined; hosts recheck root idle after true. + */ +async waitForTreeWork(root: Agent): Promise + +/** + * Steer one model-authored message to another member of the same subagent tree. + * A running target receives it at its next step; an idle target starts a turn. + * A cold target resumes under its runtime subagent tree root without restoring + * its creation ancestors. Sender identity is rechecked before inbox acceptance; cancellation + * stops only pre-acceptance work and never retracts an accepted message. + * @param sender - exact live root or resident continuable Agent originating the message. + * @param targetId - another root or continuable member's durable Session id. * @param content - model-authored content to deliver. * @param options - caller cancellation before inbox acceptance. * @returns the accepted message's inbox id. - * @throws when continuation services are unavailable, adjacency is rejected, - * or the message was not admitted. + * @throws when the target is outside the tree, cannot resume, or rejects input. */ async sendMessage( sender: Agent, targetId: SessionId, content: ContentBlock[], options: SubagentSendMessageOptions, ): Promise /** - * Interrupt one live child's current execution under a human parent - * address or an exact live ancestor Agent. Fire-and-return: the cancel + * Interrupt one live child's current execution under a trusted user + * operation or an exact live ancestor Agent. Fire-and-return: the cancel * signal is issued before this returns, but the target may keep running * until it observes the signal. Unclaimed pending inbox work, the Activation, * and published descendants are preserved; claimed work is not requeued. @@ -473,47 +479,22 @@ async sendMessage( sender: Agent, targetId: SessionId, content: ContentBlock[], * is an accepted no-op, as is a manager-less composition, which cannot own a * live Activation. * @param targetSessionId - the durable child session id to interrupt. - * @param authority - the human parent address or exact live ancestor Agent. + * @param authority - a trusted user operation or exact live ancestor Agent. * @throws {SubagentError} `UNAUTHORIZED` when the authority does not own the * live target. */ interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void /** - * Close subagent admission below exact live parent Agents, stop only their - * visible descendant Activations synchronously, then await admitted scoped - * materializations and release those forests child-first. The scoped cutoff - * lasts until each exact parent leaves the registry; unrelated parent trees - * remain live. - * @param parents - exact host-owned parent Agents entering teardown. - * @returns once every retained descendant activation released its execution handle. + * Close admission and stop all instances in the selected subagent trees, then await + * pending startup and cleanup. Ordinary members are ignored. The cutoff lasts + * until each exact root leaves the registry; unrelated subagent trees remain live. + * @param parents - exact live subagent tree roots entering teardown. + * @returns once all admitted startup and member cleanup have settled. * @throws an aggregate error after all branches settle when any failed. */ async drainDescendants(parents: readonly Agent[]): Promise -/** - * Release selected resident direct children of one exact live - * parent. Other children of the same parent remain admitted and resident. - * Absent targets and a manager-less composition are accepted no-ops. - * @param parent - exact live direct parent authorizing the selected release. - * @param childIds - durable direct-child ids to release when resident. - * @returns once every selected activation released its execution handle. - * @throws {SubagentError} `UNAUTHORIZED` when a resident target belongs to a - * different parent or the supplied parent identity is stale. - */ -async drainChildren(parent: Agent, childIds: readonly SessionId[]): Promise - -/** - * Read the parent's durable direct-child catalog without loading or resuming an Agent. - * The service owns and releases the live-preferred Session observation. - * @param parentSessionId - parent whose direct children are requested. - * @param signal - cancellation forwarded to the Session query. - * @returns catalog children in parent event order. - * @throws {@link SubagentError} when query or catalog projection is unavailable. - * @throws SessionQueryError when the parent cannot be read or the query is cancelled. - */ -listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise - /** * Recursively list reachable parent catalogs in stable pre-order, preserving * each catalog's event order. Each row carries its catalog parent and depth; @@ -533,8 +514,8 @@ listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise /** - * Deliver one browser-authored message to a continuable child through the - * exact live direct parent, retaining the caller-minted request identity and + * Deliver one browser-authored message to a continuable child through its + * live subagent tree root, retaining the caller-minted request identity and * validated browser zone on the accepted message. Success identifies the * message the child's inbox accepted; later execution is independent of this * call. Queue delivery targets a later turn; steer delivery targets the @@ -542,31 +523,27 @@ listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise +@Remote({ name: 'prompt', resources: { session: request => request.childSessionId } }) async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise /** - * Remote face of {@link interrupt} under one durable parent address. No - * catalog, history, persistence, or parent Agent lookup runs: the core - * primitive alone authorizes the address against the live Activation, which - * is what keeps a live child interruptible while its parent Agent is offline. + * Remote face of {@link interrupt}, authorized on the target Session by the Gateway. + * No catalog, history, persistence, or parent Agent lookup runs. * Absent, idle, and already-completed targets are accepted no-ops there. * @param childSessionId - durable child session id to interrupt. - * @param parentSessionId - durable direct parent whose authority is claimed. - * @param mode - required continuable-address discriminator. * @returns acknowledgement that the cancel signal was admitted, not that the target is quiescent. * @throws {RemoteError} `gateway/bad-request` for an empty id, - * `subagent/unauthorized` when the address does not own the live target, * otherwise `gateway/internal`. */ -@Remote('interruptByParent') interruptByParent( childSessionId: SessionId, parentSessionId: SessionId, mode: 'continuable', ): SubagentInterruptReceipt +@Remote({ name: 'interruptByUser', resources: { session: childSessionId => childSessionId } }) interruptByUser(childSessionId: SessionId): SubagentInterruptReceipt /** * Register a provider under its name. Registration is effect-scoped and HMR @@ -584,12 +561,6 @@ registerProvider(provider: SubagentProvider): () => void * @returns the provider, or undefined when absent. */ getProvider(name: string): SubagentProvider | undefined - -/** - * List registered provider names in insertion order. - * @returns the registered names. - */ -list(): string[] ``` Types: [Agent](core.zh.md) · [ContentBlock](llm-streaming.zh.md) · [MessageId](llm-streaming.zh.md) · [SessionId](core.zh.md) @@ -604,14 +575,14 @@ Source: [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/suba #### `subagent/end` — emit -A published child settled. Scope-filtered dispatch uses the same delegating parent carrier as `subagent/start`, so the lifecycle pair reaches the same scoped audience. +A published child settled. Scope-filtered dispatch uses the same subagent tree-root carrier as `subagent/start`, so the lifecycle pair reaches the same scoped audience. ```ts cordis-catalog /** - * A published child settled. Scope-filtered dispatch uses the same delegating - * parent carrier as `subagent/start`, so the lifecycle pair reaches the + * A published child settled. Scope-filtered dispatch uses the same subagent tree-root + * carrier as `subagent/start`, so the lifecycle pair reaches the * same scoped audience. - * @param info - the run identity and terminal outcome. + * @param info - run identity, durable direct creator `parentSessionId`, and terminal outcome. * @mode emit */ 'subagent/end'(this: Scoped, info: SubagentRunEndInfo): void @@ -659,16 +630,16 @@ Source: [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/suba #### `subagent/start` — emit -A provider established a published child. For in-process providers, `ctx.agents.get(info.id)` resolves during this notification. Scope-filtered dispatch keys the carrier by the delegating parent, so a parent-scoped listener observes only its own delegations. Paired with `subagent/end`. +A provider established a published child. For in-process providers, `ctx.agents.get(info.id)` resolves during this notification. Scope-filtered dispatch keys the carrier by the subagent tree root, so a root-scoped listener observes all subagent tree activations. Paired with `subagent/end`. ```ts cordis-catalog /** * A provider established a published child. For in-process providers, * `ctx.agents.get(info.id)` resolves during this notification. - * Scope-filtered dispatch keys the carrier by the delegating parent, so a - * parent-scoped listener observes only its own delegations. Paired with + * Scope-filtered dispatch keys the carrier by the subagent tree root, so a + * root-scoped listener observes all subagent tree activations. Paired with * `subagent/end`. - * @param info - the provider and published child identity. + * @param info - provider, child identity, and durable direct creator `parentSessionId`. * @mode emit */ 'subagent/start'(this: Scoped, info: SubagentRunInfo): void diff --git a/docs/subsystems/user-questions.md b/docs/subsystems/user-questions.md index 2a2463b475..2c86b22e1c 100644 --- a/docs/subsystems/user-questions.md +++ b/docs/subsystems/user-questions.md @@ -278,17 +278,16 @@ async askTimed( request: AskUserQuestionRequest & { agent: Agent }, callId: Tool * Ask the scoped answerer waterfall and wait for the user's answer. * * When a caller supplies an agent, human interaction is valid only for the - * exact live runtime root. Runtime ownership, not durable session lineage, - * decides this boundary: an owned child has no human answerer and would - * block forever, while a lineage-bearing session resumed as a new runtime - * root may ask normally. + * exact live instance of an ordinary Session. Explicit subagent origin + * rejects delegated callers; historical ordinary forks may retain parent + * metadata and still ask normally. * * @param request Questions, owner agent, and abort signal. * @returns The answer chosen or typed by the human. * @throws {UserQuestionError} code `ASK_ABORTED` when the supplied signal * is already or becomes aborted, `CALLER_NOT_LIVE` when a supplied agent * is not the registry's exact live instance, or `DELEGATED_CALLER` when - * that live agent is owned by another agent. + * that live agent has subagent origin. */ async ask(request: AskUserQuestionRequest): Promise ``` diff --git a/docs/subsystems/user-questions.zh.md b/docs/subsystems/user-questions.zh.md index f433339045..ad39e1004f 100644 --- a/docs/subsystems/user-questions.zh.md +++ b/docs/subsystems/user-questions.zh.md @@ -278,17 +278,16 @@ async askTimed( request: AskUserQuestionRequest & { agent: Agent }, callId: Tool * Ask the scoped answerer waterfall and wait for the user's answer. * * When a caller supplies an agent, human interaction is valid only for the - * exact live runtime root. Runtime ownership, not durable session lineage, - * decides this boundary: an owned child has no human answerer and would - * block forever, while a lineage-bearing session resumed as a new runtime - * root may ask normally. + * exact live instance of an ordinary Session. Explicit subagent origin + * rejects delegated callers; historical ordinary forks may retain parent + * metadata and still ask normally. * * @param request Questions, owner agent, and abort signal. * @returns The answer chosen or typed by the human. * @throws {UserQuestionError} code `ASK_ABORTED` when the supplied signal * is already or becomes aborted, `CALLER_NOT_LIVE` when a supplied agent * is not the registry's exact live instance, or `DELEGATED_CALLER` when - * that live agent is owned by another agent. + * that live agent has subagent origin. */ async ask(request: AskUserQuestionRequest): Promise ``` 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/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 22deacea2a..1e5054c723 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: f790063d536da1bf - zh: 6933b54fc70cd470 + en: 17d0b1d1a3c15c7f + zh: 1a9d90fc99e0e848 /tool-schema-catalog/deepseek-ai-dsh-plugin-manager: en: e0143916bb634762 zh: e0143916bb634762 @@ -246,23 +246,26 @@ en: 841abe0dd5913ea7 zh: 841abe0dd5913ea7 /tool-schema-catalog/deepseek-ai-dsh-tool-subagent/list-subagent-models: - en: 207228068bc61885 - zh: 36e9344ee1af8869 + en: 0e21650bbe8c71c7 + zh: fe4033efb38d51a3 /tool-schema-catalog/deepseek-ai-dsh-tool-subagent/subagent: - en: 140c965c2b04d02f - zh: 95495dad5c77046d + en: 430672da3c1a6ca6 + zh: bd3a9ab4089f9dbb /tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control: en: ab82e130c3413f04 zh: ab82e130c3413f04 /tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control/interrupt-agent: - en: 9190a32df5e10ddd + en: a622b15cc2598d51 zh: 48e1b05956d63e26 /tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control/list-agents: - en: ae22e8543d123c39 - zh: 0991a73ef49bcdea + en: a56168133c21c6a0 + zh: f9df1c0fe49fa225 /tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control/send-message: - en: 9d180d8811903366 - zh: d63f94cffcb952d1 + en: b53326fedeab850d + zh: ecb75b6ce31da009 +/tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control/wait-agent: + en: f3cf5a0c56ddc07a + zh: 7f6754d71e1b4ae4 /tool-schema-catalog/deepseek-ai-dsh-tool-jobs: en: 9be673b1604d0114 zh: 9be673b1604d0114 @@ -273,38 +276,23 @@ en: 0c37fb1fed979d72 zh: 5ea9d07f5a4a8a6c /tool-schema-catalog/deepseek-ai-dsh-tool-jobs/job-output: - en: 1e084359a8cd42b3 - zh: 3f6c245dbbb2b920 -/tool-schema-catalog/deepseek-ai-dsh-experimental-tool-agent-team: - en: 6b1106f417b50db6 - zh: 6b1106f417b50db6 -/tool-schema-catalog/deepseek-ai-dsh-experimental-tool-agent-team/interrupt-agent: - en: c3c7af05ceb9f8c9 - zh: ec379e0b42110c6a -/tool-schema-catalog/deepseek-ai-dsh-experimental-tool-agent-team/list-agents: - en: f9457f033f75f00a - zh: a2d60ec44118c34c -/tool-schema-catalog/deepseek-ai-dsh-experimental-tool-agent-team/send-message: - en: ec50e6a0b86aecc6 - zh: 29c8728536a5adf1 -/tool-schema-catalog/deepseek-ai-dsh-experimental-tool-agent-team/spawn-teammate: - en: 02d6cc746e234b99 - zh: 49bc60ac098bca48 -/tool-schema-catalog/deepseek-ai-dsh-experimental-tool-agent-team/team-task-create: - en: 91e84febe063c3bd - zh: 550555d0f44f603b -/tool-schema-catalog/deepseek-ai-dsh-experimental-tool-agent-team/team-task-get: - en: 1c6ae0aba0b8041d - zh: f8d634387b74121d -/tool-schema-catalog/deepseek-ai-dsh-experimental-tool-agent-team/team-task-list: - en: 2084d494aae8798a - zh: e4c136c91028a7b1 -/tool-schema-catalog/deepseek-ai-dsh-experimental-tool-agent-team/team-task-update: - en: 2eac7360ba68b1bd - zh: b5d676bf288c245e -/tool-schema-catalog/deepseek-ai-dsh-experimental-tool-agent-team/wait-agent: - en: e70c77a45832bb28 - zh: 7d03bec294b65452 + en: 2977f731fb90d147 + zh: 4f422613ff720055 +/tool-schema-catalog/deepseek-ai-dsh-tool-agent-team: + en: 3f1a9c3509853972 + zh: 3f1a9c3509853972 +/tool-schema-catalog/deepseek-ai-dsh-tool-agent-team/team-task-create: + en: ac9ed819b705b4b8 + zh: 306477d2d457369a +/tool-schema-catalog/deepseek-ai-dsh-tool-agent-team/team-task-get: + en: bca1abf330638c19 + zh: 903bfa977f75c046 +/tool-schema-catalog/deepseek-ai-dsh-tool-agent-team/team-task-list: + en: 8001eda7e9ff6a25 + zh: 2414761029f21404 +/tool-schema-catalog/deepseek-ai-dsh-tool-agent-team/team-task-update: + en: 9acd1c5b2fac9853 + zh: 3bd81e9cd1f1c28d /tool-schema-catalog/deepseek-ai-dsh-tool-todo: en: dea62d26fb18ff68 zh: dea62d26fb18ff68 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 9da0029e4f..53cd390361 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -41,9 +41,9 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `ctx.agents`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. | | `@deepseek-ai/dsh-experimental-tool-subagent-oar` | `subagent_oar` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `parent subagent catalog and completion notices` | - | The subagent_oar tool selects among configured aliases for external providers. This catalog shows Codex and Claude Code aliases; each deployment chooses its providers. Native model and permission settings belong to the selected provider configuration. | | `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.sessionProjections`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The load-time `toolName` selects the delegation name (default `subagent`); `modelSelection` adds optional route fields. `modelDiscovery` defaults off and registers shared discovery on one instance per scope. Web and Desktop presets enable spawn discovery and route fields and leave `subagent_fork` route fields disabled. The Host settings service records authorization for each new top-level Session; child Sessions inherit it. Without a recorded policy, discovery and explicit selection permit only the current parent route. Every delegation returns a child id promptly; local children accept follow-ups, while external backends report their single execution automatically. | -| `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`, `list_agents`, `send_message` | `ctx.tools`, `ctx.subagents`, `ctx.agents` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The globally named discovery, interruption, and local-message tools over managed subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while one instance of this package registers `send_message`, `interrupt_agent`, and `list_agents`. | +| `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`, `list_agents`, `send_message`, `wait_agent` | `ctx.tools`, `ctx.subagents`, `ctx.agents` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The shared control tools discover and contact agents in the subagent tree, interrupt owned descendants, and wait for input in the caller’s inbox. Provider-bound `tool-subagent` instances supply delegation tools. | | `@deepseek-ai/dsh-tool-jobs` | `job_kill`, `job_list`, `job_output` | `ctx.tools`, `ctx.jobs`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-job controller: background bash commands and PTY sends are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. | -| `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`, `list_agents`, `send_message`, `spawn_teammate`, `team_task_create`, `team_task_get`, `team_task_list`, `team_task_update`, `wait_agent` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agentTeams`, `an exact live Team member Agent` | `tool/call`, `team/member`, `team/task`, `agent/inbox/spliced`, `user/message on inbox claim`, `tool/result` | - | All nine tools are scoped to implicit Team Leads and durable teammates. The shipped dsh-base bundle keeps the package disabled; the documented Agent Teams profile patch enables it while disabling the legacy continuable-child control names. | +| `@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` | - | 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. | @@ -2039,7 +2039,7 @@ The subagent_oar tool selects among configured aliases for external providers. T ### `list_subagent_models` -Inspect subagent models without changing the current Agent. No arguments: providers; `provider`: allowed models; `provider` + `model`: that model and its reasoning efforts. Inspect options here before selecting a child provider and model; use returned ids for delegation. +Inspect the routes available for explicit subagent model selection. No arguments: authorized providers. `provider`: that provider's authorized models. `provider` + `model`: that model and its reasoning efforts. Query when choosing an explicit child route or reasoning effort. Omit optional fields rather than sending empty strings. ```json { @@ -2061,7 +2061,7 @@ Source: [`packages/subagent/tool-subagent/src/list-models.ts`](../packages/subag ### `subagent` -Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. This tool starts an independently managed subagent and immediately returns its id. The runtime notifies you when it finishes. The child reports results with `send_message`; use `send_message` to steer it while running or continue its conversation after it finishes. +Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. This tool starts an independently managed subagent and immediately returns its id. If you are still online when it finishes, the runtime automatically delivers its completion notice. The notice includes the final answer unless the child sent you a message just before finishing. Use `send_message` to steer it while running or continue its conversation after it finishes. ```json { @@ -2073,7 +2073,7 @@ Delegate a self-contained task to a subagent (a separate agent that works in its }, "description": { "type": "string", - "description": "A short (3-5 word) description of the delegated task, for display." + "description": "A short (3-5 word) description of the delegated task, for display. Use the language of the user's current request." }, "prompt": { "type": "string", @@ -2097,7 +2097,7 @@ The load-time `toolName` selects the delegation name (default `subagent`); `mode ### `interrupt_agent` -Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local direct child's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running. +Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running. ```json { @@ -2118,21 +2118,12 @@ Source: [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/sub ### `list_agents` -List subagents you started, with their ids, labels, and status. running means it is working; inactive means it is not currently working. You will be notified when a subagent finishes; there is no need to keep checking its status. Use send_message to continue the conversation. +List your entire subagent tree: the root agent and all reachable continuable descendants, including yourself. Rows carry ids, descriptions, and status; descendant rows carry parent ids and depth from the root. You can message any other agent in the subagent tree listed as running or inactive. running means it is working; inactive means no turn is executing; it does not indicate task completion, success, or failure. While you remain active, you will be notified when a direct child finishes; there is no need to keep checking its status. Use send_message to continue the conversation. ```json { "type": "object", - "properties": { - "scope": { - "type": "string", - "description": "children (default) lists direct children, which accept send_message in any status. descendants lists the whole tree below you with each entry's parent session id and depth; entries deeper than 1 accept only interrupt_agent.", - "enum": [ - "children", - "descendants" - ] - } - } + "properties": {} } ``` @@ -2140,7 +2131,7 @@ Source: [`packages/subagent/tool-subagent-control/src/list-agents.ts`](../packag ### `send_message` -Send a message to an agent. A working agent receives it at its next step; an idle agent starts a new turn with it. Returns delivery confirmation, not the agent's answer. +Send a message to an agent. A working agent receives it at its next step; an idle agent starts a new turn with it. Returns delivery confirmation. ```json { @@ -2148,7 +2139,7 @@ Send a message to an agent. A working agent receives it at its next step; an idl "properties": { "agent_id": { "type": "string", - "description": "The agent id of your direct continuable child, or your direct parent when you are a resident continuable child." + "description": "The agent id of another member of your subagent tree, as shown by list_agents." }, "message": { "type": "string", @@ -2164,7 +2155,25 @@ Send a message to an agent. A working agent receives it at its next step; an idl Source: [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts) -The globally named discovery, interruption, and local-message tools over managed subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while one instance of this package registers `send_message`, `interrupt_agent`, and `list_agents`. +### `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. + +```json +{ + "type": "object", + "properties": { + "timeout_ms": { + "type": "integer", + "description": "Wait duration in milliseconds, from 10000 through 3600000. Defaults to 30000." + } + } +} +``` + +Source: [`packages/subagent/tool-subagent-control/src/wait-agent.ts`](../packages/subagent/tool-subagent-control/src/wait-agent.ts) + +The shared control tools discover and contact agents in the subagent tree, interrupt owned descendants, and wait for input in the caller’s inbox. Provider-bound `tool-subagent` instances supply delegation tools. @@ -2239,108 +2248,9 @@ Source: [`packages/jobs/tool-jobs/src/index.ts`](../packages/jobs/tool-jobs/src/ The kind-agnostic background-job controller: background bash commands and PTY sends are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. - + -## `@deepseek-ai/dsh-experimental-tool-agent-team` - -### `interrupt_agent` - -Interrupt one teammate's current turn while preserving its pending inbox. Team Lead only. - -```json -{ - "type": "object", - "properties": { - "target": { - "type": "string", - "description": "Teammate target returned by spawn_teammate or list_agents." - } - }, - "required": [ - "target" - ] -} -``` - -Source: [`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) - -### `list_agents` - -List the Lead and every durable teammate with an addressable target and current availability. inactive means no turn is executing, not a task result. provisioning and failed describe member creation. - -```json -{ - "type": "object", - "properties": {} -} -``` - -Source: [`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) - -### `send_message` - -Send one message to another Team member. A running target receives it at the nearest step boundary; an inactive target starts or resumes a turn. - -```json -{ - "type": "object", - "properties": { - "target": { - "type": "string", - "description": "Member target returned by spawn_teammate or list_agents, including lead." - }, - "message": { - "type": "string", - "description": "Self-contained message for the target." - } - }, - "required": [ - "target", - "message" - ] -} -``` - -Source: [`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) - -### `spawn_teammate` - -Create one named, durable teammate. Only the Team Lead may call this tool. - -```json -{ - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Unique lower-kebab-case teammate name." - }, - "description": { - "type": "string", - "description": "Short description of the delegated responsibility." - }, - "prompt": { - "type": "string", - "description": "Complete initial task for the teammate." - }, - "context": { - "type": "string", - "description": "fresh starts without Lead history; fork inherits completed Lead turns. Defaults to fresh.", - "enum": [ - "fresh", - "fork" - ] - } - }, - "required": [ - "name", - "description", - "prompt" - ] -} -``` - -Source: [`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) +## `@deepseek-ai/dsh-tool-agent-team` ### `team_task_create` @@ -2380,7 +2290,7 @@ Create one unowned pending task on the shared Team task board. } ``` -Source: [`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) +Source: [`packages/team/tool-agent-team/src/index.ts`](../packages/team/tool-agent-team/src/index.ts) ### `team_task_get` @@ -2401,7 +2311,7 @@ Read the complete latest value of one shared task before changing or executing i } ``` -Source: [`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) +Source: [`packages/team/tool-agent-team/src/index.ts`](../packages/team/tool-agent-team/src/index.ts) ### `team_task_list` @@ -2422,7 +2332,7 @@ List shared tasks, including readiness, owner, revision, blockers, and write-sco }, "owner": { "type": "string", - "description": "Optional member target from spawn_teammate or list_agents, matching ownerName; use unowned for tasks without an owner." + "description": "Optional exact ownerId filter; use unowned for tasks without an owner." }, "ready": { "type": "boolean", @@ -2440,7 +2350,7 @@ List shared tasks, including readiness, owner, revision, blockers, and write-sco } ``` -Source: [`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) +Source: [`packages/team/tool-agent-team/src/index.ts`](../packages/team/tool-agent-team/src/index.ts) ### `team_task_update` @@ -2496,7 +2406,7 @@ Compare-and-set a shared task action using the latest revision from team_task_ge }, "owner": { "type": "string", - "description": "Member target from spawn_teammate or list_agents for Lead-only reassign; omit to unassign." + "description": "Agent id of the root or any continuable descendant for root-only reassign; omit to unassign." } }, "required": [ @@ -2507,27 +2417,9 @@ Compare-and-set a shared task action using the latest revision from team_task_ge } ``` -Source: [`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) +Source: [`packages/team/tool-agent-team/src/index.ts`](../packages/team/tool-agent-team/src/index.ts) -### `wait_agent` - -Wait for the next teammate status, message, or shared-task change after this call starts. This never wakes inactive members and returns noProgress immediately when no other member is running or provisioning. Re-list after wakeup or timeout instead of polling. - -```json -{ - "type": "object", - "properties": { - "timeout_ms": { - "type": "integer", - "description": "Wait duration in milliseconds, from 10000 through 3600000. Defaults to 30000." - } - } -} -``` - -Source: [`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) - -All nine tools are scoped to implicit Team Leads and durable teammates. The shipped dsh-base bundle keeps the package disabled; the documented Agent Teams profile patch enables it while disabling the legacy continuable-child control names. +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. @@ -2778,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 19e6f8beb5..bdeaa78be6 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -45,9 +45,9 @@ | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`、`session_event_search`、`session_event_trace`、`session_search`、`session_trace` | `ctx.tools`、`ctx.systemPrompt`、`ctx.sessionQuery`、`a calling Agent for workspace authority` | `tool/call`、`tool/result` | - | 这 5 个只读工具会隐藏提供方游标,并根据不可变的调用 agent 会话为每个结果授权。该包需要选择启用;需要强制截止时间或限制行内输出的组合还会挂载通用超时或 spill 策略。 | | `@deepseek-ai/dsh-experimental-tool-subagent-oar` | `subagent_oar` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `parent subagent catalog and completion notices` | - | subagent_oar 工具在为外部提供方配置的别名之间选择。本目录展示 Codex 和 Claude Code 别名;每个部署自行选择提供方。原生模型和权限设置由所选提供方的配置决定。 | | `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`ctx.sessionProjections`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 加载时的 `toolName` 选择委派名称(默认为 `subagent`);`modelSelection` 增加可选路由字段。`modelDiscovery` 默认关闭,每个作用域由一个实例注册共享发现工具。Web 与 Desktop preset 启用 spawn 发现和路由字段,并为 `subagent_fork` 保持路由字段关闭。Host 设置服务为每个新顶层 Session 记录授权;子 Session 继承该授权。没有已记录策略时,发现和显式选择只允许当前父级路由。每次委派都会立即返回子代理 id;本地子代理接受后续消息,外部后端自动汇报单次执行结果。 | -| `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`、`list_agents`、`send_message` | `ctx.tools`、`ctx.subagents`、`ctx.agents` | `tool/call`、`tool/result`、`child session events through ctx.subagents` | - | 这些是用于发现、中断托管 subagent 和向本地子代理发送消息的全局命名工具:绑定提供方的 `tool-subagent` 实例注册不同的委派工具;本包的一个实例注册 `send_message`、`interrupt_agent` 和 `list_agents`。 | +| `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`、`list_agents`、`send_message`、`wait_agent` | `ctx.tools`、`ctx.subagents`、`ctx.agents` | `tool/call`、`tool/result`、`child session events through ctx.subagents` | - | 共享控制工具发现并联系 subagent 树中的 Agent,中断有权控制的后代,并等待调用者收件箱中的输入。绑定提供方的 `tool-subagent` 实例提供委派工具。 | | `@deepseek-ai/dsh-tool-jobs` | `job_kill`、`job_list`、`job_output` | `ctx.tools`、`ctx.jobs`、`ctx.systemPrompt` | `tool/call`、`tool/result`、`user/message via agent.inject() for background completion notices` | - | 与任务种类无关的后台任务控制器:后台 bash 命令与 PTY 发送 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 | -| `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`、`list_agents`、`send_message`、`spawn_teammate`、`team_task_create`、`team_task_get`、`team_task_list`、`team_task_update`、`wait_agent` | `ctx.tools`、`ctx.systemPrompt`、`ctx.agentTeams`、`an exact live Team member Agent` | `tool/call`、`team/member`、`team/task`、`agent/inbox/spliced`、`user/message on inbox claim`、`tool/result` | - | 这 9 个工具限定于隐式 Team Lead 与持久 teammate 作用域。随产品发布的 dsh-base bundle 默认禁用该包;文档中的 Agent Teams profile patch 会启用它,并禁用旧 continuable child 的同名控制工具。 | +| `@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` | - | 在 Web 和 Desktop 上通过变更请求与工作树组合包启用;其他 profile 可以选择独立组合包。在现有写权限下从固定的本地提交创建新分支与检出目录,再改变 Session 工作目录。离开时保留分支与检出目录。 | @@ -2045,7 +2045,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 ### `list_subagent_models` -查询 subagent 可用的模型,不更改当前 Agent。无参数:提供方;`provider`:允许的模型;`provider` + `model`:该模型及其推理强度。选择子级提供方和模型前先在此查询选项,再将返回的 id 用于委派。 +查询允许为 subagent 显式选择的路由。无参数:提供方;`provider`:允许的模型;`provider` + `model`:该模型及其推理强度。选择子级提供方和模型前先在此查询选项,再将返回的 id 用于委派。 ```json { @@ -2067,7 +2067,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 ### `subagent` -将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。此工具启动独立管理的 subagent 并立即返回其 id。运行时会在任务结束时通知你。子代理通过 `send_message` 汇报结果;你可以使用 `send_message` 在运行时引导它,或在结束后继续对话。 +将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。此工具启动独立管理的 subagent 并立即返回其 id。如果它结束时你仍在线,运行时会自动递送其完成通知。通知包含最终回答,除非子任务在结束前刚给你发过消息。你可以使用 `send_message` 在运行时引导它,或在结束后继续对话。 ```json { @@ -2079,7 +2079,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 }, "description": { "type": "string", - "description": "A short (3-5 word) description of the delegated task, for display." + "description": "A short (3-5 word) description of the delegated task, for display. Use the language of the user's current request." }, "prompt": { "type": "string", @@ -2124,21 +2124,12 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 ### `list_agents` -按持久 ID 和标签列出调用方目录中可继续的直接子代理。使用此工具回忆已启动的子代理,不要轮询完成状态;完成时会收到通知。状态来自在线注册表:running 表示当前正在工作;inactive 表示当前没有执行轮次,无论子代理已加载还是需要恢复,并不表示任务完成、成功、失败或正在等待其他代理。`send_message` 可在最近的步骤边界引导正在运行的子代理,或启动、恢复非活跃子代理的轮次;任何状态下的直接子级都可作为 `send_message` 的候选目标。列表快照不保证投递成功,`send_message` 仍进行权威检查,且可能失败。 +列出整棵 subagent 树:根代理和所有可达的可继续后代,包括调用方自身。行包含 id、描述和状态;后代行包含父级 id 和相对根的深度。可以向树中列为 running 或 inactive 的任意其他代理发送消息。running 表示正在工作;inactive 表示当前没有轮次在执行,不表示任务完成、成功或失败。自身仍在线时,直属子代理结束会收到通知,无需反复查询其状态。使用 send_message 继续对话。 ```json { "type": "object", - "properties": { - "scope": { - "type": "string", - "description": "children (default) lists direct children, which accept send_message in any status. descendants lists the whole tree below you with each entry's parent session id and depth; entries deeper than 1 accept only interrupt_agent.", - "enum": [ - "children", - "descendants" - ] - } - } + "properties": {} } ``` @@ -2146,7 +2137,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 ### `send_message` -根据 agent id 向直接可继续 child 发送消息。如果你是驻留的可继续 child,也可以把自己的直接 parent 作为目标。如果目标仍在工作,消息会 steer 其最近的 step;如果目标处于 idle,消息会启动一个轮次。此调用不会返回该 agent 的答案,只会确认消息已投递。调用失败表示消息**未**投递。 +向代理发消息。工作中的代理在下一步骤接收消息,空闲代理用消息启动新轮次。返回投递确认。 ```json { @@ -2154,7 +2145,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 "properties": { "agent_id": { "type": "string", - "description": "The agent id of your direct continuable child, or your direct parent when you are a resident continuable child." + "description": "The agent id of another member of your subagent tree, as shown by list_agents." }, "message": { "type": "string", @@ -2170,7 +2161,25 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 来源:[`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts) -这些是用于发现、中断托管 subagent 和向本地子代理发送消息的全局命名工具:绑定提供方的 `tool-subagent` 实例注册不同的委派工具;本包的一个实例注册 `send_message`、`interrupt_agent` 和 `list_agents`。 +### `wait_agent` + +等待下一步可处理的输入,包括 Agent 消息、完成通知和用户引导。已有此类待处理输入时立即返回。 + +```json +{ + "type": "object", + "properties": { + "timeout_ms": { + "type": "integer", + "description": "Wait duration in milliseconds, from 10000 through 3600000. Defaults to 30000." + } + } +} +``` + +来源:[`packages/subagent/tool-subagent-control/src/wait-agent.ts`](../packages/subagent/tool-subagent-control/src/wait-agent.ts) + +共享控制工具发现并联系 subagent 树中的 Agent,中断有权控制的后代,并等待调用者收件箱中的输入。绑定提供方的 `tool-subagent` 实例提供委派工具。 @@ -2245,108 +2254,9 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 与任务种类无关的后台任务控制器:后台 bash 命令与 PTY 发送 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 - + -## `@deepseek-ai/dsh-experimental-tool-agent-team` - -### `interrupt_agent` - -中断一名 teammate 的当前 turn,同时保留其待处理 inbox。仅 Team Lead 可用。 - -```json -{ - "type": "object", - "properties": { - "target": { - "type": "string", - "description": "Teammate target returned by spawn_teammate or list_agents." - } - }, - "required": [ - "target" - ] -} -``` - -来源:[`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) - -### `list_agents` - -列出 Lead 与所有持久 teammate,以及可用于寻址的 target 和当前可用状态。inactive 表示没有轮次在执行,不表示任务结果。provisioning 与 failed 描述成员创建状态。 - -```json -{ - "type": "object", - "properties": {} -} -``` - -来源:[`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) - -### `send_message` - -向另一名 Team member 发送一条消息。running target 会在最近的步骤边界收到消息;inactive target 会启动或恢复一个 turn。 - -```json -{ - "type": "object", - "properties": { - "target": { - "type": "string", - "description": "Member target returned by spawn_teammate or list_agents, including lead." - }, - "message": { - "type": "string", - "description": "Self-contained message for the target." - } - }, - "required": [ - "target", - "message" - ] -} -``` - -来源:[`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) - -### `spawn_teammate` - -创建一名具名、持久的 teammate。只有 Team Lead 可以调用此工具。 - -```json -{ - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Unique lower-kebab-case teammate name." - }, - "description": { - "type": "string", - "description": "Short description of the delegated responsibility." - }, - "prompt": { - "type": "string", - "description": "Complete initial task for the teammate." - }, - "context": { - "type": "string", - "description": "fresh starts without Lead history; fork inherits completed Lead turns. Defaults to fresh.", - "enum": [ - "fresh", - "fork" - ] - } - }, - "required": [ - "name", - "description", - "prompt" - ] -} -``` - -来源:[`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) +## `@deepseek-ai/dsh-tool-agent-team` ### `team_task_create` @@ -2386,7 +2296,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 } ``` -来源:[`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) +来源:[`packages/team/tool-agent-team/src/index.ts`](../packages/team/tool-agent-team/src/index.ts) ### `team_task_get` @@ -2407,7 +2317,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 } ``` -来源:[`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) +来源:[`packages/team/tool-agent-team/src/index.ts`](../packages/team/tool-agent-team/src/index.ts) ### `team_task_list` @@ -2428,7 +2338,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 }, "owner": { "type": "string", - "description": "Optional member target from spawn_teammate or list_agents, matching ownerName; use unowned for tasks without an owner." + "description": "Optional exact ownerId filter; use unowned for tasks without an owner." }, "ready": { "type": "boolean", @@ -2446,7 +2356,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 } ``` -来源:[`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) +来源:[`packages/team/tool-agent-team/src/index.ts`](../packages/team/tool-agent-team/src/index.ts) ### `team_task_update` @@ -2502,7 +2412,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 }, "owner": { "type": "string", - "description": "Member target from spawn_teammate or list_agents for Lead-only reassign; omit to unassign." + "description": "Agent id of the root or any continuable descendant for root-only reassign; omit to unassign." } }, "required": [ @@ -2513,28 +2423,9 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 } ``` -来源:[`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) - -### `wait_agent` - -等待本次调用开始后下一次 teammate 状态、消息或共享任务变更。它绝不会唤醒 inactive member;若没有其他 member 正在 running 或 provisioning,则立即返回 noProgress。唤醒或超时后应重新列出状态,而不是轮询。 - -```json -{ - "type": "object", - "properties": { - "timeout_ms": { - "type": "integer", - "description": "Wait duration in milliseconds, from 10000 through 3600000. Defaults to 30000." - } - } -} -``` - -来源:[`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) - -这 10 个工具限定于隐式 Team Lead 与持久 teammate 作用域。随产品发布的 dsh-base bundle 默认禁用该包;文档中的 Agent Teams profile patch 会启用它,并禁用旧 continuable child 的同名控制工具。 +来源:[`packages/team/tool-agent-team/src/index.ts`](../packages/team/tool-agent-team/src/index.ts) +四个任务板工具共享 subagent 树的根 Session。在根 Session 开启 Team 模式,会在标准子代理委派和控制工具之外增加任务板指导。标准子代理控制工具提供必需身份,不受 Team 模式影响。 @@ -2786,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/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.i18n.yaml index 0f10d46677..fee15bfbc4 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.i18n.yaml @@ -12,5 +12,5 @@ en: 9968decf3dd340af zh: 736a32caac4b5103 /agent-teams-messages-use-target-inboxes/migration: - en: 7ffd442bfd55769e - zh: ffb11a143f11ce7f + en: a4b8999943c76cc4 + zh: 042927b5c857c8d5 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.md index 974c1aa13c..d2e03eacd5 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.md +++ b/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.md @@ -18,4 +18,4 @@ The `maxPendingMessagesPerMember` configuration key is removed. Historical `team 1. Before upgrading, let pending Team messages finish and confirm that their targets received them. After upgrading, explicitly resend any required undelivered message; check the target's history first to avoid duplicates. Do not edit historical Session records. 2. Remove `maxPendingMessagesPerMember` from the `@deepseek-ai/dsh-experimental-agent-team` plugin configuration in your `cordis.yml` or overlays. There is no replacement Team queue limit; `maxMessageBytes` still bounds each complete sender-framed message. 3. Update any consumer of `send_message` results to read `sent` instead of message IDs or delivery statuses, and handle send errors explicitly. Do not treat success as completed target work. The `ctx.agentTeams.sendMessage()` service still returns `messageId`. -4. Start your Team-enabled profile, send a message to another member, and confirm that the tool returns `{ "sent": true }` and that the target receives it. Consult the [package reference](../../../../packages/experimental/agent-team/README.md#use-this-package) for delivery semantics. +4. Start your Team-enabled profile, send a message to another member, and confirm that the tool returns `{ "sent": true }` and that the target receives it. Consult the [package reference](../../../../packages/team/agent-team/README.md#use-this-package) for delivery semantics. diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.zh.md index c10791fbf7..1f5014e0c7 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.zh.md @@ -18,4 +18,4 @@ description: "Agent Teams 移除待投递消息数量限制,并停止投递历 1. 升级前,让待投递的 Team 消息完成投递,并确认目标已收到。升级后,显式重发仍需处理的未投递消息;先检查目标历史,避免重复。不要编辑历史 Session 记录。 2. 从 `cordis.yml` 或覆盖配置中 `@deepseek-ai/dsh-experimental-agent-team` 插件的配置移除 `maxPendingMessagesPerMember`。没有替代的 Team 队列限制;`maxMessageBytes` 仍限制包含发送者标识的完整消息大小。 3. 更新所有读取 `send_message` 结果的调用方,改为读取 `sent` 而非消息 ID 或投递状态,并显式处理发送错误。不要把发送成功视为目标工作已完成。`ctx.agentTeams.sendMessage()` 服务仍返回 `messageId`。 -4. 启动启用 Team 的 profile,向其他成员发送消息,确认工具返回 `{ "sent": true }` 且目标收到消息。投递语义参见[包参考](../../../../packages/experimental/agent-team/README.zh.md#use-this-package)。 +4. 启动启用 Team 的 profile,向其他成员发送消息,确认工具返回 `{ "sent": true }` 且目标收到消息。投递语义参见[包参考](../../../../packages/team/agent-team/README.zh.md#use-this-package)。 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/agent-runtime-owner/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/agent-runtime-owner/guide.i18n.yaml new file mode 100644 index 0000000000..2374c0b447 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/agent-runtime-owner/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.2/agent-runtime-owner/guide.md +/: + en: ced4d23952f772d5 + zh: f8e7e8c069266fa1 +/agent-runtime-ownership-uses-owneragent: + en: 8a1164f53b3627da + zh: 19fae50e43c4dca4 +/agent-runtime-ownership-uses-owneragent/change: + en: 88457ac32ffc2c7d + zh: 05ea7db957db119b +/agent-runtime-ownership-uses-owneragent/migration: + en: d25ad901f4601369 + zh: 96b556991249635b diff --git a/docs/upgrade-guide/v0.2.1-rc.2/agent-runtime-owner/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/agent-runtime-owner/guide.md new file mode 100644 index 0000000000..4648667d9e --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/agent-runtime-owner/guide.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "Agent creation and resume options rename parentAgent to ownerAgent for runtime ownership." +--- + +# Agent runtime ownership uses `ownerAgent` + +English | [中文](guide.zh.md) + +## Change + +`CreateAgentOptions` and `ResumeAgentOptions` in `@deepseek-ai/dsh-agent` replace `parentAgent` with `ownerAgent`. This affects plugins and custom factories calling `ctx.agents.create()` or `ctx.agents.resume()`. The field selects the live Agent that owns the runtime instance; omitting it creates a runtime root. + +Session creation lineage remains in `meta.parentSession`. For a nested subagent, the runtime owner can be the tree root while the direct creator is another descendant. Persisted Session fields and formats are unchanged. + +## Migration + +1. Rename the `parentAgent` option to `ownerAgent` in creation and resume calls, and update custom `AgentFactory` implementations that read or forward it. Pass the same Agent as before. +2. Keep `meta.parentSession` and stored `parentSession` values unchanged. Do not substitute the runtime owner for the direct creator. +3. Typecheck consumers against the updated package, then verify that `ctx.agents.isOwnedBy(id, owner)` and `ctx.agents.roots()` report the same runtime relationships after creation and resume. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/agent-runtime-owner/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/agent-runtime-owner/guide.zh.md new file mode 100644 index 0000000000..b1e36efd9f --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/agent-runtime-owner/guide.zh.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "Agent 创建和恢复选项将表示运行归属的 parentAgent 改名为 ownerAgent。" +--- + +# Agent 运行归属使用 `ownerAgent` + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-agent` 中的 `CreateAgentOptions` 和 `ResumeAgentOptions` 将 `parentAgent` 替换为 `ownerAgent`。这影响调用 `ctx.agents.create()` 或 `ctx.agents.resume()` 的插件与自定义工厂。该字段指定拥有运行实例的在线 Agent;省略时创建运行时根。 + +Session 创建来源仍由 `meta.parentSession` 记录。嵌套子代理的运行归属者可以是树根,而直接创建者是另一个后代。持久化 Session 字段和格式保持不变。 + +## 迁移 + +1. 将创建和恢复调用中的 `parentAgent` 选项改名为 `ownerAgent`,并更新读取或透传该字段的自定义 `AgentFactory` 实现。继续传入原来的 Agent。 +2. 保持 `meta.parentSession` 和存储的 `parentSession` 值不变。不要用运行归属者替换直接创建者。 +3. 针对更新后的包对消费者运行类型检查,再验证创建和恢复后的 `ctx.agents.isOwnedBy(id, owner)` 与 `ctx.agents.roots()` 仍报告相同的运行归属关系。 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.i18n.yaml new file mode 100644 index 0000000000..1b127d0026 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/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.2/agent-team-subagents/guide.md +/: + en: 1f073189776760d8 + zh: cfbb0403aea96f84 +/agent-teams-uses-subagents-and-session-ids: + en: 703c7766dd80e1d1 + zh: 157c67164c1cbc7d +/agent-teams-uses-subagents-and-session-ids/change: + en: d387c95a87d1bd6a + zh: 69dd000fbff0b6fa +/agent-teams-uses-subagents-and-session-ids/migration: + en: dcc698d29a4677f0 + zh: 2af5cea905ad4371 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.md new file mode 100644 index 0000000000..dedc7025fb --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.md @@ -0,0 +1,31 @@ +--- +kind: upgrade-guide +description: "Agent Teams uses the standard subagent tools and Session ids; Team-specific creation, messaging, listing, interruption, and their configuration keys are removed." +--- + +# Agent Teams uses subagents and Session ids + +English | [中文](guide.zh.md) + +## Change + +Descriptions label responsibilities; only Session ids identify recipients. + +- `spawn_teammate` and the Team-scoped `send_message`, `list_agents`, and `interrupt_agent` are removed. The standard control tools use `agent_id`, not `target`. Members of one root tree can message each other across branches by id; interruption remains ancestor-only. +- `list_agents` takes no arguments and lists the entire tree. Remove scripted `scope` arguments. Rows use `kind: root`, `kind: child`, or `kind: diagnostic`; descendants include parent ids and root-relative depth. +- Team supplies four task tools and policy; controls supply required identity. `wait_agent` moves to ordinary subagent controls and waits for the caller’s next-step inbox, including pending input. Status changes and unrelated inboxes do not wake it; `noProgress` is removed. Timeout bounds/default remain unchanged. +- `agent-team` rejects `maxMembers`, `maxMessageBytes`, and `disposalTimeoutMs`; `tool-agent-team` rejects `interrupt`, `freshProvider`, and `forkProvider`. +- `ctx.agentTeams` removes creation, messaging, interruption, roster, membership, and wait APIs. `TeamMembership`, `TeamMemberView`, spawn/send request/result types, and `TeamWaitResult` are removed. Task ownership uses `ownerId`, including the root’s Session id. +- Runtime instances share root ownership without changing creation ancestry. Member settlement does not dispose descendants. Messages restore only their target. Hosts close the tree when its root closes; members obtain required results explicitly. +- Cold resume uses saved presets and permissions, not the creator’s current generation. Missing presets fail restoration; historical targets without one use host composition plus descriptor overrides. Lifecycle dispatch uses the root scope while retaining the direct creator in `parentSessionId`. Browser continuation requires the root online; failure uses `subagent/delivery-unavailable`. +- Historical Team roster and mailbox records remain readable. Historical names do not resolve message recipients or task owners. Subagent catalog payloads are unchanged. +- The Web Team panel shows tasks only; subagent navigation owns member browsing. The wire `agentTeam.members` field and `TeamMemberProjection` are removed; persisted roster state still decodes. Task commits do not end waits, deliver messages, or start idle Agents. Message another Agent when it needs to act on task changes. + +## Migration + +1. Move `interrupt: false` from `tool-agent-team` to `tool-subagent-control`. Remove other obsolete keys and any patch rows that disable subagent tools solely for Team support. +2. Replace `spawn_teammate({ name, description, prompt, context })` with `subagent({ description, prompt })`, or `subagent_fork` for inherited conversation history. Save the returned agent id. +3. Use `send_message({ agent_id, message })` and `interrupt_agent({ agent_id })`. Discover all Team member ids with `list_agents`. +4. Pass member Session ids to task `owner` fields and read `ownerId` in results. Use the Lead's Session id instead of the alias `lead`. +5. Replace `spawnTeammate`, `sendMessage`, and `interrupt` with `ctx.subagents.startActivation`, `sendMessage`, and `interrupt`. Replace `membership`/`tryMembership` with `rootOf`, and `listMembers` with `listDescendants`. Replace `waitForChange` with `wait_agent`, then re-read tasks. +6. SDK `session/resume` rejects subagent sessions. Resume the root Session instead. Verify navigation, nested task assignment, and that messages end inbox waits while task commits do not. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.zh.md new file mode 100644 index 0000000000..177831b543 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.zh.md @@ -0,0 +1,31 @@ +--- +kind: upgrade-guide +description: "Agent Teams 使用标准 subagent 工具和 Session id;移除 Team 专属创建、消息、列举、中断操作及其配置键。" +--- + +# Agent Teams 使用子代理和 Session id + +[English](guide.md) | 中文 + +## 变更 + +描述标注职责;仅 Session id 标识接收者。 + +- 移除 `spawn_teammate` 以及 Team 作用域的 `send_message`、`list_agents` 和 `interrupt_agent`。标准控制工具使用 `agent_id` 而非 `target`。同一根下的成员可以通过 id 跨分支互发消息;中断仍只允许祖先执行。 +- `list_agents` 不接受参数,列出整棵树。移除脚本中的 `scope` 参数。行使用 `kind: root`、`kind: child` 或 `kind: diagnostic`;后代包含父 id 和相对根的深度。 +- Team 提供四个任务工具和策略;控制工具提供必需身份。`wait_agent` 移至普通 subagent 控制工具,等待调用者的下一步收件箱,包括待处理输入。状态变化和其他收件箱不会唤醒它;移除 `noProgress`。超时范围和默认值不变。 +- `agent-team` 拒绝 `maxMembers`、`maxMessageBytes` 和 `disposalTimeoutMs`;`tool-agent-team` 拒绝 `interrupt`、`freshProvider` 和 `forkProvider`。 +- `ctx.agentTeams` 移除创建、通信、中断、名册、成员关系和等待 API。`TeamMembership`、`TeamMemberView`、spawn/send 请求和结果类型以及 `TeamWaitResult` 被移除。任务归属使用 `ownerId`,包括根的 Session id。 +- 运行时实例共享根所有权,不改变创建祖先关系。成员收尾不释放后代。消息仅恢复目标。根关闭时 host 关闭整棵树;成员显式获取所需结果。 +- 冷恢复使用已保存的 preset 和权限,而非创建者当前代次。缺失 preset 会导致恢复失败;历史目标没有保存 preset 时,使用 host 组合及描述符覆盖。生命周期分派使用根作用域,同时在 `parentSessionId` 中保留直接创建者。浏览器继续执行需要根在线;失败使用 `subagent/delivery-unavailable`。 +- 历史 Team 名册和邮箱记录仍可读取。其中的名字仅作为历史数据,不用于解析消息收件人或任务所有者。Subagent catalog 载荷不变。 +- Web Team 面板仅显示任务;subagent 导航负责浏览成员。线上 `agentTeam.members` 字段与 `TeamMemberProjection` 被移除;持久化成员状态仍可解码。任务提交不结束等待、不投递消息,也不启动空闲 Agent。需要另一个 Agent 处理任务变更时,应发送消息。 + +## 迁移 + +1. 将 `interrupt: false` 从 `tool-agent-team` 移到 `tool-subagent-control`。删除其余废弃键,以及仅为支持 Team 而禁用 subagent 工具的 patch 行。 +2. 将 `spawn_teammate({ name, description, prompt, context })` 替换为 `subagent({ description, prompt })`;继承对话历史时使用 `subagent_fork`。保存返回的 agent id。 +3. 使用 `send_message({ agent_id, message })` 和 `interrupt_agent({ agent_id })`。通过 `list_agents` 查询所有 Team 成员的 id。 +4. 任务的 `owner` 字段传入成员 Session id,结果读取 `ownerId`。以 Lead 的 Session id 取代 `lead` 别名。 +5. 将 `spawnTeammate`、`sendMessage` 和 `interrupt` 替换为 `ctx.subagents.startActivation`、`sendMessage` 和 `interrupt`。将 `membership`/`tryMembership` 替换为 `rootOf`,将 `listMembers` 替换为 `listDescendants`。将 `waitForChange` 替换为 `wait_agent`,随后重新读取任务。 +6. SDK `session/resume` 拒绝子代理会话。改为恢复根 Session。验证导航、嵌套任务分配,以及消息结束收件箱等待而任务提交不会。 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.i18n.yaml new file mode 100644 index 0000000000..badcbe4e11 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/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.2/session-team-mode/guide.md +/: + en: 2b713df685f8b593 + zh: 4e185e636a3b5d99 +/select-agent-teams-per-session: + en: 9e82418f46f6275b + zh: d1282eec79b37b59 +/select-agent-teams-per-session/change: + en: 3ba5920179726841 + zh: 940e0d956a523dbc +/select-agent-teams-per-session/migration: + en: 7b6dc660602d9c3b + zh: 8f8459198300aa46 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.md new file mode 100644 index 0000000000..8f275535b6 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.md @@ -0,0 +1,22 @@ +--- +kind: upgrade-guide +description: "Agent Teams defaults off for new Sessions and uses a saved per-Session selection." +--- + +# Select Agent Teams per Session + +English | [中文](guide.zh.md) + +## Change + +Agent Teams ships with base-backed profiles; new Sessions default to off. The Plugins page has no separate Agent Teams switch. The root Session’s selection controls task tools and Team guidance throughout its subagent tree. The model and reasoning menu offers **Use Agent Teams**, and the root conversation’s Subagent header control opens members above shared tasks in one panel while enabled. Child conversations retain navigation without a shared task board. + +Disabling Team retains tasks and subagents. Admitted task operations finish; queued operations check the current mode when they start. The selection survives restart. A historical task board without a saved selection remains enabled. The required `team/mode` event records selections; older readers reject this unfamiliar event. See the [persistence acknowledgement](../../../persistence-changes/2026-10-19-session-team-mode.md). + +## Migration + +1. Start each existing profile once. Profile loading removes the retired `@deepseek-ai/dsh-experimental-agent-team-profile` bundle selection; the base and Web bundles supply its plugins. Keep `agent-team`, `tool-agent-team`, and `ui-agent-team` row overrides. Custom compositions replace package names `@deepseek-ai/dsh-experimental-agent-team`, `@deepseek-ai/dsh-experimental-tool-agent-team`, and `@deepseek-ai/dsh-experimental-client-ui-agent-team` with `@deepseek-ai/dsh-agent-team`, `@deepseek-ai/dsh-tool-agent-team`, and `@deepseek-ai/dsh-client-ui-agent-team`. +2. Toggle **Use Agent Teams** in the root conversation’s model and reasoning menu. The `/team on` and `/team off` commands are removed. +3. Verify that enabling shows shared tasks in the root conversation below the Subagent tree in the same panel and exposes four task tools. Reload the Session to confirm the saved selection. Re-enabling after disabling restores the existing tasks. + +Client plugin consumers replace `TeamActionProps` with `TeamBoardProps`; task content registers in `conversation.session.header.actions.subagent.footer` instead of the header actions slot. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.zh.md new file mode 100644 index 0000000000..65f149a2f2 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.zh.md @@ -0,0 +1,22 @@ +--- +kind: upgrade-guide +description: "Agent Teams 在新 Session 中默认关闭,并使用按 Session 保存的选择。" +--- + +# 按 Session 选择智能体团队 + +[English](guide.md) | 中文 + +## 变更 + +Agent Teams 随基于 base 的 profile 提供;新 Session 默认关闭。插件页不再提供独立的智能体团队开关。根 Session 的选择控制整棵子代理树中的任务工具和 Team 提示词。模型与推理等级菜单提供**使用智能体团队**开关,开启后通过根会话的 Subagent 页头入口,在同一面板中查看上方成员与下方共享任务。子会话保留导航,不显示共享任务板。 + +关闭 Team 保留任务和子代理。已经进入执行的任务操作继续完成;排队中的操作在开始时检查当前模式。选择在重启后仍有效。没有保存选择的历史任务板保持开启。必读事件 `team/mode` 记录选择;旧读取器会拒绝该未知事件。参见[持久化变更确认](../../../persistence-changes/2026-10-19-session-team-mode.zh.md)。 + +## 迁移 + +1. 启动每个已有 profile 一次。加载时会移除已停用的 `@deepseek-ai/dsh-experimental-agent-team-profile` 组合选项;base 与 Web 组合提供对应插件。保留 `agent-team`、`tool-agent-team` 与 `ui-agent-team` 行覆盖。自定义组合将包名 `@deepseek-ai/dsh-experimental-agent-team`、`@deepseek-ai/dsh-experimental-tool-agent-team`、`@deepseek-ai/dsh-experimental-client-ui-agent-team` 分别替换为 `@deepseek-ai/dsh-agent-team`、`@deepseek-ai/dsh-tool-agent-team`、`@deepseek-ai/dsh-client-ui-agent-team`。 +2. 在根会话的模型与推理等级菜单中切换**使用智能体团队**。`/team on` 与 `/team off` 命令已移除。 +3. 在根会话中确认开启后通过 Subagent 入口在树下方查看共享任务板,并提供四个任务工具。重新加载 Session,确认选择已保存。关闭后再开启会恢复已有任务。 + +Client 插件消费者将 `TeamActionProps` 替换为 `TeamBoardProps`;任务内容注册到 `conversation.session.header.actions.subagent.footer`,而非页头操作插槽。 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/subagent-activation-options/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/subagent-activation-options/guide.i18n.yaml new file mode 100644 index 0000000000..12139cd087 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/subagent-activation-options/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.2/subagent-activation-options/guide.md +/: + en: b087162d9a24f772 + zh: a32f167c3529fb1c +/subagent-activation-options: + en: 20a5b01ef11bff72 + zh: 919de21bc865ee9e +/subagent-activation-options/change: + en: 7cc2554992e98c53 + zh: 434540bcd037d1f5 +/subagent-activation-options/migration: + en: 2243d08661eb457c + zh: a928a8e7fb3515ff diff --git a/docs/upgrade-guide/v0.2.1-rc.2/subagent-activation-options/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/subagent-activation-options/guide.md new file mode 100644 index 0000000000..3f2a202b0c --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/subagent-activation-options/guide.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "SubagentActivationSpec accepts task options directly instead of a nested request object." +--- + +# Subagent activation options + +English | [中文](guide.zh.md) + +## Change + +Plugin code calling `ctx.subagents.startActivation()` must pass `prompt`, `parent`, and optional execution settings directly on `SubagentActivationSpec`. The nested `request` field is removed. Type names, provider requests, cancellation, and result delivery are unchanged. + +## Migration + +1. In each `startActivation()` call, move the fields from `request` to the outer object. For an existing task object, replace `request: task` with `...task`; keep the activation's `label` and `signal` after the spread so they take precedence. +2. Replace reads such as `spec.request.prompt` with `spec.prompt`. Keep backend `SubagentStartRequest` implementations unchanged. +3. Typecheck the plugin and run its delegation tests to verify the updated calls. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/subagent-activation-options/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/subagent-activation-options/guide.zh.md new file mode 100644 index 0000000000..32c2785bcc --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/subagent-activation-options/guide.zh.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "SubagentActivationSpec 直接接受任务选项,不再使用嵌套的 request 对象。" +--- + +# 子代理 activation 选项 + +[English](guide.md) | 中文 + +## 变更 + +调用 `ctx.subagents.startActivation()` 的插件代码必须将 `prompt`、`parent` 和可选执行设置直接放在 `SubagentActivationSpec` 上。嵌套的 `request` 字段已移除。类型名称、提供方请求、取消和结果投递保持不变。 + +## 迁移 + +1. 在每个 `startActivation()` 调用中,将 `request` 内的字段移到外层对象。已有任务对象时,将 `request: task` 替换为 `...task`;将 activation 的 `label` 和 `signal` 放在展开操作之后,以确保它们优先生效。 +2. 将 `spec.request.prompt` 等读取替换为 `spec.prompt`。保持后端的 `SubagentStartRequest` 实现不变。 +3. 对插件运行类型检查和委派测试,验证更新后的调用。 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/subagent-service-methods/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/subagent-service-methods/guide.i18n.yaml new file mode 100644 index 0000000000..464cd3ccbe --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/subagent-service-methods/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.2/subagent-service-methods/guide.md +/: + en: 6955b11d6f698e4c + zh: 8a8d4d6edd5dea0c +/subagent-service-method-removal: + en: 102f43348f7c8eb0 + zh: be09ff117c31ada1 +/subagent-service-method-removal/change: + en: 8cd7f93df401c417 + zh: 27313127c2059e6b +/subagent-service-method-removal/migration: + en: f0f0f921f284efec + zh: 8b9e88e9412282c2 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/subagent-service-methods/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/subagent-service-methods/guide.md new file mode 100644 index 0000000000..a1eaad618b --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/subagent-service-methods/guide.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "The subagent service removes drainChildren, listChildren, and list." +--- + +# Subagent service method removal + +English | [中文](guide.zh.md) + +## Change + +`ctx.subagents` removes `drainChildren(parent, childIds)`, `listChildren(parentSessionId, signal?)`, and `list()`. This affects plugins that release resident children by id, read direct-child catalogs through the service, or enumerate registered provider names. + +## Migration + +1. Retain the activation returned by `startActivation()` and await its `dispose()` to release that exact execution. An old activation cannot release a later execution of the same Session. There is no replacement for releasing the current resident child by id; use `drainDescendants([root])` only when closing the entire tree. `interrupt()` stops current work but preserves a local Activation. +2. For direct-child entries, use `ctx.sessionQuery.observeSession(parentSessionId, { signal })`, read `observation.projections?.values.subagentCatalog`, and dispose the observation with `using`. Load Session Query and the subagent service, and treat a missing catalog projection as unavailable. Keep `listDescendants()` for recursive discovery; its rows differ from direct catalog entries. +3. Use `getProvider(name)` to check known provider names. The service no longer enumerates all registered names. +4. Rebuild plugins against the updated service declarations. Verify registration and unload checks, exact-activation disposal, and catalog observation cleanup. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/subagent-service-methods/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/subagent-service-methods/guide.zh.md new file mode 100644 index 0000000000..ce24ef1b90 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/subagent-service-methods/guide.zh.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "子代理服务移除 drainChildren、listChildren 和 list。" +--- + +# 子代理服务方法移除 + +[English](guide.md) | 中文 + +## 变更 + +`ctx.subagents` 移除 `drainChildren(parent, childIds)`、`listChildren(parentSessionId, signal?)` 和 `list()`。这会影响按 id 释放驻留子代理、通过服务读取直接子级目录或枚举已注册 provider 名称的插件。 + +## 迁移 + +1. 保留 `startActivation()` 返回的 activation,并等待其 `dispose()` 完成以释放那一次执行。旧 activation 不能释放同一 Session 后来恢复的执行。按 id 释放当前驻留子代理没有替代方法;仅在关闭整棵树时使用 `drainDescendants([root])`。`interrupt()` 停止当前工作,但保留本地 Activation。 +2. 读取直接子级条目时,使用 `ctx.sessionQuery.observeSession(parentSessionId, { signal })`,读取 `observation.projections?.values.subagentCatalog`,并通过 `using` 释放观察对象。加载 Session Query 和子代理服务,并将缺失的目录投影视为不可用。递归发现继续使用 `listDescendants()`;它返回的行与直接目录条目不同。 +3. 使用 `getProvider(name)` 检查已知 provider 名称。服务不再枚举全部已注册名称。 +4. 根据更新后的服务声明重新构建插件,验证注册和卸载检查、指定 activation 的释放以及目录观察对象的清理。 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/subagent-user-controls/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/subagent-user-controls/guide.i18n.yaml new file mode 100644 index 0000000000..1ef94f1d8f --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/subagent-user-controls/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.2/subagent-user-controls/guide.md +/: + en: 9d77440fbd1ac5ee + zh: e51db37880a28684 +/subagent-user-controls-address-the-target-session: + en: 8f5fcee305e3a5d7 + zh: c6bb2c762cd2b86d +/subagent-user-controls-address-the-target-session/change: + en: 0a309d3a2a123b46 + zh: 416f075bfdb8664a +/subagent-user-controls-address-the-target-session/migration: + en: f3a61ba7c3a5110e + zh: 95c88d6f9d685fd4 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/subagent-user-controls/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/subagent-user-controls/guide.md new file mode 100644 index 0000000000..9080ef5f66 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/subagent-user-controls/guide.md @@ -0,0 +1,26 @@ +--- +kind: upgrade-guide +description: "Subagent user controls address only the target Session, rename the interrupt endpoint, and require target Session permissions instead of global permissions." +--- + +# Subagent user controls address the target Session + +English | [中文](guide.zh.md) + +## Change + +Browser and custom Remote callers send subagent prompts without `parentSessionId` or `mode`. `subagents/interruptByParent(childSessionId, parentSessionId, mode)` is replaced by `subagents/interruptByUser(childSessionId)`. Creation ancestry, history addresses, completion recipients, and persisted Session data are unchanged. + +Both operations name the child as a `session` access resource. Non-owner policies must grant the corresponding action on that Session; a `global` binding or a binding on its parent does not grant access. Owner behavior is unchanged. Model messages remain limited to one subagent tree, and model interruption remains ancestor-only. + +The same-process `SubagentInterruptAuthority` user variant is `{ kind: 'user' }`. The published `@deepseek-ai/dsh-subagent/internal` entry point is removed, including its host prompt adapters and tool identity markers. Remote authorization is performed by the Gateway. + +Old prompt payloads with extra fields inside `request` can decode on the new Host, subject to its new access rules. Old interrupt calls fail because the endpoint is removed. New callers require the new Host. Upgrade custom clients and hosts together; the bundled Web client is delivered with its Host. + +## Migration + +1. Remove `parentSessionId` and `mode` from subagent prompt requests. Keep `childSessionId`, `requestId`, `delivery`, `content`, and optional `clientTimeZone`. +2. Replace calls to `interruptByParent(childId, parentId, 'continuable')` with `interruptByUser(childId)`. +3. Update non-owner policies to grant `subagents/prompt` and/or `subagents/interruptByUser` on `{ kind: 'session', id: childId }`. Parent-to-child grants remain an explicit policy decision. +4. Remove `parentSessionId` from `{ kind: 'user' }` interrupt authorities. Replace imports from `@deepseek-ai/dsh-subagent/internal`: authenticated user prompts use `subagents/prompt`; model-authored messages use `ctx.subagents.sendMessage()`. There is no public arbitrary-source host prompt adapter. Custom tools own their instructions through tool descriptions and `systemPrompt.context()`, without identity markers. Keep parent ids in history addresses and creation records. +5. Verify that a target-authorized user can send and interrupt, while a user authorized only for the parent or global resource is refused before any input or cancellation is admitted. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/subagent-user-controls/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/subagent-user-controls/guide.zh.md new file mode 100644 index 0000000000..8b9d8fab50 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/subagent-user-controls/guide.zh.md @@ -0,0 +1,26 @@ +--- +kind: upgrade-guide +description: "子代理用户控制仅按目标 Session 寻址,重命名中断端点,并以目标 Session 权限替代 global 权限。" +--- + +# 子代理用户控制按目标 Session 寻址 + +[English](guide.md) | 中文 + +## 变更 + +浏览器和自定义 Remote 调用方发送子代理提示词时,不再提供 `parentSessionId` 或 `mode`。`subagents/interruptByParent(childSessionId, parentSessionId, mode)` 替换为 `subagents/interruptByUser(childSessionId)`。创建祖先、历史地址、完成通知接收者和持久化 Session 数据保持不变。 + +两个操作都将子会话声明为 `session` 访问资源。非 owner 策略必须授予该 Session 上对应操作的权限;`global` 绑定或父会话上的绑定不授予访问权。owner 行为保持不变。模型消息仍限于同一棵子代理树,模型中断仍仅允许祖先发起。 + +同进程 `SubagentInterruptAuthority` 的用户分支为 `{ kind: 'user' }`。已发布的 `@deepseek-ai/dsh-subagent/internal` 入口已删除,包括宿主 prompt 适配器和工具身份标记。远程授权由 Gateway 执行。 + +旧提示词载荷在 `request` 内多带字段时,可以在新 Host 上解码,但须满足新的访问规则。旧中断调用因端点已移除而失败。新调用方要求新 Host。自定义客户端和宿主应一起升级;内置 Web 客户端随 Host 一起交付。 + +## 迁移 + +1. 从子代理提示词请求中移除 `parentSessionId` 和 `mode`。保留 `childSessionId`、`requestId`、`delivery`、`content` 和可选的 `clientTimeZone`。 +2. 将 `interruptByParent(childId, parentId, 'continuable')` 调用替换为 `interruptByUser(childId)`。 +3. 更新非 owner 策略,在 `{ kind: 'session', id: childId }` 上授予 `subagents/prompt` 和/或 `subagents/interruptByUser` 权限。父会话到子会话的授权仍由策略显式决定。 +4. 从 `{ kind: 'user' }` 中断授权中移除 `parentSessionId`。替换 `@deepseek-ai/dsh-subagent/internal` 的导入:经过认证的用户 prompt 使用 `subagents/prompt`;模型编写的消息使用 `ctx.subagents.sendMessage()`。不再提供允许任意来源的公开宿主 prompt 适配器。自定义工具通过工具描述和 `systemPrompt.context()` 提供说明,无需身份标记。历史地址和创建记录仍保留父级 id。 +5. 验证获准操作目标的用户能够发送和中断,而仅获准操作父会话或 global 资源的用户在输入或取消被接纳前遭到拒绝。 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.i18n.yaml index f8584a6842..7e4e4247a2 100644 --- a/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.i18n.yaml @@ -12,5 +12,5 @@ en: 1552866718133b10 zh: 1c760c5c2d5cfc0b /authorize-the-computer-use-helper/migration: - en: a79e5995415ff456 - zh: 6f818a092e53af2c + en: 2a28385e24468734 + zh: 725c1856a604bc0c diff --git a/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.md index c913488785..9e57c7c569 100644 --- a/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.md +++ b/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.md @@ -13,7 +13,7 @@ macOS Computer Use runs as `DSH Computer Use.app`, launched through LaunchServic ## Migration -1. Computer Use now installs on demand. In **Plugins → Add plugin**, install `@deepseek-ai/dsh-cua-bundle` and enable it; profiles that previously selected the shipped copy need this installation. Restart DSH if requested. In the Computer Use plugin page, grant Accessibility and Screen Recording access to `DSH Computer Use.app` when macOS requests it. +1. Restart DSH after upgrading the Computer Use package. In the Computer Use plugin page, grant Accessibility and Screen Recording access to `DSH Computer Use.app` when macOS requests it. 2. Remove an obsolete `helperPath` override or `DSH_CUA_HELPER` value to use automatic preparation. For a custom build, point it to the executable at `DSH Computer Use.app/Contents/MacOS/dsh-cua-native` inside the complete application bundle. 3. Confirm that the permission page identifies `DSH Computer Use.app` and both permissions are allowed before resuming native application tasks. Target-application consent remains required. 4. If a terminal or Desktop launcher was granted these permissions only for Computer Use, revoke those obsolete grants in System Settings. Authorizing the helper does not revoke other applications’ existing permissions; programs the model runs still follow their launcher’s system permissions and the DSH sandbox policy. diff --git a/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.zh.md index f25a4b805d..a6c3a2f634 100644 --- a/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.zh.md @@ -13,7 +13,7 @@ macOS Computer Use 通过 LaunchServices 以 `DSH Computer Use.app` 运行。终 ## 迁移 -1. Computer Use 改为按需安装。在“插件 → 添加插件”中安装 `@deepseek-ai/dsh-cua-bundle` 并启用;此前选中过内置副本的 profile 也需完成这次安装。按提示重启 DSH。在 Computer Use 插件页按 macOS 提示,给 `DSH Computer Use.app` 授予辅助功能和屏幕录制权限。 +1. 升级 Computer Use 包后重启 DSH。在 Computer Use 插件页按 macOS 提示,给 `DSH Computer Use.app` 授予辅助功能和屏幕录制权限。 2. 删除过时的 `helperPath` 覆盖配置或 `DSH_CUA_HELPER` 值,使用自动准备。使用自定义构建时,将其指向完整应用包内的 `DSH Computer Use.app/Contents/MacOS/dsh-cua-native` 可执行文件。 3. 恢复原生应用任务前,确认权限页显示 `DSH Computer Use.app`,且两项权限均已允许。具体目标应用仍需另行许可。 4. 若终端或 Desktop 启动器的这两项权限只用于 Computer Use,可在系统设置中撤销这些旧授权。给 helper 授权不会撤销其他应用已有的权限;模型运行的程序仍遵循启动器的系统权限和 DSH 沙箱策略。 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/inbox-only-agent-wait/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/inbox-only-agent-wait/guide.i18n.yaml new file mode 100644 index 0000000000..dadc616c76 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/inbox-only-agent-wait/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/inbox-only-agent-wait/guide.md +/: + en: f09a5fde2cedb48c + zh: beec903a10ed62ee +/agent-waits-require-inbox-input: + en: 96c9f5fc6514557a + zh: aac52f37d543ed9b +/agent-waits-require-inbox-input/change: + en: 568583cb15f85337 + zh: 6c7e3028f23d1f3e +/agent-waits-require-inbox-input/migration: + en: 156a027d11ad3769 + zh: 301029cecd3ae640 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/inbox-only-agent-wait/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/inbox-only-agent-wait/guide.md new file mode 100644 index 0000000000..9252add26b --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/inbox-only-agent-wait/guide.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "Task-board changes no longer end wait_agent calls; send a message to request further work." +--- +# Agent waits require inbox input + +English | [中文](guide.zh.md) + +## Change + +`wait_agent` waits only for the calling Agent's next-step inbox. Task creation, assignment, completion, and other task updates no longer end an active wait. They do not add inbox messages or start idle Agents. Queued next-turn input and other Agents' status changes also leave the wait pending. + +Existing next-step input returns immediately; new messages, completion notifications, and user steering end the wait without consuming that input. Timeout and cancellation behavior remain unchanged. + +## Migration + +1. After changing a task, use `send_message` when its owner or another member needs to act on the update. Do not rely on a task mutation to release `wait_agent`. +2. Read current task state before acting; a task update is not a message or an instruction to begin a new turn. +3. Verify that a task update leaves an empty-inbox wait pending, then send a message and confirm that the wait returns with `timedOut: false`. diff --git a/docs/upgrade-guide/v0.2.1-rc.3/inbox-only-agent-wait/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/inbox-only-agent-wait/guide.zh.md new file mode 100644 index 0000000000..27bd015147 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/inbox-only-agent-wait/guide.zh.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "任务板变更不再结束 wait_agent 调用;需要继续工作时发送消息。" +--- +# Agent 等待需要收件箱输入 + +[English](guide.md) | 中文 + +## 变更 + +`wait_agent` 仅等待调用 Agent 的下一步收件箱。任务创建、分配、完成和其他任务更新不再结束正在进行的等待,也不添加收件箱消息或启动空闲 Agent。排队的下一轮输入和其他 Agent 的状态变化同样不会结束等待。 + +已有下一步输入时立即返回;新消息、完成通知和用户引导会结束等待,但不消费输入。超时和取消行为保持不变。 + +## 迁移 + +1. 修改任务后,需要任务所有者或其他成员处理更新时使用 `send_message`。不要依赖任务修改来结束 `wait_agent`。 +2. 行动前读取当前任务状态;任务更新不是消息,也不是开始新轮次的指令。 +3. 验证任务更新不会结束空收件箱上的等待,然后发送消息,确认等待返回 `timedOut: false`。 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/subagent-runtime-ownership/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/subagent-runtime-ownership/guide.i18n.yaml new file mode 100644 index 0000000000..2972e9a489 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/subagent-runtime-ownership/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/subagent-runtime-ownership/guide.md +/: + en: 10f7c9b47f2b39f2 + zh: 2c93aa4e6bef0f33 +/subagent-runtime-ownership: + en: 20c105e3d68b46ee + zh: 685c1ad4eef7eefb +/subagent-runtime-ownership/change: + en: bacb015157610964 + zh: 635055eab24394ac +/subagent-runtime-ownership/migration: + en: 3fb0ce93ad94074f + zh: 49e2f520191d6c53 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/subagent-runtime-ownership/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/subagent-runtime-ownership/guide.md new file mode 100644 index 0000000000..2cc07287e3 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/subagent-runtime-ownership/guide.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: Agent factory ownership parameters and registry tree queries are removed; subagent Activations own runtime tree membership. +--- + +# Subagent runtime ownership + +English | [中文](guide.zh.md) + +## Change + +`@deepseek-ai/dsh-agent` removes the create/resume runtime-owner parameter (`parentAgent` or `ownerAgent`), `AgentRegistry.isOwnedBy()`, `AgentRegistry.roots()`, and the owner argument of `enter()`. Direct factory creation no longer enrolls an Agent in a managed subagent tree. Custom factories no longer implement runtime tree ownership. + +The subagent service resolves runtime membership from its existing Activations through `rootOf(agent)`. The Session format and Cordis context ownership of handles are unchanged. + +## Migration + +1. Create delegated work through `ctx.subagents.startActivation()` with a registered provider. Remove ownership parameters from direct `ctx.agents.create()` and `resume()` calls; these operations create independent runtime instances. +2. Use `ctx.agents.get(id)` and `list()` for live lookup. To select ordinary live Agents, filter `list()` by `agent.session.header.origin !== 'subagent'`; historical ordinary forks can still have `parentSession`. +3. Query the live tree root with `ctx.subagents.rootOf(agent)` and compare Agent objects, not only ids. Ordinary live Agents resolve to themselves; unmanaged delegated or stale instances throw `SubagentError` with code `UNAUTHORIZED`. Read `session.header.parentSession` for the direct creator; use durable subagent catalogs when traversing offline creation ancestors. +4. Update custom factory implementations to call `ctx.agents.enter(agent)` after setup. Retain caller-context disposal, initialization rollback, and exact-instance registration checks. Runtime tree membership is available after managed activation publication; `agent/created` runs before that point. Verify stale instances cannot reuse a disposed instance's ownership. diff --git a/docs/upgrade-guide/v0.2.1-rc.3/subagent-runtime-ownership/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/subagent-runtime-ownership/guide.zh.md new file mode 100644 index 0000000000..ee212f5c96 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/subagent-runtime-ownership/guide.zh.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: Agent 工厂归属参数和注册表树查询已删除;subagent Activation 拥有运行时树成员关系。 +--- + +# Subagent 运行时归属 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-agent` 删除 create/resume 的运行时归属参数(`parentAgent` 或 `ownerAgent`)、`AgentRegistry.isOwnedBy()`、`AgentRegistry.roots()` 以及 `enter()` 的 owner 参数。直接通过工厂创建 Agent 不再将其加入托管 subagent 树。自定义工厂不再实现运行时树归属。 + +subagent 服务通过 `rootOf(agent)` 从已有 Activation 解析运行时成员关系。Session 格式和 Cordis 上下文对句柄的所有权不变。 + +## 迁移 + +1. 通过 `ctx.subagents.startActivation()` 和已注册提供方创建委派工作。删除直接调用 `ctx.agents.create()` 与 `resume()` 时的归属参数;这些操作创建独立的运行时实例。 +2. 通过 `ctx.agents.get(id)` 与 `list()` 查询存活实例。筛选普通存活 Agent 时,用 `agent.session.header.origin !== 'subagent'` 过滤 `list()`;历史普通 fork 仍可能包含 `parentSession`。 +3. 通过 `ctx.subagents.rootOf(agent)` 查询存活树根,并比较 Agent 对象,而非仅比较 id。普通存活 Agent 返回自身;非托管的委派实例或失效实例抛出代码为 `UNAUTHORIZED` 的 `SubagentError`。直接创建者读取 `session.header.parentSession`;遍历离线创建祖先时使用持久化 subagent 目录。 +4. 更新自定义工厂,在 setup 后调用 `ctx.agents.enter(agent)`。保留调用方上下文释放、初始化回滚和确切实例注册检查。运行时树成员关系在托管 activation 发布后可用;`agent/created` 早于该时点。验证失效实例不能复用已释放实例的归属。 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/team-mode-menu/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/team-mode-menu/guide.i18n.yaml new file mode 100644 index 0000000000..8916474764 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/team-mode-menu/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/team-mode-menu/guide.md +/: + en: 1c39dada6c6162a7 + zh: ab2f5e2824bfbf0c +/select-team-mode-from-the-conversation-menu: + en: d570d0f970f6a77a + zh: 7a722461f27bc620 +/select-team-mode-from-the-conversation-menu/change: + en: 37b569a98d41587d + zh: ce9c8ee4c35ac634 +/select-team-mode-from-the-conversation-menu/migration: + en: d548bbca6276ed74 + zh: 2468a89bd342b3ff diff --git a/docs/upgrade-guide/v0.2.1-rc.3/team-mode-menu/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/team-mode-menu/guide.md new file mode 100644 index 0000000000..0d54d68096 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/team-mode-menu/guide.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "The Team mode slash command is removed; use the root conversation menu." +--- + +# Select Team mode from the conversation menu + +English | [中文](guide.zh.md) + +## Change + +`/team on` and `/team off` are no longer registered. The root conversation’s **Use Agent Teams** switch calls `remote.agentTeams.setEnabled` directly. Saved selections, task retention, and root-only authorization are unchanged. + +## Migration + +1. Enable or disable **Use Agent Teams** in the root conversation’s model and reasoning menu. +2. Plugins that execute the command should call `ctx.agentTeams.setEnabled(rootAgent, enabled)` instead; browser clients call `remote.agentTeams.setEnabled(rootSessionId, enabled)`. +3. Confirm the switch reflects the saved selection after reloading the Session. diff --git a/docs/upgrade-guide/v0.2.1-rc.3/team-mode-menu/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/team-mode-menu/guide.zh.md new file mode 100644 index 0000000000..c8e847e91f --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/team-mode-menu/guide.zh.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "Team 模式斜杠命令已移除,请使用根会话菜单。" +--- + +# 从会话菜单选择 Team 模式 + +[English](guide.md) | 中文 + +## 变更 + +`/team on` 和 `/team off` 不再注册。根会话的**使用智能体团队**开关直接调用 `remote.agentTeams.setEnabled`。保存的选择、任务保留以及仅允许根会话修改的授权规则不变。 + +## 迁移 + +1. 在根会话的模型与推理等级菜单中打开或关闭**使用智能体团队**。 +2. 执行该命令的插件改为调用 `ctx.agentTeams.setEnabled(rootAgent, enabled)`;浏览器客户端调用 `remote.agentTeams.setEnabled(rootSessionId, enabled)`。 +3. 重新加载 Session,确认开关反映已保存的选择。 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/team-projection/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/team-projection/guide.i18n.yaml new file mode 100644 index 0000000000..b709e46f55 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/team-projection/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/team-projection/guide.md +/: + en: 185304d86b229610 + zh: b98e40567653a55f +/team-projection-retains-tasks-and-mode: + en: c20510084cf9d412 + zh: ee26f2365ab5d216 +/team-projection-retains-tasks-and-mode/change: + en: 5b92baca861b7281 + zh: 57b4162fb425163c +/team-projection-retains-tasks-and-mode/migration: + en: 6c30148dea17af5e + zh: 81b7fa72cc118252 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/team-projection/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/team-projection/guide.md new file mode 100644 index 0000000000..fe0258e51e --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/team-projection/guide.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "Team projection stops restoring historical teammates and mailbox state while retaining tasks and saved mode." +--- + +# Team projection retains tasks and mode + +English | [中文](guide.zh.md) + +## Change + +The `agentTeam` projection no longer restores `members`, `messages`, or `delivered` from historical `team/member` and `team/message/*` events. It ignores their payloads, so historical roster or mailbox validation failures no longer block task reads or writes. Historical teammates are not restored and queued mail is not delivered. + +Task revisions, task owners, deleted-task records, and the saved Team mode remain supported. Ordinary subagent discovery and messaging are unchanged. The Session format and committed log files do not change; projection checkpoint version 7 rebuilds older cached state from the log. + +## Migration + +1. No data conversion or configuration change is required. Open the Session normally to rebuild incompatible projection checkpoints. +2. Use `list_agents` and `send_message` for current teammates. Read historical roster or mailbox events from the original Session log if you need an archive; the Team projection no longer supplies these collections. +3. Confirm that the task board and saved Team mode are restored. Historical roster and mailbox records do not grant task-assignment authority. diff --git a/docs/upgrade-guide/v0.2.1-rc.3/team-projection/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/team-projection/guide.zh.md new file mode 100644 index 0000000000..b6b131599e --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/team-projection/guide.zh.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "Team 投影停止恢复历史队友与邮箱状态,同时保留任务和保存的模式。" +--- + +# Team 投影保留任务与模式 + +[English](guide.md) | 中文 + +## 变更 + +`agentTeam` 投影不再从历史 `team/member` 和 `team/message/*` 事件恢复 `members`、`messages` 或 `delivered`。投影忽略这些载荷,因此历史名册或邮箱校验失败不再阻止任务读写。不恢复历史队友,也不投递排队邮件。 + +任务修订、任务所有者、已删除任务记录和保存的 Team 模式仍受支持。普通子代理发现与通信保持不变。Session 格式和已提交日志文件不变;投影检查点版本 7 从日志重建旧缓存状态。 + +## 迁移 + +1. 无需转换数据或修改配置。正常打开 Session 即可重建不兼容的投影检查点。 +2. 使用 `list_agents` 和 `send_message` 与当前队友协作。需要归档信息时,从原始 Session 日志读取历史名册或邮箱事件;Team 投影不再提供这些集合。 +3. 确认任务板和保存的 Team 模式已恢复。历史名册与邮箱记录不授予任务分配权限。 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/cua-source-dev-helper/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/cua-source-dev-helper/guide.i18n.yaml new file mode 100644 index 0000000000..1e9d8c17c2 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/cua-source-dev-helper/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/cua-source-dev-helper/guide.md +/: + en: 2f745e146345238c + zh: 9b41a7625ae45170 +/source-built-computer-use-helper-identity: + en: 72a8ea364f7427fa + zh: 9e7ed2e00425bddb +/source-built-computer-use-helper-identity/change: + en: c1aa84364896b994 + zh: a4559c563d995b2b +/source-built-computer-use-helper-identity/migration: + en: a86e330ab60ef800 + zh: ce32e1959ca707fc diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/cua-source-dev-helper/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/cua-source-dev-helper/guide.md new file mode 100644 index 0000000000..92d0339b4e --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/cua-source-dev-helper/guide.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "Source-built Computer Use helpers use a separate development bundle identifier and need one new macOS permission grant." +--- + +# Source-built Computer Use helper identity + +English | [中文](guide.zh.md) + +## Change + +Source checkouts previously built `DSH Computer Use.app` with the release bundle identifier `com.deepseek.harness.computer-use` and an ad-hoc signature. They now build `DSH Computer Use Dev.app` with bundle identifier `com.deepseek.harness.computer-use.dev`. Accessibility and Screen Recording grants for the old entry do not apply to it. Desktop and npm installations are unchanged. Source builds still default to ad-hoc signing; developers generate and maintain fixed identities outside DSH home and select them through configuration. + +## Migration + +1. Keep the default `sourceSigning: ad-hoc` for temporary signatures. To reduce repeated grants after rebuilds and across worktrees, follow the [developer signing skill](../../../../.agents/skills/dsh-cua-macos-signing/SKILL.md) to generate or reuse a certificate, then write `sourceSigning: { identity, keychain }` into the DSH home’s `cordis.patch.yml`; `keychain` must be an absolute path. +2. Start DSH from the source checkout, call Computer Use, and grant Accessibility and Screen Recording to `DSH Computer Use Dev` in System Settings. +3. Confirm that `getCapabilities` reports both permissions as `true`. With a fixed signature, check again after changing and rebuilding Swift code and from another worktree. Remove an old `DSH Computer Use` entry only when no installed DSH uses it. +4. After the keychain locks, rerun setup as described in the skill to unlock and reuse the identity. To stop using it, select `ad-hoc` or remove the configuration, then remove the identity through the skill; deleting DSH home does not delete the developer keychain. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/cua-source-dev-helper/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/cua-source-dev-helper/guide.zh.md new file mode 100644 index 0000000000..64552b7524 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/cua-source-dev-helper/guide.zh.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "源码构建的 Computer Use helper 使用独立的开发版 Bundle ID,需要重新授予一次 macOS 权限。" +--- + +# 源码构建的 Computer Use helper 身份 + +[English](guide.md) | 中文 + +## 变更 + +源码检出原先以临时签名构建 `DSH Computer Use.app`,使用正式版 Bundle ID `com.deepseek.harness.computer-use`。现在构建 `DSH Computer Use Dev.app`,使用 `com.deepseek.harness.computer-use.dev`。旧条目的辅助功能和屏幕录制授权不能沿用。Desktop 和 npm 安装版不变。源码构建默认仍为临时签名;固定身份由开发者在 DSH home 之外生成和维护,再通过配置指定。 + +## 迁移 + +1. 保持默认 `sourceSigning: ad-hoc` 即可使用临时签名。需要减少重新编译和跨 worktree 的授权次数时,按[开发者签名 skill](../../../../.agents/skills/dsh-cua-macos-signing/SKILL.md)生成或复用证书,并将 `sourceSigning: { identity, keychain }` 写入 DSH home 的 `cordis.patch.yml`;`keychain` 必须为绝对路径。 +2. 从源码检出启动 DSH,调用 Computer Use,在系统设置中给 `DSH Computer Use Dev` 授予辅助功能和屏幕录制权限。 +3. 确认 `getCapabilities` 的两项权限均为 `true`。使用固定签名时,在修改 Swift 源码重建和另一个 worktree 中分别复查。只有确认没有安装版 DSH 使用旧条目时,才移除旧 `DSH Computer Use` 条目。 +4. 钥匙串锁定后,按 skill 重新运行 setup 解锁并复用现有身份。停止使用时改回 `ad-hoc` 或移除该配置,再按 skill 删除身份;删除 DSH home 不会删除开发者钥匙串。 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/subagent-explicit-reasoning/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/subagent-explicit-reasoning/guide.i18n.yaml new file mode 100644 index 0000000000..24621ccc04 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/subagent-explicit-reasoning/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/subagent-explicit-reasoning/guide.md +/: + en: 74a51bb421eb125c + zh: 2fccbfa5bcafb2af +/subagent-reasoning-overrides-require-an-explicit-route: + en: f6bc10b254fe4420 + zh: 9ac48f0264d1bd69 +/subagent-reasoning-overrides-require-an-explicit-route/change: + en: e31a906fc29dd5a6 + zh: 2fa39b8bcbdfdb38 +/subagent-reasoning-overrides-require-an-explicit-route/migration: + en: 9ebfdab054389c89 + zh: 7c50e28db91781e4 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/subagent-explicit-reasoning/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/subagent-explicit-reasoning/guide.md new file mode 100644 index 0000000000..70bd516d85 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/subagent-explicit-reasoning/guide.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "Subagent reasoning overrides require an explicit provider and model, and explicit routes use model-default reasoning when effort is omitted." +--- + +# Subagent reasoning overrides require an explicit route + +English | [中文](guide.zh.md) + +## Change + +Delegation tools with model selection enabled reject `reasoning_effort` unless the same call supplies both `provider` and `model`. Previously, an effort-only call could use a configured or inherited route. The rejection occurs before child creation. + +An explicit provider/model pair without `reasoning_effort` uses the selected model's default effort, including when the pair matches the parent or configured child route. Previously, the unchanged route could retain a configured or parent effort. Omitting all three tool fields preserves the configured child defaults and existing fallback behavior. + +## Migration + +1. Update prompts, skills, and tool-call producers that send only `reasoning_effort`: include the authorized `provider` and `model`, or omit all three fields to use the default child selection. Query `list_subagent_models` for authorized routes and supported effort ids. +2. To preserve a specific effort while selecting a route, pass it explicitly as `reasoning_effort`. +3. Confirm an effort-only call returns a missing-route error without starting a child. For a complete route, check the child's logged `request/header` configuration: an omitted effort follows that model's default rather than the parent's selected effort. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/subagent-explicit-reasoning/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/subagent-explicit-reasoning/guide.zh.md new file mode 100644 index 0000000000..4ab85b612a --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/subagent-explicit-reasoning/guide.zh.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "subagent 推理强度覆盖要求显式指定提供方和模型,显式路由省略强度时使用模型默认值。" +--- + +# subagent 推理强度覆盖要求显式路由 + +[English](guide.md) | 中文 + +## 变更 + +启用模型选择的委派工具要求同一次调用同时提供 `provider` 和 `model`,否则拒绝 `reasoning_effort`。此前,仅指定强度的调用可以使用配置或继承的路由。拒绝发生在创建子级之前。 + +显式提供提供方/模型组合但省略 `reasoning_effort` 时,使用所选模型的默认强度,即使该组合与父级或配置的子级路由相同。此前,路由不变时可能保留配置或父级强度。省略全部三个工具字段时,仍保留配置的子级默认值与现有回退行为。 + +## 迁移 + +1. 更新仅发送 `reasoning_effort` 的提示、skill 和工具调用生成方:补齐已授权的 `provider` 和 `model`,或省略全部三个字段以使用默认子级选择。通过 `list_subagent_models` 查询已授权路由与支持的强度 ID。 +2. 选择路由时若要保留某个强度,显式传入 `reasoning_effort`。 +3. 确认仅指定强度的调用返回缺少路由的错误,且没有启动子级。对于完整路由,检查子级日志中的 `request/header` 配置:省略强度时应采用该模型的默认值,而不是父级选择的强度。 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/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/packages/README.i18n.yaml b/packages/README.i18n.yaml index 92c89f5ff2..d96e805d23 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -15,8 +15,8 @@ en: ffc55792d6e922e1 zh: 83bd759638857e2d /packages/package-groups: - en: c397fc0c1885058a - zh: c720d0e91dd1d15e + en: 60a6056460b8d4c1 + zh: fce319504ae9b6ef /packages/release-expectations: en: 236081c1659b25ab zh: 809110adf003802e diff --git a/packages/README.md b/packages/README.md index ed7c55ec5a..85a63643d0 100644 --- a/packages/README.md +++ b/packages/README.md @@ -52,6 +52,7 @@ Every package lives in exactly one group; new packages join existing groups, and | [`compaction/`](compaction/README.md) | Compaction capability family: Service Definition + basic provider + command Consumer | | [`context/`](context/README.md) | Model-visible request context: workspace instructions, time context, references | | [`subagent/`](subagent/README.md) | Subagent capability family: provider-registry contract and model-facing delegation tools | +| [`team/`](team/README.md) | Team coordination: shared task boards and model-facing task tools | | [`jobs/`](jobs/README.md) | Generic background-job runtime and model-facing job control tools | | [`experimental/`](experimental/README.md) | Capabilities under evaluation; [status and support policy](experimental/README.md#status) | | [`workflow/`](workflow/README.md) | Workflow seam, PTC process engine, and the model-facing `workflow` tool | diff --git a/packages/README.zh.md b/packages/README.zh.md index ae3a18bbc1..f293666d2f 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -52,6 +52,7 @@ harness 由 `packages/` 下的 npm 包组装而成,按能力系列分组:会 | [`compaction/`](compaction/README.zh.md) | 压缩(compaction)能力系列:Service Definition + 基础提供方 + 命令 Consumer | | [`context/`](context/README.zh.md) | 模型可见请求上下文:workspace 指令、时间上下文、引用 | | [`subagent/`](subagent/README.zh.md) | subagent 能力系列:提供方注册表约定和面向模型的委托工具 | +| [`team/`](team/README.zh.md) | Team 协调:共享任务板与面向模型的任务工具 | | [`jobs/`](jobs/README.zh.md) | 通用后台任务运行时和面向模型的作业控制工具 | | [`experimental/`](experimental/README.zh.md) | 仍在评估中的能力;[状态与支持策略](experimental/README.zh.md#status) | | [`workflow/`](workflow/README.zh.md) | 工作流 seam、PTC 进程引擎、面向模型的 `workflow` 工具 | diff --git a/packages/acp/acp/src/session.ts b/packages/acp/acp/src/session.ts index 2af289b3a2..e1abf0b2f0 100644 --- a/packages/acp/acp/src/session.ts +++ b/packages/acp/acp/src/session.ts @@ -24,8 +24,8 @@ import { assistantUpdates, toolCallUpdate, toolResultUpdate } from './updates.ts interface SubagentLifecycle { /** Dispose subagent descendants below exact host-owned parents child-first. */ drainDescendants(parents: readonly Agent[]): Promise - /** Observe currently managed descendants without closing future admission. */ - waitForChildren(parent: Agent): Promise + /** Join progressing work in the root's subagent tree without closing future admission. */ + waitForTreeWork(root: Agent): Promise } /** Inputs shared by fresh and resumed ACP session construction. */ @@ -489,7 +489,7 @@ export class AcpSession { await Promise.race([this.agent.whenIdle(), cancelled]) if (isCancelled()) break const idleSeq = this.agent.session.seq - const children = await Promise.race([subagents?.waitForChildren(this.agent), cancelled]) + const children = await Promise.race([subagents?.waitForTreeWork(this.agent), cancelled]) if (isCancelled()) break if (children || !idleAt(idleSeq)) continue await this.outputTail diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index e85c2553d9..39eb9f784e 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -50,7 +50,7 @@ async function installDelegationTool(ctx: BridgeHarness['ctx']): Promise<() => b if (exec.agent === undefined) throw new Error('delegation fixture requires an Agent') const child = await ctx.subagents.startActivation({ provider: 'local-test', label: 'later child', delivery: 'caller', signal: exec.signal, - request: { parent: exec.agent, prompt: [{ type: 'text', text: 'later child task' }] }, + parent: exec.agent, prompt: [{ type: 'text', text: 'later child task' }], }) const result = await child.result delegated = result.stopReason === 'completed' @@ -215,7 +215,7 @@ describe('ACP prompt lifecycle', () => { const appendSpy = vi.spyOn(parent.session, 'append') const failures: number[] = [] harness.ctx.on('agent/error', ({ turn }) => { failures.push(turn) }) - const waitForChildren = vi.fn().mockImplementationOnce(() => { + const waitForTreeWork = vi.fn().mockImplementationOnce(() => { appendSpy.mockImplementation(((type: string, ...rest: never[]) => { if (type === failedEvent) throw new Error(`summary ${failedEvent} unavailable`) return (append as (...args: never[]) => unknown)(type as never, ...rest) @@ -223,7 +223,7 @@ describe('ACP prompt lifecycle', () => { parent.followup(createUserMessage({ content: [{ type: 'text', text: 'child result' }], source: { kind: 'test' } })) return Promise.resolve(true) }).mockResolvedValue(false) - harness.ctx.provide('subagents', { waitForChildren, drainDescendants: vi.fn() } as never) + harness.ctx.provide('subagents', { waitForTreeWork, drainDescendants: vi.fn() } as never) try { await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'delegate' }] })) .rejects.toThrow(`summary ${failedEvent} unavailable`) @@ -594,10 +594,10 @@ describe('ACP prompt lifecycle', () => { const parent = harness.ctx.agents.get(SessionId(sessionId))! const waiting = Promise.withResolvers() const children = Promise.withResolvers() - const waitForChildren = vi.fn() + const waitForTreeWork = vi.fn() .mockImplementationOnce(() => { waiting.resolve(undefined); return children.promise }) .mockResolvedValue(false) - harness.ctx.provide('subagents', { waitForChildren, drainDescendants: vi.fn() } as never) + harness.ctx.provide('subagents', { waitForTreeWork, drainDescendants: vi.fn() } as never) let settled = false const prompt = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'delegate' }] }) const observed = prompt.then(() => { settled = true }, () => { settled = true }) @@ -619,11 +619,11 @@ describe('ACP prompt lifecycle', () => { const sessionId = await newSession(harness) const waiting = Promise.withResolvers() const children = Promise.withResolvers() - const waitForChildren = vi.fn() + const waitForTreeWork = vi.fn() .mockImplementationOnce(() => { waiting.resolve(undefined); return children.promise }) .mockResolvedValue(false) const drainDescendants = vi.fn() - harness.ctx.provide('subagents', { waitForChildren, drainDescendants } as never) + harness.ctx.provide('subagents', { waitForTreeWork, drainDescendants } as never) const first = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'delegate' }] }) await waiting.promise await harness.client.cancel({ sessionId }) @@ -647,7 +647,7 @@ describe('ACP prompt lifecycle', () => { const parent = ctx.agents.get(SessionId(sessionId))! const waiting = Promise.withResolvers() const children = Promise.withResolvers() - vi.spyOn(ctx.subagents, 'waitForChildren').mockImplementationOnce(() => { + vi.spyOn(ctx.subagents, 'waitForTreeWork').mockImplementationOnce(() => { waiting.resolve(undefined) return children.promise }) @@ -873,11 +873,11 @@ describe('ACP prompt lifecycle', () => { await delivery.promise if (fail) throw new Error('summary image unavailable') } - const waitForChildren = vi.fn().mockImplementationOnce(() => { + const waitForTreeWork = vi.fn().mockImplementationOnce(() => { parent.followup(createUserMessage({ content: [{ type: 'text', text: 'child result' }], source: { kind: 'test' } })) return Promise.resolve(true) }).mockResolvedValue(false) - harness.ctx.provide('subagents', { waitForChildren, drainDescendants: vi.fn() } as never) + harness.ctx.provide('subagents', { waitForTreeWork, drainDescendants: vi.fn() } as never) let settled = false const prompt = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'delegate' }] }) const observed = prompt.then(() => { settled = true }, () => { settled = true }) @@ -910,7 +910,7 @@ describe('ACP prompt lifecycle', () => { const sessionId = await newSession(harness) const parent = ctx.agents.get(SessionId(sessionId))! const noChildren = Promise.withResolvers() - vi.spyOn(ctx.subagents, 'waitForChildren').mockImplementationOnce(() => { + vi.spyOn(ctx.subagents, 'waitForTreeWork').mockImplementationOnce(() => { noChildren.resolve(undefined) return Promise.resolve(false) }) diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index f3b9eab6fc..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: a80d44e0683768a5 - zh: 379833ac180e5418 + 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 8d49cb49a5..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, 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 4f9772403d..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、凭据、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、凭据、settings、Goal、Schedu 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 cea0ed7941..529a762528 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -93,12 +93,15 @@ "@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:*", "@deepseek-ai/dsh-api-account-controller": "workspace:*", "@deepseek-ai/dsh-deepseek-account": "workspace:*", "@deepseek-ai/dsh-client-product-analytics": "workspace:*", - "@deepseek-ai/dsh-client-ui-trajectory": "workspace:*" + "@deepseek-ai/dsh-agent-team": "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 8a4b7dea8a..a3012bb69f 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -6,6 +6,8 @@ export type {} from '@deepseek-ai/dsh-client-product-analytics/remote' import agentPresetsRemote from '@deepseek-ai/dsh-agent-preset-registry/remote' import userQuestionsRemote from '@deepseek-ai/dsh-user-questions/remote' import commandsRemote from '@deepseek-ai/dsh-commands/remote' +import agentTeamsRemote from '@deepseek-ai/dsh-agent-team/remote' +export type {} from '@deepseek-ai/dsh-agent-team/remote' import accountRemote from '@deepseek-ai/dsh-api-account-controller/remote' import settingsControllerRemote from '@deepseek-ai/dsh-api-settings-controller/remote' import officeToPdfRemote from '@deepseek-ai/dsh-office-to-pdf/remote' @@ -14,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' @@ -29,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' @@ -40,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' @@ -52,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' @@ -83,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' @@ -189,13 +197,14 @@ export async function apply(ctx: Context): Promise<() => Promise> { const disposers: Array<() => Promise> = [] try { for (const contribution of [ - productAnalyticsRemote, agentPresetsRemote, commandsRemote, settingsControllerRemote, accountRemote, + 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 70181b08c5..858f7b7d7d 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -11,6 +11,7 @@ "src/types.ts" ], "references": [ + { "path": "../../team/agent-team" }, { "path": "../../client/product-analytics/tsconfig.client.json" }, { "path": "../../../vendor/cordis" @@ -102,6 +103,9 @@ { "path": "../../boot/plugin-manager" }, + { + "path": "../../mcp/mcp-manager" + }, { "path": "../../boot/plugin-market" }, @@ -113,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 8bea04d9db..38ebd48e01 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -15,11 +15,11 @@ en: 1fdf707322fb6996 zh: 0204f7e9d4aa3cce /session-controller/use-this-package: - en: 22136176884a239b - zh: b8eeea5908b9a6fd + en: 300f02be359a3170 + zh: ac7fb4abbe78eddf /session-controller/client-references: - en: 41bdda1ac32df558 - zh: aa5d0e325924066b + en: e8979a803ec3e194 + zh: c567efcd971728ba /session-controller/session-media-references: en: 954923654eebe2dc zh: 6bd9a7d2ea965aed diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 3a9dc2cd6b..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. 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 subagent ownership fence 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. @@ -76,14 +76,14 @@ Queue edits replace pending content with non-empty text only. `session.attachment` receives `{ sessionId, attachment: ImageAttachmentRef }` and passes the complete reference to `AttachmentStore.readImage()`, without reading or traversing Session history. The Client supplies the message's reference through `session.readAttachment(ref)`. Connection authentication and Gateway permission checks still apply; `sessionId` selects the Session resource for the endpoint's permission check. After authorization, the handler does not check whether that Session exists and does not return `session/not-found` merely because it is absent. An authorized caller may read any image in the same store, including an image referenced only by another Session or by no Session. The response preserves the supplied reference after verifying its ID, media type, byte length, width, and height against the stored bytes. Optional `name` and `originalDimensions` pass through as caller-supplied display information and are not verified against the file. Missing or invalid objects return a terminal attachment failure. The Client's historical-image cache retains one result and browser URL per Session and attachment ID. -The controller composes `ArchivedSessionGate` through `ctx.plugin`: it loads once the Agent registry, Session store, and Workspace registry exist and unwinds with the controller. Its `agent/pre-step` listener rejects a step proposed for an archived Session or for a subagent descendant of one — read from the Session header's lineage fields, never a fork — so a late waking delivery ends its turn as `blocked` without a model request; unarchiving lifts the gate for the whole lineage. The work an archived Session still runs is reported and stopped by its owners through the Workspace registry's archive admission ([seam](../../workspace/workspace/README.md)): the running turn by the [Agent registry](../../core/agent/README.md), owned jobs by the [job registry seam](../../jobs/jobs/README.md), subagent descendants by the [Subagent](../../subagent/subagent/README.md) runtime, reminders by the [Schedule](../../schedule/schedule/README.md) plugin; this controller reports nothing itself. +The controller composes `ArchivedSessionGate` through `ctx.plugin`: it loads once the Agent registry, Session store, and Workspace registry exist and unwinds with the controller. Its `agent/pre-step` listener rejects a step proposed for an archived Session or for a subagent descendant of one — read through available Session headers, never a fork — so a late waking delivery ends its turn as `blocked` without a model request; unarchiving lifts the gate for the whole lineage. The Subagent runtime separately checks retained creation ancestors, covering managed descendants whose intermediate creators are offline. The work an archived Session still runs is reported and stopped by its owners through the Workspace registry's archive admission ([seam](../../workspace/workspace/README.md)): the running turn by the [Agent registry](../../core/agent/README.md), owned jobs by the [job registry seam](../../jobs/jobs/README.md), subagent descendants by the [Subagent](../../subagent/subagent/README.md) runtime, reminders by the [Schedule](../../schedule/schedule/README.md) plugin; this controller reports nothing itself. ## Client references `sessions.retain(target, { source, signal? })` immediately acquires one exact Client generation and starts its shared initial history opening. The target is a known Session id or a durable direct-parent subagent address; the Host validates an explicit address when history opens. The returned reference supports idempotent `release()` and `Symbol.dispose`; its `ready` Promise follows the shared `Session.open()` result and resolves to the exact binding when that attempt settles, including when a Remote failure is represented by `openState: 'error'`. It rejects when `Session.open()` rejects, its waiter is cancelled, or the reference is released early. Cancelling one waiter does not cancel another owner's opening. `sessions.using(target, options, operation)` waits for that settlement, holds its reference until the callback settles, and propagates rejected readiness and callback failures. -References keep local Session data, scoped Contexts, and history streams alive, not Host Agents. Ordinary Session retention never adds a catalog row. Retained subagents with a known direct-parent address keep a fallback row and notify list readers even before the parent catalog arrives; these rows do not join Host-list membership. Fork-title assignment sends rename directly and applies the returned title projection without retaining the child or opening its history. Final release withdraws the generation before teardown; later acquisition can create a new generation with the same id. `binding(id)` and `scope(id)` only borrow an existing generation. `retainInfo(id)` observes stable read-only source counts independently of catalog membership and performs no history I/O. Consumer source keys are declaration-merge extensible; navigation and completion acknowledgement belong to UI consumers, not this Controller. See [Client Session references](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.md) for ownership and teardown rules. An unknown-mode subagent address permits history reads with direct-parent validation. Successful descriptor restoration determines the displayed mode; failures remain local to the child, and control requests still require a confirmed continuable identity. +References keep local Session data, scoped Contexts, and history streams alive, not Host Agents. Ordinary Session retention never adds a catalog row. Retained subagents with a known direct-parent address keep a fallback row and notify list readers even before the parent catalog arrives; these rows do not join Host-list membership. Fork-title assignment sends rename directly and applies the returned title projection without retaining the child or opening its history. Final release withdraws the generation before teardown; later acquisition can create a new generation with the same id. `binding(id)` and `scope(id)` only borrow an existing generation. `retainInfo(id)` observes stable read-only source counts independently of catalog membership and performs no history I/O. Consumer source keys are declaration-merge extensible; navigation and completion acknowledgement belong to UI consumers, not this Controller. See [Client Session references](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.md) for ownership and teardown rules. An unknown-mode subagent address permits history reads with direct-parent consistency validation; access is checked separately on the child Session. Successful descriptor restoration determines the displayed mode; failures remain local to the child. Control requests carry only the target id, and the Host checks continuation availability. Client snapshots retain only the subagent address; runtime admission owns continuation availability. ## Session media references diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 9259bdad17..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 都声明自己的激活策略。对冷 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 的恢复策略和 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。 @@ -76,14 +76,14 @@ Client 的首次 `follow`、重连首屏与 `loadOlder()` 至少请求 50 条以 `session.attachment` 接收 `{ sessionId, attachment: ImageAttachmentRef }`,并把完整引用交给 `AttachmentStore.readImage()`,不读取或遍历 Session 历史。Client 通过 `session.readAttachment(ref)` 传入消息中的引用。连接身份认证和 Gateway 权限检查仍然适用;`sessionId` 为该接口的权限检查指定 Session 资源。授权通过后,处理函数不检查该 Session 是否存在,也不会仅因其不存在而返回 `session/not-found`。获准调用的身份可以读取同一存储中的任意图片,包括仅被其他 Session 引用或未被任何 Session 引用的图片。响应在把 ID、媒体类型、字节数、宽度和高度与存储字节校验后,保留传入的引用。可选的 `name` 和 `originalDimensions` 作为调用方提交的显示信息原样返回,不根据文件校验。对象缺失或无效时返回明确的附件失败。Client 的历史图片缓存按 Session 和附件 ID 保留一份结果与浏览器 URL。 -本控制器通过 `ctx.plugin` 组合 `ArchivedSessionGate`:在 Agent 注册表、Session store 与 Workspace 注册表就绪后加载,随控制器一起释放。它的 `agent/pre-step` 监听器会拒绝为已归档会话或其子代理子孙——从 Session header 的血缘字段读出,从不包括 fork——提出的步骤,因此迟到的唤醒投递会让该回合以 `blocked` 收口而不发出模型请求;取消归档即为整条血缘解除门禁。已归档会话仍在跑的工作由各自的 owner 通过 Workspace 注册表的归档准入([接缝](../../workspace/workspace/README.zh.md))报告与停止:运行中的回合由 [Agent 注册表](../../core/agent/README.zh.md)负责,所属任务由[任务注册表接缝](../../jobs/jobs/README.zh.md)负责,子代理子孙由 [Subagent](../../subagent/subagent/README.zh.md) runtime 负责,提醒由 [Schedule](../../schedule/schedule/README.zh.md) 插件负责;本控制器自己不报告任何内容。 +本控制器通过 `ctx.plugin` 组合 `ArchivedSessionGate`:在 Agent 注册表、Session store 与 Workspace 注册表就绪后加载,随控制器一起释放。它的 `agent/pre-step` 监听器会拒绝为已归档会话或其子代理子孙——沿可用的 Session header 读取,从不包括 fork——提出的步骤,因此迟到的唤醒投递会让该回合以 `blocked` 收口而不发出模型请求;取消归档即为整条血缘解除门禁。Subagent runtime 另行检查保留的创建祖先,覆盖中间创建者已离线的托管后代。已归档会话仍在跑的工作由各自的 owner 通过 Workspace 注册表的归档准入([接缝](../../workspace/workspace/README.zh.md))报告与停止:运行中的回合由 [Agent 注册表](../../core/agent/README.zh.md)负责,所属任务由[任务注册表接缝](../../jobs/jobs/README.zh.md)负责,子代理子孙由 [Subagent](../../subagent/subagent/README.zh.md) runtime 负责,提醒由 [Schedule](../../schedule/schedule/README.zh.md) 插件负责;本控制器自己不报告任何内容。 ## Client 引用 `sessions.retain(target, { source, signal? })` 立即获取一个精确 Client generation 的引用,并启动其共享的首次历史打开。目标是已知 Session id 或持久的直接父子 subagent 地址;Host 在打开历史时校验显式地址。返回引用支持幂等的 `release()` 和 `Symbol.dispose`;其 `ready` Promise 跟随共享的 `Session.open()` 结果,并在该次尝试结算时解析为确切 binding,包括 Remote failure 以 `openState: 'error'` 表示的情况。仅当 `Session.open()` 拒绝、等待方取消或引用提前释放时,`ready` 才拒绝。取消一个等待方不会取消其他 owner 的打开。`sessions.using(target, options, operation)` 等待该次结算,持有引用直到回调结束,并传播被拒绝的就绪与回调失败。 -引用保活本地会话数据、作用域 Context 和历史流,不保活 Host Agent。普通 Session 不因保留引用而添加目录行。已保留且具有明确直接父子地址的 subagent 即使尚未收到父目录,也会保留兜底行并通知列表读取方;这些行不加入 Host 列表成员集合。Fork 标题设置直接发送 rename 并应用返回的标题投影,不 retain 子会话,也不打开其历史。最后一个引用释放时,generation 先退出可访问映射,再执行清理;后续获取可以为同一 id 创建新 generation。`binding(id)` 和 `scope(id)` 只借用已有 generation。`retainInfo(id)` 独立于目录成员关系观察稳定的只读来源计数,不执行历史 I/O。消费方来源键可通过声明合并扩展;导航和完成确认属于 UI 消费方,不属于本控制器。所有权与清理规则见 [Client 会话引用](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md)。 模式未知的子代理地址允许读取历史,但仍校验直接父级。成功恢复 descriptor 后确定展示模式;失败只影响该子会话,控制请求仍要求已确认的 continuable 身份。 +引用保活本地会话数据、作用域 Context 和历史流,不保活 Host Agent。普通 Session 不因保留引用而添加目录行。已保留且具有明确直接父子地址的 subagent 即使尚未收到父目录,也会保留兜底行并通知列表读取方;这些行不加入 Host 列表成员集合。Fork 标题设置直接发送 rename 并应用返回的标题投影,不 retain 子会话,也不打开其历史。最后一个引用释放时,generation 先退出可访问映射,再执行清理;后续获取可以为同一 id 创建新 generation。`binding(id)` 和 `scope(id)` 只借用已有 generation。`retainInfo(id)` 独立于目录成员关系观察稳定的只读来源计数,不执行历史 I/O。消费方来源键可通过声明合并扩展;导航和完成确认属于 UI 消费方,不属于本控制器。所有权与清理规则见 [Client 会话引用](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md)。 模式未知的子代理地址允许读取历史,但仍校验直接父级的一致性;访问权单独按子 Session 检查。成功恢复 descriptor 后确定展示模式;失败只影响该子会话。控制请求仅携带目标 id,由 Host 检查是否支持继续执行。Client 快照仅保留子代理地址;是否能接收续接输入由运行时校验。 ## 会话媒体引用 diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts index d16e041773..343ad9ba5f 100644 --- a/packages/api/session-controller/src/agent.ts +++ b/packages/api/session-controller/src/agent.ts @@ -92,21 +92,11 @@ export type ApiSessionAgentResult = /** * Test whether generic Session routing must leave an identity to subagent routing. - * @param ctx - Host context carrying the Agent ownership registry. - * @param session - attached or live Session whose ownership is tested. - * @param agent - live Agent when one exists for the Session. + * @param session - Attached or live Session whose origin is tested. * @returns whether subagent routing owns the Session identity. */ -export function hasApiSessionSubagentOwner( - ctx: Context, - session: Pick, - agent: Agent | undefined, -): boolean { - if (session.header.origin === 'subagent') return true - const parentId = session.header.parentSession - if (parentId === undefined || agent === undefined) return false - const parent = ctx.agents.get(parentId) - return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent) +export function hasApiSessionSubagentOwner(session: Pick): boolean { + return session.header.origin === 'subagent' } /** @@ -178,7 +168,7 @@ export class ApiSessionAgentController { const live = this.liveAgent(sessionId) if (live !== undefined) return live const attached = this.ctx.sessions.get(sessionId) - if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, undefined)) { + if (attached !== undefined && hasApiSessionSubagentOwner(attached)) { return { error: apiSessionSubagentOwnershipError(sessionId) } } @@ -203,7 +193,7 @@ export class ApiSessionAgentController { const raced = this.liveAgent(sessionId) if (raced !== undefined) return raced const racedSession = this.ctx.sessions.get(sessionId) - if (racedSession !== undefined && hasApiSessionSubagentOwner(this.ctx, racedSession, undefined)) { + if (racedSession !== undefined && hasApiSessionSubagentOwner(racedSession)) { return { error: apiSessionSubagentOwnershipError(sessionId) } } if (error instanceof Error && error.name === 'SessionAlreadyOwnedError') { @@ -286,7 +276,7 @@ export class ApiSessionAgentController { private liveAgent(sessionId: SessionId): ApiSessionAgentResult | undefined { const agent = this.ctx.agents.get(sessionId) if (agent === undefined) return undefined - return hasApiSessionSubagentOwner(this.ctx, agent.session, agent) + return hasApiSessionSubagentOwner(agent.session) ? { error: apiSessionSubagentOwnershipError(sessionId) } : { agent } } @@ -312,13 +302,12 @@ export class ApiSessionAgentController { if (observation.header.id !== sessionId || observation.header.cwd === undefined) { throw new ApiSessionNotFound(`session "${sessionId}" not found`) } - if (hasApiSessionSubagentOwner(this.ctx, { header: observation.header }, undefined)) { + if (hasApiSessionSubagentOwner({ header: observation.header })) { throw new ApiSessionSubagentOwnership(sessionId) } const composition = await this.composeAgent(this.presetForObservation(observation)) const published = this.ctx.sessions.get(sessionId) - const live = this.ctx.agents.get(sessionId) - if (published !== undefined && hasApiSessionSubagentOwner(this.ctx, published, live)) { + if (published !== undefined && hasApiSessionSubagentOwner(published)) { throw new ApiSessionSubagentOwnership(sessionId) } return (await this.ctx.agents.resume({ @@ -343,7 +332,7 @@ export class ApiSessionAgentController { private async claim(sessionId: SessionId, request: ApiSessionCreateRequest): Promise { const attached = this.ctx.sessions.get(sessionId) const live = this.ctx.agents.get(sessionId) - if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, live)) { + if (attached !== undefined && hasApiSessionSubagentOwner(attached)) { throw new ApiSessionSubagentOwnership(sessionId) } if (live !== undefined) { @@ -363,7 +352,7 @@ export class ApiSessionAgentController { let reusable: boolean { using observed = observation - if (hasApiSessionSubagentOwner(this.ctx, { header: observed.header }, undefined)) { + if (hasApiSessionSubagentOwner({ header: observed.header })) { throw new ApiSessionSubagentOwnership(sessionId) } if (observed.header.cwd !== request.cwd) { diff --git a/packages/api/session-controller/src/archived-session-gate.ts b/packages/api/session-controller/src/archived-session-gate.ts index 06c6ee5f02..b1f556b397 100644 --- a/packages/api/session-controller/src/archived-session-gate.ts +++ b/packages/api/session-controller/src/archived-session-gate.ts @@ -4,7 +4,9 @@ * it is restored. The work a Session still runs is reported and stopped by * its owners — the Agent registry (`turn`), the job registry seam (`job`), * the Subagent runtime (`subagent`), and the Schedule plugin (`schedule`) — - * through the Workspace registry's archive-admission events. + * through the Workspace registry's archive-admission events. The Subagent + * runtime also checks its retained ancestry before each step, so a released + * intermediate Session cannot hide an archived ancestor. */ import type { Context, Plugin } from '@deepseek-ai/cordis' @@ -33,9 +35,10 @@ export const ArchivedSessionGate: Plugin.Object = { /** * Whether the Agent's Session, or a Session above it in its subagent lineage, - * is archived. Lineage follows the durable header fields through + * is archived. This header-only check follows the durable fields through * subagent-origin Sessions only: a fork of an archived Session is an - * independent conversation. + * independent conversation. The Subagent runtime separately checks managed + * ancestry when an intermediate Session is unavailable here. * @param ctx - Host context. * @param agent - the Agent proposing a step. * @returns whether an archived Session owns the step. diff --git a/packages/api/session-controller/src/client/contract/snapshot.ts b/packages/api/session-controller/src/client/contract/snapshot.ts index 78e0c8c8a3..4912d19b16 100644 --- a/packages/api/session-controller/src/client/contract/snapshot.ts +++ b/packages/api/session-controller/src/client/contract/snapshot.ts @@ -73,8 +73,6 @@ export interface SessionSnapshot { readonly running: boolean readonly subagent: { readonly address: SubagentAddress - /** Absent until the direct-parent catalog resolves. */ - readonly parentAvailable?: boolean } | null readonly removed: boolean readonly openState: OpenState diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index ecb6a0e127..e7450ca973 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -147,12 +147,7 @@ export class SessionManager { throw new Error(`sessions.retain: unknown session ${id}`) } if (address !== undefined) this.addresses.set(id, address) - this.sessions.get(id)?.configureSubagent( - address, - address === undefined - ? undefined - : this.agentAvailable(address.parentSessionId), - ) + this.sessions.get(id)?.configureSubagent(address) return id } @@ -276,14 +271,8 @@ export class SessionManager { private createSession(sessionId: SessionId): Session { const address = this.addresses.get(sessionId) - const parentAvailable = address === undefined - ? undefined - : this.agentAvailable(address.parentSessionId) return new Session(sessionId, this.remote, { - ...(address === undefined ? {} : { - address, - ...parentAvailable === undefined ? {} : { parentAvailable }, - }), + ...(address === undefined ? {} : { address }), // The sender's local first-send flip mirrors into the list row so the // session surfaces (lists filter on blank) before any host frame lands. onEngaged: (engaged) => { @@ -400,18 +389,6 @@ export class SessionManager { return operation } - private agentAvailable(sessionId: SessionId): boolean | undefined { - return this.summaries.find(summary => summary.sessionId === sessionId)?.agentAvailable - ?? (this.listPhase === 'ready' ? false : undefined) - } - - private updateParentAvailability(): void { - for (const [childId, address] of this.addresses) { - const available = this.agentAvailable(address.parentSessionId) - if (available !== undefined) this.sessions.get(childId)?.handleSubagentParentAvailable(available) - } - } - // ---- List API ---- /** Full refresh via session.list (single-flight within one Host generation). */ @@ -446,7 +423,7 @@ export class SessionManager { for (const sessionId of this.engagedSessions) this.pruneEngagement(sessionId, retained) this.listState = 'idle' this.listPhase = 'ready' - this.updateParentAvailability() + // Resident Sessions and list rows share one display blank, reconciled with the metadata projection. for (const s of this.summaries) { const session = this.sessions.get(s.sessionId) @@ -599,7 +576,6 @@ export class SessionManager { */ private mergeSummary(summary: SessionSummary): void { this.recordMutation({ kind: 'upsert', summary }) - this.updateParentAvailability() } /** Apply immediately and retain for replay when a list response is in flight. */ @@ -721,11 +697,6 @@ export class SessionManager { this.projectionInflight.get(sessionId)?.controller.abort() this.projectionInflight.delete(sessionId) this.projectionLoads.delete(sessionId) - for (const [childId, address] of this.addresses) { - if (address.parentSessionId === sessionId) { - this.sessions.get(childId)?.handleSubagentParentAvailable(false) - } - } } /** @@ -736,7 +707,6 @@ export class SessionManager { handleSessionStatus(sessionId: SessionId, running: boolean): void { if (!this.disposed && running) this.engagedSessions.add(sessionId) this.recordMutation({ kind: 'status', sessionId, running, agentAvailable: true }) - this.updateParentAvailability() this.sessions.get(sessionId)?.handleRunning(running) } diff --git a/packages/api/session-controller/src/client/sessions/remotes.ts b/packages/api/session-controller/src/client/sessions/remotes.ts index 8e084384d4..bbe026bf4e 100644 --- a/packages/api/session-controller/src/client/sessions/remotes.ts +++ b/packages/api/session-controller/src/client/sessions/remotes.ts @@ -30,10 +30,8 @@ export interface SessionSubagentsRemote { request: SubagentPromptRequest, signal?: AbortSignal, ): Promise> - interruptByParent( + interruptByUser( childSessionId: SessionId, - parentSessionId: SessionId, - mode: 'continuable', ): Promise> } diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 253da9c463..e74dd84422 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -71,8 +71,6 @@ interface PendingHistory { export interface SessionOptions { /** Catalog-discovered address selecting non-activating subagent transport. */ address?: SubagentAddress - /** Whether the exact direct parent Agent is available in Host summaries; absent until known. */ - parentAvailable?: boolean /** * Publish each accepted prompt to the Manager, including after this Session * object is replaced. Acceptance converts display state, but does not @@ -113,7 +111,6 @@ export class Session implements SessionFace { private readonly assistantStream = new ClientAssistantStream() private running = false private address: SubagentAddress | undefined - private parentAvailable: boolean | undefined /** * Sticky send marker, private input of the composerPhase derivation: set * when the Host accepts a prompt, never reset — the blank → engaging edge of @@ -193,7 +190,6 @@ export class Session implements SessionFace { ) { this.projections = options.projections ?? new ProjectionValueStore() this.address = options.address - this.parentAvailable = options.parentAvailable this.notifier = new Notifier(() => { this.snapshotCache = this.buildSnapshot() }) @@ -294,9 +290,7 @@ export class Session implements SessionFace { const routedContent = content as Exclude[] const routed = await this.remote.subagents.prompt({ requestId: requestId ?? randomUUID() as SessionRequestId, - parentSessionId: this.address.parentSessionId, childSessionId: this.address.childSessionId, - mode: 'continuable', delivery: mode, content: routedContent, clientTimeZone: resolvedClientTimeZone(), @@ -363,18 +357,14 @@ export class Session implements SessionFace { /** * Stop the active turn while the Host preserves pending inbox work; failures * land in promptError (same error-strip display slot). A subagent address - * routes through `subagents.interruptByParent`, whose durable parent-address - * authority works without a live parent Agent. + * routes through `subagents.interruptByUser`, authorized on the target Session + * without requiring a live parent Agent. * @returns the cancel result. */ async cancel(): Promise> { const address = this.address const result = address !== undefined - ? await this.remote.subagents.interruptByParent( - address.childSessionId, - address.parentSessionId, - 'continuable', - ) + ? await this.remote.subagents.interruptByUser(address.childSessionId) : await this.remote.session.cancel({ sessionId: this.sessionId }) if (!result.ok) { this.promptError = { op: 'stop', error: result.error } @@ -562,28 +552,16 @@ export class Session implements SessionFace { * Install or clear the catalog-discovered transport address. A changed * address rebuilds an already-open window through its new history route. * @param address - direct parent/child address, or undefined for ordinary transport. - * @param parentAvailable - latest exact-parent availability hint, or undefined before a catalog read. */ - configureSubagent(address: SubagentAddress | undefined, parentAvailable?: boolean): void { + configureSubagent(address: SubagentAddress | undefined): void { const same = this.address?.parentSessionId === address?.parentSessionId && this.address?.childSessionId === address?.childSessionId && this.address?.mode === address?.mode this.address = address - this.parentAvailable = parentAvailable if (!same && this.openState !== 'cold') void this.resync() else this.notifier.markDirty() } - /** - * Update only the parent availability hint from a catalog refresh. - * @param available - whether the exact direct parent is live. - */ - handleSubagentParentAvailable(available: boolean): void { - if (this.parentAvailable === available) return - this.parentAvailable = available - this.notifier.markDirty() - } - /** * Apply the Manager's effective display blank, further reconciled with the * current `sessionListMetadata` projection. Local send attempts and current @@ -934,7 +912,6 @@ export class Session implements SessionFace { address: this.address.mode === 'unknown' && identity != null ? { ...this.address, mode: identity.mode } : this.address, - ...(this.parentAvailable === undefined ? {} : { parentAvailable: this.parentAvailable }), }, removed: this.removed, openState: this.openState, diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 5309872331..d306953c8c 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -409,7 +409,7 @@ export class SessionCommandController { } agent = found.agent } - if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { + if (hasApiSessionSubagentOwner(agent.session)) { const identity = this.ctx.sessionProjections .snapshot(agent.session, ['subagent']) .values.subagent @@ -470,7 +470,7 @@ export class SessionCommandController { { sessionId: request.sessionId }, ) } - if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { + if (hasApiSessionSubagentOwner(agent.session)) { throw apiSessionSubagentOwnershipError(request.sessionId) } agent.cancel({ kind: 'user' }, { keepInbox: true }) diff --git a/packages/api/session-controller/tests/list-scheduling.host.spec.ts b/packages/api/session-controller/tests/list-scheduling.host.spec.ts index d589dfd5ca..0cba6939d7 100644 --- a/packages/api/session-controller/tests/list-scheduling.host.spec.ts +++ b/packages/api/session-controller/tests/list-scheduling.host.spec.ts @@ -70,7 +70,7 @@ async function harness(rows: readonly Row[], summaryMs = 8, workSliceMs = 8) { ctx.sessions.announce(session) const agent = { id: session.id, session, ctx, status: 'idle' as Agent['status'] } // Only registry lookup and status are consumed; no Agent execution occurs in this fixture. - const detachAgent = ctx.agents.enter(agent as Agent, undefined) + const detachAgent = ctx.agents.enter(agent as Agent) ctx.effect(() => detachAgent, 'list-scheduling.agent') agents.set(id, agent) detach.set(id, () => { detachAgent(); detachSession() }) diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index 1946dfdeca..4e64470548 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -862,7 +862,6 @@ describe('subagent catalogs', () => { address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable', }, - parentAvailable: true, }) // Clicking the same child through an ordinary list-selection path must not // erase the catalog-derived address and fall back to session.* transport. @@ -874,7 +873,6 @@ describe('subagent catalogs', () => { address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable', }, - parentAvailable: true, }) await manager.get(S2).open() await manager.get(S2).prompt([{ type: 'text', text: 'continue' }], 'queue') @@ -895,8 +893,7 @@ describe('subagent catalogs', () => { expect(remote.subagents.prompt.mock.calls.map(([request]) => request)).toEqual([ { requestId: expect.any(String) as unknown as string, - parentSessionId: S1, childSessionId: S2, - mode: 'continuable', + childSessionId: S2, delivery: 'queue', content: [{ type: 'text', text: 'continue' }], clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone, @@ -1061,36 +1058,6 @@ describe('subagent catalogs', () => { expect(remote.session.projections).toHaveBeenCalledTimes(2) }) - it.for([false, true])('keeps live parent availability when an earlier initial read settles (failure: %s)', async (failure, { mock, remote }) => { - const response = Promise.withResolvers>>() - remote.session.projections.mockImplementation(() => response.promise) - const manager = makeManager(mock, remote) - manager.resolveTarget({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' }) - const child = manager.get(S2) - const read = manager.refreshProjections(S1) - manager.handleSessionAdded(summary(S1)) - manager.handleSessionStatus(S1, false) - response.resolve(failure - ? err(new RemoteError('gateway/internal', 'offline', {})) - : ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [] } })) - await read - expect(child.getSnapshot().subagent?.parentAvailable).toBe(true) - expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(1) - }) - - it('keeps parent availability unknown after list failure and accepts a later Host summary', async ({ mock, remote }) => { - const manager = makeManager(mock, remote) - manager.resolveTarget({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' }) - const child = manager.get(S2) - remote.session.list.mockResolvedValue(err(new RemoteError('gateway/internal', 'offline', {}))) - await manager.refreshList() - expect(manager.getListSnapshot().state).toBe('error') - expect(child.getSnapshot().subagent?.parentAvailable).toBeUndefined() - manager.handleSessionAdded(summary(S1)) - expect(child.getSnapshot().subagent?.parentAvailable).toBe(true) - await manager.dispose() - }) - it('keeps a pushed catalog when an earlier initial read reports the parent missing', async ({ mock, remote }) => { const response = Promise.withResolvers>>() remote.session.projections.mockImplementation(() => response.promise) @@ -1260,23 +1227,6 @@ describe('remaining branches', () => { await manager.dispose() }) - it('preserves Host availability when a local create echo follows Agent disposal', async ({ mock, remote }) => { - const response = Promise.withResolvers>>() - remote.session.create.mockImplementation(() => response.promise) - const manager = makeManager(mock, remote) - manager.resolveTarget({ - parentSessionId: S1, childSessionId: S2, mode: 'continuable', - }) - const child = manager.get(S2) - const request = manager.create() - manager.handleSessionAdded(summary(S1, { agentAvailable: false })) - response.resolve(ok({ sessionId: S1 })) - await request - manager.resolveTarget(S2) - expect(child.getSnapshot().subagent?.parentAvailable).toBe(false) - await manager.dispose() - }) - it('publishes a real Ungrouped summary from workspace-attach-failed', async ({ mock, remote }) => { remote.session.create.mockResolvedValue(err(new RemoteError('session/workspace-attach-failed', 'published but unattached', { sessionId: S1, workspaceId: 'w1', @@ -1631,7 +1581,6 @@ describe('connected generation', () => { }) expect(manager.get(S2).getSnapshot().subagent).toEqual({ address, - parentAvailable: true, }) expect(manager.subagentAddress(S2)).toEqual(address) }) diff --git a/packages/api/session-controller/tests/remote/bench.client.ts b/packages/api/session-controller/tests/remote/bench.client.ts index 6e0f10d569..a696fd0c28 100644 --- a/packages/api/session-controller/tests/remote/bench.client.ts +++ b/packages/api/session-controller/tests/remote/bench.client.ts @@ -11,7 +11,7 @@ import { sessionWorld } from './session.client.ts' * @param mock - this case's Remote responses. * @param start - the fixture's lazy client startup. * @param sessionId - Session identity. - * @param options - addressed child and parent availability, when applicable. + * @param options - addressed child and other Session options, when applicable. * @returns the Session, disposed at the end of the case. */ export async function sessionBench( diff --git a/packages/api/session-controller/tests/remote/session.client.ts b/packages/api/session-controller/tests/remote/session.client.ts index f920d1592e..c4d8049177 100644 --- a/packages/api/session-controller/tests/remote/session.client.ts +++ b/packages/api/session-controller/tests/remote/session.client.ts @@ -147,7 +147,7 @@ export const sessionWorld: RemoteTable = { 'session/page': pageRule(ok({ cursor: -1, records: [], hasMore: false })), 'session/projections': ok({ kind: 'sequenced', asOfSeq: -1, values: { subagentCatalog: [] } }), 'subagents/prompt': ok({ messageId: 'fake-message' }), - 'subagents/interruptByParent': ok({ accepted: true }), + 'subagents/interruptByUser': ok({ accepted: true }), }, stream: { 'session/follow': followScript(ok({ cursor: -1, records: [], hasMore: false })), 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 7cfc874c14..49e0b3210d 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -673,10 +673,10 @@ describe('subagent ownership fence', () => { await ctx.agents.register(originChild) const startingSession = ctx.sessions.create(sid('session-starting-child'), { - meta: { cwd: '/proj', parentSession: parent.id }, + meta: { cwd: '/proj', parentSession: parent.id, origin: 'subagent' }, }) const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent - ctx.agents.enter(startingChild, parent) + ctx.agents.enter(startingChild) const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const stopped = await remote.cancel(request({ sessionId: originChild.id })) diff --git a/packages/api/session-controller/tests/session-remote-rejection.client.spec.ts b/packages/api/session-controller/tests/session-remote-rejection.client.spec.ts index a6e7a274aa..2fb870f910 100644 --- a/packages/api/session-controller/tests/session-remote-rejection.client.spec.ts +++ b/packages/api/session-controller/tests/session-remote-rejection.client.spec.ts @@ -7,21 +7,28 @@ */ import { describe, expect, it } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import { RemoteMock } from '@deepseek-ai/dsh-remote-mock' import { Session } from '../src/client/sessions/session.ts' import type { SessionRemotes } from '../src/client/sessions/remotes.ts' const SID = 'fk-s1' as SessionId const PARENT = 'fk-parent' as SessionId -/** Every command rejects with `message`; nothing else on the face is reachable from prompt or cancel. */ +/** Calls used by Session prompt and cancel reject with `message`. */ function rejecting(message: string): SessionRemotes { const reject = (): Promise => Promise.reject(new Error(message)) + const { remote } = RemoteMock.create() + remote.commands.execute.mockImplementation(reject) + remote.session.prompt.mockImplementation(reject) + remote.session.cancel.mockImplementation(reject) + remote.subagents.prompt.mockImplementation(reject) + remote.subagents.interruptByUser.mockImplementation(reject) return { $stream: () => { throw new Error('unused') }, - commands: { execute: reject }, - session: { prompt: reject, cancel: reject }, - subagents: { list: reject, prompt: reject, interruptByParent: reject }, - } as unknown as SessionRemotes + commands: remote.commands, + session: remote.session, + subagents: remote.subagents, + } } describe('Session over a rejecting Remote face', () => { @@ -35,7 +42,6 @@ describe('Session over a rejecting Remote face', () => { it('propagates the rejection from a subagent continuation prompt and interrupt', async () => { const session = new Session(SID, rejecting('assembly fault'), { address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, - parentAvailable: true, }) await expect(session.prompt([{ type: 'text', text: 'x' }], 'queue')).rejects.toThrow('assembly fault') await expect(session.cancel()).rejects.toThrow('assembly fault') diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index 70c69ca033..e118ef29e5 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -474,7 +474,7 @@ describe('prompt and cancel errors', () => { const CHILD = { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' } as const it('routes an addressed child through non-activating history, continuation prompt, and interrupt only', async ({ mock, start }) => { - const session = await sessionBench(mock, start, SID, { address: CHILD, parentAvailable: true }) + const session = await sessionBench(mock, start, SID, { address: CHILD }) await session.open() const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue') const steered = await session.prompt([{ type: 'text', text: '现在处理' }], 'steer') @@ -491,34 +491,34 @@ describe('prompt and cancel errors', () => { expect(mock.log.requests('subagents/prompt')).toEqual([ { requestId: expect.any(String) as unknown as string, - ...CHILD, + childSessionId: SID, delivery: 'queue', content: [{ type: 'text', text: '继续' }], clientTimeZone: TIME_ZONE, }, { requestId: expect.any(String) as unknown as string, - ...CHILD, + childSessionId: SID, delivery: 'steer', content: [{ type: 'text', text: '现在处理' }], clientTimeZone: TIME_ZONE, }, ]) // Positional parameters cross the wire as positional args. - expect(mock.log.calls('subagents/interruptByParent').map(call => call.args)).toEqual([[SID, PARENT, 'continuable']]) + expect(mock.log.calls('subagents/interruptByUser').map(call => call.args)).toEqual([[SID]]) expect(mock.log.requests('session/prompt')).toEqual([]) expect(mock.log.requests('session/cancel')).toEqual([]) // A successful interrupt leaves no stop error behind. expect(session.getSnapshot().promptError).toBeNull() - expect(session.getSnapshot().subagent).toEqual({ address: CHILD, parentAvailable: true }) + expect(session.getSnapshot().subagent).toEqual({ address: CHILD }) }) it('sends an addressed child continuation under its echo request id and retires a refused echo', async ({ mock, start }) => { - const session = await sessionBench(mock, start, SID, { address: CHILD, parentAvailable: true }) + const session = await sessionBench(mock, start, SID, { address: CHILD }) await session.open() const accepted = session.beginSubmission({ mode: 'queue', text: '继续', attachments: [] }) await session.prompt([{ type: 'text', text: '继续' }], 'queue', undefined, accepted.requestId) - expect(mock.log.requests('subagents/prompt')).toMatchObject([{ requestId: accepted.requestId, ...CHILD }]) + expect(mock.log.requests('subagents/prompt')).toMatchObject([{ requestId: accepted.requestId, childSessionId: SID }]) expect(session.getSnapshot().pendingSubmissions.map(echo => echo.requestId)).toEqual([accepted.requestId]) const refused = session.beginSubmission({ mode: 'queue', text: '附件', attachments: [] }) @@ -527,7 +527,7 @@ describe('prompt and cancel errors', () => { }) it('forwards continuation image parts to the subagent prompt Remote unstripped', async ({ mock, start }) => { - const session = await sessionBench(mock, start, SID, { address: CHILD, parentAvailable: true }) + const session = await sessionBench(mock, start, SID, { address: CHILD }) await session.open() const content = [ { type: 'text' as const, text: '看这张图' }, @@ -539,7 +539,7 @@ describe('prompt and cancel errors', () => { expect(mock.log.requests('subagents/prompt')).toEqual([ { requestId: expect.any(String) as unknown as string, - ...CHILD, + childSessionId: SID, delivery: 'queue', content, clientTimeZone: TIME_ZONE, @@ -549,18 +549,18 @@ describe('prompt and cancel errors', () => { }) it('lands an interrupt business failure in promptError with op=stop', async ({ mock, start }) => { - const session = await sessionBench(mock, start, SID, { address: CHILD, parentAvailable: true }) - mock.remote.subagents.interruptByParent.mockResolvedValue(err(new RemoteError('subagent/unauthorized', 'nope', { childSessionId: SID }))) + const session = await sessionBench(mock, start, SID, { address: CHILD }) + mock.remote.subagents.interruptByUser.mockResolvedValue(err(new RemoteError('remote-access/denied', 'nope', { action: 'subagents/interruptByUser', reason: 'no-allow' }))) await session.open() const cancelled = await session.cancel() - expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent/unauthorized' } }) + expect(cancelled).toMatchObject({ ok: false, error: { code: 'remote-access/denied' } }) expect(session.getSnapshot().promptError).toMatchObject({ - op: 'stop', error: { code: 'subagent/unauthorized' }, + op: 'stop', error: { code: 'remote-access/denied' }, }) }) it('rejects staged files instead of dropping them from subagent continuations', async ({ mock, start }) => { - const session = await sessionBench(mock, start, SID, { address: CHILD, parentAvailable: true }) + const session = await sessionBench(mock, start, SID, { address: CHILD }) await session.open() const prompted = await session.prompt([ @@ -578,7 +578,7 @@ describe('prompt and cancel errors', () => { expect(mock.log.requests('subagents/prompt')).toEqual([]) }) - it('sends a one-shot address to the Host under the continuable marker', async ({ mock, start }) => { + it('lets the Host validate the target continuation state', async ({ mock, start }) => { const session = await sessionBench(mock, start, SID, { address: { ...CHILD, mode: 'one-shot' } }) mock.remote.subagents.prompt.mockResolvedValue(err(new RemoteError( 'subagent/not-resumable', 'subagent cannot be resumed', { childSessionId: SID }, @@ -587,11 +587,10 @@ describe('prompt and cancel errors', () => { const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue') const cancelled = await session.cancel() - // The Host reads the durable descriptor; the wire marker stays 'continuable'. expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent/not-resumable' } }) expect(cancelled).toEqual({ ok: true, value: { accepted: true } }) - expect(mock.log.requests('subagents/prompt')).toMatchObject([CHILD]) - expect(mock.log.calls('subagents/interruptByParent').map(call => call.args)).toEqual([[SID, PARENT, 'continuable']]) + expect(mock.log.requests('subagents/prompt')).toMatchObject([{ childSessionId: SID }]) + expect(mock.log.calls('subagents/interruptByUser').map(call => call.args)).toEqual([[SID]]) expect(mock.log.requests(FOLLOW)).toEqual([ { address: { kind: 'subagent', ...CHILD, mode: 'one-shot' }, activate: true, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, ]) diff --git a/packages/api/session-controller/tests/subagent-access.host.spec.ts b/packages/api/session-controller/tests/subagent-access.host.spec.ts new file mode 100644 index 0000000000..ad077c695b --- /dev/null +++ b/packages/api/session-controller/tests/subagent-access.host.spec.ts @@ -0,0 +1,128 @@ +/** Target Session permissions are enforced before subagent input or cancellation is admitted. */ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import TypertGatewayService from '@deepseek-ai/dsh-api-gateway' +import type { RemoteAccessBinding } from '@deepseek-ai/dsh-api-remote-access' +import { SessionId } from '@deepseek-ai/dsh-session' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import type { PeerId, PeerScope, Principal } from '@deepseek-ai/dsh-typert-protocol' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MockAdapter } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { mountWorkingDirectoryFixture } from '../../../subagent/subagent/tests/working-directory-fixture.ts' +import { createSessionTestController } from './test-remote.ts' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface PrincipalMap { + /** Test-only non-owner caller shared by the access specs. */ + 'test-guest': { readonly kind: 'test-guest'; readonly guest: string } + } +} + +const GUEST: Principal = { kind: 'test-guest', guest: 'subagent-controls' } +const ACTIONS = ['subagents/prompt', 'subagents/interruptByUser'] as const +const cleanups: Array<() => Promise> = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup() +}) + +async function harness() { + const root = await mkdtemp(join(tmpdir(), 'dsh-subagent-access-')) + const ctx = new Context() + cleanups.push(async () => { + try { await ctx.fiber.dispose() } finally { await rm(root, { recursive: true, force: true }) } + }) + await ctx.plugin(TypertRegistry) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(JsonlSessionPersistence, { root }) + await ctx.plugin(AgentLoop, { agents: [] }) + await mountWorkingDirectoryFixture(ctx) + await createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'mock', model: 'mock' }), cwd: root }) + await ctx.plugin(SubagentRuntime) + ctx.subagents.registerProvider({ + name: 'test', inheritsParentContext: false, + capabilities: { agentOptions: true, agentPreset: false, outputSchema: false, depthLimit: false }, + prepareContinuable: async () => ({}), + }) + const adapter = new MockAdapter(['hang']) + ctx.llm.registerAdapter(['mock'], adapter) + const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + const run = await ctx.subagents.startActivation({ + provider: 'test', label: 'access target', delivery: 'caller', + parent, prompt: [{ type: 'text', text: 'hold' }], + signal: new AbortController().signal, + }) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const child = ctx.agents.get(run.childId)! + await ctx.plugin(TypertGatewayService) + const bindings: RemoteAccessBinding[] = [] + ctx.remoteAccess.registerPolicy('test-guest', { bindings: () => bindings }) + const call = (method: 'prompt' | 'interruptByUser', principal: Principal = GUEST, image = false) => { + const peer: PeerScope = { + id: 'subagent-access-peer' as PeerId, ctx, principal, interactions: 'answer', + signal: new AbortController().signal, dispose: async () => {}, + } + return ctx.typertGateway.invoke({ + peer, namespace: 'subagents', method, + args: method === 'prompt' ? { request: { + requestId: 'access-follow-up', childSessionId: child.id, delivery: 'queue', + content: image ? [{ type: 'image', mediaType: 'image/png', data: 'aGk=' }] : [{ type: 'text', text: 'follow-up' }], + } } : { childSessionId: child.id }, + }) + } + return { ctx, parent, child, bindings, call } +} + +describe('subagent target Session access', () => { + it.each(['prompt', 'interruptByUser'] as const)('requires the target permission for %s', async (method) => { + const { ctx, parent, child, bindings, call } = await harness() + const attachments = vi.spyOn(ctx.attachments, 'admitPromptContent') + const observe = vi.spyOn(ctx.sessionQuery, 'observeSession') + const cancel = vi.spyOn(child, 'cancel') + const grants: RemoteAccessBinding[][] = [ + [], + [{ role: { name: 'global controls', resource: 'global', actions: ACTIONS }, resource: { kind: 'global' } }], + [{ role: { name: 'parent controls', resource: 'session', actions: ACTIONS }, resource: { kind: 'session', id: parent.id } }], + [{ role: { name: 'other control', resource: 'session', actions: [method === 'prompt' ? ACTIONS[1] : ACTIONS[0]] }, resource: { kind: 'session', id: child.id } }], + ] + for (const grant of grants) { + bindings.splice(0, bindings.length, ...grant) + await expect(call(method, GUEST, true)).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: `subagents/${method}`, reason: 'no-allow' }, + }) + } + expect(attachments).not.toHaveBeenCalled() + expect(observe).not.toHaveBeenCalled() + expect(cancel).not.toHaveBeenCalled() + expect(child.inbox.nextTurn).toHaveLength(0) + + bindings.splice(0, bindings.length, { + role: { name: 'target control', resource: 'session', actions: [`subagents/${method}`] }, + resource: { kind: 'session', id: child.id }, + }) + const accepted = await call(method) + if (method === 'prompt') { + expect(child.inbox.nextTurn).toHaveLength(1) + expect(accepted).toEqual({ messageId: child.inbox.nextTurn[0]?.id }) + expect(child.inbox.nextTurn[0]?.source).toMatchObject({ kind: 'user', principal: GUEST }) + } else { + expect(accepted).toEqual({ accepted: true }) + expect(cancel).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true }) + await child.whenIdle() + } + }) + + it('allows the owner without target bindings', async () => { + const { child, call } = await harness() + const accepted = await call('prompt', { kind: 'owner' }) + expect(child.inbox.nextTurn).toHaveLength(1) + expect(accepted).toEqual({ messageId: child.inbox.nextTurn[0]?.id }) + await expect(call('interruptByUser', { kind: 'owner' })).resolves.toEqual({ accepted: true }) + await child.whenIdle() + }) +}) diff --git a/packages/api/session-controller/tests/transport.client.spec.ts b/packages/api/session-controller/tests/transport.client.spec.ts index 2568f3f807..db0857e5b7 100644 --- a/packages/api/session-controller/tests/transport.client.spec.ts +++ b/packages/api/session-controller/tests/transport.client.spec.ts @@ -83,7 +83,7 @@ function sessionClient(remote: SessionTransportRemote): SessionRemotes { commands: { execute: () => Promise.reject(new Error('stream tests never run commands')) }, subagents: { prompt: () => Promise.reject(new Error('stream tests never prompt a subagent')), - interruptByParent: () => Promise.reject(new Error('stream tests never interrupt a subagent')), + interruptByUser: () => Promise.reject(new Error('stream tests never interrupt a subagent')), }, } } diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 9ce0beae8e..98cb511540 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-app-boot/use-this-package: - en: f6fa378225009c8f - zh: 324c0e4640bb1358 + en: 8e42ac55d685e0f1 + zh: e9e8804af1b2df2b /deepseek-ai-dsh-app-boot/use-this-package/when-to-use-it: en: 2fdfe02f2ed4a258 zh: d748228ece579c4a diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 4d30457f81..46fc9be865 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -27,6 +27,8 @@ English | [中文](README.zh.md) Starting an app with this package is a small, explicit entry point: you give it a config file and it runs the whole boot. This section covers what you can do and what you get; the helper calls behind each outcome are documented in the folded implementation section. +Profile loading removes retired installation-bundle selections from `dsh.profile.bundles` and rewrites the manifest only when needed, preserving other fields. [Team migration](../../../docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.md) describes the default composition and retained row overrides. + ### When to use it Use it when implementing the shared `dsh` launcher or embedding its lower-level boot helpers. Product features belong in profile bundles instead of new application bins; code that only adds plugins to an already-running app mounts those plugins directly. diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index a4d8554ca5..1bc7234f55 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -27,6 +27,8 @@ kind: "package-library" 用此包启动应用是一个小而显式的入口:你给它一个配置文件,它运行整个启动过程。本节说明你能做什么、能得到什么;每个结果背后的 helper 调用记录在下方可折叠的实现章节中。 +profile 加载时会从 `dsh.profile.bundles` 移除已停用的安装方 bundle 选项,仅在需要时重写 manifest,保留其他字段。[Team 迁移](../../../docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.zh.md)说明默认组合与保留的行覆盖。 + ### 何时使用 在实现共享 `dsh` launcher 或嵌入其低层启动 helper 时使用它。产品功能应放入 profile 组合包,而不是新增应用 bin;只向已运行应用添加插件的代码直接挂载插件即可。 diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts index 0a67d476e1..238f316dc9 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/packages/boot/app-boot/tests/profile.spec.ts @@ -394,13 +394,13 @@ describe('loadProfile', () => { ]) }) - it('removes a retired bundle from an application-owned profile and keeps the rest of the manifest', () => { + it.each(['@deepseek-ai/dsh-experimental-schedule-bundle', '@deepseek-ai/dsh-experimental-agent-team-profile'])('removes retired %s and keeps the rest of an application-owned manifest', (retired) => { const anchor = stageInstallation({ '@deepseek-ai/dsh-base': { patch: '[]\n' }, 'custom-bundle': { patch: '[]\n' }, }) const dir = join(tmp(), 'app-profile') - initProfile(dir, ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-experimental-schedule-bundle', 'custom-bundle']) + initProfile(dir, ['@deepseek-ai/dsh-base', retired, 'custom-bundle']) writeProfileManifest(dir, { ...readProfileManifest('t', dir), dependencies: { 'custom-bundle': '^1.0.0' } }) const profile = loadProfileDirectory('t', dir, anchor) expect(profile.skippedBundles).toEqual([]) diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index ceb82aac47..b1d696ad74 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -522,7 +522,6 @@ export class PluginManager extends TypertRemoteService { catch (error) { platformError = managementError(error) } const error = platformError ?? targetError ?? (enabled ? { code: 'not-bundle' as const } : undefined) bundles.push({ name, official, availability: 'missing', ...catalogMeta, ...target, enabled, installed, optional, - ...offered.listedByDefault === undefined ? {} : { listedByDefault: offered.listedByDefault }, ...platformError?.code === 'unsupported-platform' ? { listedByDefault: false } : {}, removable: enabled, rows: [], overrides: [], ...error === undefined ? {} : { error } }) diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index 207f34291f..8187c45a9b 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -1811,7 +1811,6 @@ it('lists absent Official entries offline without resolving their packages or co expect(bundles.filter(bundle => bundle.name === entry.packageName)).toMatchObject([{ name: entry.packageName, official: true, availability: 'missing', enabled: false, installed: false, optional: false, removable: false, updatable: false, rows: [], overrides: [], meta: entry.meta, - ...entry.listedByDefault === undefined ? {} : { listedByDefault: entry.listedByDefault }, ...entry.platforms !== undefined && !entry.platforms.includes(process.platform) ? { listedByDefault: false, error: { code: 'unsupported-platform' } } : {}, installTarget: { spec: `${entry.packageName}@${getDshRuntimeVersion()}`, version: getDshRuntimeVersion() }, diff --git a/packages/boot/plugin-market/README.i18n.yaml b/packages/boot/plugin-market/README.i18n.yaml index fb47f93c88..e8352139a5 100644 --- a/packages/boot/plugin-market/README.i18n.yaml +++ b/packages/boot/plugin-market/README.i18n.yaml @@ -18,8 +18,8 @@ en: fef9850d643e327f zh: c6e8cab773cf93b9 /deepseek-ai-dsh-plugin-market/use-this-package/plugin-detail-documents: - en: 0e15de273c9acaae - zh: 4fd93c92b71d4398 + en: b5d96ca29cf76f31 + zh: e5099b4f694cf4c9 /deepseek-ai-dsh-plugin-market/understand-the-implementation: en: 678705f57d986377 zh: 68d090edfe04acde diff --git a/packages/boot/plugin-market/README.md b/packages/boot/plugin-market/README.md index b93adc2bf9..cdd9b211c2 100644 --- a/packages/boot/plugin-market/README.md +++ b/packages/boot/plugin-market/README.md @@ -72,7 +72,7 @@ Text matches the package name, author, keywords, and every language of the title Without any, the result is `{ status: 'none' }`. A found document carries its `origin` (`locale`, `readme`, or `repository`), its Markdown without a leading YAML front matter block, and a `document` id. Results, and the `too-large`, `denied`, and `invalid` failures that reading again would repeat, are cached for `cacheTtlMs`; `refresh` reads again instead, and concurrent reads share one read. A `document` id stays valid while a read returns it or an image request names it at least once every `cacheTtlMs`; an unused id is forgotten, and its images answer 404 until the document is read again. -Repository files are read through GitHub's raw file host for `github.com` and the GitLab repository files API for `detailGitlabHosts`. API requests follow redirects within their origin. When an API answers 401, 403, or 404, a request to the repository's smart HTTP endpoint without any credentials decides whether the repository is public; a public repository's miss means the file does not exist. A repository that needs credentials, and a repository on a host without such an API, is read from a partial clone over https, http, or ssh. Git uses the user's configuration, credential helpers, and `url..insteadOf` rules without prompting, so an https URL can be routed to ssh; ssh runs in batch mode unless `GIT_SSH` or `GIT_SSH_COMMAND` is set. +Repository files are read through GitHub's raw file host for `github.com` and the GitLab repository files API for `detailGitlabHosts`. API requests follow redirects within their origin. When an API answers 401, 403, or 404, a request to the repository's smart HTTP endpoint without any credentials decides whether the repository is public; a public repository's miss means the file does not exist. A repository that needs credentials, and a repository on a host without such an API, is read from a partial clone over https, http, or ssh. Git uses the user's configuration, credential helpers, and `url..insteadOf` rules without prompting, so an https URL can be routed to ssh. When Git refuses an http(s) URL, the clone fetches again from `ssh://git@/`, the SSH form plugin installation also uses, and keeps that remote; a failed retry reports both diagnostics, as `network` or `too-large` for those causes and `denied` otherwise. Opening a detail page can therefore connect over ssh to the host an index entry names; with batch mode and the default host key checking, ssh authenticates only to hosts in the user's known hosts. ssh runs in batch mode unless `GIT_SSH` or `GIT_SSH_COMMAND` is set. `GET /api/plugin-market/detail-file?document=&path=` serves an image beside a read document to authenticated owner connections. The destination is relative to the document, or to the package or repository root when it starts with `/`; its query and fragment are ignored. Only PNG, JPEG, GIF, WebP, AVIF, and SVG files inside that root are served, with `Content-Security-Policy: sandbox`, `X-Content-Type-Options: nosniff`, and a private cache for `cacheTtlMs`. A missing file, unknown document, or other path answers 404; the failures below answer 413, 502, 403, 503, and 400; failure responses are not cached. diff --git a/packages/boot/plugin-market/README.zh.md b/packages/boot/plugin-market/README.zh.md index 5d82b46c77..ca213854bd 100644 --- a/packages/boot/plugin-market/README.zh.md +++ b/packages/boot/plugin-market/README.zh.md @@ -73,7 +73,7 @@ Remote 方法如下: 三者都没有时,结果为 `{ status: 'none' }`。找到的文档携带其 `origin`(`locale`、`readme` 或 `repository`)、去掉开头 YAML front matter 块后的 Markdown 内容和 `document` id。结果,以及再次读取也会重复出现的 `too-large`、`denied` 和 `invalid` 失败,缓存 `cacheTtlMs`;传入 `refresh` 时改为重新读取;并发读取共享一次读取。只要每个 `cacheTtlMs` 内至少有一次读取返回某个 `document` id 或有一次图片请求引用它,该 id 就保持有效;未使用的 id 会被遗忘,在再次读取该文档之前,其图片请求返回 404。 -`github.com` 上的仓库文件通过 GitHub 的原始文件主机读取,`detailGitlabHosts` 中主机上的仓库文件通过 GitLab 仓库文件 API 读取。API 请求跟随同一 origin 内的重定向。API 返回 401、403 或 404 时,由一次不带任何凭据、发往该仓库 smart HTTP 端点的请求判断仓库是否公开;公开仓库中未找到文件即表示该文件不存在。需要凭据的仓库,以及所在主机没有此类 API 的仓库,通过 https、http 或 ssh 从部分克隆中读取。Git 使用用户的配置、凭据助手和 `url..insteadOf` 规则,且不提示输入,因此 https URL 可能被改为通过 ssh 访问;除非设置了 `GIT_SSH` 或 `GIT_SSH_COMMAND`,ssh 以批处理模式运行。 +`github.com` 上的仓库文件通过 GitHub 的原始文件主机读取,`detailGitlabHosts` 中主机上的仓库文件通过 GitLab 仓库文件 API 读取。API 请求跟随同一 origin 内的重定向。API 返回 401、403 或 404 时,由一次不带任何凭据、发往该仓库 smart HTTP 端点的请求判断仓库是否公开;公开仓库中未找到文件即表示该文件不存在。需要凭据的仓库,以及所在主机没有此类 API 的仓库,通过 https、http 或 ssh 从部分克隆中读取。Git 使用用户的配置、凭据助手和 `url..insteadOf` 规则,且不提示输入,因此 https URL 可能被改为通过 ssh 访问。Git 拒绝 http(s) URL 时,部分克隆改从 `ssh://git@/`(插件安装也使用的 SSH 形式)再次获取,并保留该远程地址;重试失败时报告两次的诊断,原因是网络或大小限制时分别报告 `network` 或 `too-large`,否则报告 `denied`。因此打开详情页可能通过 ssh 连接索引条目指定的主机;在批处理模式和默认的主机密钥检查下,ssh 只向用户 known hosts 中的主机认证。除非设置了 `GIT_SSH` 或 `GIT_SSH_COMMAND`,ssh 以批处理模式运行。 `GET /api/plugin-market/detail-file?document=&path=` 向已通过认证的 owner 连接提供已读取文档旁的图片。目标路径相对于该文档;以 `/` 开头时相对于包或仓库的根目录;路径中的查询和片段被忽略。只提供该根目录内的 PNG、JPEG、GIF、WebP、AVIF 和 SVG 文件,响应带有 `Content-Security-Policy: sandbox`、`X-Content-Type-Options: nosniff` 和时长为 `cacheTtlMs` 的私有缓存。文件不存在、文档未知或其他路径返回 404;下文所列的失败依次返回 413、502、403、503 和 400;失败响应不被缓存。 diff --git a/packages/boot/plugin-market/src/index.ts b/packages/boot/plugin-market/src/index.ts index f4f2a26c95..b64d36e702 100644 --- a/packages/boot/plugin-market/src/index.ts +++ b/packages/boot/plugin-market/src/index.ts @@ -142,7 +142,7 @@ export default class PluginMarket extends TypertRemoteService { const lifetime = this.lifetime.signal this.repositories = new RepositoryCache({ dir: cacheDir, maxRepositoryBytes: config.detailRepositoryMaxBytes, maxCacheBytes: config.detailCacheMaxBytes, - // The URL forms repositories are read from, and ssh, which `url..insteadOf` may route them to. + // The URL forms repositories are read from, and ssh, which `url..insteadOf` or the retry after a refused http(s) fetch uses. timeoutMs: config.detailGitTimeoutMs, ttlMs: config.cacheTtlMs, transports: ['https', 'http', 'ssh'], lifetime, // Bounds how far a fetch overshoots `detailRepositoryMaxBytes`; directory walks cost little at this rate. sizePollMs: 200, diff --git a/packages/boot/plugin-market/src/repository-cache.ts b/packages/boot/plugin-market/src/repository-cache.ts index 87a9b51b15..944e79fbd0 100644 --- a/packages/boot/plugin-market/src/repository-cache.ts +++ b/packages/boot/plugin-market/src/repository-cache.ts @@ -15,10 +15,10 @@ import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import { DetailFailure, readmeName, type FoundFile } from '@deepseek-ai/dsh-plugin-detail' /** - * Git deadlines one operation's lock waits for: an operation runs at most a fetch and a file read that contact the - * remote, each bounded by the deadline, plus short local commands. + * Git deadlines one operation's lock waits for: an operation runs at most a fetch, its SSH retry, and a file read that + * contact the remote, each bounded by the deadline, plus short local commands. */ -const OPERATION_COMMANDS = 4 +const OPERATION_COMMANDS = 5 /** Limits, location, and transports of the clone cache. */ export interface RepositoryCacheOptions { @@ -68,6 +68,18 @@ function remoteFailureOf(result: GitResult, label: string): DetailFailure { return new DetailFailure(NETWORK_FAILURE.test(result.diagnostic) ? 'network' : 'denied', `${label}: ${result.diagnostic}`) } +/** + * The SSH form of an http(s) repository URL: `ssh://git@/` on SSH's default port, the form that + * `sshGitSpec` in `@deepseek-ai/dsh-plugin-manager` gives installation. + * @returns undefined for another scheme, or a URL with credentials or a query. + */ +function sshUrlOf(url: string): string | undefined { + if (!/^https?:/iu.test(url)) return undefined + const parsed = new URL(url) + if (parsed.username !== '' || parsed.password !== '' || parsed.search !== '') return undefined + return `ssh://git@${parsed.hostname}${parsed.pathname}` +} + /** A value reused until it expires. */ interface Expiring { readonly value: T @@ -259,17 +271,35 @@ export class RepositoryCache { const head = this.heads.get(url) const known = commit ?? (head !== undefined && head.expiresAt > Date.now() ? head.value : undefined) if (known !== undefined && !(await this.git(['show-ref', '--verify', '--quiet', `refs/dsh/${known}`], repository, signal)).failed) return known - await this.fetch(repository, url, commit ?? 'HEAD', signal) + await this.fetch(repository, commit ?? 'HEAD', signal) const fetched = commit ?? (await this.run(repository, ['rev-parse', 'FETCH_HEAD'], signal)).toString('utf8').trim() await this.run(repository, ['update-ref', `refs/dsh/${fetched}`, fetched], signal) if (commit === undefined) this.heads.set(url, { value: fetched, expiresAt: Date.now() + this.options.ttlMs }) return fetched } - /** Fetch one commit without file contents. */ - private async fetch(repository: string, url: string, target: string, signal: AbortSignal): Promise { + /** + * Fetch one commit without file contents. When Git refuses an http(s) URL, the clone's remote switches to the SSH form + * of that URL and the fetch runs again, so a user with only SSH credentials for the host can read it; later reads of + * the clone keep that remote. A failed SSH fetch reports both diagnostics, as `network` or `too-large` when it failed + * for that reason and as `denied` otherwise. + */ + private async fetch(repository: string, target: string, signal: AbortSignal): Promise { const args = ['fetch', '--quiet', '--depth', '1', '--filter=blob:none', '--no-tags', 'origin', target] - await this.watched(repository, url, args, signal, `${url}: git fetch`) + // The remote is the URL this cache wrote or its SSH form; 64 KiB holds any such URL. + const remote = (await this.run(repository, ['config', 'remote.origin.url'], signal, 64 * 1024)).toString('utf8').trim() + try { + await this.watched(repository, remote, args, signal, `${remote}: git fetch`) + } catch (error) { + const ssh = sshUrlOf(remote) + if (ssh === undefined || !(error instanceof DetailFailure) || error.reason !== 'denied') throw error + await this.run(repository, ['config', 'remote.origin.url', ssh], signal) + await this.watched(repository, ssh, args, signal, `${ssh}: git fetch`).catch((sshError: unknown) => { + throw sshError instanceof DetailFailure && sshError.reason !== 'too-large' + ? new DetailFailure(sshError.reason === 'network' ? 'network' : 'denied', `${error.message}\n${sshError.message}`, { cause: sshError }) + : sshError + }) + } await this.evict(repository) } diff --git a/packages/boot/plugin-market/tests/repository-cache.spec.ts b/packages/boot/plugin-market/tests/repository-cache.spec.ts index 9b9e77803c..350e3989fe 100644 --- a/packages/boot/plugin-market/tests/repository-cache.spec.ts +++ b/packages/boot/plugin-market/tests/repository-cache.spec.ts @@ -193,6 +193,52 @@ it('reports a repository Git cannot fetch as denied', async () => { await expect(repositories.read(source.url, source.commit, 'README.md', 100, signal)).resolves.toBeDefined() }) +it('fetches over SSH when Git refuses the https URL, and reports the https refusal when SSH also fails', async () => { + const source = repository('ssh', { 'README.md': 'over ssh', 'a.png': 'png' }) + // The cache allows no https transport, so Git refuses every https URL; the SSH form, without the https port, reaches the source. + writeFileSync(join(root, 'home', '.gitconfig'), [ + `[url "${source.url}"]`, ' insteadOf = ssh://git@git.example/team/plugin.git', + `[url "${pathToFileURL(join(root, 'missing')).href}"]`, ' insteadOf = ssh://git@git.example/team/other.git', '', + ].join('\n')) + const repositories = cache() + expect(text(await repositories.read('https://git.example:8443/team/plugin.git', undefined, 'README.md', 100, signal))).toBe('over ssh') + // The clone keeps the SSH remote for file contents it downloads later. + expect(text(await repositories.read('https://git.example:8443/team/plugin.git', source.commit, 'a.png', 100, signal))).toBe('png') + // A refused SSH fetch reports both refusals. + const refused = repositories.read('https://git.example/team/other.git', undefined, 'README.md', 100, signal) + await expectRejection(refused, { reason: 'denied' }, 'https://git.example/team/other.git: git fetch') + await expect(refused).rejects.toThrow('ssh://git@git.example/team/other.git: git fetch') + // A missing commit on a clone that already fetches over SSH is not retried. + const missing = await repositories.read('https://git.example:8443/team/plugin.git', 'f'.repeat(40), 'README.md', 100, signal).catch((error: unknown) => error) + expect(missing).toMatchObject({ reason: 'denied' }) + expect((missing as Error).message.match(/git fetch/gu)).toEqual(['git fetch']) + expect((missing as Error).message).toContain('ssh://git@git.example/team/plugin.git: git fetch') + // The SSH fetch keeps the repository limit. + const small = cache({ dir: join(root, 'small'), maxRepositoryBytes: 1, sizePollMs: 3_600_000 }) + await expect(small.read('https://git.example/team/plugin.git', undefined, 'README.md', 100, signal)).rejects.toMatchObject({ reason: 'too-large' }) + // A URL with credentials or a query has no SSH form. + for (const url of ['https://user@git.example/team/plugin.git', 'https://git.example/team/plugin.git?x=1']) { + await expectRejection(repositories.read(url, undefined, 'README.md', 100, signal), { reason: 'denied' }, `${url}: git fetch`) + } +}) + +it('reports an SSH retry that cannot connect as a network failure, and does not retry an https URL that cannot connect', async () => { + vi.stubEnv('GIT_SSH_COMMAND', 'ssh -F /dev/null -o BatchMode=yes -o ConnectTimeout=5') + for (const name of ['http_proxy', 'https_proxy', 'HTTP_PROXY', 'HTTPS_PROXY', 'all_proxy', 'ALL_PROXY']) vi.stubEnv(name, '') + writeFileSync(join(root, 'home', '.gitconfig'), [ + '[url "ssh://127.0.0.1:1/down.git"]', ' insteadOf = ssh://git@git.example/team/down.git', + '[url "http://127.0.0.1:1/offline.git"]', ' insteadOf = https://git.example/team/offline.git', + '[http]', ' proxy =', '', + ].join('\n')) + const refused = cache().read('https://git.example/team/down.git', undefined, 'README.md', 100, signal) + await expectRejection(refused, { reason: 'network' }, 'https://git.example/team/down.git: git fetch') + await expect(refused).rejects.toThrow('ssh://git@git.example/team/down.git: git fetch') + const offline = await cache({ transports: ['http', 'ssh'] }).read('https://git.example/team/offline.git', undefined, 'README.md', 100, signal) + .catch((error: unknown) => error) + expect(offline).toMatchObject({ reason: 'network' }) + expect((offline as Error).message).not.toContain('ssh://') +}) + it('stops a fetch once the repository grows past its limit, and deletes the repository', async () => { const source = repository('e', { 'README.md': 'x' }) // The empty repository already exceeds one byte, so the first size check stops Git. diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index c6f661f22d..8c6ebd500c 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/README.i18n.yaml @@ -21,8 +21,8 @@ en: 0f971f7ac7c09139 zh: ceb866da681c709c /deepseek-ai-dsh-base/use-this-package/what-you-get: - en: 2ef2653d55107eec - zh: 4936c0fff94b1984 + en: 94430c891006526d + zh: 94ba771960ec6f71 /deepseek-ai-dsh-base/use-this-package/shell-tools-per-platform: en: 2dce12f82d6ee955 zh: d4599eb899a3cd88 diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index f928d433d6..db77c345ec 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -51,6 +51,8 @@ Out of the box, every profile built on this core provides: a DeepSeek model conn Both `subagent` and `subagent_fork` create managed children and return a child id immediately. Use `send_message` to steer active children or continue their conversations after completion. [Delegation-tool behavior](../../subagent/tool-subagent/README.md) describes result delivery and backend capabilities. +The bundle also mounts [Agent Teams](../../team/agent-team/README.md) and its task tools. Team mode defaults off per root Session; the root conversation’s **Use Agent Teams** menu switch enables collaboration for that tree without changing the profile. + The built-in `working_directory` tool changes the directory for subsequent operations while existing processes and write permissions remain independent. Current directory information appears in user context. Default file editing uses `read`, `write`, and `edit`. The `str_replace_editor` tool remains available as an explicit opt-in. To add it to a base-backed profile, put this entry in the profile, home, or invocation patch: ```yaml diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index 132a40bab3..53c04f4b52 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -51,6 +51,8 @@ kind: "package-bundle" `subagent` 与 `subagent_fork` 都创建受管理的子任务,并立即返回 child id。使用 `send_message` 可引导活跃子任务,或在完成后继续其对话。[委派工具行为](../../subagent/tool-subagent/README.zh.md)说明了结果交付和后端能力。 +此 bundle 还挂载 [Agent Teams](../../team/agent-team/README.zh.md) 及其任务工具。每个根 Session 的 Team 模式默认关闭;根会话菜单中的**使用智能体团队**开关为当前树开启协作,无需修改 profile。 + 内置 `working_directory` 工具改变后续操作使用的目录,已有进程与写权限保持独立。当前目录信息出现在用户上下文中。默认文件编辑使用 `read`、`write` 和 `edit`。`str_replace_editor` 工具仍可显式启用。要将它加入基于 base 的 profile,请在 profile、home 或逐次调用 patch 中添加以下条目: ```yaml diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index fc83f8160c..d94f5cf2dd 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -363,6 +363,14 @@ - id: subagent name: '@deepseek-ai/dsh-subagent' + - id: agent-team + name: '@deepseek-ai/dsh-agent-team' + config: + maxTasks: 256 + + - id: tool-agent-team + name: '@deepseek-ai/dsh-tool-agent-team' + - id: subagent-spawn-in-process name: '@deepseek-ai/dsh-subagent-spawn-in-process' config: @@ -453,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 @@ -477,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 72e1ecb686..2565cbc7d5 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -34,13 +34,16 @@ } }, "dependencies": { - "@deepseek-ai/dsh-hmr": "workspace:*", "@deepseek-ai/cordis-plugin-timer": "workspace:~", "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-agent-default-model": "workspace:*", + "@deepseek-ai/dsh-agent-instructions": "workspace:*", "@deepseek-ai/dsh-agent-loop": "workspace:*", + "@deepseek-ai/dsh-agent-team": "workspace:*", + "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-attachment-local": "workspace:*", - "@deepseek-ai/dsh-shell-env": "workspace:*", + "@deepseek-ai/dsh-authorization": "workspace:*", "@deepseek-ai/dsh-bash-sandbox": "workspace:*", "@deepseek-ai/dsh-command-compact": "workspace:*", "@deepseek-ai/dsh-command-feedback": "workspace:*", @@ -49,24 +52,29 @@ "@deepseek-ai/dsh-compaction-basic": "workspace:*", "@deepseek-ai/dsh-compaction-image-offload": "workspace:*", "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:*", + "@deepseek-ai/dsh-config-editor": "workspace:*", "@deepseek-ai/dsh-credentials-local": "workspace:*", + "@deepseek-ai/dsh-deepseek-account-platform": "workspace:*", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:*", - "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-fs-observation-policy": "workspace:*", "@deepseek-ai/dsh-fs-sandbox": "workspace:*", "@deepseek-ai/dsh-goal": "workspace:*", "@deepseek-ai/dsh-goal-round-driver": "workspace:*", - "@deepseek-ai/dsh-api-gateway": "workspace:*", - "@deepseek-ai/dsh-api-remote-access": "workspace:*", + "@deepseek-ai/dsh-hmr": "workspace:*", + "@deepseek-ai/dsh-jobs-local": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", - "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", "@deepseek-ai/dsh-llm-deepseek-account": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", "@deepseek-ai/dsh-llm-pi-ai": "workspace:*", "@deepseek-ai/dsh-llm-retry": "workspace:*", "@deepseek-ai/dsh-mcp-resources": "workspace:*", + "@deepseek-ai/dsh-otel": "workspace:*", "@deepseek-ai/dsh-permission-presets": "workspace:*", "@deepseek-ai/dsh-plan-mode": "workspace:*", + "@deepseek-ai/dsh-plugin-manager": "workspace:*", + "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:*", + "@deepseek-ai/dsh-ptc-runtime-node": "workspace:*", "@deepseek-ai/dsh-pwsh-sandbox": "workspace:*", "@deepseek-ai/dsh-repeat-tool-reminder": "workspace:*", "@deepseek-ai/dsh-sandbox-local": "workspace:*", @@ -81,6 +89,8 @@ "@deepseek-ai/dsh-session-telemetry-otel": "workspace:*", "@deepseek-ai/dsh-session-title": "workspace:*", "@deepseek-ai/dsh-session-title-first-prompt-llm": "workspace:*", + "@deepseek-ai/dsh-settings": "workspace:*", + "@deepseek-ai/dsh-shell-env": "workspace:*", "@deepseek-ai/dsh-skill": "workspace:*", "@deepseek-ai/dsh-skill-filesystem": "workspace:*", "@deepseek-ai/dsh-spill-local": "workspace:*", @@ -93,44 +103,36 @@ "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:*", "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", - "@deepseek-ai/dsh-jobs-local": "workspace:*", - "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", + "@deepseek-ai/dsh-tool-agent-team": "workspace:*", "@deepseek-ai/dsh-tool-bash": "workspace:*", - "@deepseek-ai/dsh-tool-present": "workspace:*", + "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:*", "@deepseek-ai/dsh-tool-executor-native": "workspace:*", "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-fs-search": "workspace:*", "@deepseek-ai/dsh-tool-goal": "workspace:*", + "@deepseek-ai/dsh-tool-jobs": "workspace:*", + "@deepseek-ai/dsh-tool-present": "workspace:*", "@deepseek-ai/dsh-tool-pwsh": "workspace:*", "@deepseek-ai/dsh-tool-skill": "workspace:*", "@deepseek-ai/dsh-tool-subagent": "workspace:*", "@deepseek-ai/dsh-tool-subagent-control": "workspace:*", - "@deepseek-ai/dsh-tool-jobs": "workspace:*", "@deepseek-ai/dsh-tool-todo": "workspace:*", "@deepseek-ai/dsh-tool-web": "workspace:*", "@deepseek-ai/dsh-tool-workflow": "workspace:*", + "@deepseek-ai/dsh-tool-working-directory": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-typert-loader": "workspace:*", "@deepseek-ai/dsh-typert-registry": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", "@deepseek-ai/dsh-user-questions": "workspace:*", "@deepseek-ai/dsh-web": "workspace:*", + "@deepseek-ai/dsh-web-deepseek-api": "workspace:*", "@deepseek-ai/dsh-web-fetch-http": "workspace:*", "@deepseek-ai/dsh-web-search-deepseek": "workspace:*", "@deepseek-ai/dsh-workflow-ptc": "workspace:*", - "@deepseek-ai/dsh-agent-instructions": "workspace:*", - "@deepseek-ai/dsh-ptc-runtime-node": "workspace:*", - "@deepseek-ai/dsh-plugin-manager": "workspace:*", - "@deepseek-ai/dsh-authorization": "workspace:*", - "@deepseek-ai/dsh-deepseek-account-platform": "workspace:*", - "@deepseek-ai/dsh-config-editor": "workspace:*", - "@deepseek-ai/dsh-settings": "workspace:*", - "@deepseek-ai/dsh-otel": "workspace:*", - "@deepseek-ai/dsh-working-directory": "workspace:*", - "@deepseek-ai/dsh-tool-working-directory": "workspace:*", - "@deepseek-ai/dsh-web-deepseek-api": "workspace:*" + "@deepseek-ai/dsh-working-directory": "workspace:*" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~" 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/src/index.ts b/packages/bundle/headless/src/index.ts index d02729c097..bc98d7ae6b 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -426,7 +426,7 @@ async function run(ctx: Context, config: Config, io: HeadlessIo): Promise await agent.whenIdle() const idleSeq = agent.session.seq // Child settlement can wake the parent before the child wait observes it. - if (!await subagents?.waitForChildren(agent) + if (!await subagents?.waitForTreeWork(agent) && agent.status === 'idle' && agent.session.seq === idleSeq) break } } finally { diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index 0d00029409..7a76b0b3e5 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -381,7 +381,7 @@ describe('headless runner', () => { if (turns === 1) { await test.ctx.subagents.startActivation({ provider: 'external', label: 'work', delivery: 'parent', signal: new AbortController().signal, - request: { parent, prompt: [] }, + parent, prompt: [], }) await cleaning.promise appendTurn(session, 1, message, 'waiting for child', true) @@ -429,7 +429,7 @@ describe('headless runner', () => { }, }) test.ctx.provide('subagents', { - async waitForChildren(parent: Agent) { + async waitForTreeWork(parent: Agent) { expect(test.ctx.agents.get(parent.id)).toBe(parent) const batch = checks++ if (batch === 2) return false @@ -467,7 +467,7 @@ describe('headless runner', () => { }, }) test.ctx.provide('subagents', { - async waitForChildren(parent: Agent) { + async waitForTreeWork(parent: Agent) { const check = checks++ if (check === 2) return false parent.followup(createUserMessage({ content: [{ type: 'text', text: 'batch complete' }], source: { kind: 'user' } })) diff --git a/packages/bundle/headless/tests/terminal-failures.spec.ts b/packages/bundle/headless/tests/terminal-failures.spec.ts index 5c20881ea9..c92f095334 100644 --- a/packages/bundle/headless/tests/terminal-failures.spec.ts +++ b/packages/bundle/headless/tests/terminal-failures.spec.ts @@ -60,7 +60,7 @@ describe('headless terminal failures', () => { const test = await harness() const failures: unknown[] = [] test.ctx.on('agent/error', ({ error }) => { failures.push(error) }) - const waitForChildren = vi.fn().mockImplementationOnce((parent: Agent) => { + const waitForTreeWork = vi.fn().mockImplementationOnce((parent: Agent) => { const append = parent.session.append.bind(parent.session) const failAppend: typeof parent.session.append = (type, data, ...intent) => { if (type === failedEvent) throw new Error(`summary ${failedEvent} unavailable`) @@ -70,7 +70,7 @@ describe('headless terminal failures', () => { followup(parent) return Promise.resolve(true) }).mockResolvedValue(false) - test.ctx.provide('subagents', { waitForChildren } as never) + test.ctx.provide('subagents', { waitForTreeWork } as never) const result = await test.run(json) expect(failures).toHaveLength(1) @@ -87,7 +87,7 @@ describe('headless terminal failures', () => { it.each([false, true])('uses the durable outcome after a summary hook fails (recover=%s)', async (recover) => { const test = await harness() - const waitForChildren = vi.fn().mockImplementationOnce((parent: Agent) => { + const waitForTreeWork = vi.fn().mockImplementationOnce((parent: Agent) => { const stop = test.ctx.on('agent/pre-step', () => { stop() throw new Error('summary hook failed') @@ -98,7 +98,7 @@ describe('headless terminal failures', () => { if (recover) followup(parent) return Promise.resolve(recover) }).mockResolvedValue(false) - test.ctx.provide('subagents', { waitForChildren } as never) + test.ctx.provide('subagents', { waitForTreeWork } as never) await expect(test.run()).resolves.toEqual(recover ? { code: 0, out: 'summary\n', err: '' } @@ -107,7 +107,7 @@ describe('headless terminal failures', () => { it.each(['turn/start', 'turn/end'] as const)('accepts a recovered summary after a missing %s', async (failedEvent) => { const test = await harness() - const waitForChildren = vi.fn().mockImplementationOnce((parent: Agent) => { + const waitForTreeWork = vi.fn().mockImplementationOnce((parent: Agent) => { const append = parent.session.append.bind(parent.session) let failed = false const failAppend: typeof parent.session.append = (type, data, ...intent) => { @@ -124,7 +124,7 @@ describe('headless terminal failures', () => { followup(parent) return Promise.resolve(true) }).mockResolvedValue(false) - test.ctx.provide('subagents', { waitForChildren } as never) + test.ctx.provide('subagents', { waitForTreeWork } as never) await expect(test.run()).resolves.toEqual({ code: 0, @@ -142,12 +142,12 @@ describe('headless terminal failures', () => { if (agent === other) throw new Error('another Agent failed') return next() }) - const waitForChildren = vi.fn().mockImplementationOnce(async () => { + const waitForTreeWork = vi.fn().mockImplementationOnce(async () => { followup(other) await other.whenIdle() return false }).mockResolvedValue(false) - test.ctx.provide('subagents', { waitForChildren } as never) + test.ctx.provide('subagents', { waitForTreeWork } as never) await expect(test.run()).resolves.toEqual({ code: 0, out: 'waiting\n', err: '' }) }) diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index ac78994ab7..adb17d9e6d 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-web-app/use-this-package: - en: 3bd37b85139d2fa7 - zh: ba86c807eb83866e + en: 1f69a4c52a27e7e5 + zh: e4431a252fb86f7c /deepseek-ai-dsh-web-app/use-this-package/starting-the-web-gui: en: e3cac911f080d8ef zh: 60749e9f5c39c1bc diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index cb974443e5..b621d1e8c2 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -31,6 +31,8 @@ Run `dsh --profile web` for browser chat, model and settings management, and ses Start the GUI, open your browser, and start talking to the agent. The flags fine-tune the invocation. +[Agent Teams UI](../../client/ui-agent-team/README.md) loads with this bundle. Enable it per Session in the model and reasoning menu; shared tasks open below the member tree from the Subagent control. + ### Starting the Web GUI ```sh diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 88bbae54a5..9df9111bf3 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -31,6 +31,8 @@ kind: "package-bundle" 启动 GUI、打开浏览器,然后开始与 agent(智能体)对话。flag 用于微调本次调用。 +[Agent Teams UI](../../client/ui-agent-team/README.zh.md) 随本 bundle 加载。在模型与推理等级菜单中按 Session 开启;通过 Subagent 控件打开成员树,下方显示共享任务。 + ### 启动 Web GUI ```sh diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index d751fcc462..eb076e461e 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' @@ -382,6 +390,9 @@ - id: ui-skill name: '@deepseek-ai/dsh-client-ui-skill' + - id: ui-agent-team + name: '@deepseek-ai/dsh-client-ui-agent-team' + - id: ui-subagent name: '@deepseek-ai/dsh-client-ui-subagent' @@ -552,12 +563,9 @@ - id: tool-result-pruner disabled: true -# The subagent registry and its backends STAY in the host plane. `subagents` is -# a process singleton with a cross-session query surface (`listChildren`, -# `followup`) that the host api-proxy serves to the browser, and a provider -# registers under a globally unique name, so a per-session copy would both -# starve that host row and collide on the second session. What a preset -# chooses is which delegation TOOLS its agent sees, below. +# The subagent registry and its backends belong in the host composition: browser +# prompt and interrupt requests share its runtime, and provider names are unique +# within the registry. Presets select the delegation tools visible to each Agent. - id: tool-subagent-control disabled: true diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 6a526bab3d..db79100589 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -58,6 +58,7 @@ "@deepseek-ai/dsh-agent-instructions": "workspace:*", "@deepseek-ai/dsh-agent-preset": "workspace:*", "@deepseek-ai/dsh-agent-preset-registry": "workspace:*", + "@deepseek-ai/dsh-agent-tool-presentation": "workspace:*", "@deepseek-ai/dsh-api-job-controller": "workspace:*", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-api-session-controller": "workspace:*", @@ -71,8 +72,11 @@ "@deepseek-ai/dsh-client-hmr": "workspace:*", "@deepseek-ai/dsh-client-locale": "workspace:*", "@deepseek-ai/dsh-client-modules": "workspace:*", + "@deepseek-ai/dsh-client-product-analytics": "workspace:*", "@deepseek-ai/dsh-client-resources": "workspace:*", + "@deepseek-ai/dsh-client-shortcuts": "workspace:*", "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:*", + "@deepseek-ai/dsh-client-ui-agent-team": "workspace:*", "@deepseek-ai/dsh-client-ui-approval": "workspace:*", "@deepseek-ai/dsh-client-ui-attachment": "workspace:*", "@deepseek-ai/dsh-client-ui-brand-official": "workspace:*", @@ -97,18 +101,21 @@ "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*", "@deepseek-ai/dsh-client-ui-reference": "workspace:*", "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-schedule": "workspace:*", "@deepseek-ai/dsh-client-ui-secret-mask": "workspace:*", "@deepseek-ai/dsh-client-ui-session": "workspace:*", - "@deepseek-ai/dsh-client-ui-schedule": "workspace:*", "@deepseek-ai/dsh-client-ui-settings": "workspace:*", + "@deepseek-ai/dsh-client-ui-settings-account": "workspace:*", + "@deepseek-ai/dsh-client-ui-settings-agent-loop": "workspace:*", "@deepseek-ai/dsh-client-ui-settings-general": "workspace:*", "@deepseek-ai/dsh-client-ui-settings-models": "workspace:*", "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:*", "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:*", + "@deepseek-ai/dsh-client-ui-settings-session-log": "workspace:*", "@deepseek-ai/dsh-client-ui-settings-shell": "workspace:*", - "@deepseek-ai/dsh-client-ui-settings-agent-loop": "workspace:*", "@deepseek-ai/dsh-client-ui-settings-subagent": "workspace:*", "@deepseek-ai/dsh-client-ui-settings-web-search": "workspace:*", + "@deepseek-ai/dsh-client-ui-shortcuts": "workspace:*", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:*", "@deepseek-ai/dsh-client-ui-sidebar-browser": "workspace:*", "@deepseek-ai/dsh-client-ui-sidebar-documentpreview": "workspace:*", @@ -130,16 +137,20 @@ "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:*", "@deepseek-ai/dsh-cordis-client-runner": "workspace:*", "@deepseek-ai/dsh-cordis-host-runner": "workspace:*", + "@deepseek-ai/dsh-deliverable-file-references": "workspace:*", "@deepseek-ai/dsh-file-reference": "workspace:*", "@deepseek-ai/dsh-file-reference-local": "workspace:*", + "@deepseek-ai/dsh-harness-dev-context": "workspace:*", "@deepseek-ai/dsh-host-directory-picker-auto": "workspace:*", "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:*", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:*", "@deepseek-ai/dsh-host-frontend-static": "workspace:*", "@deepseek-ai/dsh-host-open-in-app": "workspace:*", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:*", + "@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/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 503d65a1c8..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: b6fae58a79112e2b - zh: c95394e541d421ba + 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 fe33ee2a20..3047a181c1 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -64,6 +64,7 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`ui-skill/`](ui-skill/README.md) | Adds skill references to inline suggestions | — | | [`ui-reference/`](ui-reference/README.md) | Unified Web `@file` / `@session` reference source | — | | [`ui-secret-mask/`](ui-secret-mask/README.md) | Masks recognized credentials in sent user text | — | +| [`ui-agent-team/`](ui-agent-team/README.md) | Session Team switch and shared tasks below the Subagent tree | — | | [`ui-subagent/`](ui-subagent/README.md) | Provides subagent navigation, child transcript states, and inline references | — | | [`ui-jobs/`](ui-jobs/README.md) | Lists this session's background jobs with on-demand streaming record panels | — | | [`ui-schedule/`](ui-schedule/README.md) | Provides the sidebar Automation tasks page and right Sidebar task tab, which create, edit, run, pause, resume, and delete Host scheduled tasks, plus the Session header clock and row mark | — | @@ -80,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 de0479c889..e327a717d9 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -64,6 +64,7 @@ kind: "package-group" | [`ui-skill/`](ui-skill/README.zh.md) | 向内联建议添加 skill(技能)引用 | — | | [`ui-reference/`](ui-reference/README.zh.md) | 统一的 Web `@file` / `@session` 引用 source | — | | [`ui-secret-mask/`](ui-secret-mask/README.zh.md) | 遮挡已发送用户文本中可识别的凭据 | — | +| [`ui-agent-team/`](ui-agent-team/README.zh.md) | Session Team 开关与 Subagent 树下方的共享任务 | — | | [`ui-subagent/`](ui-subagent/README.zh.md) | 提供 subagent 导航、子级 transcript(文本记录)状态与内联引用 | — | | [`ui-jobs/`](ui-jobs/README.zh.md) | 列出本会话的后台任务,并提供按需展开的流式输出面板 | — | | [`ui-schedule/`](ui-schedule/README.zh.md) | 提供侧栏“自动化任务”页面与右侧栏任务页签,用于创建、编辑、立即运行、暂停、恢复和删除宿主的计划任务,并提供 Session 顶部时钟与行标记 | — | @@ -80,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/locale/src/locales/de.ts b/packages/client/locale/src/locales/de.ts index dd88cf6fc4..cb0bb45579 100644 --- a/packages/client/locale/src/locales/de.ts +++ b/packages/client/locale/src/locales/de.ts @@ -33,6 +33,8 @@ export const de = { 'more': 'Mehr', 'collapse': 'Einklappen', 'expand': 'Ausklappen', + 'subtitle.expand': 'Beschreibung für {name} ausklappen', + 'subtitle.collapse': 'Beschreibung für {name} einklappen', 'back': 'Zurück', 'brand.localBuild': 'Lokaler DSH-Build', 'workspace.defaultName': 'Standardarbeitsbereich', diff --git a/packages/client/locale/src/locales/en.ts b/packages/client/locale/src/locales/en.ts index b649bbc3dc..47a03d69ef 100644 --- a/packages/client/locale/src/locales/en.ts +++ b/packages/client/locale/src/locales/en.ts @@ -32,6 +32,8 @@ export const en = { 'more': 'More', 'collapse': 'Collapse', 'expand': 'Expand', + 'subtitle.expand': 'Expand description for {name}', + 'subtitle.collapse': 'Collapse description for {name}', 'back': 'Back', 'brand.localBuild': 'DSH Local Build', 'workspace.defaultName': 'Default workspace', diff --git a/packages/client/locale/src/locales/es.ts b/packages/client/locale/src/locales/es.ts index 7eb95374e1..4fba984885 100644 --- a/packages/client/locale/src/locales/es.ts +++ b/packages/client/locale/src/locales/es.ts @@ -33,6 +33,8 @@ export const es = { 'more': 'Más', 'collapse': 'Contraer', 'expand': 'Expandir', + 'subtitle.expand': 'Expandir la descripción de {name}', + 'subtitle.collapse': 'Contraer la descripción de {name}', 'back': 'Atrás', 'brand.localBuild': 'Compilación local de DSH', 'workspace.defaultName': 'Espacio de trabajo predeterminado', diff --git a/packages/client/locale/src/locales/fr.ts b/packages/client/locale/src/locales/fr.ts index c57f1a3b11..f0e5030f41 100644 --- a/packages/client/locale/src/locales/fr.ts +++ b/packages/client/locale/src/locales/fr.ts @@ -33,6 +33,8 @@ export const fr = { 'more': 'Plus', 'collapse': 'Réduire', 'expand': 'Développer', + 'subtitle.expand': 'Développer la description de {name}', + 'subtitle.collapse': 'Réduire la description de {name}', 'back': 'Retour', 'brand.localBuild': 'Version locale de DSH', 'workspace.defaultName': 'Espace de travail par défaut', diff --git a/packages/client/locale/src/locales/ja.ts b/packages/client/locale/src/locales/ja.ts index 1186838c31..2b70cbb156 100644 --- a/packages/client/locale/src/locales/ja.ts +++ b/packages/client/locale/src/locales/ja.ts @@ -33,6 +33,8 @@ export const ja = { 'more': 'その他', 'collapse': '折りたたむ', 'expand': '展開', + 'subtitle.expand': '{name} の説明を展開', + 'subtitle.collapse': '{name} の説明を折りたたむ', 'back': '戻る', 'brand.localBuild': 'DSHローカルビルド', 'workspace.defaultName': 'デフォルトワークスペース', diff --git a/packages/client/locale/src/locales/pt.ts b/packages/client/locale/src/locales/pt.ts index 75cdb19c29..adf711152d 100644 --- a/packages/client/locale/src/locales/pt.ts +++ b/packages/client/locale/src/locales/pt.ts @@ -33,6 +33,8 @@ export const pt = { 'more': 'Mais', 'collapse': 'Recolher', 'expand': 'Expandir', + 'subtitle.expand': 'Expandir descrição de {name}', + 'subtitle.collapse': 'Recolher descrição de {name}', 'back': 'Voltar', 'brand.localBuild': 'Versão local do DSH', 'workspace.defaultName': 'Espaço de trabalho padrão', diff --git a/packages/client/locale/src/locales/ru.ts b/packages/client/locale/src/locales/ru.ts index a8a6d829da..e75620fe21 100644 --- a/packages/client/locale/src/locales/ru.ts +++ b/packages/client/locale/src/locales/ru.ts @@ -33,6 +33,8 @@ export const ru = { 'more': 'Ещё', 'collapse': 'Свернуть', 'expand': 'Развернуть', + 'subtitle.expand': 'Развернуть описание {name}', + 'subtitle.collapse': 'Свернуть описание {name}', 'back': 'Назад', 'brand.localBuild': 'Локальная сборка DSH', 'workspace.defaultName': 'Рабочая область по умолчанию', diff --git a/packages/client/locale/src/locales/zh.ts b/packages/client/locale/src/locales/zh.ts index 11ff789b8a..6350caae3c 100644 --- a/packages/client/locale/src/locales/zh.ts +++ b/packages/client/locale/src/locales/zh.ts @@ -30,6 +30,8 @@ export const zh = { 'more': '更多', 'collapse': '收起', 'expand': '展开', + 'subtitle.expand': '展开{name}的描述', + 'subtitle.collapse': '收起{name}的描述', 'back': '返回', 'brand.localBuild': 'DSH 本地构建', 'workspace.defaultName': '默认工作区', 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/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/ui-agent-preset/src/client/AgentPresetLabel.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css index f828de9af1..50a1b2b181 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css @@ -19,7 +19,6 @@ .icon { flex: none; - opacity: 0.7; } /* First sacrifice when the title row (the enclosing inline-size container) diff --git a/packages/client/ui-agent-team/README.i18n.yaml b/packages/client/ui-agent-team/README.i18n.yaml new file mode 100644 index 0000000000..dfab72bf26 --- /dev/null +++ b/packages/client/ui-agent-team/README.i18n.yaml @@ -0,0 +1,40 @@ +# 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 packages/client/ui-agent-team/README.md +/: + en: cecfe90c9079279f + zh: 58062db454093088 +/deepseek-ai-dsh-client-ui-agent-team: + en: 39c47e6e926425bf + zh: 0fd155c454355e3a +/deepseek-ai-dsh-client-ui-agent-team/summary: + en: 88103cec4c91d755 + zh: 6536e4307a5ce122 +/deepseek-ai-dsh-client-ui-agent-team/table-of-contents: + en: d152484eb41ac6b4 + zh: 09388d293f9be9cb +/deepseek-ai-dsh-client-ui-agent-team/use-this-package: + en: d502ab38773eef34 + zh: e1f9c4edbc74c38f +/deepseek-ai-dsh-client-ui-agent-team/use-this-package/inspect-the-task-board: + en: 7e88fb00a3abd230 + zh: 08efcf80afecb0c0 +/deepseek-ai-dsh-client-ui-agent-team/understand-the-implementation: + en: 9b0b6a8505a9ebbf + zh: beb5d89f80463b3e +/deepseek-ai-dsh-client-ui-agent-team/further-exploration: + en: 4d9214370681b02e + zh: 4acb21b9ecf25e85 +/deepseek-ai-dsh-client-ui-agent-team/model-experience: + en: 47ebeba07d72759d + zh: 8fe8133f6dd845a9 +/deepseek-ai-dsh-client-ui-agent-team/model-experience/kv-cache-effect: + en: b51b320f5e4dacfc + zh: 6695947c3f9fed28 +/deepseek-ai-dsh-client-ui-agent-team/known-limitations-and-deferred-work: + en: 8be0a045f2f42e9e + zh: 36f98d5cc1189f7a +/deepseek-ai-dsh-client-ui-agent-team/known-limitations-and-deferred-work/dev-note: + en: e67f633dc6fe7773 + zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-agent-team/README.md b/packages/client/ui-agent-team/README.md new file mode 100644 index 0000000000..d156e7b7cb --- /dev/null +++ b/packages/client/ui-agent-team/README.md @@ -0,0 +1,100 @@ +--- +description: "Inspect the shared task board from the Web conversation header." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-agent-team + +English | [中文](README.zh.md) + +## Summary + +This package displays shared tasks below the Subagent tree in one header panel. Host projection frames keep task state current without a refresh control. Ordinary subagent navigation owns the member list and teammate conversations. The Web bundle loads this package; it adds no model-facing input. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +In the root conversation’s model and reasoning menu, turn on **Enable agent teamwork**. New Sessions default to off. While enabled, open the root conversation’s Subagent control to see members above shared tasks. Child conversations keep subagent navigation without a task board or Team switch, including before their subagent address is discovered. The entry is hidden when both the subagent catalog and shared tasks are empty; catalog and task-record errors remain accessible. Section headers appear only when Team mode is enabled, at least one nondeleted shared task exists, and the task footer is available. In that case, the noncollapsible Subagents section remains above shared tasks even when its catalog is empty. Otherwise, the menu shows only subagent navigation, without section headers, a shared-task module, empty-state copy, or a divider. Turning it off retains tasks and subagents. The entire Team row highlights on hover and toggles the selection when clicked, except for its independent information button. The switch calls `remote.agentTeams.setEnabled`, waits for the saved projection and reports failures through the menu host; the row cannot toggle while locked or saving. The adjacent information button explains shared-task coordination and additional token use on hover, keyboard focus, or click/tap without changing the selection. Escape or an outside press dismisses the explanation. + +Turning off the menu switch requires confirmation after the Session records a model request; a model choice alone does not require it. The switch waits while request history is unavailable. The dialog explains the tool, coordination, and possible cache effects without deleting task records. Cancel, Escape, or closing the dialog leaves Team mode unchanged. Dismissing the dialog returns focus to the model selector. Confirmation targets the originating Session, waits for the save, and keeps failures visible for retry; it does not depend on the model menu remaining open. Team mode has no slash command; menu changes create no `command/run` or `command/done` records. + +Chat hides historical `/team on` and `/team off` receipts with pending or successful outcomes. Failures remain visible with a Users icon, a localized “Agent teamwork” title, and a localized failure prefix followed by the first nonempty line of the recorded reason without translation. Expanding the receipt shows the full original error. Unknown or missing arguments retain a visible receipt. + +The [Web bundle](../../bundle/web-app/README.md) loads this package by default. The Web Client loader mounts the `/client` export; the root Host export is inert, and the package has no user configuration fields. + +### Inspect the task board + +Ready pending tasks use idle, blocked pending tasks use warning, in-progress tasks use ongoing, and completed tasks use done. + +The noncollapsible Shared tasks header uses a Checklist icon and completed, in-progress, and pending counts, with zero counts omitted and wording and separators matching the input task list. Compact read-only rows show the subject, status, and a chevron, with 13px primary titles and hover geometry aligned to subagent rows; expanded details use 12px text. Task rows use 7px vertical padding, 6px horizontal padding, and 7px gaps to align their clickable content with subagent rows. All task details start closed; opening one closes the previous task. Details show only the full description when present, followed by a single-line owner label and Session id in the same color with a 4px gap. Long owner ids are ellipsized and reveal their full text in a tooltip only when clipped. Task-text and owner tooltips appear after 500ms of hover. Every owner is identified by its Session id. The shared menu is 360px wide with 6px outer padding, constrained to fit the viewport. Shared tasks have a 4px top margin rather than a divider. Both section headers keep a 7px icon/title gap and an additional 4px before their counts. Only the menu scrolls; task lists and details have no nested scrolling. Agents mutate tasks through tools; the panel provides no mutation controls. A projection failure appears separately above any retained tasks and remains visible when no tasks exist. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The Client export registers its locale dictionaries and subagent-catalog and model-menu slots through Cordis effects; it mounts no Remote namespace. Disposing the plugin fiber removes its registrations. + +The Subagent catalog owns the shared panel, scrolling, hover, keyboard, and dismissal interactions. This package contributes the task section only below the root conversation’s tree. The board reads the current Session’s mode and tasks through `useProjection('agentTeam')`. An available, enabled projection supplies the task section only when it contains nondeleted tasks; a projection failure can render independently without a task section. Task IDs, dependencies, readiness, write scopes, and write scope warnings remain in task data but are not displayed as detail fields; blocked pending tasks still use the warning glyph. + +The [Chat visibility policy](../ui-chat/src/client/contract/chat-visibility.ts) hides only historical commands named `team` whose trimmed recorded arguments are exactly `on` or `off` and whose outcome is pending or successful. The remaining receipts use a localized running summary or preserve raw successful output, with a localized completed fallback when that text is empty. Rendering neither reads the current Team projection nor parses English outcome text. Filtering and localization affect only Chat presentation: command records, logs, trajectory, and raw errors remain unchanged. + +| File | Role | +|---|---| +| [`src/client/mount.ts`](src/client/mount.ts) | Locale, catalog-section, and model-menu registrations | +| [`src/client/TeamBoard.tsx`](src/client/TeamBoard.tsx) | Projection-derived task section | +| [`src/client/locales.ts`](src/client/locales.ts) | Panel and command copy in eight languages | +| [`src/index.ts`](src/index.ts) | Inert Host entry | + +
+ +----- + + +## Further Exploration + +- [Web bundle](../../bundle/web-app/README.md) — mounts the Client plugin by default. +- [Agent Teams service](../../team/agent-team/README.md) — authoritative task and projection behavior. +- [Conversation UI](../ui-conversation/README.md) — the stable header slot and addressed-subagent navigation surface. + +----- + + +## Model Experience + +None, as this browser projection registers no model-facing input. + +#### KV Cache effect + +No direct effect; the Team tools and ordinary conversation submission own any later model-visible use. + +## Known Limitations and Deferred Work + + + +- **No mailbox timeline** — the panel displays tasks, not messages. +- **No lifecycle or workspace controls** — the panel cannot spawn, rename, delete, or interrupt teammates, and write scopes remain advisory metadata. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/client/ui-agent-team/README.zh.md b/packages/client/ui-agent-team/README.zh.md new file mode 100644 index 0000000000..6a370c6eeb --- /dev/null +++ b/packages/client/ui-agent-team/README.zh.md @@ -0,0 +1,100 @@ +--- +description: "从 Web 对话标题栏查看共享任务板。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-agent-team + +[English](README.md) | 中文 + +## 概述 + +此包在同一个页头面板中,将共享任务显示在 Subagent 树下方。Host 投影帧持续更新任务状态,无需刷新控件。普通 subagent(子智能体)导航负责成员列表和队友对话。Web bundle 默认加载此包;它不添加模型可见输入。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +在根会话的模型与推理等级菜单中打开**启用智能体团队协作**。新 Session 默认关闭。开启后,通过根会话的 Subagent 入口同时查看上方的成员树和下方的共享任务。子会话保留子智能体导航,不显示任务板或 Team 开关,包括尚未发现子代理地址时。子智能体目录和共享任务都为空时隐藏入口;目录或任务记录错误仍保留查看入口。仅当 Team 模式已开启、存在至少一个未删除的共享任务且任务 footer 可用时,才显示区域标题。此时即使目录为空,共享任务上方仍保留不可折叠的“子智能体”区域。其他情况下,菜单仅显示 subagent 导航,不显示区域标题、共享任务模块、空状态文案或分隔线。关闭后保留任务和子代理。团队设置整行在悬停时高亮,点击可切换状态,但独立的信息按钮除外。开关调用 `remote.agentTeams.setEnabled`,等待保存后的投影更新,失败由菜单宿主提示;锁定或保存中不能通过整行切换。旁边的信息按钮可通过悬停、键盘聚焦或点击/轻触查看共享任务协作与额外 token 消耗的说明,不改变开关状态。按 Escape 或点击外部可关闭说明。 + +会话记录过模型请求后,从菜单开关停用团队需要确认;仅选择模型不会触发确认。请求历史尚未加载时,开关暂不可操作。弹窗说明工具、协作与可能的缓存影响,不会删除任务记录。取消、按 Escape 或关闭弹窗都保持团队状态不变。弹窗关闭后,焦点回到模型选择按钮。确认操作绑定到最初的会话,等待保存完成,失败时保留错误供重试,不依赖模型菜单保持打开。Team 模式没有斜杠命令;菜单切换不会生成 `command/run` 或 `command/done` 记录。 + +Chat 隐藏历史 `/team on` 和 `/team off` 记录中尚未完成或执行成功的回执。失败回执仍显示 Users 图标、本地化的“智能体团队协作”标题,以及本地化的失败前缀和已记录原因的首个非空行,不翻译原因原文。展开回执可查看完整原始错误。参数无法识别或缺失时仍显示回执。 + +[Web bundle](../../bundle/web-app/README.zh.md) 默认加载本包。Web Client loader 挂载 `/client` export;root Host export 不执行行为,本包也没有用户配置字段。 + +### 查看任务板 + +可开始的 pending 任务使用 idle 灰点,被依赖阻塞的 pending 任务使用 warning 橙点,in-progress 任务使用 ongoing loading,completed 任务使用 done 绿点。 + +不可折叠的“共享任务”标题使用 Checklist 图标,并显示已完成、进行中和待处理数量;省略零值,文案和分隔符与输入区任务列表一致。紧凑只读行显示主题、状态和展开箭头,13px 主标题与悬停区域对齐子智能体行;展开详情使用 12px 文字。任务行使用 7px 垂直内边距、6px 水平内边距和 7px 间距,使可点击内容与 subagent 行对齐。所有任务详情初始关闭,打开一项会关闭上一项。详情仅显示完整描述(如有),下方以单行显示负责人标签和 Session id,两者使用相同颜色,间距为 4px。过长的负责人 id以省略号截断,仅在被截断时通过工具提示显示完整内容。任务文字和负责人的工具提示在悬停 500ms 后显示。每个负责人均通过其 Session id 标识。共享菜单宽度为 360px,外层内边距为 6px,并受视口可用宽度约束。共享任务上方保留 4px 外边距,不显示分隔线。两个区域标题均保留 7px 图标与文字间距,并在统计数量前额外留出 4px。仅菜单本身滚动,任务列表与详情不单独滚动。Agent 通过工具修改任务;面板不提供修改控件。投影失败以独立警告显示在保留的任务上方,即使没有任务也保持可见。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +Client export 通过 Cordis effect 注册语言字典、子代理目录与模型菜单 slot;它不挂载 Remote namespace。销毁插件 fiber 会移除这些注册。 + +Subagent 目录负责共享面板的滚动、悬停、键盘和关闭交互。本包仅在根会话的树下方提供任务区。任务板通过 `useProjection('agentTeam')` 读取当前 Session 的模式与任务;仅当投影可用、已开启且含有未删除的任务时才显示任务区;投影错误可独立显示,不需要任务区。任务 ID、依赖、准备状态、写入范围及其提示仍保留在任务数据中,但不作为详情字段显示;被依赖阻塞的 pending 任务仍使用 warning 图标。 + +[Chat 可见性策略](../ui-chat/src/client/contract/chat-visibility.ts) 仅隐藏名称为 `team`、已记录参数去除首尾空白后恰为 `on` 或 `off`,且尚未完成或执行成功的历史命令。其余回执在执行中显示本地化摘要,成功时保留原始结果文本,文本为空则使用本地化的完成提示。渲染既不读取当前 Team 投影,也不解析英文结果文案。过滤与本地化仅影响 Chat 展示:命令记录、日志、轨迹和原始错误均保持不变。 + +| 文件 | 职责 | +|---|---| +| [`src/client/mount.ts`](src/client/mount.ts) | 语言字典、目录任务区和模型菜单注册 | +| [`src/client/TeamBoard.tsx`](src/client/TeamBoard.tsx) | 从投影派生的任务区 | +| [`src/client/locales.ts`](src/client/locales.ts) | 八种语言的面板与命令文案 | +| [`src/index.ts`](src/index.ts) | 不执行行为的 Host entry | + +
+ +----- + + +## 进一步探索 + +- [Web bundle](../../bundle/web-app/README.zh.md)——默认挂载本 Client plugin。 +- [Agent Teams 服务](../../team/agent-team/README.zh.md) — 任务和投影行为的权威来源。 +- [会话 UI](../ui-conversation/README.zh.md)——稳定 header slot 与 addressed-subagent 导航表层。 + +----- + + +## 模型体验 + +无直接影响,因为该浏览器 projection 不注册面向模型的输入。 + +#### KV Cache 影响 + +无直接影响;Team 工具与普通会话提交负责后续任何模型可见用途。 + +## 已知限制与延期工作 + + + +- **无邮箱时间线** — 面板显示任务,不显示消息。 +- **没有 lifecycle 或 workspace control**——panel 不能 spawn、rename、delete 或 interrupt teammate,write scope 仍只是提示性 metadata。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/experimental/client-ui-agent-team/locale/de.json b/packages/client/ui-agent-team/locale/de.json similarity index 100% rename from packages/experimental/client-ui-agent-team/locale/de.json rename to packages/client/ui-agent-team/locale/de.json diff --git a/packages/client/ui-agent-team/locale/en.json b/packages/client/ui-agent-team/locale/en.json new file mode 100644 index 0000000000..c6667a6afb --- /dev/null +++ b/packages/client/ui-agent-team/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Task board", + "description": "View shared tasks for the subagent tree." + } +} diff --git a/packages/experimental/client-ui-agent-team/locale/es.json b/packages/client/ui-agent-team/locale/es.json similarity index 100% rename from packages/experimental/client-ui-agent-team/locale/es.json rename to packages/client/ui-agent-team/locale/es.json diff --git a/packages/experimental/client-ui-agent-team/locale/fr.json b/packages/client/ui-agent-team/locale/fr.json similarity index 100% rename from packages/experimental/client-ui-agent-team/locale/fr.json rename to packages/client/ui-agent-team/locale/fr.json diff --git a/packages/client/ui-agent-team/locale/ja.json b/packages/client/ui-agent-team/locale/ja.json new file mode 100644 index 0000000000..ae63e83f31 --- /dev/null +++ b/packages/client/ui-agent-team/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "タスクボード", + "description": "サブエージェントツリーの共有タスクを表示します。" + } +} diff --git a/packages/experimental/client-ui-agent-team/locale/pt.json b/packages/client/ui-agent-team/locale/pt.json similarity index 100% rename from packages/experimental/client-ui-agent-team/locale/pt.json rename to packages/client/ui-agent-team/locale/pt.json diff --git a/packages/experimental/client-ui-agent-team/locale/ru.json b/packages/client/ui-agent-team/locale/ru.json similarity index 100% rename from packages/experimental/client-ui-agent-team/locale/ru.json rename to packages/client/ui-agent-team/locale/ru.json diff --git a/packages/client/ui-agent-team/locale/zh.json b/packages/client/ui-agent-team/locale/zh.json new file mode 100644 index 0000000000..9d06145f0f --- /dev/null +++ b/packages/client/ui-agent-team/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "任务板", + "description": "查看子智能体树的共享任务。" + } +} diff --git a/packages/experimental/client-ui-agent-team/package.json b/packages/client/ui-agent-team/package.json similarity index 71% rename from packages/experimental/client-ui-agent-team/package.json rename to packages/client/ui-agent-team/package.json index 0f1f10d2b0..12887a765d 100644 --- a/packages/experimental/client-ui-agent-team/package.json +++ b/packages/client/ui-agent-team/package.json @@ -1,6 +1,6 @@ { - "name": "@deepseek-ai/dsh-experimental-client-ui-agent-team", - "description": "Web Agent Teams roster, task board, and teammate navigation", + "name": "@deepseek-ai/dsh-client-ui-agent-team", + "description": "Web shared task board for a subagent tree", "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" @@ -8,7 +8,7 @@ "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/experimental/client-ui-agent-team" + "directory": "packages/client/ui-agent-team" }, "type": "module", "main": "lib/index.js", @@ -32,8 +32,9 @@ "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation", - "@deepseek-ai/dsh-client-ui-primitives", - "@deepseek-ai/dsh-client-ui-workspace" + "@deepseek-ai/dsh-client-ui-chat", + "@deepseek-ai/dsh-client-ui-layout", + "@deepseek-ai/dsh-client-ui-primitives" ], "platform": "web" } @@ -51,18 +52,23 @@ "@deepseek-ai/dsh-client-locale": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-commands": "workspace:*", "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-ui-chat": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", - "@deepseek-ai/dsh-client-ui-session": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", - "@deepseek-ai/dsh-client-ui-workspace": "workspace:*", - "@deepseek-ai/dsh-experimental-agent-team": "workspace:*", + "@deepseek-ai/dsh-agent-team": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@testing-library/react": "^16.3.3", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "react-dom": "^18.2.0", + "@deepseek-ai/dsh-client-ui-model-selection": "workspace:*", + "@deepseek-ai/dsh-client-ui-layout": "workspace:*", + "@deepseek-ai/dsh-api-remotes": "workspace:*", + "@deepseek-ai/dsh-client-ui-subagent": "workspace:*", "@deepseek-ai/dsh-agent": "workspace:*" }, "files": [ diff --git a/packages/client/ui-agent-team/src/client/TeamBoard.module.css b/packages/client/ui-agent-team/src/client/TeamBoard.module.css new file mode 100644 index 0000000000..3d10436a16 --- /dev/null +++ b/packages/client/ui-agent-team/src/client/TeamBoard.module.css @@ -0,0 +1,165 @@ +.board { + min-width: 0; + padding: 8px 7px; +} + +.board[data-with-members='true'] { + margin-top: 4px; +} + +.header { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px 7px; + margin: 0 0 4px; + padding: 0; +} + +.lead, +.glyph, +.chevron { + display: grid; + flex: none; + place-items: center; + color: var(--dsw-alias-label-tertiary); +} + +.title { + flex: none; + color: var(--dsw-alias-label-primary); + font-size: 13px; + font-weight: 500; + line-height: 24px; +} + +.progress { + margin-inline-start: 4px; + color: var(--dsw-alias-label-tertiary); + font-size: 13px; + font-weight: 400; + font-variant-numeric: tabular-nums; + line-height: 20px; + overflow-wrap: anywhere; +} + +.tasks { + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + list-style: none; +} + +.task { + min-width: 0; +} + +.taskRow { + box-sizing: border-box; + display: flex; + align-items: center; + gap: 7px; + width: calc(100% + 12px); + min-width: 0; + margin: 0 -6px; + padding: 7px 6px; + border: 0; + border-radius: var(--dsw-radius-md); + background: transparent; + color: var(--dsw-alias-label-primary); + font: inherit; + font-size: 13px; + line-height: 17px; + text-align: left; + cursor: pointer; +} + +.taskRow:focus-visible { + outline: none; + background: var(--dsw-alias-interactive-bg-hover); +} + +@media (hover: hover) { + .taskRow:hover { + background: var(--dsw-alias-interactive-bg-hover); + } +} + +.glyph { + width: 14px; + height: 17px; +} + +.subject { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task[data-expanded='true'] .taskRow { + align-items: flex-start; +} + +.task[data-expanded='true'] .subject { + overflow: visible; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.task[data-expanded='true'] .chevron { + transform: rotate(180deg); +} + +.chevron { + width: 28px; + height: 17px; +} + +.details { + padding: 0 0 8px 21px; + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 18px; + overflow-wrap: anywhere; +} + +.description { + margin: 0; + white-space: pre-wrap; +} + +.owner { + display: flex; + gap: 4px; + min-width: 0; + margin: 0; + white-space: nowrap; +} + +.description + .owner { + margin-top: 6px; +} + +.ownerLabel { + flex: none; +} + +.ownerValue { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.error { + display: flex; + align-items: flex-start; + gap: 7px; + padding: 8px 0; + color: var(--dsw-alias-state-error-primary); + font-size: 12px; + line-height: 18px; + overflow-wrap: anywhere; +} diff --git a/packages/client/ui-agent-team/src/client/TeamBoard.tsx b/packages/client/ui-agent-team/src/client/TeamBoard.tsx new file mode 100644 index 0000000000..dbc554d33c --- /dev/null +++ b/packages/client/ui-agent-team/src/client/TeamBoard.tsx @@ -0,0 +1,112 @@ +import { useState } from 'react' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { TeamTaskView, TeamTaskId } from '@deepseek-ai/dsh-agent-team/client' +import type {} from '@deepseek-ai/dsh-api-session-controller/client' +import { + IconChecklistOutlineRegular, IconChevronDownOutlineRegular, + StateDot, Tooltip, type StateDotState, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-subagent/client' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { NS, type TeamKey } from './locales.ts' +import css from './TeamBoard.module.css' + +/** Full props of the task board below the shared subagent catalog. */ +export type TeamBoardProps = + PropsRuntime<'conversation.session.header.actions.subagent.footer'> & PropsLocale + +type VisibleTask = TeamTaskView & { status: Exclude } + +const statusKeys = { + pending: 'status.pending', in_progress: 'status.in_progress', completed: 'status.completed', +} satisfies Record + +const dotStates = { + pending: 'idle', in_progress: 'ongoing', completed: 'done', +} satisfies Record + +function progressLabel(tasks: readonly VisibleTask[], t: TeamBoardProps['t']): string { + const completed = tasks.filter(task => task.status === 'completed').length + const active = tasks.filter(task => task.status === 'in_progress').length + const pending = tasks.length - completed - active + return [ + ...completed > 0 ? [t('progress.completed', { count: completed })] : [], + ...active > 0 ? [t('progress.active', { count: active })] : [], + ...pending > 0 ? [t('progress.pending', { count: pending })] : [], + ].join('\u2002·\u2002') +} + +function TaskRow({ task, rootId, expanded, onToggle, t }: Pick & { + task: VisibleTask + rootId: SessionId + expanded: boolean + onToggle: () => void +}) { + const owner = task.ownerId ?? t('unowned') + const blocked = task.status === 'pending' && !task.ready + const detailsId = `team-task-${rootId}-${task.id}` + return ( +
  • + + + + {expanded && ( +
    + {task.description &&

    {task.description}

    } +

    + {t('owner')}: + + {owner} + +

    +
    + )} +
  • + ) +} + +/** Render read-only tasks in source order; the enclosing subagent menu owns scrolling. */ +export function TeamBoard({ sessionId, useProjection, useSessions, t }: TeamBoardProps) { + const team = useProjection('agentTeam') + const hasMembers = useSessions(state => (state.projectionsBySession[sessionId]?.values.subagentCatalog?.length ?? 0) > 0) + const [expandedTaskId, setExpandedTaskId] = useState(null) + if (!team?.enabled) return null + const tasks = team.tasks.filter((task): task is VisibleTask => task.status !== 'deleted') + const failure = team.failure === undefined ? null : ( +
    {t('failure', { message: team.failure })}
    + ) + if (tasks.length === 0) return failure === null ? null :
    {failure}
    + return ( +
    +

    + + {t('tasks')} + {progressLabel(tasks, t)} +

    + {failure} +
      + {tasks.map(task => ( + { setExpandedTaskId(current => current === task.id ? null : task.id) }} + t={t} + /> + ))} +
    +
    + ) +} diff --git a/packages/client/ui-agent-team/src/client/TeamCommandRow.module.css b/packages/client/ui-agent-team/src/client/TeamCommandRow.module.css new file mode 100644 index 0000000000..a36f5ff903 --- /dev/null +++ b/packages/client/ui-agent-team/src/client/TeamCommandRow.module.css @@ -0,0 +1,44 @@ +.root { + display: flex; + flex-direction: column; +} + +.title { font-weight: 400; } + +.separator { + flex: none; + width: 2px; + height: 2px; + margin: 0 8px; + border-radius: 1px; + background: var(--dsw-alias-label-caption); +} + +.summary { + min-width: 0; + overflow: hidden; + flex: 1 1 auto; + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(24px + var(--dsh-content-font-delta, 0px)); + text-overflow: ellipsis; + white-space: nowrap; +} + +.summary[data-error], +.body[data-error] { + color: var(--dsw-alias-state-error-primary); +} + +.body { + max-height: 260px; + margin: 4px 0 4px 4px; + padding: 12px 16px; + overflow: auto; + border: 0.5px solid var(--dsw-alias-border-l1); + border-radius: var(--dsw-radius-lg); + background: var(--dsw-alias-markdown-code-block); + color: var(--dsw-alias-label-primary); + font: var(--dsw-font-markdown-code-block-small); + white-space: pre-wrap; + overflow-wrap: anywhere; +} diff --git a/packages/client/ui-agent-team/src/client/TeamCommandRow.tsx b/packages/client/ui-agent-team/src/client/TeamCommandRow.tsx new file mode 100644 index 0000000000..dde0b5cced --- /dev/null +++ b/packages/client/ui-agent-team/src/client/TeamCommandRow.tsx @@ -0,0 +1,65 @@ +/** Read-only historical /team failures and fallback output. */ +import { useState } from 'react' +import { DisclosureRow, IconUsersOutlineRegular, TextShimmer } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-chat/client' +import { assertNever } from '@deepseek-ai/dsh-util-values' +import type { NS } from './locales.ts' +import css from './TeamCommandRow.module.css' + +/** Framework command data and the owning Team dictionary. */ +export type TeamCommandRowProps = PropsRuntime<'conversation.chat.commandview'> & PropsLocale + +const TEAM_ICON = + +function commandSummary(node: TeamCommandRowProps['node'], t: TeamCommandRowProps['t']): string { + const outcome = node.outcome + if (outcome === null) return t('command.running') + switch (outcome.kind) { + case 'success': + return outcome.text || t('command.done') + case 'error': { + const reason = outcome.text?.trim().split(/\r?\n/u)[0] + return reason ? t('command.failedReason', { reason }) : t('command.failed') + } + /* v8 ignore next -- CommandNode restricts outcomes to success or error. */ + default: return assertNever(outcome.kind) + } +} + +/** + * Render the recorded Team command with the shared agent glyph and disclosure. + * @param props - command record and localized Team text. + * @returns an outcome row; recorded failures and multiline output remain expandable. + */ +export function TeamCommandRow({ node, t }: Pick) { + const [expanded, setExpanded] = useState(false) + const running = node.outcome === null + const failed = node.outcome?.kind === 'error' + const summary = commandSummary(node, t) + const body = node.outcome?.text + const expandable = body !== undefined && body !== '' && (failed || body.includes('\n')) + return ( +
    + { setExpanded(value => !value) }} + collapsedContent={( + <> + + {summary} + + )} + > +
    {body}
    +
    +
    + ) +} diff --git a/packages/client/ui-agent-team/src/client/TeamDisableConfirmation.module.css b/packages/client/ui-agent-team/src/client/TeamDisableConfirmation.module.css new file mode 100644 index 0000000000..5763edbcbc --- /dev/null +++ b/packages/client/ui-agent-team/src/client/TeamDisableConfirmation.module.css @@ -0,0 +1,31 @@ +.dialog { + width: min(440px, 100%); + max-height: 100%; +} + +.content { + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; +} + +.copy { + min-width: 0; + overflow-wrap: anywhere; + color: var(--dsw-alias-label-secondary); + font-size: 14px; + line-height: 22px; +} + +.copy p { + margin: 0; +} + +.copy p + p { + margin-top: 12px; +} + +.error { + color: var(--dsw-alias-state-error-primary); + overflow-wrap: anywhere; +} diff --git a/packages/client/ui-agent-team/src/client/TeamDisableConfirmation.tsx b/packages/client/ui-agent-team/src/client/TeamDisableConfirmation.tsx new file mode 100644 index 0000000000..e02bc87ba3 --- /dev/null +++ b/packages/client/ui-agent-team/src/client/TeamDisableConfirmation.tsx @@ -0,0 +1,76 @@ +/** Session-targeted confirmation that outlives the model menu which opened it. */ +import { useState } from 'react' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives' +import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import type { NS } from './locales.ts' +import css from './TeamDisableConfirmation.module.css' + +/** The originating Session remains the target while the dialog is open. */ +export interface TeamDisableRequest { + sessionId: SessionId +} + +/** Confirmation request and Session-bound selection callbacks supplied by the plugin. */ +export interface TeamDisableConfirmationInjected { + hooks: { disableRequest: HostObservable } + cancelDisable: (sessionId: SessionId) => void + disableTeam: (sessionId: SessionId) => Promise +} + +/** Root overlay props, independent of the model menu lifetime. */ +export type TeamDisableConfirmationProps = PropsRuntime<'shell.overlay'> + & InjectFace & PropsLocale + +/** + * Render the requested confirmation outside the model menu. + * @param props - request observation, command callbacks, and localized text. + * @returns the confirmation, or nothing when no request is pending. + */ +export function TeamDisableConfirmation({ useDisableRequest, ...props }: TeamDisableConfirmationProps) { + const request = useDisableRequest(value => value) + if (request === null) return null + return +} + +/** One confirmation keeps its failure visible until retry or cancellation. */ +function DisableConfirmationForm({ sessionId, cancelDisable, disableTeam, t }: { + sessionId: SessionId + cancelDisable: TeamDisableConfirmationInjected['cancelDisable'] + disableTeam: TeamDisableConfirmationInjected['disableTeam'] + t: TeamDisableConfirmationProps['t'] +}) { + const [state, setState] = useState<{ kind: 'ready' } | { kind: 'saving' } | { kind: 'failed'; message: string }>({ kind: 'ready' }) + const saving = state.kind === 'saving' + const cancel = (): void => { + if (!saving) cancelDisable(sessionId) + } + const confirm = (): void => { + if (saving) return + setState({ kind: 'saving' }) + void disableTeam(sessionId).then((failure) => { + if (failure === null) cancelDisable(sessionId) + else setState({ kind: 'failed', message: failure }) + }, (error: unknown) => { + setState({ kind: 'failed', message: error instanceof Error ? error.message : String(error) }) + }) + } + return ( + + + + + )} + > +
    +

    {t('disable.description')}

    +

    {t('disable.warning')}

    +
    + {state.kind === 'failed' &&

    {t('mode.failed', { message: state.message })}

    } +
    + ) +} diff --git a/packages/client/ui-agent-team/src/client/TeamModeToggle.module.css b/packages/client/ui-agent-team/src/client/TeamModeToggle.module.css new file mode 100644 index 0000000000..75eedfb3e7 --- /dev/null +++ b/packages/client/ui-agent-team/src/client/TeamModeToggle.module.css @@ -0,0 +1,52 @@ +.row { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 34px; + padding: 0 8px; + border-radius: var(--dsw-radius-md); + color: var(--dsw-alias-label-primary); + font-size: 13px; + line-height: 20px; +} + +.label { + display: inline-flex; + align-items: center; + gap: 4px; + min-width: 0; +} + +.infoButton { + position: relative; + z-index: 1; + flex: none; + width: 20px; + height: 20px; + padding: 0; + color: var(--dsw-alias-label-caption); +} + +/* Extend the native switch hit target without nesting the information button. */ +.row .toggle { + position: static; +} + +.toggle::after { + content: ''; + position: absolute; + inset: 0; + border-radius: var(--dsw-radius-md); +} + +.row:has(.toggle:focus-visible) { + background: var(--dsw-alias-interactive-bg-hover); +} + +@media (hover: hover) { + .row:has(.toggle:not(:disabled)):hover { + background: var(--dsw-alias-interactive-bg-hover); + } +} diff --git a/packages/client/ui-agent-team/src/client/TeamModeToggle.tsx b/packages/client/ui-agent-team/src/client/TeamModeToggle.tsx new file mode 100644 index 0000000000..cd0d3dcafa --- /dev/null +++ b/packages/client/ui-agent-team/src/client/TeamModeToggle.tsx @@ -0,0 +1,70 @@ +/** Session-owned Agent Teams selection in the model and reasoning menu. */ + +import { useState } from 'react' +import { Button, IconInfoOutlineRegular, Switch, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-model-selection/client' +import type {} from '@deepseek-ai/dsh-agent-team/client' +import type {} from '@deepseek-ai/dsh-agent' +import type { NS } from './locales.ts' +import css from './TeamModeToggle.module.css' + +/** Session selection callbacks supplied by the Client plugin. */ +export interface TeamModeToggleInjected { + /** Save the selection; null means success, otherwise the string describes the failure. */ + selectTeamMode: (enabled: boolean) => Promise + /** Open a Session-targeted confirmation outside this menu. */ + requestDisableConfirmation: () => void +} + +/** Full props of the model-menu Team switch. */ +export type TeamModeToggleProps = PropsRuntime<'conversation.input.model.options'> + & PropsLocale & TeamModeToggleInjected + +/** + * Render the saved selection without an optimistic update. + * @param props - Session projection, menu state, localized copy, and selection callback. + * @returns the Team switch when the capability projection is available. + */ +export function TeamModeToggle(props: TeamModeToggleProps) { + const addressedChild = props.useSession(snapshot => snapshot.subagent !== null) + const listedChild = props.useSessions(state => state.byId[props.sessionId]?.origin === 'subagent') + if (addressedChild || listedChild) return null + return +} + +/** Read and change the selection only for root Sessions. */ +function RootTeamModeToggle({ + useProjection, locked, dismiss, reportFailure, selectTeamMode, requestDisableConfirmation, t, +}: TeamModeToggleProps) { + const mode = useProjection('agentTeam') + const modelSelection = useProjection('modelSelection') + const [pending, setPending] = useState(false) + if (mode === undefined) return null + const select = (enabled: boolean): void => { + if (!enabled && modelSelection !== undefined && modelSelection.lastUsed !== null) { + dismiss() + requestDisableConfirmation() + return + } + setPending(true) + void selectTeamMode(enabled).then((failure) => { + if (failure !== null) reportFailure(t('mode.failed', { message: failure })) + }, (error: unknown) => { + reportFailure(t('mode.failed', { message: error instanceof Error ? error.message : String(error) })) + }).finally(() => { setPending(false) }) + } + return ( +
    + + {t('mode.label')} + + + + + +
    + ) +} diff --git a/packages/experimental/client-ui-agent-team/src/client/index.ts b/packages/client/ui-agent-team/src/client/index.ts similarity index 60% rename from packages/experimental/client-ui-agent-team/src/client/index.ts rename to packages/client/ui-agent-team/src/client/index.ts index a4896c82af..23de9e7ee5 100644 --- a/packages/experimental/client-ui-agent-team/src/client/index.ts +++ b/packages/client/ui-agent-team/src/client/index.ts @@ -1,14 +1,14 @@ -/** Browser entry registering the Agent Teams conversation-header action. */ +/** Browser entry registering the task-board sections in the subagent catalog. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' import { registerAgentTeamUi } from './mount.ts' export { inject } from './mount.ts' -export type { TeamActionInjected, TeamActionProps } from './TeamAction.tsx' +export type { TeamBoardProps } from './TeamBoard.tsx' export type { TeamKey } from './locales.ts' /** - * Register the Team locale dictionaries and header action on the Client Context. + * Register the task-board locale dictionaries and catalog sections on the Client Context. * @param ctx - Client Context with the declared `inject` services available. */ export function apply(ctx: ClientContext): void { diff --git a/packages/client/ui-agent-team/src/client/locales.ts b/packages/client/ui-agent-team/src/client/locales.ts new file mode 100644 index 0000000000..0c5b29a4fb --- /dev/null +++ b/packages/client/ui-agent-team/src/client/locales.ts @@ -0,0 +1,279 @@ +/** Agent teamwork Web dictionaries. */ + +/** Locale namespace owned by the Agent teamwork Web UI. */ +export const NS = 'agent-team' + +/** Simplified Chinese dictionary and key source. */ +export const zh = { + 'progress.completed': '{count} 已完成', + 'progress.active': '{count} 进行中', + 'progress.pending': '{count} 待处理', + 'command.title': '智能体团队协作', + 'command.running': '执行中', + 'command.done': '已完成', + 'command.failed': '执行失败', + 'command.failedReason': '执行失败:{reason}', + trigger: '任务板', + 'mode.label': '启用智能体团队协作', + 'mode.infoLabel': '关于智能体团队协作', + 'mode.description': '通过共享任务板协调多个智能体的分工与进度,复杂任务表现更佳,token 开销也相应更高。', + 'mode.failed': '无法确认智能体团队协作设置已保存,请重试:{message}', + 'disable.title': '确认停用智能体团队协作?', + 'disable.description': '对话过程中停用智能体团队协作,将停止共享任务工具和团队协调指令。', + 'disable.warning': '停用后可能影响缓存复用、增加后续 token 用量与开销,并影响当前任务的协作效果。', + 'disable.cancel': '取消', + 'disable.confirm': '确认停用', + 'disable.close': '关闭', + loading: '正在加载任务板…', + unavailable: '任务板暂不可用', + failure: '任务记录无效:{message}', + empty: '暂无共享任务,可以通过对话创建', + tasks: '共享任务', + owner: '负责人', + unowned: '未分配', + blocked: '被依赖阻塞', + 'status.pending': '待处理', + 'status.in_progress': '进行中', + 'status.completed': '已完成', +} satisfies Record + +/** Agent teamwork locale key union. */ +export type TeamKey = keyof typeof zh + +/** English dictionary checked against the Chinese key set. */ +export const en = { + 'progress.completed': '{count} completed', + 'progress.active': '{count} in progress', + 'progress.pending': '{count} pending', + 'command.title': 'Agent teamwork', + 'command.running': 'Running', + 'command.done': 'Completed', + 'command.failed': 'Failed', + 'command.failedReason': 'Failed: {reason}', + trigger: 'Task board', + 'mode.label': 'Enable agent teamwork', + 'mode.infoLabel': 'About agent teamwork', + 'mode.description': 'Coordinate work and progress across agents with a shared task board for better results on complex tasks, with correspondingly higher token costs.', + 'mode.failed': 'Could not confirm the agent teamwork setting was saved. Try again: {message}', + 'disable.title': 'Disable agent teamwork?', + 'disable.description': 'Disabling agent teamwork during a conversation stops the use of shared task tools and team coordination instructions.', + 'disable.warning': 'Disabling it may affect cache reuse, increase subsequent token usage and costs, and affect collaboration on the current task.', + 'disable.cancel': 'Cancel', + 'disable.confirm': 'Disable', + 'disable.close': 'Close', + loading: 'Loading task board…', + unavailable: 'Task board is unavailable', + failure: 'Invalid persisted task record: {message}', + empty: 'No shared tasks yet. Create them through the conversation.', + tasks: 'Shared tasks', + owner: 'Owner', + unowned: 'Unowned', + blocked: 'Blocked by dependencies', + 'status.pending': 'Pending', + 'status.in_progress': 'In progress', + 'status.completed': 'Completed', +} satisfies Record + +/** Japanese copy, checked against the English keys. */ +export const ja = { + 'progress.completed': '{count}件完了', + 'progress.active': '{count}件進行中', + 'progress.pending': '{count}件保留中', + 'command.title': 'エージェント連携', + 'command.running': '実行中', + 'command.done': '完了', + 'command.failed': '実行失敗', + 'command.failedReason': '実行失敗: {reason}', + 'mode.label': 'エージェント連携を有効にする', + 'mode.infoLabel': 'エージェント連携について', + 'mode.description': '共有タスクボードで複数のエージェントの分担と進捗を調整します。複雑なタスクでより良い結果が得られますが、その分トークンのコストも高くなります。', + 'mode.failed': 'エージェント連携の設定が保存されたことを確認できませんでした。再試行してください: {message}', + 'disable.title': 'エージェント連携を無効にしますか?', + 'disable.description': '会話中にエージェント連携を無効にすると、共有タスクツールとチーム調整用の指示の使用が停止されます。', + 'disable.warning': '無効にすると、キャッシュの再利用に影響し、その後のトークン使用量や費用が増加する可能性があります。また、現在のタスクでの連携に影響する可能性があります。', + 'disable.cancel': 'キャンセル', + 'disable.confirm': '無効にする', + 'disable.close': '閉じる', + 'trigger': 'タスクボード', + 'loading': 'タスクボードを読み込み中…', + 'unavailable': 'タスクボードは利用できません', + 'failure': '保存されたタスクレコードが無効です: {message}', + 'empty': '共有タスクはまだありません。会話から作成してください。', + 'tasks': '共有タスク', + 'owner': 'オーナー', + 'unowned': 'オーナーなし', + 'blocked': '依存関係によりブロック中', + 'status.pending': '保留中', + 'status.in_progress': '進行中', + 'status.completed': '完了', +} satisfies Record + +/** Spanish dictionary, checked against the English keys. */ +export const es = { + 'progress.completed': '{count} completado', + 'progress.active': '{count} en progreso', + 'progress.pending': '{count} pendiente', + 'command.title': 'Colaboración entre agentes', + 'command.running': 'En ejecución', + 'command.done': 'Completado', + 'command.failed': 'Error', + 'command.failedReason': 'Error: {reason}', + 'mode.label': 'Activar colaboración entre agentes', + 'mode.infoLabel': 'Acerca de la colaboración entre agentes', + 'mode.description': 'Coordina el trabajo y el progreso de varios agentes mediante un tablero de tareas compartido, con mejores resultados en tareas complejas y un coste de tokens proporcionalmente mayor.', + 'mode.failed': 'No se pudo confirmar que se guardó la configuración de colaboración entre agentes. Inténtalo de nuevo: {message}', + 'disable.title': '¿Desactivar la colaboración entre agentes?', + 'disable.description': 'Desactivar la colaboración entre agentes durante una conversación detiene el uso de las herramientas de tareas compartidas y las instrucciones de coordinación del equipo.', + 'disable.warning': 'La desactivación puede afectar a la reutilización de la caché, aumentar el consumo posterior de tokens y los costes, y afectar a la colaboración en la tarea actual.', + 'disable.cancel': 'Cancelar', + 'disable.confirm': 'Confirmar desactivación', + 'disable.close': 'Cerrar', + trigger: 'Tablero de tareas', + loading: 'Cargando tablero de tareas…', + unavailable: 'El tablero de tareas no está disponible', + failure: 'Registro de tarea persistente no válido: {message}', + empty: 'Aún no hay tareas compartidas. Créelos a través de la conversación.', + tasks: 'Tareas compartidas', + owner: 'propietario', + unowned: 'Sin dueño', + blocked: 'Bloqueado por dependencias', + 'status.pending': 'Pendiente', + 'status.in_progress': 'En progreso', + 'status.completed': 'Completado', +} satisfies Record + +/** French dictionary, checked against the English keys. */ +export const fr = { + 'progress.completed': '{count} terminé', + 'progress.active': '{count} en cours', + 'progress.pending': '{count} en attente', + 'command.title': 'Collaboration entre agents', + 'command.running': 'En cours', + 'command.done': 'Terminé', + 'command.failed': 'Échec', + 'command.failedReason': 'Échec : {reason}', + 'mode.label': 'Activer la collaboration entre agents', + 'mode.infoLabel': 'À propos de la collaboration entre agents', + 'mode.description': 'Coordonnez la répartition du travail et la progression de plusieurs agents via un tableau de tâches partagé, pour de meilleurs résultats sur les tâches complexes, avec un coût en tokens plus élevé en contrepartie.', + 'mode.failed': 'Impossible de confirmer l’enregistrement du réglage de collaboration entre agents. Réessayez : {message}', + 'disable.title': 'Désactiver la collaboration entre agents ?', + 'disable.description': 'Désactiver la collaboration entre agents pendant une conversation arrête l’utilisation des outils de tâches partagées et des instructions de coordination de l’équipe.', + 'disable.warning': 'La désactivation peut affecter la réutilisation du cache, augmenter la consommation ultérieure de tokens et les coûts, et affecter la collaboration sur la tâche en cours.', + 'disable.cancel': 'Annuler', + 'disable.confirm': 'Confirmer la désactivation', + 'disable.close': 'Fermer', + trigger: 'Tableau des tâches', + loading: 'Chargement du tableau des tâches…', + unavailable: 'Le tableau des tâches n’est pas disponible', + failure: 'Enregistrement de tâche persistant non valide : {message}', + empty: "Aucune tâche partagée pour l'instant. Créez-les à travers la conversation.", + tasks: 'Tâches partagées', + owner: 'Propriétaire', + unowned: 'Sans propriétaire', + blocked: 'Bloqué par les dépendances', + 'status.pending': 'En attente', + 'status.in_progress': 'En cours', + 'status.completed': 'Terminé', +} satisfies Record + +/** German dictionary, checked against the English keys. */ +export const de = { + 'progress.completed': '{count} abgeschlossen', + 'progress.active': '{count} in Bearbeitung', + 'progress.pending': '{count} ausstehend', + 'command.title': 'Agentenzusammenarbeit', + 'command.running': 'Wird ausgeführt', + 'command.done': 'Abgeschlossen', + 'command.failed': 'Fehlgeschlagen', + 'command.failedReason': 'Fehlgeschlagen: {reason}', + 'mode.label': 'Agentenzusammenarbeit aktivieren', + 'mode.infoLabel': 'Über Agentenzusammenarbeit', + 'mode.description': 'Koordiniert die Arbeitsteilung und den Fortschritt mehrerer Agenten über eine gemeinsame Aufgabenübersicht. Erzielt bessere Ergebnisse bei komplexen Aufgaben, bei entsprechend höheren Tokenkosten.', + 'mode.failed': 'Das Speichern der Einstellung zur Agentenzusammenarbeit konnte nicht bestätigt werden. Erneut versuchen: {message}', + 'disable.title': 'Agentenzusammenarbeit deaktivieren?', + 'disable.description': 'Wenn Sie die Agentenzusammenarbeit während eines Gesprächs deaktivieren, werden die Werkzeuge für gemeinsame Aufgaben und die Anweisungen zur Teamkoordination nicht mehr verwendet.', + 'disable.warning': 'Die Deaktivierung kann die Wiederverwendung des Caches beeinträchtigen, den späteren Tokenverbrauch und die Kosten erhöhen und die Zusammenarbeit bei der aktuellen Aufgabe beeinträchtigen.', + 'disable.cancel': 'Abbrechen', + 'disable.confirm': 'Deaktivierung bestätigen', + 'disable.close': 'Schließen', + trigger: 'Aufgabenübersicht', + loading: 'Aufgabenübersicht wird geladen…', + unavailable: 'Aufgabenübersicht ist nicht verfügbar', + failure: 'Ungültiger gespeicherter Aufgabeneintrag: {message}', + empty: 'Noch keine geteilten Aufgaben. Erstellen Sie sie durch das Gespräch.', + tasks: 'Geteilte Aufgaben', + owner: 'Besitzer', + unowned: 'Unbesessen', + blocked: 'Blockiert durch Abhängigkeiten', + 'status.pending': 'Ausstehend', + 'status.in_progress': 'In Bearbeitung', + 'status.completed': 'Abgeschlossen', +} satisfies Record + +/** Russian dictionary, checked against the English keys. */ +export const ru = { + 'progress.completed': '{count} завершено', + 'progress.active': '{count} в процессе', + 'progress.pending': '{count} в ожидании', + 'command.title': 'Совместная работа агентов', + 'command.running': 'Выполняется', + 'command.done': 'Завершено', + 'command.failed': 'Ошибка', + 'command.failedReason': 'Ошибка: {reason}', + 'mode.label': 'Включить совместную работу агентов', + 'mode.infoLabel': 'О совместной работе агентов', + 'mode.description': 'Координируйте распределение работы и прогресс нескольких агентов с помощью общей доски задач: лучшие результаты в сложных задачах при соответственно более высоких затратах на токены.', + 'mode.failed': 'Не удалось подтвердить сохранение настройки совместной работы агентов. Повторите попытку: {message}', + 'disable.title': 'Отключить совместную работу агентов?', + 'disable.description': 'Отключение совместной работы агентов во время разговора прекращает использование инструментов общих задач и инструкций по координации команды.', + 'disable.warning': 'Отключение может повлиять на повторное использование кэша, увеличить последующий расход токенов и затраты, а также повлиять на совместную работу над текущей задачей.', + 'disable.cancel': 'Отмена', + 'disable.confirm': 'Подтвердить отключение', + 'disable.close': 'Закрыть', + trigger: 'Доска задач', + loading: 'Загрузка доски задач…', + unavailable: 'Доска задач недоступна', + failure: 'Недопустимая сохранённая запись задачи: {message}', + empty: 'Общие задачи пока отсутствуют. Создайте их посредством разговора.', + tasks: 'Общие задачи', + owner: 'Владелец', + unowned: 'Невладеющий', + blocked: 'Заблокировано зависимостями', + 'status.pending': 'Ожидается', + 'status.in_progress': 'В процессе', + 'status.completed': 'Завершено', +} satisfies Record + +/** Portuguese dictionary, checked against the English keys. */ +export const pt = { + 'progress.completed': '{count} concluído', + 'progress.active': '{count} em andamento', + 'progress.pending': '{count} pendente', + 'command.title': 'Colaboração entre agentes', + 'command.running': 'Em execução', + 'command.done': 'Concluído', + 'command.failed': 'Falha', + 'command.failedReason': 'Falha: {reason}', + 'mode.label': 'Ativar colaboração entre agentes', + 'mode.infoLabel': 'Sobre a colaboração entre agentes', + 'mode.description': 'Coordene a divisão do trabalho e o progresso de vários agentes com um quadro de tarefas compartilhado, para melhores resultados em tarefas complexas, com custos de tokens proporcionalmente maiores.', + 'mode.failed': 'Não foi possível confirmar que a configuração de colaboração entre agentes foi salva. Tente novamente: {message}', + 'disable.title': 'Desativar a colaboração entre agentes?', + 'disable.description': 'Desativar a colaboração entre agentes durante uma conversa interrompe o uso das ferramentas de tarefas compartilhadas e das instruções de coordenação da equipe.', + 'disable.warning': 'A desativação pode afetar a reutilização do cache, aumentar o consumo posterior de tokens e os custos, e afetar a colaboração na tarefa atual.', + 'disable.cancel': 'Cancelar', + 'disable.confirm': 'Confirmar desativação', + 'disable.close': 'Fechar', + trigger: 'Quadro de tarefas', + loading: 'Carregando quadro de tarefas…', + unavailable: 'O quadro de tarefas não está disponível', + failure: 'Registro de tarefa persistente inválido: {message}', + empty: 'Ainda não há tarefas compartilhadas. Crie-os através da conversa.', + tasks: 'Tarefas compartilhadas', + owner: 'Proprietário', + unowned: 'Sem dono', + blocked: 'Bloqueado por dependências', + 'status.pending': 'Pendente', + 'status.in_progress': 'Em andamento', + 'status.completed': 'Concluído', +} satisfies Record diff --git a/packages/client/ui-agent-team/src/client/mount.ts b/packages/client/ui-agent-team/src/client/mount.ts new file mode 100644 index 0000000000..833267e39e --- /dev/null +++ b/packages/client/ui-agent-team/src/client/mount.ts @@ -0,0 +1,71 @@ +/** Source-safe task-board browser registration. */ + +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-api-session-controller/client' +import type {} from '@deepseek-ai/dsh-client-ui-subagent/client' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type {} from '@deepseek-ai/dsh-client-ui-model-selection/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { TeamDisableConfirmation, type TeamDisableConfirmationInjected, type TeamDisableRequest } from './TeamDisableConfirmation.tsx' +import { TeamModeToggle, type TeamModeToggleInjected } from './TeamModeToggle.tsx' +import { TeamBoard } from './TeamBoard.tsx' +import { TeamCommandRow } from './TeamCommandRow.tsx' +import { en, ja, es, fr, de, ru, pt, NS, zh, type TeamKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Shared task-board copy. */ + 'agent-team': TeamKey + } +} + +/** Required browser services for slots and localized copy. */ +export const inject = ['slots', 'locale'] + +/** + * Register the task-board dictionaries and subagent catalog sections. + * The panel reads the root Session’s `agentTeam` projection through standard Session hooks. + * @param ctx - Client Context carrying the injected locale and slot services. + */ +export function registerAgentTeamUi(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en, ja, es, fr, de, ru, pt }), 'client-ui-agent-team: dictionaries') + ctx.slots.inject('conversation.chat.commandview', () => ctx.slots.register({ + name: 'conversation.chat.commandview', key: 'team', locale: NS, + }, TeamCommandRow)) + ctx.inject(['remote', 'remote.agentTeams'], (scope) => { + scope.slots.inject('shell.overlay', function* () { + const disableRequest = createSnapshotStore(null) + const selectTeamMode = async (sessionId: SessionId, enabled: boolean): Promise => { + const response = await scope.remote.agentTeams.setEnabled(sessionId, enabled) + return response.ok ? null : response.error.message + } + yield () => { disableRequest.set(null) } + yield scope.slots.register({ + name: 'shell.overlay', id: 'agent-team.disable', locale: NS, + inject: (): TeamDisableConfirmationInjected => ({ + hooks: { disableRequest }, + cancelDisable: (sessionId) => { + if (disableRequest.getSnapshot()?.sessionId === sessionId) disableRequest.set(null) + }, + disableTeam: sessionId => selectTeamMode(sessionId, false), + }), + }, TeamDisableConfirmation) + yield scope.slots.inject('conversation.input.model.options', () => scope.slots.register({ + name: 'conversation.input.model.options', + id: 'agent-team', + locale: NS, + inject: (sessionId): TeamModeToggleInjected => ({ + selectTeamMode: enabled => selectTeamMode(sessionId, enabled), + requestDisableConfirmation: () => { disableRequest.set({ sessionId }) }, + }), + }, TeamModeToggle)) + }) + }) + ctx.slots.inject('conversation.session.header.actions.subagent.footer', () => ctx.slots.register({ + name: 'conversation.session.header.actions.subagent.footer', id: 'agent-team', locale: NS, + }, TeamBoard)) +} diff --git a/packages/experimental/client-ui-agent-team/src/css-modules.d.ts b/packages/client/ui-agent-team/src/css-modules.d.ts similarity index 100% rename from packages/experimental/client-ui-agent-team/src/css-modules.d.ts rename to packages/client/ui-agent-team/src/css-modules.d.ts diff --git a/packages/experimental/client-ui-agent-team/src/index.ts b/packages/client/ui-agent-team/src/index.ts similarity index 100% rename from packages/experimental/client-ui-agent-team/src/index.ts rename to packages/client/ui-agent-team/src/index.ts diff --git a/packages/client/ui-agent-team/tests/browser-plugin.client.spec.ts b/packages/client/ui-agent-team/tests/browser-plugin.client.spec.ts new file mode 100644 index 0000000000..ba964a033f --- /dev/null +++ b/packages/client/ui-agent-team/tests/browser-plugin.client.spec.ts @@ -0,0 +1,135 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { TeamModeToggle, type TeamModeToggleInjected } from '../src/client/TeamModeToggle.tsx' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { TeamBoard } from '../src/client/TeamBoard.tsx' +import { TeamCommandRow } from '../src/client/TeamCommandRow.tsx' +import { TeamDisableConfirmation, type TeamDisableConfirmationInjected } from '../src/client/TeamDisableConfirmation.tsx' +import { apply, inject } from '../src/client/index.ts' +import { apply as nodeApply } from '../src/index.ts' + +async function bench() { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + ctx.provide('locale', new LocaleRuntime(ctx)) + await ctx.plugin(SlotRegistry).await() + const collapseHeader = ctx.slots.register({ + name: 'root', + children: { + 'conversation.session.header.actions.subagent.footer': { kind: 'list', scope: 'session' }, + 'conversation.input.model': { kind: 'single', scope: 'session' }, + 'shell.overlay': { kind: 'list', scope: 'root' }, + }, + } as never, () => null) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + const entry = () => ctx.slots.entries('conversation.session.header.actions.subagent.footer') + .find(candidate => candidate.component === TeamBoard) + return { ctx, fiber, entry, collapseHeader } +} + +describe('task-board browser plugin', () => { + it('registers a disposable root task-board section without navigation or Team Remote services', async () => { + const b = await bench() + expect(inject).toEqual(['slots', 'locale']) + expect(b.entry()).toMatchObject({ + options: { id: 'agent-team' }, + locale: 'agent-team', + }) + expect(b.entry()!.inject).toBeUndefined() + expect(b.ctx.slots.entries('conversation.session.header.actions')).toHaveLength(0) + const t = b.ctx.locale.bind('agent-team') + expect(t('trigger')).toBe('Task board') + await b.fiber.dispose() + expect(b.entry()).toBeUndefined() + expect(t('empty')).toBe('empty') + }) + + it('re-registers when the conversation header slot is declared again', async () => { + const b = await bench() + expect(b.entry()).toBeDefined() + b.collapseHeader() + expect(b.entry()).toBeUndefined() + b.ctx.slots.register({ + name: 'root', + children: { 'conversation.session.header.actions.subagent.footer': { kind: 'list', scope: 'session' } }, + } as never, () => null) + await Promise.resolve() + expect(b.entry()).toBeDefined() + await b.fiber.dispose() + }) + + it('registers the mode switch when Agent Teams arrives and reports each execution outcome', async () => { + const b = await bench() + b.ctx.slots.register({ + name: 'conversation.input.model', + children: { 'conversation.input.model.options': { kind: 'list', scope: 'session' } }, + } as never, () => null) + const entry = () => b.ctx.slots.entries('conversation.input.model.options') + .find(candidate => candidate.component === TeamModeToggle) + expect(entry()).toBeUndefined() + const setEnabled = vi.fn() + new TestRemote(b.ctx, { agentTeams: { setEnabled } }) + await vi.waitFor(() => { expect(entry()).toBeDefined() }) + const injected = entry()!.inject as (sessionId: SessionId) => Record + const value: object = injected(SessionId('chosen-session')) + const mode = value as TeamModeToggleInjected + setEnabled.mockResolvedValueOnce({ ok: true, value: undefined }) + await expect(mode.selectTeamMode(true)).resolves.toBeNull() + expect(setEnabled).toHaveBeenLastCalledWith('chosen-session', true) + setEnabled.mockResolvedValueOnce({ ok: true, value: undefined }) + await expect(mode.selectTeamMode(false)).resolves.toBeNull() + expect(setEnabled).toHaveBeenLastCalledWith('chosen-session', false) + setEnabled.mockResolvedValueOnce({ ok: false, error: new RemoteError('gateway/internal', 'connection failed', {}) }) + await expect(mode.selectTeamMode(true)).resolves.toBe('connection failed') + const overlayEntry = () => b.ctx.slots.entries('shell.overlay').find(candidate => candidate.component === TeamDisableConfirmation) + const overlayValue: object = overlayEntry()!.inject!() + const overlay = overlayValue as TeamDisableConfirmationInjected + const request = overlay.hooks.disableRequest + expect(request.getSnapshot()).toBeNull() + const beforeConfirmation = setEnabled.mock.calls.length + mode.requestDisableConfirmation() + expect(request.getSnapshot()).toEqual({ sessionId: 'chosen-session' }) + expect(setEnabled).toHaveBeenCalledTimes(beforeConfirmation) + overlay.cancelDisable(SessionId('other-session')) + expect(request.getSnapshot()).toEqual({ sessionId: 'chosen-session' }) + setEnabled.mockResolvedValueOnce({ ok: true, value: undefined }) + await expect(overlay.disableTeam(SessionId('chosen-session'))).resolves.toBeNull() + expect(setEnabled).toHaveBeenLastCalledWith('chosen-session', false) + overlay.cancelDisable(SessionId('chosen-session')) + expect(request.getSnapshot()).toBeNull() + mode.requestDisableConfirmation() + await b.fiber.dispose() + expect(entry()).toBeUndefined() + expect(overlayEntry()).toBeUndefined() + expect(request.getSnapshot()).toBeNull() + }) + + it('binds Team command receipts to the command slot lifetime without Remote services', async () => { + const b = await bench() + const entry = () => b.ctx.slots.entries('conversation.chat.commandview').find(value => value.component === TeamCommandRow) + expect(entry()).toBeUndefined() + b.collapseHeader() + const registerParent = () => b.ctx.slots.register({ + name: 'root', + children: { 'conversation.chat.commandview': { kind: 'keyed', scope: 'session' } }, + } as never, () => null) + const collapse = registerParent() + await vi.waitFor(() => { expect(entry()).toBeDefined() }) + expect(entry()).toMatchObject({ options: { key: 'team' }, locale: 'agent-team' }) + expect(entry()!.inject).toBeUndefined() + collapse() + expect(entry()).toBeUndefined() + registerParent() + await vi.waitFor(() => { expect(entry()).toBeDefined() }) + await b.fiber.dispose() + expect(entry()).toBeUndefined() + }) + + it('keeps the node half inert', () => { + expect(() => { nodeApply() }).not.toThrow() + }) +}) diff --git a/packages/client/ui-agent-team/tests/team-board.client.spec.tsx b/packages/client/ui-agent-team/tests/team-board.client.spec.tsx new file mode 100644 index 0000000000..b09aedc4e5 --- /dev/null +++ b/packages/client/ui-agent-team/tests/team-board.client.spec.tsx @@ -0,0 +1,416 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { TeamProjection, TeamTaskId, TeamTaskView as TeamTask } from '@deepseek-ai/dsh-agent-team/client' +import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import { TeamBoard, type TeamBoardProps } from '../src/client/TeamBoard.tsx' +import { en, zh } from '../src/client/locales.ts' + +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +const SESSION = 'root-session-id' as SessionId +const WORKER = 'worker-id' as SessionId +const NESTED = 'nested-id' as SessionId +const TASK_1 = 'task-1' as TeamTaskId +const TASK_2 = 'task-2' as TeamTaskId +const task: TeamTask = { + id: TASK_1, + revision: 1, + subject: 'Implement runtime', + description: 'Build the Team runtime', + status: 'in_progress', + ownerId: SESSION, + blockedBy: [], + writeScopes: ['src'], + ready: false, + writeScopeWarnings: ['write scopes overlap with task-2'], +} +const team: TeamProjection = { enabled: true, tasks: [task] } +type Projections = SessionListState['projectionsBySession'] + +function summary(id: SessionId, title?: string): SessionSummary { + return { + id, ...(title === undefined ? {} : { title }), displayTitle: title ?? id, + running: false, retainedBy: {}, blank: false, updatedAt: 0, + } +} + +function bench(options: { + projections?: Projections + sessionId?: SessionId + byId?: Record + value?: TeamProjection +} = {}) { + const sessionId = options.sessionId ?? SESSION + const byId = options.byId ?? { [SESSION]: summary(SESSION) } + const sessions = createSnapshotStore({ + ids: Object.keys(byId) as SessionId[], byId, phase: 'ready', + projectionsBySession: options.projections ?? { [SESSION]: { state: 'ready', error: null, values: { agentTeam: options.value ?? team } } }, + }) + const useProjection = ((_key: 'agentTeam', selector?: (value: TeamProjection | undefined) => unknown) => + bindSnapshotSelector(sessions)((state) => { + const value = state.projectionsBySession[sessionId]?.values.agentTeam + return selector === undefined ? value : selector(value) + })) as TeamBoardProps['useProjection'] + const props = { + sessionId, useProjection, + useSessions: bindSnapshotSelector(sessions), + t: makeTranslate(zh, commonZh), + } as TeamBoardProps + return { props, sessions } +} + +function setProjectionSnapshot( + sessions: ReturnType['sessions'], sessionId: SessionId, snapshot: Projections[SessionId], +): void { + act(() => { + const current = sessions.getSnapshot() + sessions.set({ ...current, projectionsBySession: { ...current.projectionsBySession, [sessionId]: snapshot } }) + }) +} + +function setProjection(sessions: ReturnType['sessions'], sessionId: SessionId, value: TeamProjection): void { + setProjectionSnapshot(sessions, sessionId, { state: 'ready', error: null, values: { agentTeam: value } }) +} + +function row(subject = task.subject): HTMLElement { + return screen.getByRole('button', { name: new RegExp(subject, 'u') }) +} + +function ownerValue(): string | null { + return document.querySelector('[data-team-task-owner] span:last-child')?.textContent ?? null +} + +function enableTooltips(): void { + class TooltipObserver implements ResizeObserver { + constructor(private readonly callback: ResizeObserverCallback) {} + observe(target: Element): void { + const size = { inlineSize: 160, blockSize: 30 } + this.callback([{ + target, borderBoxSize: [size], contentBoxSize: [size], devicePixelContentBoxSize: [size], contentRect: new DOMRect(), + }], this) + } + unobserve(): void {} + disconnect(): void {} + } + vi.stubGlobal('ResizeObserver', TooltipObserver) + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true }))) +} + +describe('TeamBoard', () => { + it('shows a nonclickable Checklist header and compact initially closed rows', () => { + const b = bench() + render() + const heading = screen.getByRole('heading') + expect(heading.textContent).toBe(`${zh.tasks}${b.props.t('progress.active', { count: 1 })}`) + expect(heading.querySelector('button')).toBeNull() + expect(heading.querySelector('[data-icon="dsw-icon-checklist-outline-regular"]')).toBeTruthy() + expect(screen.getAllByRole('button')).toEqual([row()]) + expect(row().getAttribute('aria-expanded')).toBe('false') + expect(row().textContent).toBe(task.subject) + expect(within(row()).getByRole('img', { name: zh['status.in_progress'] })).toBeTruthy() + expect(row().querySelector('[data-state="ongoing"]')).toBeTruthy() + expect(row().querySelector('[data-icon="dsw-icon-chevron-down-outline-regular"]')).toBeTruthy() + expect(screen.queryByText(task.description)).toBeNull() + expect(screen.queryByText(zh.owner)).toBeNull() + expect(screen.queryByRole('button', { name: '展开' })).toBeNull() + }) + + it('expands the full description before the owner and keeps only one task open', () => { + const longSubject = 'A complete subject that must remain readable when the task is expanded' + const description = 'First line\n'.repeat(30) + 'Final description line' + const b = bench({ value: { ...team, tasks: [ + { ...task, subject: longSubject, description }, + { ...task, id: TASK_2, subject: 'Review implementation', ownerId: WORKER }, + ] } }) + render() + const first = row(longSubject) + fireEvent.click(first) + expect(first.getAttribute('aria-expanded')).toBe('true') + expect(row(longSubject)).toBe(first) + const details = document.getElementById(first.getAttribute('aria-controls')!)! + const owner = details.querySelector('[data-team-task-owner]')! + expect(owner.tagName).toBe('P') + expect(owner.textContent).toBe(`${zh.owner}: ${SESSION}`) + expect(ownerValue()).toBe(SESSION) + const paragraphs = details.querySelectorAll('p') + expect(paragraphs).toHaveLength(2) + expect(paragraphs[0]?.textContent).toBe(description) + expect(paragraphs[1]).toBe(owner) + expect(details.querySelector('dl, dt, dd')).toBeNull() + expect(screen.queryByText(TASK_1)).toBeNull() + expect(screen.queryByText('src')).toBeNull() + expect(screen.queryByText(task.writeScopeWarnings[0]!)).toBeNull() + expect(screen.getAllByRole('button')).toHaveLength(2) + fireEvent.click(row('Review implementation')) + expect(first.getAttribute('aria-expanded')).toBe('false') + expect(ownerValue()).toBe(WORKER) + expect(document.querySelectorAll('[data-team-task-details]')).toHaveLength(1) + expect(screen.queryByText(description)).toBeNull() + fireEvent.click(row('Review implementation')) + expect(ownerValue()).toBeNull() + expect(screen.getAllByRole('button').every(button => button.getAttribute('aria-expanded') === 'false')).toBe(true) + }) + + it('preserves source order, keeps completed tasks, and excludes deleted tombstones from rows and counts', () => { + const b = bench({ value: { ...team, tasks: [ + { ...task, subject: 'Pending first', status: 'pending', ready: true }, + { ...task, id: TASK_2, subject: 'Completed second', status: 'completed' }, + { ...task, id: 'third' as TeamTaskId, subject: 'Active third' }, + { ...task, id: 'deleted' as TeamTaskId, subject: 'Deleted task', status: 'deleted' }, + ] } }) + render() + expect(screen.getAllByRole('button').map(button => button.textContent)).toEqual(['Pending first', 'Completed second', 'Active third']) + expect(screen.getByRole('heading').textContent).toBe(`${zh.tasks}${[ + b.props.t('progress.completed', { count: 1 }), b.props.t('progress.active', { count: 1 }), b.props.t('progress.pending', { count: 1 }), + ].join('\u2002·\u2002')}`) + expect(screen.queryByText('Deleted task')).toBeNull() + expect(row('Pending first').querySelector('[data-state="idle"]')).toBeTruthy() + expect(row('Completed second').querySelector('[data-state="done"]')).toBeTruthy() + fireEvent.click(row('Completed second')) + expect(row('Completed second').getAttribute('aria-expanded')).toBe('true') + expect(ownerValue()).toBe(SESSION) + expect(screen.getByText(task.description)).toBeTruthy() + }) + + it('announces blocked status without displaying readiness, dependencies, scopes, warnings, or task ids', () => { + const { ownerId: _ownerId, ...unowned } = task + const b = bench({ value: { ...team, tasks: [ + { ...unowned, status: 'pending', blockedBy: [TASK_2, 'missing' as TeamTaskId] }, + { ...task, id: TASK_2, subject: 'Prepare shared types', status: 'completed' }, + ] } }) + render() + expect(within(row()).getByRole('img', { name: zh.blocked })).toBeTruthy() + expect(row().querySelector('[data-state="warning"]')).toBeTruthy() + fireEvent.click(row()) + expect(ownerValue()).toBe(zh.unowned) + const details = document.querySelector('[data-team-task-details]')! + expect(details.textContent).toBe(`${task.description}${zh.owner}: ${zh.unowned}`) + expect(details.querySelector('dl, dt, dd')).toBeNull() + for (const metadata of [ + TASK_1, TASK_2, 'Prepare shared types', 'missing', 'src', task.writeScopeWarnings[0]!, zh.blocked, + ]) expect(within(details).queryByText(metadata)).toBeNull() + expect(b.sessions.getSnapshot().projectionsBySession[SESSION]!.values.agentTeam?.tasks[0]).toEqual({ + ...unowned, status: 'pending', blockedBy: [TASK_2, 'missing' as TeamTaskId], + }) + }) + + it('keeps the pending glyph and shows only the owner when description is empty', () => { + render() + fireEvent.click(row()) + expect(within(row()).getByRole('img', { name: zh['status.pending'] })).toBeTruthy() + expect(row().querySelector('[data-state="idle"]')).toBeTruthy() + expect(ownerValue()).toBe(SESSION) + expect(document.querySelector('[data-team-task-details]')?.children).toHaveLength(1) + expect(document.querySelector('[data-team-task-details] p:not([data-team-task-owner])')).toBeNull() + }) + + it('uses the owner id independently of catalog descriptions and Session titles', () => { + const b = bench({ + byId: { [SESSION]: summary(SESSION), [NESTED]: { ...summary(NESTED, 'Session title'), parentId: WORKER } }, + projections: { + [SESSION]: { state: 'ready', error: null, values: { + agentTeam: { ...team, tasks: [{ ...task, ownerId: NESTED }] }, + } }, + [WORKER]: { state: 'ready', error: null, values: { + subagentCatalog: [{ id: NESTED, createdAt: 0, mode: 'continuable', label: 'Catalog responsibility' }], + } }, + }, + }) + render() + fireEvent.click(row()) + expect(ownerValue()).toBe(NESTED) + setProjectionSnapshot(b.sessions, WORKER, { state: 'ready', error: null, values: {} }) + act(() => { b.sessions.update((draft) => { draft.byId[NESTED] = { ...summary(NESTED), displayTitle: 'Display title' } }) }) + expect(ownerValue()).toBe(NESTED) + setProjection(b.sessions, SESSION, { ...team, tasks: [{ ...task, ownerId: WORKER }] }) + expect(ownerValue()).toBe(WORKER) + }) + + it('uses owner id when neither root nor parent projections exist', () => { + const b = bench({ value: { ...team, tasks: [{ ...task, ownerId: NESTED }] }, byId: { + [NESTED]: { ...summary(NESTED), parentId: WORKER }, + } }) + render() + fireEvent.click(row()) + expect(ownerValue()).toBe(NESTED) + }) + + it('hides retained tasks while Team is disabled and shows them when enabled', () => { + const b = bench() + render() + setProjection(b.sessions, SESSION, { ...team, enabled: false }) + expect(screen.queryByRole('region')).toBeNull() + expect(screen.queryByText(task.subject)).toBeNull() + setProjection(b.sessions, SESSION, team) + expect(row()).toBeTruthy() + }) + + it('accepts absent baselines, capability removal, empty tasks, and deleted-only snapshots', () => { + const b = bench({ projections: {} }) + render() + expect(screen.queryByRole('region')).toBeNull() + setProjectionSnapshot(b.sessions, SESSION, { state: 'idle', error: null, values: { agentTeam: team } }) + expect(row()).toBeTruthy() + setProjectionSnapshot(b.sessions, SESSION, { state: 'idle', error: null, values: {} }) + expect(screen.queryByRole('region')).toBeNull() + for (const tasks of [[], [{ ...task, status: 'deleted' as const }]]) { + setProjection(b.sessions, SESSION, { enabled: true, tasks }) + expect(screen.queryByText(zh.empty)).toBeNull() + expect(screen.queryByRole('heading')).toBeNull() + expect(screen.queryByRole('button')).toBeNull() + expect(screen.queryByRole('region')).toBeNull() + expect(document.querySelector('[data-team-panel]')).toBeNull() + } + }) + + it('reads only the current Session projection', () => { + const b = bench({ sessionId: WORKER }) + render() + expect(screen.queryByRole('region')).toBeNull() + setProjection(b.sessions, WORKER, { ...team, tasks: [{ ...task, subject: 'Current task' }] }) + expect(row('Current task')).toBeTruthy() + setProjection(b.sessions, SESSION, { ...team, tasks: [{ ...task, subject: 'Other task' }] }) + expect(screen.queryByText('Other task')).toBeNull() + }) + + it('keeps failures beside valid tasks but shows only an alert without visible tasks', () => { + const b = bench({ value: { ...team, failure: 'revision is not contiguous' } }) + const view = render() + expect(screen.getByRole('alert').textContent).toBe('任务记录无效:revision is not contiguous') + expect(row()).toBeTruthy() + expect(screen.getByRole('heading')).toBeTruthy() + for (const tasks of [[], [{ ...task, status: 'deleted' as const }]]) { + setProjectionSnapshot(b.sessions, SESSION, { state: 'ready', error: null, values: { + agentTeam: { enabled: true, tasks, failure: 'invalid task' }, + subagentCatalog: [{ id: WORKER, createdAt: 0, mode: 'continuable', label: 'Worker' }], + } }) + const alert = screen.getByRole('alert') + expect(alert.textContent).toBe('任务记录无效:invalid task') + expect(screen.getAllByRole('alert')).toHaveLength(1) + expect(view.container.textContent).toBe(alert.textContent) + expect(view.container.querySelector('[data-with-members="true"]')).toBeNull() + expect(screen.queryByRole('heading')).toBeNull() + expect(screen.queryByRole('region')).toBeNull() + expect(screen.queryByText(zh.empty)).toBeNull() + expect(document.querySelector('[data-team-panel]')).toBeNull() + } + setProjection(b.sessions, SESSION, { enabled: false, tasks: [], failure: 'invalid task' }) + expect(view.container.childElementCount).toBe(0) + }) + + it('renders nothing for empty or deleted-only tasks even when members exist', () => { + const b = bench() + const view = render() + for (const tasks of [[], [{ ...task, status: 'deleted' as const }]]) { + setProjectionSnapshot(b.sessions, SESSION, { state: 'ready', error: null, values: { + agentTeam: { enabled: true, tasks }, + subagentCatalog: [{ id: WORKER, createdAt: 0, mode: 'continuable', label: 'Worker' }], + } }) + expect(view.container.childElementCount).toBe(0) + } + }) + + it('keeps live updates and expansion tied to task identity without sorting or polling', () => { + const b = bench() + render() + fireEvent.click(row()) + setProjection(b.sessions, SESSION, { ...team, tasks: [ + { ...task, id: TASK_2, subject: 'New task', status: 'pending', ready: true }, + { ...task, status: 'completed', description: 'Updated details' }, + ] }) + expect(screen.getAllByRole('button').map(button => button.textContent)).toEqual(['New task', task.subject]) + expect(row().getAttribute('aria-expanded')).toBe('true') + expect(row('New task').getAttribute('aria-expanded')).toBe('false') + expect(screen.getByText('Updated details')).toBeTruthy() + setProjection(b.sessions, SESSION, { ...team, tasks: [{ ...task, status: 'deleted' }] }) + expect(screen.queryByText('Updated details')).toBeNull() + }) + + it('uses translated English progress and details without Show more or Show less controls', () => { + const b = bench() + render() + expect(screen.getByRole('heading').textContent).toBe(`${en.tasks}${makeTranslate(en)('progress.active', { count: 1 })}`) + fireEvent.click(row()) + expect(ownerValue()).toBe(SESSION) + expect(document.querySelector('[data-team-task-owner]')?.textContent).toBe(`${en.owner}: ${SESSION}`) + expect(screen.queryByText(TASK_1)).toBeNull() + expect(screen.queryByText('Show more')).toBeNull() + expect(screen.queryByText('Show less')).toBeNull() + }) + + it('shows the clipped owner tooltip after 500ms of hover', () => { + vi.useFakeTimers() + enableTooltips() + const ownerId = '8c0404a0-4c67-48ab-9513-50b4a755d6de' as SessionId + const b = bench({ + value: { ...team, tasks: [{ ...task, ownerId }] }, + }) + const view = render() + fireEvent.click(row()) + const owner = screen.getByText(ownerId) + expect(owner.parentElement?.hasAttribute('data-team-task-owner')).toBe(true) + const scrollWidth = vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockReturnValue(80) + vi.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(80) + fireEvent.pointerEnter(owner) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.queryByRole('tooltip')).toBeNull() + fireEvent.pointerLeave(owner) + scrollWidth.mockImplementation(function (this: Element) { return this === owner ? 240 : 80 }) + fireEvent.pointerEnter(owner) + act(() => { vi.advanceTimersByTime(499) }) + expect(screen.queryByRole('tooltip')).toBeNull() + act(() => { vi.advanceTimersByTime(1) }) + expect(screen.getByRole('tooltip').textContent).toBe(ownerId) + expect(view.container.contains(screen.getByRole('tooltip'))).toBe(false) + expect(document.querySelectorAll('[data-team-task-owner]')).toHaveLength(1) + fireEvent.click(row()) + expect(screen.queryByRole('tooltip')).toBeNull() + expect(ownerValue()).toBeNull() + }) + + it('delays clipped collapsed subject hover by 500ms while keeping keyboard focus immediate', () => { + vi.useFakeTimers() + enableTooltips() + const view = render() + const button = row() + const subject = screen.getByText(task.subject) + const scrollWidth = vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockReturnValue(80) + vi.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(80) + fireEvent.pointerEnter(button) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.queryByRole('tooltip')).toBeNull() + fireEvent.pointerLeave(button) + scrollWidth.mockImplementation(function (this: Element) { return this === subject ? 180 : 80 }) + fireEvent.pointerEnter(button) + act(() => { vi.advanceTimersByTime(499) }) + expect(screen.queryByRole('tooltip')).toBeNull() + act(() => { vi.advanceTimersByTime(1) }) + expect(screen.getByRole('tooltip').textContent).toBe(task.subject) + expect(view.container.contains(screen.getByRole('tooltip'))).toBe(false) + fireEvent.click(button) + expect(row()).toBe(button) + expect(screen.queryByRole('tooltip')).toBeNull() + fireEvent.pointerLeave(button) + fireEvent.pointerEnter(button) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.queryByRole('tooltip')).toBeNull() + fireEvent.click(button) + fireEvent.pointerLeave(button) + fireEvent.keyDown(document.body, { key: 'Tab' }) + fireEvent.focus(button) + expect(screen.getByRole('tooltip').textContent).toBe(task.subject) + }) +}) diff --git a/packages/client/ui-agent-team/tests/team-command.client.spec.tsx b/packages/client/ui-agent-team/tests/team-command.client.spec.tsx new file mode 100644 index 0000000000..999b336108 --- /dev/null +++ b/packages/client/ui-agent-team/tests/team-command.client.spec.tsx @@ -0,0 +1,98 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { CommandId } from '@deepseek-ai/dsh-commands/brand' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { IconUsersOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import { TeamCommandRow, type TeamCommandRowProps } from '../src/client/TeamCommandRow.tsx' +import { zh, en, ja, es, fr, de, ru, pt } from '../src/client/locales.ts' + +afterEach(cleanup) + +type Node = TeamCommandRowProps['node'] +function command(args: string | null, outcome: Node['outcome']): Node { + return { kind: 'command', seq: 1, time: 1, commandId: CommandId('team-command'), name: 'team', args, outcome } +} + +describe.each([ + { id: 'zh', dictionary: zh }, { id: 'en', dictionary: en }, { id: 'ja', dictionary: ja }, { id: 'es', dictionary: es }, + { id: 'fr', dictionary: fr }, { id: 'de', dictionary: de }, { id: 'ru', dictionary: ru }, { id: 'pt', dictionary: pt }, +])('Team command fallback in $id', ({ dictionary }) => { + const t = makeTranslate(dictionary) + + it.each(['status', 'ON', '', null])('shows a localized pending status for unrecognized input %j', (args) => { + const view = render() + expect(view.getByText(dictionary['command.title'])).toBeTruthy() + expect(view.getByText(dictionary['command.running'])).toBeTruthy() + expect(view.container.querySelector('[data-team-command]')?.getAttribute('aria-busy')).toBe('true') + expect(view.container.querySelector('[data-team-command]')?.getAttribute('data-state')).toBe('running') + expect(view.queryByRole('button')).toBeNull() + }) + + it.each(['status', 'ON', '', null])('preserves successful output for unrecognized input %j', (args) => { + const text = 'Agent Teams enabled.' + const node = command(args, { kind: 'success', text }) + const original = JSON.stringify(node) + const view = render() + expect(view.getByText(dictionary['command.title'])).toBeTruthy() + expect(view.getByText(text)).toBeTruthy() + expect(view.queryByRole('button')).toBeNull() + expect(view.container.querySelector('[data-team-command]')?.getAttribute('data-state')).toBe('ok') + expect(JSON.stringify(node)).toBe(original) + }) + + it.each([undefined, ''])('uses a localized completed fallback for empty output %j', (text) => { + const outcome: Node['outcome'] = { kind: 'success', ...text === undefined ? {} : { text } } + const view = render() + expect(view.getByText(dictionary['command.title'])).toBeTruthy() + expect(view.getByText(dictionary['command.done'])).toBeTruthy() + }) + + it.each(['on', 'off', 'status', null])('keeps the raw failure expandable for input %j', (args) => { + const raw = ' Save failed\r\nThe original diagnostic remains available.\n' + const node = command(args, { kind: 'error', text: raw }) + const original = JSON.stringify(node) + const view = render() + expect(view.getByText(dictionary['command.title'])).toBeTruthy() + expect(view.getByText(t('command.failedReason', { reason: 'Save failed' }))).toBeTruthy() + expect(view.container.querySelector('[data-team-command]')?.getAttribute('data-state')).toBe('error') + const toggle = view.getByRole('button', { expanded: false }) + fireEvent.keyDown(toggle, { key: 'Enter' }) + expect(view.container.querySelector('pre')?.textContent).toBe(raw) + fireEvent.keyDown(toggle, { key: ' ' }) + expect(view.container.querySelector('pre')).toBeNull() + expect(JSON.stringify(node)).toBe(original) + }) + + it.each([undefined, '', '\n '])('uses a localized failure fallback for empty reason %j', (text) => { + const outcome: Node['outcome'] = { kind: 'error', ...text === undefined ? {} : { text } } + const view = render() + expect(view.getByText(dictionary['command.failed'])).toBeTruthy() + }) +}) + +it('uses the shared agent glyph for a failed command', () => { + const view = render() + const icon = render() + expect(view.container.querySelector('svg')?.outerHTML).toBe(icon.container.querySelector('svg')?.outerHTML) +}) + +it('keeps a long single-line failure expandable', () => { + const raw = 'The original failure detail '.repeat(20) + const view = render() + fireEvent.click(view.getByRole('button', { expanded: false })) + expect(view.container.querySelector('pre')?.textContent).toBe(raw) +}) + +it.each(['success', 'error'] as const)('relocalizes the title without rewriting expanded %s output', (kind) => { + const node = command('status', { kind, text: 'Recorded output\nAdditional recorded detail.' }) + const original = JSON.stringify(node) + const view = render() + expect(view.getByText(en['command.title'])).toBeTruthy() + fireEvent.click(view.getByRole('button', { expanded: false })) + expect(view.container.querySelector('pre')?.textContent).toBe(node.outcome?.text) + view.rerender() + expect(view.getByText(zh['command.title'])).toBeTruthy() + expect(view.container.querySelector('pre')?.textContent).toBe(node.outcome?.text) + expect(JSON.stringify(node)).toBe(original) +}) diff --git a/packages/client/ui-agent-team/tests/team-disable-confirmation-callbacks.client.spec.tsx b/packages/client/ui-agent-team/tests/team-disable-confirmation-callbacks.client.spec.tsx new file mode 100644 index 0000000000..92fa679c4c --- /dev/null +++ b/packages/client/ui-agent-team/tests/team-disable-confirmation-callbacks.client.spec.tsx @@ -0,0 +1,43 @@ +// @vitest-environment jsdom + +import type { ComponentProps } from 'react' +import { afterEach, expect, it, onTestFinished, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { SessionId } from '@deepseek-ai/dsh-session' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { Button } from '@deepseek-ai/dsh-client-ui-primitives' +import { TeamDisableConfirmation, type TeamDisableConfirmationProps } from '../src/client/TeamDisableConfirmation.tsx' +import { zh } from '../src/client/locales.ts' + +vi.mock('../src/client/TeamDisableConfirmation.module.css', () => ({ default: {} })) +vi.mock('@deepseek-ai/dsh-client-ui-primitives', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + // Deliver repeated activations to the form even while its native button would suppress them. + Button: (props: ComponentProps) => , + } +}) + +afterEach(cleanup) + +it('keeps one disable operation pending despite repeated callbacks without optional stylesheet classes', async () => { + const pending = Promise.withResolvers() + onTestFinished(async () => { await act(async () => { pending.resolve(null) }) }) + const sessionId = SessionId('pending-disable-session') + const disableTeam = vi.fn(() => pending.promise) + const cancelDisable = vi.fn() + const useDisableRequest: TeamDisableConfirmationProps['useDisableRequest'] = select => select({ sessionId }) + const props = { useDisableRequest, disableTeam, cancelDisable, t: makeTranslate(zh) } as TeamDisableConfirmationProps + render() + const confirm = screen.getByRole('button', { name: zh['disable.confirm'] }) + fireEvent.click(confirm) + expect(confirm.getAttribute('aria-disabled')).toBe('true') + fireEvent.click(confirm) + fireEvent.click(screen.getByRole('button', { name: zh['disable.cancel'] })) + expect(disableTeam).toHaveBeenCalledExactlyOnceWith(sessionId) + expect(cancelDisable).not.toHaveBeenCalled() + expect(screen.getByRole('dialog', { name: zh['disable.title'] })).toBeTruthy() + await act(async () => { pending.resolve(null) }) + expect(cancelDisable).toHaveBeenCalledExactlyOnceWith(sessionId) +}) diff --git a/packages/client/ui-agent-team/tests/team-disable-confirmation.client.spec.tsx b/packages/client/ui-agent-team/tests/team-disable-confirmation.client.spec.tsx new file mode 100644 index 0000000000..58b3fa9004 --- /dev/null +++ b/packages/client/ui-agent-team/tests/team-disable-confirmation.client.spec.tsx @@ -0,0 +1,172 @@ +// @vitest-environment jsdom + +import { afterEach, expect, it, onTestFinished, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { SessionId } from '@deepseek-ai/dsh-session' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { + TeamDisableConfirmation, type TeamDisableConfirmationProps, type TeamDisableRequest, +} from '../src/client/TeamDisableConfirmation.tsx' +import { zh } from '../src/client/locales.ts' + +afterEach(cleanup) + +const ORIGINAL = SessionId('original-session') +const REPLACEMENT = SessionId('replacement-session') + +function props( + request: TeamDisableRequest | null = { sessionId: ORIGINAL }, + disableTeam = vi.fn(async () => null), +) { + const useDisableRequest: TeamDisableConfirmationProps['useDisableRequest'] = select => select(request) + const cancelDisable = vi.fn() + return { + props: { useDisableRequest, cancelDisable, disableTeam, t: makeTranslate(zh) } as TeamDisableConfirmationProps, + cancelDisable, + disableTeam, + } +} + +function pendingDisable() { + const result = Promise.withResolvers() + onTestFinished(async () => { + await act(async () => { result.resolve(null) }) + }) + return result +} + +it('renders nothing when no disable request exists', () => { + const input = props(null) + const view = render() + expect(view.container.childElementCount).toBe(0) + expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.queryByRole('button')).toBeNull() + expect(input.disableTeam).not.toHaveBeenCalled() + expect(input.cancelDisable).not.toHaveBeenCalled() +}) + +it('shows the localized title, two paragraphs, and actions without a checkbox', () => { + const input = props() + render() + const dialog = within(screen.getByRole('dialog', { name: '确认停用智能体团队协作?' })) + expect(dialog.getByRole('heading', { name: '确认停用智能体团队协作?' })).toBeTruthy() + expect(dialog.getByText(zh['disable.description'], { selector: 'p' })).toBeTruthy() + expect(dialog.getByText(zh['disable.warning'], { selector: 'p' })).toBeTruthy() + expect(dialog.getAllByRole('paragraph')).toHaveLength(2) + const cancel = dialog.getByRole('button', { name: zh['disable.cancel'] }) + expect(cancel.hasAttribute('disabled')).toBe(false) + expect(document.activeElement).toBe(cancel) + expect(dialog.getByRole('button', { name: zh['disable.confirm'] }).hasAttribute('disabled')).toBe(false) + expect(dialog.getByRole('button', { name: zh['disable.close'] })).toBeTruthy() + expect(dialog.queryByRole('checkbox')).toBeNull() + expect(dialog.queryByRole('alert')).toBeNull() + expect(input.disableTeam).not.toHaveBeenCalled() +}) + +it.each(['cancel', 'close', 'Escape'] as const)('dismisses through %s without disabling Team', (action) => { + const input = props() + const view = render() + if (action === 'Escape') fireEvent.keyDown(document, { key: 'Escape' }) + else fireEvent.click(screen.getByRole('button', { name: zh[`disable.${action}`] })) + expect(input.cancelDisable).toHaveBeenCalledExactlyOnceWith(ORIGINAL) + expect(input.disableTeam).not.toHaveBeenCalled() + view.rerender( select(null)} />) + expect(screen.queryByRole('dialog')).toBeNull() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(input.cancelDisable).toHaveBeenCalledTimes(1) +}) + +it('disables the originating Session once and blocks cancellation until success', async () => { + const result = pendingDisable() + const input = props({ sessionId: ORIGINAL }, vi.fn(() => result.promise)) + const view = render() + const confirm = screen.getByRole('button', { name: zh['disable.confirm'] }) + fireEvent.click(confirm) + expect(input.disableTeam).toHaveBeenCalledExactlyOnceWith(ORIGINAL) + expect(confirm.hasAttribute('disabled')).toBe(true) + const cancel = screen.getByRole('button', { name: zh['disable.cancel'] }) + expect(cancel.hasAttribute('disabled')).toBe(true) + fireEvent.click(confirm) + fireEvent.click(cancel) + fireEvent.click(screen.getByRole('button', { name: zh['disable.close'] })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(input.disableTeam).toHaveBeenCalledTimes(1) + expect(input.cancelDisable).not.toHaveBeenCalled() + expect(screen.getByRole('dialog')).toBeTruthy() + await act(async () => { result.resolve(null) }) + expect(input.cancelDisable).toHaveBeenCalledExactlyOnceWith(ORIGINAL) + view.rerender( select(null)} />) + expect(screen.queryByRole('dialog')).toBeNull() +}) + +it.each(['returned', 'error', 'string'] as const)('keeps a %s failure visible and allows retry', async (failure) => { + const result = pendingDisable() + const disableTeam = vi.fn() + .mockImplementationOnce(async () => { + if (failure === 'error') throw new Error('offline') + if (failure === 'string') throw 'offline' + return 'offline' + }) + .mockImplementationOnce(() => result.promise) + const input = props({ sessionId: ORIGINAL }, disableTeam) + const view = render() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['disable.confirm'] })) }) + expect(screen.getByRole('alert').textContent).toBe(input.props.t('mode.failed', { message: 'offline' })) + expect(input.cancelDisable).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: zh['disable.cancel'] }).hasAttribute('disabled')).toBe(false) + expect(screen.getByRole('button', { name: zh['disable.confirm'] }).hasAttribute('disabled')).toBe(false) + view.rerender( select({ sessionId: ORIGINAL })} />) + expect(screen.getByRole('alert').textContent).toContain('offline') + fireEvent.click(screen.getByRole('button', { name: zh['disable.confirm'] })) + expect(disableTeam.mock.calls).toEqual([[ORIGINAL], [ORIGINAL]]) + expect(screen.queryByRole('alert')).toBeNull() + expect(screen.getByRole('button', { name: zh['disable.confirm'] }).hasAttribute('disabled')).toBe(true) + await act(async () => { result.resolve(null) }) + expect(input.cancelDisable).toHaveBeenCalledExactlyOnceWith(ORIGINAL) +}) + +it('clears the previous failure when the request targets a different Session', async () => { + const input = props({ sessionId: ORIGINAL }, vi.fn() + .mockResolvedValueOnce('old failure').mockResolvedValueOnce(null)) + const view = render() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['disable.confirm'] })) }) + expect(screen.getByRole('alert').textContent).toContain('old failure') + view.rerender( select({ sessionId: REPLACEMENT })} />) + expect(screen.queryByRole('alert')).toBeNull() + expect(screen.getByRole('button', { name: zh['disable.confirm'] }).hasAttribute('disabled')).toBe(false) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['disable.confirm'] })) }) + expect(input.disableTeam.mock.calls).toEqual([[ORIGINAL], [REPLACEMENT]]) + expect(input.cancelDisable).toHaveBeenCalledExactlyOnceWith(REPLACEMENT) +}) + +it.each(['success', 'returned', 'error'] as const)('isolates a replacement request from the old pending %s', async (outcome) => { + const original = pendingDisable() + const replacement = pendingDisable() + const input = props({ sessionId: ORIGINAL }, vi.fn() + .mockImplementationOnce(() => original.promise).mockImplementationOnce(() => replacement.promise)) + const view = render() + fireEvent.click(screen.getByRole('button', { name: zh['disable.confirm'] })) + expect(screen.getByRole('button', { name: zh['disable.confirm'] }).hasAttribute('disabled')).toBe(true) + view.rerender( select({ sessionId: REPLACEMENT })} />) + const confirm = screen.getByRole('button', { name: zh['disable.confirm'] }) + expect(confirm.hasAttribute('disabled')).toBe(false) + expect(screen.getByRole('button', { name: zh['disable.cancel'] }).hasAttribute('disabled')).toBe(false) + expect(screen.queryByRole('alert')).toBeNull() + fireEvent.click(confirm) + expect(input.disableTeam.mock.calls).toEqual([[ORIGINAL], [REPLACEMENT]]) + await act(async () => { + if (outcome === 'error') original.reject(new Error('old failure')) + else original.resolve(outcome === 'success' ? null : 'old failure') + }) + expect(screen.getByRole('dialog', { name: zh['disable.title'] })).toBeTruthy() + expect(screen.queryByRole('alert')).toBeNull() + expect(confirm.hasAttribute('disabled')).toBe(true) + if (outcome === 'success') expect(input.cancelDisable).toHaveBeenCalledExactlyOnceWith(ORIGINAL) + else expect(input.cancelDisable).not.toHaveBeenCalled() + fireEvent.click(confirm) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(input.disableTeam).toHaveBeenCalledTimes(2) + expect(input.cancelDisable).not.toHaveBeenCalledWith(REPLACEMENT) + await act(async () => { replacement.resolve(null) }) + expect(input.cancelDisable.mock.calls).toEqual(outcome === 'success' ? [[ORIGINAL], [REPLACEMENT]] : [[REPLACEMENT]]) +}) diff --git a/packages/client/ui-agent-team/tests/team-mode.client.spec.tsx b/packages/client/ui-agent-team/tests/team-mode.client.spec.tsx new file mode 100644 index 0000000000..13a1da8450 --- /dev/null +++ b/packages/client/ui-agent-team/tests/team-mode.client.spec.tsx @@ -0,0 +1,131 @@ +// @vitest-environment jsdom + +import { afterEach, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { SessionId } from '@deepseek-ai/dsh-session' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { TeamModeToggle, type TeamModeToggleProps } from '../src/client/TeamModeToggle.tsx' +import { de, en, es, fr, ja, pt, ru, zh } from '../src/client/locales.ts' + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +function props(enabled: boolean | undefined, selectTeamMode = vi.fn(async () => null as string | null), child?: 'addressed' | 'listed', requestedModel: boolean | 'loading' = false): TeamModeToggleProps { + return { + sessionId: SessionId('root'), + useSession: (select: (value: { subagent: object | null }) => unknown) => select({ subagent: child === 'addressed' ? {} : null }), + useSessions: (select: (value: { byId: object }) => unknown) => select({ byId: child === 'listed' ? { root: { origin: 'subagent' } } : {} }), + useProjection: (key: string) => key === 'modelSelection' + ? requestedModel === 'loading' ? undefined : { lastUsed: requestedModel ? { provider: 'mock', model: 'mock' } : null, next: { provider: 'mock', model: 'mock' } } + : enabled === undefined ? undefined : { enabled, tasks: [] }, + locked: false, dismiss: vi.fn(), reportFailure: vi.fn(), selectTeamMode, requestDisableConfirmation: vi.fn(), + t: makeTranslate(zh), + } as TeamModeToggleProps +} + +it('renders only available mode and waits for persisted state before displaying a new selection', async () => { + let finish!: (result: string | null) => void + const select = vi.fn(() => new Promise((resolve) => { finish = resolve })) + const view = render() + expect(screen.queryByRole('switch')).toBeNull() + view.rerender() + const toggle = screen.getByRole('switch', { name: '启用智能体团队协作' }) + fireEvent.click(toggle) + expect(select).toHaveBeenCalledWith(true) + expect(toggle.getAttribute('aria-checked')).toBe('false') + expect(toggle.hasAttribute('disabled')).toBe(true) + await act(async () => { finish(null) }) + view.rerender() + expect(toggle.getAttribute('aria-checked')).toBe('true') +}) + +it.each(['returned', 'error', 'string'])('reports rejected mode writes through the menu host (%s)', async (failure) => { + const p = props(false, vi.fn(async () => { + if (failure === 'error') throw new Error('offline') + if (failure === 'string') throw 'offline' + return 'offline' + })) + render() + await act(async () => { fireEvent.click(screen.getByRole('switch')) }) + expect(p.reportFailure).toHaveBeenCalledWith(expect.stringContaining('offline')) + expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('false') +}) + +it.each(Object.entries({ zh, en, ja, es, fr, de, ru, pt }).flatMap(([locale, dictionary]) => + [false, true].map(locked => ({ locale, dictionary, locked })), +))('opens the $locale explanation without changing Team mode (locked: $locked)', ({ dictionary, locked }) => { + vi.stubGlobal('ResizeObserver', class { + observe() {} + unobserve() {} + disconnect() {} + }) + const input = props(false) + render() + const info = screen.getByRole('button', { name: dictionary['mode.infoLabel'] }) + expect(info.hasAttribute('disabled')).toBe(false) + fireEvent.click(info) + const tooltip = screen.getByRole('tooltip', { hidden: true }) + expect(tooltip.textContent).toBe(dictionary['mode.description']) + expect(info.getAttribute('aria-describedby')).toBe(tooltip.id) + expect(document.body.contains(tooltip)).toBe(true) + expect(input.selectTeamMode).not.toHaveBeenCalled() + expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('false') + fireEvent.keyDown(info, { key: 'Escape' }) + expect(screen.queryByRole('tooltip', { hidden: true })).toBeNull() + fireEvent.click(info) + fireEvent.pointerDown(document.body) + expect(screen.queryByRole('tooltip', { hidden: true })).toBeNull() + expect(input.selectTeamMode).not.toHaveBeenCalled() +}) + +it('requests confirmation before disabling after a recorded model request', () => { + const input = props(true, undefined, undefined, true) + render() + fireEvent.click(screen.getByRole('switch')) + expect(input.dismiss).toHaveBeenCalledOnce() + expect(input.dismiss).toHaveBeenCalledBefore(vi.mocked(input.requestDisableConfirmation)) + expect(input.requestDisableConfirmation).toHaveBeenCalledOnce() + expect(input.selectTeamMode).not.toHaveBeenCalled() + expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('true') +}) + +it.each([false, true])('enables directly regardless of model history (requested: %s)', async (requested) => { + const input = props(false, undefined, undefined, requested) + render() + await act(async () => { fireEvent.click(screen.getByRole('switch')) }) + expect(input.selectTeamMode).toHaveBeenCalledWith(true) + expect(input.requestDisableConfirmation).not.toHaveBeenCalled() +}) + +it('disables directly when a model is selected but no request has been recorded', async () => { + const input = props(true) + render() + await act(async () => { fireEvent.click(screen.getByRole('switch')) }) + expect(input.selectTeamMode).toHaveBeenCalledWith(false) + expect(input.requestDisableConfirmation).not.toHaveBeenCalled() +}) + +it('waits for model history before allowing the switch to change', () => { + const input = props(true, undefined, undefined, 'loading') + render() + expect(screen.getByRole('switch').hasAttribute('disabled')).toBe(true) + fireEvent.click(screen.getByRole('switch')) + expect(input.selectTeamMode).not.toHaveBeenCalled() + expect(input.requestDisableConfirmation).not.toHaveBeenCalled() +}) + +it('disables selection while the Session is locked', () => { + render() + expect(screen.getByRole('switch').hasAttribute('disabled')).toBe(true) +}) + +it.each(['addressed', 'listed'] as const)('hides the switch on a child Session (%s)', (source) => { + const input = props(false, undefined, source) + const useProjection = vi.fn((): never => { throw new Error('Child must not read Team mode') }) + render() + expect(screen.queryByRole('switch')).toBeNull() + expect(useProjection).not.toHaveBeenCalled() + expect(input.selectTeamMode).not.toHaveBeenCalled() +}) diff --git a/packages/client/ui-agent-team/tsconfig.json b/packages/client/ui-agent-team/tsconfig.json new file mode 100644 index 0000000000..429b9adac3 --- /dev/null +++ b/packages/client/ui-agent-team/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../team/agent-team" }, + { "path": "../../api/session-controller/tsconfig.client.json" }, + { "path": "../../api/remotes/tsconfig.client.json" }, + { "path": "../ui-model-selection" }, + { "path": "../ui-layout" }, + { "path": "../../core/agent" }, + { "path": "../ui-subagent" }, + { "path": "../locale/tsconfig.client.json" }, + { "path": "../store" }, + { "path": "../ui-conversation/tsconfig.client.json" }, + { "path": "../ui-chat/tsconfig.client.json" }, + { "path": "../../util/values" }, + { "path": "../ui-primitives" }, + { "path": "../ui-renderer" }, + { "path": "../ui-slots" }, + { "path": "../../core/session" } + ] +} diff --git a/packages/experimental/client-ui-agent-team/tsdown.config.ts b/packages/client/ui-agent-team/tsdown.config.ts similarity index 54% rename from packages/experimental/client-ui-agent-team/tsdown.config.ts rename to packages/client/ui-agent-team/tsdown.config.ts index 71d5bd77f8..7aa03cebc1 100644 --- a/packages/experimental/client-ui-agent-team/tsdown.config.ts +++ b/packages/client/ui-agent-team/tsdown.config.ts @@ -1,3 +1,3 @@ -import { clientBundle } from '../../client/tsdown.client.ts' +import { clientBundle } from '../tsdown.client.ts' export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 0966845bde..54168bf257 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -18,11 +18,11 @@ en: 9386791a9e5efa00 zh: 7cb9f3bbc42319d6 /deepseek-ai-dsh-client-ui-chat/hidden-chat-rows: - en: b0c4e6711d69d2f8 - zh: 09add0ea1a243922 + en: 91b7c7a5263eda35 + zh: 16d39b45edee038f /deepseek-ai-dsh-client-ui-chat/command-and-failure-rows: - en: be90c24392fb021b - zh: 992b8745b385f9ab + en: c5b23d0ed29a0425 + zh: 689bab2c8253294a /deepseek-ai-dsh-client-ui-chat/turn-token-usage: en: cfdfe3d99d3f4e09 zh: 9330bad3d3f7a689 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index be43482e8a..b23b9dc3e1 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. Context containing tool additions or removals remains visible. The filter changes neither recorded Session events nor Trajectory inspection. Non-human Turn triggers remain independent, bordered notices with a contrasting neutral background; other command rows remain in Chat. +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. 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. @@ -63,6 +63,8 @@ Text and reasoning contribute to Assistant visibility only when they contain a n ## Command and failure rows +Subagent result notices use the Users icon and show only the localized execution outcome in their heading, without a task description or child ID. Completed means that execution ended normally, not that its output passed validation. Historical notices without a readable outcome retain the generic title; unfamiliar outcomes display “Subagent run ended unexpectedly”. Expanded notices preserve their original model-facing body. + Generic command rows retain the ordinary command glyph in every lifecycle state; failure remains explicit through the row state and summary. Every terminal Turn failure renders its inline red-dot row; a quota failure's row states the neutral `message.failure.quota` copy instead of the provider message. The transient notice for a newly appended `QUOTA` or `ACCOUNT_QUOTA` comes from this package's frame-wide entry in `shell.overlay`, which outlives the Chat panel: it offers the one live notice to the `shell.quota-notice` chain and falls back to its own warning Toast, while an entry that claims the code replaces that fallback. Only Sessions this Client has bound and materialized publish; quota failures in Sessions it never opened do not. A newer notice replaces the current one unless a claiming entry retains it with `keepOpen()`: that call returns a release the caller owns and must run on unmount, any live hold keeps the claiming entry mounted and drops later notices, and releasing resumes later notices without replaying the dropped ones. The fallback Toast has no deferral of its own: while the Desktop account's opaque native Platform page covers the document, it still runs underneath and its display timer may elapse unseen, dismissing the notice itself, so only the persistent failure row remains. A release drops only its own hold, so one that runs after a dismissal or a newer hold leaves that newer hold intact. Dismissal and sign-out clear every hold, and dropped notices are not queued while their persistent failure rows still render. History replacement and pagination never publish a notice. Intermediate retries do not create a terminal row; output-token limits use the amber warning dot. A Turn that restart recovery closed as `interrupted` renders a localized stopped row stating that the DSH process exited mid-Turn, without a code chip, and its process summary reads Stopped. Chat adapts Markdown targets at `MarkdownDelegateProvider.getTriggerProps`. The assistant-node Slot obtains `useContextMenu` directly and passes a file-path adapter to the optional mention service; intermediate flow and node owners carry no menu callbacks. Paths resolve against the originating Session’s current working directory when the gesture begins. Menus retain that captured path and line even if focus changes. Undeclared plugin links keep native behavior. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 9c0314fbd8..e5f8add05b 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` 命令行。包含工具添加或移除记录的上下文仍然可见。该过滤不改变已记录的 Session 事件或 Trajectory 查看能力。非人工轮次触发仍作为独立通知显示,其他命令行仍保留在 Chat 中。 +Chat 在所有工作步骤展示模式下都不显示系统提示词行、普通上下文注入和 `permission` 命令行。记录中的命令名和去除首尾空白后的参数确定为 `/team on` 或 `/team off` 时,也隐藏执行中与成功回执;失败以及参数缺失或无法识别的命令仍然可见。包含工具添加或移除记录的上下文仍然可见。该过滤不改变已记录的 Session 事件或 Trajectory 查看能力,隐藏回执也不计入行间距和过程摘要。非人工轮次触发仍作为独立通知显示,其他命令行仍保留在 Chat 中。 Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node,不移除其 key。同一 Step 的重试再次产生可见内容时,复用该 key。已加载窗口缺少 Step 起点时也遵循此规则。 @@ -63,6 +63,8 @@ Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node, ## 指令与失败行 +子智能体结果通知使用 Users 图标,标题仅显示本地化的执行状态,不附加任务描述或子智能体 ID。完成表示执行正常结束,不表示输出已通过验证。历史通知没有可读取的结束状态时保留通用标题;未知状态显示“子智能体异常结束”。展开后的通知保留原始模型可见正文。 + 通用指令行在所有生命周期状态中都保留普通指令图标;失败仍通过行状态与摘要表达。每个终止轮次都渲染自己的红点行;欠费失败的行使用中立的 `message.failure.quota` 文案,而不是提供方消息。新追加的 `QUOTA` 或 `ACCOUNT_QUOTA` 产生的瞬时提示来自本包注册在 `shell.overlay` 的全局条目,它不随 Chat 面板消失:该条目把唯一一条实时提示交给 `shell.quota-notice` 链,并在无人接管时回退到自己的警告 Toast,接管该错误码的条目会替换该回退。只有本 Client 已绑定并已物化的 Session 会发布提示;它从未打开的 Session 中的欠费不会发布。更新的提示会替换当前提示,除非认领条目用 `keepOpen()` 保持住当前提示:该调用返回一个由调用方持有、必须在卸载时调用的释放函数,只要存在保持,认领条目就继续挂载,后续提示被丢弃,释放后恢复后续提示但不重放已丢弃的提示。回退 Toast 自身没有延后策略:当 Desktop 账号的不透明原生 Platform 页盖住本页时,它仍会在下方运行,其显示计时器可能在不可见时走完并自行撤下提示,因此只留下持久化失败行。释放只移除自己的保持,因此它在关闭或更新的保持之后执行时不会影响更新的保持。关闭与退出登录会清空所有保持;被丢弃的提示不排队,其对应的持久化失败行仍会渲染。历史替换和分页不会发布提示。中间重试不会创建终止行;输出 token 上限使用琥珀色警告点。重启恢复以 `interrupted` 结束的轮次会渲染本地化的停止行,说明 DSH 进程在轮次中途退出,不显示代码标签,其过程摘要显示“已停止”。 Chat 在 `MarkdownDelegateProvider.getTriggerProps` 接入处适配 Markdown 目标。助手节点 Slot 直接获取 `useContextMenu`,向可选的文件引用服务传入路径适配函数;消息流和节点中间层不再携带菜单回调。手势发生时按原始 Session 的当前工作目录解析路径,菜单保留捕获的路径和行号,不受焦点变化影响。未声明的插件链接保留原生行为。 diff --git a/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.module.css b/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.module.css index 4a860b611f..4bf309274c 100644 --- a/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.module.css +++ b/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.module.css @@ -26,7 +26,7 @@ .header:hover { color: var(--dsw-alias-label-secondary); } } .icon { display: inline-flex; flex: none; color: inherit; } -.title { flex: none; font-size: var(--dsh-content-font-size, 14px); } +.title { flex: 1; min-width: 0; overflow-wrap: anywhere; font-size: var(--dsh-content-font-size, 14px); } .time { flex: none; margin-left: auto; color: inherit; font: var(--dsw-font-xxs-12); } .chevron, .openChevron { flex: none; color: inherit; } .openChevron { transform: rotate(180deg); } diff --git a/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.tsx index afa2106ca9..e6e366666a 100644 --- a/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.tsx @@ -4,7 +4,7 @@ import { IconAgentPresetOutlineRegular, IconClockOutlineRegular, IconBranchOutlineRegular, IconChevronDownOutlineRegular, IconContextInjectionOutlineRegular, IconCordisPluginOutlineRegular, IconGoalOutlineRegular, IconGlobeOutlineRegular, IconPaperPlaneOutlineRegular, IconQueueOutlineRegular, - type IconProps, + IconUsersOutlineRegular, type IconProps, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeViewProps } from '../contract/slots.ts' import { formatMessageClock } from './message-chrome.ts' @@ -17,7 +17,7 @@ const TRIGGER_ICONS: Record> = { goal: IconGoalOutlineRegular, agent: IconPaperPlaneOutlineRegular, team: IconAgentPresetOutlineRegular, - subagent: IconAgentPresetOutlineRegular, + subagent: IconUsersOutlineRegular, github: IconBranchOutlineRegular, webhook: IconGlobeOutlineRegular, schedule: IconClockOutlineRegular, diff --git a/packages/client/ui-chat/src/client/chat/turn-trigger.ts b/packages/client/ui-chat/src/client/chat/turn-trigger.ts index d4912668ea..ade8093490 100644 --- a/packages/client/ui-chat/src/client/chat/turn-trigger.ts +++ b/packages/client/ui-chat/src/client/chat/turn-trigger.ts @@ -13,6 +13,20 @@ function field(source: Record, key: string): string { return typeof source[key] === 'string' ? source[key] : '' } +/** Missing or malformed historical reasons stay generic; unfamiliar reasons never imply success. */ +function subagentTitle(reason: unknown): ChatKey { + if (typeof reason !== 'string' || reason === '') return 'message.trigger.subagent' + switch (reason) { + case 'completed': return 'message.trigger.subagent.completed' + case 'aborted': return 'message.trigger.subagent.aborted' + case 'error': return 'message.trigger.subagent.error' + case 'max-tokens': return 'message.trigger.subagent.maxTokens' + case 'refusal': return 'message.trigger.subagent.refusal' + // Backends may extend the recorded reason vocabulary. + default: return 'message.trigger.subagent.unknown' + } +} + /** * Describe a waking message using its recorded source. * @param node - durable context, including the original notification body. @@ -40,11 +54,10 @@ export function turnTriggerDetails(node: ContextMessageNode): { title = 'message.trigger.team' icon = 'team' break - case 'subagent-settled': { - title = 'message.trigger.subagent' + case 'subagent-settled': + title = subagentTitle(source.stopReason) icon = 'subagent' break - } case 'webhook': { const github = field(source, 'provider') === 'github' title = github ? 'message.trigger.github' : 'message.trigger.webhook' diff --git a/packages/client/ui-chat/src/client/contract/chat-visibility.ts b/packages/client/ui-chat/src/client/contract/chat-visibility.ts index 2fd60b4b1d..8f1c6f89b5 100644 --- a/packages/client/ui-chat/src/client/contract/chat-visibility.ts +++ b/packages/client/ui-chat/src/client/contract/chat-visibility.ts @@ -2,15 +2,21 @@ import type { ChatNode } from './chat-nodes.ts' /** - * Exclude system prompts, ordinary Context, and permission commands from visible Chat rows. - * Context containing tool changes retains its notice row. + * Exclude system prompts, ordinary Context, permission commands, and normal Team toggle receipts. + * Failed Team toggles and Context containing tool changes remain visible. * @param node - projected Chat node. * @returns whether the node contributes a visible Chat row. */ export function isVisibleChatNode(node: ChatNode): boolean { + if (node.kind === 'command') { + if (node.data.name === 'permission') return false + if (node.data.name === 'team' && node.data.outcome?.kind !== 'error') { + const selection = node.data.args?.trim() + if (selection === 'on' || selection === 'off') return false + } + } return node.visibility === 'visible' && node.kind !== 'system-prompt' && (node.kind !== 'context' || node.data.content.some(block => block.type === 'tool-addition' || block.type === 'tool-removal')) - && !(node.kind === 'command' && node.data.name === 'permission') } diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 6f0e6eda52..221ab256e8 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -55,6 +55,12 @@ export const zh = { 'message.trigger.agent': '收到任务消息', 'message.trigger.team': '收到团队消息', 'message.trigger.subagent': '子任务状态更新', + 'message.trigger.subagent.completed': '子智能体已完成任务', + 'message.trigger.subagent.aborted': '子智能体任务已停止', + 'message.trigger.subagent.error': '子智能体执行失败', + 'message.trigger.subagent.maxTokens': '子智能体已达 token 上限', + 'message.trigger.subagent.refusal': '子智能体拒绝执行', + 'message.trigger.subagent.unknown': '子智能体异常结束', 'message.trigger.github': '收到 GitHub 事件', 'message.trigger.webhook': '收到外部事件', 'message.trigger.schedule': '自动化任务', @@ -257,6 +263,12 @@ export const en = { 'message.trigger.agent': 'Task message received', 'message.trigger.team': 'Team message received', 'message.trigger.subagent': 'Subtask status updated', + 'message.trigger.subagent.completed': 'Subagent run completed', + 'message.trigger.subagent.aborted': 'Subagent run stopped', + 'message.trigger.subagent.error': 'Subagent run failed', + 'message.trigger.subagent.maxTokens': 'Subagent reached the token limit', + 'message.trigger.subagent.refusal': 'Subagent declined the task', + 'message.trigger.subagent.unknown': 'Subagent run ended unexpectedly', 'message.trigger.github': 'GitHub event received', 'message.trigger.webhook': 'External event received', 'message.trigger.schedule': 'Automation task', @@ -456,6 +468,12 @@ export const ja = { 'message.trigger.agent': 'タスクメッセージを受信しました', 'message.trigger.team': 'チームメッセージを受信しました', 'message.trigger.subagent': 'サブタスクの状態が更新されました', + 'message.trigger.subagent.completed': 'サブエージェントの実行が完了しました', + 'message.trigger.subagent.aborted': 'サブエージェントの実行が停止しました', + 'message.trigger.subagent.error': 'サブエージェントの実行が失敗しました', + 'message.trigger.subagent.maxTokens': 'サブエージェントがトークンの上限に達しました', + 'message.trigger.subagent.refusal': 'サブエージェントがタスクを拒否しました', + 'message.trigger.subagent.unknown': 'サブエージェントの実行が予期せず終了しました', 'message.trigger.github': 'GitHubイベントを受信しました', 'message.trigger.webhook': '外部イベントを受信しました', 'message.trigger.schedule': '自動化タスク', @@ -655,6 +673,12 @@ export const es = { 'message.trigger.agent': 'Mensaje de tarea recibido', 'message.trigger.team': 'Mensaje del equipo recibido', 'message.trigger.subagent': 'Estado de subtarea actualizado', + 'message.trigger.subagent.completed': 'Ejecución del subagente completada', + 'message.trigger.subagent.aborted': 'Ejecución del subagente detenida', + 'message.trigger.subagent.error': 'La ejecución del subagente falló', + 'message.trigger.subagent.maxTokens': 'El subagente alcanzó el límite de tokens', + 'message.trigger.subagent.refusal': 'El subagente rechazó la tarea', + 'message.trigger.subagent.unknown': 'La ejecución del subagente terminó de forma inesperada', 'message.trigger.github': 'Evento GitHub recibido', 'message.trigger.webhook': 'Evento externo recibido', 'message.trigger.schedule': 'Tarea de automatización', @@ -854,6 +878,12 @@ export const fr = { 'message.trigger.agent': 'Message de tâche reçu', 'message.trigger.team': "Message d'équipe reçu", 'message.trigger.subagent': 'Statut de la sous-tâche mis à jour', + 'message.trigger.subagent.completed': 'Exécution du sous-agent terminée', + 'message.trigger.subagent.aborted': 'Exécution du sous-agent arrêtée', + 'message.trigger.subagent.error': 'Échec de l’exécution du sous-agent', + 'message.trigger.subagent.maxTokens': 'Le sous-agent a atteint la limite de jetons', + 'message.trigger.subagent.refusal': 'Le sous-agent a refusé la tâche', + 'message.trigger.subagent.unknown': 'L’exécution du sous-agent s’est terminée de façon inattendue', 'message.trigger.github': 'Événement GitHub reçu', 'message.trigger.webhook': 'Événement externe reçu', 'message.trigger.schedule': "Tâche d'automatisation", @@ -1053,6 +1083,12 @@ export const de = { 'message.trigger.agent': 'Aufgabennachricht erhalten', 'message.trigger.team': 'Teamnachricht erhalten', 'message.trigger.subagent': 'Unteraufgabenstatus aktualisiert', + 'message.trigger.subagent.completed': 'Ausführung des Subagenten abgeschlossen', + 'message.trigger.subagent.aborted': 'Ausführung des Subagenten gestoppt', + 'message.trigger.subagent.error': 'Ausführung des Subagenten fehlgeschlagen', + 'message.trigger.subagent.maxTokens': 'Subagent hat das Token-Limit erreicht', + 'message.trigger.subagent.refusal': 'Subagent hat die Aufgabe abgelehnt', + 'message.trigger.subagent.unknown': 'Ausführung des Subagenten unerwartet beendet', 'message.trigger.github': 'GitHub-Ereignis empfangen', 'message.trigger.webhook': 'Externes Ereignis empfangen', 'message.trigger.schedule': 'Automatisierungsaufgabe', @@ -1252,6 +1288,12 @@ export const ru = { 'message.trigger.agent': 'Сообщение о задаче получено', 'message.trigger.team': 'Сообщение команды получено', 'message.trigger.subagent': 'Статус подзадачи обновлен', + 'message.trigger.subagent.completed': 'Работа субагента завершена', + 'message.trigger.subagent.aborted': 'Работа субагента остановлена', + 'message.trigger.subagent.error': 'Работа субагента завершилась с ошибкой', + 'message.trigger.subagent.maxTokens': 'Субагент достиг лимита токенов', + 'message.trigger.subagent.refusal': 'Субагент отказался выполнять задачу', + 'message.trigger.subagent.unknown': 'Работа субагента неожиданно завершилась', 'message.trigger.github': 'Событие GitHub получено', 'message.trigger.webhook': 'Получено внешнее событие', 'message.trigger.schedule': 'Задача автоматизации', @@ -1451,6 +1493,12 @@ export const pt = { 'message.trigger.agent': 'Mensagem de tarefa recebida', 'message.trigger.team': 'Mensagem da equipe recebida', 'message.trigger.subagent': 'Status da subtarefa atualizado', + 'message.trigger.subagent.completed': 'Execução do subagente concluída', + 'message.trigger.subagent.aborted': 'Execução do subagente interrompida', + 'message.trigger.subagent.error': 'Falha na execução do subagente', + 'message.trigger.subagent.maxTokens': 'O subagente atingiu o limite de tokens', + 'message.trigger.subagent.refusal': 'O subagente recusou a tarefa', + 'message.trigger.subagent.unknown': 'A execução do subagente terminou inesperadamente', 'message.trigger.github': 'GitHub evento recebido', 'message.trigger.webhook': 'Evento externo recebido', 'message.trigger.schedule': 'Tarefa de automação', diff --git a/packages/client/ui-chat/tests/chat-visibility.client.spec.ts b/packages/client/ui-chat/tests/chat-visibility.client.spec.ts new file mode 100644 index 0000000000..70b2d38fef --- /dev/null +++ b/packages/client/ui-chat/tests/chat-visibility.client.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { CommandId } from '@deepseek-ai/dsh-commands/brand' +import type { ChatNode } from '../src/client/contract/chat-nodes.ts' +import { isVisibleChatNode } from '../src/client/contract/chat-visibility.ts' + +const placement = { + key: 'command:test', id: 'test', target: 'chat', anchorSeq: 1, + location: { kind: 'unresolved' }, visibility: 'visible', +} as const + +function command(args: string | null, outcome: ChatNode<'command'>['data']['outcome'], name: string | null = 'team'): ChatNode<'command'> { + return { + ...placement, kind: 'command', + data: { kind: 'command', seq: 1, time: 0, commandId: CommandId('test'), name, args, outcome }, + } +} + +describe('Chat-only command visibility', () => { + it.each(['on', 'off', ' on ', '\toff\n'])('omits pending and successful Team selection %j without rewriting the record', (args) => { + for (const outcome of [null, { kind: 'success', text: 'Unchanged recorded output' }] as const) { + const node = command(args, outcome) + const before = JSON.stringify(node) + expect(isVisibleChatNode(node)).toBe(false) + expect(JSON.stringify(node)).toBe(before) + } + }) + + it.each(['on', 'off', null, 'invalid'])('keeps a failed Team command visible for input %j', (args) => { + expect(isVisibleChatNode(command(args, { kind: 'error', text: 'The operation failed' }))).toBe(true) + }) + + it.each(['ON', 'status', '', null])('does not infer a toggle from result text for input %j', (args) => { + expect(isVisibleChatNode(command(args, { kind: 'success', text: 'Agent Teams enabled.' }))).toBe(true) + expect(isVisibleChatNode(command(args, null))).toBe(true) + }) + + it.each(['other', null])('retains unrelated and window-cut commands named %j', (name) => { + expect(isVisibleChatNode(command('on', { kind: 'success', text: 'done' }, name))).toBe(true) + }) + + it('retains a failed toggle after its hidden pending record settles', () => { + const pending = command('on', null) + const failed = { ...pending, data: { ...pending.data, outcome: { kind: 'error' as const, text: 'Save failed' } } } + expect(isVisibleChatNode(pending)).toBe(false) + expect(isVisibleChatNode(failed)).toBe(true) + }) + + it('keeps the existing permission and explicit hidden-node policies', () => { + expect(isVisibleChatNode(command('on', { kind: 'error', text: 'failure' }, 'permission'))).toBe(false) + expect(isVisibleChatNode({ ...command('on', { kind: 'error', text: 'failure' }), visibility: 'hidden' })).toBe(false) + }) +}) + +it('keeps ordinary prompt/context hidden but retains tool changes', () => { + const prompt: ChatNode<'system-prompt'> = { ...placement, kind: 'system-prompt', data: { text: 'system instructions' } } + const context: ChatNode<'context'> = { + ...placement, kind: 'context', + data: { + kind: 'context', seq: 1, time: 0, content: [{ type: 'text', text: 'context' }], source: null, + producer: { role: 'inject', label: null }, form: null, + }, + } + expect(isVisibleChatNode(prompt)).toBe(false) + expect(isVisibleChatNode(context)).toBe(false) + for (const type of ['tool-addition', 'tool-removal'] as const) { + expect(isVisibleChatNode({ ...context, data: { ...context.data, content: [{ type, toolName: 'example' }] } })).toBe(true) + } +}) diff --git a/packages/client/ui-chat/tests/turn-trigger.client.spec.tsx b/packages/client/ui-chat/tests/turn-trigger.client.spec.tsx index c45672dac5..bbb2999cc7 100644 --- a/packages/client/ui-chat/tests/turn-trigger.client.spec.tsx +++ b/packages/client/ui-chat/tests/turn-trigger.client.spec.tsx @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' -import { IconClockOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconClockOutlineRegular, IconUsersOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNode } from '../src/client/contract/chat-nodes.ts' import { TurnTriggerNodeView } from '../src/client/chat/TurnTriggerNodeView.tsx' import { turnTriggerDetails } from '../src/client/chat/turn-trigger.ts' @@ -72,6 +72,37 @@ describe('Turn trigger notices', () => { expect(node.data.source).toEqual({ kind: 'schedule' }) }) + it.each([ + ['completed', 'Subagent run completed', '子智能体已完成任务'], + ['aborted', 'Subagent run stopped', '子智能体任务已停止'], + ['error', 'Subagent run failed', '子智能体执行失败'], + ['max-tokens', 'Subagent reached the token limit', '子智能体已达 token 上限'], + ['refusal', 'Subagent declined the task', '子智能体拒绝执行'], + ['backend-specific', 'Subagent run ended unexpectedly', '子智能体异常结束'], + ])('presents only the recorded %s outcome in both locales', (stopReason, english, chinese) => { + const source = { kind: 'subagent-settled', stopReason, senderSessionId: 'child-1' } + const node = trigger(source) + const view = render() + expect(view.getByRole('button').textContent).toContain(english) + expect(view.getByRole('button').textContent).not.toContain(' · ') + const reference = render() + expect(view.getByRole('button').querySelector('svg')?.outerHTML) + .toBe(reference.container.querySelector('svg')?.outerHTML) + view.rerender() + expect(view.getByRole('button').textContent).toContain(chinese) + expect(view.getByRole('button').textContent).not.toContain(' · ') + fireEvent.click(view.getByRole('button')) + expect(view.container.querySelector('[data-context-text]')?.textContent) + .toBe('Recorded notification\nwith its original body.') + expect(node.data.source).toEqual(source) + }) + + it.each([undefined, null, false, 12, {}, '', []])('does not infer an absent or malformed outcome %j from English text', (stopReason) => { + const node = trigger({ kind: 'subagent-settled', stopReason, summary: 'Background subagent child-1 finished.' }) + node.data.content = [{ type: 'text', text: 'Background subagent child-1 finished.' }] + expect(turnTriggerDetails(node.data)).toEqual({ title: 'message.trigger.subagent', icon: 'subagent' }) + }) + it('opens the recorded notice body and closes it independently of the Turn', () => { const view = render() const button = view.getByRole('button') diff --git a/packages/client/ui-commands/src/client/presentation.ts b/packages/client/ui-commands/src/client/presentation.ts index f5abacd2a4..95204d468e 100644 --- a/packages/client/ui-commands/src/client/presentation.ts +++ b/packages/client/ui-commands/src/client/presentation.ts @@ -2,7 +2,8 @@ import type { ComponentType } from 'react' import type { InputTriggerCandidate } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { - IconCompactOutlineRegular, IconDownloadOutlineRegular, IconGoalOutlineRegular, IconPaperPlaneOutlineRegular, IconPlanOutlineRegular, + IconCompactOutlineRegular, IconDownloadOutlineRegular, + IconGoalOutlineRegular, IconPaperPlaneOutlineRegular, IconPlanOutlineRegular, PermissionIconFullAccessRegular, } from '@deepseek-ai/dsh-client-ui-primitives' import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives' diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index f17358e4b5..6c078adbe3 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -21,8 +21,8 @@ en: cc4dafd1aae02468 zh: be6b67724b7ac4a3 /deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props: - en: 99ec447f0a6bda48 - zh: 922cf64666676530 + en: 3fa2fb91ad383c05 + zh: 1e1edd7f9a830377 /deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries: en: 59cd63057dfb6eeb zh: b2e2c86e810b7bb8 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 14f54dc287..cc60deae1c 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -124,7 +124,7 @@ A default send waits for the Host: Enter captures the draft text, its reference Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A refused or failed submission publishes no echo and leaves the draft in the composer with an error notice. A refusal from the Agent admission stage (`session/prompt-rejected`) shows its own text, unless `presented` says the refusing plugin explains it in its own interface, in which case the composer adds no notice. -Disabled Send and Stop buttons suppress their tooltips, including a Stop button that becomes a disabled Send button when the turn ends. While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Send; clearing or successfully submitting the draft restores Stop. While a message waits for the Host, the seat shows its progress, and a separate Stop stays beside it. The busy-Enter setting selects the Queue or Steer delivery for ordinary Sessions and continuable children, and the running Send button delivers through the same mode plain Enter resolves to; while it is enabled (no upload pending) over a plain message draft its label names that mode (Queue message or Steer message), so the setting governs Enter and the button together while Cmd/Ctrl+Enter still uses the other mode, and idle sessions, empty drafts, and `/` command lines keep the plain Send label. Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions and accept pasted or dropped images, but expose no File row; if their parent is offline, Send and the composer gestures lock while QueueDock controls for the live inbox remain available ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../subagent/subagent/README.md)). +Disabled Send and Stop buttons suppress their tooltips, including a Stop button that becomes a disabled Send button when the turn ends. While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Send; clearing or successfully submitting the draft restores Stop. While a message waits for the Host, the seat shows its progress, and a separate Stop stays beside it. The busy-Enter setting selects the Queue or Steer delivery for ordinary Sessions and continuable children, and the running Send button delivers through the same mode plain Enter resolves to; while it is enabled (no upload pending) over a plain message draft its label names that mode (Queue message or Steer message), so the setting governs Enter and the button together while Cmd/Ctrl+Enter still uses the other mode, and idle sessions, empty drafts, and `/` command lines keep the plain Send label. Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions and accept pasted or dropped images, but expose no File row; creator availability does not disable input, and Host admission failures use the ordinary prompt error feedback ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../subagent/subagent/README.md)). File chips and editable skill references share a whole-reference hover background and follow the composer's line height and text baseline. The first click delegates preview opening to the registered reference source immediately, including the first click of a double-click sequence. Subsequent clicks retain native text selection; an existing noncollapsed selection suppresses pointer preview activation. Previewing does not change the draft, its clipboard projection, or submission. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 473e15825e..75aa7b7fbe 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -124,7 +124,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到提示词确认不会启用队列操作。被拒绝或失败的提交不会公开回显,草稿留在 composer 中并显示错误提示。Agent 接纳阶段的拒绝(`session/prompt-rejected`)显示它自带的文字;若 `presented` 表明拒绝它的插件会在自己的界面里说明,composer 不再提示。 -Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切换成禁用 Send 的按钮也遵循此规则。普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。消息等待 Host 接收期间,该位置显示发送进度,旁边另有独立的 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,允许粘贴或拖入图片,但不提供「文件」菜单项;parent 离线时,Send 与 composer 手势锁定,但在线 inbox 的 QueueDock 控制仍可使用([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../subagent/subagent/README.zh.md))。 +Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切换成禁用 Send 的按钮也遵循此规则。普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。消息等待 Host 接收期间,该位置显示发送进度,旁边另有独立的 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,允许粘贴或拖入图片,但不提供「文件」菜单项;创建者的在线状态不禁用输入,Host 接收失败使用普通提示词错误反馈([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../subagent/subagent/README.zh.md))。 文件标签和可编辑的 skill 引用共用覆盖整个引用的悬停背景,并跟随输入框的行高与文字基线。首次点击立即由已注册的引用来源负责打开预览,包括双击序列的第一次点击。后续点击保留原生文本选择行为;已有非折叠选区时,指针点击不打开预览。预览不改变草稿、剪贴板文本或提交内容。 diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 02897a18c6..9ed94a567b 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -130,7 +130,7 @@ export class InputHub implements SessionInputResolver { quoteInsertionAllowed: () => { const state = session.getSnapshot() return !state.removed && (state.subagent === null - || (state.subagent.address.mode === 'continuable' && state.subagent.parentAvailable === true)) + || state.subagent.address.mode === 'continuable') && this.conversation().blocks.storeFor(session.sessionId).getSnapshot() === undefined }, steerQueue: () => { void this.steerQueue(session, shell) }, diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 2918c177a0..d89b2e91c4 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -20,7 +20,6 @@ export const zh = { 'placeholder.plan': PLAN_NEXT_ACTION_ZH, 'placeholder.default': '发消息或创建任务, / 调用指令, @ 文件或对话', 'placeholder.unavailable': '会话不可用', - 'placeholder.parentOffline': '父会话已离线,无法继续发送;仍可停止当前运行', 'placeholder.hero': '描述你想要构建的内容, / 调用指令, @ 文件或对话', 'placeholder.workspace': '选择一个工作区开始', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息', @@ -164,6 +163,7 @@ export const zh = { 'tool.title.traceSession': '追踪会话', 'tool.title.listModels': '查看可用模型', 'tool.title.subagent': '创建子智能体', + 'tool.title.subagentFork': '派生子智能体', 'tool.title.listAgents': '查看子智能体', 'tool.title.sendMessage': '发送消息', 'tool.title.interruptAgent': '中断智能体', @@ -185,7 +185,7 @@ export const zh = { 'tool.title.getTeamTask': '读取团队任务', 'tool.title.updateTeamTask': '更新团队任务', 'tool.title.listTeamTasks': '查看团队任务', - 'tool.title.waitAgent': '等待子智能体', + 'tool.title.waitAgent': '等待消息…', 'detail.recordedResult': '调用结果', 'detail.empty': '暂无结果', 'detail.none': '无', @@ -242,11 +242,12 @@ export const zh = { 'detail.field.agents': '启动智能体数', 'detail.field.result': '结果', 'detail.field.parent': '父级', - 'detail.field.depth': '层级', 'detail.field.exitCode': '退出码', 'detail.field.signal': '信号', 'detail.field.previousStatus': '中断前状态', 'detail.field.agent': '智能体 ID', + 'detail.field.agentName': '智能体名称', + 'detail.field.agentDescription': '智能体任务描述', 'detail.field.job': '任务 ID', 'detail.field.task': '任务内容', 'detail.field.processGroup': '进程组', @@ -270,9 +271,9 @@ export const zh = { 'detail.receipt.closed': '已关闭', 'detail.receipt.closing': '关闭中', 'detail.wait.noProgress': '没有正在运行的子智能体', - 'detail.wait.title': '子智能体状态', - 'detail.wait.timeout': '等待超时', - 'detail.wait.changed': '检测到变化', + 'detail.wait.title': '等待结果', + 'detail.wait.timeout': '等待已超时', + 'detail.wait.ended': '等待已结束', 'detail.agent.reply': '智能体回复', 'detail.models.title': '可用模型', 'detail.output.lines': '第 {begin}–{end} 行,共 {total} 行', @@ -410,7 +411,6 @@ export const en = { 'placeholder.plan': PLAN_NEXT_ACTION_EN, 'placeholder.default': 'Message or run a task, / commands, @ files or sessions', 'placeholder.unavailable': 'Session unavailable', - 'placeholder.parentOffline': 'Parent session offline; sending is unavailable but you can still stop the run', 'placeholder.hero': 'Describe what you want to build, / commands, @ files or sessions', 'placeholder.workspace': 'Choose a workspace to start', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages', @@ -554,6 +554,7 @@ export const en = { 'tool.title.traceSession': 'Trace session', 'tool.title.listModels': 'List models', 'tool.title.subagent': 'Create subagent', + 'tool.title.subagentFork': 'Fork subagent', 'tool.title.listAgents': 'List subagents', 'tool.title.sendMessage': 'Send message', 'tool.title.interruptAgent': 'Interrupt agent', @@ -575,7 +576,7 @@ export const en = { 'tool.title.getTeamTask': 'Read team task', 'tool.title.updateTeamTask': 'Update team task', 'tool.title.listTeamTasks': 'List team tasks', - 'tool.title.waitAgent': 'Wait for subagent', + 'tool.title.waitAgent': 'Waiting for messages…', 'detail.recordedResult': 'Recorded result', 'detail.empty': 'No results', 'detail.none': 'None', @@ -632,11 +633,12 @@ export const en = { 'detail.field.agents': 'Agents started', 'detail.field.result': 'Result', 'detail.field.parent': 'Parent', - 'detail.field.depth': 'Depth', 'detail.field.exitCode': 'Exit code', 'detail.field.signal': 'Signal', 'detail.field.previousStatus': 'Previous status', 'detail.field.agent': 'Agent ID', + 'detail.field.agentName': 'Agent name', + 'detail.field.agentDescription': 'Agent task description', 'detail.field.job': 'Job ID', 'detail.field.task': 'Task', 'detail.field.processGroup': 'Process group', @@ -660,9 +662,9 @@ export const en = { 'detail.receipt.closed': 'Closed', 'detail.receipt.closing': 'Closing', 'detail.wait.noProgress': 'No active subagents', - 'detail.wait.title': 'Subagent activity', + 'detail.wait.title': 'Wait result', 'detail.wait.timeout': 'Wait timed out', - 'detail.wait.changed': 'Change detected', + 'detail.wait.ended': 'Wait ended', 'detail.agent.reply': 'Agent response', 'detail.models.title': 'Available models', 'detail.output.lines': 'Lines {begin}–{end} of {total}', @@ -799,7 +801,6 @@ export const ja = { 'placeholder.plan': '計画を生成するにはタスクを説明してください', 'placeholder.default': 'メッセージを送信するかタスクを実行、/ コマンド、@ ファイルやセッション', 'placeholder.unavailable': 'セッションを利用できません', - 'placeholder.parentOffline': '親セッションがオフラインです。送信はできませんが、実行は停止できます', 'placeholder.hero': '作りたいものを説明してください。/ コマンド、@ ファイルやセッション', 'placeholder.workspace': '開始するワークスペースを選択', 'placeholder.steerQueue': 'Cmd/Ctrl+Enterでキュー内のすべてのメッセージを実行中の指示に追加', @@ -943,6 +944,7 @@ export const ja = { 'tool.title.traceSession': 'セッションをトレース', 'tool.title.listModels': 'モデルを一覧表示', 'tool.title.subagent': 'サブエージェントを作成', + 'tool.title.subagentFork': 'サブエージェントを作成(コンテキストを継承)', 'tool.title.listAgents': 'サブエージェントを一覧表示', 'tool.title.sendMessage': 'メッセージを送信', 'tool.title.interruptAgent': 'エージェントを中断', @@ -964,7 +966,7 @@ export const ja = { 'tool.title.getTeamTask': 'チームタスクを読み取り', 'tool.title.updateTeamTask': 'チームタスクを更新', 'tool.title.listTeamTasks': 'チームタスクを一覧表示', - 'tool.title.waitAgent': 'サブエージェントを待機', + 'tool.title.waitAgent': 'メッセージを待機中…', 'detail.recordedResult': '記録された結果', 'detail.empty': '結果なし', 'detail.none': 'なし', @@ -1021,11 +1023,12 @@ export const ja = { 'detail.field.agents': '起動したエージェント', 'detail.field.result': '結果', 'detail.field.parent': '親', - 'detail.field.depth': '深さ', 'detail.field.exitCode': '終了コード', 'detail.field.signal': 'シグナル', 'detail.field.previousStatus': '以前のステータス', 'detail.field.agent': 'エージェント ID', + 'detail.field.agentName': 'エージェント名', + 'detail.field.agentDescription': 'エージェントのタスク説明', 'detail.field.job': 'ジョブ ID', 'detail.field.task': 'タスク', 'detail.field.processGroup': 'プロセスグループ', @@ -1051,7 +1054,7 @@ export const ja = { 'detail.wait.noProgress': 'アクティブなサブエージェントなし', 'detail.wait.title': 'サブエージェントのアクティビティ', 'detail.wait.timeout': '待機がタイムアウトしました', - 'detail.wait.changed': '変更を検出', + 'detail.wait.ended': '待機が終了しました', 'detail.agent.reply': 'エージェントの応答', 'detail.models.title': '利用可能なモデル', 'detail.output.lines': '{total} 行中 {begin}–{end} 行', @@ -1186,7 +1189,6 @@ export const es = { 'placeholder.plan': PLAN_NEXT_ACTION_EN, 'placeholder.default': 'Envía mensajes o ejecuta una tarea, / comandos, @ archivos o sesiones', 'placeholder.unavailable': 'Sesión no disponible', - 'placeholder.parentOffline': 'Sesión para padres sin conexión; El envío no está disponible pero aún puedes detener la ejecución.', 'placeholder.hero': 'Describe lo que quieres construir, / comandos, @ archivos o sesiones', 'placeholder.workspace': 'Elija un espacio de trabajo para comenzar', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter dirige todos los mensajes en cola', @@ -1325,6 +1327,7 @@ export const es = { 'tool.title.traceSession': 'Sesión de seguimiento', 'tool.title.listModels': 'Listar modelos', 'tool.title.subagent': 'Crear subagente', + 'tool.title.subagentFork': 'Crear subagente (heredar contexto)', 'tool.title.listAgents': 'Listar subagentes', 'tool.title.sendMessage': 'Enviar mensaje', 'tool.title.interruptAgent': 'agente de interrupción', @@ -1346,7 +1349,7 @@ export const es = { 'tool.title.getTeamTask': 'Leer tarea de equipo', 'tool.title.updateTeamTask': 'Actualizar tarea de equipo', 'tool.title.listTeamTasks': 'Listar tareas del equipo', - 'tool.title.waitAgent': 'Espere al subagente', + 'tool.title.waitAgent': 'Esperando mensajes…', 'detail.recordedResult': 'Resultado registrado', 'detail.empty': 'Sin resultados', 'detail.none': 'Ninguno', @@ -1403,11 +1406,12 @@ export const es = { 'detail.field.agents': 'Los agentes comenzaron', 'detail.field.result': 'Resultado', 'detail.field.parent': 'Padre', - 'detail.field.depth': 'Profundidad', 'detail.field.exitCode': 'código de salida', 'detail.field.signal': 'señal', 'detail.field.previousStatus': 'Estado anterior', 'detail.field.agent': 'ID del agente', + 'detail.field.agentName': 'Nombre del agente', + 'detail.field.agentDescription': 'Descripción de la tarea del agente', 'detail.field.job': 'ID de trabajo', 'detail.field.task': 'Tarea', 'detail.field.processGroup': 'grupo de procesos', @@ -1433,7 +1437,7 @@ export const es = { 'detail.wait.noProgress': 'No hay subagentes activos', 'detail.wait.title': 'Actividad del subagente', 'detail.wait.timeout': 'Se agotó el tiempo de espera', - 'detail.wait.changed': 'Cambio detectado', + 'detail.wait.ended': 'Espera finalizada', 'detail.agent.reply': 'Respuesta del agente', 'detail.models.title': 'Modelos disponibles', 'detail.output.lines': 'Líneas {begin}–{end} de {total}', @@ -1575,7 +1579,6 @@ export const fr = { 'placeholder.plan': PLAN_NEXT_ACTION_EN, 'placeholder.default': 'Envoyez un message ou exécutez une tâche, des commandes /, des fichiers @ ou des sessions', 'placeholder.unavailable': 'Séance indisponible', - 'placeholder.parentOffline': "Session parent hors ligne ; l'envoi n'est pas disponible mais vous pouvez toujours arrêter l'exécution", 'placeholder.hero': 'Décrivez ce que vous souhaitez construire, / commandes, @ fichiers ou sessions', 'placeholder.workspace': 'Choisissez un espace de travail pour commencer', 'placeholder.steerQueue': "Cmd/Ctrl+Entrée dirige tous les messages en file d'attente", @@ -1714,6 +1717,7 @@ export const fr = { 'tool.title.traceSession': 'Session de traçage', 'tool.title.listModels': 'Liste des modèles', 'tool.title.subagent': 'Créer un sous-agent', + 'tool.title.subagentFork': 'Créer un sous-agent (hériter du contexte)', 'tool.title.listAgents': 'Liste des sous-agents', 'tool.title.sendMessage': 'Envoyer le message', 'tool.title.interruptAgent': "Agent d'interruption", @@ -1735,7 +1739,7 @@ export const fr = { 'tool.title.getTeamTask': "Lire la tâche d'équipe", 'tool.title.updateTeamTask': "Mettre à jour la tâche de l'équipe", 'tool.title.listTeamTasks': "Répertorier les tâches de l'équipe", - 'tool.title.waitAgent': 'Attendez le sous-agent', + 'tool.title.waitAgent': 'En attente de messages…', 'detail.recordedResult': 'Résultat enregistré', 'detail.empty': 'Aucun résultat', 'detail.none': 'Aucun', @@ -1792,11 +1796,12 @@ export const fr = { 'detail.field.agents': 'Les agents ont commencé', 'detail.field.result': 'Résultat', 'detail.field.parent': 'Parent', - 'detail.field.depth': 'Profondeur', 'detail.field.exitCode': 'Code de sortie', 'detail.field.signal': 'Signal', 'detail.field.previousStatus': 'Statut précédent', 'detail.field.agent': "ID d'agent", + 'detail.field.agentName': 'Nom de l’agent', + 'detail.field.agentDescription': 'Description de la tâche de l’agent', 'detail.field.job': 'ID de travail', 'detail.field.task': 'Tâche', 'detail.field.processGroup': 'Groupe de processus', @@ -1822,7 +1827,7 @@ export const fr = { 'detail.wait.noProgress': 'Aucun sous-agent actif', 'detail.wait.title': 'Activité du sous-agent', 'detail.wait.timeout': "Le délai d'attente a expiré", - 'detail.wait.changed': 'Changement détecté', + 'detail.wait.ended': 'Attente terminée', 'detail.agent.reply': "Réponse de l'agent", 'detail.models.title': 'Modèles disponibles', 'detail.output.lines': 'Lignes {begin}–{end} de {total}', @@ -1964,7 +1969,6 @@ export const de = { 'placeholder.plan': PLAN_NEXT_ACTION_EN, 'placeholder.default': 'Senden Sie eine Nachricht oder führen Sie eine Aufgabe, /-Befehle, @-Dateien oder Sitzungen aus', 'placeholder.unavailable': 'Sitzung nicht verfügbar', - 'placeholder.parentOffline': 'Elternsitzung offline; Das Senden ist nicht möglich, aber Sie können den Lauf trotzdem stoppen', 'placeholder.hero': 'Beschreiben Sie, was Sie erstellen möchten: /-Befehle, @-Dateien oder Sitzungen', 'placeholder.workspace': 'Wählen Sie einen Arbeitsbereich zum Starten aus', 'placeholder.steerQueue': 'Cmd/Strg+Enter steuert alle Nachrichten in der Warteschlange', @@ -2103,6 +2107,7 @@ export const de = { 'tool.title.traceSession': 'Trace-Sitzung', 'tool.title.listModels': 'Modelle auflisten', 'tool.title.subagent': 'Subagent erstellen', + 'tool.title.subagentFork': 'Subagent erstellen (Kontext übernehmen)', 'tool.title.listAgents': 'Subagenten auflisten', 'tool.title.sendMessage': 'Nachricht senden', 'tool.title.interruptAgent': 'Interrupt-Agent', @@ -2124,7 +2129,7 @@ export const de = { 'tool.title.getTeamTask': 'Teamaufgabe lesen', 'tool.title.updateTeamTask': 'Teamaufgabe aktualisieren', 'tool.title.listTeamTasks': 'Teamaufgaben auflisten', - 'tool.title.waitAgent': 'Warten Sie auf den Subagenten', + 'tool.title.waitAgent': 'Warten auf Nachrichten…', 'detail.recordedResult': 'Aufgezeichnetes Ergebnis', 'detail.empty': 'Keine Ergebnisse', 'detail.none': 'Keine', @@ -2181,11 +2186,12 @@ export const de = { 'detail.field.agents': 'Agenten begannen', 'detail.field.result': 'Ergebnis', 'detail.field.parent': 'Eltern', - 'detail.field.depth': 'Tiefe', 'detail.field.exitCode': 'Exit-Code', 'detail.field.signal': 'Signal', 'detail.field.previousStatus': 'Vorheriger Stand', 'detail.field.agent': 'Agenten-ID', + 'detail.field.agentName': 'Agentenname', + 'detail.field.agentDescription': 'Aufgabenbeschreibung des Agenten', 'detail.field.job': 'Job-ID', 'detail.field.task': 'Aufgabe', 'detail.field.processGroup': 'Prozessgruppe', @@ -2211,7 +2217,7 @@ export const de = { 'detail.wait.noProgress': 'Keine aktiven Subagenten', 'detail.wait.title': 'Aktivität des Subagenten', 'detail.wait.timeout': 'Wartezeit abgelaufen', - 'detail.wait.changed': 'Änderung erkannt', + 'detail.wait.ended': 'Warten beendet', 'detail.agent.reply': 'Antwort des Agenten', 'detail.models.title': 'Verfügbare Modelle', 'detail.output.lines': 'Linien {begin}–{end} von {total}', @@ -2353,7 +2359,6 @@ export const ru = { 'placeholder.plan': PLAN_NEXT_ACTION_EN, 'placeholder.default': 'Отправьте сообщение или запустите задачу, /команды, @файлы или сеансы', 'placeholder.unavailable': 'Сеанс недоступен', - 'placeholder.parentOffline': 'Родительская сессия в автономном режиме; отправка недоступна, но вы все равно можете остановить запуск', 'placeholder.hero': 'Опишите, что вы хотите построить: /команды, @файлы или сеансы.', 'placeholder.workspace': 'Выберите рабочее пространство для начала', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter управляет всеми сообщениями в очереди.', @@ -2492,6 +2497,7 @@ export const ru = { 'tool.title.traceSession': 'Трассировка сеанса', 'tool.title.listModels': 'Список моделей', 'tool.title.subagent': 'Создать субагент', + 'tool.title.subagentFork': 'Создать субагент (наследовать контекст)', 'tool.title.listAgents': 'Список субагентов', 'tool.title.sendMessage': 'Отправить сообщение', 'tool.title.interruptAgent': 'Агент прерываний', @@ -2513,7 +2519,7 @@ export const ru = { 'tool.title.getTeamTask': 'Прочитать командное задание', 'tool.title.updateTeamTask': 'Обновить групповую задачу', 'tool.title.listTeamTasks': 'Перечислить командные задачи', - 'tool.title.waitAgent': 'Подождите субагента', + 'tool.title.waitAgent': 'Ожидание сообщений…', 'detail.recordedResult': 'Записанный результат', 'detail.empty': 'Нет результатов', 'detail.none': 'Нет', @@ -2570,11 +2576,12 @@ export const ru = { 'detail.field.agents': 'Агенты запущены', 'detail.field.result': 'Результат', 'detail.field.parent': 'Родитель', - 'detail.field.depth': 'Глубина', 'detail.field.exitCode': 'Код выхода', 'detail.field.signal': 'Сигнал', 'detail.field.previousStatus': 'Предыдущий статус', 'detail.field.agent': 'Идентификатор агента', + 'detail.field.agentName': 'Имя агента', + 'detail.field.agentDescription': 'Описание задачи агента', 'detail.field.job': 'Идентификатор вакансии', 'detail.field.task': 'Задача', 'detail.field.processGroup': 'Группа процессов', @@ -2600,7 +2607,7 @@ export const ru = { 'detail.wait.noProgress': 'Нет активных субагентов', 'detail.wait.title': 'Субагентская деятельность', 'detail.wait.timeout': 'Время ожидания истекло', - 'detail.wait.changed': 'Обнаружено изменение', + 'detail.wait.ended': 'Ожидание завершено', 'detail.agent.reply': 'Ответ агента', 'detail.models.title': 'Доступные модели', 'detail.output.lines': 'Линии {begin} – {end} из {total}', @@ -2742,7 +2749,6 @@ export const pt = { 'placeholder.plan': PLAN_NEXT_ACTION_EN, 'placeholder.default': 'Envie mensagens ou execute uma tarefa, / comandos, @ arquivos ou sessões', 'placeholder.unavailable': 'Sessão indisponível', - 'placeholder.parentOffline': 'Sessão pai offline; o envio não está disponível, mas você ainda pode interromper a execução', 'placeholder.hero': 'Descreva o que você deseja construir, / comandos, @ arquivos ou sessões', 'placeholder.workspace': 'Escolha um espaço de trabalho para começar', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter direciona todas as mensagens na fila', @@ -2881,6 +2887,7 @@ export const pt = { 'tool.title.traceSession': 'Sessão de rastreamento', 'tool.title.listModels': 'Listar modelos', 'tool.title.subagent': 'Criar subagente', + 'tool.title.subagentFork': 'Criar subagente (herdar contexto)', 'tool.title.listAgents': 'Listar subagentes', 'tool.title.sendMessage': 'Enviar mensagem', 'tool.title.interruptAgent': 'Agente de interrupção', @@ -2902,7 +2909,7 @@ export const pt = { 'tool.title.getTeamTask': 'Ler tarefa da equipe', 'tool.title.updateTeamTask': 'Atualizar tarefa da equipe', 'tool.title.listTeamTasks': 'Listar tarefas da equipe', - 'tool.title.waitAgent': 'Aguarde o subagente', + 'tool.title.waitAgent': 'Aguardando mensagens…', 'detail.recordedResult': 'Resultado registrado', 'detail.empty': 'Nenhum resultado', 'detail.none': 'Nenhum', @@ -2959,11 +2966,12 @@ export const pt = { 'detail.field.agents': 'Agentes iniciados', 'detail.field.result': 'Resultado', 'detail.field.parent': 'Pai', - 'detail.field.depth': 'Profundidade', 'detail.field.exitCode': 'Código de saída', 'detail.field.signal': 'Sinal', 'detail.field.previousStatus': 'Estado anterior', 'detail.field.agent': 'ID do agente', + 'detail.field.agentName': 'Nome do agente', + 'detail.field.agentDescription': 'Descrição da tarefa do agente', 'detail.field.job': 'ID do trabalho', 'detail.field.task': 'Tarefa', 'detail.field.processGroup': 'Grupo de processos', @@ -2989,7 +2997,7 @@ export const pt = { 'detail.wait.noProgress': 'Nenhum subagente ativo', 'detail.wait.title': 'Atividade do subagente', 'detail.wait.timeout': 'A espera expirou', - 'detail.wait.changed': 'Alteração detectada', + 'detail.wait.ended': 'Espera encerrada', 'detail.agent.reply': 'Resposta do agente', 'detail.models.title': 'Modelos disponíveis', 'detail.output.lines': 'Linhas {begin}–{end} de {total}', diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx index 674b644a9f..dc82d99775 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx @@ -29,15 +29,7 @@ export function ConversationMainPanel(props: ConversationSlotProps) { // The exemption is deliberately open-state-wide, not loading-only: a // summary-blank session is the hero before its open starts (`cold`) and // after one fails (`error`) for the same reason — there is no history. - // A restored continuable subagent waits for a Host summary to establish - // parent availability. This keeps the composer - // hidden instead of briefly rendering the parent-offline takeover. - const parentAvailabilityPending = session?.subagent?.address.mode === 'continuable' - && session.subagent.parentAvailable === undefined - const settling = sessionId !== undefined && ( - (shellPhase === 'blank' && openState === 'loading' && summaryBlank !== true) - || parentAvailabilityPending - ) + const settling = sessionId !== undefined && shellPhase === 'blank' && openState === 'loading' && summaryBlank !== true const hero = sessionId === undefined || (shellPhase === 'blank' && (openState === 'open' || summaryBlank === true)) const phase = settling ? 'settling' : hero ? 'hero' : 'active' diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index cfe0953095..dd60f4cff6 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -149,11 +149,6 @@ cursor: pointer; } -.crumbSubagent { - font-size: 12px; - line-height: 18px; -} - @media (hover: hover) { button.crumb:hover { background: var(--dsw-alias-interactive-bg-hover); @@ -170,7 +165,13 @@ display: flex; flex: none; align-items: center; - gap: 8px; + gap: 10px; +} + +@container (max-width: 460px) { + .headerActions { + gap: 8px; + } } .headerUtilities { diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index 14602cbca0..cc309bbfab 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -157,14 +157,14 @@ export function ConversationSessionHeader({ // base.css) and leave the title inert for dragging too. const title = last ? ( - + {summary.displayTitle} ) : ( + + } + )} {browsable && 0} icon={} onSelect={() => { onDismiss(); browsePlugins() }}> {t('install')} diff --git a/packages/client/ui-entry-customization/src/client/locales.ts b/packages/client/ui-entry-customization/src/client/locales.ts index c0a0e2cccc..c9191fb438 100644 --- a/packages/client/ui-entry-customization/src/client/locales.ts +++ b/packages/client/ui-entry-customization/src/client/locales.ts @@ -1,5 +1,7 @@ /** Copy for the More menu rows: navigation actions, and Creator draft prompts per region. */ export const en = { + 'pinTooltip': 'Pin to left sidebar', + 'pin': 'Pin {name} to sidebar', install: 'Browse other plugins', create: 'Create your plugin', 'createPrompt.sidebar': 'Create a DSH plugin with a left sidebar entry.', @@ -9,6 +11,8 @@ export const en = { /** Chinese counterpart of the entry-customization dictionary. */ export const zh: Record = { + 'pinTooltip': '固定到左侧栏', + 'pin': '将{name}固定到侧边栏', install: '浏览更多插件', create: '创建你的插件', 'createPrompt.sidebar': '创建一个带左侧栏入口的 DSH 插件。', @@ -18,6 +22,8 @@ export const zh: Record = { /** Japanese copy, checked against the English keys. */ export const ja = { + 'pinTooltip': '左サイドバーに固定', + 'pin': '{name}をサイドバーに固定', 'install': '他のプラグインを探す', 'create': 'プラグインを作成', 'createPrompt.sidebar': '左サイドバーにエントリを持つ DSH プラグインを作成します。', @@ -27,6 +33,8 @@ export const ja = { /** Spanish dictionary, checked against the English keys. */ export const es = { + 'pinTooltip': 'Fijar a la barra lateral izquierda', + 'pin': 'Fijar {name} en la barra lateral', install: 'Explorar otros complementos', create: 'Crea tu complemento', 'createPrompt.sidebar': 'Cree un complemento DSH con una entrada en la barra lateral izquierda.', @@ -36,6 +44,8 @@ export const es = { /** French dictionary, checked against the English keys. */ export const fr = { + 'pinTooltip': 'Épingler sur la barre latérale gauche', + 'pin': 'Épingler {name} à la barre latérale', install: "Parcourir d'autres plugins", create: 'Créez votre plugin', 'createPrompt.sidebar': 'Créez un plugin DSH avec une entrée dans la barre latérale gauche.', @@ -45,6 +55,8 @@ export const fr = { /** German dictionary, checked against the English keys. */ export const de = { + 'pinTooltip': 'An die linke Seitenleiste anpinnen', + 'pin': '{name} an der Seitenleiste anheften', install: 'Durchsuchen Sie andere Plugins', create: 'Erstellen Sie Ihr Plugin', 'createPrompt.sidebar': 'Erstellen Sie ein DSH-Plugin mit einem Eintrag in der linken Seitenleiste.', @@ -54,6 +66,8 @@ export const de = { /** Russian dictionary, checked against the English keys. */ export const ru = { + 'pinTooltip': 'Закрепить на левой боковой панели', + 'pin': 'Закрепить {name} на боковой панели', install: 'Просмотрите другие плагины', create: 'Создайте свой плагин', 'createPrompt.sidebar': 'Создайте плагин DSH с записью на левой боковой панели.', @@ -63,6 +77,8 @@ export const ru = { /** Portuguese dictionary, checked against the English keys. */ export const pt = { + 'pinTooltip': 'Fixar na barra lateral esquerda', + 'pin': 'Fixar {name} na barra lateral', install: 'Procure outros plug-ins', create: 'Crie seu plug-in', 'createPrompt.sidebar': 'Crie um plug-in DSH com uma entrada na barra lateral esquerda.', diff --git a/packages/client/ui-entry-customization/tests/menu.client.spec.tsx b/packages/client/ui-entry-customization/tests/menu.client.spec.tsx index ff77c49ca7..334352d679 100644 --- a/packages/client/ui-entry-customization/tests/menu.client.spec.tsx +++ b/packages/client/ui-entry-customization/tests/menu.client.spec.tsx @@ -2,11 +2,12 @@ /** The plugin fills each region's More menu with the discovery and Creator actions. */ import type { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, fireEvent, render, within } from '@testing-library/react' +import { act, cleanup, fireEvent, render, waitFor, within } from '@testing-library/react' import type { ComponentProps } from 'react' import { useState } from 'react' import { makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { Menu } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import type { TrackProductEvent } from '@deepseek-ai/dsh-client-product-analytics/client' import { EntryMenuContent } from '../src/client/EntryMenuContent.tsx' @@ -35,16 +36,16 @@ function content(overrides: Partial = {}) { region: 'sidebar', t: makeTranslate(en), usePluginNavigation: (select: (present: boolean) => T): T => select(true), usePanelInfo: unused, useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused, - useResource: unused, useWorkspaces: unused, + useResource: unused, useWorkspaces: unused, renderFactorySlot: () => null, ...callbacks, ...overrides, } as ContentProps const view = render(
    ) - return { view, ...callbacks } + return { view, props, ...callbacks } } describe('More menu rows', () => { - it.each(['sidebar', 'rightbar'] as const)('shows only discovery and creation in %s regardless of entries', (region) => { + it.each(['rightbar'] as const)('shows only discovery and creation in %s regardless of entries', (region) => { const b = content({ region, entries: [ { id: 'notes', label: 'Notes', pinned: true, active: true }, { id: 'tasks', label: 'Tasks', pinned: false }, @@ -61,6 +62,37 @@ describe('More menu rows', () => { expect(b.onPinnedChange).not.toHaveBeenCalled() }) + it('lists only unpinned sidebar entries and pins without navigating', () => { + const b = content() + expect(b.view.queryByRole('menuitem', { name: 'Notes' })).toBeNull() + fireEvent.click(b.view.getByRole('menuitem', { name: 'Tasks' })) + expect(b.onOpen).toHaveBeenCalledExactlyOnceWith('tasks') + expect(b.onPinnedChange).not.toHaveBeenCalled() + b.onOpen.mockClear() + fireEvent.click(b.view.getByRole('menuitem', { name: 'Pin Tasks to sidebar' })) + expect(b.onPinnedChange).toHaveBeenCalledExactlyOnceWith('tasks', true) + expect(b.onOpen).not.toHaveBeenCalled() + }) + + it('returns focus to More after keyboard pinning without opening the entry', async () => { + const b = content() + b.view.unmount() + function PinMenu() { + const [open, setOpen] = useState(true) + return { setOpen(false) }} autoFocus + anchor={}> + { setOpen(false) }} /> + + } + const view = render() + const pin = view.getByRole('menuitem', { name: 'Pin Tasks to sidebar' }) + act(() => { pin.focus() }) + fireEvent.keyDown(pin, { key: 'Tab' }) + await waitFor(() => { expect(document.activeElement).toBe(view.getByRole('button', { name: 'More' })) }) + expect(b.onPinnedChange).toHaveBeenCalledExactlyOnceWith('tasks', true) + expect(b.onOpen).not.toHaveBeenCalled() + }) + it.each([true, false])('opens folded Views and marks the active View with plugin navigation present=%s', (browsable) => { const b = content({ region: 'conversation', usePluginNavigation: select => select(browsable), @@ -90,7 +122,7 @@ describe('More menu rows', () => { }) it.each(['sidebar', 'rightbar'] as const)('keeps only creation in %s without plugin navigation', (region) => { - const b = content({ region, usePluginNavigation: select => select(false) }) + const b = content({ region, entries: [], usePluginNavigation: select => select(false) }) expect(b.view.getAllByRole('menuitem').map(row => row.textContent)).toEqual(['Create your plugin']) expect(b.view.queryByRole('separator')).toBeNull() fireEvent.click(b.view.getByRole('menuitem', { name: 'Create your plugin' })) diff --git a/packages/client/ui-jobs/README.i18n.yaml b/packages/client/ui-jobs/README.i18n.yaml index 9f8db4d72f..4d2ee72e3b 100644 --- a/packages/client/ui-jobs/README.i18n.yaml +++ b/packages/client/ui-jobs/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-jobs/use-this-package: - en: 60f6d05d870f6518 - zh: dddf3f532d828b78 + en: 3466553808f81088 + zh: 02a806ece1c3e012 /deepseek-ai-dsh-client-ui-jobs/use-this-package/one-row-per-job: en: 64c1f55b35e73295 zh: 4d93594923cf787f diff --git a/packages/client/ui-jobs/README.md b/packages/client/ui-jobs/README.md index bd8a10e8f2..f4b2d9c091 100644 --- a/packages/client/ui-jobs/README.md +++ b/packages/client/ui-jobs/README.md @@ -27,6 +27,10 @@ English | [中文](README.zh.md) Load the plugin through the web-app manifest; it renders nothing until the session can see at least one job, so an ordinary conversation never grows a control for a capability it is not using. +The header shows the live job count when any jobs are running or stopping, otherwise the visible job count, without repeating the spinner's running state in visible text. The trigger's accessible description identifies live work in both wide and compact layouts. In wide layout, a 14px icon slot shows the spinner for live jobs or the 14px layers icon for idle jobs; the root trigger has no disclosure arrow. At container widths up to 460px, the count hides and the trigger always shows the layers icon, including while jobs are live; running indicators remain inside the menu. The trigger matches More and sidebar controls: a 28×28px icon button with no padding, a 15px glyph in a 15px slot, secondary ink, and ghost hover and active backgrounds. Header actions use an 8px gap in compact layout and 10px in wide layout. Its count tooltip appears after 500ms of hover or immediately on keyboard focus, and stays hidden while the list is open. + +Non-touch hover on the text-visible trigger opens the list after 150ms; leaving both the trigger and its portaled list closes it after 120ms. Clicking the wide trigger opens or pins the list; clicking an already pinned trigger closes it. The compact trigger toggles on click and never opens on hover, including when the count hides during a pending hover. ArrowDown opens and pins the list and focuses its first control. Pointer interaction or keyboard focus inside also pins it. Outside click or Escape dismisses it, and Tab connects the trigger to the portaled controls. Hover timers and pins reset when the session changes, no visible jobs remain, or the component unmounts. + ### One row per job The `job.list` stream `ctx.jobs` mirrors is the single roster: each `JobView` row carries lifecycle, duration, the live `progress` line or the terminal `detail`, and its retained byte count — a live job, or a settled one with retained output, is what makes a row expandable. There is no second roster to join. diff --git a/packages/client/ui-jobs/README.zh.md b/packages/client/ui-jobs/README.zh.md index 62201bccee..f1b99059af 100644 --- a/packages/client/ui-jobs/README.zh.md +++ b/packages/client/ui-jobs/README.zh.md @@ -27,6 +27,10 @@ kind: "package-reference" 通过 web-app 清单加载本插件;在会话看得到至少一个 job 之前它不渲染任何东西,因此普通对话不会为未使用的能力长出控件。 +有运行中或正在停止的任务时,头部显示这些任务的数量;否则显示可见任务总数,可见文字不重复旋转指示器已表达的运行状态。宽布局和紧凑布局中的触发器均通过无障碍描述说明是否有进行中的任务。宽布局下,14px 图标区域在有进行中任务时显示旋转指示器,否则显示 14px 图层图标;根触发器不带展开箭头。容器宽度不超过 460px 时,数量文字隐藏,触发器始终显示图层图标,包括任务运行期间;运行指示器仍在菜单内显示。触发器与更多及侧边栏控件一致:28×28px 图标按钮,无内边距,15px 图标位内显示 15px 图标,使用次级前景色以及 ghost 悬停和按下背景。页头操作在紧凑布局中间距为 8px,宽布局中为 10px。数量工具提示在悬停 500ms 后或获得键盘焦点时立即显示,列表打开期间保持隐藏。 + +非触摸指针在文字可见的触发器上悬停 150ms 后打开列表;指针离开触发器和挂载到 portal 的列表后,经过 120ms 关闭。点击宽布局触发器会打开或固定列表;再次点击已固定的触发器会关闭列表。紧凑触发器通过点击切换开关状态,不因悬停打开,包括等待悬停期间数量文字隐藏的情况。ArrowDown 会打开并固定列表,将焦点移至首个控件。在列表内进行指针交互或移入键盘焦点也会固定列表。点击外部或按 Escape 可关闭列表,Tab 将触发器与 portal 内的控件连接起来。切换会话、没有可见任务或组件卸载时,悬停计时器与固定状态都会重置。 + ### 一 job 一行 `ctx.jobs` 镜像的 `job.list` 流是唯一名册:每个 `JobView` 行携带生命周期、时长、实时 `progress` 行或终态 `detail`,以及其保留字节数——进行中的 job,或留有保留输出的已结束 job,就是行可展开的依据。不存在需要 join 的第二份名册。 diff --git a/packages/client/ui-jobs/src/client/JobListAction.module.css b/packages/client/ui-jobs/src/client/JobListAction.module.css index a9eedb8079..4283427e63 100644 --- a/packages/client/ui-jobs/src/client/JobListAction.module.css +++ b/packages/client/ui-jobs/src/client/JobListAction.module.css @@ -1,7 +1,7 @@ .trigger { display: inline-flex; align-items: center; - gap: 3px; + gap: 4px; min-height: 28px; padding: 3px 2px; border: 0; @@ -23,20 +23,61 @@ } } -.trigger svg { - transition: transform 120ms ease; -} - -.triggerOpen { - transform: rotate(180deg); -} - -.triggerDot { +.activitySlot { + display: inline-flex; flex: none; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; } -.count { - margin: 0 5px; +.trigger .compactIcon { + display: none; +} + +@container (max-width: 460px) { + .trigger .compactIcon { + display: block; + } + + .trigger .textModeActivity { + display: none; + } + + .trigger .count { + display: none; + } + + .trigger { + box-sizing: border-box; + width: 28px; + height: 28px; + padding: 0; + justify-content: center; + color: var(--dsw-alias-label-secondary); + } + + .trigger .activitySlot, + .trigger .activitySlot > svg { + width: 15px; + height: 15px; + } + + .trigger:focus-visible { + color: var(--dsw-alias-label-secondary); + } + + .trigger:active { + background: var(--dsw-alias-interactive-bg-active); + } + + @media (hover: hover) { + .trigger:hover { + color: var(--dsw-alias-label-secondary); + background: var(--dsw-alias-interactive-bg-hover); + } + } } /* Portaled and fixed in the viewport with coordinates supplied inline from the diff --git a/packages/client/ui-jobs/src/client/JobListAction.tsx b/packages/client/ui-jobs/src/client/JobListAction.tsx index 7027483b96..c985af6fef 100644 --- a/packages/client/ui-jobs/src/client/JobListAction.tsx +++ b/packages/client/ui-jobs/src/client/JobListAction.tsx @@ -1,9 +1,9 @@ -import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react' +import { useCallback, useEffect, useId, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from 'react' import { createPortal } from 'react-dom' import type { JobsSnapshot, JobView, ObservedJob } from '@deepseek-ai/dsh-api-job-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { - IconChevronDownOutlineRegular, IconStopFillRegular, MenuSurface, StateDot, TerminalBlock, + IconChevronDownOutlineRegular, IconLayersOutlineRegular, IconStopFillRegular, MenuSurface, StateDot, TerminalBlock, Tooltip, useAnchoredPosition, useDismissOnOutsidePointer, type StateDotState, type TerminalBlockLabels, } from '@deepseek-ai/dsh-client-ui-primitives' @@ -329,15 +329,25 @@ function JobItem({ job, view, expanded, now, onToggle, kill, t }: { * or a settled one with retained output) starts its observation stream, and * collapsing (or closing the popover) stops it — output only flows while * someone is watching. A running row carries a two-press stop button that - * requests a human kill through the job controller. + * requests a human kill through the job controller. Non-touch hover on a + * text-visible trigger opens after 150ms and closes after 120ms outside both + * trigger and portal. Compact triggers toggle on click; ArrowDown opens and + * pins either layout. Interacting inside also pins the menu until explicit + * dismissal; timers and the pin are local to the mounted session. * @param props - runtime slot currency, the jobs snapshot hook, the roster, * observation, and kill controls, and the namespace translator. * @returns the trigger and its popover list, or null when there is nothing to show. */ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob, t }: JobListActionProps) { + const activityId = useId() const jobs = useJobs(state => state.rows[sessionId]) ?? NO_JOBS const observedViews = useJobs(state => state.observed) - const [open, setOpen] = useState(false) + const [menuState, setMenuState] = useState<{ sessionId: SessionId; mode: 'closed' | 'hover' | 'pinned' }>( + { sessionId, mode: 'closed' }, + ) + const hoverOpenTimer = useRef | undefined>(undefined) + const hoverCloseTimer = useRef | undefined>(undefined) + if (menuState.sessionId !== sessionId) setMenuState({ sessionId, mode: 'closed' }) const [expandedKey, setExpandedKey] = useState(undefined) const [now, setNow] = useState(() => Date.now()) // Settled-section fold: an explicit user toggle wins; before one, the tail @@ -350,6 +360,7 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob, const [killPhase, setKillPhase] = useState<{ key: string; state: Exclude } | undefined>(undefined) const rootRef = useRef(null) const triggerRef = useRef(null) + const countRef = useRef(null) const menuRef = useRef(null) const rows = useMemo(() => ordered(jobs), [jobs]) @@ -360,6 +371,50 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob, ) const settledExpanded = settledOpen ?? liveRows.length === 0 const visibleCount = liveRows.length + settledRows.length + const hasJobs = visibleCount > 0 + const open = hasJobs && menuState.sessionId === sessionId && menuState.mode !== 'closed' + + const cancelHover = useCallback((): void => { + clearTimeout(hoverOpenTimer.current) + clearTimeout(hoverCloseTimer.current) + hoverOpenTimer.current = undefined + hoverCloseTimer.current = undefined + }, []) + + const changeOpen = useCallback((next: boolean): void => { + cancelHover() + if (next) setNow(Date.now()) + setMenuState({ sessionId, mode: next ? 'pinned' : 'closed' }) + }, [cancelHover, sessionId]) + + // CSS owns the compact breakpoint; read its result only for pointer gestures. + const allowsHoverOpen = (): boolean => countRef.current !== null + && getComputedStyle(countRef.current).display !== 'none' + + const hoverEnter = (event: PointerEvent): void => { + if (event.pointerType === 'touch') return + cancelHover() + if (open || !allowsHoverOpen()) return + hoverOpenTimer.current = setTimeout(() => { + hoverOpenTimer.current = undefined + if (!allowsHoverOpen()) return + setNow(Date.now()) + setMenuState({ sessionId, mode: 'hover' }) + }, 150) + } + + const hoverLeave = (event: PointerEvent): void => { + if (event.pointerType === 'touch') return + cancelHover() + if (!open || menuState.mode === 'pinned') return + hoverCloseTimer.current = setTimeout(() => { + hoverCloseTimer.current = undefined + setMenuState({ sessionId, mode: 'closed' }) + }, 120) + } + + // Timers belong to this mounted session and cannot survive a disappearing trigger. + useEffect(() => cancelHover, [cancelHover, sessionId, hasJobs]) // The popover portals to the document body: the session header sits in a // clipping column that the right sidebar borders or overlaps, so an in-place @@ -367,7 +422,18 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob, const menuPosition = useAnchoredPosition({ open, anchorRef: rootRef, panelRef: menuRef, gap: MENU_GAP, margin: VIEWPORT_MARGIN, }) - useDismissOnOutsidePointer(rootRef, open, setOpen, menuRef) + useDismissOnOutsidePointer(rootRef, open, changeOpen, menuRef) + // Hover does not move focus; Escape must also work from the rest of the page. + useEffect(() => { + if (!open) return + const dismiss = (event: globalThis.KeyboardEvent): void => { + if (event.key !== 'Escape' || event.defaultPrevented) return + event.preventDefault() + changeOpen(false) + } + document.addEventListener('keydown', dismiss) + return () => { document.removeEventListener('keydown', dismiss) } + }, [open, changeOpen]) // The roster follows the mounted session: one stream while this control // lives, released with it. @@ -392,11 +458,10 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob, return observe(sessionId, activeJob) }, [sessionId, activeJob, observe]) - // The last visible job disappearing removes this control; close first so - // focus does not vanish from an unmounting node. + // A returning roster starts closed rather than restoring an obsolete pin. useEffect(() => { - if (visibleCount === 0 && open) setOpen(false) - }, [visibleCount, open]) + if (!hasJobs) changeOpen(false) + }, [hasJobs, changeOpen]) // An expanded row that left the list (owner disposal) folds its panel. useEffect(() => { @@ -448,10 +513,8 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob, if (visibleCount === 0) return null - const countKey = liveRows.length > 0 - ? (liveRows.length === 1 ? 'count.live.one' : 'count.live.other') - : (visibleCount === 1 ? 'count.idle.one' : 'count.idle.other') - const countLabel = t(countKey, { count: liveRows.length > 0 ? liveRows.length : visibleCount }) + const count = liveRows.length > 0 ? liveRows.length : visibleCount + const countLabel = t(count === 1 ? 'count.idle.one' : 'count.idle.other', { count }) const clearSettled = (): void => { setClearedKeys((current) => { @@ -468,14 +531,14 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob, // keep the in-place order: the trigger leads into the list, and leaving // either end of the list returns to the trigger's position in the page. const onKeyDown = (event: KeyboardEvent): void => { - if (!open) return const trigger = triggerRef.current if (event.key === 'Escape') { event.preventDefault() - setOpen(false) + changeOpen(false) trigger?.focus() return } + if (!open) return const menu = menuRef.current /* v8 ignore next -- both refs are attached while the open list renders. */ if (event.key !== 'Tab' || trigger === null || menu === null) return @@ -517,28 +580,43 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob, return (
    - + + + + {liveRows.length > 0 && } {open ? createPortal( - + { changeOpen(true) }} + onFocusCapture={() => { changeOpen(true) }} + >
      {liveRows.length > 0 ? diff --git a/packages/client/ui-jobs/src/client/locales.ts b/packages/client/ui-jobs/src/client/locales.ts index 700009d9a5..c968a08b5d 100644 --- a/packages/client/ui-jobs/src/client/locales.ts +++ b/packages/client/ui-jobs/src/client/locales.ts @@ -5,8 +5,6 @@ export const NS = 'job' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'count.live.one': '{count} 个后台任务运行中', - 'count.live.other': '{count} 个后台任务运行中', 'count.idle.one': '{count} 个后台任务', 'count.idle.other': '{count} 个后台任务', 'list.aria': '后台任务', @@ -49,8 +47,6 @@ export const zh = { /** English dictionary, key-identical to the Chinese source of truth. */ export const en: Record = { - 'count.live.one': '{count} background job running', - 'count.live.other': '{count} background jobs running', 'count.idle.one': '{count} background job', 'count.idle.other': '{count} background jobs', 'list.aria': 'Background jobs', @@ -96,8 +92,6 @@ export type JobKey = keyof typeof zh /** Japanese copy, checked against the English keys. */ export const ja = { - 'count.live.one': 'バックグラウンドジョブ {count} 件が実行中', - 'count.live.other': 'バックグラウンドジョブ {count} 件が実行中', 'count.idle.one': 'バックグラウンドジョブ {count} 件', 'count.idle.other': 'バックグラウンドジョブ {count} 件', 'list.aria': 'バックグラウンドジョブ', @@ -140,8 +134,6 @@ export const ja = { /** Spanish dictionary, checked against the English keys. */ export const es = { - 'count.live.one': 'Tareas en segundo plano en ejecución: {count}', - 'count.live.other': 'Tareas en segundo plano en ejecución: {count}', 'count.idle.one': 'Tareas en segundo plano: {count}', 'count.idle.other': 'Tareas en segundo plano: {count}', 'list.aria': 'Trabajos en segundo plano', @@ -184,8 +176,6 @@ export const es = { /** French dictionary, checked against the English keys. */ export const fr = { - 'count.live.one': 'Tâches en arrière-plan en cours : {count}', - 'count.live.other': 'Tâches en arrière-plan en cours : {count}', 'count.idle.one': 'Tâches en arrière-plan : {count}', 'count.idle.other': 'Tâches en arrière-plan : {count}', 'list.aria': 'Travaux en arrière-plan', @@ -228,8 +218,6 @@ export const fr = { /** German dictionary, checked against the English keys. */ export const de = { - 'count.live.one': 'Laufende Hintergrundaufgaben: {count}', - 'count.live.other': 'Laufende Hintergrundaufgaben: {count}', 'count.idle.one': 'Hintergrundaufgaben: {count}', 'count.idle.other': 'Hintergrundaufgaben: {count}', 'list.aria': 'Hintergrundjobs', @@ -272,8 +260,6 @@ export const de = { /** Russian dictionary, checked against the English keys. */ export const ru = { - 'count.live.one': 'Фоновые задачи выполняются: {count}', - 'count.live.other': 'Фоновые задачи выполняются: {count}', 'count.idle.one': 'Фоновые задачи: {count}', 'count.idle.other': 'Фоновые задачи: {count}', 'list.aria': 'Фоновые задания', @@ -316,8 +302,6 @@ export const ru = { /** Portuguese dictionary, checked against the English keys. */ export const pt = { - 'count.live.one': 'Tarefas em segundo plano em execução: {count}', - 'count.live.other': 'Tarefas em segundo plano em execução: {count}', 'count.idle.one': 'Tarefas em segundo plano: {count}', 'count.idle.other': 'Tarefas em segundo plano: {count}', 'list.aria': 'Trabalhos em segundo plano', diff --git a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx index d3a4c72053..c8ee1a0e6a 100644 --- a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx +++ b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx @@ -5,7 +5,23 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { JobsSnapshot, JobView, ObservedJob } from '@deepseek-ai/dsh-api-job-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { JobListAction, type JobListActionProps } from '../src/client/JobListAction.tsx' -import { zh, ru } from '../src/client/locales.ts' +import { zh, en, ja, es, fr, de, ru, pt } from '../src/client/locales.ts' + +beforeEach(() => { + class TooltipObserver implements ResizeObserver { + constructor(private readonly callback: ResizeObserverCallback) {} + observe(target: Element): void { + const size = { inlineSize: 160, blockSize: 30 } + this.callback([{ + target, borderBoxSize: [size], contentBoxSize: [size], devicePixelContentBoxSize: [size], contentRect: new DOMRect(), + }], this) + } + unobserve(): void {} + disconnect(): void {} + } + vi.stubGlobal('ResizeObserver', TooltipObserver) + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true }))) +}) beforeEach(() => { vi.stubGlobal('CSS', { supports: (property: string) => property === '-webkit-backdrop-filter' }) @@ -57,6 +73,28 @@ function openList(): void { fireEvent.click(screen.getAllByRole('button')[0]!) } +describe('JobListAction count copy', () => { + it.each([ + [zh, '1 个后台任务', '2 个后台任务'], + [en, '1 background job', '2 background jobs'], + [ja, 'バックグラウンドジョブ 1 件', 'バックグラウンドジョブ 2 件'], + [es, 'Tareas en segundo plano: 1', 'Tareas en segundo plano: 2'], + [fr, 'Tâches en arrière-plan : 1', 'Tâches en arrière-plan : 2'], + [de, 'Hintergrundaufgaben: 1', 'Hintergrundaufgaben: 2'], + [ru, 'Фоновые задачи: 1', 'Фоновые задачи: 2'], + [pt, 'Tarefas em segundo plano: 1', 'Tarefas em segundo plano: 2'], + ] as const)('uses neutral count copy for live and settled jobs in locale %#', (dictionary, one, two) => { + const translate: JobListActionProps['t'] = makeTranslate(dictionary) + const settled = job({ id: 'settled' as JobView['id'], status: 'completed', finishedAt: 1_700_000_003_000 }) + const { rerender } = render() + expect(screen.getByRole('button', { name: one, description: dictionary['section.live'] })).toBeDefined() + rerender() + expect(screen.getByRole('button', { name: one, description: dictionary['section.live'] })).toBeDefined() + rerender() + expect(screen.getByRole('button', { name: two, description: '' })).toBeDefined() + }) +}) + describe('JobListAction visibility', () => { it('renders nothing while the session sees no jobs', () => { const { container } = render() @@ -78,7 +116,24 @@ describe('JobListAction visibility', () => { outputJob(), job({ id: 'subagent-1' as JobView['id'], kind: 'subagent', label: 'explore' }), ])} />) - expect(screen.getByRole('button', { name: '2 个后台任务运行中' })).toBeDefined() + expect(screen.getByRole('button', { name: '2 个后台任务' })).toBeDefined() + }) + + it('retains the compact Layers icon through running, stopping, and idle states without a root chevron', () => { + const { rerender } = render() + const trigger = screen.getByRole('button') + expect(trigger.querySelector('[data-icon="dsw-icon-layers-outline-regular"]')).not.toBeNull() + expect(trigger.querySelector('[data-state="ongoing"]')).not.toBeNull() + expect(trigger.querySelector('[data-icon="dsw-icon-chevron-down-outline-regular"]')).toBeNull() + rerender() + expect(screen.getByRole('button')).toBe(trigger) + expect(trigger.querySelector('[data-state="ongoing"]')).not.toBeNull() + rerender() + expect(screen.getByRole('button')).toBe(trigger) + expect(trigger.querySelector('[data-state="ongoing"]')).toBeNull() + const icon = trigger.querySelector('[data-icon="dsw-icon-layers-outline-regular"]') + expect(icon?.getAttribute('width')).toBe('14') + expect(trigger.querySelectorAll('svg')).toHaveLength(1) }) it('falls back to the total when nothing is live', () => { @@ -89,6 +144,281 @@ describe('JobListAction visibility', () => { }) }) +describe('JobListAction hover', () => { + function pointer(target: Element, type: 'pointerover' | 'pointerout', pointerType = 'mouse'): void { + // jsdom lacks PointerEvent; preserve its pointer type on the native event. + const event = new MouseEvent(type, { bubbles: true }) + Object.defineProperty(event, 'pointerType', { value: pointerType }) + fireEvent(target, event) + } + + function advance(ms: number): void { + act(() => { vi.advanceTimersByTime(ms) }) + } + + function hoverOpen(): HTMLElement { + const trigger = screen.getByRole('button', { name: '1 个后台任务' }) + pointer(trigger, 'pointerover') + advance(149) + expect(screen.queryByRole('list')).toBeNull() + advance(1) + expect(trigger.getAttribute('aria-expanded')).toBe('true') + return trigger + } + + it('keeps the compact trigger click-only and shows its count tooltip after 500ms', () => { + vi.useFakeTimers() + render() + const trigger = screen.getByRole('button') + const count = trigger.querySelector('[data-job-count]')! + count.style.display = 'none' + pointer(trigger, 'pointerover') + advance(499) + expect(screen.queryByRole('list')).toBeNull() + expect(screen.queryByRole('tooltip')).toBeNull() + advance(1) + expect(screen.getByRole('tooltip').textContent).toBe('1 个后台任务') + fireEvent.click(trigger) + expect(screen.queryByRole('tooltip')).toBeNull() + pointer(trigger, 'pointerout') + advance(500) + expect(screen.getByRole('list')).toBeDefined() + fireEvent.click(trigger) + expect(screen.queryByRole('list')).toBeNull() + }) + + it('cancels pending wide hover when CSS hides the count and compact clicks close hover-opened lists', () => { + vi.useFakeTimers() + render() + const trigger = screen.getByRole('button') + const count = trigger.querySelector('[data-job-count]')! + pointer(trigger, 'pointerover') + advance(100) + count.style.display = 'none' + advance(50) + expect(screen.queryByRole('list')).toBeNull() + pointer(trigger, 'pointerout') + count.style.display = '' + hoverOpen() + count.style.display = 'none' + fireEvent.click(trigger) + expect(screen.queryByRole('list')).toBeNull() + }) + + it('shows count on keyboard focus immediately and opens and pins with ArrowDown', async () => { + vi.useFakeTimers() + render() + const trigger = screen.getByRole('button') + trigger.querySelector('[data-job-count]')!.style.display = 'none' + fireEvent.keyDown(document.body, { key: 'Tab' }) + act(() => { trigger.focus() }) + expect(screen.getByRole('tooltip').textContent).toBe('1 个后台任务') + fireEvent.keyDown(trigger, { key: 'ArrowDown' }) + await act(async () => { vi.runAllTicks() }) + expect(screen.queryByRole('tooltip')).toBeNull() + const row = screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') }) + expect(document.activeElement).toBe(row) + pointer(trigger, 'pointerout') + advance(120) + expect(screen.getByRole('list')).toBeDefined() + fireEvent.keyDown(row, { key: 'Escape' }) + expect(screen.queryByRole('list')).toBeNull() + expect(document.activeElement).toBe(trigger) + }) + + it('waits for hover, bridges the portal gap, and closes after leaving both surfaces', () => { + vi.useFakeTimers() + const observe = vi.fn(() => () => {}) + const { container } = render() + const trigger = hoverOpen() + const menu = screen.getByRole('list').parentElement! + expect(container.contains(menu)).toBe(false) + expect(observe).not.toHaveBeenCalled() + pointer(trigger, 'pointerout') + advance(119) + pointer(menu, 'pointerover') + advance(120) + expect(screen.getByRole('list')).toBeDefined() + pointer(menu, 'pointerout') + advance(119) + expect(screen.getByRole('list')).toBeDefined() + advance(1) + expect(screen.queryByRole('list')).toBeNull() + }) + + it('cancels short hover and preserves a returning pointer during leave grace', () => { + vi.useFakeTimers() + render() + const trigger = screen.getByRole('button') + pointer(trigger, 'pointerover') + advance(149) + pointer(trigger, 'pointerout') + advance(150) + expect(screen.queryByRole('list')).toBeNull() + hoverOpen() + pointer(trigger, 'pointerout') + advance(100) + pointer(trigger, 'pointerover') + advance(120) + expect(screen.getByRole('list')).toBeDefined() + }) + + it('pins a hover-opened list on click until explicit dismissal', () => { + vi.useFakeTimers() + render() + const trigger = hoverOpen() + fireEvent.click(trigger) + pointer(trigger, 'pointerout') + advance(120) + expect(screen.getByRole('list')).toBeDefined() + fireEvent.click(trigger) + expect(screen.queryByRole('list')).toBeNull() + hoverOpen() + fireEvent.click(trigger) + fireEvent.pointerDown(document.body) + expect(screen.queryByRole('list')).toBeNull() + hoverOpen() + pointer(trigger, 'pointerout') + advance(120) + expect(screen.queryByRole('list')).toBeNull() + }) + + it('ignores touch hover while keeping tap usable', () => { + vi.useFakeTimers() + render() + const trigger = screen.getByRole('button') + pointer(trigger, 'pointerover', 'touch') + advance(150) + expect(screen.queryByRole('list')).toBeNull() + fireEvent.click(trigger) + pointer(trigger, 'pointerout', 'touch') + advance(120) + expect(screen.getByRole('list')).toBeDefined() + fireEvent.click(trigger) + expect(screen.queryByRole('list')).toBeNull() + }) + + it('pins keyboard focus and releases expanded output on Escape or hover close', () => { + vi.useFakeTimers() + const stop = vi.fn() + const observe = vi.fn(() => stop) + render() + const trigger = hoverOpen() + pointer(trigger, 'pointerout') + act(() => { trigger.focus() }) + fireEvent.keyDown(trigger, { key: 'Tab' }) + const row = screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') }) + expect(document.activeElement).toBe(row) + advance(120) + fireEvent.click(row) + expect(observe).toHaveBeenCalledTimes(1) + fireEvent.keyDown(row, { key: 'Escape' }) + expect(document.activeElement).toBe(trigger) + expect(stop).toHaveBeenCalledTimes(1) + hoverOpen() + expect(observe).toHaveBeenCalledTimes(2) + pointer(trigger, 'pointerout') + advance(120) + expect(screen.queryByRole('list')).toBeNull() + expect(stop).toHaveBeenCalledTimes(2) + }) + + it('pins pointer interaction so leaving cannot interrupt stop confirmation', () => { + vi.useFakeTimers() + render() + const trigger = hoverOpen() + pointer(trigger, 'pointerout') + const stop = screen.getByTitle(zh['kill.stop'].replace('{label}', 'pnpm run build')) + fireEvent.pointerDown(stop) + fireEvent.click(stop) + pointer(screen.getByRole('list').parentElement!, 'pointerout') + advance(120) + expect(stop.getAttribute('data-kill-state')).toBe('armed') + expect(screen.getByRole('list')).toBeDefined() + }) + + it('dismisses a hover-opened list with Escape while focus is elsewhere', () => { + vi.useFakeTimers() + render() + hoverOpen() + fireEvent.keyDown(document.body, { key: 'a' }) + expect(screen.getByRole('list')).toBeDefined() + fireEvent.keyDown(document.body, { key: 'Escape' }) + expect(screen.queryByRole('list')).toBeNull() + }) + + it.each(['opening', 'closing'] as const)('cleans up a pending %s timer on unmount', (phase) => { + vi.useFakeTimers() + const { unmount } = render() + const trigger = screen.getByRole('button') + pointer(trigger, 'pointerover') + if (phase === 'closing') { + advance(150) + pointer(trigger, 'pointerout') + } + unmount() + expect(vi.getTimerCount()).toBe(0) + advance(150) + expect(screen.queryByRole('list')).toBeNull() + }) + + it('releases expanded output and pending hover close before showing another session', () => { + vi.useFakeTimers() + const stop = vi.fn() + const observe = vi.fn(() => stop) + const first = props([outputJob()], observe) + const { rerender } = render() + const trigger = hoverOpen() + const row = screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') }) + fireEvent.click(row) + expect(observe).toHaveBeenCalledTimes(1) + pointer(trigger, 'pointerout') + const nextId = 'next-session' as SessionId + rerender( select({ rows: { [nextId]: [outputJob()] }, observed: {} })} + />) + expect(stop).toHaveBeenCalledTimes(1) + expect(observe).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + hoverOpen() + advance(120) + expect(screen.getByRole('list')).toBeDefined() + expect(observe).toHaveBeenLastCalledWith(nextId, 'bash-1') + }) + + it('cancels pending hover and clears pins on session change or an empty roster', () => { + vi.useFakeTimers() + const first = props([job()]) + const nextId = 'next-session' as SessionId + const next: JobListActionProps = { + ...first, + sessionId: nextId, + useJobs: select => select({ rows: { [nextId]: [job()] }, observed: {} }), + } + const { rerender } = render() + pointer(screen.getByRole('button'), 'pointerover') + rerender() + advance(150) + expect(screen.queryByRole('list')).toBeNull() + const trigger = hoverOpen() + fireEvent.click(trigger) + rerender() + expect(screen.queryByRole('list')).toBeNull() + pointer(screen.getByRole('button'), 'pointerover') + rerender() + advance(150) + expect(vi.getTimerCount()).toBe(0) + rerender() + hoverOpen() + fireEvent.click(screen.getByRole('button', { name: '1 个后台任务' })) + rerender() + rerender() + expect(screen.queryByRole('list')).toBeNull() + }) +}) + describe('JobListAction rows', () => { it('renders a settled job without retained output as a static row', () => { render( { const settled = [job({ id: 'bash-2' as JobView['id'], label: 'settled work', status: 'completed', finishedAt: 1_700_000_012_000 })] render() openList() - expect(screen.getByText(zh['section.live'])).toBeDefined() + expect(screen.getByText(zh['section.live'], { selector: 'li' })).toBeDefined() const toggle = screen.getByRole('button', { name: zh['section.settledCount'].replace('{count}', '1') }) // Folded by default while something runs; the toggle expands it. expect(toggle.getAttribute('aria-expanded')).toBe('false') @@ -293,7 +623,7 @@ describe('JobListAction observation', () => { vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(440) vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(120) const { container } = render() - const trigger = screen.getByRole('button', { name: '1 个后台任务运行中' }) + const trigger = screen.getByRole('button', { name: '1 个后台任务' }) const anchor = vi.spyOn(trigger.parentElement!, 'getBoundingClientRect') .mockReturnValue(new DOMRect(344, 40, 180, 28)) fireEvent.click(trigger) @@ -319,7 +649,7 @@ describe('JobListAction observation', () => { vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(600) vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(120) render() - const trigger = screen.getByRole('button', { name: '1 个后台任务运行中' }) + const trigger = screen.getByRole('button', { name: '1 个后台任务' }) vi.spyOn(trigger.parentElement!, 'getBoundingClientRect').mockReturnValue(new DOMRect(4, 540, 180, 28)) fireEvent.click(trigger) const menu = screen.getByRole('list', { name: zh['list.aria'] }) @@ -335,7 +665,7 @@ describe('JobListAction observation', () => { vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(80) const remove = vi.spyOn(viewport, 'removeEventListener') render() - const trigger = screen.getByRole('button', { name: '1 个后台任务运行中' }) + const trigger = screen.getByRole('button', { name: '1 个后台任务' }) vi.spyOn(trigger.parentElement!, 'getBoundingClientRect').mockReturnValue(new DOMRect(100, 300, 200, 20)) fireEvent.click(trigger) const menu = screen.getByRole('list', { name: zh['list.aria'] }).closest('[data-menu-material]')! @@ -362,7 +692,7 @@ describe('JobListAction observation', () => { const stop = vi.fn() const observe = vi.fn(() => stop) const { container, unmount } = render() - const trigger = screen.getByRole('button', { name: '1 个后台任务运行中' }) + const trigger = screen.getByRole('button', { name: '1 个后台任务' }) trigger.focus() fireEvent.click(trigger) const list = screen.getByRole('list', { name: zh['list.aria'] }) @@ -629,7 +959,7 @@ it.each([1, 2, 5, 21])('renders count-independent Russian labels for %i jobs', ( const jobs = Array.from({ length: count }, (_, i) => job({ id: `job-${i}` as JobView['id'] })) const translate: JobListActionProps['t'] = makeTranslate(ru) const view = render() - expect(screen.getByRole('button', { name: `Фоновые задачи выполняются: ${count}` })).toBeDefined() + expect(screen.getByRole('button', { name: `Фоновые задачи: ${count}` })).toBeDefined() view.rerender( ({ ...item, status: 'completed', finishedAt: 1_700_000_003_000 })))} t={translate} />) expect(screen.getByRole('button', { name: `Фоновые задачи: ${count}` })).toBeDefined() }) diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index 3b307be019..b4bb58e659 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/README.i18n.yaml @@ -27,8 +27,8 @@ en: 7f20428c747ff052 zh: 975e6de728e02fa0 /deepseek-ai-dsh-client-ui-model-selection/use-this-package/selection-failures: - en: 261bbf42296243af - zh: 72779fb7b8fbc473 + en: 80fc237fa5c829f7 + zh: ecff5c8526f45996 /deepseek-ai-dsh-client-ui-model-selection/understand-the-implementation: en: 14870a55b8f4d4b7 zh: f498b469a785271e diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index dbffa12fef..1a53aa7c7b 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -61,6 +61,8 @@ Catalog availability does not block sending with a saved selection; request exec When another writer owns the Session, model-selection failures tell the user to quit other running DSH instances and retry. +The session-scoped `conversation.input.model.options` list slot appends controls below model and reasoning selection. Owner props expose `locked`, `reportFailure(message)`, and `dismiss()`, which closes the menu and synchronously focuses its trigger before another overlay opens; contributions read their own projections and supply narrow commands. Enabled switch buttons participate in the menu’s keyboard navigation. + ----- diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index 49c1d7ea7c..253370b97f 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -61,6 +61,8 @@ composer 菜单中的模型名称和推理等级均使用 400(regular)字重 当会话被其他写句柄占用时,模型选择失败提示用户退出其他正在运行的 DSH 后重试。 +会话级列表 slot `conversation.input.model.options` 在模型与推理等级选项下方追加控件。Owner props 提供 `locked`、`reportFailure(message)` 和 `dismiss()`,后者关闭菜单并同步聚焦触发按钮,供贡献方随后打开其他浮层;贡献方读取自身投影并提供专用命令。启用的开关按钮参与菜单键盘导航。 + ----- diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index ecc984ca76..0369578a08 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -66,7 +66,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } export function ModelSelect( { locked, available, directory, load, select, resolveText, t, renderSlot }: ModelSelectInjected & Pick, 'locked'> & PropsLocale<'model'> - & Omit, 'SessionProvider'>, + & Omit, 'SessionProvider'>, ) { const state = useSyncExternalStore( fn => directory.subscribe(fn), @@ -267,7 +267,9 @@ export function ModelSelect( } const moveFocus = (offset: number): void => { - const items = itemRefs.current.filter(item => item !== null) + const items = Array.from(menuRef.current?.querySelectorAll( + 'button[role="menuitem"]:not([disabled]), button[role="menuitemradio"]:not([disabled]), button[role="switch"]:not([disabled])', + ) ?? []) if (items.length === 0) return const active = items.findIndex(item => item === document.activeElement) // Focus outside the rows (the trigger, which keeps it while the menu @@ -321,7 +323,9 @@ export function ModelSelect( // control inside the card (a retry button) keeps the browser's traversal, // so the keystroke stays unconsumed there. const focused = document.activeElement - const rows = itemRefs.current.filter((item): item is HTMLButtonElement => item !== null) + const rows = Array.from(menuRef.current?.querySelectorAll( + 'button[role="menuitem"]:not([disabled]), button[role="menuitemradio"]:not([disabled]), button[role="switch"]:not([disabled])', + ) ?? []) if (focused instanceof HTMLButtonElement && rows.includes(focused)) { event.preventDefault() focused.click() @@ -507,6 +511,14 @@ export function ModelSelect( )} + {renderSlot('conversation.input.model.options', { + locked: locked || busy, + dismiss: () => { + close() + triggerRef.current?.focus() + }, + reportFailure: (message) => { setToast({ seq: ++toastSeq.current, text: message }) }, + })} )} diff --git a/packages/client/ui-model-selection/src/client/index.ts b/packages/client/ui-model-selection/src/client/index.ts index b91ee20b48..224990e848 100644 --- a/packages/client/ui-model-selection/src/client/index.ts +++ b/packages/client/ui-model-selection/src/client/index.ts @@ -174,6 +174,7 @@ export function apply(ctx: ClientContext): void { locale: NS, children: { 'conversation.input.model.label': { kind: 'single', scope: 'session', inline: true }, + 'conversation.input.model.options': { kind: 'list', scope: 'session' }, }, inject: (sessionId): ModelSelectInjected => { const directory = models.directoryFor(sessionId) diff --git a/packages/client/ui-model-selection/src/client/slots.ts b/packages/client/ui-model-selection/src/client/slots.ts index 4ffddee580..39a31b673f 100644 --- a/packages/client/ui-model-selection/src/client/slots.ts +++ b/packages/client/ui-model-selection/src/client/slots.ts @@ -19,6 +19,19 @@ export interface ModelLabelOwner { declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { + /** Session options appended after model and reasoning controls in the root menu. */ + 'conversation.input.model.options': { + kind: 'list' + scope: 'session' + owner: { + /** Whether the composer currently refuses selection changes. */ + locked: boolean + /** Close the menu and focus its trigger synchronously before opening another overlay. */ + dismiss: () => void + /** Report a failed selection through the menu owner's persistent toast. */ + reportFailure: (message: string) => void + } + } /** * Non-interactive inline model label; selection controls remain owner-managed. * Without a contribution, the badge precedes the catalog name in the trigger diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index dbb5b247db..2807820dc5 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -7,6 +7,7 @@ import { SessionId } from '@deepseek-ai/dsh-session/types' import type { ModelSelection } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ComponentProps } from 'react' +import type { OwnerOf } from '@deepseek-ai/dsh-client-ui-slots' import type { ModelDirectoryState } from '../src/client/directory.ts' import { ModelSelect } from '../src/client/ModelSelect.tsx' import { en, zh } from '../src/client/locales.ts' @@ -98,6 +99,7 @@ describe('ModelSelect label slot', () => { }) await runtime.root.declare({ 'conversation.input.model.label': { kind: 'single', scope: 'session', inline: true }, + 'conversation.input.model.options': { kind: 'list', scope: 'session' }, }, ({ renderSlot, SessionProvider }) => @@ -1121,3 +1123,20 @@ it('restores the account model name after login without changing the saved route expect(screen.getByRole('button', { name: /选择模型,当前/ }).textContent).toBe('DeepSeek FlashHigh') expect(directory.getSnapshot().current).toEqual(selected) }) + + +it('dismisses contributed options with focus on the trigger before opening another overlay', () => { + const renderSlot = vi.fn((_name: string, _owner: object) => null) + render() + const trigger = screen.getByRole('button', { name: /^选择模型/ }) + fireEvent.click(trigger) + const options = renderSlot.mock.calls.find(call => call[0] === 'conversation.input.model.options') + if (options?.[0] !== 'conversation.input.model.options') throw new Error('Missing model options') + act(() => { + screen.getAllByRole('menuitem')[0]!.focus() + ;(options[1] as OwnerOf<'conversation.input.model.options'>).dismiss() + expect(document.activeElement).toBe(trigger) + }) + expect(screen.queryByRole('menu')).toBeNull() +}) diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 781b5d2a7c..737ae32b4f 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package: - en: 07da57fb0a0cb10e - zh: c743fd96317bec22 + en: 01ba764b51f010e8 + zh: e43a158a5b09d987 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle: en: cd4e17d62c407541 zh: 9610d3ee55c0e56a diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 03ed8e8c24..53a1c9a44e 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -31,7 +31,7 @@ Below 768px, the page uses 20px horizontal insets to align with the conversation [Ralph loops](../../experimental/ralph-bundle/README.md) and [Session search](../../experimental/session-search/README.md) remain bundled but are hidden from both plugin lists unless the profile enables or separately installs them. Existing selections remain manageable; switching off an installation-carried copy hides it again. Enter either exact package name in **Add plugin**, then choose **Enable now** to use the shipped copy without downloading it again. Sidebar session search is independent. LiveShare declares the same default-hidden intent in its bundle manifest (`dsh.bundle.listedByDefault: false`), so it stays out of both lists, including **More**, until a declared row runs or it is enabled. The page's manifest rule is generic: a bundle whose manifest does not declare `listedByDefault: false` is listed; one that does is listed only when the profile enables it, the installation carries it selected, or one of its declared rows runs; a hidden bundle is not opened by its ID either. -Select **Plugins** in the sidebar. The information button uses an 11px caption-colored icon in a 20px square hover area, with a 4px gap after the subtitle. It explains that this page configures official plugins and installs or manages other plugins, while **Settings → Built-in plugins** shows the built-in inventory and runtime status. Hover or focus shows the shared tooltip; clicking keeps it open until another click, Escape, Tab, or an outside click. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Built-in plugin settings** lists official configuration pages by their configured order: Subagent, web search, agent loop, then shell. **Experimental plugins** shows the primary optional bundles, with **More** beside its heading opening a separate page of the same title. That page lists the official bundles eligible for display, including those on the main list, with Codex and Claude Code subagents last. Its back button returns to the main list, where **Installed plugins** remains below Experimental plugins. Bundle details return to the list they were opened from. Catalog entries keep their position after installation or removal. **Installed plugins** lists other profile bundles. Installation-carried optional bundles have no uninstall action. Experimental packages retain the **Experimental** marker on both lists. Switching on a bundle that page holds back adds it to the main list in the position that page shows it, and switching it off leaves it on that page alone unless its manifest hides unselected bundles; other bundles keep their place on both. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Built-in plugins** section. +Select **Plugins** in the sidebar. The information button uses an 11px caption-colored icon in a 20px square hover area, with a 4px gap after the subtitle. It explains that this page configures official plugins and installs or manages other plugins, while **Settings → Built-in plugins** shows the built-in inventory and runtime status. Hover or focus shows the shared tooltip; clicking keeps it open until another click, Escape, Tab, or an outside click. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Built-in plugin settings** lists official configuration pages by their configured order: Subagent, web search and fetch, agent loop, then shell. **Experimental plugins** shows the primary optional bundles, with **More** beside its heading opening a separate page of the same title. That page lists the official bundles eligible for display, including those on the main list, with Codex and Claude Code subagents last. Its back button returns to the main list, where **Installed plugins** remains below Experimental plugins. Bundle details return to the list they were opened from. Catalog entries keep their position after installation or removal. **Installed plugins** lists other profile bundles. Installation-carried optional bundles have no uninstall action. Experimental packages retain the **Experimental** marker on both lists. Switching on a bundle that page holds back adds it to the main list in the position that page shows it, and switching it off leaves it on that page alone unless its manifest hides unselected bundles; other bundles keep their place on both. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Built-in plugins** section. As available header width decreases, Refresh and Add plugin first become icon-only controls with accessible names and tooltips; the tabs then shrink from their usual minimum width to fit their labels, and the action group wraps only when necessary. The information button follows the subtitle text. The main page header and list share one scroll container. Header controls move with the scroll position until they reach their compact positions; scrolling back follows the same coordinates. The header retains its resting layout height, so moving its controls does not change the list's scroll range. A barely scrolling list can stop before the header is fully compact. The More list's back-and-title row stays fixed, and detail pages scroll their head row with the content. The Installed/Discover tabs use the compact 34px control with a 2px inset around the selection indicator. The subtitle scrolls out of view; with tabs present, the title also scrolls out, and the tabs settle beside Refresh and Add plugin where space permits. On narrow pages, the tabs and icon actions share a row beneath the heading where they fit; otherwise the tabs stay above the actions. Their relative order stays the same throughout scrolling. The resting and compact layouts retain their 28px and 16px page-top insets respectively, with 16px below the compact controls. macOS adds the frame's titlebar clearance while resting; its compact bar keeps 16px above and below with the sidebar expanded, and with the sidebar collapsed it also clears the frame's top strip so the controls stay below the native window controls. Only on macOS, when the sidebar is collapsed, the compact controls keep 8px rather than 16px above them in addition to the unchanged frame clearance, at every width. The resting layout and compact bottom spacing stay unchanged. Windows relies on the frame's caption offset without adding it again. The divider spans the page at the bottom of the compact header; the list's initial top spacing scrolls with its content. Hover styling applies only where the pointer can hover, so a tap leaves no card, row, or menu option in its hover state; cards and icon actions answer a touch-down with the pressed fill instead. The install, registry, and filter fields take a 16px face under a coarse pointer, which keeps iOS Safari from zooming a focused field. @@ -39,7 +39,7 @@ Installed bundles and their plugin rows display their own title and description A bundle's page shows the bundle's detail document first among its sections, without a heading of its own: the bundle's long description in the current UI language, as Markdown with images. It is the Markdown file that `meta.detail` in the bundle's `locale/.json` names, otherwise the bundle's README, read by [`pluginManager.readBundleDetail`](../../boot/plugin-manager/README.md#bundle-detail-documents) in the [document order of `dsh-plugin-detail`](../../util/plugin-detail/README.md#document-order); the repository a bundle declares is not read. An installed bundle's document comes from its own files without network access, and an on-demand Official bundle's from the npm file CDNs at its offered version. While the document loads the section shows three skeleton lines; a bundle without a document shows no section; a failed read names its reason with **Retry**. Raw HTML is omitted, relative images load from the Host beside the document, relative links stay text, and a document taller than the section's height limit scrolls inside it. A bundle whose files are not installed, such as an on-demand Official bundle, is read from the npm file CDNs: its read first asks the Host's registry comparison and, when npmmirror answered fastest, reads it and its images from npmmirror first. An installed bundle's read does not wait for the comparison. Any profile change and a connection reset read shown documents again. -On-demand Official cards follow package discovery visibility and are available offline before installation. In Add plugin, a bare Official package name uses the Host’s matching-version or source-checkout target, retains the selected registry, and offers Enable now after installation. Turning one On opens the existing install progress dialog, installs the [target offered by the running Host](../../boot/plugin-manager/README.md#use-this-package), and selects its layer. The same registry fallback, cancellation, and script-approval controls apply. Turning Off keeps the dependency installed; Remove is separate. A different version or development link source offers **Update**; details show current and offered sources and versions. An update removes the installed package and installs the offered one with the same selection, reporting whether a restart is required; when the installation fails after the removal, the package stays uninstalled. Missing or unreadable packages keep their recorded dependency source alongside the diagnostic. An enabled bundle can still be switched Off when its checkout target is unavailable. +On-demand Official cards are visible offline before installation. In Add plugin, a bare Official package name uses the Host’s matching-version or source-checkout target, retains the selected registry, and offers Enable now after installation. Turning one On opens the existing install progress dialog, installs the [target offered by the running Host](../../boot/plugin-manager/README.md#use-this-package), and selects its layer. The same registry fallback, cancellation, and script-approval controls apply. Turning Off keeps the dependency installed; Remove is separate. A different version or development link source offers **Update**; details show current and offered sources and versions. An update removes the installed package and installs the offered one with the same selection, reporting whether a restart is required; when the installation fails after the removal, the package stays uninstalled. Missing or unreadable packages keep their recorded dependency source alongside the diagnostic. An enabled bundle can still be switched Off when its checkout target is unavailable. The first list read shows a short group-heading placeholder and four skeleton cards, using the real cards' row, icon, and text-line spacing, with the action area left blank. They use the same opacity pulse as Session search and remain static with reduced motion. The accessible loading label and read timing are unchanged; refreshes retain the existing list, and installation and component-loading indicators keep their own states. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index b0dedeaad9..8b6afcd74b 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -31,7 +31,7 @@ kind: "package-reference" [Ralph 循环](../../experimental/ralph-bundle/README.zh.md)和[会话搜索](../../experimental/session-search/README.zh.md)仍随包提供,但默认不在两个插件列表中展示,只有 profile 启用或另行安装后才显示。已有的启用状态仍可管理;关闭随安装提供的版本后,它会再次隐藏。在**添加插件**中输入准确包名,再选择**立即启用**,即可使用随包提供的版本,无需再次下载。侧栏会话搜索独立于此功能。LiveShare 在其 bundle manifest 中声明相同的默认隐藏意图(`dsh.bundle.listedByDefault: false`),因此在自身声明的行开始运行或被启用前,它不会出现在两个列表(含**更多**页)中。页面的 manifest 规则是通用的:未声明 `listedByDefault: false` 的组合包会列出;声明了的,只有在 profile 启用、安装随附版本被选中,或它声明的某一行开始运行时才列出;隐藏的组合包也不能通过 ID 打开详情。 -在侧栏选择**插件**。信息按钮使用 11px 的 caption 色图标和 20px 方形悬停区域,与副标题间隔 4px。它说明当前页用于配置官方插件、安装和管理其他插件,**设置 → 内置插件**则展示内置插件列表及运行状态。悬停或聚焦显示共享 tooltip;点击后保持展开,直至再次点击、Escape、Tab 或外部点击。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置插件配置**按配置的顺序列出官方配置页:子智能体、网页搜索、Agent 循环、终端。**实验性插件**展示主要的可选组合包,标题旁的**更多**打开同名的独立页面。该页列出符合展示条件的官方组合包,也包含主列表已有的条目,Codex 和 Claude Code 子智能体排在最后。返回按钮回到主列表,**已安装插件**保持在实验性插件下方。组合包详情返回打开它的列表。目录条目在安装或移除后保持原位置。**已安装插件**列出其他 profile 组合包。安装随附的可选组合包没有卸载操作。实验性包在两个列表中均保留**实验性**标签。开启仅在该页列出的组合包会按该页中的位置把它加入主列表,关闭后它只留在该页,但 manifest 声明默认隐藏的组合包会从该页消失;其他组合包在两个列表中均保持原位。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**内置插件**分区中编辑。 +在侧栏选择**插件**。信息按钮使用 11px 的 caption 色图标和 20px 方形悬停区域,与副标题间隔 4px。它说明当前页用于配置官方插件、安装和管理其他插件,**设置 → 内置插件**则展示内置插件列表及运行状态。悬停或聚焦显示共享 tooltip;点击后保持展开,直至再次点击、Escape、Tab 或外部点击。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置插件配置**按配置的顺序列出官方配置页:子智能体、网页搜索与获取、Agent 循环、终端。**实验性插件**展示主要的可选组合包,标题旁的**更多**打开同名的独立页面。该页列出符合展示条件的官方组合包,也包含主列表已有的条目,Codex 和 Claude Code 子智能体排在最后。返回按钮回到主列表,**已安装插件**保持在实验性插件下方。组合包详情返回打开它的列表。目录条目在安装或移除后保持原位置。**已安装插件**列出其他 profile 组合包。安装随附的可选组合包没有卸载操作。实验性包在两个列表中均保留**实验性**标签。开启仅在该页列出的组合包会按该页中的位置把它加入主列表,关闭后它只留在该页,但 manifest 声明默认隐藏的组合包会从该页消失;其他组合包在两个列表中均保持原位。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**内置插件**分区中编辑。 页头可用宽度缩小时,“刷新”和“添加插件”先变为保留可访问名称与 tooltip 的图标按钮;标签再从常规最小宽度收紧为文案所需宽度,仍不足时操作组才换行。信息按钮紧随副标题文字。主页面的页头和列表共用一个滚动容器。页头控件随滚动位置移动,到达紧凑态位置后停住;反向滚动沿同样的坐标返回。页头保留静止态的布局高度,因此控件移动不会改变列表的滚动范围。只能滚动少量距离的列表可能在页头完全收拢前到达底部。“更多”列表的返回与标题行固定,详情页的页头随内容滚动。“已安装/发现”标签使用总高 34px 的紧凑控件,选中指示块与外框之间留 2px。副标题随滚动离开视口;有标签时标题也随之滚出,标签在空间足够时停靠到“刷新”“添加插件”旁边。窄页面中,标签与图标操作优先在标题下方共用一行;放不下时,标签始终在操作组上方。滚动过程中两组控件的相对顺序保持不变。静止态与紧凑态分别保留 28px 和 16px 的页面顶部留白,紧凑态控件下方保留 16px。macOS 静止态保留框架的标题栏安全区;折叠态在侧栏展开时上下各留 16px,侧栏收起时再避让框架顶部安全区,使控件位于原生窗口控件下方。仅在 macOS 侧栏收起时,紧凑态控件在原有框架安全区之外的顶部留白统一由 16px 减为 8px,不受窗口宽度影响;静止态布局与紧凑态底部留白均不变。Windows 使用框架已有的标题栏偏移,不重复叠加。分隔线在紧凑页头底部横跨页面,列表初始的顶部留白随内容滚动。悬停样式只在可悬停的指针下生效,点按不会让卡片、行或菜单项停留在悬停态;卡片与图标操作改以按下填充回馈触摸。安装、注册表与筛选输入框在粗指针下使用 16px 字号,iOS Safari 不会因聚焦而放大页面。 @@ -39,7 +39,7 @@ kind: "package-reference" 组合包页面在其各部分中首先显示该组合包的详细介绍文档,不带单独的标题:以当前界面语言、带图片的 Markdown 显示该组合包的详细介绍。该文档是组合包 `locale/.json` 中 `meta.detail` 指定的 Markdown 文件,没有时为组合包的 README,由 [`pluginManager.readBundleDetail`](../../boot/plugin-manager/README.zh.md#bundle-detail-documents) 按 [`dsh-plugin-detail` 的文档顺序](../../util/plugin-detail/README.zh.md#document-order)读取;不读取组合包声明的仓库。已安装组合包的文档来自其自身文件,不访问网络;按需官方组合包的文档按其提供的版本从 npm 文件 CDN 读取。文档加载期间,该部分显示三行骨架屏;没有文档的组合包不显示该部分;读取失败时说明原因,并提供**重试**。原始 HTML 会被省略,相对路径的图片从 Host 上该文档旁的位置加载,相对链接保持为文本,文档高度超过该部分的高度上限时在该部分内滚动。文件未安装的组合包(例如按需官方组合包)从 npm 文件 CDN 读取:读取前先询问 Host 的注册表比较,npmmirror 响应最快时,先从 npmmirror 读取该文档及其图片。已安装组合包的读取不等待该比较。任何 profile 变化和连接重置都会重新读取已显示的文档。 -官方按需卡片遵循包的显示声明,在安装前即可离线读取。在“添加插件”中输入不带版本的官方包名时,使用 Host 提供的匹配版本或源码 checkout 目标,保留用户选择的安装源,并在安装后提供“立即启用”。开启会打开已有的安装进度对话框,安装[当前 Host 提供的目标](../../boot/plugin-manager/README.zh.md#use-this-package)并选择配置层。安装源回退、取消和脚本审批继续使用同一组控件。关闭保留依赖;移除是独立操作。版本或开发链接来源不同会提供**更新**;详情显示当前及所提供的来源和版本。更新先删除已安装的包,再以相同的选择状态安装所提供的包,并报告是否需要重启;删除之后安装失败时,该包保持未安装。文件缺失或不可读的包在诊断旁保留记录的依赖来源。已启用的 bundle 在 checkout 目标不可用时仍可关闭。 +官方按需卡片在安装前即可离线显示。在“添加插件”中输入不带版本的官方包名时,使用 Host 提供的匹配版本或源码 checkout 目标,保留用户选择的安装源,并在安装后提供“立即启用”。开启会打开已有的安装进度对话框,安装[当前 Host 提供的目标](../../boot/plugin-manager/README.zh.md#use-this-package)并选择配置层。安装源回退、取消和脚本审批继续使用同一组控件。关闭保留依赖;移除是独立操作。版本或开发链接来源不同会提供**更新**;详情显示当前及所提供的来源和版本。更新先删除已安装的包,再以相同的选择状态安装所提供的包,并报告是否需要重启;删除之后安装失败时,该包保持未安装。文件缺失或不可读的包在诊断旁保留记录的依赖来源。已启用的 bundle 在 checkout 目标不可用时仍可关闭。 首次读取列表时,页面显示一条较短的分组标题占位和四行骨架卡片,沿用真实卡片的行、图标和文字间距,操作区域留白。骨架使用与 Session 搜索相同的透明度呼吸动效,在减少动态效果模式下保持静止。无障碍加载文案和读取时机不变;刷新时保留已有列表,安装及组件加载仍使用各自的状态提示。 diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index 29dd9ffd63..79c530ccf4 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -735,14 +735,14 @@ describe('PluginManagerPage', () => { pkg({ name: '@acme/dsh-tool', enabled: false }), // Selected by the profile but not a bundle: a problem the person can switch off, in the profile's own group. pkg({ name: 'dsh-selected', installed: false, error: { code: 'not-bundle' } }), - pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', installed: false, optional: true, removable: false, enabled: false }), + pkg({ name: '@deepseek-ai/dsh-experimental-voice-input-bundle', installed: false, optional: true, removable: false, enabled: false }), ], busy: ['dsh-protected'], }) const cards = screen.getAllByRole('listitem') // Offered extensions precede installed bundles. expect(cards.map(card => card.getAttribute('data-plugin-package'))).toEqual([ - '@deepseek-ai/dsh-experimental-agent-team-profile', 'dsh-better-sidebar', 'dsh-broken', 'dsh-protected', '@acme/dsh-tool', 'dsh-selected', + '@deepseek-ai/dsh-experimental-voice-input-bundle', 'dsh-better-sidebar', 'dsh-broken', 'dsh-protected', '@acme/dsh-tool', 'dsh-selected', ]) expect(cards.map(card => card.getAttribute('data-plugin-status'))).toEqual(['disabled', 'running', 'problem', 'running', 'disabled', 'problem']) // Group titles have no counts; the official bundle retains its experimental marker. @@ -776,11 +776,11 @@ describe('PluginManagerPage', () => { ].map(name => pkg({ name })), pkg({ name: '@acme/dsh-base', readOnlyReason: 'management-required' }), pkg({ name: 'dsh-better-sidebar' }), - pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', installed: false, optional: true, removable: false }), + pkg({ name: '@deepseek-ai/dsh-experimental-voice-input-bundle', installed: false, optional: true, removable: false }), ], }) expect(screen.getAllByRole('listitem').map(card => card.getAttribute('data-plugin-package'))).toEqual([ - '@deepseek-ai/dsh-experimental-agent-team-profile', '@acme/dsh-base', 'dsh-better-sidebar', + '@deepseek-ai/dsh-experimental-voice-input-bundle', '@acme/dsh-base', 'dsh-better-sidebar', ]) expect(document.querySelector('[data-plugin-count]')).toBeNull() }) @@ -789,7 +789,7 @@ describe('PluginManagerPage', () => { const name = '@deepseek-ai/dsh-experimental-terminal-bundle' const b = renderTab({ packages: [ pkg({ name, official: true, enabled, rows: [row()] }), - pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', official: true }), + pkg({ name: '@deepseek-ai/dsh-experimental-voice-input-bundle', official: true }), ] }) expect(document.querySelector(`[data-plugin-package="${name}"]`)).toBeNull() fireEvent.click(screen.getByRole('button', { name: en.moreAction })) @@ -807,7 +807,7 @@ describe('PluginManagerPage', () => { it('keeps a shipped bundle out of both lists until one of its declared rows runs', () => { const hidden = '@deepseek-ai/dsh-experimental-liveshare' - const shown = '@deepseek-ai/dsh-experimental-agent-team-profile' + const shown = '@deepseek-ai/dsh-experimental-voice-input-bundle' const offer = (rows: readonly PackageRow[]): PackageView => pkg({ name: hidden, official: true, installed: false, optional: true, removable: false, enabled: false, listedByDefault: false, rows, }) @@ -881,16 +881,16 @@ describe('PluginManagerPage', () => { }) it('opens an official bundle\'s page with its beta tag and no uninstall, and switches it on', () => { - const title = 'Agent Teams' + const title = 'Voice input' const { actions } = renderTab({ - packages: [pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', meta: { title }, installed: false, optional: true, removable: false, enabled: false })], + packages: [pkg({ name: '@deepseek-ai/dsh-experimental-voice-input-bundle', meta: { title }, installed: false, optional: true, removable: false, enabled: false })], }) fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', title) })) const detail = document.querySelector('[data-plugin-detail]') as HTMLElement expect(within(detail).getByText(en.statusBeta)).toBeTruthy() expect(within(detail).queryByRole('button', { name: en.uninstallLabel.replace('{name}', title) })).toBeNull() fireEvent.click(within(detail).getByRole('switch', { name: en.enableToggle.replace('{name}', title) })) - expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith('@deepseek-ai/dsh-experimental-agent-team-profile', true) + expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith('@deepseek-ai/dsh-experimental-voice-input-bundle', true) }) it('keeps the persisted selection while marking a failed component as a problem', () => { @@ -915,7 +915,7 @@ describe('PluginManagerPage', () => { }) it.each([ - ['@deepseek-ai/dsh-experimental-agent-team-profile', true], + ['@deepseek-ai/dsh-experimental-voice-input-bundle', true], ['@deepseek-ai/dsh-experimental-auto-review', true], ['@deepseek-ai/dsh-experimental-fixture-input', false], ['@deepseek-ai/dsh-cua-bundle', true], @@ -1198,11 +1198,11 @@ describe('PluginManagerPage', () => { } it('keeps configuration and installed plugins on the main list and shows eligible official plugins on the secondary page', () => { - const team = '@deepseek-ai/dsh-experimental-agent-team-profile' + const team = '@deepseek-ai/dsh-experimental-voice-input-bundle' renderTab( { packages: [ pkg({ name: '@deepseek-ai/dsh-experimental-badge-skill-bundle', official: true, enabled: false }), - pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', installed: false, optional: true, removable: false, enabled: false }), + pkg({ name: '@deepseek-ai/dsh-experimental-voice-input-bundle', installed: false, optional: true, removable: false, enabled: false }), pkg({ name: '@deepseek-ai/dsh-subagent-claude-code', official: true, availability: 'missing', installed: false, enabled: false }), pkg({ name: '@deepseek-ai/dsh-subagent-codex', official: true, enabled: false }), pkg({ name: '@deepseek-ai/dsh-experimental-tool-worktree', official: true, enabled: false }), @@ -1223,7 +1223,7 @@ describe('PluginManagerPage', () => { expect(within(extensions).getByRole('heading', { name: en.extensionsTitle })).toBeTruthy() expect(within(extensions).getByRole('button', { name: en.moreAction }).textContent).toBe(en.moreAction) expect(within(extensions).getAllByRole('listitem').map(card => card.getAttribute('data-plugin-package'))) - .toEqual(['@deepseek-ai/dsh-experimental-agent-team-profile', '@acme/new-official']) + .toEqual(['@deepseek-ai/dsh-experimental-voice-input-bundle', '@acme/new-official']) expect(document.querySelector('[data-plugin-count]')).toBeNull() expect(within(basic).getByText('Limits every command.')).toBeTruthy() expect(within(basic).queryByRole('switch', { name: en.enableToggle.replace('{name}', 'Shell') })).toBeNull() @@ -1270,7 +1270,6 @@ describe('PluginManagerPage', () => { }) it.each([ - '@deepseek-ai/dsh-cua-bundle', '@deepseek-ai/dsh-experimental-session-search', '@deepseek-ai/dsh-experimental-ralph-bundle', '@acme/shipped-exploration', @@ -1378,7 +1377,7 @@ describe('PluginManagerPage', () => { '@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-agent-team-profile' + const team = '@deepseek-ai/dsh-experimental-voice-input-bundle' const second = '@deepseek-ai/dsh-experimental-cot-translation-bundle' const entry = (name: string, enabled: boolean): PackageView => pkg({ name, official: true, enabled }) const b = renderTab({ packages: [entry(second, true), entry(team, true), entry(first, false)] }) diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts index 67b95bded7..dd8cb347e7 100644 --- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts @@ -1979,10 +1979,10 @@ const CATALOG: BundleInfo = { installTarget: { spec: '@deepseek-ai/dsh-subagent-codex@2.0.0', version: '2.0.0' }, } -it.each(['registry', 'source'] as const)('installs a typed default-hidden Official name using its %s target', async (mode) => { - const name = '@deepseek-ai/dsh-cua-bundle' - const spec = mode === 'source' ? 'link:/workspace/cua-bundle' : `${name}@2.0.0` - const entry = { ...CATALOG, name, listedByDefault: false, installTarget: { spec, version: '2.0.0' } } +it.each(['registry', 'source'] as const)('installs a typed Official catalog name using its %s target', async (mode) => { + const name = CATALOG.name + const spec = mode === 'source' ? 'link:/workspace/subagent-codex' : `${name}@2.0.0` + const entry = { ...CATALOG, installTarget: { spec, version: '2.0.0' } } const { controller, plugins, face, state } = bench({ listBundles: vi.fn().mockResolvedValue(ok([entry])) }) await controller.load() face.openInstall() diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 6bdac6f1ab..9b127ea88a 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -18,14 +18,14 @@ en: ead6be55c7c9d7cf zh: 125ad36db7328551 /deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog: - en: cfdc59fa1a077ff7 - zh: 4e6dcca712ba6565 + en: 8c99c97e369192cb + zh: f263851ad2a7ba2e /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: en: 0d756c1e8207181d zh: 921ce997c6b14823 /deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output: - en: 3271871a42bd52c8 - zh: 89b7a9f03018454d + en: e2649937f077d4e0 + zh: 6ad604cb5adfc16b /deepseek-ai-dsh-client-ui-primitives/use-this-package/localizing-copy: en: 81e58c7b342baf3c zh: 6cd47b16124b215c @@ -54,8 +54,8 @@ en: ca75c51c89c2c9b0 zh: de54f3467ef3b9be /deepseek-ai-dsh-client-ui-primitives/known-limitations-and-deferred-work: - en: 6b4d21406db53e7c - zh: aa130102a8f7cd13 + en: 433ff539bd3d16b3 + zh: fcb37c1fbe503620 /deepseek-ai-dsh-client-ui-primitives/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 1e38950360..1fcc2320df 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -45,7 +45,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `Button` | Clickable action; `variant` selects `primary`, `ghost`, `outline`, or `toolbar`. Outlined actions use an inset hairline. Its ref targets the native button for focus and overlay anchoring. | | `Switch` | Two-state toggle, 36×20. The off-state thumb reads `--dsw-alias-switch-thumb` so it stays light in both themes, and the on-state thumb contrasts with the brand track. Disabled controls use half opacity. `label` is required, so the control cannot ship unnamed. | | `SegmentedControl` | Tablist of two or more equal-width segments with one sliding indicator, for switching a card or panel between a few modes; the owner holds the selection and `label` names the list. `id` seeds each tab's id (`-`) and the panel it controls (`--panel`), which the owner renders and points back at the tab with `aria-labelledby`; a segment may be `disabled` with a `title`, and `disabled` on the control locks every segment while the shown panel has work in flight. | -| `Checkbox` | Labeled native checkbox with controlled state, keyboard interaction, and disabled styling; the caller supplies localized `label` text. | +| `Checkbox` | Labeled native checkbox with controlled state, keyboard interaction, and disabled styling; the caller supplies localized `label` text. `modalAutofocus` marks the input for its owning Modal's initial focus without moving focus itself. | | `Input` | Single-line text entry for search boxes and inline forms. Its ref targets the native input for focus and is cleared on unmount. | | `InlineEditor` | Autofocused multi-line inline editor for short drafts such as queued messages and goals. It grows with its text and its width up to six lines, then scrolls; Enter saves, Shift+Enter breaks the line, Enter during IME composition does nothing, and Escape cancels. The caller supplies the localized `label` and rebinds the scrollbar on an elevated surface. | | `ZoomControls`, `DOCUMENT_ZOOM` | Floating document zoom controls with localized labels, fixed presets, and visibility/activity callbacks. An omitted `fit` hides the fitting row. The toolbar carries `data-document-zoom-controls`, plus `data-document-zoom-visible` while visible. An owner `className` overrides `bottom` and `max-width` regardless of stylesheet order; `inset-inline-start` and `transform` center the toolbar and move it on enter and exit, `opacity` fades it, and all three stay with the primitive. Renderers share the published scale limits. | @@ -58,11 +58,11 @@ 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. | -| `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. | +| `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. | +| `RiskConfirmation` | Sensitive action gated behind an explicit checkbox, using the shared warning-title layout. | | `Tooltip` | Hover and keyboard-focus text anchored to a cloned child; optional `delayMs` controls hover delay and `focusDelayMs` controls keyboard-focus delay, both defaulting to 0 ms. Optional `portal` rendering escapes clipping containers and ancestor stacking contexts that cap the bubble's z-index. | | `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. | @@ -115,7 +115,7 @@ The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and Explicit `./`, `../`, slash/backslash-rooted paths (including UNC), and Windows drive paths such as `C:/work/file.ts` take precedence over URL parsing. Otherwise, destinations accepted by the absolute URL parser remain URLs: `index.ts:24`, `Makefile:12`, and `localhost:3000` are links, without adding a protocol or testing whether they can open. Use `./index.ts:24` for a file reference. Other destinations are paths. Authored links reject `javascript:`, `vbscript:`, `data:`, and `blob:`; other parseable protocols retain native navigation. HTTP(S) links retain delegated navigation and modified-click behavior. No click-time failure notice is added. -`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. Strikethrough requires `~~`; a single `~`, as in `10~20`, stays literal. A bare URL ends before non-ASCII punctuation or symbols, such as `。` or `,`, so CJK prose that follows it stays outside the link. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. `omitHtml` renders raw HTML as nothing instead of literal text, for documents written for HTML renderers. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. Rewritten images accept HTTP(S), data, blob, and the Desktop `dsh-app://app/api/file`, `dsh-app://app/api/plugin-market/detail-file`, and `dsh-app://app/api/plugin-manager/detail-file` routes; authored Desktop URLs remain inert. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty; `fileImages` additionally supplies a localized failure prefix. Changing the image source permits a fresh load. Each mounted image appends a `load` query parameter to a resolved Host `api/file` URL, because browsers reuse a same-URL image within one document despite `no-store`; a newly rendered image of an overwritten file therefore loads its current bytes, and every remount requests the file again; the Host file route redirects that request to a cacheable URL for the current file version. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `UserTextRun` renders uninterpreted text and `ReferenceChip` renders business-resolved labels. An explicit `onActivate` makes a chip a keyboard-accessible button; repeated pointer clicks and an existing text selection do not activate it, while keyboard activation remains available. Labels inherit their host’s wrapping policy. Recognition and JSON transforms belong to [ui-conversation](../ui-conversation/README.md#sent-user-text). +`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. Copying a selection that lies inside one `MarkdownText` document and includes rendered math puts each whole formula on the clipboard as TeX source in both plain text and HTML, delimited as `$…$` inline and `$$…$$` for display math. Strikethrough requires `~~`; a single `~`, as in `10~20`, stays literal. A bare URL ends before non-ASCII punctuation or symbols, such as `。` or `,`, so CJK prose that follows it stays outside the link. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. `omitHtml` renders raw HTML as nothing instead of literal text, for documents written for HTML renderers. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. Rewritten images accept HTTP(S), data, blob, and the Desktop `dsh-app://app/api/file`, `dsh-app://app/api/plugin-market/detail-file`, and `dsh-app://app/api/plugin-manager/detail-file` routes; authored Desktop URLs remain inert. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty; `fileImages` additionally supplies a localized failure prefix. Changing the image source permits a fresh load. Each mounted image appends a `load` query parameter to a resolved Host `api/file` URL, because browsers reuse a same-URL image within one document despite `no-store`; a newly rendered image of an overwritten file therefore loads its current bytes, and every remount requests the file again; the Host file route redirects that request to a cacheable URL for the current file version. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `UserTextRun` renders uninterpreted text and `ReferenceChip` renders business-resolved labels. An explicit `onActivate` makes a chip a keyboard-accessible button; repeated pointer clicks and an existing text selection do not activate it, while keyboard activation remains available. Labels inherit their host’s wrapping policy. Recognition and JSON transforms belong to [ui-conversation](../ui-conversation/README.md#sent-user-text). `MarkdownText` defaults to `variant="body"`. Use `variant="compact"` for secondary content: its 13px text and 20px line height follow the content-size setting, all heading levels use the same size with weight 600, and paragraphs and lists use tighter spacing. Text, links, and code keep the tertiary color; dotted underlines distinguish links. Code headers scroll with their blocks. Tables and math stay enabled at the surrounding text size and scroll horizontally within the available width. Both variants share the parser and streaming cache. `textInteractionSource` marks its existing root as a source for text selection interactions. @@ -230,6 +230,7 @@ These limits define how the atoms behave at the edges; they are current package - **Known-site marks are a fixed list** — only the named hosts resolve to their own mark, and every other external host keeps the globe; recognizing an arbitrary site would require fetching its icon over the network. - **Streaming defers cross-boundary reference resolution** — a reference-style link or footnote whose definition sits on the other side of the incremental freeze boundary renders as literal text while the reply streams; the settled full parse at finalize resolves it. - **A long highlighted fence retains its complete token DOM** — streaming avoids re-parsing, re-tokenizing, and reconciling the completed prefix, but it does not discard old colors or virtualize token spans. Final DOM cardinality therefore still follows the fence's token count; nested/container fences and a pathological single long line remain on the general tail path. +- **Math copies as TeX only within one Markdown document** — a selection that reaches outside its `MarkdownText` root, such as from a user message into a reply, keeps the browser's native copy, which joins KaTeX's hidden MathML text with the visible glyphs. Inside one document, the plain text of non-math content comes from `innerText`, so code-block language labels and copy-button text are included, as in native copy. - **Overlong lines stay uncolored** — a source line of 1,000 or more UTF-16 units renders as one plain run in every highlighted surface, because TextMate scanning cost grows superlinearly with line length and a single generated line can otherwise block the page for seconds. The grammar state skips that line, so a comment or template literal that opens or closes on it mis-colors the following lines. Highlighting still runs synchronously during render, so a file with thousands of ordinary lines costs proportionally more. - **Bare URLs end before non-ASCII punctuation** — the [tokenizer patch](../../../patches/micromark-extension-gfm-autolink-literal@2.1.0.patch) and the [transform patch](../../../patches/mdast-util-gfm-autolink-literal@2.0.1.patch) end a GFM autolink literal at non-ASCII punctuation, symbols, or whitespace, so a URL with an unencoded character such as `(` or `・` in its path links only up to it; write such a URL as `<…>` or `[…](…)`. A URL followed directly by CJK letters still links them, because paths such as `/wiki/中文` are valid. Both patches are keyed to exact versions: an upgrade of either package must re-apply or retire its patch, otherwise the boundary rule silently disappears. - **The Go struct-field rule is patched locally** — when the Go grammar loads, its repeated one-line struct-field rule is replaced with the rule [worlpaker/go-syntax#25](https://github.com/worlpaker/go-syntax/pull/25) merged, because the vendored rule backtracks exponentially on lines such as a brace-bearing comment after `struct {`. If a dependency upgrade removes that rule, loading the grammar throws and Go code renders as plain text; delete the patch once `@shikijs/langs` ships the upstream rule. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index d7c8b68585..bd098df27e 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -45,7 +45,7 @@ kind: "package-library" | `Button` | 可点击操作;`variant` 选择 `primary`、`ghost`、`outline` 或 `toolbar`。描边操作使用内侧细线。ref 指向原生按钮,供焦点控制与浮层锚定使用。 | | `Switch` | 36×20 的双态开关。关闭态滑块读取 `--dsw-alias-switch-thumb`,在两种主题下均保持亮色;开启态滑块与品牌色轨道形成对比。禁用控件使用半透明样式。`label` 必填,控件不可能在没有名称的情况下发布。 | | `SegmentedControl` | 两段或更多等宽分段加一个滑动指示块的 tablist,用于在几种模式间切换一张卡片或面板;选中项由调用方持有,`label` 为列表命名。`id` 派生每个 tab 的 id(`-`)及其控制的面板 id(`--panel`),面板由调用方渲染并用 `aria-labelledby` 指回 tab;分段可 `disabled` 并带 `title`,控件级 `disabled` 在当前面板有进行中的操作时锁住全部分段。 | -| `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。 | +| `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。`modalAutofocus` 将输入标记为所属 Modal 的初始焦点,本身不会移动焦点。 | | `Input` | 单行文本输入,用于搜索框与行内表单。ref 指向原生输入框,供焦点控制使用,并在卸载时清空。 | | `InlineEditor` | 自动聚焦的多行行内编辑器,用于排队消息、goal 等短草稿。它随文本和宽度增高,最多六行,之后滚动;Enter 保存,Shift+Enter 换行,输入法组合中的 Enter 不生效,Escape 取消。调用方提供本地化 `label`,并在 elevated 表面上重绑滚动条。 | | `ZoomControls`, `DOCUMENT_ZOOM` | 悬浮文档缩放控件,接收本地化标签、固定预设,以及可见性和交互状态回调。省略 `fit` 时不显示适配项。工具栏带 `data-document-zoom-controls`,可见时另带 `data-document-zoom-visible`。owner 的 `className` 覆盖 `bottom` 与 `max-width`,不受样式表加载顺序影响;`inset-inline-start` 和 `transform` 负责居中与进出场位移,`opacity` 负责淡入淡出,三者都由原语保留。渲染器共用导出的缩放范围。 | @@ -58,11 +58,11 @@ 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}`。 | -| `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 | +| `Modal` | 页面遮罩之上的居中对话框。嵌套对话框可通过 `onKeyDownCapture` 在文档级 Escape 处理器之前拦截按键。 色层与弹窗淡入,背景模糊始终完整生效,并遵循减少动态效果偏好。调用方已模糊源页面时设置 `backdropBlur={false}`。`tone="warning"` 在标题中显示橙色警告图标,并移除正文额外的顶部间距;其他弹窗的间距保持不变。 | +| `RiskConfirmation` | 以显式复选框把关的敏感操作确认,采用共用的警告标题布局。 | | `Tooltip` | 锚定在克隆子元素上的悬停与键盘聚焦提示;可选的 `delayMs` 控制悬停延迟,`focusDelayMs` 控制键盘聚焦延迟,均默认为 0 毫秒。可通过 `portal` 渲染到外层,避免被容器裁剪,或受祖先层叠上下文限制其 z-index。 | | `HoverCard`、`HoverCardGroup` | 可阅读、可选带复制按钮的悬停预览;物理列表可选择让卡片共享首次显示等待,并互斥显示。 | | `DraggableImage` | 通过显式传递与反馈回调提供原图拖放。 | @@ -115,7 +115,7 @@ kind: "package-library" 明确的 `./`、`../`、斜杠或反斜杠开头的路径(包括 UNC),以及 `C:/work/file.ts` 这样的 Windows 盘符路径优先于 URL 解析。其余目标若能被绝对 URL 解析器接受,就保留为 URL:`index.ts:24`、`Makefile:12` 和 `localhost:3000` 均为链接,不补协议,也不预先检查能否打开。文件引用使用 `./index.ts:24`。其他目标视为路径。正文链接禁止 `javascript:`、`vbscript:`、`data:` 和 `blob:`;其余可解析的协议保留原生导航。HTTP(S) 链接保留委托导航和修饰键点击行为。不添加点击后的失败提示。 -`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。删除线需要 `~~`;单个 `~`(如 `10~20`)保持字面文本。裸 URL 在非 ASCII 标点或符号(如 `。`、`,`)前结束,紧随其后的中文正文不会并入链接。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。`omitHtml` 不渲染原始 HTML,而不是将其显示为原样文本,用于为 HTML 渲染器编写的文档。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。重写后的图片支持 HTTP(S)、data、blob 及桌面端 `dsh-app://app/api/file`、`dsh-app://app/api/plugin-market/detail-file` 与 `dsh-app://app/api/plugin-manager/detail-file` 路由;正文直接书写的桌面端 URL 仍保持惰性。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径;`fileImages` 还提供本地化的失败提示前缀。图片源变化后可重新加载。每个挂载的图片会为解析得到的 Host `api/file` 地址追加 `load` 查询参数,因为浏览器在同一文档内会无视 `no-store` 复用相同 URL 的图片;因此覆盖后的文件在新渲染的图片中会加载当前内容,每次重新挂载都会重新请求该文件;Host 文件路由会把该请求重定向到当前文件版本的可缓存地址。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`UserTextRun` 渲染不做解析的文本,`ReferenceChip` 渲染业务已解析的标签。显式传入 `onActivate` 后,胶囊成为可由键盘操作的按钮;重复指针点击和已有文本选区不触发激活,键盘激活仍然可用。标签继承所属容器的换行规则。识别和 JSON 变换由 [ui-conversation](../ui-conversation/README.zh.md#sent-user-text) 负责。 +`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。复制位于同一个 `MarkdownText` 文档内且包含已渲染公式的选区时,每个完整公式会以 TeX 源码同时写入剪贴板的纯文本和 HTML 格式,行内公式用 `$…$` 包裹,展示公式用 `$$…$$` 包裹。删除线需要 `~~`;单个 `~`(如 `10~20`)保持字面文本。裸 URL 在非 ASCII 标点或符号(如 `。`、`,`)前结束,紧随其后的中文正文不会并入链接。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。`omitHtml` 不渲染原始 HTML,而不是将其显示为原样文本,用于为 HTML 渲染器编写的文档。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。重写后的图片支持 HTTP(S)、data、blob 及桌面端 `dsh-app://app/api/file`、`dsh-app://app/api/plugin-market/detail-file` 与 `dsh-app://app/api/plugin-manager/detail-file` 路由;正文直接书写的桌面端 URL 仍保持惰性。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径;`fileImages` 还提供本地化的失败提示前缀。图片源变化后可重新加载。每个挂载的图片会为解析得到的 Host `api/file` 地址追加 `load` 查询参数,因为浏览器在同一文档内会无视 `no-store` 复用相同 URL 的图片;因此覆盖后的文件在新渲染的图片中会加载当前内容,每次重新挂载都会重新请求该文件;Host 文件路由会把该请求重定向到当前文件版本的可缓存地址。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`UserTextRun` 渲染不做解析的文本,`ReferenceChip` 渲染业务已解析的标签。显式传入 `onActivate` 后,胶囊成为可由键盘操作的按钮;重复指针点击和已有文本选区不触发激活,键盘激活仍然可用。标签继承所属容器的换行规则。识别和 JSON 变换由 [ui-conversation](../ui-conversation/README.zh.md#sent-user-text) 负责。 `MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。 `textInteractionSource` 将已有根元素标记为文本选区交互的来源。 @@ -230,6 +230,7 @@ kind: "package-library" - **已知站点标记是固定列表**:只有列名的主机解析为自己的标记,其余外部主机仍使用地球;要识别任意站点需要通过网络抓取它的图标。 - **流式期间跨边界引用解析被推迟**:定义落在增量冻结边界另一侧的引用式链接或脚注,在回复流式输出期间渲染为字面文本;定稿时的全量解析会将其解析。 - **长高亮 fence 会保留完整 token DOM**:流式路径避免重新解析、重新 tokenize 和 reconcile 已完成前缀,但不会丢弃旧颜色或虚拟化 token span。因此最终 DOM 数量仍随 fence 的 token 数增长;嵌套/容器内 fence 与病态的单个超长行仍走通用尾部路径。 +- **公式仅在单个 Markdown 文档内复制为 TeX**:超出其 `MarkdownText` 根节点的选区(例如从用户消息延伸到回复)沿用浏览器原生复制,会将 KaTeX 隐藏的 MathML 文本与可见字形拼接在一起。在单个文档内,非公式内容的纯文本取自 `innerText`,因此与原生复制一样,会包含代码块语言标签和复制按钮文本。 - **超长行不着色**:所有高亮界面中,长度达到 1,000 个 UTF-16 单元的源码行渲染为一段纯文本,因为 TextMate 扫描成本随行长超线性增长,单个生成行否则会阻塞页面数秒。语法状态会跳过该行,因此在该行开始或结束的注释、模板字符串会使后续行着色错误。高亮仍在渲染期间同步执行,因此数千个普通行的文件仍按比例增加开销。 - **裸 URL 在非 ASCII 标点前结束**:[tokenizer 补丁](../../../patches/micromark-extension-gfm-autolink-literal@2.1.0.patch)和[转换补丁](../../../patches/mdast-util-gfm-autolink-literal@2.0.1.patch)让 GFM 自动链接在非 ASCII 标点、符号或空白处结束,因此路径中含未编码 `(`、`・` 等字符的 URL 只链接到该字符之前;这类 URL 请写成 `<…>` 或 `[…](…)`。URL 后直接跟中日韩文字时仍会并入链接,因为 `/wiki/中文` 这类路径是合法的。两个补丁都绑定精确版本:升级任一依赖时必须重新应用或撤销对应补丁,否则该边界规则会悄然失效。 - **Go 结构体字段规则在本地打了补丁**:Go 语法加载时,重复匹配单行结构体字段的规则会替换为 [worlpaker/go-syntax#25](https://github.com/worlpaker/go-syntax/pull/25) 合入的规则,因为内置规则在 `struct {` 之后含花括号注释等行上会指数级回溯。若依赖升级后该规则消失,加载语法会抛错,Go 代码以纯文本显示;`@shikijs/langs` 发布上游规则后删除此补丁。 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/Checkbox.tsx b/packages/client/ui-primitives/src/Checkbox.tsx index bf1cbf9a65..e53456863d 100644 --- a/packages/client/ui-primitives/src/Checkbox.tsx +++ b/packages/client/ui-primitives/src/Checkbox.tsx @@ -11,19 +11,22 @@ import css from './Checkbox.module.css' * @param props.disabled - whether the control refuses changes. * @param props.title - optional localized hover text. * @param props.className - extra class for the label's placement. + * @param props.modalAutofocus - designate the input for the owning Modal's initial focus without React autoFocus. * @returns the label containing its checkbox. */ -export function Checkbox({ checked, onChange, label, disabled = false, title, className }: { +export function Checkbox({ checked, onChange, label, disabled = false, title, className, modalAutofocus = false }: { checked: boolean onChange: (next: boolean) => void label: string disabled?: boolean title?: string | undefined className?: string | undefined + modalAutofocus?: boolean }) { return ( diff --git a/packages/client/ui-primitives/src/Modal.module.css b/packages/client/ui-primitives/src/Modal.module.css index 6be4e62b0a..d1a67e6a61 100644 --- a/packages/client/ui-primitives/src/Modal.module.css +++ b/packages/client/ui-primitives/src/Modal.module.css @@ -84,6 +84,22 @@ color: var(--dsw-alias-label-primary); } +.warning .title { + display: flex; + align-items: flex-start; + gap: 8px; +} + +.warningIcon { + flex: none; + margin-top: 3px; + color: var(--dsw-alias-state-warn-primary); +} + +.warning .body { + margin-top: 0; +} + .close { flex: none; display: inline-flex; diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx index e7fa7faa2e..3f47c7cbc8 100644 --- a/packages/client/ui-primitives/src/Modal.tsx +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -2,7 +2,7 @@ import { useRef } from 'react' import type { KeyboardEventHandler, ReactNode } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' -import { IconCloseOutlineRegular } from './icons/index.tsx' +import { IconCloseOutlineRegular, IconWarningOutlineRegular } from './icons/index.tsx' import { useModalLayer } from './useModalLayer.ts' import css from './Modal.module.css' @@ -10,6 +10,7 @@ interface ModalBaseProps { open: boolean onClose: () => void title: string + tone?: 'warning' description?: string children?: ReactNode footer?: ReactNode @@ -31,6 +32,7 @@ type ModalProps = ModalBaseProps & ( * @param props.onClose - application close command, Escape, or mask click; while a menu is open inside the * dialog, Escape belongs to that menu first. * @param props.title - dialog heading (aria-label in every mode). + * @param props.tone - warning adds an amber title icon and compact title-to-body spacing; ignored for headless dialogs. * @param props.closeLabel - localized accessible close-button label. * @param props.description - optional supporting sentence under the title. * @param props.children - dialog body; mark its initial-focus control with @@ -47,7 +49,7 @@ type ModalProps = ModalBaseProps & ( */ export function Modal({ open, onClose, title, closeLabel, description, children, footer, className, contentClassName, - onKeyDownCapture, headless = false, backdropBlur = true, shortcutModal, + onKeyDownCapture, headless = false, backdropBlur = true, shortcutModal, tone, }: ModalProps) { const dialog = useRef(null) useModalLayer(dialog, open, onClose) @@ -61,7 +63,7 @@ export function Modal({ ref={dialog} tabIndex={-1} data-shortcut-modal={shortcutModal} - className={clsx(css.dialog, className)} + className={clsx(css.dialog, !headless && tone === 'warning' && css.warning, className)} role="dialog" aria-modal="true" aria-label={title} @@ -72,7 +74,10 @@ export function Modal({ <>
      -

      {title}

      +

      + {tone === 'warning' &&

      diff --git a/packages/client/ui-primitives/src/RiskConfirmation.module.css b/packages/client/ui-primitives/src/RiskConfirmation.module.css index 3c29131429..6a7db49ffa 100644 --- a/packages/client/ui-primitives/src/RiskConfirmation.module.css +++ b/packages/client/ui-primitives/src/RiskConfirmation.module.css @@ -16,52 +16,26 @@ } } -.warning { - display: flex; - align-items: flex-start; - gap: 10px; +.description { + margin: 0; color: var(--dsw-alias-label-secondary); font-size: 14px; line-height: 22px; } -.warning p { - margin: 0; -} - -.warningIcon { - flex: none; - margin-top: 2px; - color: var(--dsw-alias-state-error-primary); -} - -.acknowledgement { - display: flex; +.confirmation .acknowledgement { align-items: flex-start; - gap: 10px; - margin-top: 20px; - color: var(--dsw-alias-label-primary); + margin-top: 16px; + color: var(--dsw-alias-label-secondary); font-size: 14px; - line-height: 22px; - cursor: pointer; + font-weight: 400; + line-height: 20px; } -.acknowledgement input { - flex: none; - width: 16px; - height: 16px; - margin: 3px 0 0; - accent-color: var(--dsw-alias-button-primary-fill); - cursor: pointer; -} - -.acknowledgement input:focus-visible { - outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); - outline-offset: 2px; -} - -.acknowledgement input:disabled { - cursor: default; +.confirmation .acknowledgement input { + width: 14px; + height: 14px; + margin-top: 3px; } .modalAction { diff --git a/packages/client/ui-primitives/src/RiskConfirmation.tsx b/packages/client/ui-primitives/src/RiskConfirmation.tsx index 7109d096b2..1ad24e8298 100644 --- a/packages/client/ui-primitives/src/RiskConfirmation.tsx +++ b/packages/client/ui-primitives/src/RiskConfirmation.tsx @@ -3,7 +3,7 @@ * must gate a sensitive action behind an explicit checkbox. */ import { Button } from './Button.tsx' -import { IconWarningOutlineRegular } from './icons/index.tsx' +import { Checkbox } from './Checkbox.tsx' import { Modal } from './Modal.tsx' import css from './RiskConfirmation.module.css' @@ -45,6 +45,7 @@ export function RiskConfirmation({ open={open} onClose={onCancel} title={title} + tone="warning" closeLabel={closeLabel} className={css.confirmation ?? ''} contentClassName={css.confirmationContent ?? ''} @@ -64,20 +65,15 @@ export function RiskConfirmation({ )} > -
      - -

      {description}

      -
      - +

      {description}

      + ) } diff --git a/packages/client/ui-primitives/src/icons/artwork.ts b/packages/client/ui-primitives/src/icons/artwork.ts index 073f9988d3..28a3819995 100644 --- a/packages/client/ui-primitives/src/icons/artwork.ts +++ b/packages/client/ui-primitives/src/icons/artwork.ts @@ -20,6 +20,7 @@ interface IconArtwork { /** Default artwork keyed by glyph name; symbol ids derive from these keys. */ export const ICON_ARTWORK = { + 'layers-outline': { size: 16, svg: strokeWidth => `` }, 'save-outline': { size: 16, svg: strokeWidth => `` }, 'new-chat-outline': { size: 16, svg: strokeWidth => `` }, 'search-outline': { size: 16, svg: strokeWidth => `` }, @@ -59,7 +60,8 @@ export const ICON_ARTWORK = { 'check-circle-fill': { size: 16, svg: strokeWidth => `` }, 'warning-triangle-outline': { size: 16, svg: strokeWidth => `` }, 'user-outline': { size: 16, svg: strokeWidth => `` }, - 'users-outline': { size: 16, svg: strokeWidth => `` }, + // Users has inset contours in the 16px box; only coordinates, never stroke widths, carry the inset. + 'users-outline': { size: 16, svg: strokeWidth => `` }, 'paper-plane-outline': { size: 14, svg: strokeWidth => `` }, 'stop-fill': { size: 16, svg: strokeWidth => `` }, 'paperclip-outline': { size: 16, svg: strokeWidth => `` }, diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index a8f86d71ab..b6f03f1299 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -24,6 +24,12 @@ function productIcon(glyph: IconGlyph, weight: IconWeight): (props: IconProps) = ) } +/** Regular one-pixel IconLayersOutline artwork. */ +export const IconLayersOutlineRegular = productIcon('layers-outline', 'regular') + +/** Medium IconLayersOutline artwork with a 1.3px stroke. */ +export const IconLayersOutlineMedium = productIcon('layers-outline', 'medium') + /** Regular one-pixel IconNewChatOutline artwork. */ export const IconNewChatOutlineRegular = productIcon('new-chat-outline', 'regular') diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index 004c962963..8f1c4c7d29 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -14,6 +14,7 @@ import { memo, useMemo, useRef } from 'react' import type { ReactNode } from 'react' import clsx from 'clsx' +import { copyKatexAsTex } from './copy-tex.ts' import { IncrementalMarkdownParser } from './incremental.ts' import { parseGfm, parseGfmWithMath } from './parse.ts' import { @@ -179,6 +180,8 @@ class StreamingRenderer { * including `#L24` / `#L24-L30` fragments and `:24` / `:24-30` / `:24:5` suffixes * (ranges open at their first line, columns are ignored, and invalid locations * open only the file; see the README for URL/path classification). + * Copying a selection inside the document writes selected formulas as + * `$…$` / `$$…$$` TeX source. * @returns A GFM document with TeX math rendered through KaTeX; raw HTML never * renders as markup, and unsafe protocols are disabled. Local links without an opener remain text; * absolute HTTP(S) images render directly. @@ -210,5 +213,6 @@ export const MarkdownText = memo(function MarkdownText({ }, [text, streaming, labels, fileMentions, pathImages, omitHtml]) return
      {children}
      + data-markdown-variant={variant === 'compact' ? variant : undefined} + onCopy={(event) => { copyKatexAsTex(event.nativeEvent, event.currentTarget) }}>{children}
      }) diff --git a/packages/client/ui-primitives/src/markdown/copy-tex.ts b/packages/client/ui-primitives/src/markdown/copy-tex.ts new file mode 100644 index 0000000000..0b600281ec --- /dev/null +++ b/packages/client/ui-primitives/src/markdown/copy-tex.ts @@ -0,0 +1,53 @@ +/** Clipboard projection that copies rendered KaTeX formulas as their TeX source. */ + +/** The whole formula around a node, including the display wrapper that marks display math. */ +function closestFormula(node: Node): Element | null { + const element = node instanceof Element ? node : node.parentElement + return element?.closest('.katex-display') ?? element?.closest('.katex') ?? null +} + +/** + * Rewrite a copy whose selection lies inside one rendered Markdown document and + * includes rendered math, so each formula becomes its trimmed TeX source, + * delimited as `$…$` inline and `$$…$$` for display math, in both the + * plain-text and HTML clipboard data. A selection endpoint inside a formula + * extends to the whole formula in the clipboard only; the visible selection is + * unchanged. A copy without math, a selection reaching outside `root`, and a + * copy whose default an inner listener already prevented keep their data. + * @param event - Native copy event. + * @param root - The rendered Markdown document root. + */ +export function copyKatexAsTex(event: ClipboardEvent, root: HTMLElement): void { + const selection = root.ownerDocument.getSelection() + if (event.defaultPrevented || event.clipboardData === null || selection === null + || selection.rangeCount !== 1 || selection.isCollapsed) return + const range = selection.getRangeAt(0).cloneRange() + if (!root.contains(range.commonAncestorContainer)) return + const startFormula = closestFormula(range.startContainer) + if (startFormula !== null) range.setStartBefore(startFormula) + const endFormula = closestFormula(range.endContainer) + if (endFormula !== null) range.setEndAfter(endFormula) + const fragment = range.cloneContents() + const formulas = [...fragment.querySelectorAll('.katex')] + if (formulas.length === 0) return + for (const formula of formulas) { + const tex = formula.querySelector('annotation[encoding="application/x-tex"]')?.textContent.trim() + if (tex === undefined) continue + const delimiter = formula.closest('.katex-display') === null ? '$' : '$$' + formula.replaceWith(`${delimiter}${tex}${delimiter}`) + } + // innerText applies the browser's own block and line-break serialization, + // which needs a rendered element; an off-screen child of the root keeps the + // Markdown styles that decide block layout while staying invisible. + const container = root.ownerDocument.createElement('div') + container.style.cssText = 'position:fixed;top:0;left:-100000px;pointer-events:none' + container.append(fragment) + root.append(container) + try { + event.clipboardData.setData('text/plain', container.innerText) + event.clipboardData.setData('text/html', container.innerHTML) + } finally { + container.remove() + } + event.preventDefault() +} diff --git a/packages/client/ui-primitives/tests/animated-rows.client.spec.tsx b/packages/client/ui-primitives/tests/animated-rows.client.spec.tsx index fe46cb5a2b..57b331ffc8 100644 --- a/packages/client/ui-primitives/tests/animated-rows.client.spec.tsx +++ b/packages/client/ui-primitives/tests/animated-rows.client.spec.tsx @@ -73,6 +73,15 @@ function finish(animation: Animation): void { animation.onfinish?.call(animation, new Event('finish') as AnimationPlaybackEvent) } +function naturalHeightRows(tree: HTMLElement, top = 0): void { + measure.mockImplementation(function (this: HTMLElement) { + const items = Array.from(tree.querySelectorAll('[data-row-key]')) + if (this === tree) return new DOMRect(0, top, 200, items.length * 34) + if (this.dataset.rowKey !== undefined) return new DOMRect(0, top + items.indexOf(this) * 34, 200, 32) + return new DOMRect(0, top, 200, items.length * 34) + }) +} + describe('AnimatedRows', () => { it('settles initial arrivals and glides a subsequent pin reorder', () => { const view = render(rows(['a'])) @@ -168,6 +177,57 @@ describe('AnimatedRows', () => { for (const call of animations) expect(call.cancel).toHaveBeenCalled() }) + it('animates only rows inside the window when 300 natural-height rows reorder', () => { + vi.stubGlobal('innerHeight', 324) + const keys = Array.from({ length: 300 }, (_, index) => String(index)) + const view = render(rows(keys)) + const tree = arm() + naturalHeightRows(tree, 50 - 100 * 34) + const reordered = keys.map((_, index) => String(index % 2 === 0 ? index + 1 : index - 1)) + view.rerender(rows(reordered)) + expect(animations.map(call => call.element.dataset.rowKey)) + .toEqual(['99', '98', '101', '100', '103', '102', '105', '104', '107', '106', '109', '108']) + expect(animations.every(call => call.options.duration === 200)).toBe(true) + expect(tree.querySelectorAll('[data-row-key]')).toHaveLength(300) + }) + + it('fades entries and exits only inside the window', () => { + vi.stubGlobal('innerHeight', 68) + const view = render(rows(['a', 'b', 'c', 'd', 'e'])) + const tree = arm() + naturalHeightRows(tree) + view.rerender(rows(['x', 'y', 'z', 'w', 'v'])) + expect(animations.map(call => [call.element.textContent, call.options.duration])).toEqual([ + ['x', 100], ['y', 100], ['a', 100], ['b', 100], + ]) + expect(animations.filter(call => call.element.inert)).toHaveLength(2) + }) + + it('compares previous positions with the window bounds measured before the commit', () => { + vi.stubGlobal('innerHeight', 102) + const content = (keys: readonly string[], top: number) =>
      {rows(keys)}
      + const view = render(content(['a', 'b', 'c', 'd', 'e', 'f'], 0)) + const tree = arm() + const shift = screen.getByTestId('shift') + measure.mockImplementation(function (this: HTMLElement) { + const items = Array.from(tree.querySelectorAll('[data-row-key]')) + const top = Number.parseFloat(shift.style.top) + if (this.dataset.rowKey !== undefined) return new DOMRect(0, top + items.indexOf(this) * 34, 200, 32) + return new DOMRect(0, top, 200, items.length * 34) + }) + view.rerender(content(['b', 'a', 'c', 'd', 'f', 'e'], -136)) + expect(animations.map(call => call.element.dataset.rowKey)).toEqual(['b', 'a', 'c', 'f', 'e']) + }) + + it('limits natural-height lists to the window', () => { + vi.stubGlobal('innerHeight', 68) + const view = render(rows(['a', 'b', 'c', 'd'])) + const tree = arm() + naturalHeightRows(tree) + view.rerender(rows(['b', 'a', 'd', 'c'])) + expect(animations.map(call => call.element.dataset.rowKey)).toEqual(['b', 'a']) + }) + it('does not animate rows that stay outside the list viewport', () => { const view = render(rows(['a', 'b', 'c', 'd', 'e'])) arm() diff --git a/packages/client/ui-primitives/tests/atoms.client.spec.tsx b/packages/client/ui-primitives/tests/atoms.client.spec.tsx index 3e7033474b..1bd9692b64 100644 --- a/packages/client/ui-primitives/tests/atoms.client.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.client.spec.tsx @@ -898,6 +898,22 @@ describe('Modal', () => { expect(onClose).toHaveBeenCalledTimes(2) }) + it('places a decorative warning icon in the title only for warning dialogs', () => { + const view = render( {}}> +

      Review the consequences.

      +
      ) + const heading = screen.getByRole('heading', { name: 'Confirm change' }) + const icon = heading.querySelector('svg') + expect(icon).not.toBeNull() + expect(icon?.getAttribute('aria-hidden')).toBe('true') + expect(screen.getByText('Review the consequences.').querySelector('svg')).toBeNull() + view.rerender( {}}>Body) + expect(screen.getByRole('heading', { name: 'Confirm change' }).querySelector('svg')).toBeNull() + view.rerender( {}}>Body) + expect(screen.queryByRole('heading')).toBeNull() + expect(screen.getByRole('dialog').querySelector('svg')).toBeNull() + }) + it('renders headless content without the default close chrome', () => { render( {}} title="Custom surface" headless backdropBlur={false}> diff --git a/packages/client/ui-primitives/tests/checkbox.client.spec.tsx b/packages/client/ui-primitives/tests/checkbox.client.spec.tsx index 838a589dab..1386decedc 100644 --- a/packages/client/ui-primitives/tests/checkbox.client.spec.tsx +++ b/packages/client/ui-primitives/tests/checkbox.client.spec.tsx @@ -25,6 +25,14 @@ it('refuses label activation while disabled', () => { expect(screen.getByRole('checkbox').checked).toBe(true) }) +it('marks initial modal focus only when requested', () => { + const view = render( {}} />) + expect(screen.getByRole('checkbox').hasAttribute('data-modal-autofocus')).toBe(false) + view.rerender( {}} modalAutofocus />) + expect(screen.getByRole('checkbox').hasAttribute('data-modal-autofocus')).toBe(true) + expect(document.activeElement).not.toBe(screen.getByRole('checkbox')) +}) + it('preserves caller hover text', () => { const onChange = vi.fn() render() diff --git a/packages/client/ui-primitives/tests/icons.client.spec.tsx b/packages/client/ui-primitives/tests/icons.client.spec.tsx index 00ba3a3bf1..9bf949a9db 100644 --- a/packages/client/ui-primitives/tests/icons.client.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.client.spec.tsx @@ -27,8 +27,13 @@ function artwork(glyph: IconGlyph, weight: IconWeight): Document { } describe('product icon set', () => { - it('exports regular and medium variants for all 97 public glyphs', () => { - expect(iconNames.length).toBe(194) + it('exports regular and medium variants for every public glyph', () => { + const publicGlyphs = glyphs.filter(glyph => glyph !== 'apps-add-compact-outline') + const expectedNames = publicGlyphs.flatMap((glyph) => { + const name = glyph.split('-').map(part => part[0]!.toUpperCase() + part.slice(1)).join('') + return [`Icon${name}Regular`, `Icon${name}Medium`] + }) + expect([...iconNames].sort()).toEqual(expectedNames.sort()) expect(iconNames.some(name => /\d+$/.test(name))).toBe(false) const regular = iconNames.filter(name => name.endsWith('Regular')).map(name => name.slice(0, -'Regular'.length)) const medium = iconNames.filter(name => name.endsWith('Medium')).map(name => name.slice(0, -'Medium'.length)) @@ -53,6 +58,75 @@ describe('product icon set', () => { expect(render().container.querySelector('svg')!.getAttribute('width')).toBe('16') }) + it.each([ + ['regular', primitives.IconUsersOutlineRegular, 1], + ['medium', primitives.IconUsersOutlineMedium, 1.3], + ] as const)('keeps the inset Users contours and %s stroke at every caller size', (weight, Icon, stroke) => { + const svg = artwork('users-outline', weight).documentElement + const paths = [...svg.querySelectorAll('path')] + const contours = paths.map(path => path.getAttribute('d')!) + // A 0.91 coordinate scale about (8, 8) preserves the four cubic contours without scaling their strokes. + expect(contours).toEqual([ + 'M6.18 8.2275C7.5620898 8.2275 8.6825 7.1070898 8.6825 5.725C8.6825 4.3429102 7.5620898 3.2225 6.18 3.2225C4.7979102 3.2225 3.6775 4.3429102 3.6775 5.725C3.6775 7.1070898 4.7979102 8.2275 6.18 8.2275Z', + 'M1.63 13.915C1.63 11.185 3.905 10.0475 6.18 10.0475C8.455 10.0475 10.73 11.185 10.73 13.915', + 'M10.275 3.359C11.3215 3.7685 12.0495 4.6785 12.0495 5.725C12.0495 6.7715 11.3215 7.6815 10.275 8.091', + 'M12.004 10.366C13.369 11.003 14.37 12.186 14.37 13.915', + ]) + // These contour extrema are endpoints; every Bezier control point stays inside the same bounds. + const coordinates = contours.flatMap(path => path.match(/\d+(?:\.\d+)?/g)!.map(Number)) + const xs = coordinates.filter((_, index) => index % 2 === 0) + const ys = coordinates.filter((_, index) => index % 2 === 1) + expect([Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)]) + .toEqual([1.63, 3.2225, 14.37, 13.915]) + expect(svg.getAttribute('viewBox')).toBe('0 0 16 16') + expect(svg.getAttribute('fill')).toBe('none') + expect(svg.getAttribute('stroke-width')).toBe(String(stroke)) + expect(svg.querySelector('[transform], [vector-effect], [stroke-width]')).toBeNull() + expect(svg.hasAttribute('transform')).toBe(false) + expect(svg.hasAttribute('vector-effect')).toBe(false) + expect(paths.every(path => path.getAttribute('stroke') === 'currentColor')).toBe(true) + expect(primitives.ICON_SYMBOLS[`dsw-icon-users-outline-${weight}`]) + .toBe(ICON_ARTWORK['users-outline'].svg(stroke)) + const view = render() + const rendered = view.container.querySelector('svg')! + expect([rendered.getAttribute('width'), rendered.getAttribute('height')]).toEqual(['16', '16']) + view.rerender() + expect([rendered.getAttribute('width'), rendered.getAttribute('height')]).toEqual(['14', '14']) + expect(rendered.querySelector('use')?.getAttribute('href')).toBe(`#dsw-icon-users-outline-${weight}`) + }) + + it.each([ + ['regular', primitives.IconLayersOutlineRegular, 1], + ['medium', primitives.IconLayersOutlineMedium, 1.3], + ] as const)('draws the inset three rounded layers with the %s stroke', (weight, Icon, stroke) => { + const svg = artwork('layers-outline', weight).documentElement + const paths = [...svg.querySelectorAll('path')] + // A 0.87 scale about (8, 8) insets coordinates and arc radii, retaining rotations, flags, and strokes. + expect(paths.map(path => path.getAttribute('d'))).toEqual([ + 'M8.481371 2.304371 a1.159971 1.159971 0 0 0-.962742 0 L2.547971 4.566371 a.580029 .580029 0 0 0 0 1.0614 l4.9764 2.267829 a1.159971 1.159971 0 0 0 .962829 0 l4.9764-2.262 a.580029 .580029 0 0 0 0-1.0614Z', + 'M2.199971 8 a.580029 .580029 0 0 0 .336429 .527829 l4.987971 2.267742 a1.159971 1.159971 0 0 0 .957 0 l4.9764-2.262 A.580029 .580029 0 0 0 13.800029 8', + 'M2.199971 10.899971 a.580029 .580029 0 0 0 .336429 .527829 l4.987971 2.267829 a1.159971 1.159971 0 0 0 .957 0 l4.9764-2.262 A.580029 .580029 0 0 0 13.800029 10.899971', + ]) + expect(svg.children).toHaveLength(3) + expect(svg.getAttribute('viewBox')).toBe('0 0 16 16') + expect(svg.getAttribute('fill')).toBe('none') + expect(svg.getAttribute('stroke-width')).toBe(String(stroke)) + expect(svg.getAttribute('stroke-linecap')).toBe('round') + expect(svg.getAttribute('stroke-linejoin')).toBe('round') + expect(svg.querySelector('[transform], [vector-effect], [stroke-width], [stroke-linecap], [stroke-linejoin]')).toBeNull() + expect(svg.hasAttribute('transform')).toBe(false) + expect(svg.hasAttribute('vector-effect')).toBe(false) + expect(paths.every(path => path.getAttribute('stroke') === 'currentColor')).toBe(true) + expect(primitives.ICON_SYMBOLS[`dsw-icon-layers-outline-${weight}`]) + .toBe(ICON_ARTWORK['layers-outline'].svg(stroke)) + const view = render() + const rendered = view.container.querySelector('svg')! + expect([rendered.getAttribute('width'), rendered.getAttribute('height')]).toEqual(['16', '16']) + view.rerender() + expect([rendered.getAttribute('width'), rendered.getAttribute('height')]).toEqual(['14', '14']) + expect(rendered.querySelector('use')?.getAttribute('href')).toBe(`#dsw-icon-layers-outline-${weight}`) + }) + it('exports the shield contour and regular stroke for composite glyphs', () => { const shield = artwork('shield-outline', 'regular') expect(shield.querySelector('path')?.getAttribute('d')).toBe(primitives.SHIELD_OUTLINE_PATH) diff --git a/packages/client/ui-primitives/tests/markdown-copy-tex.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-copy-tex.client.spec.tsx new file mode 100644 index 0000000000..abe9353102 --- /dev/null +++ b/packages/client/ui-primitives/tests/markdown-copy-tex.client.spec.tsx @@ -0,0 +1,131 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { MarkdownText } from './markdown-test-components.tsx' +import { copyKatexAsTex } from '../src/markdown/copy-tex.ts' + +// jsdom implements no layout or innerText, so textContent stands in for it here +// and these specs assert only the TeX fragments; math-rendering.e2e.ts covers +// the browser's line breaks. +const nativeInnerText = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'innerText') +beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, 'innerText', { + configurable: true, + get(this: HTMLElement) { return this.textContent }, + }) +}) +afterAll(() => { + if (nativeInnerText === undefined) Reflect.deleteProperty(HTMLElement.prototype, 'innerText') + else Object.defineProperty(HTMLElement.prototype, 'innerText', nativeInnerText) +}) +afterEach(() => { + document.getSelection()?.removeAllRanges() + cleanup() +}) + +function select(start: Node, startOffset: number, end: Node, endOffset: number): void { + const range = document.createRange() + range.setStart(start, startOffset) + range.setEnd(end, endOffset) + document.getSelection()!.removeAllRanges() + document.getSelection()!.addRange(range) +} + +function copy(target: Element): { data: Map; prevented: boolean } { + const data = new Map() + const clipboardData = { setData: (type: string, value: string) => { data.set(type, value) } } + const prevented = !fireEvent.copy(target, { clipboardData }) + return { data, prevented } +} + +describe('MarkdownText math copy', () => { + it('copies selected inline and display formulas as delimited TeX source', () => { + const view = render() + const paragraphs = view.container.querySelectorAll('p') + const inlineVisual = view.container.querySelector('.katex-html')! + const lastText = paragraphs[paragraphs.length - 1]!.firstChild! + select(inlineVisual.firstChild!, 0, lastText, 3) + const { data, prevented } = copy(inlineVisual) + expect(prevented).toBe(true) + for (const type of ['text/plain', 'text/html']) { + expect(data.get(type)).toContain('$E=mc^2$ and') + expect(data.get(type)).toContain('$$\\frac{a}{b}$$') + expect(data.get(type)).toContain('end') + } + expect(data.get('text/html')).not.toMatch(/katex-(?:html|mathml)/u) + expect(view.container.textContent).not.toContain('$E=mc^2$') + expect(document.getSelection()!.getRangeAt(0).startContainer).toBe(inlineVisual.firstChild) + }) + + it('extends a selection starting or ending inside a formula to the whole formula', () => { + const view = render() + const paragraph = view.container.querySelector('p')! + const visualText = view.container.querySelector('.katex-html .mord')!.firstChild! + select(paragraph.firstChild!, 0, visualText, 1) + expect(copy(paragraph).data.get('text/plain')).toBe('Energy $E=mc^2$') + select(visualText, 0, paragraph.lastChild!, 3) + expect(copy(paragraph).data.get('text/plain')).toBe('$E=mc^2$ ta') + }) + + it('marks a selection entirely inside display math as display TeX', () => { + const view = render() + const glyph = view.container.querySelector('.katex-display .katex-html .mord')! + select(glyph, 0, glyph, 1) + expect(copy(glyph).data.get('text/plain')).toBe('$$\\frac{a}{b}$$') + }) + + it('trims the trailing newline of a fenced math block', () => { + const view = render() + const glyph = view.container.querySelector('.katex-display .katex-html .mord')! + select(glyph, 0, glyph, 1) + expect(copy(glyph).data.get('text/plain')).toBe('$$\\frac{a}{b}$$') + }) + + it('keeps native copy without math or with a collapsed selection', () => { + const view = render() + const text = view.container.querySelector('p')!.firstChild! + select(text, 0, text, 5) + expect(copy(text.parentElement!)).toEqual({ data: new Map(), prevented: false }) + select(text, 0, text, 0) + expect(copy(text.parentElement!).prevented).toBe(false) + }) + + it('keeps native copy for a selection reaching outside the Markdown document', () => { + const view = render(<>

      outside

      ) + const formula = view.container.querySelector('.katex-html')! + const outside = view.container.lastElementChild!.firstChild! + select(formula, 0, outside, 3) + expect(copy(formula)).toEqual({ data: new Map(), prevented: false }) + }) + + it('keeps data from an inner listener that prevented the default', () => { + const view = render() + const paragraph = view.container.querySelector('p')! + paragraph.addEventListener('copy', (event) => { + event.clipboardData!.setData('text/plain', 'inner') + event.preventDefault() + }) + select(paragraph, 0, paragraph, paragraph.childNodes.length) + expect(copy(paragraph).data).toEqual(new Map([['text/plain', 'inner']])) + }) + + it('ignores a copy without clipboard data', () => { + const view = render() + const root = view.container.firstElementChild as HTMLElement + select(root, 0, root, root.childNodes.length) + const event = new Event('copy', { cancelable: true }) as ClipboardEvent + Object.defineProperty(event, 'clipboardData', { value: null }) + copyKatexAsTex(event, root) + expect(event.defaultPrevented).toBe(false) + }) + + it('leaves a formula without TeX annotation as rendered text', () => { + const root = document.createElement('div') + root.innerHTML = '

      a visual b

      ' + document.body.append(root) + root.addEventListener('copy', (event) => { copyKatexAsTex(event, root) }) + select(root, 0, root, 1) + expect(copy(root).data.get('text/plain')).toBe('a visual b') + root.remove() + }) +}) diff --git a/packages/client/ui-renderer/README.i18n.yaml b/packages/client/ui-renderer/README.i18n.yaml index 5022127e14..df00ec41b1 100644 --- a/packages/client/ui-renderer/README.i18n.yaml +++ b/packages/client/ui-renderer/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-renderer/use-this-package: - en: 39d5f36f7cb7d680 - zh: a61f0aa8b339e06f + en: b83a441098d5b7fd + zh: 57cd24edbc2e6602 /deepseek-ai-dsh-client-ui-renderer/use-this-package/what-mounting-does: en: 1d5350799ce072d0 zh: 942e543fdab51818 @@ -45,8 +45,8 @@ en: ca75c51c89c2c9b0 zh: de54f3467ef3b9be /deepseek-ai-dsh-client-ui-renderer/known-limitations-and-deferred-work: - en: 5ebaf02736035a44 - zh: 3c04f28da7ec6c40 + en: 14bccc68da30d0f3 + zh: 6b6b306556cdd8a4 /deepseek-ai-dsh-client-ui-renderer/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-renderer/README.md b/packages/client/ui-renderer/README.md index e6699e8d98..13a759a182 100644 --- a/packages/client/ui-renderer/README.md +++ b/packages/client/ui-renderer/README.md @@ -25,7 +25,7 @@ English | [中文](README.zh.md) ## Use this package -This package is infrastructure: the web shell and the boot kernel are its only direct consumers. A composition needs it whenever it wants a React-rendered GUI — `dsh-client-web` loads the roster, waits for every entry to activate, then calls `ctx.uiRenderer.mount(container)`. +This package is infrastructure: the web shell and the boot kernel are its only direct consumers. A composition needs it whenever it wants a React-rendered GUI — `dsh-client-web` loads the roster, waits for it to settle and for the boot audit to accept it, then calls `ctx.uiRenderer.mount(container)`. ### What mounting does @@ -91,7 +91,7 @@ None; this package neither assembles nor sends a provider request. These limits define when the application frame appears and how far per-region readiness goes; they are current package constraints. -- **The first application frame waits for every client entry** — the boot kernel hands over the mount point only after the loader roster settles; per-region readiness remains deferred. +- **The first application frame waits for the whole client roster** — the boot kernel hands over the mount point only after the loader roster settles and the boot audit accepts it; per-region readiness remains deferred. - **Slot rendering has no Suspense integration or per-entry lazy loading** — the complete plugin roster settles before the renderer mounts the root. diff --git a/packages/client/ui-renderer/README.zh.md b/packages/client/ui-renderer/README.zh.md index bac1dc9064..cdf8de1820 100644 --- a/packages/client/ui-renderer/README.zh.md +++ b/packages/client/ui-renderer/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -本包属于基础设施:Web 外壳与启动内核是它仅有的直接消费方。只要组合需要 React 渲染的 GUI,就需要它——`dsh-client-web` 加载名册,等待每个 entry 激活,然后调用 `ctx.uiRenderer.mount(container)`。 +本包属于基础设施:Web 外壳与启动内核是它仅有的直接消费方。只要组合需要 React 渲染的 GUI,就需要它——`dsh-client-web` 加载名册,等待名册稳定且启动审计通过,然后调用 `ctx.uiRenderer.mount(container)`。 ### 挂载做什么 @@ -91,7 +91,7 @@ React、React DOM、Cordis、ui-slots 与 ui-primitives 通过 Web 外壳的静 这些限制说明应用首帧何时出现、按区域就绪能走多远;它们是当前包约束。 -- **应用首帧会等待全部客户端 entry**:启动内核只在 loader 名册稳定后交出挂载点;按区域就绪仍属暂缓事项。 +- **应用首帧会等待整个客户端名册**:启动内核只在 loader 名册稳定且启动审计通过后交出挂载点;按区域就绪仍属暂缓事项。 - **slot 渲染没有 Suspense 集成或逐 entry 惰性加载**:完整插件名册稳定后,渲染器才挂载根节点。 diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 3d8f2a3a55..216983e29e 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -24,8 +24,8 @@ en: 597df68b5913e8d7 zh: bc9114f3e877679a /deepseek-ai-dsh-client-ui-settings-general/use-this-package/onboarding-steps: - en: ba55fe92bf49dc5a - zh: 9a0e1345496a0ae6 + en: 7600861d064dbc99 + zh: a52ba446016e3a21 /deepseek-ai-dsh-client-ui-settings-general/understand-the-implementation: en: 9c8c35ae0e672b14 zh: 503c8e9d24a93d88 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index 0cc3645d8e..bca8bb88ee 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -63,6 +63,8 @@ Access: the settings shell and the `settings.open` shortcut are registered only ----- +Desktop also contributes an **Update channel** row to General when the preload exposes the channel API. Selecting the current healthy channel leaves downloads and prepared updates intact without a save or notification. Selecting another channel calls Electron directly and temporarily displays that choice with a spinner. The reply settles the selection against actual shell state; failure rolls it back. A shell-owned toast reports success or a short save failure, including after Settings closes. Startup settings errors use a ten-second toast with a details action; dismissing it does not clear the error. Persistent updater suspensions remain in the update indicator; a failed save does not create one. Ordinary browsers omit this row. File ownership and restart rules are documented in [local desktop settings](../../../apps/desktop/README.md#local-desktop-settings). + ## Understand the implementation diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 8ff8cc8e57..e014b6f91f 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -63,6 +63,8 @@ Web 与桌面端的通用设置底部显示当前发布版本,使用构建注 ----- +当预加载提供渠道 API 时,桌面版还会在通用设置中提供**更新渠道**。重复选择当前正常渠道不会保存、提示或影响下载与待安装更新。选择其他渠道直接调用 Electron,等待期间显示所选项和加载指示;返回后按实际运行状态确定选项,失败则回滚。由 shell 持有的 toast 显示成功或简短保存失败,关闭设置后仍可见。启动配置错误使用可查看详情的十秒 toast;提示消失不会清除错误。持续的更新暂停保留在更新入口;保存失败本身不会产生暂停。普通浏览器不显示该行。文件归属和重启规则见[本地桌面设置](../../../apps/desktop/README.zh.md#local-desktop-settings)。 + ## 理解实现 diff --git a/packages/client/ui-settings-general/src/client/DesktopChannelRow.module.css b/packages/client/ui-settings-general/src/client/DesktopChannelRow.module.css new file mode 100644 index 0000000000..928c7b1bf8 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/DesktopChannelRow.module.css @@ -0,0 +1,75 @@ +/* Desktop update channel row: label plus selector pill. */ + +.row { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 0; + border-bottom: 0.5px solid var(--dsw-alias-border-l2); +} + +.rowText { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding-right: 48px; +} + +/* Narrow settings panel: the 48px gutter separates the label from its control + across a wide row and only squeezes the label here. */ +@media (max-width: 767px) { + :global(html:not([data-windows-titlebar]):not([data-platform='darwin'])) .rowText { + padding-right: 16px; + } +} + +.title { + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.description { + font-size: 12px; + font-weight: 400; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); +} + +.selector { + display: inline-flex; + align-items: center; + gap: 12px; + height: 36px; + padding: 0 14px; + border: none; + border-radius: var(--dsw-radius-md); + background: var(--dsw-alias-bg-module-platform); + font: inherit; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +@media (hover: hover) { + .selector:hover { + background: var(--dsw-alias-interactive-bg-hover); + } +} + +.chevron { + flex: none; +} + +.spinner { + flex: none; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} diff --git a/packages/client/ui-settings-general/src/client/DesktopChannelRow.tsx b/packages/client/ui-settings-general/src/client/DesktopChannelRow.tsx new file mode 100644 index 0000000000..4027e7bf47 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/DesktopChannelRow.tsx @@ -0,0 +1,69 @@ +/** Desktop channel selection; persistence and updater transitions remain in Electron. */ +import { useState } from 'react' +import { Menu, Toast, IconWarningOutlineRegular, IconLoadingOutlineRegular, IconChevronDownOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { DesktopChannelSource } from './desktop-channel-source.ts' +import type { DesktopUpdateChannel } from '../types.ts' +import css from './DesktopChannelRow.module.css' + +/** Framework-bound shell state and explicit selection action. */ +export interface DesktopChannelRowInjected { + hooks: { channel: DesktopChannelSource['store'] } + select(channel: DesktopUpdateChannel): Promise +} + +/** + * Render the Desktop-only channel control without depending on a Host settings service. + * @param props - Shell observation, selection action and localized copy. + * @returns The General settings row. + */ +export function DesktopChannelRow({ useChannel, select, t }: + PropsRuntime<'settings.general.item'> & PropsLocale<'settings'> & InjectFace) { + const { state, failed, pending } = useChannel(value => value) + const [open, setOpen] = useState(false) + const busy = pending !== undefined || state?.changing === true + const channel = pending ?? state?.channel + return
      +
      +
      {t('desktop.channel.title')}
      +
      {t('desktop.channel.description')}
      + {(failed || state?.error !== undefined) &&
      {t(failed ? 'desktop.channel.readError' : 'desktop.channel.error')}
      } +
      + { setOpen(false) }} portal align="end" + selectedId={channel ?? ''} + items={[{ id: 'stable', label: t('desktop.channel.stable') }, { id: 'nightly', label: t('desktop.channel.nightly') }]} + onSelect={(id) => { + setOpen(false) + void select(id as DesktopUpdateChannel) + }} + anchor={} /> +
      +} + +/** Shell-owned operation feedback that survives closing Settings. */ +export interface DesktopChannelToastInjected { + hooks: { channel: DesktopChannelSource['store'] } + dismiss(sequence: number): void + openConfiguration(): void +} + +/** + * Render the latest channel save result outside Settings. + * @param props - Shared operation state, dismissal and localized copy. + * @returns A transient toast, or nothing. + */ +export function DesktopChannelToast({ useChannel, dismiss, openConfiguration, t }: + PropsRuntime<'shell.overlay'> & PropsLocale<'settings'> & InjectFace) { + const notice = useChannel(value => value.notice) + if (notice === undefined) return null + return { dismiss(notice.sequence) }} {...notice.outcome === 'applied' ? { tone: 'success' as const } : { icon: }} /> +} diff --git a/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx b/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx index 728fee6537..ae22d5c6fb 100644 --- a/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx +++ b/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx @@ -12,6 +12,7 @@ const BUSY_PHASES: ReadonlySet = new Set([ ]) function updateCopy(state: DesktopUpdatePresentation, t: SettingsTranslate): { label: string; detail: string } { + if (state.configurationError !== undefined) return { label: t('desktop.channel.unavailable'), detail: t(`desktop.update.configuration.${state.configurationError}`) } const labels: Readonly> = { idle: '', checking: t('desktop.update.checking'), @@ -28,9 +29,13 @@ function updateCopy(state: DesktopUpdatePresentation, t: SettingsTranslate): { l } if (state.phase === 'error') { const failures: Readonly> = { + configuration: t('desktop.update.configurationFailed'), check: t('desktop.update.checkFailed'), 'check-network': t('desktop.update.checkNetworkFailed'), download: t('desktop.update.downloadFailed'), + 'download-space': t('desktop.update.downloadSpaceFailed'), + 'download-permission': t('desktop.update.downloadPermissionFailed'), + 'download-busy': t('desktop.update.downloadBusyFailed'), 'download-network': t('desktop.update.downloadNetworkFailed'), install: t('desktop.update.installFailed'), 'install-network': t('desktop.update.installNetworkFailed'), @@ -62,12 +67,12 @@ export function DesktopUpdateIndicator({ wide, hidden, t, view, onOpen }: { onOpen: () => void }) { const { presentation: state, failed, opening } = view - if (!wide || hidden || (!failed && (state === undefined || state.phase === 'idle'))) return null + if (!wide || hidden || (!failed && (state === undefined || (state.phase === 'idle' && state.configurationError === undefined)))) return null const retryLabel = t('desktop.update.retry') const copy = state === undefined ? { label: retryLabel, detail: retryLabel } : updateCopy(state, t) const label = failed ? retryLabel : copy.label - const error = failed || state?.phase === 'error' - const busy = opening || (state !== undefined && BUSY_PHASES.has(state.phase)) + const error = failed || state?.phase === 'error' || state?.configurationError !== undefined + const busy = opening || (state !== undefined && state.configurationError === undefined && BUSY_PHASES.has(state.phase)) return + ) + return ( + { setOpen(false) }} + items={props.items} + selectedId={props.selectedId} + onSelect={(id) => { + trigger.current?.focus({ preventScroll: true }) + setOpen(false) + if (id !== props.selectedId) props.onSelect(id) + }} + portal + autoFocus + minWidthFromAnchor + anchor={anchor} + /> + ) +} + +/** + * A notice that a list could not be read, with a retry. + * @param props - the notice copy, the retry label, and the retry callback. + * @returns the status line. + */ +function ReadFailure(props: { message: string; retry: string; onRetry: () => void }) { + return ( +
      + {props.message} + +
      + ) +} + +/** + * One section's provider menu, with the selected provider's description and + * the page's own line about it below, and a notice with a retry while the provider list could not be read. + * @param props - the control id, the id of the section title naming it, the section state, the selection callback, and the list read state. + * @returns the labelled menu. + */ +function ProviderSelect(props: { + id: string + /** Id of the section title, which names the menu. */ + labelledBy: string + t: T + section: WebSearchCardState['search' | 'fetch'] + disabled: boolean + field: keyof WebProviderSelection + onSelect: WebSearchCardFace['selectProvider'] + /** The selected provider's registered description, resolved to the active language, if it has one. */ + description: string | undefined + /** The page's own line about the selected provider, after its description, if it has one. */ + hint: ReactNode + /** Whether the latest provider list read failed. */ + providersFailed: boolean + onRetry: WebSearchCardFace['retryProviders'] +}) { + const { t, section } = props + const current = section.options.find(option => option.id === section.selected) + const items = [ + ...section.auto ? [{ id: AUTO_ID, label: t('providerAuto') }] : [], + ...section.options.map(option => ({ id: option.id, label: providerLabel(t, option) })), + ] + return ( +
      + { props.onSelect(props.field, id) }} /> + {props.description !== undefined &&

      {props.description}

      } + {props.hint !== undefined &&

      {props.hint}

      } + {props.providersFailed && } +
      + ) +} diff --git a/packages/client/ui-settings-web-search/src/client/index.ts b/packages/client/ui-settings-web-search/src/client/index.ts index b4ed1c6779..6f73122098 100644 --- a/packages/client/ui-settings-web-search/src/client/index.ts +++ b/packages/client/ui-settings-web-search/src/client/index.ts @@ -1,12 +1,11 @@ /** - * The web-search settings page, browser half: the switch between DeepSeek - * Messages search and the Search and Fetch APIs, which writes the `web` - * provider selection, and the selected API's key, endpoint, and per-search - * limit over the `web-search-deepseek` or `web-deepseek-api` namespace. The - * page registers into the Plugins page's `plugins.item` slot while the Host - * serves `web-search-deepseek`, so a deployment without that provider shows no - * trace of it; the switch appears only while the Host also serves `web` and - * `web-deepseek-api`. + * The web search and fetch settings page, browser half: one provider menu per + * capability, which writes the `web` provider selection, the Search API result + * count over the `web-deepseek-api` namespace, and legacy Messages search's + * key, endpoint, and search limit over `web-search-deepseek`. The page + * registers into the Plugins page's `plugins.item` slot while the Host serves + * `web`; a provider's own controls appear only while the Host serves its + * namespace. */ // Type-only: pulls the locale plugin's Context merge (ctx.locale). @@ -26,13 +25,13 @@ import { en, ja, es, fr, de, ru, pt, zh, type WebSearchSettingsLocaleKey } from export type { WebSearchCardProps } from './WebSearchCard.tsx' export type { - WebApiSettings, WebProviderSelection, WebSearchCardFace, WebSearchCardScopes, WebSearchCardState, WebSearchSettings, + WebApiSettings, WebLegacySettings, WebProviderSelection, WebSearchCardFace, WebSearchCardScopes, WebSearchCardState, } from './web-search-card-controller.ts' export type { WebSearchSettingsLocaleKey } from './locales.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { - /** Web-search settings page copy. */ + /** Web search and fetch settings page copy. */ 'settings.webSearch': WebSearchSettingsLocaleKey } } @@ -41,27 +40,36 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { export const NS = 'settings.webSearch' /** Required services (cordis fiber inject). */ -export const inject = ['slots', 'locale', 'remote', 'remote.credentials', 'configForms'] +export const inject = ['slots', 'locale', 'remote', 'remote.credentials', 'remote.web', 'configForms'] /** - * Mount the web-search settings page while the Host serves its namespace. + * Mount the web search and fetch settings page while the Host serves the `web` namespace. * @param ctx - the browser plugin context. */ export function apply(ctx: ClientContext): void { const t = ctx.locale.bind(NS) ctx.effect(() => ctx.locale.register(NS, { zh, en, ja, es, fr, de, ru, pt }), 'ui-settings-web-search: dictionaries') const card = new WebSearchCardController({ - web: ctx.configForms.get(WEB_NS), messages: ctx.configForms.get(WEB_SEARCH_NS), api: ctx.configForms.get(WEB_API_NS), + web: ctx.configForms.get(WEB_NS), api: ctx.configForms.get(WEB_API_NS), legacy: ctx.configForms.get(WEB_SEARCH_NS), }, ctx) ctx.effect(() => () => { card.dispose() }, 'ui-settings-web-search: form subscription') - // The credential the page reports is not part of any settings section, so + // The credentials the page reports are not part of any settings section, so // no scope publishes when one is written. This is the only signal // that a key written on another surface reached the Host. ctx.effect( () => ctx.remote.$on('credentials/reference-updated', (ref) => { card.refreshCredential(ref) }), 'ui-settings-web-search: credential invalidations', ) - ctx.effect(() => ctx.configForms.whileServed([WEB_SEARCH_NS], () => ctx.slots.inject('plugins.item', () => ctx.slots.register({ + // Plugins register and retract providers without touching any settings + // section, and a reconnected Host may hold a different set. + ctx.effect(() => { + const disposers = [ + ctx.remote.$on('web/providers-changed', () => { card.refreshProviders() }), + ctx.on('connection/reset', () => { card.refreshProviders() }), + ] + return () => { for (const dispose of disposers) dispose() } + }, 'ui-settings-web-search: provider invalidations') + ctx.effect(() => ctx.configForms.whileServed([WEB_NS], () => ctx.slots.inject('plugins.item', () => ctx.slots.register({ name: 'plugins.item', id: 'web-search', order: 20, label: () => t('title'), locale: NS, inject: () => card.inject(), }, WebSearchCard))), 'ui-settings-web-search: page') } diff --git a/packages/client/ui-settings-web-search/src/client/locales.ts b/packages/client/ui-settings-web-search/src/client/locales.ts index 1290fb9000..1165f08054 100644 --- a/packages/client/ui-settings-web-search/src/client/locales.ts +++ b/packages/client/ui-settings-web-search/src/client/locales.ts @@ -4,30 +4,43 @@ import type { SettingsFormLabels } from '@deepseek-ai/dsh-client-ui-primitives' /** Locale keys the page renders. */ export type WebSearchSettingsLocaleKey = - | 'title' | 'description' | 'useSearchApi' | 'useSearchApiHint' | 'searchGuide' | 'searchGuideUrl' - | 'apiKey' | 'apiKeyHint' | 'apiKeySet' | 'apiKeyUnset' - | 'baseUrl' | 'baseUrlHint' | 'searchApiBaseUrl' | 'searchApiBaseUrlHint' | 'maxUses' | 'maxUsesHint' | 'count' | 'countHint' + | 'title' | 'description' | 'searchSection' | 'fetchSection' + | 'providerDeepSeek' | 'providerLocal' | 'providerDeepSeekFetch' | 'providerMissing' | 'providerAuto' + | 'providerLegacy' | 'legacyKeyHint' | 'maxUses' | 'maxUsesHint' | 'invalidMaxUses' + | 'providersFailed' | 'retry' + | 'pluginProviderHint' | 'searchGuide' | 'searchGuideUrl' + | 'credential' | 'apiKeySet' | 'apiKeyUnsetApi' + | 'baseUrl' | 'baseUrlHint' + | 'count' | 'countHint' | 'overridden' | 'reset' | 'readOnly' | 'unavailable' | 'save' | 'saving' | 'saveFailed' | 'invalidNumber' | 'invalidCount' /** English copy. */ export const en: Record = { - title: 'Web search', - description: 'Set up the DeepSeek search provider.', - useSearchApi: 'Use the new Web Search API', - useSearchApiHint: 'Off by default: search runs through DeepSeek Messages. For the new Web Search API\'s features and pricing, see the ', + title: 'Web search and fetch', + description: 'Choose the providers web search and web fetch use.', + searchSection: 'Web search provider', + fetchSection: 'Web fetch provider', + providerDeepSeek: 'DeepSeek Search API', + providerLegacy: 'DeepSeek (legacy)', + legacyKeyHint: 'Uses the signed-in account on account-model requests; otherwise uses this API key ({ref}), shared with Models by default.', + maxUses: 'Maximum searches per request', + maxUsesHint: 'Maximum server-side searches per Messages request: a positive integer, default 5.', + invalidMaxUses: 'Enter a positive integer, or leave blank to use the default.', + providerLocal: 'Direct fetch', + providerDeepSeekFetch: 'DeepSeek Fetch API', + providerMissing: '{id} (not loaded)', + providerAuto: 'Automatic', + providersFailed: "Couldn't load the provider list, so this menu may be incomplete.", + retry: 'Retry', + pluginProviderHint: 'Configure this provider in its own plugin settings.', + credential: 'API key', + baseUrl: 'Base URL', + baseUrlHint: 'Include the API prefix. Leave blank to use the official address. The provider appends {path}.', + apiKeyUnsetApi: 'No key is configured.', searchGuide: 'Search & Fetch guide', searchGuideUrl: 'https://api-docs.deepseek.com/guides/search', - apiKey: 'API key', - apiKeyHint: 'Stored outside the settings file. Leave blank to keep the current key.', apiKeySet: 'A key is configured.', - apiKeyUnset: 'No key is configured; only conversations using a DeepSeek Account model can search, through the default endpoint.', - baseUrl: 'Messages API base URL', - baseUrlHint: 'Include the API prefix, such as https://api.deepseek.com/anthropic/v1. The provider appends /messages. Leave blank to restore the default.', - searchApiBaseUrl: 'Search / Fetch API base URL', - searchApiBaseUrlHint: 'Include the API prefix, such as https://api.deepseek.com/v1/tools. The provider appends /search or /fetch. Leave blank to restore the default.', - maxUses: 'Max searches per request', - maxUsesHint: 'How many searches the auxiliary model may run within one Messages request. Applies only to the Messages API.', count: 'Results per search', countHint: 'Results returned per query: an integer from 1 to 50, default 10. This controls result count, not search calls. web_search keeps no more sources than its own result limit.', overridden: 'Overridden', @@ -43,22 +56,30 @@ export const en: Record = { /** Simplified Chinese copy. */ export const zh: Record = { - title: '网页搜索', - description: '设置 DeepSeek 的搜索提供方。', - useSearchApi: '启用新版 Web 搜索 API', - useSearchApiHint: '默认关闭,搜索通过 DeepSeek Messages 完成。新版 Web 搜索 API 功能与计费详见', - searchGuide: '网页搜索与抓取指南', - searchGuideUrl: 'https://api-docs.deepseek.com/zh-cn/guides/search', - apiKey: 'API Key', - apiKeyHint: '不写入设置文件。留空表示保持当前密钥。', - apiKeySet: '已配置密钥。', - apiKeyUnset: '未配置密钥;仅使用 DeepSeek 账号模型的对话可以通过默认接口地址搜索。', - baseUrl: 'Messages API 基础地址', - baseUrlHint: '填写含 API 前缀的地址,例如 https://api.deepseek.com/anthropic/v1;程序追加 /messages,留空恢复默认。', - searchApiBaseUrl: 'Search / Fetch API 基础地址', - searchApiBaseUrlHint: '填写含 API 前缀的地址,例如 https://api.deepseek.com/v1/tools;程序追加 /search 或 /fetch,留空恢复默认。', + title: '网页搜索与获取', + description: '选择网页搜索与网页获取使用的提供方。', + searchSection: '网页搜索提供方', + fetchSection: '网页获取提供方', + providerDeepSeek: 'DeepSeek Search API', + providerLegacy: 'DeepSeek(旧版)', + legacyKeyHint: '账号模型发起的请求使用已登录账号;其他请求使用此 API Key({ref}),默认与「模型」页共用', maxUses: '单次请求最多搜索次数', - maxUsesHint: '辅助模型在一次 Messages 请求内最多可执行的搜索次数,仅适用于 Messages API。', + maxUsesHint: '每次 Messages 请求允许的服务端搜索次数上限,须为正整数,默认 5', + invalidMaxUses: '请填正整数;留空表示使用默认值', + providerLocal: '本机直接获取', + providerDeepSeekFetch: 'DeepSeek Fetch API', + providerMissing: '{id}(未加载)', + providerAuto: '自动选择', + providersFailed: '无法读取提供方列表,此菜单可能不完整', + retry: '重试', + pluginProviderHint: '请在该插件自己的设置中配置', + credential: 'API Key', + baseUrl: 'Base URL', + baseUrlHint: '填写含 API 前缀的地址;留空时使用官方地址;程序追加 {path}', + apiKeyUnsetApi: '未配置密钥。', + searchGuide: '网页搜索与获取指南', + searchGuideUrl: 'https://api-docs.deepseek.com/zh-cn/guides/search', + apiKeySet: '已配置密钥。', count: '每次搜索结果数', countHint: '每个查询返回的结果条数,须为 1–50 的整数,默认 10;不控制搜索调用次数。web_search 保留的来源数不超过其自身的结果上限。', overridden: '已覆盖', @@ -83,22 +104,30 @@ export function formLabels(t: (key: WebSearchSettingsLocaleKey) => string): Sett /** Japanese copy, checked against the English keys. */ export const ja = { - 'title': 'Web 検索', - 'description': 'DeepSeek 検索プロバイダーを設定します。', - 'useSearchApi': '新しい Web Search API を使用する', - 'useSearchApiHint': 'デフォルトではオフです。検索は DeepSeek Messages 経由で実行されます。新しい Web Search API の機能と料金については、', + 'title': 'Web 検索と取得', + 'description': 'Web 検索と Web 取得に使用するプロバイダーを選択します。', + 'searchSection': 'Web 検索プロバイダー', + 'fetchSection': 'Web 取得プロバイダー', + 'providerDeepSeek': 'DeepSeek Search API', + 'providerLegacy': 'DeepSeek(旧版)', + 'legacyKeyHint': 'アカウントモデルのリクエストではログイン中のアカウントを使い、それ以外ではこの API キー({ref}、既定ではモデル設定と共有)を使います。', + 'maxUses': 'リクエストごとの最大検索回数', + 'maxUsesHint': 'Messages リクエストごとのサーバー側検索回数の上限です。正の整数で、既定値は 5 です。', + 'invalidMaxUses': '正の整数を入力するか、既定値を使う場合は空欄にしてください。', + 'providerLocal': '直接取得', + 'providerDeepSeekFetch': 'DeepSeek Fetch API', + 'providerMissing': '{id}(未読み込み)', + 'providerAuto': '自動', + 'providersFailed': 'プロバイダー一覧を読み込めなかったため、このメニューは不完全な可能性があります。', + 'retry': '再試行', + 'pluginProviderHint': 'このプロバイダーは、そのプラグイン自身の設定で構成してください。', + 'credential': 'API キー', + 'baseUrl': 'ベース URL', + 'baseUrlHint': 'API プレフィックスを含めてください。空欄の場合は公式アドレスを使用します。プロバイダーは {path} を追加します。', + 'apiKeyUnsetApi': 'キーが設定されていません。', 'searchGuide': '検索と取得のガイド', 'searchGuideUrl': 'https://api-docs.deepseek.com/guides/search', - 'apiKey': 'API キー', - 'apiKeyHint': '設定ファイルの外に保存されます。現在のキーを維持するには空欄のままにしてください。', 'apiKeySet': 'キーが設定されています。', - 'apiKeyUnset': 'キーが設定されていません。DeepSeek Account のモデルを使用する会話のみ、デフォルトのエンドポイント経由で検索できます。', - 'baseUrl': 'Messages API ベース URL', - 'baseUrlHint': 'https://api.deepseek.com/anthropic/v1 など、API プレフィックスを含めてください。プロバイダーは /messages を追加します。デフォルトに戻すには空欄にしてください。', - 'searchApiBaseUrl': 'Search / Fetch API ベース URL', - 'searchApiBaseUrlHint': 'https://api.deepseek.com/v1/tools など、API プレフィックスを含めてください。プロバイダーは /search または /fetch を追加します。デフォルトに戻すには空欄にしてください。', - 'maxUses': 'リクエストあたりの最大検索回数', - 'maxUsesHint': '1 回の Messages リクエスト内で補助モデルが実行できる検索回数です。Messages API にのみ適用されます。', 'count': '検索あたりの結果数', 'countHint': 'クエリごとに返される結果数: 1〜50 の整数、デフォルトは 10 です。これは結果数であり、検索呼び出し数ではありません。web_search は自身の結果上限を超えるソースを保持しません。', 'overridden': '上書き済み', @@ -114,22 +143,30 @@ export const ja = { /** Spanish dictionary, checked against the English keys. */ export const es = { - title: 'búsqueda web', - description: 'Configure el proveedor de búsqueda DeepSeek.', - useSearchApi: 'Utilice la nueva búsqueda web API', - useSearchApiHint: 'Desactivado de forma predeterminada: la búsqueda se ejecuta a través de Mensajes DeepSeek. Para conocer las características y los precios del nuevo Web Search API, consulte el ', + title: 'Búsqueda y obtención web', + description: 'Elija los proveedores que usan la búsqueda web y la obtención web.', + searchSection: 'Proveedor de búsqueda web', + fetchSection: 'Proveedor de obtención web', + providerDeepSeek: 'DeepSeek Search API', + providerLegacy: 'DeepSeek (heredado)', + legacyKeyHint: 'Las solicitudes del modelo de cuenta usan la cuenta conectada; las demás usan esta clave API ({ref}), compartida con Modelos de forma predeterminada.', + maxUses: 'Búsquedas máximas por solicitud', + maxUsesHint: 'Máximo de búsquedas del servidor por solicitud Messages: entero positivo, predeterminado 5.', + invalidMaxUses: 'Introduzca un entero positivo o deje vacío para usar el valor predeterminado.', + providerLocal: 'Obtención directa', + providerDeepSeekFetch: 'DeepSeek Fetch API', + providerMissing: '{id} (no cargado)', + providerAuto: 'Automático', + providersFailed: 'No se pudo cargar la lista de proveedores, por lo que este menú puede estar incompleto.', + retry: 'Reintentar', + pluginProviderHint: 'Configure este proveedor en la configuración de su propio complemento.', + credential: 'Clave de API', + baseUrl: 'URL base', + baseUrlHint: 'Incluya el prefijo de la API. Déjelo en blanco para usar la dirección oficial. El proveedor agrega {path}.', + apiKeyUnsetApi: 'No hay ninguna clave configurada.', searchGuide: 'Guía de búsqueda y recuperación', searchGuideUrl: 'https://api-docs.deepseek.com/guides/search', - apiKey: 'Clave de API', - apiKeyHint: 'Almacenado fuera del archivo de configuración. Déjelo en blanco para conservar la clave actual.', apiKeySet: 'Se configura una clave.', - apiKeyUnset: 'No hay ninguna clave configurada; solo se pueden buscar conversaciones que utilicen un modelo de cuenta DeepSeek, a través del punto final predeterminado.', - baseUrl: 'Mensajes API URL base', - baseUrlHint: 'Incluya el prefijo API, como https://api.deepseek.com/anthropic/v1.. El proveedor agrega /messages. Déjelo en blanco para restaurar el valor predeterminado.', - searchApiBaseUrl: 'Buscar/obtener URL base API', - searchApiBaseUrlHint: 'Incluya el prefijo API, como https://api.deepseek.com/v1/tools.. El proveedor agrega /search o /fetch. Déjelo en blanco para restaurar el valor predeterminado.', - maxUses: 'Búsquedas máximas por solicitud', - maxUsesHint: 'Cuántas búsquedas puede ejecutar el modelo auxiliar dentro de una solicitud de Mensajes. Aplica sólo para Mensajes API.', count: 'Resultados por búsqueda', countHint: 'Resultados devueltos por consulta: un número entero del 1 al 50, valor predeterminado 10. Esto controla el recuento de resultados, no las llamadas de búsqueda. web_search no guarda más fuentes que su propio límite de resultados.', overridden: 'Anulado', @@ -145,22 +182,30 @@ export const es = { /** French dictionary, checked against the English keys. */ export const fr = { - title: 'Recherche sur le Web', - description: 'Configurez le moteur de recherche DeepSeek.', - useSearchApi: 'Utilisez la nouvelle recherche Web API', - useSearchApiHint: "Désactivé par défaut : la recherche s'effectue dans les messages DeepSeek. Pour connaître les fonctionnalités et les tarifs du nouveau Web Search API, consultez le ", + title: 'Recherche et récupération web', + description: 'Choisissez les fournisseurs utilisés par la recherche web et la récupération web.', + searchSection: 'Fournisseur de recherche web', + fetchSection: 'Fournisseur de récupération web', + providerDeepSeek: 'DeepSeek Search API', + providerLegacy: 'DeepSeek (ancienne version)', + legacyKeyHint: 'Les requêtes du modèle de compte utilisent le compte connecté ; les autres utilisent cette clé API ({ref}), partagée avec Modèles par défaut.', + maxUses: 'Recherches maximales par requête', + maxUsesHint: 'Maximum de recherches côté serveur par requête Messages : entier positif, 5 par défaut.', + invalidMaxUses: 'Saisissez un entier positif ou laissez vide pour utiliser la valeur par défaut.', + providerLocal: 'Récupération directe', + providerDeepSeekFetch: 'DeepSeek Fetch API', + providerMissing: '{id} (non chargé)', + providerAuto: 'Automatique', + providersFailed: 'Impossible de charger la liste des fournisseurs ; ce menu peut être incomplet.', + retry: 'Réessayer', + pluginProviderHint: 'Configurez ce fournisseur dans les paramètres de son propre plugin.', + credential: 'Clé API', + baseUrl: 'URL de base', + baseUrlHint: "Incluez le préfixe de l'API. Laissez vide pour utiliser l'adresse officielle. Le fournisseur ajoute {path}.", + apiKeyUnsetApi: "Aucune clé n'est configurée.", searchGuide: 'Guide de recherche et de récupération', searchGuideUrl: 'https://api-docs.deepseek.com/guides/search', - apiKey: 'Clé API', - apiKeyHint: 'Stocké en dehors du fichier de paramètres. Laissez vide pour conserver la clé actuelle.', apiKeySet: 'Une clé est configurée.', - apiKeyUnset: "Aucune clé n'est configurée ; seules les conversations utilisant un modèle de compte DeepSeek peuvent effectuer une recherche, via le point de terminaison par défaut.", - baseUrl: 'URL de base des messages API', - baseUrlHint: 'Incluez le préfixe API, tel que https://api.deepseek.com/anthropic/v1.. Le fournisseur ajoute /messages. Laissez vide pour restaurer la valeur par défaut.', - searchApiBaseUrl: "Rechercher/récupérer l'URL de base de API", - searchApiBaseUrlHint: 'Incluez le préfixe API, tel que https://api.deepseek.com/v1/tools.. Le fournisseur ajoute /search ou /fetch. Laissez vide pour restaurer la valeur par défaut.', - maxUses: 'Nombre maximum de recherches par requête', - maxUsesHint: "Combien de recherches le modèle auxiliaire peut exécuter dans une requête de messages. S'applique uniquement aux messages API.", count: 'Résultats par recherche', countHint: 'Résultats renvoyés par requête : un nombre entier de 1 à 50, 10 par défaut. Cela contrôle le nombre de résultats, pas les appels de recherche. web_search ne conserve pas plus de sources que sa propre limite de résultats.', overridden: 'Remplacé', @@ -176,22 +221,30 @@ export const fr = { /** German dictionary, checked against the English keys. */ export const de = { - title: 'Websuche', - description: 'Richten Sie den Suchanbieter DeepSeek ein.', - useSearchApi: 'Nutzen Sie die neue Websuche API', - useSearchApiHint: 'Standardmäßig deaktiviert: Die Suche erfolgt über DeepSeek-Nachrichten. Informationen zu den Funktionen und Preisen der neuen Websuche API finden Sie im ', + title: 'Websuche und Webabruf', + description: 'Wählen Sie die Anbieter für Websuche und Webabruf.', + searchSection: 'Anbieter für Websuche', + fetchSection: 'Anbieter für Webabruf', + providerDeepSeek: 'DeepSeek Search API', + providerLegacy: 'DeepSeek (Legacy)', + legacyKeyHint: 'Kontomodellanfragen verwenden das angemeldete Konto; andere verwenden diesen API-Schlüssel ({ref}), standardmäßig mit Modelle geteilt.', + maxUses: 'Maximale Suchen pro Anfrage', + maxUsesHint: 'Maximale serverseitige Suchen pro Messages-Anfrage: positive Ganzzahl, Standard 5.', + invalidMaxUses: 'Geben Sie eine positive Ganzzahl ein oder lassen Sie das Feld für den Standard leer.', + providerLocal: 'Direkter Abruf', + providerDeepSeekFetch: 'DeepSeek Fetch API', + providerMissing: '{id} (nicht geladen)', + providerAuto: 'Automatisch', + providersFailed: 'Die Anbieterliste konnte nicht geladen werden, daher ist dieses Menü möglicherweise unvollständig.', + retry: 'Erneut versuchen', + pluginProviderHint: 'Konfigurieren Sie diesen Anbieter in den Einstellungen seines eigenen Plugins.', + credential: 'API-Schlüssel', + baseUrl: 'Basis-URL', + baseUrlHint: 'Fügen Sie das API-Präfix ein. Leer lassen, um die offizielle Adresse zu verwenden. Der Anbieter hängt {path} an.', + apiKeyUnsetApi: 'Es ist kein Schlüssel konfiguriert.', searchGuide: 'Leitfaden zum Suchen und Abrufen', searchGuideUrl: 'https://api-docs.deepseek.com/guides/search', - apiKey: 'API-Schlüssel', - apiKeyHint: 'Außerhalb der Einstellungsdatei gespeichert. Lassen Sie das Feld leer, um den aktuellen Schlüssel beizubehalten.', apiKeySet: 'Ein Schlüssel ist konfiguriert.', - apiKeyUnset: 'Es ist kein Schlüssel konfiguriert; Nur Konversationen, die ein DeepSeek-Kontomodell verwenden, können über den Standardendpunkt suchen.', - baseUrl: 'Nachrichten API Basis-URL', - baseUrlHint: 'Fügen Sie das Präfix API ein, z. B. https://api.deepseek.com/anthropic/v1.. Der Anbieter hängt /messages an. Lassen Sie das Feld leer, um die Standardeinstellung wiederherzustellen.', - searchApiBaseUrl: 'Suchen/Abrufen der API-Basis-URL', - searchApiBaseUrlHint: 'Fügen Sie das Präfix API ein, z. B. https://api.deepseek.com/v1/tools.. Der Anbieter hängt /search oder /fetch an. Lassen Sie das Feld leer, um die Standardeinstellung wiederherzustellen.', - maxUses: 'Maximale Anzahl an Suchanfragen pro Anfrage', - maxUsesHint: 'Wie viele Suchvorgänge das Hilfsmodell innerhalb einer Nachrichtenanforderung ausführen darf. Gilt nur für die Nachrichten API.', count: 'Ergebnisse pro Suche', countHint: 'Pro Abfrage zurückgegebene Ergebnisse: eine Ganzzahl von 1 bis 50, Standard 10. Dies steuert die Ergebnisanzahl, nicht Suchaufrufe. web_search behält nicht mehr Quellen als sein eigenes Ergebnislimit.', overridden: 'Überschrieben', @@ -207,22 +260,30 @@ export const de = { /** Russian dictionary, checked against the English keys. */ export const ru = { - title: 'Веб-поиск', - description: 'Настройте поисковую систему DeepSeek.', - useSearchApi: 'Используйте новый веб-поиск API', - useSearchApiHint: 'По умолчанию отключено: поиск осуществляется по сообщениям DeepSeek. Информацию о новых функциях и ценах веб-поиска API см. ', + title: 'Веб-поиск и загрузка', + description: 'Выберите поставщиков для веб-поиска и загрузки веб-страниц.', + searchSection: 'Поставщик веб-поиска', + fetchSection: 'Поставщик загрузки веб-страниц', + providerDeepSeek: 'DeepSeek Search API', + providerLegacy: 'DeepSeek (прежняя версия)', + legacyKeyHint: 'Запросы модели аккаунта используют активный аккаунт; остальные — этот ключ API ({ref}), по умолчанию общий с настройками моделей.', + maxUses: 'Максимум поисков на запрос', + maxUsesHint: 'Максимум серверных поисков на запрос Messages: положительное целое, по умолчанию 5.', + invalidMaxUses: 'Введите положительное целое или оставьте пустым для значения по умолчанию.', + providerLocal: 'Прямая загрузка', + providerDeepSeekFetch: 'DeepSeek Fetch API', + providerMissing: '{id} (не загружен)', + providerAuto: 'Автоматически', + providersFailed: 'Не удалось загрузить список поставщиков, поэтому это меню может быть неполным.', + retry: 'Повторить', + pluginProviderHint: 'Настройте этого поставщика в настройках его собственного плагина.', + credential: 'Ключ API', + baseUrl: 'Базовый URL', + baseUrlHint: 'Включите префикс API. Оставьте пустым, чтобы использовать официальный адрес. Поставщик добавляет {path}.', + apiKeyUnsetApi: 'Ключ не настроен.', searchGuide: 'Руководство по поиску и получению', searchGuideUrl: 'https://api-docs.deepseek.com/guides/search', - apiKey: 'Ключ API', - apiKeyHint: 'Хранится вне файла настроек. Оставьте пустым, чтобы сохранить текущий ключ.', apiKeySet: 'Ключ настроен.', - apiKeyUnset: 'Ключ не настроен; только разговоры, использующие модель учетной записи DeepSeek, могут выполнять поиск через конечную точку по умолчанию.', - baseUrl: 'Базовый URL-адрес сообщений API', - baseUrlHint: 'Включите префикс API, например https://api.deepseek.com/anthropic/v1.. Поставщик добавляет /messages. Оставьте пустым, чтобы восстановить значение по умолчанию.', - searchApiBaseUrl: 'Найдите/получите базовый URL-адрес API', - searchApiBaseUrlHint: 'Включите префикс API, например https://api.deepseek.com/v1/tools.. Поставщик добавляет /search или /fetch. Оставьте пустым, чтобы восстановить значение по умолчанию.', - maxUses: 'Максимальное количество запросов на запрос', - maxUsesHint: 'Сколько поисков вспомогательная модель может выполнить в рамках одного запроса сообщений. Применяется только к сообщениям API.', count: 'Результаты по поиску', countHint: 'Результаты, возвращаемые по запросу: целое число от 1 до 50, по умолчанию 10. Это контролирует количество результатов, а не поисковые вызовы. web_search хранит не больше источников, чем его собственный лимит результатов.', overridden: 'Переопределено', @@ -238,22 +299,30 @@ export const ru = { /** Portuguese dictionary, checked against the English keys. */ export const pt = { - title: 'Pesquisa na web', - description: 'Configure o provedor de pesquisa DeepSeek.', - useSearchApi: 'Use a nova Pesquisa na Web API', - useSearchApiHint: 'Desativado por padrão: a pesquisa é executada em DeepSeek Mensagens. Para conhecer os recursos e preços da nova Pesquisa na Web API, consulte o ', + title: 'Pesquisa e obtenção na web', + description: 'Escolha os provedores usados pela pesquisa na web e pela obtenção na web.', + searchSection: 'Provedor de pesquisa na web', + fetchSection: 'Provedor de obtenção na web', + providerDeepSeek: 'DeepSeek Search API', + providerLegacy: 'DeepSeek (legado)', + legacyKeyHint: 'Pedidos do modelo de conta usam a conta conectada; os demais usam esta chave API ({ref}), compartilhada com Modelos por padrão.', + maxUses: 'Máximo de pesquisas por pedido', + maxUsesHint: 'Máximo de pesquisas no servidor por pedido Messages: inteiro positivo, padrão 5.', + invalidMaxUses: 'Insira um inteiro positivo ou deixe vazio para usar o padrão.', + providerLocal: 'Obtenção direta', + providerDeepSeekFetch: 'DeepSeek Fetch API', + providerMissing: '{id} (não carregado)', + providerAuto: 'Automático', + providersFailed: 'Não foi possível carregar a lista de provedores, por isso este menu pode estar incompleto.', + retry: 'Tentar novamente', + pluginProviderHint: 'Configure este provedor nas configurações do próprio plugin.', + credential: 'Chave de API', + baseUrl: 'URL base', + baseUrlHint: 'Inclua o prefixo da API. Deixe em branco para usar o endereço oficial. O provedor anexa {path}.', + apiKeyUnsetApi: 'Nenhuma chave está configurada.', searchGuide: 'Guia de pesquisa e busca', searchGuideUrl: 'https://api-docs.deepseek.com/guides/search', - apiKey: 'API chave', - apiKeyHint: 'Armazenado fora do arquivo de configurações. Deixe em branco para manter a chave atual.', apiKeySet: 'Uma chave está configurada.', - apiKeyUnset: 'Nenhuma chave está configurada; somente conversas que usam um modelo de conta DeepSeek podem pesquisar, por meio do endpoint padrão.', - baseUrl: 'URL base de mensagens API', - baseUrlHint: 'Inclua o prefixo API, como https://api.deepseek.com/anthropic/v1. O provedor anexa /messages. Deixe em branco para restaurar o padrão.', - searchApiBaseUrl: 'Pesquisar/buscar API URL base', - searchApiBaseUrlHint: 'Inclua o prefixo API, como https://api.deepseek.com/v1/tools. O provedor anexa /search ou /fetch. Deixe em branco para restaurar o padrão.', - maxUses: 'Máximo de pesquisas por solicitação', - maxUsesHint: 'Quantas pesquisas o modelo auxiliar pode executar em uma solicitação de Mensagens. Aplica-se somente às Mensagens API.', count: 'Resultados por pesquisa', countHint: 'Resultados retornados por consulta: um número inteiro de 1 a 50, padrão 10. Isso controla a contagem de resultados, não as chamadas de pesquisa. web_search não mantém mais fontes do que seu próprio limite de resultados.', overridden: 'Substituído', diff --git a/packages/client/ui-settings-web-search/src/client/web-search-card-controller.ts b/packages/client/ui-settings-web-search/src/client/web-search-card-controller.ts index b4f014d433..42dbaea4e0 100644 --- a/packages/client/ui-settings-web-search/src/client/web-search-card-controller.ts +++ b/packages/client/ui-settings-web-search/src/client/web-search-card-controller.ts @@ -1,26 +1,38 @@ /** - * The web-search page's staged form over three settings namespaces: `web`, - * whose provider selection the Search API switch writes, `web-search-deepseek` - * for Messages search, and `web-deepseek-api` for the Search and Fetch APIs. + * The web search and fetch page's staged form over three settings namespaces: + * `web`, whose `searchProvider` and `fetchProvider` the two provider menus + * write, `web-deepseek-api` for the DeepSeek Search and Fetch APIs, and + * `web-search-deepseek` for legacy Messages search. * - * The switch selects `deepseek-search-api` and `deepseek-fetch-api` together, or - * Messages search with local HTTP fetch. A selection equal to the deployment's - * composed value clears the override instead of storing a copy of it. + * A selection equal to the deployment's composed value clears the override + * instead of storing a copy of it, and while the deployment composes no + * provider for a capability its menu also offers automatic selection, which + * clears the override. The menus offer the providers the Host reports through + * `web/listProviders`, the official ones first in a fixed order and the rest + * in registration order, plus a selected id the Host no longer registers. A + * failed read is reported on the page with a retry. * - * The key is the one control that does not live in a section: its literal - * never rides a response, so the page learns only whether one is configured - * and writes it through the credentials domain, addressed by the reference the - * selected API's section names. It is still staged with the rest of the form, - * so one save covers everything the page shows. + * The DeepSeek Search and Fetch APIs take no credential settings here: they + * follow the DeepSeek credential of the conversation, and their own endpoint + * fields are YAML-only. Legacy Messages search keeps its key control. + * + * The legacy key's literal never rides a response, so the page learns only + * whether one is configured and writes it through the credentials domain, + * addressed by the reference `web-search-deepseek.apiKeyEnv` names. A key + * draft belongs to the reference its control addressed when it was typed: it + * is dropped when that reference changes or the page stops showing the + * control, and a save writes it under that reference. The key is still staged + * with the rest of the form, so one save covers everything the page shows. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' // Type-only: pulls the ctx.remote merge into this program. import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type { WebProviderInfo, WebProviderList as ProviderList } from '@deepseek-ai/dsh-web/types' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import { SettingsFormModel, settingsNumberField, settingsTextField, - type SettingsFieldState, type SettingsFormActions, type SettingsFormShell, type SettingsFormScope, type SettingsFormScopeSnapshot, + type SettingsFieldState, type SettingsFormActions, type SettingsFormShell, type SettingsFormScope, } from '@deepseek-ai/dsh-client-ui-primitives' /** @@ -33,17 +45,37 @@ export const WEB_API_NS = 'web-deepseek-api' /** Namespace of the web service's provider selection. */ export const WEB_NS = 'web' -/** Provider ids the switch writes into the web service's selection. */ -const PROVIDERS = { - searchProvider: { api: 'deepseek-search-api', messages: 'deepseek-official' }, - fetchProvider: { api: 'deepseek-fetch-api', messages: 'http' }, +/** Provider ids of the official providers whose settings this page edits. */ +export const PROVIDER_IDS = { + apiSearch: 'deepseek-search-api', + legacySearch: 'deepseek-official', + localFetch: 'http', + apiFetch: 'deepseek-fetch-api', } as const -/** Credential reference the providers resolve when a section names none. */ +/** + * The official providers each menu lists first, in this order, while the Host + * registers them; the Host's own list stays in registration order. + */ +const MENU_ORDER = { + search: [PROVIDER_IDS.apiSearch, PROVIDER_IDS.legacySearch], + fetch: [PROVIDER_IDS.localFetch, PROVIDER_IDS.apiFetch], +} as const satisfies Record + +/** Credential reference legacy Messages search resolves when its section names none. */ const DEFAULT_API_KEY_REF = 'DEEPSEEK_API_KEY' /** Form field the credential control stages under. */ -const API_KEY_FIELD = 'apiKey' +const API_KEY_FIELD = 'legacyApiKey' + +/** Settings-section fields the face edits as text. */ +export type WebSectionField = 'count' | 'messagesBaseURL' | 'maxUses' + +/** The legacy key control. */ +export type WebApiKeyField = 'legacyApiKey' + +/** Controls the face edits as text: the key controls and the settings-section fields. */ +export type WebSearchCardField = WebApiKeyField | WebSectionField /** The web service's provider selection. */ export interface WebProviderSelection { @@ -54,7 +86,7 @@ export interface WebProviderSelection { } /** The Messages search fields this page edits. */ -export interface WebSearchSettings { +export interface WebLegacySettings { /** Credential reference naming the environment key. */ apiKeyEnv?: string /** Messages endpoint; blank inherits the provider default. */ @@ -65,15 +97,102 @@ export interface WebSearchSettings { /** The Search and Fetch API fields this page edits. */ export interface WebApiSettings { - /** Credential reference naming the environment key. */ - apiKeyEnv?: string - /** Search and Fetch API base with its path prefix; blank inherits the provider default. */ - baseURL?: string /** Search API results per search. */ count?: number } -/** What the credentials domain last reported, and for which reference. */ +/** One entry of a provider menu. */ +export interface WebProviderOption { + /** Provider id, written into the selection. */ + id: string + /** Title the provider registered with, if any. */ + title?: string + /** Description the provider registered with, if any; the page shows it under the menu while the provider is selected. */ + description?: WebProviderInfo['description'] + /** True for a selected id the Host does not currently register. */ + missing: boolean +} + +/** What the Search section shows for its selected provider. */ +export type WebSearchKind = 'deepseek' | 'legacy' | 'plugin' | 'none' +/** What the Fetch section shows for its selected provider. */ +export type WebFetchKind = 'local' | 'deepseek' | 'plugin' | 'none' + +/** The legacy key control. */ +export interface WebApiKeyState extends SettingsFieldState { + /** Whether the Host reports a credential configured for the referenced key. */ + configured: boolean + /** Whether the credentials domain accepts a write for it; false disables the control. */ + writable: boolean + /** The credential reference the control writes: `web-search-deepseek.apiKeyEnv`, or its default. */ + ref: string +} + +/** One provider section: its menu and the controls its selection shows. */ +export interface WebProviderSectionState { + /** Id of the selected provider, or the empty string when the selection names none. */ + selected: string + /** Menu entries: the registered providers, plus a selected id that is not registered. */ + options: readonly WebProviderOption[] + /** Which provider-specific controls the section shows. */ + kind: K + /** Whether the menu offers automatic selection, because the deployment composes no provider for this capability. */ + auto: boolean +} + +/** What the web search and fetch page renders. */ +export interface WebSearchCardState extends SettingsFormShell { + /** Invalid edits or failed saves that changing providers must keep visible. */ + selectionBlocked: boolean + /** + * Whether the latest provider list read failed. The menus keep the last list + * read, or before any list arrives only the selected ids, so they may be + * incomplete. + */ + providersFailed: boolean + /** The Search section. */ + search: WebProviderSectionState + /** The Fetch section. */ + fetch: WebProviderSectionState + /** Search API results per search, shown in the Search section while it selects the DeepSeek Search API. */ + count: SettingsFieldState + /** Legacy Messages endpoint. */ + messagesBaseURL: SettingsFieldState + /** Legacy server-side search limit. */ + maxUses: SettingsFieldState + /** Legacy key control, present only while the legacy provider is selected. */ + legacyKey: WebApiKeyState | undefined +} + +/** The registration-side face the page's slot entry injects. */ +export interface WebSearchCardFace extends Pick { + hooks: { + /** Page snapshot bound by the renderer as useWebSearchCard. */ + webSearchCard: SnapshotStore + } + /** Stage draft text for one control. */ + edit: (field: WebSearchCardField, text: string) => void + /** Stage a clear for one section field. */ + resetField: (field: WebSectionField) => void + /** Stage one provider selection; the empty id stages automatic selection. */ + selectProvider: (field: keyof WebProviderSelection, id: string) => void + /** Re-read the registered providers after a failed read. */ + retryProviders: () => void + /** Resolve a provider description to the active language. */ + resolveText: (text: NonNullable) => string +} + +/** Settings scopes for the namespaces the page edits. */ +export interface WebSearchCardScopes { + /** The web service's provider selection. */ + web: SettingsFormScope + /** Search and Fetch API settings. */ + api: SettingsFormScope + /** Legacy Messages search settings. */ + legacy: SettingsFormScope +} + +/** What the credentials domain last reported for the legacy key control. */ interface CredentialState { /** Reference this answer describes; a stale response for another one is dropped. */ ref: string @@ -83,77 +202,47 @@ interface CredentialState { writable: boolean } -/** What the web-search page renders. */ -export interface WebSearchCardState extends SettingsFormShell { - /** Whether the Host serves both the selection and the Search / Fetch API section. */ - apiSwitchAvailable: boolean - /** Invalid edits or failed saves that switching APIs must keep visible. */ - apiSwitchBlocked: boolean - /** Staged switch position: true selects the Search and Fetch APIs. */ - useSearchApi: boolean - /** Endpoint of the selected API. */ - baseURL: SettingsFieldState - /** Maximum Messages server-tool uses. */ - maxUses: SettingsFieldState - /** Search API results per search. */ - count: SettingsFieldState - /** The staged credential, which starts blank on every load. */ - apiKey: SettingsFieldState - /** Whether the Host reports a credential configured for the referenced key. */ - apiKeyConfigured: boolean - /** Whether the credentials domain accepts a write for it; false disables the control. */ - apiKeyWritable: boolean -} +/** What `credentials/describe` answers, by reference. */ +type CredentialViews = Readonly> -/** The registration-side face the web-search page's slot entry injects. */ -export interface WebSearchCardFace extends SettingsFormActions { - hooks: { - /** Page snapshot bound by the renderer as useWebSearchCard. */ - webSearchCard: SnapshotStore - } - /** Stage the API switch: true selects the Search and Fetch APIs, false Messages search with local fetch. */ - setSearchApi: (enabled: boolean) => void -} - -/** Settings scopes for the namespaces the page edits. */ -export interface WebSearchCardScopes { - /** The web service's provider selection. */ - web: SettingsFormScope - /** Messages search settings. */ - messages: SettingsFormScope - /** Search and Fetch API settings. */ - api: SettingsFormScope -} - -/** Bridges the three settings scopes and the credentials domain onto the page. */ +/** Bridges the settings scopes, the provider lists, and the credentials domain onto the page. */ export class WebSearchCardController { private readonly selection: SettingsFormModel - private readonly messages: SettingsFormModel private readonly api: SettingsFormModel + private readonly legacy: SettingsFormModel private readonly store: SnapshotStore private readonly unsubscribe: (() => void)[] - private credential: CredentialState = { ref: '', configured: false, writable: true } + private credential: CredentialState private credentialReadQueued = false private credentialReadSequence = 0 + /** Reference the key draft was typed for; read only while that draft is non-empty. */ + private draftRef: string + /** The draft reference the running or last selection save writes under. */ + private savingRef: string + private droppingDraft = false + private providers: ProviderList | undefined + private providersFailed = false + private providerReadSequence = 0 + private selectionServed = false private disposed = false /** * @param scopes - the bound settings scopes for the page's namespaces. * @param ctx - the page plugin's context, whose `remote.credentials` namespace - * answers for the credential the selected section references. + * answers for the credential the legacy key control references, and whose + * `remote.web` namespace lists the registered providers. */ constructor( private readonly scopes: WebSearchCardScopes, private readonly ctx: ClientContext, ) { + this.credential = { ref: this.ref(), configured: false, writable: true } + this.draftRef = this.ref() + this.savingRef = this.draftRef this.selection = new SettingsFormModel(scopes.web, [ settingsTextField('searchProvider'), settingsTextField('fetchProvider'), ], [{ field: API_KEY_FIELD, write: text => this.writeKey(text) }]) - this.messages = new SettingsFormModel(scopes.messages, [ - settingsTextField('baseURL'), settingsNumberField('maxUses'), - ]) this.api = new SettingsFormModel(scopes.api, [ - settingsTextField('baseURL'), { ...settingsNumberField('count'), parse: (text) => { @@ -163,37 +252,138 @@ export class WebSearchCardController { }, }, ]) + this.legacy = new SettingsFormModel(scopes.legacy, [ + settingsTextField('baseURL'), + { ...settingsNumberField('maxUses'), parse: (text) => { + if (text.trim() === '') return { kind: 'clear' } + const value = Number(text) + return Number.isInteger(value) && value >= 1 ? { kind: 'set', value } : undefined + } }, + ]) + this.selectionServed = this.selection.shell().available this.store = createSnapshotStore(this.projection()) - // A staged switch can change which section names the key, without any - // scope publishing; a scope change can rename the reference itself. - const publish = () => { - const ref = this.ref() - if (ref !== this.credential.ref) { - this.resetCredentialReference(ref) - this.queueCredentialRead() - } - this.store.set(this.projection()) - } this.unsubscribe = [ - ...[this.selection, this.messages, this.api].map(form => form.bind(() => form.shell()).subscribe(publish)), - ...[scopes.web, scopes.messages, scopes.api].map(scope => scope.subscribe(() => { this.queueCredentialRead() })), + ...[this.selection, this.api, this.legacy].map(form => form.bind(() => form.shell()).subscribe(() => { this.publish() })), + ...[scopes.web, scopes.api, scopes.legacy].map(scope => scope.subscribe(() => { this.queueCredentialRead() })), + scopes.web.subscribe(() => { + // The provider list comes from the web service; a Host that starts + // serving it registers providers this page has not read. + const served = this.selection.shell().available + if (served && !this.selectionServed) this.refreshProviders() + this.selectionServed = served + }), ] void this.readCredential() + this.refreshProviders() } - /** Whether the Host serves both sections the switch needs. */ - private switchAvailable(): boolean { - return this.selection.shell().available && this.api.shell().available + /** + * Republish after a form or scope change. A staged selection can hide the + * key control without any scope publishing; a scope change can rename the + * reference itself. + */ + private publish(): void { + // Dropping a draft stages an edit, which republishes; publish once after it. + if (this.droppingDraft) return + this.dropStaleKeyDraft() + const ref = this.ref() + if (ref !== this.credential.ref) { + this.resetCredentialReference(ref) + this.queueCredentialRead() + } + this.store.set(this.projection()) } - /** The staged switch position, read from the staged search selection. */ - private searchApi(): boolean { - return this.switchAvailable() && this.selection.field('searchProvider').text === PROVIDERS.searchProvider.api + /** The provider id a selection field stages. */ + private selected(field: keyof WebProviderSelection): string { + return this.selection.field(field).text + } + + /** + * Menu entries for one capability: the registered official providers in + * {@link MENU_ORDER}, then every other registered provider in Host order, + * and the selected id when the Host does not register it. Before the list + * arrives, the menu holds only the selected id. + */ + private options(capability: keyof ProviderList, selected: string): WebProviderOption[] { + const registered = this.providers?.[capability] + if (registered === undefined) return selected === '' ? [] : [{ id: selected, missing: false }] + const first: readonly string[] = MENU_ORDER[capability] + const ordered = [ + ...first.flatMap(id => registered.filter(info => info.id === id)), + ...registered.filter(info => !first.includes(info.id)), + ] + const options = ordered.map(info => ({ + id: info.id, + ...info.title === undefined ? {} : { title: info.title }, + ...info.description === undefined ? {} : { description: info.description }, + missing: false, + })) + if (selected !== '' && !registered.some(info => info.id === selected)) options.push({ id: selected, missing: true }) + return options + } + + /** Whether the Host reports `id` as registered, or has not answered yet. */ + private registered(capability: keyof ProviderList, id: string): boolean { + return this.providers === undefined || this.providers[capability].some(info => info.id === id) + } + + private searchKind(): WebSearchKind { + const id = this.selected('searchProvider') + if (id === '' || !this.registered('search', id)) return 'none' + if (id === PROVIDER_IDS.apiSearch) return this.api.shell().available ? 'deepseek' : 'none' + if (id === PROVIDER_IDS.legacySearch) return this.legacy.shell().available ? 'legacy' : 'none' + return this.providers === undefined ? 'none' : 'plugin' + } + + private fetchKind(): WebFetchKind { + const id = this.selected('fetchProvider') + if (id === '' || !this.registered('fetch', id)) return 'none' + if (id === PROVIDER_IDS.localFetch) return 'local' + if (id === PROVIDER_IDS.apiFetch) return this.api.shell().available ? 'deepseek' : 'none' + return this.providers === undefined ? 'none' : 'plugin' + } + + /** The reference the key control addresses, from the legacy settings or the default. */ + private ref(): string { + return this.scopes.legacy.getSnapshot().value?.apiKeyEnv || DEFAULT_API_KEY_REF + } + + /** Whether the page shows the key control. */ + private keyShown(): boolean { + return this.searchKind() === 'legacy' + } + + /** + * Drop the key draft once the page no longer shows the control or the + * control addresses another reference, so a draft is never written under + * another reference or under one no control shows. + */ + private dropStaleKeyDraft(): void { + if (this.selection.field(API_KEY_FIELD).text === '') return + if (this.keyShown() && this.draftRef === this.ref()) return + this.droppingDraft = true + try { + this.selection.actions().edit(API_KEY_FIELD, '') + } finally { + this.droppingDraft = false + } + } + + /** + * Stage a key draft for the reference the control addresses now. + * @param text - the draft text. + */ + private editKey(text: string): void { + // The page shows no key control, so a draft would belong to no reference. + if (!this.keyShown()) return + this.draftRef = this.ref() + this.selection.actions().edit(API_KEY_FIELD, text) } private projection(): WebSearchCardState { - const primary = this.messages.shell() - const served = [primary, this.selection.shell(), this.api.shell()].filter(item => item.available) + const primary = this.selection.shell() + const served = [primary, this.api.shell(), this.legacy.shell()].filter(item => item.available) const shell: SettingsFormShell = { available: primary.available, writable: served.every(item => item.writable), @@ -202,24 +392,50 @@ export class WebSearchCardController { saving: served.some(item => item.saving), failed: served.some(item => item.failed), } - const searchApi = this.searchApi() + const searchSelected = this.selected('searchProvider') + const fetchSelected = this.selected('fetchProvider') + const base = this.scopes.web.getSnapshot().base as WebProviderSelection | undefined return { ...shell, - apiSwitchAvailable: this.switchAvailable(), - apiSwitchBlocked: shell.invalid || shell.failed, - useSearchApi: searchApi, - baseURL: (searchApi ? this.api : this.messages).field('baseURL'), - maxUses: this.messages.field('maxUses'), + selectionBlocked: shell.invalid || shell.failed, + providersFailed: this.providersFailed, + search: { + selected: searchSelected, + options: this.options('search', searchSelected), + kind: this.searchKind(), + auto: base?.searchProvider === undefined, + }, + fetch: { + selected: fetchSelected, + options: this.options('fetch', fetchSelected), + kind: this.fetchKind(), + auto: base?.fetchProvider === undefined, + }, count: this.api.field('count'), - apiKey: this.selection.field(API_KEY_FIELD), - apiKeyConfigured: this.credential.configured, - apiKeyWritable: this.credential.writable, + messagesBaseURL: this.legacy.field('baseURL'), + maxUses: this.legacy.field('maxUses'), + legacyKey: this.keyShown() ? { ...this.selection.field(API_KEY_FIELD), ...this.credential } : undefined, } } - /** The reference the selected API's section names, or the providers' default. */ - private ref(): string { - return refOf(this.searchApi() ? this.scopes.api.getSnapshot() : this.scopes.messages.getSnapshot()) + /** + * Re-read the registered providers. Only the latest read publishes; a failed + * read keeps the previous list and marks the page's provider list as failed + * until a later read succeeds. + */ + refreshProviders(): void { + void this.readProviders() + } + + private async readProviders(): Promise { + if (this.disposed) return + const sequence = ++this.providerReadSequence + const response = await this.ctx.remote.web.listProviders() + // oxlint-disable-next-line typescript/no-unnecessary-condition -- dispose() can run during the await. + if (this.disposed || sequence !== this.providerReadSequence) return + if (response.ok) this.providers = response.value + this.providersFailed = !response.ok + this.publish() } /** Clear the old reference's answer and invalidate its pending reads. */ @@ -239,13 +455,15 @@ export class WebSearchCardController { } /** - * Ask the credentials domain about the reference the selected section currently names. + * Ask the credentials domain about the reference the key control currently names. * - * Only the latest read for the selected reference publishes its answer. - * A write's readback retains its own result even if another read starts. - * @returns the answer for this read, or undefined after failure, disposal, or a reference change. + * Only the latest read publishes its answer, and only while its reference + * is unchanged. A write's readback retains its own result even if + * another read starts or the reference changes. + * @param written - a reference a save just wrote, asked about in the same read. + * @returns the answer by reference, or undefined after failure or disposal. */ - private async readCredential(): Promise { + private async readCredential(written?: string): Promise { if (this.disposed) return const ref = this.ref() if (ref !== this.credential.ref) { @@ -255,23 +473,26 @@ export class WebSearchCardController { this.store.set(this.projection()) } const sequence = ++this.credentialReadSequence - const response = await this.ctx.remote.credentials.describe([ref]) + const names = [...new Set([ref, ...written === undefined ? [] : [written]])] + const response = await this.ctx.remote.credentials.describe(names) // oxlint-disable-next-line typescript/no-unnecessary-condition -- dispose() can run during the await. - if (this.disposed || !response.ok || ref !== this.ref()) return - const view = response.value[ref] - const next: CredentialState = { - ref, - configured: view?.configured ?? false, - // An unknown reference is treated as writable: the control stays usable - // and the Host is what refuses, rather than the page guessing a refusal. - writable: view?.writable ?? true, + if (this.disposed || !response.ok) return + const views: CredentialViews = response.value + if (sequence !== this.credentialReadSequence) return views + if (ref !== this.ref()) { + // The reference changed outside a publication: drop this answer and read the new one. + this.publish() + return views } - if (sequence === this.credentialReadSequence - && (next.configured !== this.credential.configured || next.writable !== this.credential.writable)) { + const view = views[ref] + // An unknown reference is treated as writable: the control stays usable + // and the Host is what refuses, rather than the page guessing a refusal. + const next: CredentialState = { ref, configured: view?.configured ?? false, writable: view?.writable ?? true } + if (next.configured !== this.credential.configured || next.writable !== this.credential.writable) { this.credential = next - this.store.set(this.projection()) + this.publish() } - return next + return views } /** @@ -288,88 +509,87 @@ export class WebSearchCardController { } /** - * Build the face the page's slot registration injects. `baseURL` edits the - * selected API's endpoint; `maxUses` and `count` edit their own sections. + * Build the face the page's slot registration injects. * @returns the page's snapshot and its form actions. */ inject(): WebSearchCardFace { - const formOf = (field: string): { actions(): SettingsFormActions } => { - if (field === API_KEY_FIELD) return this.selection - if (field === 'maxUses') return this.messages - if (field === 'count') return this.api - return this.searchApi() ? this.api : this.messages - } return { hooks: { webSearchCard: this.store }, - edit: (field, text) => { formOf(field).actions().edit(field, text) }, - resetField: (field) => { formOf(field).actions().resetField(field) }, - setSearchApi: (enabled) => { this.select(enabled) }, + edit: (field, text) => { + if (field === 'legacyApiKey') { this.editKey(text); return } + if (field === 'messagesBaseURL' || field === 'maxUses') { + this.legacy.actions().edit(field === 'messagesBaseURL' ? 'baseURL' : field, text) + return + } + this.api.actions().edit(field, text) + }, + resetField: (field) => { + const form = field === 'messagesBaseURL' || field === 'maxUses' ? this.legacy : this.api + form.actions().resetField(field === 'messagesBaseURL' ? 'baseURL' : field) + }, + selectProvider: (field, id) => { this.select(field, id) }, + retryProviders: () => { this.refreshProviders() }, + resolveText: text => this.ctx.locale.resolveText(text), save: () => { void this.save() }, discard: () => { - for (const form of [this.selection, this.messages, this.api]) form.actions().discard() + for (const form of [this.selection, this.api, this.legacy]) form.actions().discard() }, } } /** - * Stage both provider selections for one switch position. A target equal to - * the composed value clears the override so the deployment default keeps + * Stage one provider selection. Automatic selection and a target equal to + * the composed value clear the override, so the deployment default keeps * applying. - * @param enabled - true for the Search and Fetch APIs. + * @param field - the selection field. + * @param id - the provider id to select, or the empty string for automatic selection. */ - private select(enabled: boolean): void { - const base = this.scopes.web.getSnapshot().base as WebProviderSelection | undefined - const actions = this.selection.actions() - for (const field of ['searchProvider', 'fetchProvider'] as const) { - const target = PROVIDERS[field][enabled ? 'api' : 'messages'] - if (base?.[field] === target) actions.resetField(field) - else actions.edit(field, target) - } + private select(field: keyof WebProviderSelection, id: string): void { + const composed = (this.scopes.web.getSnapshot().base as WebProviderSelection | undefined)?.[field] + if (id === '' || composed === id) this.selection.actions().resetField(field) + else this.selection.actions().edit(field, id) } /** - * Save the provider sections before the selection, so a switch takes effect - * only after the endpoint it uses has landed. A refused section stops the - * selection from saving and keeps every remaining draft for correction. + * Save the API section before the selection, so a selection takes effect + * only after the settings it uses have landed. A refused section stops the + * selection from saving and keeps every remaining draft for correction. The + * key draft's reference is fixed when the selection save plans its writes, + * so a provider change during the save does not redirect a key the save + * already planned. * @returns settlement after every attempted write. */ private async save(): Promise { const state = this.projection() if (!state.available || state.invalid || state.saving) return - for (const form of [this.messages, this.api]) { + for (const form of [this.api, this.legacy]) { if (!form.shell().available || !form.shell().dirty) continue await form.save() if (form.shell().failed) return } - if (this.selection.shell().dirty) await this.selection.save() + if (!this.selection.shell().dirty) return + this.savingRef = this.draftRef + await this.selection.save() } /** - * Write the staged key, then re-read whether the Host now holds one. + * Write the staged key under the reference its draft belonged to when the + * save started, then re-read whether the Host now holds it. * @param value - the staged credential literal. * @returns whether the Host reports a configured credential afterwards. */ private async writeKey(value: string): Promise { + const ref = this.savingRef // Refusals surface through the re-read below: the Host is the only // authority on whether the key now exists. - await this.ctx.remote.credentials.set(this.ref(), value) - return (await this.readCredential())?.configured ?? false + await this.ctx.remote.credentials.set(ref, value) + return (await this.readCredential(ref))?.[ref]?.configured ?? false } /** Release subscriptions and prevent queued reads or late answers from publishing. */ dispose(): void { this.disposed = true for (const off of this.unsubscribe) off() - for (const form of [this.selection, this.messages, this.api]) form.dispose() + for (const form of [this.selection, this.api, this.legacy]) form.dispose() } } - -/** - * The credential reference a section names, or the providers' default. - * @param snapshot - the section's current scope snapshot. - * @returns the reference to address. - */ -function refOf(snapshot: SettingsFormScopeSnapshot<{ apiKeyEnv?: string }>): string { - const declared = snapshot.value?.apiKeyEnv - return declared !== undefined && declared.length > 0 ? declared : DEFAULT_API_KEY_REF -} diff --git a/packages/client/ui-settings-web-search/tests/apply.client.spec.ts b/packages/client/ui-settings-web-search/tests/apply.client.spec.ts index 84efc65063..268e650126 100644 --- a/packages/client/ui-settings-web-search/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-web-search/tests/apply.client.spec.ts @@ -29,12 +29,14 @@ async function bench(served?: string[]) { const describeSettings = vi.fn(() => Promise.resolve(served === undefined ? { ok: false, error: new RemoteError('gateway/internal', 'no provider', {}) } : { ok: true, value: { writable: true, hasDocument: true, namespaces: served.map(ns => view(ns)) } })) + const listProviders = vi.fn(() => Promise.resolve({ ok: true, value: { search: [], fetch: [] } })) const remote = new TestRemote(ctx, { credentials: { describe: describeCredentials, set: vi.fn() }, settings: { describe: describeSettings }, + web: { listProviders }, }) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() - return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, remote } + return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, listProviders, remote } } /** The Plugins page's item slot, as its owner declares it. */ @@ -51,11 +53,11 @@ describe('ui-settings-web-search apply', () => { }) it('declares the services it uses', () => { - expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.credentials', 'configForms']) + expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.credentials', 'remote.web', 'configForms']) }) - it('registers the page while the Host serves the namespace, titled in the active locale', async () => { - const { ctx, slots } = await bench(['web-search-deepseek']) + it('registers the page while the Host serves the web namespace, titled in the active locale', async () => { + const { ctx, slots } = await bench(['web']) declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() @@ -63,14 +65,14 @@ describe('ui-settings-web-search apply', () => { await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(1) }) const entry = slots.entries('plugins.item')[0]! expect(entry.options).toMatchObject({ id: 'web-search', order: 20 }) - expect(resolveSlotLabel(entry.options.label)).toBe('网页搜索') + expect(resolveSlotLabel(entry.options.label)).toBe('网页搜索与获取') expect(entry.locale).toBe(NS) const face = (entry.inject as () => Pick)() expect(Object.keys(face.hooks)).toEqual(['webSearchCard']) }) - it('registers nothing while the namespace is not served', async () => { - const { ctx, slots, describeSettings } = await bench(['shell']) + it('registers nothing while the web namespace is not served', async () => { + const { ctx, slots, describeSettings } = await bench(['shell', 'web-deepseek-api']) declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() }) @@ -78,14 +80,16 @@ describe('ui-settings-web-search apply', () => { expect(slots.entries('plugins.item')).toHaveLength(0) }) - it('re-reads the credential when the Host reports the watched reference changed, and ignores another', async () => { + it('re-reads the credentials when the Host reports a watched reference changed, and ignores another', async () => { const { ctx, slots, describeCredentials, remote } = await bench() declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() }) describeCredentials.mockClear() - remote.emit('credentials/reference-updated', ['SOME_OTHER_KEY']) + for (const ref of ['UNWATCHED_KEY', 'DEEPSEEK_SEARCH_API_KEY', 'DEEPSEEK_FETCH_API_KEY']) { + remote.emit('credentials/reference-updated', [ref]) + } await Promise.resolve() expect(describeCredentials).not.toHaveBeenCalled() @@ -95,8 +99,27 @@ describe('ui-settings-web-search apply', () => { await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) }) }) + it('re-reads the providers when the Host reports a registration change or the connection resets', async () => { + const { ctx, slots, listProviders, remote } = await bench() + declareRoot(slots) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + await vi.waitFor(() => { expect(listProviders).toHaveBeenCalledTimes(1) }) + + remote.emit('web/providers-changed', []) + await vi.waitFor(() => { expect(listProviders).toHaveBeenCalledTimes(2) }) + ctx.emit('connection/reset') + await vi.waitFor(() => { expect(listProviders).toHaveBeenCalledTimes(3) }) + + await fiber.dispose() + remote.emit('web/providers-changed', []) + ctx.emit('connection/reset') + await Promise.resolve() + expect(listProviders).toHaveBeenCalledTimes(3) + }) + it('collapses the page on teardown', async () => { - const { ctx, slots } = await bench(['web-search-deepseek']) + const { ctx, slots } = await bench(['web']) declareRoot(slots) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() diff --git a/packages/client/ui-settings-web-search/tests/card.client.spec.tsx b/packages/client/ui-settings-web-search/tests/card.client.spec.tsx index 0d517266f4..2d4928707f 100644 --- a/packages/client/ui-settings-web-search/tests/card.client.spec.tsx +++ b/packages/client/ui-settings-web-search/tests/card.client.spec.tsx @@ -1,18 +1,19 @@ // @vitest-environment jsdom -/** The web-search page as the Plugins page renders it: its API switch, key control, two fields, and their resets. */ +/** The web search and fetch page as the Plugins page renders it: two provider menus and the selected providers' own settings. */ -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SettingsFieldState, SettingsFormShell } from '@deepseek-ai/dsh-client-ui-primitives' import { WebSearchCard, type WebSearchCardProps } from '../src/client/WebSearchCard.tsx' -import type { WebSearchCardState } from '../src/client/web-search-card-controller.ts' +import type { WebProviderOption, WebSearchCardState } from '../src/client/web-search-card-controller.ts' import { en, zh } from '../src/client/locales.ts' afterEach(cleanup) -const t = (key: keyof typeof en) => en[key] +const t = ((key: keyof typeof en, params?: Record) => + en[key].replace(/\{(\w+)\}/g, (match, name: string) => params !== undefined && name in params ? String(params[name]) : match)) as WebSearchCardProps['t'] const settled: SettingsFormShell = { available: true, writable: true, dirty: false, invalid: false, saving: false, failed: false } @@ -20,164 +21,346 @@ function field(text: string, rest: Partial = {}): SettingsFi return { text, overridden: false, invalid: false, ...rest } } -const endpoints = { - messages: field(''), - api: field('https://api.deepseek.com/v1/tools'), +/** Each official provider's registered description; the page shows the active language's entry. */ +const DESCRIPTIONS: Record = { + 'deepseek-search-api': { + en: 'Search API description. Uses the DeepSeek account of the current conversation, or a DeepSeek API key, and may incur API charges.', + zh: 'Search API 说明。使用当前会话的 DeepSeek 账号或 DeepSeek API Key,可能产生计费', + }, + 'http': { en: 'Direct fetch description.', zh: '本机获取说明' }, + 'deepseek-fetch-api': { + en: 'Fetch API description. Uses the DeepSeek account of the current conversation, or a DeepSeek API key, may incur API charges.', + zh: 'Fetch API 说明。使用当前会话的 DeepSeek 账号或 DeepSeek API Key,可能产生计费', + }, +} + +function options(...ids: string[]): WebProviderOption[] { + return ids.map(id => ({ id, ...DESCRIPTIONS[id] === undefined ? {} : { description: DESCRIPTIONS[id] }, missing: false })) +} + +/** Menu entries as the controller orders them. */ +const SEARCH_OPTIONS = options('deepseek-search-api', 'deepseek-official') +const FETCH_OPTIONS = options('http', 'deepseek-fetch-api') + +/** The locale service's resolution, with Chinese active. */ +const resolveText = vi.fn((text: string | { en: string; [locale: string]: string }) => typeof text === 'string' ? text : text.zh ?? text.en) + +/** The shipped selection: the DeepSeek Search API with local fetch. */ +function shipped(): WebSearchCardState { + return { + ...settled, + selectionBlocked: false, + providersFailed: false, + search: { selected: 'deepseek-search-api', options: SEARCH_OPTIONS, kind: 'deepseek', auto: false }, + fetch: { selected: 'http', options: FETCH_OPTIONS, kind: 'local', auto: false }, + count: field('10'), + messagesBaseURL: field(''), + maxUses: field('5'), + legacyKey: undefined, + } +} + +/** Both DeepSeek API providers selected. */ +function apis(): WebSearchCardState { + const base = shipped() + return { ...base, fetch: { ...base.fetch, selected: 'deepseek-fetch-api', kind: 'deepseek' } } +} + +/** Legacy Messages search selected, with its key control. */ +function legacy(key: Partial> = {}): WebSearchCardState { + const base = shipped() + return { + ...base, + search: { ...base.search, selected: 'deepseek-official', kind: 'legacy' }, + legacyKey: { ...field(''), ref: 'DEEPSEEK_API_KEY', configured: false, writable: true, ...key }, + } +} + +/** Search and Fetch providers that take no settings on this page. */ +function plugins(): WebSearchCardState { + const base = shipped() + return { + ...base, + search: { selected: 'exa', kind: 'plugin', auto: false, options: [{ id: 'exa', title: 'Exa', missing: false }] }, + fetch: { selected: '', kind: 'none', auto: false, options: [] }, + } } function cardActions() { - return { edit: vi.fn(), resetField: vi.fn(), setSearchApi: vi.fn(), save: vi.fn(), discard: vi.fn() } + return { + edit: vi.fn(), resetField: vi.fn(), selectProvider: vi.fn(), retryProviders: vi.fn(), save: vi.fn(), discard: vi.fn(), + } } +function renderPage(state: WebSearchCardState = shipped(), view: 'page' | 'summary' = 'page') { + const store = createSnapshotStore(state) + const actions = cardActions() + const props = { ...actions, view, t, resolveText, useWebSearchCard: bindSnapshotSelector(store) } as WebSearchCardProps + render() + return { actions, store } +} + +/** The provider menus, named by their section title followed by the selected provider. */ +const searchMenu = () => screen.getByRole('button', { name: new RegExp(`^${en.searchSection} `) }) +const fetchMenu = () => screen.getByRole('button', { name: new RegExp(`^${en.fetchSection} `) }) +const region = (name: string) => within(screen.getByRole('region', { name })) +/** The text of every paragraph in a section, in order. */ +const paragraphs = (name: string) => [...screen.getByRole('region', { name }).querySelectorAll('p')].map(node => node.textContent) + describe('WebSearchCard', () => { - function renderWebSearch(state: Partial = {}) { - const store = createSnapshotStore({ - ...settled, - apiSwitchAvailable: true, - apiSwitchBlocked: state.invalid === true || state.failed === true, - baseURL: state.useSearchApi === true ? endpoints.api : endpoints.messages, useSearchApi: false, maxUses: field('5'), - count: field('10'), - apiKey: field(''), - apiKeyConfigured: false, - apiKeyWritable: true, - ...state, + it('offers legacy search in both locales and renders its own settings', () => { + const { actions } = renderPage(legacy()) + expect(searchMenu().textContent).toBe('DeepSeek (legacy)') + expect(zh.providerLegacy).toBe('DeepSeek(旧版)') + expect(screen.queryByLabelText(en.count)).toBeNull() + fireEvent.change(screen.getByLabelText(en.credential), { target: { value: 'new-key' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://messages.test/v1' } }) + fireEvent.change(screen.getByLabelText(en.maxUses), { target: { value: '8' } }) + expect(actions.edit.mock.calls).toEqual([ + ['legacyApiKey', 'new-key'], ['messagesBaseURL', 'https://messages.test/v1'], ['maxUses', '8'], + ]) + }) + + it('resets the legacy endpoint and search limit under their own names', () => { + const { actions } = renderPage({ + ...legacy(), messagesBaseURL: field('https://messages.test/v1', { overridden: true }), maxUses: field('8', { overridden: true }), }) - const actions = cardActions() - const props = { ...actions, view: 'page', t, useWebSearchCard: bindSnapshotSelector(store) } as WebSearchCardProps - render() - return actions - } + for (const button of screen.getAllByRole('button', { name: en.reset })) fireEvent.click(button) + expect(actions.resetField.mock.calls).toEqual([['messagesBaseURL'], ['maxUses']]) + }) it('renders its one-liner alone in the summary view', () => { - const store = createSnapshotStore({ - ...settled, baseURL: endpoints.messages, apiSwitchAvailable: true, apiSwitchBlocked: false, useSearchApi: false, - maxUses: field('5'), count: field('10'), apiKey: field(''), apiKeyConfigured: false, apiKeyWritable: true, - }) - const props = { ...cardActions(), view: 'summary', t, useWebSearchCard: bindSnapshotSelector(store) } as WebSearchCardProps - render() - + renderPage(shipped(), 'summary') expect(document.body.textContent).toBe(en.description) - expect(screen.queryByLabelText(en.apiKey)).toBeNull() + expect(screen.queryByLabelText(en.count)).toBeNull() }) - it('reports whether a key is configured without ever showing one', () => { - renderWebSearch({ apiKeyConfigured: true }) - - expect(screen.getByText(en.apiKeySet)).toBeTruthy() - expect(screen.getByLabelText(en.apiKey)).toHaveProperty('type', 'password') + it('titles the page and its two sections by the provider each one picks', () => { + renderPage() + expect(en.title).toBe('Web search and fetch') + expect(zh.title).toBe('网页搜索与获取') + expect([en.searchSection, en.fetchSection]).toEqual(['Web search provider', 'Web fetch provider']) + expect([zh.searchSection, zh.fetchSection]).toEqual(['网页搜索提供方', '网页获取提供方']) + expect(screen.getByRole('heading', { name: en.searchSection })).toBeTruthy() + expect(screen.getByRole('heading', { name: en.fetchSection })).toBeTruthy() + expect(screen.getByRole('region', { name: en.searchSection })).toBeTruthy() + expect(screen.getByRole('region', { name: en.fetchSection })).toBeTruthy() }) - it('keeps the key control usable while the settings document is read-only', () => { - const actions = renderWebSearch({ writable: false }) - - const key = screen.getByLabelText(en.apiKey) - expect(key).toHaveProperty('disabled', false) - expect(screen.getByLabelText(en.baseUrl)).toHaveProperty('disabled', true) - expect(screen.getByRole('switch', { name: en.useSearchApi })).toHaveProperty('disabled', true) - - fireEvent.change(key, { target: { value: 'ds-secret' } }) - - expect(actions.edit).toHaveBeenCalledWith('apiKey', 'ds-secret') + it('names each provider menu by its section title and the selected provider, with no label of its own', () => { + renderPage() + expect(searchMenu().getAttribute('aria-labelledby')).toBe('plugin-config-web-search-section plugin-config-web-search-provider-value') + expect(screen.getByRole('button', { name: `${en.searchSection} ${en.providerDeepSeek}` })).toBe(searchMenu()) + expect(screen.getByRole('button', { name: `${en.fetchSection} ${en.providerLocal}` })).toBe(fetchMenu()) + for (const title of [en.searchSection, en.fetchSection]) expect(screen.getAllByText(title)).toHaveLength(1) + expect(document.querySelectorAll('label[for="plugin-config-web-search-provider"], label[for="plugin-config-web-fetch-provider"]')).toHaveLength(0) }) - it('disables the key control when the reference itself is not writable', () => { - // A key coming from the process environment: the settings document is - // writable, the credential is not. - renderWebSearch({ apiKeyConfigured: true, apiKeyWritable: false }) - - expect(screen.getByLabelText(en.apiKey)).toHaveProperty('disabled', true) - expect(screen.getByLabelText(en.baseUrl)).toHaveProperty('disabled', false) + it('shows the Search API result count in a panel, and gives local fetch no panel', () => { + renderPage() + expect(searchMenu().textContent).toBe(en.providerDeepSeek) + expect(fetchMenu().textContent).toBe(en.providerLocal) + expect(region(en.searchSection).getByText(DESCRIPTIONS['deepseek-search-api']!.zh)).toBeTruthy() + expect(region(en.fetchSection).getByText('本机获取说明')).toBeTruthy() + expect(region(en.searchSection).getByLabelText(en.count)).toHaveProperty('value', '10') + for (const label of [en.credential, en.baseUrl]) expect(screen.queryByLabelText(label)).toBeNull() + expect(region(en.fetchSection).queryByRole('textbox')).toBeNull() }) - it('stages the endpoint, the search budget, and their resets', () => { - const actions = renderWebSearch({ - baseURL: { ...endpoints.messages, ...field('https://search.test/v1', { overridden: true }) }, - maxUses: field('3', { overridden: true }), - }) - - fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://other.test' } }) - fireEvent.change(screen.getByLabelText(en.maxUses), { target: { value: '4' } }) - const resets = screen.getAllByRole('button', { name: en.reset }) - expect(resets).toHaveLength(2) - for (const reset of resets) fireEvent.click(reset) - - expect(actions.edit.mock.calls).toEqual([ - ['baseURL', 'https://other.test'], - ['maxUses', '4'], - ]) - expect(actions.resetField.mock.calls).toEqual([['baseURL'], ['maxUses']]) - }) - it.each([ - [false, en.baseUrl, ''], - [true, en.searchApiBaseUrl, 'https://api.deepseek.com/v1/tools'], - ])('shows the selected API endpoint with the Search API %s and offers no reset before an override', (searchApi, label, value) => { - const actions = renderWebSearch({ useSearchApi: searchApi }) - const input = screen.getByLabelText(label) - expect(input).toHaveProperty('value', value) - expect(screen.queryByText(en.overridden)).toBeNull() - expect(screen.queryByRole('button', { name: en.reset })).toBeNull() - fireEvent.change(input, { target: { value: 'https://custom.test' } }) - expect(actions.edit).toHaveBeenCalledWith('baseURL', 'https://custom.test') + it('shows the DeepSeek Fetch API description under its provider menu', () => { + renderPage(apis()) + expect(fetchMenu().textContent).toBe(en.providerDeepSeekFetch) + expect(region(en.fetchSection).getByText(DESCRIPTIONS['deepseek-fetch-api']!.zh)).toBeTruthy() + expect(screen.getAllByRole('button').filter(button => button.hasAttribute('aria-expanded')).map(button => button.id)) + .toEqual(['plugin-config-web-search-provider', 'plugin-config-web-fetch-provider']) + // The Fetch section holds only its title and provider menu block. + expect(screen.getByRole('region', { name: en.fetchSection }).children).toHaveLength(2) + expect(region(en.fetchSection).queryByRole('textbox')).toBeNull() }) - it('defaults the feature off and stages enabling it', () => { - const actions = renderWebSearch() - const toggle = screen.getByRole('switch', { name: en.useSearchApi }) - expect(toggle.getAttribute('aria-checked')).toBe('false') - expect(screen.getByLabelText(en.maxUses)).toBeTruthy() - expect(screen.getByText(en.baseUrlHint)).toBeTruthy() - expect(screen.queryByText(en.searchApiBaseUrlHint)).toBeNull() - expect(zh.useSearchApi).toBe('启用新版 Web 搜索 API') - fireEvent.click(toggle) - expect(actions.setSearchApi).toHaveBeenCalledWith(true) - expect(actions.save).not.toHaveBeenCalled() + it('edits and resets the Search API result count', () => { + const { actions } = renderPage({ ...apis(), count: field('20', { overridden: true }) }) + const search = region(en.searchSection) + expect(search.getByLabelText(en.count)).toHaveProperty('value', '20') + fireEvent.change(search.getByLabelText(en.count), { target: { value: '30' } }) + fireEvent.click(search.getByRole('button', { name: en.reset })) + expect(actions.edit.mock.calls).toEqual([['count', '30']]) + expect(actions.resetField.mock.calls).toEqual([['count']]) }) - it('links the switch hint to the localized search and fetch guide', () => { - renderWebSearch() + it('shows the DeepSeek search guide after the Search API description, and the Fetch API description', () => { + renderPage(apis()) + expect(fetchMenu().textContent).toBe(en.providerDeepSeekFetch) const guide = screen.getByRole('link', { name: en.searchGuide }) expect(guide).toHaveProperty('href', en.searchGuideUrl) expect(guide.getAttribute('target')).toBe('_blank') expect(guide.getAttribute('rel')).toBe('noopener noreferrer') - expect(guide.parentElement?.textContent).toBe(`${en.useSearchApiHint}${en.searchGuide}`) - expect(`${zh.useSearchApiHint}${zh.searchGuide}`).toBe('默认关闭,搜索通过 DeepSeek Messages 完成。新版 Web 搜索 API 功能与计费详见网页搜索与抓取指南') - expect(zh.searchGuideUrl).toBe('https://api-docs.deepseek.com/zh-cn/guides/search') + // The description comes first; the guide link is the page's own line after it. + expect(paragraphs(en.searchSection).slice(0, 2)).toEqual([DESCRIPTIONS['deepseek-search-api']!.zh, en.searchGuide]) }) - it('omits the switch while the Host serves no Search and Fetch API section', () => { - renderWebSearch({ apiSwitchAvailable: false }) - expect(screen.queryByRole('switch', { name: en.useSearchApi })).toBeNull() - expect(screen.queryByRole('link', { name: en.searchGuide })).toBeNull() - expect(screen.getByLabelText(en.maxUses)).toBeTruthy() + it('shows no settings while neither provider takes them', () => { + renderPage(plugins()) + expect(screen.queryByText(en.credential)).toBeNull() + expect(screen.queryByRole('textbox')).toBeNull() }) - it.each([{ invalid: true }, { failed: true }, { saving: true }])('keeps invalid or unsaved failures visible while the switch is locked: %j', (state) => { - renderWebSearch(state) - expect(screen.getByRole('switch', { name: en.useSearchApi })).toHaveProperty('disabled', true) - expect(screen.getByLabelText(en.maxUses)).toBeTruthy() + it('selects a provider from the menu and leaves the current one alone', () => { + const { actions } = renderPage() + fireEvent.click(searchMenu()) + expect(searchMenu().getAttribute('aria-expanded')).toBe('true') + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([en.providerDeepSeek, en.providerLegacy]) + fireEvent.click(screen.getByRole('menuitem', { name: en.providerDeepSeek })) + expect(screen.queryByRole('menuitem')).toBeNull() + fireEvent.click(fetchMenu()) + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([en.providerLocal, en.providerDeepSeekFetch]) + fireEvent.click(screen.getByRole('menuitem', { name: en.providerLocal })) + expect(actions.selectProvider).not.toHaveBeenCalled() + fireEvent.click(fetchMenu()) + fireEvent.click(screen.getByRole('menuitem', { name: en.providerDeepSeekFetch })) + expect(actions.selectProvider).toHaveBeenLastCalledWith('fetchProvider', 'deepseek-fetch-api') + expect(actions.save).not.toHaveBeenCalled() + }) + + it('shows each selected provider’s registered description as plain text, with no action of its own', () => { + renderPage() + expect(region(en.fetchSection).getByText('本机获取说明').tagName).toBe('P') + expect(region(en.fetchSection).queryAllByRole('button').map(button => button.id)).toEqual(['plugin-config-web-fetch-provider']) + expect(resolveText).toHaveBeenCalledWith(DESCRIPTIONS.http) + }) + + it('shows a plugin provider’s description before the pointer to its own settings, and nothing for a provider without one', () => { + const base = plugins() + renderPage({ + ...base, + search: { ...base.search, options: [{ id: 'exa', title: 'Exa', description: { en: 'Exa search.', zh: 'Exa 搜索' }, missing: false }] }, + fetch: { ...base.fetch, selected: 'jina', kind: 'plugin', options: [{ id: 'jina', description: { en: 'Jina.', zh: '' }, missing: false }] }, + }) + expect(paragraphs(en.searchSection)).toEqual(['Exa 搜索', en.pluginProviderHint]) + // An empty translation shows no paragraph. + expect(paragraphs(en.fetchSection)).toEqual([en.pluginProviderHint]) + }) + + it('closes an open menu without selecting on Escape', () => { + const { actions } = renderPage() + fireEvent.click(searchMenu()) + fireEvent.keyDown(screen.getAllByRole('menuitem')[0]!, { key: 'Escape' }) + expect(screen.queryByRole('menuitem')).toBeNull() + expect(actions.selectProvider).not.toHaveBeenCalled() + }) + + it('names plugin providers by title or id, and marks a selection the Host does not register', () => { + const base = shipped() + renderPage({ + ...base, + search: { + selected: 'perplexity', kind: 'none', auto: false, + options: [...SEARCH_OPTIONS, { id: 'exa', title: 'Exa', missing: false }, { id: 'perplexity', missing: true }], + }, + fetch: { + selected: 'jina', kind: 'plugin', auto: false, + options: [...FETCH_OPTIONS, { id: 'jina', missing: false }], + }, + }) + expect(searchMenu().textContent).toBe('perplexity (not loaded)') + expect(zh.providerMissing).toBe('{id}(未加载)') + expect(fetchMenu().textContent).toBe('jina') + expect(screen.getAllByText(en.pluginProviderHint)).toHaveLength(1) + expect(screen.queryByText(en.credential)).toBeNull() + fireEvent.click(searchMenu()) + expect(screen.getAllByRole('menuitem').map(item => item.textContent)) + .toEqual([en.providerDeepSeek, en.providerLegacy, 'Exa', 'perplexity (not loaded)']) + }) + + it('explains a plugin Search provider and an unselected Fetch provider', () => { + renderPage(plugins()) + expect(searchMenu().textContent).toBe('Exa') + expect(fetchMenu().textContent).toBe(en.providerAuto) + expect(screen.getByText(en.pluginProviderHint)).toBeTruthy() + }) + + it('offers automatic selection where the deployment composes no provider', () => { + const base = shipped() + const { actions } = renderPage({ + ...base, + search: { ...base.search, auto: true }, + fetch: { ...base.fetch, selected: '', kind: 'none', auto: true }, + }) + expect(fetchMenu().textContent).toBe(en.providerAuto) + fireEvent.click(searchMenu()) + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([en.providerAuto, en.providerDeepSeek, en.providerLegacy]) + fireEvent.click(screen.getByRole('menuitem', { name: en.providerAuto })) + expect(actions.selectProvider).toHaveBeenCalledWith('searchProvider', '') + fireEvent.click(fetchMenu()) + fireEvent.click(screen.getByRole('menuitem', { name: en.providerAuto })) + expect(actions.selectProvider).toHaveBeenCalledTimes(1) + }) + + it('reports a failed provider list read in each section and retries it', () => { + const { actions } = renderPage({ ...shipped(), providersFailed: true }) + for (const name of [en.searchSection, en.fetchSection]) { + expect(region(name).getByRole('status').textContent).toBe(`${en.providersFailed}${en.retry}`) + fireEvent.click(region(name).getByRole('button', { name: en.retry })) + } + expect(zh.providersFailed).toBe('无法读取提供方列表,此菜单可能不完整') + expect(actions.retryProviders).toHaveBeenCalledTimes(2) + expect(searchMenu().textContent).toBe(en.providerDeepSeek) + }) + + it('shows no provider list notice while the list read succeeds', () => { + renderPage() + expect(screen.queryByRole('status')).toBeNull() + expect(screen.queryByRole('button', { name: en.retry })).toBeNull() + }) + + it('keeps the legacy key control usable while the settings document is read-only', () => { + const { actions } = renderPage({ ...legacy(), writable: false }) + const key = screen.getByLabelText(en.credential) + expect(key).toHaveProperty('disabled', false) + expect(screen.getByLabelText(en.baseUrl)).toHaveProperty('disabled', true) + expect(searchMenu()).toHaveProperty('disabled', true) + fireEvent.change(key, { target: { value: 'ds-secret' } }) + expect(actions.edit).toHaveBeenCalledWith('legacyApiKey', 'ds-secret') + }) + + it('disables the legacy key control when the reference itself is not writable', () => { + renderPage(legacy({ configured: true, writable: false })) + expect(screen.getByLabelText(en.credential)).toHaveProperty('disabled', true) + expect(screen.getByText(en.apiKeySet)).toBeTruthy() + expect(searchMenu()).toHaveProperty('disabled', false) + }) + + it('disables the legacy key control while a save is in flight', () => { + const { store } = renderPage(legacy()) + expect(screen.getByLabelText(en.credential)).toHaveProperty('disabled', false) + act(() => { store.set({ ...legacy(), saving: true }) }) + expect(screen.getByLabelText(en.credential)).toHaveProperty('disabled', true) + }) + + it.each([{ invalid: true }, { failed: true }])('locks every menu while an error must stay visible: %j', (state) => { + renderPage({ ...apis(), ...state, selectionBlocked: true }) + expect(searchMenu()).toHaveProperty('disabled', true) + expect(fetchMenu()).toHaveProperty('disabled', true) + fireEvent.click(searchMenu()) + expect(screen.queryByRole('menuitem')).toBeNull() + expect(searchMenu().getAttribute('aria-expanded')).toBe('false') + }) + + it('closes an open menu and reports it collapsed when the menus lock', () => { + const { store } = renderPage() + fireEvent.click(searchMenu()) + expect(screen.getAllByRole('menuitem')).toHaveLength(2) + act(() => { store.set({ ...shipped(), invalid: true, selectionBlocked: true }) }) + expect(screen.queryByRole('menuitem')).toBeNull() + expect(searchMenu().getAttribute('aria-expanded')).toBe('false') }) it('explains the supported result count and blocks saving an invalid draft', () => { - renderWebSearch({ useSearchApi: true, count: field('51', { invalid: true }), invalid: true, dirty: true }) + renderPage({ + ...apis(), count: field('51', { invalid: true }), invalid: true, dirty: true, selectionBlocked: true, + }) expect(screen.getByText(en.invalidCount)).toBeTruthy() expect(screen.getByLabelText(en.count).getAttribute('aria-invalid')).toBe('true') expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true) }) - - it('edits the Search API endpoint and result count in Search API mode', () => { - const actions = renderWebSearch({ useSearchApi: true, count: field('20', { overridden: true }) }) - expect(screen.queryByLabelText(en.maxUses)).toBeNull() - expect(screen.getByLabelText(en.count)).toHaveProperty('value', '20') - expect(screen.getByText(en.countHint)).toBeTruthy() - expect(screen.getByText(en.searchApiBaseUrlHint)).toBeTruthy() - expect(screen.queryByText(en.baseUrlHint)).toBeNull() - expect(screen.getByLabelText(en.searchApiBaseUrl)).toHaveProperty('value', 'https://api.deepseek.com/v1/tools') - fireEvent.change(screen.getByLabelText(en.searchApiBaseUrl), { target: { value: 'https://other.test' } }) - fireEvent.change(screen.getByLabelText(en.count), { target: { value: '30' } }) - fireEvent.click(screen.getByRole('button', { name: en.reset })) - fireEvent.click(screen.getByRole('switch', { name: en.useSearchApi })) - expect(actions.edit.mock.calls).toEqual([['baseURL', 'https://other.test'], ['count', '30']]) - expect(actions.resetField.mock.calls).toEqual([['count']]) - expect(actions.setSearchApi).toHaveBeenCalledWith(false) - }) }) diff --git a/packages/client/ui-settings-web-search/tests/controller.client.spec.ts b/packages/client/ui-settings-web-search/tests/controller.client.spec.ts index 3b3c773c02..4b02df5458 100644 --- a/packages/client/ui-settings-web-search/tests/controller.client.spec.ts +++ b/packages/client/ui-settings-web-search/tests/controller.client.spec.ts @@ -3,7 +3,7 @@ import type { SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client' import { RemoteError, stubConfigForm, type StubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' import { WebSearchCardController, - type WebApiSettings, type WebProviderSelection, type WebSearchSettings, + type WebApiSettings, type WebLegacySettings, type WebProviderSelection, } from '../src/client/web-search-card-controller.ts' /** Make the stub behave like a Host that accepts every write. */ @@ -33,52 +33,98 @@ function ctxWith(namespaces: object) { return { remote: namespaces } as never } -function credentialsApi(configured: boolean) { - const describe = vi.fn((refs: readonly string[]) => Promise.resolve({ - ok: true as const, - value: Object.fromEntries(refs.map(ref => [ref, { configured, writable: true }])), - })) - const set = vi.fn(() => Promise.resolve({ ok: true as const, value: undefined })) - return { ctx: ctxWith({ credentials: { describe, set } }), describe, set } +/** Providers the shipped composition registers, in registration order. */ +const SHIPPED_PROVIDERS = { + search: [{ id: 'deepseek-search-api' }, { id: 'deepseek-official' }], + fetch: [{ id: 'http' }, { id: 'deepseek-fetch-api' }], } -/** Shipped composition: Messages search with local HTTP fetch. */ -const SHIPPED_SELECTION = { searchProvider: 'deepseek-official', fetchProvider: 'http' } +type ProviderEntry = { id: string; title?: string; description?: { en: string; [locale: string]: string } } +type ProviderList = { search: ProviderEntry[]; fetch: ProviderEntry[] } +type ProviderResponse = { ok: true; value: ProviderList } | { ok: false; error: RemoteError } + +/** The legacy key's default reference. */ +const LEGACY_KEY = 'DEEPSEEK_API_KEY' + +function remoteApi(configured: boolean | Record) { + const answer = (ref: string) => typeof configured === 'boolean' ? configured : configured[ref] ?? false + const describe = vi.fn((refs: readonly string[]) => Promise.resolve({ + ok: true as const, + value: Object.fromEntries(refs.map(ref => [ref, { configured: answer(ref), writable: true }])), + })) + const set = vi.fn(() => Promise.resolve({ ok: true as const, value: undefined })) + const listProviders = vi.fn((): Promise => Promise.resolve({ ok: true, value: SHIPPED_PROVIDERS })) + return { ctx: ctxWith({ credentials: { describe, set }, web: { listProviders } }), describe, set, listProviders } +} + +/** The shipped composition's selection: the DeepSeek Search API and local HTTP fetch. */ +const SHIPPED_SELECTION = { searchProvider: 'deepseek-search-api', fetchProvider: 'http' } + +/** Legacy Messages search with local HTTP fetch, the selection that shows the key control. */ +const LEGACY_SELECTION = { searchProvider: 'deepseek-official', fetchProvider: 'http' } /** * Mount the controller over three stub scopes. - * @param options - whether each Host accepts writes, the credential answer, and the selection's composed layer. + * @param options - whether each Host accepts writes, the credential answer, and a replacement context. * @returns the scopes, the controller, its face, and a state reader. */ -function bench(options: { accept?: boolean; configured?: boolean; ctx?: never } = {}) { +function bench(options: { accept?: boolean; configured?: boolean | Record; ctx?: never } = {}) { const web = stubConfigForm() - const messages = stubConfigForm() const api = stubConfigForm() + const legacy = stubConfigForm() if (options.accept === true) { acceptWrites(web) - acceptWrites(messages) acceptWrites(api) + acceptWrites(legacy) } - const credentials = credentialsApi(options.configured ?? true) - const controller = new WebSearchCardController( - { web: web.scope, messages: messages.scope, api: api.scope }, options.ctx ?? credentials.ctx, - ) + const remote = remoteApi(options.configured ?? true) + const controller = new WebSearchCardController({ web: web.scope, api: api.scope, legacy: legacy.scope }, options.ctx ?? remote.ctx) const face = controller.inject() - return { web, messages, api, credentials, controller, face, state: () => face.hooks.webSearchCard.getSnapshot() } + return { + web, api, legacy, credentials: remote, controller, face, state: () => face.hooks.webSearchCard.getSnapshot(), + } } -/** Serve all three sections, with the shipped selection unless one is given. */ /** Selection layers a scenario overrides. */ type SelectionLayers = { value?: WebProviderSelection; base?: WebProviderSelection; user?: WebProviderSelection } -function serve(b: ReturnType, selection: SelectionLayers = {}): void { +/** The API section the shipped composition resolves: the schema defaults. */ +const API_DEFAULTS: WebApiSettings = { count: 10 } + +/** + * Serve all three sections, with the shipped selection unless one is given, + * the API section composed as `composed`, and the legacy section as `legacy`, + * each with no user layer. + */ +function serve( + b: ReturnType, selection: SelectionLayers = {}, composed: WebApiSettings = API_DEFAULTS, + legacy: WebLegacySettings = { maxUses: 5 }, +): void { b.web.publish({ status: 'ready', writable: true, value: SHIPPED_SELECTION, base: SHIPPED_SELECTION, user: {}, ...selection }) - b.messages.publish({ status: 'ready', writable: true, value: { maxUses: 5 }, base: {}, user: {} }) - b.api.publish({ status: 'ready', writable: true, value: { count: 10 }, base: { count: 10 }, user: {} }) + b.api.publish({ status: 'ready', writable: true, value: composed, base: composed, user: {} }) + b.legacy.publish({ status: 'ready', writable: true, value: legacy, base: legacy, user: {} }) } -describe('WebSearchCardController credential', () => { - it('reads once per settings publication wave after the eager read', async () => { +/** Both DeepSeek API providers selected. */ +const API_SELECTION = { searchProvider: 'deepseek-search-api', fetchProvider: 'deepseek-fetch-api' } + +/** The shipped providers plus the plugin providers `exa` (Search) and `jina` (Fetch). */ +const WITH_PLUGINS = { + search: [...SHIPPED_PROVIDERS.search, { id: 'exa' }], + fetch: [...SHIPPED_PROVIDERS.fetch, { id: 'jina' }], +} + +/** Mount a Host that accepts writes, with legacy search selected and the legacy section carrying `legacy`. */ +async function keyBench(legacy: WebLegacySettings = {}) { + const b = bench({ accept: true }) + serve(b, { value: LEGACY_SELECTION }, API_DEFAULTS, legacy) + await vi.waitFor(() => { expect(b.state().search.options).toHaveLength(2) }) + await Promise.resolve() + return b +} + +describe('WebSearchCardController legacy key', () => { + it('reads the key reference once per settings publication wave after the eager read', async () => { const b = bench() try { expect(b.credentials.describe).toHaveBeenCalledTimes(1) @@ -88,18 +134,17 @@ describe('WebSearchCardController credential', () => { serve(b) await Promise.resolve() expect(b.credentials.describe).toHaveBeenCalledTimes(3) - for (const args of b.credentials.describe.mock.calls) expect(args).toEqual([['DEEPSEEK_API_KEY']]) + for (const args of b.credentials.describe.mock.calls) expect(args).toEqual([[LEGACY_KEY]]) } finally { b.controller.dispose() } }) - it('reads once when a settings publication wave renames the credential reference', async () => { - const b = bench() + it('reads once when a settings publication wave renames the reference', async () => { + const b = await keyBench() try { - serve(b) - await vi.waitFor(() => { expect(b.state().apiKeyConfigured).toBe(true) }) + await vi.waitFor(() => { expect(b.state().legacyKey?.configured).toBe(true) }) const count = b.credentials.describe.mock.calls.length - b.messages.publish({ value: { apiKeyEnv: 'RENAMED_KEY' } }) - expect(b.state().apiKeyConfigured).toBe(false) + b.legacy.publish({ value: { apiKeyEnv: 'RENAMED_KEY' } }) + expect(b.state().legacyKey?.configured).toBe(false) await Promise.resolve() expect(b.credentials.describe).toHaveBeenCalledTimes(count + 1) expect(b.credentials.describe).toHaveBeenLastCalledWith(['RENAMED_KEY']) @@ -113,21 +158,21 @@ describe('WebSearchCardController credential', () => { try { await Promise.resolve() b.credentials.describe.mockImplementationOnce(() => pending.promise) - serve(b) + serve(b, { value: LEGACY_SELECTION }) await Promise.resolve() const count = b.credentials.describe.mock.calls.length b.credentials.describe.mockImplementationOnce(() => updated.promise) - b.controller.refreshCredential('DEEPSEEK_API_KEY') + b.controller.refreshCredential(LEGACY_KEY) expect(b.credentials.describe).toHaveBeenCalledTimes(count + 1) if (order === 'before') { - pending.resolve({ ok: true, value: { DEEPSEEK_API_KEY: { configured: false, writable: true } } }) + pending.resolve({ ok: true, value: { [LEGACY_KEY]: { configured: false, writable: true } } }) await Promise.resolve() } - updated.resolve({ ok: true, value: { DEEPSEEK_API_KEY: { configured: true, writable: true } } }) - await vi.waitFor(() => { expect(b.state().apiKeyConfigured).toBe(true) }) - pending.resolve({ ok: true, value: { DEEPSEEK_API_KEY: { configured: false, writable: true } } }) + updated.resolve({ ok: true, value: { [LEGACY_KEY]: { configured: true, writable: true } } }) + await vi.waitFor(() => { expect(b.state().legacyKey?.configured).toBe(true) }) + pending.resolve({ ok: true, value: { [LEGACY_KEY]: { configured: false, writable: true } } }) await Promise.resolve() - expect(b.state().apiKeyConfigured).toBe(true) + expect(b.state().legacyKey?.configured).toBe(true) } finally { b.controller.dispose() pending.resolve({ ok: true, value: {} }) @@ -136,89 +181,101 @@ describe('WebSearchCardController credential', () => { } }) - it('discards queued reads and late credential answers after disposal', async () => { + it('discards queued reads and late answers after disposal', async () => { const b = bench({ configured: false }) const pending = Promise.withResolvers>>() + const providers = Promise.withResolvers() const publish = vi.fn() const off = b.face.hooks.webSearchCard.subscribe(publish) try { + await Promise.resolve() + serve(b, { value: LEGACY_SELECTION }) await Promise.resolve() b.credentials.describe.mockImplementationOnce(() => pending.promise) - b.controller.refreshCredential('DEEPSEEK_API_KEY') - serve(b) + b.controller.refreshCredential(LEGACY_KEY) + b.credentials.listProviders.mockImplementationOnce(() => providers.promise) + b.controller.refreshProviders() + serve(b, { value: LEGACY_SELECTION }) b.controller.dispose() const count = b.credentials.describe.mock.calls.length + const listed = b.credentials.listProviders.mock.calls.length publish.mockClear() - pending.resolve({ ok: true, value: { DEEPSEEK_API_KEY: { configured: true, writable: true } } }) + pending.resolve({ ok: true, value: { [LEGACY_KEY]: { configured: true, writable: true } } }) + providers.resolve({ ok: true, value: { search: [], fetch: [] } }) + await Promise.resolve() await Promise.resolve() expect(b.credentials.describe).toHaveBeenCalledTimes(count) expect(publish).not.toHaveBeenCalled() - expect(b.state().apiKeyConfigured).toBe(false) - b.controller.refreshCredential('DEEPSEEK_API_KEY') + expect(b.state().legacyKey?.configured).toBe(false) + b.controller.refreshCredential(LEGACY_KEY) + b.controller.refreshProviders() serve(b) await Promise.resolve() expect(b.credentials.describe).toHaveBeenCalledTimes(count) + expect(b.credentials.listProviders).toHaveBeenCalledTimes(listed) } finally { off() b.controller.dispose() pending.resolve({ ok: true, value: {} }) - await pending.promise + providers.resolve({ ok: true, value: { search: [], fetch: [] } }) + await Promise.all([pending.promise, providers.promise]) } }) - it('reads the credential state for the reference the selected section names', async () => { + it('shows the key control only while legacy search is selected', async () => { const b = bench() - await vi.waitFor(() => { expect(b.credentials.describe).toHaveBeenCalled() }) - serve(b) - await vi.waitFor(() => { expect(b.state().apiKeyConfigured).toBe(true) }) - expect(b.state()).toMatchObject({ baseURL: { text: '', overridden: false }, apiKey: { text: '', overridden: false } }) - b.controller.dispose() + try { + serve(b, { value: API_SELECTION }) + await vi.waitFor(() => { expect(b.credentials.describe).toHaveBeenCalledTimes(2) }) + expect(b.state().legacyKey).toBeUndefined() + b.face.selectProvider('searchProvider', 'deepseek-official') + await vi.waitFor(() => { expect(b.state().legacyKey).toMatchObject({ text: '', overridden: false, ref: LEGACY_KEY, configured: true }) }) + } finally { b.controller.dispose() } }) it('keeps an unknown credential reference writable and unconfigured', async () => { - const b = bench() + const b = await keyBench() try { - await vi.waitFor(() => { expect(b.state().apiKeyConfigured).toBe(true) }) + await vi.waitFor(() => { expect(b.state().legacyKey?.configured).toBe(true) }) b.credentials.describe.mockResolvedValue({ ok: true, value: {} }) - b.controller.refreshCredential('DEEPSEEK_API_KEY') - await vi.waitFor(() => { expect(b.state()).toMatchObject({ apiKeyConfigured: false, apiKeyWritable: true }) }) + b.controller.refreshCredential(LEGACY_KEY) + await vi.waitFor(() => { expect(b.state().legacyKey).toMatchObject({ configured: false, writable: true }) }) } finally { b.controller.dispose() } }) it('writes the staged key through the credentials domain, never a settings section', async () => { - const b = bench({ configured: false }) - serve(b) - b.face.edit('apiKey', ' ds-secret ') + const b = bench() + serve(b, { value: LEGACY_SELECTION }) + await vi.waitFor(() => { expect(b.state().legacyKey).toBeDefined() }) + b.face.edit('legacyApiKey', ' ds-secret ') expect(b.state().dirty).toBe(true) expect(b.credentials.set).not.toHaveBeenCalled() - b.credentials.describe.mockImplementation((refs: readonly string[]) => Promise.resolve({ - ok: true as const, value: Object.fromEntries(refs.map(ref => [ref, { configured: true, writable: true }])), - })) b.face.save() - await vi.waitFor(() => { expect(b.credentials.set).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'ds-secret') }) - for (const host of [b.web, b.messages, b.api]) expect(host.mutate).not.toHaveBeenCalled() - await vi.waitFor(() => { expect(b.state()).toMatchObject({ dirty: false, apiKeyConfigured: true }) }) + await vi.waitFor(() => { expect(b.credentials.set).toHaveBeenCalledWith(LEGACY_KEY, 'ds-secret') }) + for (const host of [b.web, b.api, b.legacy]) expect(host.mutate).not.toHaveBeenCalled() + await vi.waitFor(() => { expect(b.state()).toMatchObject({ dirty: false, legacyKey: { configured: true } }) }) b.controller.dispose() }) it('accepts its successful key readback while a newer credential read is pending', async () => { - const b = bench({ configured: false }) + const b = bench() const readback = Promise.withResolvers>>() const newer = Promise.withResolvers>>() try { - serve(b) - await vi.waitFor(() => { expect(b.credentials.describe).toHaveBeenCalledTimes(2) }) + serve(b, { value: LEGACY_SELECTION }) + await vi.waitFor(() => { expect(b.state().legacyKey).toBeDefined() }) + const count = b.credentials.describe.mock.calls.length b.credentials.describe.mockImplementationOnce(() => readback.promise) - b.face.edit('apiKey', 'ds-secret') + b.face.edit('legacyApiKey', 'ds-secret') b.face.save() - await vi.waitFor(() => { expect(b.credentials.describe).toHaveBeenCalledTimes(3) }) + await vi.waitFor(() => { expect(b.credentials.describe).toHaveBeenCalledTimes(count + 1) }) b.credentials.describe.mockImplementationOnce(() => newer.promise) - b.controller.refreshCredential('DEEPSEEK_API_KEY') - expect(b.credentials.describe).toHaveBeenCalledTimes(4) - readback.resolve({ ok: true, value: { DEEPSEEK_API_KEY: { configured: true, writable: true } } }) + b.controller.refreshCredential(LEGACY_KEY) + expect(b.credentials.describe).toHaveBeenCalledTimes(count + 2) + readback.resolve({ ok: true, value: { [LEGACY_KEY]: { configured: true, writable: true } } }) await vi.waitFor(() => { expect(b.state()).toMatchObject({ dirty: false, saving: false, failed: false }) }) - newer.resolve({ ok: true, value: { DEEPSEEK_API_KEY: { configured: true, writable: true } } }) - await vi.waitFor(() => { expect(b.state().apiKeyConfigured).toBe(true) }) + newer.resolve({ ok: true, value: { [LEGACY_KEY]: { configured: true, writable: true } } }) + await vi.waitFor(() => { expect(b.state().legacyKey?.configured).toBe(true) }) } finally { b.controller.dispose() readback.resolve({ ok: true, value: {} }) @@ -227,10 +284,9 @@ describe('WebSearchCardController credential', () => { } }) - it('keeps the stored key when the draft is left blank', () => { - const b = bench() - serve(b) - b.face.edit('apiKey', ' ') + it('keeps the stored key when the draft is left blank', async () => { + const b = await keyBench() + b.face.edit('legacyApiKey', ' ') expect(b.state().dirty).toBe(false) b.face.save() expect(b.credentials.set).not.toHaveBeenCalled() @@ -239,7 +295,7 @@ describe('WebSearchCardController credential', () => { it('re-reads only when the Host reports the watched reference changed', async () => { const b = bench({ configured: false }) - serve(b) + serve(b, { value: LEGACY_SELECTION }) await vi.waitFor(() => { expect(b.credentials.describe).toHaveBeenCalled() }) b.credentials.describe.mockClear() b.controller.refreshCredential('OTHER_KEY') @@ -247,68 +303,63 @@ describe('WebSearchCardController credential', () => { b.credentials.describe.mockImplementation((refs: readonly string[]) => Promise.resolve({ ok: true as const, value: Object.fromEntries(refs.map(ref => [ref, { configured: true, writable: true }])), })) - b.controller.refreshCredential('DEEPSEEK_API_KEY') - await vi.waitFor(() => { expect(b.state().apiKeyConfigured).toBe(true) }) + b.controller.refreshCredential(LEGACY_KEY) + await vi.waitFor(() => { expect(b.state().legacyKey?.configured).toBe(true) }) b.controller.dispose() }) - it('addresses the reference the selected API section declares', async () => { - const b = bench({ accept: true }) - serve(b) - b.messages.publish({ value: { apiKeyEnv: 'MESSAGES_KEY' } }) - b.api.publish({ value: { apiKeyEnv: 'API_KEY', count: 10 } }) - await vi.waitFor(() => { expect(b.credentials.describe).toHaveBeenLastCalledWith(['MESSAGES_KEY']) }) - b.face.setSearchApi(true) - await vi.waitFor(() => { expect(b.credentials.describe).toHaveBeenLastCalledWith(['API_KEY']) }) - b.face.edit('apiKey', 'ds-secret') + it('addresses the reference the legacy section declares', async () => { + const b = await keyBench({ apiKeyEnv: 'TEAM_KEY' }) + await vi.waitFor(() => { expect(b.credentials.describe).toHaveBeenLastCalledWith(['TEAM_KEY']) }) + b.face.edit('legacyApiKey', 'ds-secret') b.face.save() - await vi.waitFor(() => { expect(b.credentials.set).toHaveBeenCalledWith('API_KEY', 'ds-secret') }) + await vi.waitFor(() => { expect(b.credentials.set).toHaveBeenCalledWith('TEAM_KEY', 'ds-secret') }) b.controller.dispose() }) it('reports a key the Host did not store as a failed save', async () => { const b = bench({ configured: false }) - serve(b) - b.face.edit('apiKey', 'ds-secret') + serve(b, { value: LEGACY_SELECTION }) + b.face.edit('legacyApiKey', 'ds-secret') b.face.save() - await vi.waitFor(() => { expect(b.state()).toMatchObject({ failed: true, dirty: true, apiSwitchBlocked: true }) }) + await vi.waitFor(() => { expect(b.state()).toMatchObject({ failed: true, dirty: true, selectionBlocked: true }) }) b.controller.dispose() }) it('keeps the card usable when the credential read and write are refused', async () => { const refusal = () => Promise.resolve({ ok: false as const, - error: new RemoteError('credential/rejected', 'offline', { ref: 'DEEPSEEK_API_KEY' }), + error: new RemoteError('credential/rejected', 'offline', { ref: LEGACY_KEY }), }) const describe = vi.fn(refusal) const set = vi.fn(refusal) - const b = bench({ ctx: ctxWith({ credentials: { describe, set } }) }) + const listProviders = vi.fn(refusal) + const b = bench({ ctx: ctxWith({ credentials: { describe, set }, web: { listProviders } }) }) await vi.waitFor(() => { expect(describe).toHaveBeenCalled() }) - serve(b) - b.face.edit('apiKey', 'ds-secret') + serve(b, { value: LEGACY_SELECTION }) + b.face.edit('legacyApiKey', 'ds-secret') b.face.save() await vi.waitFor(() => { expect(set).toHaveBeenCalled() }) - expect(b.state()).toMatchObject({ available: true, apiKeyConfigured: false, baseURL: { text: '' } }) + expect(b.state()).toMatchObject({ available: true, search: { kind: 'legacy' }, legacyKey: { configured: false } }) b.controller.dispose() }) it('clears a changed reference immediately and drops its previous pending answer', async () => { - const b = bench() + const b = await keyBench() const previous = Promise.withResolvers>>() const renamed = Promise.withResolvers>>() try { - serve(b) - await vi.waitFor(() => { expect(b.state().apiKeyConfigured).toBe(true) }) + await vi.waitFor(() => { expect(b.state().legacyKey?.configured).toBe(true) }) b.credentials.describe.mockImplementationOnce(() => previous.promise) - b.controller.refreshCredential('DEEPSEEK_API_KEY') + b.controller.refreshCredential(LEGACY_KEY) b.credentials.describe.mockImplementation(() => renamed.promise) - b.messages.publish({ value: { apiKeyEnv: 'RENAMED_KEY' } }) - expect(b.state().apiKeyConfigured).toBe(false) - previous.resolve({ ok: true, value: { DEEPSEEK_API_KEY: { configured: true, writable: true } } }) + b.legacy.publish({ value: { apiKeyEnv: 'RENAMED_KEY' } }) + expect(b.state().legacyKey?.configured).toBe(false) + previous.resolve({ ok: true, value: { [LEGACY_KEY]: { configured: true, writable: true } } }) await Promise.resolve() - expect(b.state().apiKeyConfigured).toBe(false) + expect(b.state().legacyKey?.configured).toBe(false) renamed.resolve({ ok: true, value: { RENAMED_KEY: { configured: true, writable: false } } }) - await vi.waitFor(() => { expect(b.state()).toMatchObject({ apiKeyConfigured: true, apiKeyWritable: false }) }) + await vi.waitFor(() => { expect(b.state().legacyKey).toMatchObject({ configured: true, writable: false }) }) } finally { b.controller.dispose() previous.resolve({ ok: true, value: {} }) @@ -316,69 +367,209 @@ describe('WebSearchCardController credential', () => { await Promise.all([previous.promise, renamed.promise]) } }) + + it('drops the answer for a reference renamed while the read was in flight', async () => { + const b = await keyBench() + const pending = Promise.withResolvers>>() + try { + b.credentials.describe.mockImplementationOnce(() => pending.promise) + b.controller.refreshCredential(LEGACY_KEY) + // Renames the reference without a publication, as a scope that changes outside its subscription would. + const snapshot = b.legacy.scope.getSnapshot() + vi.spyOn(b.legacy.scope, 'getSnapshot').mockReturnValue({ ...snapshot, value: { ...snapshot.value, apiKeyEnv: 'RENAMED_KEY' } }) + b.credentials.describe.mockImplementation((refs: readonly string[]) => Promise.resolve({ + ok: true as const, value: Object.fromEntries(refs.map(ref => [ref, { configured: false, writable: true }])), + })) + pending.resolve({ ok: true, value: { [LEGACY_KEY]: { configured: true, writable: false } } }) + await vi.waitFor(() => { expect(b.credentials.describe).toHaveBeenLastCalledWith(['RENAMED_KEY']) }) + await vi.waitFor(() => { expect(b.state().legacyKey).toMatchObject({ ref: 'RENAMED_KEY', configured: false, writable: true }) }) + } finally { + b.controller.dispose() + pending.resolve({ ok: true, value: {} }) + } + }) + + it('clears a reference renamed outside a publication when the next read starts', async () => { + const b = await keyBench() + try { + await vi.waitFor(() => { expect(b.state().legacyKey?.configured).toBe(true) }) + const snapshot = b.legacy.scope.getSnapshot() + vi.spyOn(b.legacy.scope, 'getSnapshot').mockReturnValue({ ...snapshot, value: { ...snapshot.value, apiKeyEnv: 'RENAMED_KEY' } }) + b.credentials.describe.mockImplementation(() => new Promise(() => {})) + b.controller.refreshCredential(LEGACY_KEY) + expect(b.credentials.describe).toHaveBeenLastCalledWith(['RENAMED_KEY']) + expect(b.state().legacyKey?.configured).toBe(false) + } finally { b.controller.dispose() } + }) + + it('reads the reference an already-served legacy section names on its first read', async () => { + const web = stubConfigForm() + const api = stubConfigForm() + const legacy = stubConfigForm() + web.publish({ status: 'ready', writable: true, value: LEGACY_SELECTION, base: LEGACY_SELECTION, user: {} }) + legacy.publish({ status: 'ready', writable: true, value: { apiKeyEnv: 'SERVED_KEY' }, base: {}, user: {} }) + const remote = remoteApi({ SERVED_KEY: true }) + const controller = new WebSearchCardController({ web: web.scope, api: api.scope, legacy: legacy.scope }, remote.ctx) + try { + expect(remote.describe).toHaveBeenCalledExactlyOnceWith(['SERVED_KEY']) + await vi.waitFor(() => { expect(controller.inject().hooks.webSearchCard.getSnapshot().legacyKey?.configured).toBe(true) }) + } finally { controller.dispose() } + }) +}) + +describe('WebSearchCardController legacy key drafts', () => { + it('drops the draft once legacy search is deselected', async () => { + const b = await keyBench() + try { + b.face.edit('legacyApiKey', 'draft') + b.face.selectProvider('searchProvider', 'deepseek-search-api') + expect(b.state()).toMatchObject({ dirty: false, legacyKey: undefined }) + b.face.selectProvider('searchProvider', 'deepseek-official') + expect(b.state()).toMatchObject({ dirty: false, legacyKey: { text: '' } }) + b.face.save() + await Promise.resolve() + expect(b.credentials.set).not.toHaveBeenCalled() + } finally { b.controller.dispose() } + }) + + it('drops the draft when the Host stops registering legacy search', async () => { + const b = await keyBench() + try { + b.face.edit('legacyApiKey', 'draft') + b.credentials.listProviders.mockResolvedValue({ ok: true, value: { search: [{ id: 'exa' }], fetch: [{ id: 'http' }] } }) + b.controller.refreshProviders() + await vi.waitFor(() => { expect(b.state()).toMatchObject({ search: { kind: 'none' }, legacyKey: undefined }) }) + expect(b.state().dirty).toBe(false) + b.credentials.listProviders.mockResolvedValue({ ok: true, value: WITH_PLUGINS }) + b.controller.refreshProviders() + await vi.waitFor(() => { expect(b.state().legacyKey).toMatchObject({ text: '' }) }) + } finally { b.controller.dispose() } + }) + + it('drops the draft when the legacy section renames its reference', async () => { + const b = await keyBench({ apiKeyEnv: 'TEAM_KEY' }) + try { + b.face.edit('legacyApiKey', 'draft') + b.legacy.publish({ value: { apiKeyEnv: 'RENAMED_KEY' } }) + expect(b.state()).toMatchObject({ dirty: false, legacyKey: { text: '', ref: 'RENAMED_KEY' } }) + b.face.save() + await Promise.resolve() + expect(b.credentials.set).not.toHaveBeenCalled() + } finally { b.controller.dispose() } + }) + + it('ignores a draft for a key control the page does not show', async () => { + const b = bench({ accept: true }) + try { + serve(b, { value: API_SELECTION }) + b.face.edit('legacyApiKey', 'hidden-draft') + expect(b.state()).toMatchObject({ dirty: false, legacyKey: undefined }) + b.face.selectProvider('searchProvider', 'deepseek-official') + expect(b.state().legacyKey).toMatchObject({ text: '' }) + } finally { b.controller.dispose() } + }) + + it('completes a planned key write under its reference when its provider disappears during the save', async () => { + const b = await keyBench({ apiKeyEnv: 'TEAM_KEY' }) + const first = Promise.withResolvers<{ ok: true; value: undefined }>() + try { + b.face.edit('legacyApiKey', 'draft') + b.credentials.set.mockImplementationOnce(() => first.promise) + b.face.save() + await vi.waitFor(() => { expect(b.credentials.set).toHaveBeenCalledTimes(1) }) + b.credentials.listProviders.mockResolvedValue({ ok: true, value: { search: [{ id: 'exa' }], fetch: [{ id: 'http' }] } }) + b.controller.refreshProviders() + await vi.waitFor(() => { expect(b.state()).toMatchObject({ search: { kind: 'none' }, legacyKey: undefined }) }) + first.resolve({ ok: true, value: undefined }) + await vi.waitFor(() => { expect(b.state().saving).toBe(false) }) + expect(b.credentials.set.mock.calls).toEqual([['TEAM_KEY', 'draft']]) + expect(b.credentials.describe).toHaveBeenLastCalledWith(['TEAM_KEY']) + expect(b.state()).toMatchObject({ failed: false, dirty: false }) + } finally { + first.resolve({ ok: true, value: undefined }) + b.controller.dispose() + } + }) +}) + +describe('WebSearchCardController deployment defaults', () => { + it('saves a typed value equal to the deployment default as an override', async () => { + const b = bench({ accept: true }) + try { + const composed = { baseURL: 'https://messages.test/v1', maxUses: 5 } + serve(b, { value: LEGACY_SELECTION }, API_DEFAULTS, composed) + b.api.publish({ value: { count: 20 }, user: { count: 20 } }) + const stored = { baseURL: 'https://custom.test/v1' } + b.legacy.publish({ value: { ...composed, ...stored }, user: stored }) + b.face.edit('messagesBaseURL', ' https://messages.test/v1 ') + b.face.edit('count', '10') + expect(b.state()).toMatchObject({ + dirty: true, count: { text: '10', overridden: true }, messagesBaseURL: { text: ' https://messages.test/v1 ', overridden: true }, + }) + b.face.save() + await vi.waitFor(() => { expect(b.state().dirty).toBe(false) }) + expect(b.api.mutate).toHaveBeenCalledExactlyOnceWith([{ op: 'set', path: ['count'], value: 10 }], undefined) + expect(b.legacy.mutate).toHaveBeenCalledExactlyOnceWith([{ op: 'set', path: ['baseURL'], value: 'https://messages.test/v1' }], undefined) + expect(b.legacy.scope.getSnapshot().user).toEqual({ baseURL: 'https://messages.test/v1' }) + expect(b.state()).toMatchObject({ count: { overridden: true }, messagesBaseURL: { overridden: true } }) + } finally { b.controller.dispose() } + }) + + it('writes nothing for a typed value equal to the composed one the user layer does not override', async () => { + const b = bench({ accept: true }) + try { + serve(b) + b.face.edit('count', '11') + expect(b.state().dirty).toBe(true) + b.face.edit('count', '10') + expect(b.state().dirty).toBe(false) + b.face.save() + await Promise.resolve() + expect(b.api.mutate).not.toHaveBeenCalled() + } finally { b.controller.dispose() } + }) + + it('marks a stored override as overridden even when it equals the deployment default', () => { + const b = bench() + try { + serve(b, { value: LEGACY_SELECTION }) + const stored = { baseURL: 'https://messages.test/v1' } + b.legacy.publish({ value: { maxUses: 5, ...stored }, base: { maxUses: 5, ...stored }, user: stored }) + expect(b.state()).toMatchObject({ dirty: false, messagesBaseURL: { text: stored.baseURL, overridden: true } }) + b.face.resetField('messagesBaseURL') + expect(b.state()).toMatchObject({ dirty: true, messagesBaseURL: { overridden: false } }) + } finally { b.controller.dispose() } + }) }) describe('WebSearchCardController sections', () => { - it('saves the Messages endpoint and search budget together', async () => { + it('stages and resets the legacy endpoint and search limit apart from the result count, clearing a blank limit', async () => { const b = bench({ accept: true }) - serve(b) - b.face.edit('baseURL', 'https://other.test') - b.face.edit('maxUses', '3') - b.face.save() - await vi.waitFor(() => { expect(b.messages.mutate).toHaveBeenCalledTimes(1) }) - expect(b.messages.mutate).toHaveBeenCalledWith([ - { op: 'set', path: ['baseURL'], value: 'https://other.test' }, - { op: 'set', path: ['maxUses'], value: 3 }, - ], undefined) - expect(b.web.mutate).not.toHaveBeenCalled() - expect(b.api.mutate).not.toHaveBeenCalled() - b.controller.dispose() - }) - - /** - * Edit the selected section's endpoint, save, reset, and save again. - * @param b - the mounted controller. - * @param host - the selected section's scope. - * @param composed - the composed endpoint the field shows without an override. - */ - async function endpointRoundTrip( - b: ReturnType, host: StubConfigForm, composed: string, - ): Promise { - expect(b.state().baseURL).toEqual({ text: composed, overridden: false, invalid: false }) - b.face.edit('baseURL', 'https://custom.test/tools') - b.face.save() - await vi.waitFor(() => { expect(b.state().dirty).toBe(false) }) - expect(host.mutate).toHaveBeenLastCalledWith([{ op: 'set', path: ['baseURL'], value: 'https://custom.test/tools' }], undefined) - expect(b.state().baseURL).toMatchObject({ text: 'https://custom.test/tools', overridden: true }) - b.face.resetField('baseURL') - expect(b.state().baseURL).toMatchObject({ text: composed, overridden: false }) - b.face.save() - await vi.waitFor(() => { expect(b.state().dirty).toBe(false) }) - expect(host.mutate).toHaveBeenLastCalledWith([{ op: 'unset', path: ['baseURL'] }], undefined) - expect(b.state().baseURL).toMatchObject({ text: composed, overridden: false }) - } - - it('edits the Messages endpoint while the switch is off', async () => { - const b = bench({ accept: true }) - serve(b) - try { await endpointRoundTrip(b, b.messages, '') } finally { b.controller.dispose() } - expect(b.api.mutate).not.toHaveBeenCalled() - }) - - it('edits the Search / Fetch API endpoint while the switch is on', async () => { - const b = bench({ accept: true }) - serve(b, { value: { searchProvider: 'deepseek-search-api' } }) - const composed = { count: 10, baseURL: 'https://api.deepseek.com/v1/tools' } - b.api.publish({ value: composed, base: composed }) - try { await endpointRoundTrip(b, b.api, composed.baseURL) } finally { b.controller.dispose() } - expect(b.messages.mutate).not.toHaveBeenCalled() + try { + serve(b, { value: LEGACY_SELECTION }) + b.api.publish({ value: { count: 20 }, user: { count: 20 } }) + b.face.resetField('count') + expect(b.state().count).toMatchObject({ text: '10', overridden: false }) + const stored = { baseURL: 'https://messages.test/v1', maxUses: 8 } + b.legacy.publish({ value: stored, user: stored }) + b.face.resetField('maxUses') + expect(b.state().maxUses).toMatchObject({ text: '5', overridden: false }) + b.face.edit('maxUses', ' ') + b.face.resetField('messagesBaseURL') + b.face.save() + await vi.waitFor(() => { expect(b.state().dirty).toBe(false) }) + expect(b.legacy.mutate).toHaveBeenCalledExactlyOnceWith([ + { op: 'unset', path: ['maxUses'] }, { op: 'unset', path: ['baseURL'] }, + ], undefined) + expect(b.api.mutate).toHaveBeenCalledExactlyOnceWith([{ op: 'unset', path: ['count'] }], undefined) + } finally { b.controller.dispose() } }) it.each(['0', '51', '1.5', 'many', 'Infinity'])('rejects an unsupported Search API result count: %s', (text) => { const b = bench() serve(b, { value: { searchProvider: 'deepseek-search-api' } }) b.face.edit('count', text) - expect(b.state()).toMatchObject({ invalid: true, apiSwitchBlocked: true, count: { text, invalid: true } }) + expect(b.state()).toMatchObject({ invalid: true, selectionBlocked: true, count: { text, invalid: true } }) b.face.save() expect(b.api.mutate).not.toHaveBeenCalled() b.controller.dispose() @@ -400,61 +591,273 @@ describe('WebSearchCardController sections', () => { }) }) -describe('WebSearchCardController API switch', () => { - it('stages both provider selections, discards them, and clears them back to the shipped defaults', async () => { +describe('WebSearchCardController providers', () => { + it('lists the official providers first in a fixed order and plugin providers after them in Host order', async () => { + const b = bench() + try { + b.credentials.listProviders.mockResolvedValue({ + ok: true, + value: { + search: [{ id: 'exa' }, { id: 'brave' }, { id: 'deepseek-search-api' }], + fetch: [{ id: 'jina' }, { id: 'deepseek-fetch-api' }, { id: 'http' }], + }, + }) + b.controller.refreshProviders() + serve(b, { value: { searchProvider: 'perplexity', fetchProvider: 'http' } }) + await vi.waitFor(() => { expect(b.state().search.options).toHaveLength(4) }) + expect(b.state().search.options).toEqual([ + { id: 'deepseek-search-api', missing: false }, + { id: 'exa', missing: false }, { id: 'brave', missing: false }, { id: 'perplexity', missing: true }, + ]) + expect(b.state().fetch.options.map(option => option.id)).toEqual(['http', 'deepseek-fetch-api', 'jina']) + } finally { b.controller.dispose() } + }) + + it('omits an official provider the Host does not register from the fixed order', async () => { + const b = bench() + try { + b.credentials.listProviders.mockResolvedValue({ ok: true, value: { search: [{ id: 'exa' }], fetch: [{ id: 'http' }] } }) + b.controller.refreshProviders() + serve(b, { value: { searchProvider: 'exa', fetchProvider: 'http' } }) + await vi.waitFor(() => { expect(b.state().search.options.map(option => option.id)).toEqual(['exa']) }) + expect(b.state().fetch.options.map(option => option.id)).toEqual(['http']) + } finally { b.controller.dispose() } + }) + + it('resolves a provider description through the locale service', () => { + const { describe, set, listProviders } = remoteApi(false) + const resolveText = vi.fn(() => 'resolved') + const b = bench({ ctx: { remote: { credentials: { describe, set }, web: { listProviders } }, locale: { resolveText } } as never }) + try { + const description = { en: 'English.', zh: '中文' } + expect(b.face.resolveText(description)).toBe('resolved') + expect(resolveText).toHaveBeenCalledExactlyOnceWith(description) + } finally { b.controller.dispose() } + }) + + it('offers plugin providers and shows a plugin selection with no settings of its own', async () => { + const b = bench() + try { + b.credentials.listProviders.mockResolvedValue({ + ok: true, value: { search: [...SHIPPED_PROVIDERS.search, { id: 'exa', title: 'Exa', description: { en: 'Exa search.', zh: 'Exa 搜索' } }], fetch: SHIPPED_PROVIDERS.fetch }, + }) + b.controller.refreshProviders() + serve(b, { value: { searchProvider: 'exa', fetchProvider: 'http' } }) + await vi.waitFor(() => { expect(b.state().search.options).toHaveLength(3) }) + expect(b.state().search).toMatchObject({ + selected: 'exa', kind: 'plugin', + options: [ + { id: 'deepseek-search-api', missing: false }, + { id: 'deepseek-official', missing: false }, + { id: 'exa', title: 'Exa', description: { en: 'Exa search.', zh: 'Exa 搜索' }, missing: false }, + ], + }) + expect(b.state().fetch).toMatchObject({ selected: 'http', kind: 'local' }) + b.credentials.listProviders.mockResolvedValue({ + ok: true, value: { search: SHIPPED_PROVIDERS.search, fetch: [...SHIPPED_PROVIDERS.fetch, { id: 'jina' }] }, + }) + b.controller.refreshProviders() + b.face.selectProvider('fetchProvider', 'jina') + await vi.waitFor(() => { expect(b.state().fetch).toMatchObject({ selected: 'jina', kind: 'plugin' }) }) + } finally { b.controller.dispose() } + }) + + it('adds a selected provider the Host does not register without changing the selection', async () => { + const b = bench() + try { + serve(b, { value: { searchProvider: 'perplexity', fetchProvider: 'jina' } }) + await vi.waitFor(() => { expect(b.credentials.listProviders).toHaveBeenCalled() }) + await vi.waitFor(() => { expect(b.state().search.options).toHaveLength(3) }) + expect(b.state().search).toMatchObject({ selected: 'perplexity', kind: 'none', options: [{}, {}, { id: 'perplexity', missing: true }] }) + expect(b.state().fetch).toMatchObject({ selected: 'jina', kind: 'none', options: [{}, {}, { id: 'jina', missing: true }] }) + expect(b.state().dirty).toBe(false) + } finally { b.controller.dispose() } + }) + + it('treats an official provider the Host does not register as not loaded', async () => { + const b = bench() + try { + b.credentials.listProviders.mockResolvedValue({ ok: true, value: { search: [{ id: 'exa' }], fetch: [] } }) + b.controller.refreshProviders() + serve(b, { value: API_SELECTION }) + await vi.waitFor(() => { expect(b.state().fetch.options).toEqual([{ id: 'deepseek-fetch-api', missing: true }]) }) + expect(b.state()).toMatchObject({ search: { kind: 'none' }, fetch: { kind: 'none' } }) + } finally { b.controller.dispose() } + }) + + it('keeps the last list when a provider read fails', async () => { + const b = bench() + try { + b.credentials.listProviders.mockResolvedValue({ ok: true, value: WITH_PLUGINS }) + serve(b, { value: { searchProvider: 'exa' } }) + await vi.waitFor(() => { expect(b.state().search.options).toHaveLength(3) }) + b.credentials.listProviders.mockResolvedValueOnce({ ok: false, error: new RemoteError('gateway/internal', 'down', {}) }) + b.controller.refreshProviders() + await vi.waitFor(() => { expect(b.credentials.listProviders).toHaveBeenCalledTimes(3) }) + await vi.waitFor(() => { expect(b.state().providersFailed).toBe(true) }) + expect(b.state().search.options.map(option => option.id)).toEqual(['deepseek-search-api', 'deepseek-official', 'exa']) + b.face.retryProviders() + await vi.waitFor(() => { expect(b.state().providersFailed).toBe(false) }) + expect(b.credentials.listProviders).toHaveBeenCalledTimes(4) + } finally { b.controller.dispose() } + }) + + it('reports a failed first read without claiming the menu is complete, and recovers on retry', async () => { + const b = bench() + try { + b.credentials.listProviders.mockResolvedValue({ ok: false, error: new RemoteError('gateway/internal', 'down', {}) }) + b.controller.refreshProviders() + serve(b, { value: { searchProvider: 'exa', fetchProvider: 'http' } }) + await vi.waitFor(() => { expect(b.state().providersFailed).toBe(true) }) + expect(b.state().search).toMatchObject({ options: [{ id: 'exa', missing: false }], kind: 'none' }) + expect(b.state().fetch).toMatchObject({ options: [{ id: 'http', missing: false }], kind: 'local' }) + b.credentials.listProviders.mockResolvedValue({ ok: true, value: WITH_PLUGINS }) + b.face.retryProviders() + await vi.waitFor(() => { expect(b.state()).toMatchObject({ providersFailed: false, search: { kind: 'plugin' } }) }) + expect(b.state().search.options).toHaveLength(3) + } finally { b.controller.dispose() } + }) + + it('ignores a failure answered for a superseded read', async () => { + const stale = Promise.withResolvers() + const b = bench() + try { + serve(b) + b.credentials.listProviders.mockImplementationOnce(() => stale.promise) + b.controller.refreshProviders() + b.face.retryProviders() + await vi.waitFor(() => { expect(b.state().fetch.options).toHaveLength(2) }) + stale.resolve({ ok: false, error: new RemoteError('gateway/internal', 'down', {}) }) + await Promise.resolve() + expect(b.state().providersFailed).toBe(false) + } finally { b.controller.dispose() } + }) + + it('renders an unread list as the selected id alone', () => { + const listProviders = vi.fn(() => new Promise(() => {})) + const describe = vi.fn(() => new Promise(() => {})) + const b = bench({ ctx: ctxWith({ credentials: { describe, set: vi.fn() }, web: { listProviders } }) }) + try { + serve(b, { value: { searchProvider: 'exa', fetchProvider: 'http' } }) + expect(b.state().search).toMatchObject({ options: [{ id: 'exa', missing: false }], kind: 'none' }) + expect(b.state().fetch).toMatchObject({ options: [{ id: 'http', missing: false }], kind: 'local' }) + b.web.publish({ value: { fetchProvider: 'jina' } }) + expect(b.state().search).toMatchObject({ selected: '', options: [], kind: 'none' }) + expect(b.state().fetch).toMatchObject({ options: [{ id: 'jina', missing: false }], kind: 'none' }) + } finally { b.controller.dispose() } + }) + + it('publishes only the latest provider read', async () => { + const stale = Promise.withResolvers() + const b = bench() + try { + serve(b) + b.credentials.listProviders.mockImplementationOnce(() => stale.promise) + b.controller.refreshProviders() + b.credentials.listProviders.mockResolvedValueOnce({ ok: true, value: { search: [{ id: 'exa' }], fetch: [] } }) + b.controller.refreshProviders() + await vi.waitFor(() => { expect(b.state().search.options.map(option => option.id)).toEqual(['exa', 'deepseek-search-api']) }) + stale.resolve({ ok: true, value: SHIPPED_PROVIDERS }) + await Promise.resolve() + expect(b.state().search.options.map(option => option.id)).toEqual(['exa', 'deepseek-search-api']) + } finally { b.controller.dispose() } + }) + + it('re-reads the providers when the Host starts serving the selection', async () => { + const b = bench() + try { + await Promise.resolve() + expect(b.credentials.listProviders).toHaveBeenCalledTimes(1) + serve(b) + expect(b.credentials.listProviders).toHaveBeenCalledTimes(2) + serve(b) + expect(b.credentials.listProviders).toHaveBeenCalledTimes(2) + b.web.publish({ status: 'unavailable' }) + b.web.publish({ status: 'ready' }) + expect(b.credentials.listProviders).toHaveBeenCalledTimes(3) + } finally { b.controller.dispose() } + }) +}) + +describe('WebSearchCardController provider selection', () => { + it('keeps legacy settings and key writes separate from Search API settings and clears back to the API default', async () => { const b = bench({ accept: true }) - serve(b) - b.messages.publish({ value: { baseURL: 'https://messages.test/v1', maxUses: 5 }, user: { baseURL: 'https://messages.test/v1' } }) - const state = b.state - expect(state()).toMatchObject({ apiSwitchAvailable: true, useSearchApi: false }) - b.face.setSearchApi(true) - expect(state()).toMatchObject({ useSearchApi: true, dirty: true, baseURL: { text: '' } }) - expect(b.web.mutate).not.toHaveBeenCalled() - b.face.discard() - expect(state().useSearchApi).toBe(false) - b.face.setSearchApi(true) + try { + serve(b) + await vi.waitFor(() => { expect(b.state().search.options.map(option => option.id)).toEqual(['deepseek-search-api', 'deepseek-official']) }) + b.face.selectProvider('searchProvider', 'deepseek-official') + expect(b.state().search).toMatchObject({ kind: 'legacy' }) + b.face.edit('messagesBaseURL', 'https://messages.test/anthropic/v1') + b.face.edit('maxUses', '8') + b.face.edit('legacyApiKey', 'legacy-secret') + b.face.save() + await vi.waitFor(() => { expect(b.state().dirty).toBe(false) }) + expect(b.legacy.mutate).toHaveBeenCalledWith([ + { op: 'set', path: ['baseURL'], value: 'https://messages.test/anthropic/v1' }, + { op: 'set', path: ['maxUses'], value: 8 }, + ], undefined) + expect(b.credentials.set).toHaveBeenCalledExactlyOnceWith(LEGACY_KEY, 'legacy-secret') + expect(b.api.mutate).not.toHaveBeenCalled() + b.face.selectProvider('searchProvider', 'deepseek-search-api') + b.face.save() + await vi.waitFor(() => { expect(b.state().dirty).toBe(false) }) + expect(b.web.mutate).toHaveBeenLastCalledWith([{ op: 'unset', path: ['searchProvider'] }], undefined) + expect(b.state().legacyKey).toBeUndefined() + b.face.selectProvider('searchProvider', 'deepseek-official') + expect(b.state().messagesBaseURL.text).toBe('https://messages.test/anthropic/v1') + b.face.edit('legacyApiKey', 'must-not-save') + b.face.selectProvider('searchProvider', 'deepseek-search-api') + b.face.selectProvider('searchProvider', 'deepseek-official') + expect(b.state().legacyKey?.text).toBe('') + b.face.edit('maxUses', '1.5') + expect(b.state().selectionBlocked).toBe(true) + } finally { b.controller.dispose() } + }) + + it('selects Search and Fetch independently and clears a selection back to the composed default', async () => { + const b = bench({ accept: true }) + try { + b.credentials.listProviders.mockResolvedValue({ ok: true, value: WITH_PLUGINS }) + serve(b) + await vi.waitFor(() => { expect(b.state().search.options).toHaveLength(3) }) + expect(b.state()).toMatchObject({ search: { selected: 'deepseek-search-api', kind: 'deepseek' }, fetch: { selected: 'http', kind: 'local' } }) + b.face.selectProvider('searchProvider', 'exa') + expect(b.state()).toMatchObject({ dirty: true, search: { selected: 'exa', kind: 'plugin' }, fetch: { selected: 'http' } }) + expect(b.web.mutate).not.toHaveBeenCalled() + b.face.discard() + expect(b.state().search.selected).toBe('deepseek-search-api') + b.face.selectProvider('searchProvider', 'exa') + b.face.save() + await vi.waitFor(() => { expect(b.state().dirty).toBe(false) }) + expect(b.web.mutate).toHaveBeenLastCalledWith([{ op: 'set', path: ['searchProvider'], value: 'exa' }], undefined) + b.face.selectProvider('fetchProvider', 'deepseek-fetch-api') + b.face.save() + await vi.waitFor(() => { expect(b.state().dirty).toBe(false) }) + expect(b.web.mutate).toHaveBeenLastCalledWith([{ op: 'set', path: ['fetchProvider'], value: 'deepseek-fetch-api' }], undefined) + b.face.selectProvider('searchProvider', 'deepseek-search-api') + b.face.selectProvider('fetchProvider', 'http') + b.face.save() + await vi.waitFor(() => { expect(b.state().dirty).toBe(false) }) + expect(b.web.mutate).toHaveBeenLastCalledWith([ + { op: 'unset', path: ['searchProvider'] }, + { op: 'unset', path: ['fetchProvider'] }, + ], undefined) + expect(b.api.mutate).not.toHaveBeenCalled() + } finally { b.controller.dispose() } + }) + + it('writes explicit selections when the deployment composes the APIs', async () => { + const b = bench({ accept: true }) + serve(b, { value: API_SELECTION, base: API_SELECTION }) + expect(b.state()).toMatchObject({ search: { kind: 'deepseek' }, fetch: { kind: 'deepseek' } }) + b.face.selectProvider('fetchProvider', 'http') b.face.save() - await vi.waitFor(() => { expect(state().dirty).toBe(false) }) - expect(b.web.mutate).toHaveBeenLastCalledWith([ - { op: 'set', path: ['searchProvider'], value: 'deepseek-search-api' }, - { op: 'set', path: ['fetchProvider'], value: 'deepseek-fetch-api' }, - ], undefined) - expect(state().useSearchApi).toBe(true) - b.face.setSearchApi(false) - b.face.save() - await vi.waitFor(() => { expect(state().dirty).toBe(false) }) - expect(b.web.mutate).toHaveBeenLastCalledWith([ - { op: 'unset', path: ['searchProvider'] }, - { op: 'unset', path: ['fetchProvider'] }, - ], undefined) - expect(state()).toMatchObject({ useSearchApi: false, baseURL: { text: 'https://messages.test/v1' } }) - expect(b.messages.mutate).not.toHaveBeenCalled() + await vi.waitFor(() => { expect(b.state().dirty).toBe(false) }) + expect(b.web.mutate).toHaveBeenLastCalledWith([{ op: 'set', path: ['fetchProvider'], value: 'http' }], undefined) b.controller.dispose() }) - it('writes explicit Messages selections when the deployment composes the APIs', async () => { - const api = { searchProvider: 'deepseek-search-api', fetchProvider: 'deepseek-fetch-api' } - const b = bench({ accept: true }) - serve(b, { value: api, base: api }) - expect(b.state().useSearchApi).toBe(true) - b.face.setSearchApi(false) - b.face.save() - await vi.waitFor(() => { expect(b.state().dirty).toBe(false) }) - expect(b.web.mutate).toHaveBeenLastCalledWith([ - { op: 'set', path: ['searchProvider'], value: 'deepseek-official' }, - { op: 'set', path: ['fetchProvider'], value: 'http' }, - ], undefined) - b.face.setSearchApi(true) - b.face.save() - await vi.waitFor(() => { expect(b.state().dirty).toBe(false) }) - expect(b.web.mutate).toHaveBeenLastCalledWith([ - { op: 'unset', path: ['searchProvider'] }, - { op: 'unset', path: ['fetchProvider'] }, - ], undefined) - b.controller.dispose() - }) - - it('saves the selected API section before the selection that starts using it', async () => { + it('saves the API section before the selection that starts using it', async () => { const b = bench({ accept: true }) serve(b) const order: string[] = [] @@ -462,62 +865,74 @@ describe('WebSearchCardController API switch', () => { const accept = host.mutate.getMockImplementation()! host.mutate.mockImplementation((ops, revision) => { order.push(name); return accept(ops, revision) }) } - b.face.setSearchApi(true) - b.face.edit('baseURL', 'https://search-api.test/v1/tools') + b.face.selectProvider('fetchProvider', 'deepseek-fetch-api') + b.face.edit('count', '20') b.face.save() await vi.waitFor(() => { expect(b.state().dirty).toBe(false) }) expect(order).toEqual(['api', 'web']) b.controller.dispose() }) - it('keeps the selection staged when a provider section refuses its save', async () => { + it('keeps the selection staged when the API section refuses its save', async () => { const b = bench({ accept: true }) serve(b) b.api.mutate.mockImplementation(() => Promise.resolve(false)) - b.face.setSearchApi(true) + b.face.selectProvider('fetchProvider', 'deepseek-fetch-api') b.face.edit('count', '20') b.face.save() await vi.waitFor(() => { expect(b.state().failed).toBe(true) }) expect(b.web.mutate).not.toHaveBeenCalled() - expect(b.state()).toMatchObject({ useSearchApi: true, dirty: true, apiSwitchBlocked: true }) + expect(b.state()).toMatchObject({ fetch: { selected: 'deepseek-fetch-api' }, dirty: true, selectionBlocked: true }) b.controller.dispose() }) - it('locks the API switch while an invalid edit is staged', () => { + it('blocks provider changes while an invalid edit is staged', () => { const b = bench() serve(b) - expect(b.state().apiSwitchBlocked).toBe(false) - b.face.edit('maxUses', 'many') - expect(b.state()).toMatchObject({ invalid: true, apiSwitchBlocked: true }) + expect(b.state().selectionBlocked).toBe(false) + b.face.edit('count', 'many') + expect(b.state()).toMatchObject({ invalid: true, selectionBlocked: true }) b.face.save() - expect(b.messages.mutate).not.toHaveBeenCalled() + expect(b.api.mutate).not.toHaveBeenCalled() b.face.discard() - expect(b.state().apiSwitchBlocked).toBe(false) + expect(b.state().selectionBlocked).toBe(false) b.controller.dispose() }) - it.each(['web', 'api'] as const)('hides the switch and shows Messages settings while %s is not served', (missing) => { + it('hides provider controls while the API section is not served', () => { const b = bench() - serve(b, { value: { searchProvider: 'deepseek-search-api' } }) - b[missing].publish({ status: 'unavailable' }) - expect(b.state()).toMatchObject({ available: true, apiSwitchAvailable: false, useSearchApi: false }) + serve(b, { value: API_SELECTION }) + b.api.publish({ status: 'unavailable' }) + expect(b.state()).toMatchObject({ available: true, search: { kind: 'none' }, fetch: { kind: 'none' } }) b.controller.dispose() }) - it('saves nothing before the Messages section is served or while a save runs', async () => { + it('saves nothing before the selection is served or while a save runs', async () => { const b = bench({ accept: true }) b.face.save() expect(b.state().available).toBe(false) serve(b) let release: (accepted: boolean) => void = () => {} - b.messages.mutate.mockImplementation(() => new Promise((resolve) => { release = resolve })) - b.face.edit('maxUses', '3') + b.api.mutate.mockImplementation(() => new Promise((resolve) => { release = resolve })) + b.face.edit('count', '3') b.face.save() await vi.waitFor(() => { expect(b.state().saving).toBe(true) }) b.face.save() release(true) await vi.waitFor(() => { expect(b.state().saving).toBe(false) }) - expect(b.messages.mutate).toHaveBeenCalledTimes(1) + expect(b.api.mutate).toHaveBeenCalledTimes(1) + b.controller.dispose() + }) + + it('skips the API section while the Host does not serve it', async () => { + const b = bench({ accept: true }) + serve(b) + b.face.edit('count', '3') + b.api.publish({ status: 'unavailable' }) + b.face.selectProvider('fetchProvider', 'deepseek-fetch-api') + b.face.save() + await vi.waitFor(() => { expect(b.web.mutate).toHaveBeenCalled() }) + expect(b.api.mutate).not.toHaveBeenCalled() b.controller.dispose() }) @@ -525,6 +940,30 @@ describe('WebSearchCardController API switch', () => { const b = bench() serve(b) b.controller.dispose() - for (const host of [b.web, b.messages, b.api]) expect(host.listenerCount()).toBe(0) + for (const host of [b.web, b.api]) expect(host.listenerCount()).toBe(0) + }) +}) + +describe('WebSearchCardController automatic selection', () => { + it('offers automatic selection only where the deployment composes no provider, and returns to it', async () => { + const b = bench({ accept: true }) + try { + serve(b, { value: { fetchProvider: 'http' }, base: { fetchProvider: 'http' } }) + expect(b.state()).toMatchObject({ search: { selected: '', auto: true }, fetch: { selected: 'http', auto: false } }) + b.face.selectProvider('searchProvider', 'deepseek-search-api') + b.face.selectProvider('searchProvider', '') + expect(b.state()).toMatchObject({ dirty: false, search: { selected: '' } }) + b.face.selectProvider('searchProvider', 'deepseek-search-api') + b.face.save() + await vi.waitFor(() => { expect(b.state().dirty).toBe(false) }) + expect(b.web.mutate).toHaveBeenLastCalledWith([{ op: 'set', path: ['searchProvider'], value: 'deepseek-search-api' }], undefined) + expect(b.state().search).toMatchObject({ selected: 'deepseek-search-api', auto: true }) + b.face.selectProvider('searchProvider', '') + expect(b.state()).toMatchObject({ dirty: true, search: { selected: '', kind: 'none' } }) + b.face.save() + await vi.waitFor(() => { expect(b.state().dirty).toBe(false) }) + expect(b.web.mutate).toHaveBeenLastCalledWith([{ op: 'unset', path: ['searchProvider'] }], undefined) + expect(b.state().search).toMatchObject({ selected: '', auto: true }) + } finally { b.controller.dispose() } }) }) diff --git a/packages/client/ui-settings-web-search/tests/styles.client.spec.ts b/packages/client/ui-settings-web-search/tests/styles.client.spec.ts new file mode 100644 index 0000000000..0b86d8f4ef --- /dev/null +++ b/packages/client/ui-settings-web-search/tests/styles.client.spec.ts @@ -0,0 +1,36 @@ +/** The page stylesheet reads only theme tokens ui-theme defines, so no value silently falls back. */ + +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const read = (path: string) => readFileSync(new URL(path, import.meta.url), 'utf8') +const page = read('../src/client/WebSearchCard.module.css') +/** The global sheets that define `--dsw-*` tokens: the design palette and the base scale. */ +const theme = ['design-platform.css', 'base.css'].map(name => read(`../../ui-theme/src/styles/${name}`)).join('\n') + +describe('WebSearchCard stylesheet', () => { + it('references only defined theme tokens', () => { + const used = [...new Set([...page.matchAll(/var\((--dsw-[\w-]+)/g)].map(match => match[1]!))] + expect(used.length).toBeGreaterThan(0) + expect(used.filter(token => !theme.includes(`${token}:`))).toEqual([]) + }) + + it('separates the sections by spacing, not hairlines', () => { + expect(page.match(/\.section \+ \.section \{[^}]*\}/)?.[0]).not.toContain('border') + }) + + it('separates the provider menu block from the panel below it by spacing, not a hairline', () => { + const field = page.match(/\.field \{[^}]*\}/)?.[0] + expect(field).toContain('padding: 8px 0 12px;') + expect(field).not.toContain('border') + expect(page).not.toMatch(/\.field\s*\+/) + }) + + it('sets the settings below the provider menu on a gray panel', () => { + expect(page).toMatch(/\.panel \{[^}]*background: var\(--dsw-alias-bg-module-platform\);/) + }) + + it('colors the provider list notice with the error state token', () => { + expect(page).toMatch(/\.notice \{[^}]*color: var\(--dsw-alias-state-error-primary\);/) + }) +}) diff --git a/packages/client/ui-settings-web-search/tsconfig.json b/packages/client/ui-settings-web-search/tsconfig.json index fc1dab7fb9..24c99184f0 100644 --- a/packages/client/ui-settings-web-search/tsconfig.json +++ b/packages/client/ui-settings-web-search/tsconfig.json @@ -37,6 +37,9 @@ }, { "path": "../ui-slots" + }, + { + "path": "../../web/web" } ] } diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index 50458ab3da..6ce4278621 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/README.i18n.yaml @@ -21,8 +21,8 @@ en: 6bc49e22f0f73f07 zh: 10883997ccb7d145 /deepseek-ai-dsh-client-ui-sidebar/use-this-package/global-panel-entries: - en: e7f316023445511b - zh: 6271c8c011533877 + en: 8a23eab217042cc6 + zh: 2d760de4c83f996c /deepseek-ai-dsh-client-ui-sidebar/use-this-package/collapse-behavior: en: 10381cbac96069a3 zh: c5849741a0be2f00 @@ -33,11 +33,11 @@ en: 9e7b31acee759f2b zh: 3a6beb63b23d26ae /deepseek-ai-dsh-client-ui-sidebar/use-this-package/click-analytics: - en: 8855ad013285a27e - zh: f19b3e75c38c5b0a + en: 3e84ffdf6c7c1731 + zh: 3f7b19cac6d6674f /deepseek-ai-dsh-client-ui-sidebar/understand-the-implementation: - en: 8cc657bd9b66c93f - zh: 47ad4bd9e906ef8e + en: 76a542d822e94fc9 + zh: da25857ead58df5c /deepseek-ai-dsh-client-ui-sidebar/understand-the-implementation/slot-discipline: en: 6eefc5c45abe8a8f zh: 20b651e48a0ea903 @@ -51,8 +51,8 @@ en: ca75c51c89c2c9b0 zh: de54f3467ef3b9be /deepseek-ai-dsh-client-ui-sidebar/known-limitations-and-deferred-work: - en: 2f1899e4d0c4db92 - zh: 700385e067a5a100 + en: 9738f4cd887876d0 + zh: 95a646deba4a6489 /deepseek-ai-dsh-client-ui-sidebar/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 8783f2622c..ea682c7e41 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -39,9 +39,9 @@ The expanded brand row renders `sidebar.brand.mark` and `sidebar.brand.name` as ### Global panel entries -Plugins add an icon component to the root-scoped `sidebar.panellist` list with an `id`, optional `order`, and a string or locale-aware `label`. The same id addresses the component registered in the layout's root-scoped `main` keyed slot; selecting a missing main entry throws without changing the current selection. The label supplies plain visible text, the accessible name, and the collapsed tooltip. Expanded navigation rows have a 2px gap. Each row reads its own selected state through `usePanelInfo`; moving DOM focus to search or a directory picker does not change the displayed panel or its selected row. With no panel registrations, neither the list nor spacing for it is rendered. The shipped composition registers no example panel. +Plugins add an icon component to the root-scoped `sidebar.panellist` list with an `id`, optional `order`, and a string or locale-aware `label`. The same id addresses the component registered in the layout's root-scoped `main` keyed slot; selecting a missing main entry throws without changing the current selection. The label supplies plain visible text, the accessible name, and the collapsed tooltip. Expanded navigation rows are 32px high with a 2px gap; collapsed rail controls remain 36px high with a 12px gap. Each row reads its own selected state through `usePanelInfo`; moving DOM focus to search or a directory picker does not change the displayed panel or its selected row. With no panel registrations, neither the list nor spacing for it is rendered. The shipped composition registers no example panel. -While the root-scoped `sidebar.panellist.actions` list has registrations, a **More** button styled as a panel row follows the rows inside the navigation landmark; the collapsed rail shows its label as a tooltip. It opens a menu whose rows are that list's registrations. Each receives `entries` (customizable panels in registration order with `id`, `label`, `pinned`, and `active`; Plugins is never listed), `onOpen(id)`, which selects the panel, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The sidebar keeps one in-memory list of unpinned panel ids shared by every Session; Plugins always shows. The default More menu only offers plugin browsing and creation, without panel rows or pin controls. Pin callbacks remain available to other registrations, but choices reset when the plugin reloads and legacy localStorage values are ignored. Unpinning hides only the row; the main panel, its registration, and the current selection stay unchanged. Unregistered panel ids remain in the in-memory list until the plugin reloads. +While the root-scoped `sidebar.panellist.actions` list has registrations, a **More** button styled as a panel row follows the rows inside the navigation landmark; the collapsed rail shows its label as a tooltip. It opens a menu whose rows are that list's registrations. Each receives `entries` (customizable panels in registration order with `id`, `label`, `pinned`, and `active`; Plugins is never listed), `onOpen(id)`, which selects the panel, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The sidebar keeps one in-memory list of unpinned panel ids shared by every Session; Plugins always shows. While More shows, expanded rows other than Plugins show an unpin action on hover or keyboard focus; devices without hover show it continuously. Its resting color matches the Session pin marker, and its tooltip says “Unpin from left sidebar”. Both sidebar and More entries follow the same registration order rather than pin timing. The default More menu lists only unpinned panels before plugin browsing and creation; opening a panel does not pin it, while its separate pin icon returns the row to the sidebar and dismisses the menu. Choices reset when the plugin reloads and legacy localStorage values are ignored. Unpinning hides only the row, and only while More shows; without `sidebar.panellist.actions` registrations every registered row shows again. The main panel, its registration, and the current selection stay unchanged. Unregistered panel ids remain in the in-memory list until the plugin reloads. ### Collapse behavior @@ -68,7 +68,7 @@ On Desktop, the official navigation controls report `sidebar_menu_click` with `m - The expanded brand and the New Session button report `menu_name=new_session` with `position=sidebar`. While the Windows caption hosts the collapsed New Session button, that button reports `position=titlebar`; the macOS New Session control beside the traffic lights also reports `position=titlebar`. - The **Plugins** and **Automation tasks** rows report `menu_name=plugin` and `menu_name=cron` with `position=sidebar`. Other panel rows report nothing. - Each **More** button click reports `menu_name=more` with `position=sidebar`, whether it opens or closes the menu. -- A third-party More menu row that calls `onOpen(id)` for Plugins or Automation tasks reports `menu_name=plugin` or `menu_name=cron` with `position=sidebar_more`. The shipped More menu only browses and creates plugins, so it never calls `onOpen`. The report precedes the registration check, so a request for a panel that has unregistered still counts although nothing is selected. +- A More menu row that calls `onOpen(id)` for Plugins or Automation tasks reports `menu_name=plugin` or `menu_name=cron` with `position=sidebar_more`. The shipped More menu calls `onOpen` for unpinned panels, including Automation tasks; pin and unpin actions report no navigation click. The report precedes the registration check, so a request for a panel that has unregistered still counts although nothing is selected. ----- @@ -78,11 +78,15 @@ On Desktop, the official navigation controls report `sidebar_menu_click` with `m
      Implementation internals — click to expand -The shell is pure composition: `SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared brand, the `sidebar.workspaces` and `sidebar.settings` child slots, and injected navigation callbacks. The sidebar owns `sidebar.panellist` and renders each panel's registered icon directly. Panel metadata is derived from list registrations and locale changes; selection belongs to the layout store. The injected `trackEntryClick(attributes)` forwards to `ctx.get('productAnalytics')?.track('sidebar_menu_click', attributes)`, and `selectPanel` only selects, so each control supplies its own entry and position. +The shell is pure composition: `SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared brand, the `sidebar.workspaces` and `sidebar.settings` child slots, and injected navigation callbacks. The sidebar declares the `sidebar.panel.icon` Component Factory, which owns the `sidebar.panellist` list; panel rows and the More menu render a panel's registered icon through that factory. Panel metadata is derived from list registrations and locale changes; selection belongs to the layout store. The injected `trackEntryClick(attributes)` forwards to `ctx.get('productAnalytics')?.track('sidebar_menu_click', attributes)`, and `selectPanel` only selects, so each control supplies its own entry and position. -Expanded navigation uses 8px outer gutters, hover and selected fills inset 3px on each side, and a 16px leading cell with a 6px label gap. New Session centers its content with 4px side margins and a 10px bottom margin. The logo row has no bottom margin; its content starts 16px from the column's left edge, with 5px between mark and name. Only the macOS non-fullscreen top toggle shifts 4px right while expanded, retaining that offset throughout its fade-out. Collapsed geometry stays independent. +Expanded navigation uses 8px outer gutters, hover and selected fills inset 3px on each side, and a 16px leading cell with a 6px label gap. New Session centers its content with 4px side margins. Its initial 12px gap above navigation combines 4px outside the scrollport and an 8px gap that scrolls away. The logo row has no bottom margin; its content starts 16px from the column's left edge, with 5px between mark and name. Only the macOS non-fullscreen top toggle shifts 4px right while expanded, retaining that offset throughout its fade-out. Collapsed geometry stays independent. -The sidebar root supplies inherited CSS properties to `sidebar.workspaces` and `sidebar.settings`: `--dsh-sidebar-inline-padding: 8px`, `--dsh-sidebar-row-padding: 12px`, `--dsh-sidebar-label-gap: 6px`, and `--dsh-sidebar-paint-inset: 3px`. Occupants consume these values for expanded row alignment; standalone compositions must provide them on an ancestor. The paint inset reduces the fill without moving content: rows subtract it from their inline padding and use it as a transparent border. +Expanded panel navigation and the `sidebar.workspaces` section share one vertical-only viewport below the fixed brand and New Session controls; footer actions and Settings stay outside it. Horizontal overflow is clipped so toolbar edge offsets do not create a horizontal scrollbar. The viewport hides its own scrollbar and only scrolls the navigation away: the section is at most one viewport tall and scrolls its rows in its own scroller, which draws the only visible scrollbar; a section that fits leaves the viewport nothing to scroll. While expanded, the viewport carries `data-sidebar-scrollport`, a stable marker for tests and integrations. The shell owns the bottom fade. + +Scrolling down moves the navigation away before the section scroller moves; scrolling up returns the section scroller to its top before the navigation reappears. A wheel or trackpad gesture whose first event must move the viewport, or starts outside the section scroller, is split between the two in JavaScript until it ends, including its momentum, so one gesture crosses the boundary; wheel events less than 100ms apart count as one gesture. A gesture the section scroller can take alone keeps native scrolling; when an uncancelable native gesture reaches the section scroller's top, the viewport takes the rest. Events another listener already canceled, pinch-zoom (Ctrl), and mostly horizontal wheel events keep native scrolling. Content inside the viewport must not own vertical scrolling unless it cancels its own wheel events. The section receives this capability through `sidebar.workspaces`: each render supplies `hookContext.useSectionScroller`, and the occupant's `useSectionScroller()` returns the same `{ bindScrollerRef }` object for one sidebar. An occupant that binds no scroller keeps native scrolling. The region around the viewport is a size container with the viewport's height, and the viewport is a scroll-state container, so a section scroller can refuse native scrolling until the viewport reaches its end. + +The sidebar root supplies inherited CSS properties to `sidebar.workspaces` and `sidebar.settings`: `--dsh-sidebar-inline-padding: 8px`, `--dsh-sidebar-row-padding: 12px`, `--dsh-sidebar-label-gap: 6px`, and `--dsh-sidebar-paint-inset: 3px`. Occupants consume these values for expanded row alignment; standalone compositions must provide them on an ancestor. The paint inset reduces the fill without moving content: rows subtract it from their inline padding and use it as a transparent border. The expanded viewport hides its own scrollbar; its trailing grid column keeps navigation and the section 8px from the sidebar edge, and the section's scroller places its scrollbar 2px from that edge. ### Slot discipline @@ -125,6 +129,7 @@ These limits define what the shell owns versus what its occupants own; they are - **Workspace browser behavior is composition-owned** — grouping, ordering, search, and row state belong to ui-workspace, not this shell. - **"New task completed" unread marking is local viewing state** — completion-time > last-seen never reaches the host. - **Copying the local-build version is mouse-only** — keyboard activation of the brand starts a session. +- **Only wheel and trackpad input crosses the navigation boundary in one gesture** — touch drags keep native nested scrolling and stop where the viewport reaches its end; browsers without scroll-state container queries scroll the section first. Wheel steps split in JavaScript run on the main thread without smooth-scrolling animation or overscroll bounce. ### Dev Note diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index 15acbfc859..58ccbebef2 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -39,9 +39,9 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将 ### 全局面板入口 -插件在 root 作用域的 `sidebar.panellist` list 中注册图标组件,提供 `id`、可选 `order`,以及字符串或 locale-aware 的 `label`。同一个 id 寻址布局中 root 作用域 `main` keyed slot 的组件;选择不存在的主面板条目会抛错,并保留当前选中态。标签提供普通可见文字、无障碍名称和折叠提示。展开态导航行的间距为 2px。每一行通过 `usePanelInfo` 读取自己的选中态;DOM 焦点移到搜索框或目录选择器时,显示的面板及其列表项选中态不变。没有面板注册项时,列表及其间距均不渲染。产品随附的组合不注册示例面板。 +插件在 root 作用域的 `sidebar.panellist` list 中注册图标组件,提供 `id`、可选 `order`,以及字符串或 locale-aware 的 `label`。同一个 id 寻址布局中 root 作用域 `main` keyed slot 的组件;选择不存在的主面板条目会抛错,并保留当前选中态。标签提供普通可见文字、无障碍名称和折叠提示。展开态导航行高 32px,间距为 2px;折叠窄栏控件保持 36px 高和 12px 间距。每一行通过 `usePanelInfo` 读取自己的选中态;DOM 焦点移到搜索框或目录选择器时,显示的面板及其列表项选中态不变。没有面板注册项时,列表及其间距均不渲染。产品随附的组合不注册示例面板。 -root 作用域的 `sidebar.panellist.actions` list 有注册项时,导航区域的面板行之后显示面板行样式的**更多**按钮;收起的窄栏以提示框显示其名称。它打开的菜单以该 list 的注册项为菜单行。每个注册项接收 `entries`(按注册顺序的可自定义面板,含 `id`、`label`、`pinned` 与 `active`;不含插件管理)、选中面板的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。侧边栏在内存中保存所有会话共用的已收起面板 id 列表;插件管理入口始终显示。默认更多菜单只提供插件浏览和创建,不列出面板或 Pin 控件。其他注册项仍可调用 Pin 回调,但插件重新加载时选择重置,不读取旧 localStorage 值。收起只隐藏导航行,主面板、其注册和当前选中态不变。未注册面板的 id 保留在内存列表中,直到插件重新加载。 +root 作用域的 `sidebar.panellist.actions` list 有注册项时,导航区域的面板行之后显示面板行样式的**更多**按钮;收起的窄栏以提示框显示其名称。它打开的菜单以该 list 的注册项为菜单行。每个注册项接收 `entries`(按注册顺序的可自定义面板,含 `id`、`label`、`pinned` 与 `active`;不含插件管理)、选中面板的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。侧边栏在内存中保存所有会话共用的已收起面板 id 列表;插件管理入口始终显示。更多按钮显示时,展开态中除插件管理外的面板行在悬停或键盘聚焦时显示取消固定操作;不支持悬停的设备始终显示该操作。图标默认颜色与会话置顶标记一致,提示为“从左侧栏取消固定”。侧栏和更多菜单入口遵循相同的注册排序,不按固定时间重排。默认更多菜单在插件浏览和创建之前仅列出未固定的面板;打开面板不会将其固定,独立的固定图标则将该行放回侧边栏并关闭菜单。插件重新加载时选择重置,不读取旧 localStorage 值。收起只隐藏导航行,且仅在更多按钮显示时生效;`sidebar.panellist.actions` 没有注册项时,所有已注册的面板行重新显示。主面板、其注册和当前选中态不变。未注册面板的 id 保留在内存列表中,直到插件重新加载。 ### 折叠行为 @@ -68,7 +68,7 @@ root 作用域的 `sidebar.panellist.actions` list 有注册项时,导航区 - 展开态品牌与新建会话按钮上报 `menu_name=new_session`、`position=sidebar`。Windows 顶栏承载收起态新建会话按钮时,该按钮上报 `position=titlebar`;macOS 红绿灯旁的新建会话控件同样上报 `position=titlebar`。 - **插件**与**自动化任务**面板行分别上报 `menu_name=plugin` 与 `menu_name=cron`,`position=sidebar`。其他面板行不上报。 - 每次点击**更多**按钮都上报 `menu_name=more`、`position=sidebar`,无论打开还是关闭菜单。 -- 第三方注册的更多菜单行通过 `onOpen(id)` 打开插件或自动化任务时,上报 `menu_name=plugin` 或 `menu_name=cron`,`position=sidebar_more`。随产品提供的更多菜单只浏览和创建插件,不会调用 `onOpen`。上报先于注册检查,因此所请求的面板已注销时仍会计数,但不会选中任何面板。 +- 更多菜单行通过 `onOpen(id)` 打开插件或自动化任务时,上报 `menu_name=plugin` 或 `menu_name=cron`,`position=sidebar_more`。随产品提供的更多菜单通过 `onOpen` 打开未固定的面板,包括自动化任务;固定和取消固定操作不上报导航点击。上报先于注册检查,因此所请求的面板已注销时仍会计数,但不会选中任何面板。 ----- @@ -78,11 +78,15 @@ root 作用域的 `sidebar.panellist.actions` list 有注册项时,导航区
      实现细节——点击展开 -外壳是纯组合:`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 与 `useWorkspaces` 钩子、已声明的品牌、`sidebar.workspaces` 与 `sidebar.settings` 子 slot,以及注入的导航回调。侧边栏声明 `sidebar.panellist`,并直接渲染各面板注册的图标。面板元数据由列表注册和 locale 变化派生;选中态属于布局存储。注入的 `trackEntryClick(attributes)` 转发到 `ctx.get('productAnalytics')?.track('sidebar_menu_click', attributes)`;`selectPanel` 只负责选中,因此由各控件自行提供入口与位置。 +外壳是纯组合:`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 与 `useWorkspaces` 钩子、已声明的品牌、`sidebar.workspaces` 与 `sidebar.settings` 子 slot,以及注入的导航回调。侧边栏声明 `sidebar.panel.icon` Component Factory,由它拥有 `sidebar.panellist` list;面板行和更多菜单都通过该 factory 渲染面板注册的图标。面板元数据由列表注册和 locale 变化派生;选中态属于布局存储。注入的 `trackEntryClick(attributes)` 转发到 `ctx.get('productAnalytics')?.track('sidebar_menu_click', attributes)`;`selectPanel` 只负责选中,因此由各控件自行提供入口与位置。 -展开态导航的左右外距为 8px,悬停与选中底色在两侧各内缩 3px,前置单元宽 16px,与标签间隔 6px。新会话内容居中,左右外边距为 4px,底部外边距为 10px。logo 行无底部外边距;内容从侧栏左边缘向内 16px 开始,标记与名称间隔 5px。仅 macOS 非全屏、侧栏展开时,顶部开关向右偏移 4px,并在整个淡出期间保持该偏移。折叠态尺寸独立保留。 +展开态导航的左右外距为 8px,悬停与选中底色在两侧各内缩 3px,前置单元宽 16px,与标签间隔 6px。新会话内容居中,左右外边距为 4px。它与导航的初始 12px 间距由滚动区外的 4px 和随内容滚走的 8px 组成。logo 行无底部外边距;内容从侧栏左边缘向内 16px 开始,标记与名称间隔 5px。仅 macOS 非全屏、侧栏展开时,顶部开关向右偏移 4px,并在整个淡出期间保持该偏移。折叠态尺寸独立保留。 -侧边栏根节点向 `sidebar.workspaces` 与 `sidebar.settings` 提供可继承的 CSS 属性:`--dsh-sidebar-inline-padding: 8px`、`--dsh-sidebar-row-padding: 12px`、`--dsh-sidebar-label-gap: 6px` 和 `--dsh-sidebar-paint-inset: 3px`。占用者使用这些值对齐展开态行;独立组合必须在祖先节点提供这些值。底色内缩不移动内容:行从左右内边距中减去该内缩值,并以等宽透明边框承载内缩。 +展开态的面板导航与 `sidebar.workspaces` 区块共用一个仅纵向滚动的视口,位于固定的品牌和新会话控件下方;底部操作与设置位于视口之外。横向溢出被裁切,工具栏边缘偏移不会产生横向滚动条。视口隐藏自身滚动条,只负责把导航滚走:区块最高为一个视口,在自己的滚动容器中滚动列表行,并由该容器绘制唯一可见的滚动条;区块放得下时,视口没有可滚动的距离。展开时视口带有 `data-sidebar-scrollport`,这是供测试和集成方定位的稳定标记。外壳负责底部渐隐。 + +向下滚动时先把导航滚走,再滚动区块;向上滚动时先让区块回到顶部,导航再重新出现。如果一次滚轮或触控板手势的第一个事件需要移动视口,或者手势从区块滚动容器之外开始,JavaScript 会在整次手势(包括惯性)结束前一直把滚动量分配给两者,因此一次手势可以越过边界;间隔不足 100ms 的滚轮事件视为同一次手势。区块滚动容器能独自处理的手势保持浏览器原生滚动;不可取消的原生手势到达区块滚动容器顶部时,剩余距离交给视口。已被其他监听器取消的事件、双指缩放(Ctrl)事件以及以横向为主的滚轮事件保持浏览器原生滚动。视口内的内容不得自带纵向滚动,除非它自己取消这些滚轮事件。区块通过 `sidebar.workspaces` 获得这项能力:每次渲染都会提供 `hookContext.useSectionScroller`,同一个侧边栏中,占用者调用 `useSectionScroller()` 始终得到同一个 `{ bindScrollerRef }` 对象。未绑定滚动容器的占用者保持原生滚动。视口外层的区域是高度与视口相同的尺寸容器,视口本身是滚动状态容器,因此区块的滚动容器可以在视口滚到底之前拒绝原生滚动。 + +侧边栏根节点向 `sidebar.workspaces` 与 `sidebar.settings` 提供可继承的 CSS 属性:`--dsh-sidebar-inline-padding: 8px`、`--dsh-sidebar-row-padding: 12px`、`--dsh-sidebar-label-gap: 6px` 和 `--dsh-sidebar-paint-inset: 3px`。占用者使用这些值对齐展开态行;独立组合必须在祖先节点提供这些值。底色内缩不移动内容:行从左右内边距中减去该内缩值,并以等宽透明边框承载内缩。展开态视口隐藏自身滚动条;其尾部网格列使导航和区块与侧栏边缘保持 8px,区块的滚动容器把滚动条放在距该边缘 2px 处。 ### slot 纪律 @@ -125,6 +129,7 @@ root 作用域的 `sidebar.panellist.actions` list 有注册项时,导航区 - **Workspace 浏览行为由组合持有**:分组、排序、搜索与行状态都属于 ui-workspace,不属于此外壳。 - **「New task completed」未读标记是本地查看状态**:完成时间 > 上次查看时间这一事实永远不会到达宿主。 - **复制本地构建版本号仅支持鼠标**:键盘激活品牌会新建会话。 +- **只有滚轮和触控板输入能在一次手势中越过导航边界**:触摸拖动保持浏览器原生的嵌套滚动,在视口滚到底时停止;不支持滚动状态容器查询的浏览器会先滚动区块。由 JavaScript 分配的滚轮步进在主线程执行,没有平滑滚动动画,也没有越界回弹。 ### 开发备注 diff --git a/packages/client/ui-sidebar/src/client/PanelIcon.tsx b/packages/client/ui-sidebar/src/client/PanelIcon.tsx new file mode 100644 index 0000000000..ba3ed5fc04 --- /dev/null +++ b/packages/client/ui-sidebar/src/client/PanelIcon.tsx @@ -0,0 +1,12 @@ +/** Shared rendering of plugin-owned panel icons in navigation rows and menus. */ +import type { FactoryComponentPropsOf } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from './contract/slots.ts' + +/** + * Render the icon registered for one panel at the caller's size and selection state. + * @param props - Panel identity, icon presentation, and the authorized child renderer. + * @returns The matching panel icon. + */ +export function PanelIcon({ id, size, active, renderSlot }: FactoryComponentPropsOf<'sidebar.panel.icon'>) { + return renderSlot('sidebar.panellist', { size, active }, { only: id }) +} diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 387b6f804f..525e0e5c2d 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -427,7 +427,7 @@ gap: 0; height: 38px; padding: 8px 12px; - margin: 0 calc(var(--dsh-sidebar-paint-inset) + 1px) 10px; + margin: 0 calc(var(--dsh-sidebar-paint-inset) + 1px); box-sizing: border-box; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: var(--dsw-radius-md); @@ -530,7 +530,149 @@ } } +.scrollRegion { + margin-top: 4px; + position: relative; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +/* The workspace section is `100cqh` of this bounded region, one viewport tall without a resize observer. */ +.root:not(.collapsed) .scrollRegion { + container-type: size; +} + +/* Both gaps scroll away; ordinary rows do not force overflow when they fit. The + trailing column keeps content 8px from the sidebar edge; this viewport draws no + scrollbar of its own. */ +.scrollArea { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: 4px minmax(0, 1fr) calc(var(--dsh-sidebar-inline-padding) - 2px); + grid-template-rows: 8px max-content 2px max-content; + align-content: start; + margin-left: -4px; + margin-right: calc(2px - var(--dsh-sidebar-inline-padding)); + overflow-x: hidden; + overflow-y: auto; +} + +/* Expanded, this viewport only scrolls the navigation away; the section below + scrolls its own rows and draws the only visible scrollbar. Descendants query + the scroll state to take scrolling once this viewport ends. */ +.root:not(.collapsed) .scrollArea { + container-type: scroll-state; + scrollbar-width: none; +} + +/* Safari versions without `scrollbar-width` still use the WebKit scrollbar. */ +.root:not(.collapsed) .scrollArea::-webkit-scrollbar { + display: none; +} + +.scrollArea::before, +.scrollArea::after { + content: ''; + grid-column: 2; + pointer-events: none; +} + +.scrollArea::before { grid-row: 1; } +.scrollArea::after { grid-row: 3; } + +.scrollFade { + position: absolute; + inset-inline: 0; + bottom: 0; + height: 24px; + background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill)); + pointer-events: none; +} + +:global([data-platform='darwin']) .scrollFade, +.collapsed .scrollFade, +.collapsed .scrollArea::before, +.collapsed .scrollArea::after { + display: none; +} + +.collapsed .scrollRegion { + margin-top: 0; +} + +.collapsed .scrollArea { + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + overflow: visible; +} + +/* The entry wrapper paints the row fill, so it carries the shared paint inset + instead of its inner panel row. */ +.panelEntry { + position: relative; + display: flex; + border-inline: var(--dsh-sidebar-paint-inset) solid transparent; + border-radius: var(--dsw-radius-md); + background-clip: padding-box; +} + +.panelEntryWide > *:first-child { + flex: 1; + min-width: 0; +} + +.panelEntryWide .panelRow { + width: 100%; + margin: 0; + border-inline: 0; + padding-right: 38px; + background: transparent; +} + +.panelEntryActive, +.panelEntry:has(:focus-visible) { + background-color: var(--dsw-alias-interactive-bg-hover); +} + +.panelUnpin { + position: absolute; + right: 0; + top: 2px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: var(--dsw-alias-label-caption); + cursor: pointer; +} + +.panelUnpin:focus-visible { + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: -2px; +} + +@media (hover: hover) { + .panelUnpin { opacity: 0; } + .panelEntry:hover { background-color: var(--dsw-alias-interactive-bg-hover); } + .panelEntry .panelRow:hover { background: transparent; } + .panelUnpin:hover { color: var(--dsw-alias-label-primary); } + .panelEntry:hover .panelUnpin, + .panelEntry:has(:focus-visible) .panelUnpin { opacity: 1; } +} + .panelList { + grid-column: 2; + grid-row: 2; flex: none; display: flex; flex-direction: column; @@ -545,8 +687,8 @@ align-items: center; gap: var(--dsh-sidebar-label-gap); margin: 0; - min-height: 36px; - padding: 7px calc(var(--dsh-sidebar-row-padding) - var(--dsh-sidebar-paint-inset)); + min-height: 32px; + padding: 5px calc(var(--dsh-sidebar-row-padding) - var(--dsh-sidebar-paint-inset)); box-sizing: border-box; border: none; border-inline: var(--dsh-sidebar-paint-inset) solid transparent; @@ -566,11 +708,6 @@ } } -.panelRow.panelActive { - background-color: var(--dsw-alias-interactive-bg-hover); - color: var(--dsw-alias-label-primary); -} - .panelRow:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: -2px; @@ -610,6 +747,10 @@ margin-bottom: 12px; } +.collapsed .panelEntry { + border-inline: 0; +} + .collapsed .panelRow { width: 36px; height: 36px; @@ -621,24 +762,18 @@ color: var(--dsw-alias-label-primary); } -/* Region seat: always mounted so the foot never moves. Its trailing margin - cancels the wide shell inset so the nested scrollbar can sit at the sidebar - edge; the browser restores that inset inside its own rows. */ +/* The workspace seat stays mounted while its wide and rail bodies change. */ .regionArea { - flex: 1; + grid-column: 2; + grid-row: 4; min-height: 0; + min-width: 0; display: flex; flex-direction: column; - margin-left: -4px; - margin-right: calc(-1 * var(--dsh-sidebar-inline-padding)); - padding-left: 4px; - overflow: hidden; } .collapsed .regionArea { - margin-left: 0; - margin-right: 0; - padding-left: 0; + flex: 1; } /* Footer seats: additive actions stack above Settings. Each occupant owns its diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index f03ecc32aa..114e258ee6 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -10,24 +10,28 @@ * global panel rows and the foot is the `sidebar.workspaces` registrant's, * and the foot holds `sidebar.settings` plus `sidebar.footer.action`; the shell * hands them the wide flag (plus an expand request callback for the browser). + * Through the browser's hookContext, the shell also hands it the scroller hook + * that continues wheel scrolling over the sidebar viewport into its list. * * The column also owns whether the scroll regions nested in it draw a * scrollbar at all: the shell tracks the pointer and rebinds ui-theme's * scrollbar indirection away while it is elsewhere, so a list the user is not * pointing at carries no bar. */ -import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' import { FishLogo, IconAppsAddOutlineRegular, IconNewChatOutlineMedium, IconNewChatOutlineRegular, - IconPanelLeftOutlineRegular, focusWithoutRing, isBehindModal, isDarwinDesktop, Menu, ShortcutKeys, Toast, Tooltip, useModalLayer, - writeClipboard, + IconPanelLeftOutlineRegular, IconPinFillRegular, focusWithoutRing, isBehindModal, isDarwinDesktop, Menu, ShortcutKeys, Toast, + Tooltip, useModalLayer, writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { InjectFace, PropsRenderFactories, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { - SidebarPanelActionsOwnerProps, SidebarPanelMetadata, SidebarRootComponentProps, SidebarRootInjected, SidebarSectionOwnerProps, + SidebarPanelActionsOwnerProps, SidebarPanelMetadata, SidebarRootComponentProps, SidebarRootInjected, SidebarSectionHookContext, + SidebarSectionOwnerProps, } from './contract/slots.ts' import { FIXED_PANEL_ID } from './panel-pins.ts' +import { createScrollHandoff } from './scroll-handoff.ts' import css from './SidebarRoot.module.css' /** Wide-content unmount delay; matches the 150ms wide-content fade-out. */ @@ -55,8 +59,9 @@ type PanelRowProps = Pick & Pick & Pick, 'usePanelInfo'> - & Pick, 'selectPanel' | 'trackEntryClick'> - & PropsRenderSlots<'sidebar.panellist'> + & Pick, 'selectPanel' | 'trackEntryClick' | 'setPinned'> + & { readonly unpinLabel: string; readonly unpinTooltip: string; readonly canUnpin: boolean } + & PropsRenderFactories /** Analytics category of an official panel; other panels are never reported. */ function officialEntry(id: string): 'plugin' | 'cron' | undefined { @@ -65,31 +70,46 @@ function officialEntry(id: string): 'plugin' | 'cron' | undefined { } /** Each panel row subscribes only to its own selection state. */ -function PanelRow({ id, label, wide, usePanelInfo, selectPanel, trackEntryClick, renderSlot }: PanelRowProps) { +function PanelRow({ + id, label, wide, unpinLabel, unpinTooltip, canUnpin, usePanelInfo, selectPanel, setPinned, trackEntryClick, renderFactorySlot, +}: PanelRowProps) { const active = usePanelInfo(info => info.activePanelId === id) return ( - - - + {wide && ( + + {label} + + )} + + + {wide && canUnpin && + + } +
      ) } @@ -145,6 +165,7 @@ export function SidebarRoot({ usePanelInfo, t, renderSlot, + renderFactorySlot, }: SidebarRootComponentProps) { const panels = usePanels(snapshot => snapshot) const unpinned = useEntryPins(ids => ids) @@ -166,6 +187,10 @@ export function SidebarRoot({ }, [collapsed, drawer]) const windowsTitlebar = document.documentElement.hasAttribute('data-windows-titlebar') const wide = drawer || (windowsTitlebar ? !collapsed : !collapsed || !settled) + const [scrollHandoff] = useState(createScrollHandoff) + const sectionHookContext = useMemo( + () => ({ useSectionScroller: scrollHandoff.useSectionScroller }), [scrollHandoff], + ) // The Windows titlebar hosts the New Session controls while the sidebar is collapsed. const openNewSession = (): void => { trackEntryClick({ menu_name: 'new_session', position: windowsTitlebar && collapsed ? 'titlebar' : 'sidebar' }) @@ -376,47 +401,57 @@ export function SidebarRoot({ )} - {panels.length > 0 && ( - - )} - - {/* The browsing region fills the column between the controls and the - foot in both states; its rail icon column rides the same slot. */} -
      - {renderSlot('sidebar.workspaces', { - wide, - expandSidebar: () => { if (collapsed) toggleSidebar() }, - })} +
      + {renderSlot('sidebar.workspaces', { + wide, + expandSidebar: () => { if (collapsed) toggleSidebar() }, + }, { hookContext: sectionHookContext })} +
      +
      +
    {copiedToast !== null && ( diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index 7b6662b6c7..1c36de4120 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -7,7 +7,10 @@ * `sidebar.settings` registrant's (ui-settings), followed by optional footer * actions in `sidebar.footer.action`. */ -import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { RefCallback } from 'react' +import type { + InjectFace, PropsLocale, PropsRenderFactories, PropsRenderSlots, PropsRuntime, SlotHookFactory, +} from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client' @@ -15,6 +18,14 @@ import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' import type { ProductEventMap } from '@deepseek-ai/dsh-client-product-analytics/client' declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotFactoryMap { + /** Reuse a registered panel icon in navigation rows and menus. */ + 'sidebar.panel.icon': { + scope: 'root' + props: SidebarPanelIconOwnerProps & { readonly id: string } + children: { 'sidebar.panellist': { kind: 'list'; scope: 'root' } } + } + } interface SlotMap { /** Non-interactive notification inside the collapsed sidebar expand button. */ 'sidebar.toggle.badge': { kind: 'single'; scope: 'root'; owner: Record } @@ -40,9 +51,17 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * The workspace/session browsing region: section header, search, the * grouped/flat session list, and every workspace dialog. Declared by this * package's 'sidebar' entry (declaring is claiming); ui-workspace - * registers the browser. + * registers the browser. The section renders inside the sidebar viewport, + * which is a size container one viewport tall and a scroll-state container, + * and binds its own vertical scroller through `useSectionScroller`. */ - 'sidebar.workspaces': { kind: 'single'; scope: 'root'; owner: SidebarSectionOwnerProps } + 'sidebar.workspaces': { + kind: 'single' + scope: 'root' + owner: SidebarSectionOwnerProps + hookContext: SidebarSectionHookContext + inject: SidebarSectionInjected + } /** * The settings seat at the sidebar foot. Declared by this package's * 'sidebar' entry; ui-settings registers its trigger row + modal panel. @@ -129,6 +148,32 @@ export interface SidebarSectionOwnerProps { expandSidebar: () => void } +/** Sidebar viewport capability handed to the `sidebar.workspaces` occupant. */ +export interface SectionScroller { + /** + * Ref callback for the occupant's own vertical scroller. While bound, wheel scrolling over + * the sidebar viewport moves the navigation away first and then continues into this element; + * scrolling up returns this element to its top before the navigation reappears. + */ + readonly bindScrollerRef: RefCallback +} + +/** + * Bind the occupant to the sidebar viewport it renders in. + * @returns The same object on every call for one sidebar. + */ +export type UseSectionScroller = () => SectionScroller + +/** Viewport capability supplied with each `sidebar.workspaces` render. */ +export interface SidebarSectionHookContext { + readonly useSectionScroller: UseSectionScroller +} + +/** Slot-level Hook factory for the `sidebar.workspaces` occupant. */ +export interface SidebarSectionInjected { + hooks: { sectionScroller: SlotHookFactory<'sidebar.workspaces', UseSectionScroller> } +} + /** * Owner share of the sidebar settings seat: the column display state the * occupant's trigger row must render against (wide row vs rail icon). @@ -189,10 +234,9 @@ export type SidebarRootComponentProps = | 'sidebar.brand.mark' | 'sidebar.brand.name' | 'sidebar.toggle.badge' - | 'sidebar.panellist' | 'sidebar.panellist.actions' | 'sidebar.workspaces' | 'sidebar.settings' | 'sidebar.footer.action' > - & InjectFace & PropsLocale<'sidebar'> + & PropsRenderFactories & InjectFace & PropsLocale<'sidebar'> diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index f45c249e8f..3bf2993dd2 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -10,19 +10,26 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' // Type-only: pulls the Session root standard-props merge. import type {} from '@deepseek-ai/dsh-client-ui-session/client' -import type { SidebarPanelMetadata, SidebarRootInjected } from './contract/slots.ts' +import type { SidebarPanelMetadata, SidebarRootInjected, SidebarSectionInjected } from './contract/slots.ts' import { createPanelPins } from './panel-pins.ts' import { HeaderLeadingControls } from './HeaderLeadingControls.tsx' import { SidebarRoot } from './SidebarRoot.tsx' +import { PanelIcon } from './PanelIcon.tsx' import { en, ja, es, fr, de, ru, pt, zh, type SidebarKey } from './locales.ts' export type { SidebarBrandMarkOwnerProps, SidebarBrandNameOwnerProps, SidebarFooterActionOwnerProps, SidebarPanelActionEntry, SidebarPanelActionsOwnerProps, SidebarPanelIconOwnerProps, SidebarPanelMetadata, SidebarRootComponentProps, SidebarRootInjected, SidebarSectionOwnerProps, SidebarSettingsOwnerProps, + SectionScroller, SidebarSectionHookContext, SidebarSectionInjected, UseSectionScroller, } from './contract/slots.ts' export type { SidebarKey } from './locales.ts' +/** The section occupant receives the scroller hook from each render's hookContext. */ +const SIDEBAR_SECTION_INJECT: SidebarSectionInjected = { + hooks: { sectionScroller: (_standard, { useSectionScroller }) => useSectionScroller }, +} + declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { /** Sidebar controls and global panel copy. */ @@ -78,21 +85,26 @@ export function apply(ctx: ClientContext): void { trackEntryClick: (attributes) => { ctx.get('productAnalytics')?.track('sidebar_menu_click', attributes) }, hooks: { panels, shortcuts: ctx.shortcuts.catalog, entryPins: panelPins.unpinned, entryActions }, }) - ctx.slots.inject('sidebar', () => ctx.slots.register({ - name: 'sidebar', - locale: NS, - children: { - 'sidebar.brand.mark': { kind: 'single', scope: 'root' }, - 'sidebar.brand.name': { kind: 'single', scope: 'root' }, - 'sidebar.toggle.badge': { kind: 'single', scope: 'root' }, - 'sidebar.panellist': { kind: 'list', scope: 'root' }, - 'sidebar.panellist.actions': { kind: 'list', scope: 'root' }, - 'sidebar.workspaces': { kind: 'single', scope: 'root' }, - 'sidebar.settings': { kind: 'single', scope: 'root' }, - 'sidebar.footer.action': { kind: 'list', scope: 'root' }, - }, - inject: injectProps, - }, SidebarRoot)) + ctx.slots.inject('sidebar', function* () { + yield ctx.slots.registerFactory({ + name: 'sidebar.panel.icon', scope: 'root', + children: { 'sidebar.panellist': { kind: 'list', scope: 'root' } }, + }, PanelIcon) + yield ctx.slots.register({ + name: 'sidebar', + locale: NS, + children: { + 'sidebar.brand.mark': { kind: 'single', scope: 'root' }, + 'sidebar.brand.name': { kind: 'single', scope: 'root' }, + 'sidebar.toggle.badge': { kind: 'single', scope: 'root' }, + 'sidebar.panellist.actions': { kind: 'list', scope: 'root' }, + 'sidebar.workspaces': { kind: 'single', scope: 'root', inject: SIDEBAR_SECTION_INJECT }, + 'sidebar.settings': { kind: 'single', scope: 'root' }, + 'sidebar.footer.action': { kind: 'list', scope: 'root' }, + }, + inject: injectProps, + }, SidebarRoot) + }) // macOS desktop hides the collapsed sidebar entirely, so the open/New // Session controls move into the frame's window-chrome seat beside the // traffic lights; the occupant reuses the shell's injected actions, and diff --git a/packages/client/ui-sidebar/src/client/locales.ts b/packages/client/ui-sidebar/src/client/locales.ts index 7dd30be7ac..e6ee027f8e 100644 --- a/packages/client/ui-sidebar/src/client/locales.ts +++ b/packages/client/ui-sidebar/src/client/locales.ts @@ -2,6 +2,8 @@ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'panels.unpinTooltip': '从左侧栏取消固定', + 'panels.unpin': '从侧边栏取消固定{name}', 'session.new': '新会话', 'session.new.label': '新建会话', 'toggle.open': '打开侧边栏', @@ -17,6 +19,8 @@ export type SidebarKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { + 'panels.unpinTooltip': 'Unpin from left sidebar', + 'panels.unpin': 'Unpin {name} from sidebar', 'session.new': 'New Session', 'session.new.label': 'New session', 'toggle.open': 'Open sidebar', @@ -29,6 +33,8 @@ export const en = { /** Japanese copy, checked against the English keys. */ export const ja = { + 'panels.unpinTooltip': '左サイドバーから固定解除', + 'panels.unpin': '{name}をサイドバーから固定解除', 'session.new': '新規セッション', 'session.new.label': '新規セッション', 'toggle.open': 'サイドバーを開く', @@ -41,6 +47,8 @@ export const ja = { /** Spanish dictionary, checked against the English keys. */ export const es = { + 'panels.unpinTooltip': 'Desanclar de la barra lateral izquierda', + 'panels.unpin': 'Desfijar {name} de la barra lateral', 'session.new': 'Nueva sesión', 'session.new.label': 'Nueva sesión', 'toggle.open': 'Abrir barra lateral', @@ -53,6 +61,8 @@ export const es = { /** French dictionary, checked against the English keys. */ export const fr = { + 'panels.unpinTooltip': "Retirer l'épingle de la barre latérale gauche", + 'panels.unpin': 'Désépingler {name} de la barre latérale', 'session.new': 'Nouvelle séance', 'session.new.label': 'Nouvelle séance', 'toggle.open': 'Ouvrir la barre latérale', @@ -65,6 +75,8 @@ export const fr = { /** German dictionary, checked against the English keys. */ export const de = { + 'panels.unpinTooltip': 'Von der linken Seitenleiste lösen', + 'panels.unpin': '{name} von der Seitenleiste lösen', 'session.new': 'Neue Sitzung', 'session.new.label': 'Neue Sitzung', 'toggle.open': 'Seitenleiste öffnen', @@ -77,6 +89,8 @@ export const de = { /** Russian dictionary, checked against the English keys. */ export const ru = { + 'panels.unpinTooltip': 'Открепить от левой боковой панели', + 'panels.unpin': 'Открепить {name} от боковой панели', 'session.new': 'Новая сессия', 'session.new.label': 'Новая сессия', 'toggle.open': 'Открыть боковую панель', @@ -89,6 +103,8 @@ export const ru = { /** Portuguese dictionary, checked against the English keys. */ export const pt = { + 'panels.unpinTooltip': 'Soltar da barra lateral esquerda', + 'panels.unpin': 'Desafixar {name} da barra lateral', 'session.new': 'Nova sessão', 'session.new.label': 'Nova sessão', 'toggle.open': 'Abrir barra lateral', diff --git a/packages/client/ui-sidebar/src/client/scroll-handoff.ts b/packages/client/ui-sidebar/src/client/scroll-handoff.ts new file mode 100644 index 0000000000..f6cf64f714 --- /dev/null +++ b/packages/client/ui-sidebar/src/client/scroll-handoff.ts @@ -0,0 +1,98 @@ +/** + * Wheel hand-off between the sidebar viewport and the section scroller below its navigation. + * Scrolling down moves the navigation away before the section scroller moves; scrolling up + * returns the section scroller to its top before the navigation reappears. A gesture whose + * first event must move the viewport, or starts outside the section scroller, is split in + * JavaScript until it ends, including its momentum; both elements are then written + * synchronously in each event, so one gesture continues across the boundary. A gesture the + * section scroller can take alone keeps native scrolling. + */ +import type { RefCallback } from 'react' +import type { SectionScroller, UseSectionScroller } from './contract/slots.ts' + +/** Sidebar-owned state of one hand-off. */ +export interface ScrollHandoff { + /** Ref callback for the sidebar's own viewport; binds the wheel listener. */ + readonly bindViewportRef: RefCallback + /** Hook handed to the `sidebar.workspaces` occupant through the slot's hookContext. */ + readonly useSectionScroller: UseSectionScroller +} + +/** + * Wheel events closer together than this belong to one gesture, including its momentum phase. + * DOM wheel events carry no gesture phase, so the gap stands in for it. + */ +const GESTURE_GAP_MS = 100 + +/** + * Vertical wheel distance in pixels. Pinch-zoom and mostly horizontal events return undefined + * and keep native scrolling. + */ +function verticalWheelPixels(event: WheelEvent, page: number): number | undefined { + if (event.ctrlKey || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return undefined + if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) return event.deltaY * 16 + if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) return event.deltaY * page + return event.deltaY +} + +/** Scroll by `delta`; the browser clamps the write, and the moved distance is returned. */ +function scrollClamped(element: HTMLElement, delta: number): number { + const before = element.scrollTop + element.scrollTop = before + delta + return element.scrollTop - before +} + +/** Whether the viewport has scrolled the navigation away; sub-pixel remainders count as the end. */ +function atEnd(viewport: HTMLElement): boolean { + return viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop < 1 +} + +/** + * Create the hand-off for one sidebar render tree. + * @returns The viewport ref callback and the occupant hook; both keep their identity. + */ +export function createScrollHandoff(): ScrollHandoff { + let scroller: HTMLElement | null = null + let releaseViewport: (() => void) | undefined + let lastSplit = Number.NEGATIVE_INFINITY + const handle = (viewport: HTMLElement, event: WheelEvent): void => { + if (scroller === null || event.defaultPrevented) return + const delta = verticalWheelPixels(event, viewport.clientHeight) + if (delta === undefined || delta === 0) return + // A gesture split here stays here: native scrolling does not resume within its momentum. + const continuing = event.cancelable && event.timeStamp - lastSplit < GESTURE_GAP_MS + if (!continuing) { + // With the navigation gone, the section scroller under the pointer takes this step natively. + const native = atEnd(viewport) && scroller.contains(event.target as Node | null) + if (native && (delta > 0 || scroller.scrollTop + delta >= 0)) return + if (!event.cancelable) { + // A native gesture latched to the section scroller stops at its top; the viewport takes the rest. + if (native) scrollClamped(viewport, delta + scroller.scrollTop) + return + } + } + event.preventDefault() + lastSplit = event.timeStamp + if (delta > 0) { + const rest = delta - scrollClamped(viewport, delta) + if (rest > 0) scrollClamped(scroller, rest) + return + } + const rest = delta - scrollClamped(scroller, delta) + if (rest < 0) scrollClamped(viewport, rest) + } + const sectionScroller: SectionScroller = { + bindScrollerRef: (element) => { scroller = element }, + } + return { + bindViewportRef: (element) => { + releaseViewport?.() + releaseViewport = undefined + if (element === null) return + const onWheel = (event: WheelEvent): void => { handle(element, event) } + element.addEventListener('wheel', onWheel, { passive: false }) + releaseViewport = () => { element.removeEventListener('wheel', onWheel) } + }, + useSectionScroller: () => sectionScroller, + } +} diff --git a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap index 7f6e1ba454..186d9ef6d9 100644 --- a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap +++ b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap @@ -55,11 +55,23 @@ exports[`sidebar shell snapshots > renders Windows caption controls in expanded
    +
    +
    +
    +
    +
    renders Windows caption controls in expanded
    +
    +
    +
    +
    +
    renders the collapsed rail after the crossfad
    +
    +
    +
    +
    +
    renders the expanded column (wordmark, capsul
    +
    +
    +
    +
    +
    renders the expanded column in the default lo
    +
    +
    +
    +
    +
    { expect(b.slots.entries('sidebar')).toHaveLength(1) expect(b.slots.spec('sidebar.brand.mark')).toEqual({ kind: 'single', scope: 'root' }) expect(b.slots.spec('sidebar.brand.name')).toEqual({ kind: 'single', scope: 'root' }) - expect(b.slots.spec('sidebar.workspaces')).toEqual({ kind: 'single', scope: 'root' }) + const section = b.slots.spec('sidebar.workspaces') as { kind: string; scope: string; inject: SidebarSectionInjected } + expect(section).toMatchObject({ kind: 'single', scope: 'root' }) + // The section occupant's scroller hook is the one each sidebar render supplies. + const useSectionScroller: UseSectionScroller = () => ({ bindScrollerRef: () => {} }) + expect(section.inject.hooks.sectionScroller({} as never, { useSectionScroller })).toBe(useSectionScroller) expect(b.slots.spec('sidebar.settings')).toEqual({ kind: 'single', scope: 'root' }) expect(b.slots.spec('sidebar.footer.action')).toEqual({ kind: 'list', scope: 'root' }) expect(b.slots.spec('sidebar.panellist')).toEqual({ kind: 'list', scope: 'root' }) diff --git a/packages/client/ui-sidebar/tests/panel-list.client.spec.tsx b/packages/client/ui-sidebar/tests/panel-list.client.spec.tsx index 6d3045ca09..554192b591 100644 --- a/packages/client/ui-sidebar/tests/panel-list.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/panel-list.client.spec.tsx @@ -358,15 +358,23 @@ describe('sidebar launcher pins', () => { expect(within(navigation).getAllByRole('button').map(row => row.textContent)).toEqual(['Beta panel', 'Alpha panel']) }) - it('shows More after the rows only while the actions list has registrations', async () => { + it('shows More and hides unpinned rows only while the actions list has registrations', async () => { const { runtime, locale, view } = await bench() const probe = await mountProbeActions(runtime) expect(view.queryByRole('navigation')).toBeNull() await mountPanels(runtime, locale) const navigation = await view.findByRole('navigation', { name: 'Global panels' }) - expect(within(navigation).getAllByRole('button').map(row => row.getAttribute('aria-label'))).toEqual(['Beta panel', 'Alpha panel', 'More']) + expect(within(navigation).getAllByRole('button').map(row => row.getAttribute('aria-label'))) + .toEqual(['Beta panel', 'Unpin Beta panel from sidebar', 'Alpha panel', 'Unpin Alpha panel from sidebar', 'More']) + fireEvent.click(within(navigation).getByRole('button', { name: 'Unpin Alpha panel from sidebar' })) + expect(within(navigation).queryByRole('button', { name: 'Alpha panel' })).toBeNull() + expect(document.activeElement).toBe(within(navigation).getByRole('button', { name: 'More' })) await probe.dispose() await waitFor(() => { expect(within(navigation).queryByRole('button', { name: 'More' })).toBeNull() }) + expect(within(navigation).getAllByRole('button').map(row => row.getAttribute('aria-label'))).toEqual(['Beta panel', 'Alpha panel']) + await mountProbeActions(runtime) + await waitFor(() => { expect(within(navigation).queryByRole('button', { name: 'Alpha panel' })).toBeNull() }) + expect(within(navigation).getByRole('button', { name: 'More' })).toBeTruthy() }) it.each([false, true])('opens, pins, and dismisses through the More menu with collapsed=%s', async (collapsed) => { @@ -395,6 +403,11 @@ describe('sidebar launcher pins', () => { fireEvent.click(menu.getByRole('menuitem', { name: 'Pin Alpha panel' })) const alphaRow = await within(navigation).findByRole('button', { name: 'Alpha panel' }) expect(alphaRow.getAttribute('aria-current')).toBe('page') + fireEvent.click(menu.getByRole('menuitem', { name: 'Unpin Beta panel' })) + fireEvent.click(menu.getByRole('menuitem', { name: 'Pin Beta panel' })) + expect(within(navigation).getAllByRole('button').map(row => row.getAttribute('aria-label')) + .filter(label => !label?.startsWith('Unpin '))) + .toEqual(['Plugins', 'Beta panel', 'Alpha panel', 'More']) fireEvent.click(menu.getByRole('menuitem', { name: 'Unpin Alpha panel' })) await waitFor(() => { expect(within(navigation).queryByRole('button', { name: 'Alpha panel' })).toBeNull() }) expect(view.getByRole('heading', { name: 'Alpha content' })).toBeTruthy() diff --git a/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx b/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx index 6e15b94baa..7b06396d72 100644 --- a/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx @@ -50,6 +50,7 @@ function mountColumn(): { column: HTMLElement; quiet: () => boolean } { useContextMenu={contextMenuHook()} useResource={useResource} useWorkspaces={neverHook} {...allowAllAccess} startSession={vi.fn()} toggleSidebar={vi.fn()} trackEntryClick={vi.fn()} t={t} + renderFactorySlot={() => null} renderSlot={((_key: string, owner: SidebarSectionOwnerProps) =>
    ) as SidebarRootComponentProps['renderSlot']} />, diff --git a/packages/client/ui-sidebar/tests/scroll-handoff.client.spec.ts b/packages/client/ui-sidebar/tests/scroll-handoff.client.spec.ts new file mode 100644 index 0000000000..d1f9438ac4 --- /dev/null +++ b/packages/client/ui-sidebar/tests/scroll-handoff.client.spec.ts @@ -0,0 +1,161 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest' +import { createScrollHandoff } from '../src/client/scroll-handoff.ts' + +/** A scroll container whose scrollTop the browser would clamp to `[0, max]`; jsdom has no layout. */ +function scrollable(max: number, clientHeight = 300): HTMLDivElement { + const element = document.createElement('div') + let top = 0 + Object.defineProperty(element, 'scrollTop', { + configurable: true, + get: () => top, + set: (value: number) => { top = Math.min(max, Math.max(0, value)) }, + }) + Object.defineProperty(element, 'clientHeight', { configurable: true, value: clientHeight }) + Object.defineProperty(element, 'scrollHeight', { configurable: true, value: clientHeight + max }) + document.body.append(element) + return element +} + +function wheel(target: HTMLElement, init: WheelEventInit, timeStamp?: number): WheelEvent { + const event = new WheelEvent('wheel', { cancelable: true, bubbles: true, ...init }) + if (timeStamp !== undefined) Object.defineProperty(event, 'timeStamp', { value: timeStamp }) + target.dispatchEvent(event) + return event +} + +/** A sidebar viewport with 130px of navigation to scroll away and a section scroller with 500px of rows. */ +function bound() { + const handoff = createScrollHandoff() + const viewport = scrollable(130, 400) + const scroller = scrollable(500) + viewport.append(scroller) + handoff.bindViewportRef(viewport) + handoff.useSectionScroller().bindScrollerRef(scroller) + return { handoff, viewport, scroller } +} + +afterEach(() => { document.body.replaceChildren() }) + +describe('createScrollHandoff', () => { + it('moves the navigation away before the section scroller in one downward event', () => { + const { viewport, scroller } = bound() + expect(wheel(scroller, { deltaY: 200 }).defaultPrevented).toBe(true) + expect(viewport.scrollTop).toBe(130) + expect(scroller.scrollTop).toBe(70) + }) + + it('leaves the section scroller untouched while the navigation absorbs the distance', () => { + const { viewport, scroller } = bound() + wheel(scroller, { deltaY: 40 }) + expect(viewport.scrollTop).toBe(40) + expect(scroller.scrollTop).toBe(0) + }) + + it('keeps native scrolling inside the section scroller once the navigation is gone', () => { + const { viewport, scroller } = bound() + viewport.scrollTop = 130 + scroller.scrollTop = 70 + expect(wheel(scroller, { deltaY: 100 }).defaultPrevented).toBe(false) + expect(wheel(scroller, { deltaY: -50 }).defaultPrevented).toBe(false) + expect(viewport.scrollTop).toBe(130) + expect(scroller.scrollTop).toBe(70) + }) + + it('scrolls the section scroller for wheel events that start outside it', () => { + const { viewport, scroller } = bound() + viewport.scrollTop = 130 + expect(wheel(viewport, { deltaY: 80 }).defaultPrevented).toBe(true) + expect(scroller.scrollTop).toBe(80) + wheel(viewport, { deltaY: -50 }) + expect(scroller.scrollTop).toBe(30) + expect(viewport.scrollTop).toBe(130) + }) + + it('returns the section scroller to its top before the navigation reappears', () => { + const { viewport, scroller } = bound() + viewport.scrollTop = 130 + scroller.scrollTop = 70 + expect(wheel(scroller, { deltaY: -100 }).defaultPrevented).toBe(true) + expect(scroller.scrollTop).toBe(0) + expect(viewport.scrollTop).toBe(100) + wheel(scroller, { deltaY: -30 }) + expect(viewport.scrollTop).toBe(70) + }) + + it('gives the viewport the rest of an uncancelable native gesture that reaches the section top', () => { + const { viewport, scroller } = bound() + viewport.scrollTop = 130 + scroller.scrollTop = 20 + expect(wheel(scroller, { deltaY: -50, cancelable: false }).defaultPrevented).toBe(false) + expect(viewport.scrollTop).toBe(100) + expect(scroller.scrollTop).toBe(20) + }) + + it('keeps a split gesture in JavaScript through its momentum, then returns new gestures to native scrolling', () => { + const { viewport, scroller } = bound() + viewport.scrollTop = 100 + expect(wheel(scroller, { deltaY: 60 }, 1000).defaultPrevented).toBe(true) + expect(viewport.scrollTop).toBe(130) + expect(scroller.scrollTop).toBe(30) + expect(wheel(scroller, { deltaY: 40 }, 1016).defaultPrevented).toBe(true) + expect(scroller.scrollTop).toBe(70) + expect(wheel(scroller, { deltaY: 40 }, 1200).defaultPrevented).toBe(false) + expect(scroller.scrollTop).toBe(70) + }) + + it('leaves other uncancelable events to native scrolling', () => { + const { viewport, scroller } = bound() + wheel(scroller, { deltaY: 40, cancelable: false }) + expect(viewport.scrollTop).toBe(0) + expect(scroller.scrollTop).toBe(0) + }) + + it('converts line and page deltas to pixels', () => { + const { viewport } = bound() + wheel(viewport, { deltaY: 2, deltaMode: WheelEvent.DOM_DELTA_LINE }) + expect(viewport.scrollTop).toBe(32) + wheel(viewport, { deltaY: -0.05, deltaMode: WheelEvent.DOM_DELTA_PAGE }) + expect(viewport.scrollTop).toBe(12) + }) + + it.each([ + ['pinch-zoom', { deltaY: 40, ctrlKey: true }], + ['mostly horizontal', { deltaX: 60, deltaY: 40 }], + ['zero', { deltaY: 0 }], + ] satisfies [string, WheelEventInit][])('keeps native scrolling for %s events', (_name, init) => { + const { viewport, scroller } = bound() + expect(wheel(scroller, init).defaultPrevented).toBe(false) + expect(viewport.scrollTop).toBe(0) + expect(scroller.scrollTop).toBe(0) + }) + + it('leaves events another listener already canceled', () => { + const { viewport, scroller } = bound() + scroller.addEventListener('wheel', (event) => { event.preventDefault() }, { once: true }) + wheel(scroller, { deltaY: 40 }) + expect(viewport.scrollTop).toBe(0) + }) + + it('keeps native scrolling without a bound section scroller', () => { + const { handoff, viewport } = bound() + handoff.useSectionScroller().bindScrollerRef(null) + expect(wheel(viewport, { deltaY: 40 }).defaultPrevented).toBe(false) + expect(viewport.scrollTop).toBe(0) + }) + + it('moves its listener to a rebound viewport and removes it on unbind', () => { + const { handoff, viewport } = bound() + const next = scrollable(130) + handoff.bindViewportRef(next) + expect(wheel(viewport, { deltaY: 40 }).defaultPrevented).toBe(false) + expect(wheel(next, { deltaY: 40 }).defaultPrevented).toBe(true) + handoff.bindViewportRef(null) + expect(wheel(next, { deltaY: 40 }).defaultPrevented).toBe(false) + }) + + it('hands every caller the same scroller binding', () => { + const handoff = createScrollHandoff() + expect(handoff.useSectionScroller()).toBe(handoff.useSectionScroller()) + }) +}) diff --git a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx index ee09e12ce5..040b994806 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx @@ -9,7 +9,7 @@ import { allowAllAccess, makeTranslate } from '@deepseek-ai/dsh-client-test-runt import type { ReactNode } from 'react' import { useState } from 'react' import type { - SidebarFooterActionOwnerProps, SidebarRootComponentProps, SidebarSectionOwnerProps, + SidebarFooterActionOwnerProps, SidebarPanelMetadata, SidebarRootComponentProps, SidebarSectionHookContext, SidebarSectionOwnerProps, SidebarSettingsOwnerProps, } from '../src/client/contract/slots.ts' import { HeaderLeadingControls, type HeaderLeadingControlsProps } from '../src/client/HeaderLeadingControls.tsx' @@ -50,17 +50,19 @@ function makeHeaderProps(): HeaderLeadingControlsProps { } } -function mountShell({ collapsed = false, width = 300, drawer = false, shortcuts = [], content }: { +function mountShell({ collapsed = false, width = 300, drawer = false, shortcuts = [], panels = [], content }: { collapsed?: boolean width?: number drawer?: boolean shortcuts?: readonly ShortcutCatalogEntry[] + panels?: readonly SidebarPanelMetadata[] content?: ReactNode } = {}) { const startSession = vi.fn() const toggleSidebar = vi.fn() const trackEntryClick = vi.fn() let regionOwner: SidebarSectionOwnerProps | undefined + let regionContext: SidebarSectionHookContext | undefined let settingsOwner: SidebarSettingsOwnerProps | undefined let footerActionOwner: SidebarFooterActionOwnerProps | undefined const brandMark = M @@ -71,19 +73,22 @@ function mountShell({ collapsed = false, width = 300, drawer = false, shortcuts drawer={current.drawer} collapsed={current.collapsed} width={current.width} useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook} - usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])} useEntryPins={selector => selector([])} + usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector(panels)} useEntryPins={selector => selector([])} useEntryActions={selector => selector(false)} setPinned={vi.fn()} useShortcuts={selector => selector(shortcuts)} useContextMenu={contextMenuHook()} useResource={useResource} useWorkspaces={neverHook} {...allowAllAccess} startSession={startSession} toggleSidebar={toggleSidebar} trackEntryClick={trackEntryClick} t={t} + renderFactorySlot={() => null} renderSlot={(( key: string, owner: SidebarFooterActionOwnerProps | SidebarSectionOwnerProps | SidebarSettingsOwnerProps, + opts?: { hookContext?: SidebarSectionHookContext }, ) => { if (key === 'sidebar.brand.mark') return brandMark if (key === 'sidebar.brand.name') return brandName if (key === 'sidebar.toggle.badge') return null + if (key === 'sidebar.panellist') return if (key === 'sidebar.settings') { settingsOwner = owner return
    @@ -93,6 +98,7 @@ function mountShell({ collapsed = false, width = 300, drawer = false, shortcuts return
    } regionOwner = owner as SidebarSectionOwnerProps + regionContext = opts?.hookContext return
    {content}
    }) as SidebarRootComponentProps['renderSlot']} /> @@ -106,6 +112,10 @@ function mountShell({ collapsed = false, width = 300, drawer = false, shortcuts if (regionOwner === undefined) throw new Error('region owner not rendered') return regionOwner }, + regionContext: () => { + if (regionContext === undefined) throw new Error('region hookContext not rendered') + return regionContext + }, settingsOwner: () => { if (settingsOwner === undefined) throw new Error('settings owner not rendered') return settingsOwner @@ -122,6 +132,33 @@ function mountShell({ collapsed = false, width = 300, drawer = false, shortcuts } describe('SidebarRoot shell', () => { + it('puts navigation and workspaces in one viewport without scrolling primary controls or the footer', () => { + mountShell({ panels: [{ id: 'notes' as SidebarPanelMetadata['id'], order: 1, label: 'Notes' }] }) + const viewport = document.querySelector('[data-sidebar-scrollport]')! + expect(viewport.contains(screen.getByRole('navigation'))).toBe(true) + expect(viewport.contains(screen.getByTestId('region'))).toBe(true) + for (const button of screen.getAllByRole('button', { name: 'New session' })) { + expect(viewport.contains(button)).toBe(false) + } + expect(viewport.contains(screen.getByTestId('settings-seat'))).toBe(false) + expect(viewport.contains(screen.getByTestId('footer-action-seat'))).toBe(false) + }) + + it('hands the section a stable scroller hook bound to this sidebar viewport', () => { + const shell = mountShell() + const scroller = shell.regionContext().useSectionScroller() + shell.rerender({ width: 320 }) + expect(shell.regionContext().useSectionScroller()).toBe(scroller) + const viewport = document.querySelector('[data-sidebar-scrollport]')! + const unbound = new WheelEvent('wheel', { deltaY: 40, cancelable: true }) + viewport.dispatchEvent(unbound) + expect(unbound.defaultPrevented).toBe(false) + scroller.bindScrollerRef(document.createElement('div')) + const bound = new WheelEvent('wheel', { deltaY: 40, cancelable: true }) + viewport.dispatchEvent(bound) + expect(bound.defaultPrevented).toBe(true) + }) + it('advertises the effective new-session binding', () => { const shortcut: ShortcutCatalogEntry = { id: 'session.new' as ShortcutCommandId, label: 'New', aliases: [], binding: null, modified: true, conflicts: [], issue: null, keys: ['Ctrl', 'N'], aria: 'Control+N' } @@ -190,6 +227,7 @@ describe('SidebarRoot shell', () => { {...allowAllAccess} useRemoteAccess={useRemoteAccess} startSession={startSession} toggleSidebar={toggleSidebar} trackEntryClick={vi.fn()} t={t} + renderFactorySlot={() => null} renderSlot={((key: string) => { if (key === 'sidebar.brand.mark') return M if (key === 'sidebar.brand.name') return Brand @@ -221,6 +259,7 @@ describe('SidebarRoot shell', () => { useContextMenu={contextMenuHook()} useResource={useResource} useWorkspaces={neverHook} {...allowAllAccess} startSession={vi.fn()} toggleSidebar={vi.fn()} trackEntryClick={vi.fn()} t={t} + renderFactorySlot={() => null} renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) => options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} />) @@ -245,6 +284,7 @@ describe('SidebarRoot shell', () => { useContextMenu={contextMenuHook()} useResource={useResource} useWorkspaces={neverHook} {...allowAllAccess} startSession={vi.fn()} toggleSidebar={vi.fn()} trackEntryClick={vi.fn()} t={t} + renderFactorySlot={() => null} renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) => options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} />) @@ -273,6 +313,7 @@ describe('SidebarRoot shell', () => { useContextMenu={contextMenuHook()} useResource={useResource} useWorkspaces={neverHook} {...allowAllAccess} startSession={startSession} toggleSidebar={vi.fn()} trackEntryClick={trackEntryClick} t={t} + renderFactorySlot={() => null} renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) => options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} />) @@ -324,6 +365,7 @@ describe('SidebarRoot shell', () => { useContextMenu={contextMenuHook()} useResource={useResource} useWorkspaces={neverHook} {...allowAllAccess} startSession={vi.fn()} toggleSidebar={vi.fn()} trackEntryClick={vi.fn()} t={t} + renderFactorySlot={() => null} renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) => options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} />) @@ -388,6 +430,7 @@ describe('SidebarRoot shell', () => { useContextMenu={contextMenuHook()} useResource={useResource} useWorkspaces={neverHook} {...allowAllAccess} startSession={vi.fn()} toggleSidebar={vi.fn()} trackEntryClick={vi.fn()} t={t} + renderFactorySlot={() => null} renderSlot={((key: string) => key === 'sidebar.toggle.badge' ? : null) as SidebarRootComponentProps['renderSlot']} diff --git a/packages/client/ui-sidebar/tests/sidebar-styles.client.spec.ts b/packages/client/ui-sidebar/tests/sidebar-styles.client.spec.ts index 67efc3c945..3ed16573ec 100644 --- a/packages/client/ui-sidebar/tests/sidebar-styles.client.spec.ts +++ b/packages/client/ui-sidebar/tests/sidebar-styles.client.spec.ts @@ -31,27 +31,64 @@ describe('SidebarRoot.module.css', () => { expect(declarations('.collapsed .panelList')?.get('gap')).toBe('12px') }) - it('shares and cancels the wide shell trailing padding structurally', () => { + it('fits expanded navigation into 32px rows while retaining 36px rail controls', () => { + const row = declarations('.panelRow') + expect(row?.get('min-height')).toBe('32px') + expect(row?.get('padding')).toBe('5px calc(var(--dsh-sidebar-row-padding) - var(--dsh-sidebar-paint-inset))') + expect(row?.get('line-height')).toBe('22px') + expect(row?.get('box-sizing')).toBe('border-box') + expect(row?.get('border')).toBe('none') + expect(declarations('.collapsed .panelRow')?.get('width')).toBe('36px') + expect(declarations('.collapsed .panelRow')?.get('height')).toBe('36px') + expect(declarations('.collapsed .panelRow')?.get('padding')).toBe('0') + }) + + it('owns a shared scrollport whose gaps scroll away', () => { const root = declarations('.root') + const scrollport = declarations('.scrollArea') expect(root?.get('--dsh-sidebar-inline-padding')).toBe('8px') expect(root?.get('--dsh-sidebar-row-padding')).toBe('12px') expect(root?.get('--dsh-sidebar-label-gap')).toBe('6px') expect(root?.get('--dsh-sidebar-paint-inset')).toBe('3px') expect(root?.get('padding')).toBe('6px var(--dsh-sidebar-inline-padding)') - expect(declarations('.regionArea')?.get('margin-left')).toBe('-4px') - expect(declarations('.regionArea')?.get('padding-left')).toBe('4px') - expect(declarations('.regionArea')?.get('margin-right')).toBe( - 'calc(-1 * var(--dsh-sidebar-inline-padding))', - ) - expect(declarations('.collapsed .regionArea')?.get('margin-left')).toBe('0') - expect(declarations('.collapsed .regionArea')?.get('padding-left')).toBe('0') - expect(declarations('.collapsed .regionArea')?.get('margin-right')).toBe('0') + expect(declarations('.scrollRegion')?.get('min-height')).toBe('0') + expect(declarations('.root:not(.collapsed) .scrollRegion')?.get('container-type')).toBe('size') + expect(declarations('.scrollRegion')?.get('container-type')).toBeUndefined() + expect(scrollport?.get('display')).toBe('grid') + expect(scrollport?.get('overflow-y')).toBe('auto') + expect(scrollport?.get('overflow-x')).toBe('hidden') + expect(scrollport?.get('grid-template-columns')).toBe('4px minmax(0, 1fr) calc(var(--dsh-sidebar-inline-padding) - 2px)') + expect(scrollport?.get('grid-template-rows')).toBe('8px max-content 2px max-content') + expect(declarations('.scrollRegion')?.get('margin-top')).toBe('4px') + expect(declarations('.collapsed .scrollRegion')?.get('margin-top')).toBe('0') + expect(scrollport?.get('margin-left')).toBe('-4px') + expect(scrollport?.get('margin-right')).toBe('calc(2px - var(--dsh-sidebar-inline-padding))') + expect(scrollport?.get('scroll-padding-block')).toBeUndefined() + expect(declarations('.panelList')?.get('grid-row')).toBe('2') + expect(declarations('.regionArea')?.get('grid-row')).toBe('4') + expect(declarations('.regionArea')?.get('overflow')).toBeUndefined() + expect(declarations('.regionArea')?.get('margin-right')).toBeUndefined() + expect(declarations('.regionArea')?.get('flex')).toBeUndefined() + expect(declarations('.collapsed .regionArea')?.get('flex')).toBe('1') + expect(declarations('.collapsed .regionArea')?.get('margin-left')).toBeUndefined() + expect(declarations('.collapsed .regionArea')?.get('margin-right')).toBeUndefined() + expect(declarations('.collapsed .regionArea')?.get('padding-left')).toBeUndefined() + expect(declarations('.scrollFade')?.get('height')).toBe('24px') + expect(declarations('.collapsed .scrollArea')?.get('overflow')).toBe('visible') + expect(declarations('.collapsed .scrollArea')?.get('margin')).toBe('0') + }) + + it('hides the expanded viewport scrollbar and publishes its scroll state to the section', () => { + const expanded = declarations('.root:not(.collapsed) .scrollArea') + expect(expanded?.get('container-type')).toBe('scroll-state') + expect(expanded?.get('scrollbar-width')).toBe('none') + expect(declarations('.root:not(.collapsed) .scrollArea::-webkit-scrollbar')?.get('display')).toBe('none') }) it('keeps the reviewed expanded spacing separate from rail and caption controls', () => { expect(declarations('.logoRow')?.get('padding')).toBe('8px 0 8px 8px') expect(declarations('.logoRow')?.get('margin-bottom')).toBe('0') - expect(declarations('.newSession')?.get('margin')).toBe('0 calc(var(--dsh-sidebar-paint-inset) + 1px) 10px') + expect(declarations('.newSession')?.get('margin')).toBe('0 calc(var(--dsh-sidebar-paint-inset) + 1px)') expect(declarations('.newSessionContent')?.get('justify-content')).toBe('center') expect(declarations(":global(html[data-platform='darwin']:not([data-fullscreen])) .root:not(.collapsed) > .topStrip > .toggle")?.get('transform')).toBe('translateX(4px)') expect(css).not.toContain(':not(.fading)') diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index b12b090972..310cd7839b 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/README.i18n.yaml @@ -15,14 +15,14 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-subagent/use-this-package: - en: 9aafcfbd6a33173c - zh: 6dbd48672f58cd2d + en: d00a6c7ffced5dc7 + zh: 616220bbdfdc70a6 /deepseek-ai-dsh-client-ui-subagent/use-this-package/browsing-the-tree: - en: 3508631091820123 - zh: ec7da9e5bedb22ba + en: 4ecdca030e42109d + zh: 406b08590ba8df83 /deepseek-ai-dsh-client-ui-subagent/use-this-package/continuing-a-conversation: - en: 876e7152c8b9c4a6 - zh: f89f5e2a812e8451 + en: e4ec261d3b122eb5 + zh: 320bb2efc93dccdc /deepseek-ai-dsh-client-ui-subagent/use-this-package/the-reference-source: en: 6a5f91b7dfcefafe zh: 2d11441952cfea53 @@ -30,14 +30,14 @@ en: c158f12dcaed87b7 zh: e5d315e7b1fb4fa9 /deepseek-ai-dsh-client-ui-subagent/understand-the-implementation/catalog-derivation: - en: 5531dcc1fb976cff - zh: 6fe779f4f1c34266 + en: 41492c7bf30f2a48 + zh: b225f51bd226d60d /deepseek-ai-dsh-client-ui-subagent/understand-the-implementation/duration-completion-and-tokens: - en: b817e9907392f59c - zh: e42feb97d572a989 + en: dcbc58d4cad47fa8 + zh: e962a8cbdfd4a914 /deepseek-ai-dsh-client-ui-subagent/understand-the-implementation/composer-election: - en: 13c33af89ba0942e - zh: 00374c7944728ae2 + en: 8ff34f93e40fbdc2 + zh: cc3430130c9df0a4 /deepseek-ai-dsh-client-ui-subagent/further-exploration: en: 1766e9012e307178 zh: f3ee637f5941aae5 diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index ad56362a7a..18bee2a024 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -25,19 +25,25 @@ Browse local subagent conversations and external tasks from the parent header. L ## Use this package -The session header keeps the current session title as the lineage breadcrumb; when the session's direct catalog has entries or a read has failed, the descendant-count trigger renders after the preset label in the header actions band, with no breadcrumb separator. An absent catalog, an empty loading catalog, or a successfully loaded empty catalog hides the count trigger. The trigger opens that direct catalog, reports its total and running counts, and loads nested catalogs only when their rows expand. Select a local child at any depth to open that child's conversation with its exact `{parentSessionId, childSessionId, mode}` address, or use the row's trailing arrow to open the same address in the right Sidebar, preferring a separate pane when room permits. +The session header uses the session title for ordinary breadcrumbs. Subagent breadcrumbs prefer the original creation description (`entry.label`), then the Session id. Ordinary and subagent breadcrumb titles both use 14px text with a 20px line height. When the session's direct catalog has entries or a read has failed, the descendant-count trigger renders after the preset label in the header actions band, with no breadcrumb separator. An absent or empty catalog hides the count trigger unless the root Session has an enabled, available Team board with shared tasks or a task-record error. Enabling Team mode alone does not show an empty entry. The trigger opens that direct catalog, uses the total count as its accessible name and the running count as its accessible description, and loads nested catalogs only when their rows expand. Select a local child at any depth to open that child's conversation with its exact `{parentSessionId, childSessionId, mode}` address, or use a noncurrent local row's trailing arrow to open the same address in the right Sidebar, preferring a separate pane when room permits. This package registers the `dsh-resource://subagentchat/session/?parent=&mode=` resource and builtin Sidebar tab type. The resource retains the child `SessionReference` directly from its address without refreshing the parent catalog, and releases the reference when the tab record closes. The tab renders the shared `conversation.content` Factory through `sidebar.chat.conversation`, fixes the local View to Chat, and omits the main Conversation header and width controls. +Every open catalog menu is 360px wide with 6px outer padding, constrained to fit the viewport. The root menu shows noncollapsible Subagents and Shared tasks headers only when Team mode is enabled, at least one nondeleted shared task exists, and the footer contribution is available. The Subagents header uses a Users icon and counts direct entries in completed, in progress, not running, and external order; zero counts are omitted and the separator matches the input task list. External tasks never count as running or completed. An empty Subagents section shows “No subagents” only alongside shared tasks. With Team mode off or no shared tasks, the menu shows only the subagent list, without section headers, a shared-task module, empty-state copy, or a divider; a real task-record failure remains a separate alert. Shared tasks have a 4px top margin rather than a divider. Both section headers keep a 7px icon/title gap and an additional 4px before their counts. Only the menu scrolls; sections have no nested scrolling. In WebKit-compatible browsers, the native 5px scrollbar sits in the right 6px inset with inset track ends and a stable gutter, preserving content width when scrolling becomes necessary. Other browsers retain their standard native scrollbar. + +The root catalog exposes `conversation.session.header.actions.subagent.footer` for shared tasks. Child and ancestor breadcrumb catalogs show navigation only, without section headers. + +The wide root count trigger shows a 14px Users icon in its 14px slot when no child is running and the shared ongoing loader otherwise, without a dropdown arrow. As the title row narrows, passive preset text hides at widths of 540px or less and the count text hides at 460px or less. The compact trigger always shows the Users icon, including while children are running; running indicators remain inside the menu. It matches More and sidebar controls: a 28×28px action with no padding, a 15px glyph in a 15px slot, secondary ink, and ghost hover and active backgrounds. Header actions use an 8px gap in compact layout and 10px in wide layout. The icon-only tooltip exposes the full count. Resizing preserves focus on the trigger. Subagent switchers retain their description-first text and existing icon. + ### Browsing the tree -Hovering a trigger opens its catalog after 150ms; leaving both trigger and catalog closes it after 120ms. Clicking the descendant-count trigger pins its catalog until outside click or Escape from the trigger or tree. Breadcrumb-title clicks navigate to the corresponding conversation. +Count, owner, and sidebar-action tooltips appear after 500ms of hover. Hovering a text-visible trigger opens its catalog after 150ms; leaving both trigger and catalog closes it after 120ms. Clicking the descendant-count trigger opens and pins its catalog, or closes an already pinned catalog. The icon-only count trigger does not open on hover, including when a pending hover crosses into the narrow layout. Clicking toggles its pinned menu; ArrowDown opens and pins the menu and focuses its first row. Outside click or Escape dismisses a pinned menu. Clicking moves focus into the panel, where ArrowDown/Home enters the first tree row and ArrowUp/End enters the last; Escape returns focus when it was inside. Breadcrumb-title clicks navigate to the corresponding conversation. -Rows display mode plus activity and an optional log-backed title; running uses the shared ongoing loader, an inactive child whose latest closed turn completed normally uses the shared success dot, and other inactive children use the shared idle dot. Every row reserves the same 14px status column, centering smaller dots so titles align with the loader state. The compact header trigger vertically centers its activity glyph and count with a 4px gap. The trailing column stacks total durable provider usage above active-turn duration. Keyboard navigation works with ArrowRight/ArrowLeft to expand and collapse branches and ArrowUp/ArrowDown, Home, End, and Escape to navigate or close the tree. An unlabeled one-shot row falls back to its session id. A row is a known leaf only after its own catalog loads empty. +Rows show the creation description, or an available Session title, above the Session id. Without either description, the row shows only its Session id. Local rows use compact 4px vertical padding; their clickable content uses 6px horizontal padding and 7px gaps, aligned with shared-task rows while retaining the tree's left offset. A decorative trailing check marks the current conversation and is not an action; other local rows retain the sidebar arrow. Primary titles use 13px text and secondary ids use 12px. All rows wrap descriptions and ids in full, including on touch screens. Running uses the shared ongoing loader, an inactive child whose latest closed turn completed normally uses the shared success dot, and other inactive children use the shared idle dot. Every row reserves the same 14px status column, centering smaller dots so titles align with the loader state. The text-visible header trigger vertically centers its activity glyph and count with a 4px gap. The trailing column stacks total durable provider usage above active-turn duration. Keyboard navigation works with ArrowRight/ArrowLeft to expand and collapse branches and ArrowUp/ArrowDown, Home, End, and Escape to navigate or close the tree. An unlabeled one-shot row falls back to its session id. A row is a known leaf only after its own catalog loads empty. ### Continuing a conversation -A continuable child with a live parent keeps the ordinary input chrome: typing and Send stay available while the child runs because every follow-up joins the child's FIFO inbox, and an independent Stop routes through `subagents/interruptByParent`. A continuable child whose exact parent is unavailable and which is not running elects a read-only composer explaining the recovery path; while such a child still runs, the selector yields to the ordinary composer with input and Send disabled but its independent Stop usable. +A continuable child keeps the ordinary input and independent Stop action regardless of creator availability. Prompts route through `subagents/prompt`, whose runtime validates the creation address and live Team root; failures use ordinary prompt feedback. Stop routes through `subagents/interruptByUser`. ### The `@` reference source @@ -55,7 +61,9 @@ The [subagent runtime](../../subagent/subagent/README.md) owns continuation and ### Catalog derivation -The header lineage renderer reads `projectionsBySession` through the standard `useSessions` hook. The renderer selects `subagentCatalog` from each Session’s shared values for membership, disclosure, and counts; Activity prefers the unified UI status and falls back to Session summaries; summaries supply titles and usage. Expanding a row reads its target's projections, including ordinary cold history. A list row with `formatStatus: migration-required` displays “Migration required” before expansion and keeps its history-opening actions without descendant expansion or changing its subagent mode; a projection read requiring migration without a catalog supplies the same notice. Opening the Session supplies its catalog through history and control projections. Otherwise, an available catalog remains usable, including an explicit empty catalog. Live projection frames update every loaded level without menu subscriptions or repeated membership queries. Other rows remain expandable while their catalogs are absent, loading, or failed, and become known leaves after ready empty catalogs. External rows are always leaves and do not load child Sessions. +The header lineage renderer reads `projectionsBySession` through the standard `useSessions` hook. The renderer selects `subagentCatalog` from each Session’s shared values for membership, disclosure, and counts. Activity prefers the unified UI status and falls back to Session summaries; summaries supply titles and usage. Expanding a row reads its target's projections, including ordinary cold history. A list row with `formatStatus: migration-required` displays “Migration required” before expansion and keeps its history-opening actions without descendant expansion or changing its subagent mode; a projection read requiring migration without a catalog supplies the same notice. Opening the Session supplies its catalog through history and control projections. Otherwise, an available catalog remains usable, including an explicit empty catalog. Live projection frames update every loaded level without menu subscriptions or repeated membership queries. Other rows remain expandable while their catalogs are absent, loading, or failed, and become known leaves after ready empty catalogs. External rows are always leaves and do not load child Sessions. + +Each header entry declares a footer slot below its tree. Optional UI contributes there within the same scroll container; the catalog owns opening and dismissal. Shared anchored positioning keeps the panel inside the visible viewport when its content, the window size, or browser zoom changes. Opening a catalog dropdown does not request its root catalog. Child-catalog expansion and failed-read retries call `refreshProjection`; shared projection-value changes update the display automatically. @@ -63,15 +71,15 @@ Breadcrumb addresses derive from the Provider-bound Session address, loaded pare ### Duration, completion, and tokens -Each visible catalog level advances its own clock while it contains a running child; collapsing the level or closing the menu releases that clock. Token totals sum the four disjoint `tokenUsage` buckets. The `subagentTiming` projection sums completed-turn duration and records whether the latest closed turn ended with `completed`; opening another turn clears that completion until its own `turn/end`. Duration advances once per second only for an open turn on a running child and freezes after the child becomes inactive; an interrupted open turn is bounded by its same-cut `active.through`, never by newer session metadata. +Each visible catalog level advances its own clock while it contains a running child; collapsing the level or closing the menu releases that clock. Token totals sum the four disjoint `tokenUsage` buckets. The `subagentTiming` projection sums completed-turn duration and supplies `latestTurnCompleted` for both the success dot and completed count; opening another turn clears that completion until its own `turn/end`. Duration advances once per second only for an open turn on a running child and freezes after the child becomes inactive; an interrupted open turn is bounded by its same-cut `active.through`, never by newer session metadata. ### Composer election -One-shot children always elect a read-only composer. A continuable child elects one only when its exact parent is unavailable and the child is not running; otherwise the ordinary composer's Session routes prompts through `subagents/prompt`. This package never receives host context or calls a model-facing tool. +One-shot and unknown-mode children elect a read-only composer. Continuable children route prompts through `subagents/prompt`. This package never receives host context or calls a model-facing tool. Unknown-mode catalog rows remain visible and clickable, using the child id when no label is available. Their composer is read-only until child history establishes a supported mode; a read failure is reported in the child conversation. -Lineage-menu highlights follow the [standard menu edge geometry](../../../docs/ui-radius.md#nested-hover); tree disclosure controls retain their separate left gutter. +Lineage-menu highlights follow the [standard menu edge geometry](../../../docs/ui-radius.md#nested-hover). Each branch disclosure target covers its 28px left gutter and full row height without overlapping adjacent rows; only the 12px glyph rotates in place, and disclosure clicks do not trigger row or sidebar navigation. Disclosure hover highlights only its arrow; navigation-body hover and keyboard focus retain their background highlight. Thin level-3 border connectors align with the first-line glyph centers, cross reserved disclosure space to reach leaf status glyphs, and end the final vertical stem at its glyph center. diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index 1c2048920b..56a37645db 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -25,19 +25,25 @@ kind: "package-reference" ## 使用本包 -会话页头把当前会话标题作为谱系面包屑;当会话的直接目录有子项或读取失败时,后代数量触发器渲染在页头操作区中预设标签之后,不带任何面包屑分隔符。目录缺席、空目录加载中或成功加载为空时,均隐藏数量触发器。触发器打开该直接目录,报告总数与运行数,并且只在行展开时加载嵌套目录。选择任意深度,即可用该子会话的确切 `{parentSessionId, childSessionId, mode}` 地址打开其对话;也可以使用行尾箭头在右侧 Sidebar 打开同一地址,并在空间允许时优先使用独立分栏。 +会话页头的普通面包屑使用会话标题。subagent 面包屑优先使用创建时的原始描述(`entry.label`),缺少时回退到 Session id。普通与 subagent 面包屑标题均使用 14px 文字和 20px 行高。当会话的直接目录有子项或读取失败时,后代数量触发器渲染在页头操作区中预设标签之后,不带任何面包屑分隔符。目录缺席或为空时隐藏数量触发器,除非根 Session 已开启且可用的 Team 任务板有共享任务或任务记录错误。仅开启 Team 模式不会显示空入口。触发器打开该直接目录,以总数作为无障碍名称、运行数作为无障碍描述,并且只在行展开时加载嵌套目录。选择任意深度,即可用该子会话的确切 `{parentSessionId, childSessionId, mode}` 地址打开其对话;也可以使用非当前本地行的行尾箭头在右侧 Sidebar 打开同一地址,并在空间允许时优先使用独立分栏。 本包注册 `dsh-resource://subagentchat/session/?parent=&mode=` 资源与 builtin Sidebar tab 类型。资源直接根据地址保留 child 的 `SessionReference`,不刷新 parent 目录,并在 tab 记录关闭时释放 reference。tab 通过 `sidebar.chat.conversation` 渲染共享 `conversation.content` Factory,把局部 View 固定为 Chat,并省略主 Conversation 的 Header 与宽度控制。 +每个打开的目录菜单宽度均为 360px,外层内边距为 6px,并受视口可用宽度约束。仅当 Team 模式已开启、存在至少一个未删除的共享任务且 footer 扩展可用时,根菜单才显示不可折叠的“子智能体”和“共享任务”标题。“子智能体”标题使用 Users 图标,仅统计直接条目,依次显示已完成、进行中、未运行和外部任务数量;省略零值,分隔符与输入区任务列表一致。外部任务不计入运行中或已完成数量。空的“子智能体”区域仅在同时展示共享任务时显示“暂无子智能体”。Team 模式关闭或没有共享任务时,菜单仅显示 subagent 列表,不显示区域标题、共享任务模块、空状态文案或分隔线;真实的任务记录错误仍以独立警告显示。共享任务上方保留 4px 外边距,不显示分隔线。两个区域标题均保留 7px 图标与文字间距,并在统计数量前额外留出 4px。仅菜单本身滚动,各区域不单独滚动。在兼容 WebKit 的浏览器中,原生 5px 滚动条位于右侧 6px 内边距内,轨道两端内缩,并保留稳定的滚动条空间,使内容在需要滚动时保持原有宽度。其他浏览器保留标准原生滚动条。 + +根会话目录通过 `conversation.session.header.actions.subagent.footer` 提供共享任务扩展。子会话与祖先面包屑目录只显示导航,不显示区域标题。 + +宽布局下,根会话数量触发器在没有运行中 child 时,于 14px 图标位内显示 14px Users 图标,否则显示共享 ongoing loader,不带下拉箭头。标题行变窄时,静态预设文字在宽度不超过 540px 时隐藏,数量文字在不超过 460px 时隐藏。紧凑触发器始终显示 Users 图标,包括 child 运行期间;运行指示器仍在菜单内显示。它与更多及侧边栏控件一致:28×28px 操作区域,无内边距,15px 图标位内显示 15px 图标,使用次级前景色以及 ghost 悬停和按下背景。页头操作在紧凑布局中间距为 8px,宽布局中为 10px。纯图标工具提示提供完整数量。调整宽度不会丢失触发器焦点。subagent 切换器保留描述优先的文本与原有图标。 + ### 浏览目录 -悬停触发器 150ms 后打开目录;指针离开触发器和目录后,经过 120ms 关闭。点击后代数量触发器会固定目录,直到点击外部,或在触发器或树内按 Escape 关闭。点击面包屑标题会导航至对应会话。 +数量、负责人及侧边栏操作的工具提示在悬停 500ms 后显示。悬停文字可见的触发器 150ms 后打开目录;指针离开触发器和目录后,经过 120ms 关闭。点击后代数量触发器会打开并固定目录,或关闭已固定的目录。纯图标数量触发器不会因悬停而打开,包括等待悬停打开期间切换到窄布局的情况。点击可切换固定菜单的开关状态;ArrowDown 会打开并固定菜单,并将焦点移至首行。点击外部或按 Escape 可关闭已固定的菜单。点击会将焦点移入面板,此时 ArrowDown/Home 进入首个树行,ArrowUp/End 进入最后一个树行;Escape 在焦点原本位于面板内时将其送回。点击面包屑标题会导航至对应会话。 -行显示 mode、活动状态与由日志支撑的可选 title;running 使用共享 ongoing loading,最近一个已结束轮次正常完成的 inactive child 使用共享 success 绿点,其他 inactive child 使用共享 idle 灰点。每行都为状态图标预留相同的 14px 列宽,并将较小的圆点居中,使 title 与 loading 状态对齐。紧凑的页头触发器会垂直居中活动图标与数量,并保留 4px 水平间距。尾随列在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。键盘导航:ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树。没有 label 的 one-shot 行回退到其会话 id。只有一行自身的目录加载为空后,它才是已知叶子。 +目录行在 Session id 上方显示创建描述,缺少创建描述时使用可用的 Session title。两者均缺少时,该行仅显示 Session id。本地行使用紧凑的 4px 垂直内边距;可点击内容使用 6px 水平内边距和 7px 间距,与共享任务行对齐,同时保留树的左侧偏移。当前对话在行尾显示装饰性对勾,该对勾不是操作控件;其他本地行保留侧边栏箭头。主标题使用 13px 文字,次级 id 使用 12px。所有行均将描述与 id 换行完整展示,包括在触屏上。running 使用共享 ongoing loading,最近一个已结束轮次正常完成的 inactive child 使用共享 success 绿点,其他 inactive child 使用共享 idle 灰点。每行都为状态图标预留相同的 14px 列宽,并将较小的圆点居中,使 title 与 loading 状态对齐。文字可见的页头触发器会垂直居中活动图标与数量,并保留 4px 水平间距。尾随列在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。键盘导航:ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树。没有 label 的 one-shot 行回退到其会话 id。只有一行自身的目录加载为空后,它才是已知叶子。 ### 续接对话 -确切 parent 存活时,可继续 child 保留普通输入 chrome:child 运行期间输入和 Send 保持可用,因为每条后续消息都会进入 child 的 FIFO inbox,而独立的 Stop 经由 `subagents/interruptByParent` 路由。确切 parent 不可用且 child 未在运行的可继续 child 会选用说明恢复路径的只读编辑器;此类 child 仍在运行期间,selector 会让位给普通编辑器——输入区与 Send 被禁用,但独立的 Stop 保持可用。 +可继续 child 保留普通输入和独立 Stop 操作,不受创建者在线状态影响。提示词经由 `subagents/prompt` 路由,由运行时校验创建地址和在线 Team 根;失败使用普通提示词错误反馈。Stop 经由 `subagents/interruptByUser` 路由。 ### `@` 引用 source @@ -55,7 +61,9 @@ kind: "package-reference" ### 目录派生 -页头谱系 renderer 通过标准 `useSessions` 钩子读取 `projectionsBySession`。renderer 从每个 Session 的共享值中选择 `subagentCatalog`,用于成员关系、展开控件与数量;活动状态优先使用统一 UI status,缺少时使用 Session 摘要;摘要提供标题与用量。展开行读取目标投影,包括普通冷历史。列表行的 `formatStatus` 为 `migration-required` 时,行在展开前即显示“需要迁移”,保留打开历史的操作,但不能展开下级,也不改变子会话的 mode;投影读取需要迁移且缺少目录时,也显示同一提示。打开会话后,历史与 control 投影提供其目录。其他情况下,已有目录仍可使用,包括显式空目录。实时 projection 帧更新所有已加载层级,无需菜单订阅或重复成员查询。其他行在目录缺席、加载中或失败时保持可展开,并在目录就绪且为空后成为已知叶子。外部行始终为叶子,不加载子 Session。 +页头谱系 renderer 通过标准 `useSessions` 钩子读取 `projectionsBySession`。renderer 从每个 Session 的共享值中选择 `subagentCatalog`,用于成员关系、展开控件与数量。活动状态优先使用统一 UI status,缺少时使用 Session 摘要;摘要提供标题与用量。展开行读取目标投影,包括普通冷历史。列表行的 `formatStatus` 为 `migration-required` 时,行在展开前即显示“需要迁移”,保留打开历史的操作,但不能展开下级,也不改变子会话的 mode;投影读取需要迁移且缺少目录时,也显示同一提示。打开会话后,历史与 control 投影提供其目录。其他情况下,已有目录仍可使用,包括显式空目录。实时 projection 帧更新所有已加载层级,无需菜单订阅或重复成员查询。其他行在目录缺席、加载中或失败时保持可展开,并在目录就绪且为空后成为已知叶子。外部行始终为叶子,不加载子 Session。 + +每个页头入口在树下方声明 footer 插槽。可选 UI 通过该插槽加入同一滚动容器;目录负责打开和关闭。共享锚点定位会在内容、窗口大小或浏览器缩放变化时,将面板限制在可见视口内。 打开目录下拉菜单不会请求其根目录。展开子节点目录或重试失败读取时调用 `refreshProjection`;共享投影值的变化会自动更新显示。 @@ -63,15 +71,15 @@ kind: "package-reference" ### 耗时、完成状态与 token -每个可见目录层在包含运行中 child 时独立推进时钟;折叠该层或关闭菜单会释放时钟。token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。`subagentTiming` 投影会累加已结束轮次的耗时,并记录最近一个已结束轮次是否以 `completed` 结束;新轮次开始时会清除该完成状态,直到自身的 `turn/end` 到达。耗时仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。 +每个可见目录层在包含运行中 child 时独立推进时钟;折叠该层或关闭菜单会释放时钟。token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。`subagentTiming` 投影会累加已结束轮次的耗时,并提供 `latestTurnCompleted`,用于成功圆点与已完成数量;新轮次开始时会清除该完成状态,直到自身的 `turn/end` 到达。耗时仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。 ### 编辑器选举 -one-shot child 始终选用只读编辑器。可继续 child 仅在其确切 parent 不可用且 child 未在运行时选用只读编辑器;否则普通编辑器的会话会经 `subagents/prompt` 路由提示词。本包绝不接收宿主上下文,也不调用面向模型的工具。 +one-shot 和模式未知的 child 选用只读编辑器。可继续 child 经由 `subagents/prompt` 路由提示词。本包绝不接收宿主上下文,也不调用面向模型的工具。 模式未知的目录项仍可见、可点击,没有标签时显示子会话 id。读取子历史并确认受支持模式前,输入区保持只读;读取失败在该子会话中报告。 -会话树菜单高亮遵循[标准菜单边缘几何](../../../docs/ui-radius.zh.md#nested-hover);树形展开控件保留独立的左侧空间。 +会话树菜单高亮遵循[标准菜单边缘几何](../../../docs/ui-radius.zh.md#nested-hover)。每个分支展开控件的点击区域覆盖左侧 28px 空间与所在行的完整高度,不与相邻行重叠;仅原位的 12px 图标旋转,点击展开控件不会触发行导航或侧边栏导航。悬停展开控件仅高亮箭头;悬停导航主体或键盘聚焦时仍保留背景高亮。细连接线使用三级边框颜色,与首行图标中心对齐,跨过预留的展开控件空间连接叶子状态图标,末端竖线止于对应图标中心。 diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 18fc1cfe96..c1ff92d01b 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -70,7 +70,8 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:*", "@deepseek-ai/dsh-client-ui-session": "workspace:*", - "@deepseek-ai/dsh-client-ui-workspace": "workspace:*" + "@deepseek-ai/dsh-client-ui-workspace": "workspace:*", + "@deepseek-ai/dsh-agent-team": "workspace:*" }, "files": [ "locale/*.json", diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css index 1abef93da0..820ee6aef4 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css @@ -34,6 +34,8 @@ gap: 4px; min-width: 0; max-width: 244px; + font-size: 14px; + line-height: 20px; color: var(--dsw-alias-label-primary); font-weight: 500; } @@ -94,13 +96,57 @@ } } -.trigger svg, .switcherTrigger svg { transition: transform 120ms ease; } -.triggerOpen { - transform: rotate(180deg); +.trigger .compactIcon { + display: none; +} + +/* The title-row container drops passive preset text at 540px; this action yields later. */ +@container (max-width: 460px) { + .trigger .compactIcon { + display: block; + } + + .trigger .textModeActivity { + display: none; + } + + .trigger .count { + display: none; + } + + .trigger { + box-sizing: border-box; + width: 28px; + height: 28px; + padding: 0; + justify-content: center; + color: var(--dsw-alias-label-secondary); + } + + .trigger .activitySlot, + .trigger .activitySlot > svg { + width: 15px; + height: 15px; + } + + .trigger:focus-visible { + color: var(--dsw-alias-label-secondary); + } + + .trigger:active { + background: var(--dsw-alias-interactive-bg-active); + } + + @media (hover: hover) { + .trigger:hover { + color: var(--dsw-alias-label-secondary); + background: var(--dsw-alias-interactive-bg-hover); + } + } } .menu { @@ -109,45 +155,82 @@ box-sizing: border-box; display: flex; flex-direction: column; - width: 336px; - max-width: min(400px, calc(100vw - 32px)); - max-height: min(560px, calc(100vh - 140px)); - padding: 4px; + width: 360px; + max-width: min(calc(100vw - 32px), var(--dsh-anchored-max-width, 100vw)); + max-height: min(560px, calc(100vh - 140px), var(--dsh-anchored-max-height, 100vh)); + padding: 6px; overflow: hidden; - border-radius: var(--dsw-radius-lg); --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); box-shadow: var(--dsw-elevation-prominent); } -/* The menu skin lives on a pseudo-element: backdrop-filter on the menu itself - would turn it into the containing block for fixed descendants, so the - sidebar-button Tooltip bubbles would be re-anchored into the menu and - clipped by its hidden overflow. */ -.menu::before { - content: ''; - position: absolute; - z-index: -1; - inset: 0; - border-radius: inherit; - background: var(--dsw-specific-menu); - backdrop-filter: var(--dsw-menu-backdrop-filter); -} - .menuBody { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; overflow: auto; + scrollbar-gutter: stable; + --dsh-scrollbar-track-margin: 6px; +} + +/* Keep the shared WebKit scrollbar in the menu's right inset, not its content column. */ +@supports selector(::-webkit-scrollbar) { + .menuBody { + margin-right: calc(-1 * var(--dsh-scrollbar-width)); + } +} + +.sectionHeader { + display: flex; + align-items: flex-start; + gap: 7px; + padding: 4px 7px 8px; +} + +.sectionIcon { + display: grid; + flex: none; + place-items: center; + width: 14px; + height: 24px; + color: var(--dsw-alias-label-tertiary); +} + +.sectionTitle { + flex: none; + margin: 0; + color: var(--dsw-alias-label-primary); + font-size: 13px; + font-weight: 500; + line-height: 24px; +} + +.sectionProgress { + margin-inline-start: 4px; + flex: 1 1 auto; + min-width: 0; + padding-top: 2px; + color: var(--dsw-alias-label-tertiary); + font-size: 13px; + line-height: 20px; + overflow-wrap: anywhere; +} + +.sectionEmpty { + margin: 0; + padding: 0 7px 8px 28px; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; } .node { position: relative; min-width: 0; } -.menuBody > .node { +.menuBody > [role="tree"] > .node { margin-left: -2px; } @@ -155,11 +238,11 @@ position: relative; display: flex; align-items: flex-start; - gap: 6px; + gap: 7px; box-sizing: border-box; width: 100%; - min-height: 44px; - padding: 6px 7px 6px 9px; + min-height: 40px; + padding: 4px 6px 4px 9px; border: 0; border-radius: var(--dsw-radius-md); background: transparent; @@ -176,7 +259,7 @@ } @media (hover: hover) { - .row:hover > .clickarea { + .clickarea:hover { background: var(--dsw-alias-interactive-bg-hover); } } @@ -187,10 +270,10 @@ flex: 1; align-self: stretch; align-items: flex-start; - gap: 6px; + gap: 7px; min-width: 0; - margin: -6px -7px -6px; - padding: 6px 7px; + margin: -4px -6px -4px; + padding: 4px 6px; border-radius: var(--dsw-radius-md); } @@ -211,6 +294,7 @@ } .disclosure { + position: static; display: inline-flex; align-items: center; justify-content: center; @@ -219,12 +303,23 @@ background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; - transition: transform 120ms ease; +} + +/* The row owns this hit area; it ends before the status glyph and never rotates. */ +.disclosure::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 28px; + z-index: 1; } .disclosure svg { width: 12px; height: 12px; + transition: transform 120ms ease; } @media (hover: hover) { @@ -233,7 +328,7 @@ } } -.disclosureOpen { +.disclosureOpen svg { transform: rotate(90deg); } @@ -245,19 +340,24 @@ } .label, +.secondaryLabel, .summary { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + white-space: normal; + overflow-wrap: anywhere; } .label { color: inherit; + font-size: 13px; font-weight: 400; } .currentLabel { - font-weight: 600; + font-weight: 500; +} + +.secondaryLabel { + color: var(--dsw-alias-label-tertiary); } .summary, @@ -294,6 +394,12 @@ margin: 4px 0; } +.currentCheck { + display: grid; + place-items: center; + color: var(--dsw-alias-label-primary); +} + .sidebarButton { display: inline-flex; align-items: center; @@ -319,6 +425,8 @@ } .children { + /* First-line marker center: row inset 4px + half of the 17px status column. */ + --branch-anchor-y: 12.5px; position: relative; margin-left: 16px; padding-left: 3px; @@ -329,7 +437,7 @@ content: ''; position: absolute; left: 0; - border-left: 0.5px solid var(--dsw-alias-border-l2); + border-left: 0.5px solid var(--dsw-alias-border-l3); } .children::before { @@ -349,16 +457,21 @@ .children > .node:last-child::before { bottom: auto; - height: 15px; + height: var(--branch-anchor-y); } .children > .node > .row::before { content: ''; position: absolute; - top: 14px; + top: calc(var(--branch-anchor-y) - 0.25px); left: -3px; width: 12px; - border-top: 0.5px solid var(--dsw-alias-border-l2); + border-top: 0.5px solid var(--dsw-alias-border-l3); +} + +/* A leaf beside expandable siblings keeps their disclosure column before its status marker. */ +.children > .node > .row:has(> .disclosureSpace)::before { + width: 33px; } .notice, diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx index e3d87c3267..183e2d66ab 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type MouseEvent } from 'react' +import { useMemo, useEffect, useId, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import { createPortal } from 'react-dom' import { type SessionProjectionMap, type SessionSummary, @@ -7,12 +7,15 @@ import { import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { - IconChevronDownOutlineRegular, IconChevronRightOutlineRegular, IconRefreshOutlineRegular, StateDot, Tooltip, + IconChevronRightOutlineRegular, IconRefreshOutlineRegular, IconUsersOutlineRegular, + IconCheckOutlineRegular, MenuSurface, StateDot, Tooltip, useAnchoredPosition, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import type { HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import { NS } from './locales.ts' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-token-meter/client' +import type {} from '@deepseek-ai/dsh-agent-team/client' +import type {} from './slots.ts' import css from './SubagentHeaderLineage.module.css' type SubagentCatalogSnapshot = Omit & { @@ -22,15 +25,23 @@ type SubagentCatalogSnapshot = Omit> /** Business actions supplied by the slot registration. */ -export interface SubagentCatalogInjected { +export type SubagentCatalogInjected = { openChild: (address: SubagentAddress) => void openChildAside: (address: SubagentAddress) => void refreshProjection: (parentSessionId: SessionId) => void } +/** Root catalog actions and availability of its optional footer. */ +export type SubagentCatalogActionInjected = SubagentCatalogInjected & { + hooks: { + /** Whether the catalog footer currently has a registered contributor. */ + footerAvailable: HostObservable + } +} + /** Full props for the session-header lineage renderer. */ export type SubagentHeaderLineageProps = - PropsRuntime<'conversation.session.header.lineage'> & SubagentCatalogInjected & PropsLocale + PropsRuntime<'conversation.session.header.lineage'> & InjectFace & PropsLocale interface CatalogRowsProps { parentSessionId: SessionId @@ -197,6 +208,32 @@ function isKnownLeaf(catalog: SubagentCatalogSnapshot | undefined): boolean { return catalog?.state === 'ready' && catalog.entries.length === 0 } +/** Only a local child's last closed, normally completed turn receives the completed marker. */ +function completedEntry(entry: SubagentCatalogSnapshot['entries'][number], summary: SessionSummary | undefined): boolean { + return entry.mode !== 'external' && entry.activity === 'inactive' + && summary?.projectionValues?.subagentTiming?.lastTurnCompleted === true +} + +/** Count direct catalog members without treating external or other inactive entries as completed. */ +function catalogProgress(entries: SubagentCatalogSnapshot['entries'], summaries: CatalogRowsProps['summaries'], t: TranslateNS): string { + let completed = 0 + let active = 0 + let inactive = 0 + let external = 0 + for (const entry of entries) { + if (entry.mode === 'external') external++ + else if (entry.activity === 'running') active++ + else if (completedEntry(entry, summaries[entry.id])) completed++ + else inactive++ + } + return [ + ...completed > 0 ? [t('progress.completed', { count: completed })] : [], + ...active > 0 ? [t('progress.active', { count: active })] : [], + ...inactive > 0 ? [t('progress.inactive', { count: inactive })] : [], + ...external > 0 ? [t('progress.external', { count: external })] : [], + ].join('\u2002·\u2002') +} + /** Render one catalog level and recurse only through explicitly expanded rows. */ function CatalogRows({ parentSessionId, currentSessionId, catalog, catalogs, summaries, expanded, level, @@ -244,17 +281,18 @@ function CatalogRows({ const childLoading = childCatalog === undefined || (childCatalog.state === 'loading' && childCatalog.entries.length === 0) const summary = summaries[entry.id] - const label = entry.label ?? entry.id + const description = entry.label || summary?.title + const primaryLabel = description || entry.id + const secondaryLabel = description ? entry.id : undefined const mode = external ? t('mode.external') : entry.mode === 'unknown' ? t('mode.unknown') : entry.mode === 'one-shot' ? t('mode.oneShot') : t('mode.continuable') - const completed = !external && entry.activity === 'inactive' - && summary?.projectionValues?.subagentTiming?.lastTurnCompleted === true + const completed = completedEntry(entry, summary) const activity = external ? undefined : entry.activity === 'running' ? t('activity.running') : completed ? t('activity.completed') : t('activity.inactive') - const secondary = [summary?.title, requiresOpening ? t('catalog.migrationRequired') : mode, activity] + const secondary = [secondaryLabel, requiresOpening ? t('catalog.migrationRequired') : mode, activity] .filter(value => value !== undefined) .join(' · ') const totalTokens = tokenTotal(summary?.projectionValues?.tokenUsage) @@ -313,7 +351,7 @@ function CatalogRows({ aria-disabled={external || undefined} aria-level={level} aria-current={isCurrent || undefined} - aria-label={[label, secondary, metrics].filter(value => value !== '').join(' ')} + aria-label={[primaryLabel, secondary, metrics].filter(value => value !== '').join(' ')} {...canExpand ? { 'aria-expanded': isExpanded } : {}} className={css.row} onClick={open} @@ -326,7 +364,7 @@ function CatalogRows({ type="button" tabIndex={-1} className={`${css.disclosure} ${isExpanded ? css.disclosureOpen : ''}`} - aria-label={t(isExpanded ? 'branch.collapse' : 'branch.expand', { label })} + aria-label={t(isExpanded ? 'branch.collapse' : 'branch.expand', { label: primaryLabel })} onClick={toggle} > @@ -337,8 +375,11 @@ function CatalogRows({ - {label} - {secondary} + {primaryLabel} + {secondaryLabel !== undefined && ( + {secondaryLabel} + )} + {requiresOpening && {t('catalog.migrationRequired')}} {metrics !== '' && ( @@ -353,14 +394,18 @@ function CatalogRows({ )} )} - {isCurrent || external - ? - : ( - + {isCurrent + ? ( + + + + ) + : external ? : ( + + {t(totalCountKey, { count: directCount })} + + )} + {variant === 'switcher' && } + + + {variant === 'count' && runningCount > 0 && ( + + )} {open && createPortal(( -
    { if (event.pointerType !== 'touch') cancelHoverClose() }} onPointerLeave={scheduleHoverClose} > -
    - { changeOpen(false) }} - t={t} - /> +
    + {hasSharedTasks && ( +
    + +

    {t('section.title')}

    + {progress && {progress}} +
    + )} + {hasSharedTasks && presentedCatalog.state === 'ready' && directCount === 0 && ( +

    {t('section.empty')}

    + )} +
    + { changeOpen(false) }} + t={t} + /> +
    + {props.renderFooter?.()}
    -
    + ), document.body)}
    ) @@ -753,7 +815,8 @@ function CatalogDropdown({ /** Full props for the root-session catalog entry in the header actions band. */ export type SubagentCatalogActionProps = - PropsRuntime<'conversation.session.header.actions'> & SubagentCatalogInjected & PropsLocale + PropsRuntime<'conversation.session.header.actions'> & InjectFace & PropsLocale + & Omit, 'SessionProvider'> /** * Session-header catalog action for root sessions: the descendant count and @@ -763,16 +826,30 @@ export type SubagentCatalogActionProps = * @param props - Session standard props plus the catalog actions and translator. * @returns The count dropdown, or null on a child session. */ -export function SubagentCatalogAction({ - sessionId, useSessions, useSessionStatus, openChild, openChildAside, refreshProjection, t, +export function SubagentCatalogAction(props: SubagentCatalogActionProps) { + const addressedChild = props.useSession(snapshot => snapshot.subagent !== null) + const listedChild = props.useSessions(state => state.byId[props.sessionId]?.origin === 'subagent') + if (addressedChild || listedChild) return null + return +} + +/** Bind task-board visibility only in root conversations. */ +function RootCatalogAction({ + sessionId, useProjection, useSessions, useSessionStatus, useFooterAvailable, + openChild, openChildAside, refreshProjection, t, renderSlot, }: SubagentCatalogActionProps) { - const isChild = useSessions(state => state.byId[sessionId]?.origin === 'subagent') - if (isChild) return null + const hasSharedTasks = useProjection('agentTeam', team => team?.enabled === true + && team.tasks.some(task => task.status !== 'deleted')) + const hasTaskFailure = useProjection('agentTeam', team => team?.enabled === true && team.failure !== undefined) + const footerAvailable = useFooterAvailable(value => value) return ( renderSlot('conversation.session.header.actions.subagent.footer', {})} useSessions={useSessions} useSessionStatus={useSessionStatus} openChild={openChild} diff --git a/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx b/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx index 07feb84f55..aacbea6afe 100644 --- a/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx +++ b/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx @@ -4,7 +4,7 @@ import css from './SubagentReadOnlyComposer.module.css' /** Why a catalog-addressed conversation cannot accept human input. */ export interface SubagentReadOnlyMatch { - reason: 'one-shot' | 'parent-unavailable' | 'unknown' + reason: 'one-shot' | 'unknown' } /** Full chain props after the read-only subagent selector accepts the owner currency. */ @@ -24,7 +24,7 @@ export function SubagentReadOnlyComposer({
    {t(oneShot ? 'readonly.oneShot.title' : 'readonly.title')} - {t(matched.reason === 'unknown' ? 'readonly.unknown.body' : oneShot ? 'readonly.oneShot.body' : 'readonly.body')} + {t(oneShot ? 'readonly.oneShot.body' : 'readonly.unknown.body')}
    ) diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index 117b0b0605..a84992246d 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -3,7 +3,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { SubagentCatalogAction, SubagentHeaderLineage, type SubagentCatalogInjected } from './SubagentHeaderLineage.tsx' +import { SubagentCatalogAction, SubagentHeaderLineage, type SubagentCatalogInjected, type SubagentCatalogActionInjected } from './SubagentHeaderLineage.tsx' import { SubagentReadOnlyComposer, type SubagentReadOnlyMatch, } from './SubagentReadOnlyComposer.tsx' @@ -15,6 +15,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' import { en, ja, es, fr, de, ru, pt, NS, zh, type SubagentKey } from './locales.ts' +export type * from './slots.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -24,7 +25,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } export type { - SubagentCatalogActionProps, SubagentCatalogInjected, SubagentHeaderLineageProps, + SubagentCatalogActionProps, SubagentCatalogInjected, SubagentCatalogActionInjected, SubagentHeaderLineageProps, } from './SubagentHeaderLineage.tsx' export type { SubagentReadOnlyComposerProps, SubagentReadOnlyMatch, @@ -33,19 +34,13 @@ export type { /** Required services for subagent presentation and navigation. */ export const inject = ['sessions', 'uiWorkspace', 'slots', 'locale', 'sidebarRight'] -/** Claim the composer for one-shot history or an unavailable continuation owner. */ +/** Claim the composer when the child mode does not allow follow-ups. */ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null { const subagent = owner.session?.subagent if (subagent === undefined || subagent === null) return null if (subagent.address.mode === 'unknown') return { reason: 'unknown' } if (subagent.address.mode === 'one-shot') return { reason: 'one-shot' } - // Until a Host summary establishes parent availability, keep the normal - // disabled composer instead of claiming that the parent is offline. - if (subagent.parentAvailable !== false) return null - // A RUNNING parent-offline continuable child keeps the default composer: - // its input is disabled there, but the same primary Stop stays available so - // the child can be interrupted. Once it stops, this takeover returns. - return owner.session?.running === true ? null : { reason: 'parent-unavailable' } + return null } /** @@ -71,6 +66,10 @@ export function apply(ctx: ClientContext): void { void ctx.sessions.refreshProjections(parentSessionId) }, }) + const footerAvailable = { + getSnapshot: () => ctx.slots.entries('conversation.session.header.actions.subagent.footer').length > 0, + subscribe: (listener: () => void) => ctx.slots.subscribe('conversation.session.header.actions.subagent.footer', listener), + } ctx.slots.inject( 'conversation.session.header.lineage', () => ctx.slots.register({ @@ -84,11 +83,13 @@ export function apply(ctx: ClientContext): void { () => ctx.slots.register({ name: 'conversation.session.header.actions', id: 'subagent-catalog', - // Between the preset label (-10) and background tasks (20), after Team - // navigation (-20). + // Between the preset label (-10) and background tasks (20). order: 10, locale: NS, - inject: catalogActions, + inject: (sessionId): SubagentCatalogActionInjected => ({ + ...catalogActions(sessionId), hooks: { footerAvailable }, + }), + children: { 'conversation.session.header.actions.subagent.footer': { kind: 'list', scope: 'session' } }, }, SubagentCatalogAction), ) ctx.slots.inject( diff --git a/packages/client/ui-subagent/src/client/locales.ts b/packages/client/ui-subagent/src/client/locales.ts index 897b65ece0..27a5b61391 100644 --- a/packages/client/ui-subagent/src/client/locales.ts +++ b/packages/client/ui-subagent/src/client/locales.ts @@ -5,6 +5,12 @@ export const NS = 'subagent' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'section.title': '子智能体', + 'section.empty': '暂无子智能体', + 'progress.completed': '{count} 已完成', + 'progress.active': '{count} 进行中', + 'progress.inactive': '{count} 未运行', + 'progress.external': '{count} 外部任务', 'duration.seconds': '{seconds}秒', 'duration.minutes': '{minutes}分{seconds}秒', 'duration.hours': '{hours}小时{minutes}分{seconds}秒', @@ -46,11 +52,16 @@ export const zh = { 'readonly.oneShot.title': '一次性子智能体记录', 'readonly.title': '此子智能体暂时只读', 'readonly.oneShot.body': '一次性任务不支持后续消息,可在这里查看完整执行记录。', - 'readonly.body': '父会话当前不在线,重新打开父会话后即可继续发送消息。', } as const /** English dictionary, key-identical to the Chinese source of truth. */ export const en: Record = { + 'section.title': 'Subagents', + 'section.empty': 'No subagents', + 'progress.completed': '{count} completed', + 'progress.active': '{count} in progress', + 'progress.inactive': '{count} not running', + 'progress.external': '{count} external', 'duration.seconds': '{seconds}s', 'duration.minutes': '{minutes}m {seconds}s', 'duration.hours': '{hours}h {minutes}m {seconds}s', @@ -92,7 +103,6 @@ export const en: Record = { 'readonly.oneShot.title': 'One-shot subagent record', 'readonly.title': 'This subagent is read-only for now', 'readonly.oneShot.body': 'One-shot tasks do not accept follow-ups; review the full execution record here.', - 'readonly.body': 'The parent session is offline; reopen it to continue sending messages.', } /** Key domain of the `subagent` namespace (zh is the source of truth). */ @@ -100,6 +110,12 @@ export type SubagentKey = keyof typeof zh /** Japanese copy, checked against the English keys. */ export const ja = { + 'section.title': 'サブエージェント', + 'section.empty': 'サブエージェントはありません', + 'progress.completed': '{count}件完了', + 'progress.active': '{count}件進行中', + 'progress.inactive': '{count}件停止中', + 'progress.external': '{count}件外部タスク', 'duration.seconds': '{seconds}秒', 'duration.minutes': '{minutes}分{seconds}秒', 'duration.hours': '{hours}時間{minutes}分{seconds}秒', @@ -141,11 +157,16 @@ export const ja = { 'readonly.oneShot.title': '単発サブエージェントの記録', 'readonly.title': 'このサブエージェントは現在読み取り専用です', 'readonly.oneShot.body': '単発タスクは追加のメッセージを受け付けません。ここで実行記録の全体を確認できます。', - 'readonly.body': '親セッションがオフラインです。再度開いてメッセージの送信を続けてください。', } satisfies Record /** Spanish dictionary, checked against the English keys. */ export const es = { + 'section.title': 'Subagentes', + 'section.empty': 'No hay subagentes', + 'progress.completed': '{count} completado', + 'progress.active': '{count} en progreso', + 'progress.inactive': '{count} sin ejecutar', + 'progress.external': '{count} externos', 'duration.seconds': '{seconds}s', 'duration.minutes': '{minutes}m {seconds}s', 'duration.hours': '{hours}h {minutes}m {seconds}s', @@ -187,11 +208,16 @@ export const es = { 'readonly.oneShot.title': 'Registro de subagente de una sola vez', 'readonly.title': 'Este subagente es de solo lectura por ahora', 'readonly.oneShot.body': 'Las tareas únicas no aceptan seguimientos; revise el registro de ejecución completo aquí.', - 'readonly.body': 'La sesión para padres está fuera de línea; Vuelva a abrirlo para continuar enviando mensajes.', } satisfies Record /** French dictionary, checked against the English keys. */ export const fr = { + 'section.title': 'Sous-agents', + 'section.empty': 'Aucun sous-agent', + 'progress.completed': '{count} terminé', + 'progress.active': '{count} en cours', + 'progress.inactive': '{count} à l’arrêt', + 'progress.external': '{count} externes', 'duration.seconds': '{seconds}s', 'duration.minutes': '{minutes}m {seconds}s', 'duration.hours': '{hours}h {minutes}m {seconds}s', @@ -233,11 +259,16 @@ export const fr = { 'readonly.oneShot.title': 'Enregistrement de sous-agent unique', 'readonly.title': 'Ce sous-agent est actuellement en lecture seule', 'readonly.oneShot.body': 'Les tâches ponctuelles n’acceptent pas de messages de suivi ; consultez leur journal d’exécution complet ici.', - 'readonly.body': 'La session parente est hors ligne ; rouvrez-la pour continuer à envoyer des messages.', } satisfies Record /** German dictionary, checked against the English keys. */ export const de = { + 'section.title': 'Subagenten', + 'section.empty': 'Keine Subagenten', + 'progress.completed': '{count} abgeschlossen', + 'progress.active': '{count} in Bearbeitung', + 'progress.inactive': '{count} nicht aktiv', + 'progress.external': '{count} extern', 'duration.seconds': '{seconds}s', 'duration.minutes': '{minutes}m {seconds}s', 'duration.hours': '{hours}h {minutes}m {seconds}s', @@ -279,11 +310,16 @@ export const de = { 'readonly.oneShot.title': 'Einmaliger Subagentendatensatz', 'readonly.title': 'Dieser Subagent ist derzeit schreibgeschützt', 'readonly.oneShot.body': 'Einmalige Aufgaben akzeptieren keine Folgeaufgaben; Sehen Sie sich hier den vollständigen Ausführungsbericht an.', - 'readonly.body': 'Die übergeordnete Sitzung ist offline. Öffnen Sie es erneut, um weiterhin Nachrichten zu senden.', } satisfies Record /** Russian dictionary, checked against the English keys. */ export const ru = { + 'section.title': 'Субагенты', + 'section.empty': 'Нет субагентов', + 'progress.completed': '{count} завершено', + 'progress.active': '{count} в процессе', + 'progress.inactive': '{count} не выполняются', + 'progress.external': '{count} внешних', 'duration.seconds': '{seconds}s', 'duration.minutes': '{minutes}m {seconds}s', 'duration.hours': '{hours}h {minutes}m {seconds}s', @@ -325,11 +361,16 @@ export const ru = { 'readonly.oneShot.title': 'Разовая запись субагента', 'readonly.title': 'Этот субагент пока доступен только для чтения.', 'readonly.oneShot.body': 'Одноразовые задачи не допускают последующих действий; просмотреть полную запись выполнения можно здесь.', - 'readonly.body': 'Родительский сеанс находится в автономном режиме; откройте его повторно, чтобы продолжить отправку сообщений.', } satisfies Record /** Portuguese dictionary, checked against the English keys. */ export const pt = { + 'section.title': 'Subagentes', + 'section.empty': 'Nenhum subagente', + 'progress.completed': '{count} concluído', + 'progress.active': '{count} em andamento', + 'progress.inactive': '{count} sem execução', + 'progress.external': '{count} externos', 'duration.seconds': '{seconds}s', 'duration.minutes': '{minutes}m {seconds}s', 'duration.hours': '{hours}h {minutes}m {seconds}s', @@ -371,5 +412,4 @@ export const pt = { 'readonly.oneShot.title': 'Registro de subagente único', 'readonly.title': 'Este subagente é somente leitura por enquanto', 'readonly.oneShot.body': 'Tarefas únicas não aceitam acompanhamentos; revise o registro completo da execução aqui.', - 'readonly.body': 'A sessão pai está offline; reabra-o para continuar enviando mensagens.', } satisfies Record diff --git a/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx b/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx index ddc5bed7f9..6d9c3fcbb5 100644 --- a/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx +++ b/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx @@ -136,10 +136,7 @@ export function ConversationSlotPanel({ || session.running const shellPhase = active ? 'active' : session.promptAttempted ? 'engaging' : 'blank' const summaryBlank = useSessions(state => state.byId[sessionId]?.blank) - const parentAvailabilityPending = session.subagent?.address.mode === 'continuable' - && session.subagent.parentAvailable === undefined const settling = (shellPhase === 'blank' && session.openState === 'loading' && summaryBlank !== true) - || parentAvailabilityPending const hero = shellPhase === 'blank' && (session.openState === 'open' || summaryBlank === true) const phase = settling ? 'settling' : hero ? 'hero' : 'active' return renderFactorySlot('conversation.content', { variant: 'embedded', phase, hero }, { diff --git a/packages/client/ui-subagent/src/client/slots.ts b/packages/client/ui-subagent/src/client/slots.ts new file mode 100644 index 0000000000..a3e379e62f --- /dev/null +++ b/packages/client/ui-subagent/src/client/slots.ts @@ -0,0 +1,13 @@ +/** Extension position below the root conversation's subagent catalog. */ + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + /** Content below the root Session's subagent tree. */ + 'conversation.session.header.actions.subagent.footer': { + kind: 'list' + scope: 'session' + } + } +} + +export {} diff --git a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts index d75c9d8639..b5006a277b 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts @@ -2,7 +2,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { Context } from '@deepseek-ai/cordis' import { stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import type { SessionListState, SessionSnapshot, SessionSummary, } from '@deepseek-ai/dsh-api-session-controller/client' @@ -12,7 +12,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { - SubagentHeaderLineage, type SubagentCatalogInjected, + SubagentHeaderLineage, type SubagentCatalogInjected, type SubagentCatalogActionInjected, } from '../src/client/SubagentHeaderLineage.tsx' import { SubagentReadOnlyComposer, type SubagentReadOnlyMatch, @@ -108,6 +108,19 @@ describe('apply', () => { expect(inject).toEqual(['sessions', 'uiWorkspace', 'slots', 'locale', 'sidebarRight']) }) + it('keeps the root catalog extension available when optional Sidebar services arrive', async () => { + const { ctx } = await fullBench(FAMILY) + onTestFinished(() => ctx.fiber.dispose()) + ctx.slots.register({ name: 'conversation.session.header.actions.subagent.footer', id: 'test-content' }, () => null) + const registerResource = vi.fn(() => () => {}) + const registerTab = vi.fn(() => () => {}) + ctx.provide('resources', { register: registerResource } as never) + ctx.provide('sidebarRightTabs', { register: registerTab } as never) + await vi.waitFor(() => { expect(registerResource).toHaveBeenCalledOnce() }) + expect(registerTab).toHaveBeenCalledOnce() + expect(ctx.slots.entries('conversation.session.header.actions.subagent.footer')).toHaveLength(1) + }) + it('registers catalog actions and selects read-only subagent composers from session facts', async () => { const { ctx, face } = await fullBench(FAMILY) const catalogEntry = ctx.slots.entries('conversation.session.header.lineage') @@ -139,7 +152,20 @@ describe('apply', () => { const actionEntry = ctx.slots.entries('conversation.session.header.actions') .find(entry => entry.options.id === 'subagent-catalog')! expect(actionEntry.options.order).toBe(10) - expect(actionEntry.inject).toBe(catalogEntry.inject) + const actionProps = (actionEntry.inject as (id: SessionId) => SubagentCatalogActionInjected)(sid('parent')) + const source = actionProps.hooks.footerAvailable + const changed = vi.fn() + const unsubscribe = source.subscribe(changed) + expect(source.getSnapshot()).toBe(false) + const remove = ctx.slots.register({ + name: 'conversation.session.header.actions.subagent.footer', id: 'test-footer', + }, () => null) + expect(source.getSnapshot()).toBe(true) + await vi.waitFor(() => { expect(changed).toHaveBeenCalledOnce() }) + remove() + expect(source.getSnapshot()).toBe(false) + await vi.waitFor(() => { expect(changed).toHaveBeenCalledTimes(2) }) + unsubscribe() const composerEntry = ctx.slots.entries('conversation.composer') .find(entry => entry.component === SubagentReadOnlyComposer)! @@ -156,19 +182,14 @@ describe('apply', () => { }) expect(select(owner(undefined))).toBeNull() expect(select(owner(null))).toBeNull() - expect(select(owner({ address: { ...address, mode: 'one-shot' }, parentAvailable: true }))) + expect(select(owner({ address: { ...address, mode: 'one-shot' } }))) .toEqual({ reason: 'one-shot' }) // One-shot stays read-only even while running: it has no stop action. - expect(select(owner({ address: { ...address, mode: 'one-shot' }, parentAvailable: true }, true))) + expect(select(owner({ address: { ...address, mode: 'one-shot' } }, true))) .toEqual({ reason: 'one-shot' }) - expect(select(owner({ address: { ...address, mode: 'unknown' }, parentAvailable: true }))) + expect(select(owner({ address: { ...address, mode: 'unknown' } }))) .toEqual({ reason: 'unknown' }) expect(select(owner({ address }))).toBeNull() - expect(select(owner({ address, parentAvailable: true }))).toBeNull() - expect(select(owner({ address, parentAvailable: false }))) - .toEqual({ reason: 'parent-unavailable' }) - // A RUNNING parent-offline continuable yields the default composer, whose - // disabled input still carries the primary Stop; stopped, it takes back over. - expect(select(owner({ address, parentAvailable: false }, true))).toBeNull() + expect(select(owner({ address }, true))).toBeNull() }) }) diff --git a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx index 3ac3a7b325..030730f6e7 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -7,6 +7,7 @@ import type { SessionListState, SessionSummary, SessionSnapshot, } from '@deepseek-ai/dsh-api-session-controller/client' import type { SubagentAddress, SubagentCatalogEntry } from '@deepseek-ai/dsh-subagent/client' +import type { TeamProjection, TeamTaskId, TeamTaskView } from '@deepseek-ai/dsh-agent-team/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import { @@ -20,6 +21,7 @@ afterEach(() => { cleanup() vi.useRealTimers() vi.restoreAllMocks() + vi.unstubAllGlobals() }) const PARENT = 'parent' as SessionId @@ -27,11 +29,25 @@ const CHILD = 'child' as SessionId const GRANDCHILD = 'grandchild' as SessionId const t: SubagentHeaderLineageProps['t'] = makeTranslate(zh) +type HeaderCatalogProps = SubagentHeaderLineageProps + & Pick + type CatalogFixtureEntry = Entry extends SubagentCatalogEntry ? Omit & { activity: 'running' | 'inactive' } : never -type CatalogFixture = { entries: readonly CatalogFixtureEntry[]; parentAvailable: boolean; state: SessionListState['projectionsBySession'][SessionId]['state']; error: SessionListState['projectionsBySession'][SessionId]['error'] } +type CatalogFixture = { + team?: TeamProjection + entries: readonly CatalogFixtureEntry[] + state: SessionListState['projectionsBySession'][SessionId]['state'] + error: SessionListState['projectionsBySession'][SessionId]['error'] +} + +const sharedTask: TeamTaskView = { + id: 'shared-task' as TeamTaskId, revision: 1, subject: 'Plan shared work', description: 'Prepare the implementation', + status: 'pending', blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [], +} +const sharedTeam: TeamProjection = { enabled: true, tasks: [sharedTask] } function catalog(over: Partial = {}): CatalogFixture { return { @@ -45,7 +61,6 @@ function catalog(over: Partial = {}): CatalogFixture { label: 'reviewer', activity: 'inactive', }, ], - parentAvailable: true, state: 'ready', error: null, ...over, @@ -79,7 +94,10 @@ function props( phase: 'ready', projectionsBySession: Object.fromEntries(Object.entries(catalogs).map(([id, catalog]) => [id, { state: catalog.state, error: catalog.error, - values: { subagentCatalog: catalog.entries.map(({ activity: _activity, ...entry }) => ({ ...entry, createdAt: 1 })) }, + values: { + ...catalog.team === undefined ? {} : { agentTeam: catalog.team }, + subagentCatalog: catalog.entries.map(({ activity: _activity, ...entry }) => ({ ...entry, createdAt: 1 })), + }, }])) , } satisfies SessionListState function useSessions(select: (snapshot: SessionListState) => T): T { @@ -118,15 +136,17 @@ function props( ...standard, sessionId: PARENT, useSessions, + useFooterAvailable: (select: (available: boolean) => T): T => select(true), useSessionStatus: (select: (snapshot: SessionStatusSnapshot) => T): T => select(statuses), useSession: (select: (snapshot: SessionSnapshot) => T): T => select(snapshot), openChild: vi.fn(), openChildAside: vi.fn(), refreshProjection: vi.fn(), + renderSlot: vi.fn(() => null), lineageSessionId: PARENT, displayTitle: 'Parent title', t, - } satisfies SubagentHeaderLineageProps + } satisfies HeaderCatalogProps } function summary(id: SessionId, updatedAt: number): SessionSummary { @@ -146,14 +166,34 @@ function hoverCatalog(trigger: HTMLElement): void { act(() => { vi.advanceTimersByTime(150) }) } +function enableTooltips(): void { + class TooltipObserver implements ResizeObserver { + constructor(private readonly callback: ResizeObserverCallback) {} + observe(target: Element): void { + const size = { inlineSize: 160, blockSize: 30 } + this.callback([{ + target, borderBoxSize: [size], contentBoxSize: [size], devicePixelContentBoxSize: [size], contentRect: new DOMRect(), + }], this) + } + unobserve(): void {} + disconnect(): void {} + } + vi.stubGlobal('ResizeObserver', TooltipObserver) + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true }))) +} + /** * Both header seats as the real header composes them: the breadcrumb switcher * (child sessions, lineage slot) and the actions-band catalog entry (root * sessions) — exactly one renders per form. The actions seat sees the same * session the lineage renders. */ -function HeaderCatalog(props: SubagentHeaderLineageProps) { - const actionProps: SubagentCatalogActionProps = { ...props, sessionId: props.lineageSessionId } +function HeaderCatalog(props: HeaderCatalogProps) { + const actionProps: SubagentCatalogActionProps = { + ...props, sessionId: props.lineageSessionId, + useProjection: ((_key: 'agentTeam', selector: (value: TeamProjection | undefined) => boolean) => + props.useSessions(state => selector(state.projectionsBySession[props.lineageSessionId]?.values.agentTeam))) as SubagentCatalogActionProps['useProjection'], + } return ( <> @@ -163,6 +203,211 @@ function HeaderCatalog(props: SubagentHeaderLineageProps) { } describe('SubagentHeaderLineage', () => { + it.each([ + { dictionary: zh, total: '2 个子智能体', running: '1 个子智能体,正在运行' }, + { dictionary: en, total: '2 subagents', running: '1 subagent running' }, + ])('keeps the total accessible name separate from the running description: $total', ({ dictionary, total, running }) => { + const input = props(catalog()) + const view = render() + const trigger = screen.getByRole('button', { name: total }) + expect(trigger.textContent).toBe(total) + expect(document.getElementById(trigger.getAttribute('aria-describedby')!)?.textContent).toBe(running) + view.rerender() + expect(screen.getByRole('button', { name: total }).getAttribute('aria-describedby')).toBeNull() + }) + + it('shows a non-collapsible heading with direct-member progress beside shared tasks', () => { + const finished = 'finished' as SessionId + const paused = 'paused' as SessionId + const external = 'external' as SessionId + const entries: CatalogFixture['entries'] = [ + { id: CHILD, mode: 'continuable', label: 'worker', activity: 'running' }, + { id: finished, mode: 'continuable', label: 'finished', activity: 'inactive' }, + { id: paused, mode: 'continuable', label: 'paused', activity: 'inactive' }, + { id: external, mode: 'external', label: 'external', activity: 'running' }, + ] + const summaries: Record = { + [CHILD]: { ...summary(CHILD, 1), running: true, projectionValues: { subagentTiming: { settledMs: 0, lastTurnCompleted: true } } }, + [finished]: { ...summary(finished, 1), projectionValues: { subagentTiming: { settledMs: 0, lastTurnCompleted: true } } }, + [paused]: { ...summary(paused, 1), projectionValues: { subagentTiming: { settledMs: 0, lastTurnCompleted: false } } }, + [external]: { + ...summary(external, 1), running: true, projectionValues: { subagentTiming: { settledMs: 0, lastTurnCompleted: true } }, + }, + } + const input = props(catalog({ entries, team: sharedTeam }), { + [CHILD]: catalog({ entries: [{ id: GRANDCHILD, mode: 'continuable', label: 'nested', activity: 'running' }] }), + }, summaries) + const view = render() + fireEvent.click(screen.getByRole('button', { name: '4 个子智能体' })) + const header = view.container.ownerDocument.querySelector('[data-subagent-section-header]')! + expect(within(header).getByRole('heading', { name: '子智能体' })).toBeTruthy() + expect(within(header).queryByRole('button')).toBeNull() + expect(header.textContent).toBe('子智能体1 已完成\u2002·\u20021 进行中\u2002·\u20021 未运行\u2002·\u20021 外部任务') + fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子智能体' })) + expect(header.textContent).toContain('1 进行中') + expect(header.textContent).not.toContain('2 进行中') + view.rerender() + expect(header.textContent).toBe('Subagents1 completed\u2002·\u20021 in progress\u2002·\u20021 not running\u2002·\u20021 external') + }) + + it('omits zero progress counts from a completed-only catalog header', () => { + const input = props(catalog({ + team: sharedTeam, entries: [{ id: CHILD, mode: 'continuable', label: 'finished', activity: 'inactive' }], + }), {}, { + [CHILD]: { ...summary(CHILD, 1), projectionValues: { subagentTiming: { settledMs: 0, lastTurnCompleted: true } } }, + }) + render() + fireEvent.click(screen.getByRole('button', { name: '1 个子智能体' })) + const header = document.querySelector('[data-subagent-section-header]')! + expect(header.textContent).toBe('子智能体1 已完成') + }) + + it.each([ + { name: 'absent Team projection', team: undefined, footer: true }, + { name: 'disabled Team mode', team: { ...sharedTeam, enabled: false }, footer: true }, + { name: 'empty tasks', team: { enabled: true, tasks: [] }, footer: true }, + { name: 'deleted-only tasks', team: { enabled: true, tasks: [{ ...sharedTask, status: 'deleted' }] }, footer: true }, + { name: 'failure without tasks', team: { enabled: true, tasks: [], failure: 'Invalid task record' }, footer: true }, + { name: 'unavailable task footer', team: sharedTeam, footer: false }, + ] satisfies { name: string; team: TeamProjection | undefined; footer: boolean }[])( + 'omits section headings from a members-only catalog with $name', ({ team, footer }) => { + const input = props(catalog({ ...team === undefined ? {} : { team } })) + render( select(footer)} />) + fireEvent.click(screen.getByRole('button', { name: /\d+ 个子智能体$/ })) + expect(screen.getByRole('tree')).toBeTruthy() + expect(screen.queryByRole('heading')).toBeNull() + expect(document.querySelector('[data-subagent-section-header]')).toBeNull() + expect(screen.queryByText(zh['section.empty'])).toBeNull() + }, + ) + + it('shows the description above the Session id and opens the row without a description toggle', () => { + const description = 'Check the construction and all bounds. '.repeat(20).trim() + const input = props(catalog({ + entries: [{ id: CHILD, mode: 'continuable', label: description, activity: 'running' }], + })) + render() + fireEvent.click(screen.getByRole('button', { name: /\d+ 个子智能体$/ })) + const row = screen.getByRole('treeitem', { name: new RegExp(CHILD) }) + const primary = within(row).getByText(description) + const id = within(row).getByText(CHILD) + expect(primary.compareDocumentPosition(id) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect(row.getAttribute('aria-label')).toMatch(/^Check the construction/) + expect(primary.tagName).toBe('SPAN') + expect(primary.getAttribute('aria-expanded')).toBeNull() + expect(primary.querySelector('svg')).toBeNull() + expect(within(row).queryByRole('button', { name: /描述/ })).toBeNull() + fireEvent.click(primary) + expect(input.openChild).toHaveBeenCalledExactlyOnceWith({ parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' }) + expect(screen.queryByRole('treeitem')).toBeNull() + }) + + it.each([undefined, ''])('keeps a single ID line when the creation description is %s', (label) => { + const input = props(catalog({ + entries: [{ id: CHILD, mode: 'one-shot', ...label === undefined ? {} : { label }, activity: 'running' }], + }), {}, { [CHILD]: { ...summary(CHILD, 1), running: true } }) + render() + fireEvent.click(screen.getByRole('button', { name: /\d+ 个子智能体$/ })) + const row = screen.getByRole('treeitem', { name: new RegExp(CHILD) }) + expect(within(row).getAllByText(CHILD)).toHaveLength(1) + expect(row.getAttribute('aria-label')).toMatch(new RegExp(`^${CHILD}`)) + }) + + it('hides an empty Team entry but keeps shared tasks and errors reachable without children', () => { + const input = props(catalog({ entries: [] })) + const initial = input.useSessions(state => state) + const mode: { enabled: boolean; tasks: TeamTaskView[]; failure?: string } = { enabled: true, tasks: [] } + const values = { ...initial.projectionsBySession[PARENT]!.values, agentTeam: mode } + const current = { + ...input, + useSessions: (select: (snapshot: SessionListState) => T): T => select({ + ...initial, projectionsBySession: { [PARENT]: { state: 'ready', error: null, values } }, + }), + renderSlot: () => mode.tasks.some(task => task.status !== 'deleted') + ?
    Tasks
    + : mode.failure === undefined ? null :
    {mode.failure}
    , + } + const view = render() + expect(screen.queryByRole('button')).toBeNull() + mode.tasks = [{ + id: 'task-1' as TeamTaskId, revision: 1, subject: 'Plan work', description: 'Prepare tasks before spawning workers', + status: 'pending', blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [], + }] + view.rerender() + fireEvent.click(screen.getByRole('button', { name: '0 个子智能体' })) + const tree = screen.getByRole('tree') + const board = screen.getByRole('region', { name: 'Shared tasks' }) + expect(tree.compareDocumentPosition(board) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect(screen.getByRole('heading', { name: zh['section.title'] })).toBeTruthy() + expect(screen.getByText(zh['section.empty'])).toBeTruthy() + expect(screen.getAllByRole('dialog')).toHaveLength(1) + mode.tasks = [] + view.rerender() + expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.queryByRole('button')).toBeNull() + mode.tasks = [{ ...sharedTask, status: 'deleted' }] + view.rerender() + expect(screen.queryByRole('button')).toBeNull() + expect(screen.queryByText(zh['section.empty'])).toBeNull() + mode.tasks = [] + mode.failure = 'Invalid task record' + view.rerender() + fireEvent.click(screen.getByRole('button', { name: '0 个子智能体' })) + expect(screen.getByRole('dialog')).toBeTruthy() + expect(screen.queryByRole('heading')).toBeNull() + expect(screen.queryByText(zh['section.empty'])).toBeNull() + expect(document.querySelector('[data-subagent-section-header]')).toBeNull() + view.rerender( select(false)} />) + expect(screen.queryByRole('button')).toBeNull() + mode.enabled = false + view.rerender() + expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.queryByRole('button')).toBeNull() + }) + + it.each([false, true])('keeps child navigation without reading or rendering a task board (listed: %s)', (listed) => { + const input = props(catalog({ team: sharedTeam }), {}, listed ? { + [CHILD]: { ...summary(CHILD, 1), origin: 'subagent', parentId: PARENT }, + } : {}, { parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' }) + const readProjection = vi.fn((): never => { throw new Error('child task-board projection read') }) + const readFooter = vi.fn((): never => { throw new Error('child footer subscription') }) + const renderFooter = vi.fn(() =>
    Tasks
    ) + render(<> + + + ) + fireEvent.click(screen.getByRole('button', { name: /切换子智能体/u })) + expect(screen.getByRole('tree')).toBeTruthy() + expect(screen.queryByRole('region', { name: 'Shared tasks' })).toBeNull() + expect(screen.queryByRole('heading')).toBeNull() + expect(screen.queryByText(zh['section.empty'])).toBeNull() + expect(document.querySelector('[data-subagent-section-header]')).toBeNull() + expect(readProjection).not.toHaveBeenCalled() + expect(readFooter).not.toHaveBeenCalled() + expect(renderFooter).not.toHaveBeenCalled() + }) + + it('leaves task-board keys alone and dismisses the shared panel from outside focus', () => { + const input = props(catalog()) + render(<>