Merge origin/master into feat/preset-scoped-schedule-tools

Resolutions:
- `docs/subsystems/schedule.*` and `packages/client/ui-schedule/README.*` keep
  this branch's shipped-composition text with master's newer wording (Rules and
  Records tabs, the icon-button linked-session layout).
- `docs/event-producer-consumer.*` regenerated by `gen-doc-graphs`.
This commit is contained in:
Chinesezjc committed 2026-09-30 13:49:30 +08:00
commit 3e5231bc6f
246 files changed
+4823 -1312

No files matched your search

@@ -9,8 +9,8 @@
en: 725f773bb350218e
zh: dd8fbc09ef48208d
/agent-note-declarative-agent-presets-and-retained-revisions/decision:
en: 2a2489303fc76fc0
zh: 1a760e9a5edfccc8
en: 53fd72bf4bbe4eb3
zh: 406eee6e4f818e7c
/agent-note-declarative-agent-presets-and-retained-revisions/alternatives-considered:
en: 09f40eab4659e9f6
zh: 7a70da0d0ae6845b
@@ -16,7 +16,7 @@ The registry owns each revision's scope and Loader tree. Agents link their own s
The Web editor and `agent_preset` tool accept only child plugin YAML. Saving writes the active profile's user patch, preserving display metadata and unrelated configuration. Revision checks and profile locking prevent overwriting concurrent saves; validation rejects a higher-priority override that would defeat the edit. Persistence and activation outcomes are reported separately. Tool writes require approval or full access because configuration executes in the Host.
Session data continues to record the preset identity. Restart resolves that identity against current configuration and rejects a missing definition. Old executable revisions are process-local, not serialized. The saved user default and blank-session selection remain; `selectedDefault` is what an unnamed new session resolves, and the Client's Developer tools preference decides only whether the choice is shown.
Session data continues to record the preset identity. Restart resolves that identity against current configuration and rejects a missing definition. Old executable revisions are process-local, not serialized. New sessions without an explicit preset resolve `selectedDefault`; Client choice filtering and default correction follow [the preset UI policy](../../../../packages/client/ui-agent-preset/README.md).
## Alternatives considered
@@ -16,7 +16,7 @@ Preset 目录重复承担 Cordis 配置的所有权。独立的发现、元数
Web 编辑器与 `agent_preset` 工具只接受子插件 YAML。保存写入当前 profile 的用户补丁,保留显示元数据和无关配置。版本检查和 profile 锁防止覆盖并发保存;验证拒绝会使编辑失效的更高优先级覆盖。持久化与激活结果分别报告。配置在 Host 中执行,因此工具写入需要批准或完全访问权限。
会话数据继续记录 preset 标识。重启时根据当前配置解析该标识,定义缺失则拒绝恢复。旧的可执行代际仅保留在进程内,不会序列化。保存的用户默认值与空白会话选择保持不变:未指定 preset 的新会话解析 `selectedDefault`,客户端的开发者工具偏好只决定是否展示这项选择。
会话数据继续记录 preset 标识。重启时根据当前配置解析该标识,定义缺失则拒绝恢复。旧的可执行代际仅保留在进程内,不会序列化。未显式指定 preset 的新会话解析 `selectedDefault`;客户端选项过滤和默认值修正遵循[预设 UI 策略](../../../../packages/client/ui-agent-preset/README.zh.md)。
## Alternatives considered
@@ -9,11 +9,11 @@
en: e22ee243b1503319
zh: b53c1a932d547f55
/agent-note-shared-developer-tool-settings/decision:
en: 6b94e4c856dbd92a
zh: e923b1118dfad7b6
en: b579ed62e2846939
zh: 4d5c121599413807
/agent-note-shared-developer-tool-settings/alternatives-considered:
en: df1b80e5b8d2a178
zh: 0136d16fc44d57bf
/agent-note-shared-developer-tool-settings/consequences:
en: c1e05aebc88fc12c
zh: 0ace5bced7b32437
en: 3f50fdc4b4233266
zh: 8647e00b474da639
@@ -12,7 +12,7 @@ Diagnostic views, preset selection and changed-file summaries add complexity to
The settings-owned `ctx.configForms.developerTools` preference uses a shared entry form and exposes one accepted `ui-settings.enabled` preference. The initial default-off choice reduces routine-task complexity; [Developer tools default on after preference resolution](2026-09-20-developer-tools-default-on.md) supersedes that default and owns startup enablement. General Settings offers the same switch on Web and desktop. The existing settings transport owns validation, ordered writes, persistence and recovery; loopback Web and desktop follow Host acceptance. Remote Web owns one browser-local observable shared by all consumers until reload, with no Host writes.
Off hides the Trajectory View, hides tool Inspect actions when no visible View declares tool-call inspection, hides the conversation View tab bar while fewer than two Views are available, hides the new-session preset chip, and omits the changed-files card and its summary read. Views registered by other plugins stay available. Turning it off while Trajectory is active activates Chat. The preset composition, saved default, Session events and explicit delivery cards remain unchanged. This preference is presentation policy, not Host authorization.
Off hides the Trajectory View, hides tool Inspect actions when no visible View declares tool-call inspection, hides the conversation View tab bar while fewer than two Views are available, hides built-in PTC and Minimal choices, and omits the changed-files card and its summary read. Views registered by other plugins stay available. Turning it off while Trajectory is active activates Chat. Existing Session presets, Session events and explicit delivery cards remain unchanged. Preset choices, default correction and unapplied selections follow [the preset UI policy](../../../../packages/client/ui-agent-preset/README.md). This preference is presentation policy, not Host authorization.
Plugin assembly translates the shared preference into component-specific capabilities (`showCodeDiff`, the new-session preset chip's `developerTools`, and `interactivePreview`). Conversation supplies the inspection callback using the visible target's `toolCallFocus`; Chat does not name the target. The builtin HTML renderer uses DOMPurify for complete-document sanitization and parser-mutation defenses. Off preserves root styling, removes active documents, resource hints, SVG animation and all `href` attributes, and mounts an iframe with no sandbox permissions and a CSP denying scripts and external resources. It reads no related files. On retains the opaque scripted Blob renderer and its bounded related-file reads. Switching modes replaces the browsing context and disposes pending reads. The [document-preview decision](../architecture/2026-09-08-document-preview-operations.md) and [filesystem-read authority](../architecture/2026-09-09-workspace-file-read-authority.md) remain authoritative for advanced rendering and Host read access.
@@ -26,4 +26,4 @@ Plugin assembly translates the shared preference into component-specific capabil
Basic HTML sacrifices JavaScript, external and related resources, and link navigation; inline styles and data images remain available. Advanced mode retains normal browser networking and does not gain parent-origin, popup, form or top-navigation privileges. The setting does not constrain third-party preview implementations or revoke Host filesystem authority.
Component and settings tests cover defaulting, schema rejection, accepted-state updates, chip disclosure, changed-file visibility and iframe disposal. The recorded-session [document preview](../../../../apps/web/tests/document-preview.e2e.ts) and [changed-files](../../../../apps/web/tests/changed-files-turn.e2e.ts) scenarios exercise the real settings control and the shared renderer. The browser scaffold uses the shipped default unless a scenario specifies a preference. Settings chrome, document preview and changed-files scenarios explicitly disable developer tools; together they cover hidden diagnostic controls, static previews, and switching back to Chat. Refused Host writes recover accepted state and show the localized retry message. Native Electron execution remains unverified.
Component and settings tests cover defaulting, schema rejection, accepted-state updates, preset choice visibility, changed-file visibility and iframe disposal. The recorded-session [document preview](../../../../apps/web/tests/document-preview.e2e.ts) and [changed-files](../../../../apps/web/tests/changed-files-turn.e2e.ts) scenarios exercise the real settings control and the shared renderer. The browser scaffold uses the shipped default unless a scenario specifies a preference. Settings chrome, document preview and changed-files scenarios explicitly disable developer tools; together they cover hidden diagnostic controls, static previews, and switching back to Chat. Refused Host writes recover accepted state and show the localized retry message. Native Electron execution remains unverified.
@@ -12,7 +12,7 @@ Status: implemented
设置域内的 `ctx.configForms.developerTools` 偏好对象使用共享条目表单,提供一个已接受的 `ui-settings.enabled` 偏好。最初默认关闭的选择用于降低日常任务的复杂度;[偏好解析后默认开启开发者工具](2026-09-20-developer-tools-default-on.zh.md)取代该默认值并负责启动时的启用策略。Web 和桌面端的通用设置提供同一个开关。现有设置传输负责验证、有序写入、持久化和恢复;回环 Web 和桌面端跟随 Host 接受的状态。远程 Web 持有一个所有消费者共享的浏览器本地可观察值,刷新后重置,不发送 Host 写入。
关闭时隐藏 Trajectory View,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,可见 View 少于两个时隐藏会话 View 标签栏,隐藏新会话预设入口,并省略改动文件卡片及其摘要读取。其他插件注册的 View 保持可用。在 Trajectory 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。
关闭时隐藏 Trajectory View,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,可见 View 少于两个时隐藏会话 View 标签栏,隐藏内置 PTC 和极简选项,并省略改动文件卡片及其摘要读取。其他插件注册的 View 保持可用。在 Trajectory 中关闭开关会激活 Chat。已有 Session 的预设、Session 事件和显式交付卡片保持不变。预设选项、默认值修正和尚未应用的选择遵循[预设 UI 策略](../../../../packages/client/ui-agent-preset/README.zh.md)。此偏好是展示策略,不是 Host 授权。
插件组装层将共享偏好转换为组件自身的能力(`showCodeDiff`、新会话预设入口读取的 `developerTools`,以及 `interactivePreview`)。Conversation 根据可见目标的 `toolCallFocus` 提供检查回调;Chat 不再指定目标名称。内置 HTML 渲染器使用 DOMPurify 清理完整文档并防护解析变异。关闭时保留根节点样式,移除主动文档、资源提示、SVG 动画及所有 `href` 属性,再放入不授予沙箱权限的 iframe,并通过 CSP 禁止脚本和外部资源。不读取关联文件。开启时保留不透明源的脚本 Blob 渲染器及其有界关联文件读取。切换模式会替换浏览上下文并清理待处理读取。[文档预览决策](../architecture/2026-09-08-document-preview-operations.zh.md)和[文件系统读取权限](../architecture/2026-09-09-workspace-file-read-authority.zh.md)继续定义高级渲染与 Host 读取访问。
@@ -26,4 +26,4 @@ Status: implemented
基础 HTML 放弃 JavaScript、外部与关联资源和链接导航;行内样式与 data 图片仍可用。高级模式保留正常浏览器网络能力,不增加父源、弹窗、表单或顶层导航权限。此设置不约束第三方预览实现,也不撤销 Host 文件系统读取权限。
组件和设置测试覆盖默认值、schema 拒绝、已接受状态更新、入口折叠、改动文件展示和 iframe 清理。基于已记录 Session 的[文档预览](../../../../apps/web/tests/document-preview.e2e.ts)与[改动文件](../../../../apps/web/tests/changed-files-turn.e2e.ts)场景验证真实设置控件及共用渲染器。浏览器脚手架使用产品默认值,除非场景指定偏好。设置外壳、文档预览和改动文件场景显式关闭开发者工具;这些场景共同覆盖诊断控件隐藏、静态预览和切回 Chat。Host 拒绝写入后会恢复已接受状态,并显示本地化重试提示。原生 Electron 执行尚未验证。
组件和设置测试覆盖默认值、schema 拒绝、已接受状态更新、预设选项展示、改动文件展示和 iframe 清理。基于已记录 Session 的[文档预览](../../../../apps/web/tests/document-preview.e2e.ts)与[改动文件](../../../../apps/web/tests/changed-files-turn.e2e.ts)场景验证真实设置控件及共用渲染器。浏览器脚手架使用产品默认值,除非场景指定偏好。设置外壳、文档预览和改动文件场景显式关闭开发者工具;这些场景共同覆盖诊断控件隐藏、静态预览和切回 Chat。Host 拒绝写入后会恢复已接受状态,并显示本地化重试提示。原生 Electron 执行尚未验证。
@@ -0,0 +1,31 @@
# Bilingual-pair consistency record for 2026-09-28-session-corpus-performance.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/testing/2026-09-28-session-corpus-performance.md
/agent-note-performance-baselines-for-large-session-corpora:
en: e84801a4e9d16b7a
zh: 2fb8bc57ed14ea0d
/agent-note-performance-baselines-for-large-session-corpora/problem:
en: d2a5a514f5c0aec3
zh: becf99e4e8ae7b8f
/agent-note-performance-baselines-for-large-session-corpora/decision:
en: f8d67782422a1f40
zh: 296d52c006d41661
/agent-note-performance-baselines-for-large-session-corpora/decision/workload:
en: ae4682fdb06d3192
zh: 6a48d412f15bedfa
/agent-note-performance-baselines-for-large-session-corpora/decision/corpus-construction:
en: 1310a89c351e5bfe
zh: e6386ae10b893c2d
/agent-note-performance-baselines-for-large-session-corpora/decision/measured-endpoints:
en: b4d81575ac574f6f
zh: f4f291ab7ce83094
/agent-note-performance-baselines-for-large-session-corpora/calibration-evidence:
en: f934c95748dcbfe0
zh: df6a68286c21510f
/agent-note-performance-baselines-for-large-session-corpora/alternatives-considered:
en: 4eaa4666262a1aee
zh: 7e2eb44b94bad05d
/agent-note-performance-baselines-for-large-session-corpora/consequences:
en: f68218a31c17e0e6
zh: 2ffd8943e41774bf
@@ -0,0 +1,80 @@
# Agent Note: Performance baselines for large Session corpora
Status: implemented
English | [中文](2026-09-28-session-corpus-performance.zh.md)
## Problem
Existing gates measure one long Session: [opening it](2026-09-04-session-open-performance-gate.md) or [continuing it](2026-09-06-backend-continuation-performance.md). Listing, content search, and fork scale with the whole corpus or with the source Session's length, and no gate covered a user with many long Sessions. A corpus of uniform small Sessions would understate the work, because real corpora are heavy-tailed.
## Decision
The [session-corpus benchmark](../../../../benchmarks/session-corpus/session-corpus.bench.ts) measures three Web Host endpoints over synthetic current-generation Zstandard corpora. No product implementation changes.
### Workload
On 2026-09-28, one developer's local DSH home held 1,650 Sessions in 88 project directories. Aggregate counts were extracted from the newest generation of each Session; no content, identity, or path left that machine. [corpus-shape.ts](../../../../benchmarks/session-corpus/corpus-shape.ts) records 22 upper-quantile anchors for three dimensions: logical events (compact delta rows expanded), decompressed bytes, and turns.
| Quantile | Events | Logical bytes | Turns |
|---|---:|---:|---:|
| p50 | 53 | 86,195 | 1 |
| p90 | 709 | 1,849,503 | 3 |
| p99 | 7,229 | 8,479,554 | 18 |
| max | 84,467 | 48,139,877 | 2,181 |
Rank `r` of an `n`-Session corpus takes the first anchor at or above quantile `(r + 1) / n`, so every quantile of the synthetic corpus is at least the measured one. All dimensions share the rank: a long Session is long in every dimension. At 1,000 Sessions this upper-step sampling adds 16% events and 14% bytes over the measured quantile function; coarser anchors added up to 40%.
Other measured aggregates also set the fixture: 19 Sessions per project directory, one subagent child per six Sessions (measured 288 of 1,650), a 20,000-character system prompt (the measured median largest event is 21.9 KB), 3.9 characters per streamed delta, and 1.6 events per Zstandard frame. Turns contain tool steps (assistant message, tool call, tool result) until the event target is reached; a secant search over payload characters then reaches the byte target from below. Text comes from a Zipf-distributed synthetic vocabulary. Its stored compression ratio is 2.8, below the measured 3.4, so the fixture stores more compressed bytes than the source corpus.
### Corpus construction
Two preparation processes, one per CPU of a standard hosted runner, split the anchors into byte-balanced groups. Each authors one event body per anchor, compresses it once, and saves it with the anchor's projection record. A seed process then writes each Session as its own header frame followed by its anchor's body. Frame compression and file writes run eight at a time: each pending compression holds a native Zstandard context, and unbounded compression peaked at 7.7 GB RSS, more than a standard hosted runner provides. Sessions sharing an anchor differ only in header identity, creation time, project directory, and parent link. Creation time is a fixed permutation of rank. List order follows each body's last prompt time, which Sessions sharing an anchor share; it does not affect list cost. Events carry the fields that `Session.append` records, compact streams come from the production `AssistantStreamAccumulator`, and rows are encoded with the persistence package's `eventLines`. Before saving, each preparation process replays its authored bodies through `Session.create`, as `sessionQuery.readSession` does.
The projection cache is part of the list endpoint: each cold row carries cached projection values. Preparation folds each anchor once through the public `sessionProjectionCache.coldSnapshot` write-back. The seed then writes per-record documents for every Session with `serializeRecord`, rebinding identity to each header. Writing through the domain API costs one fsync per record, which took 89 s for 10,000 Sessions. The list worker requires every returned row to carry projections, so a storage layout change fails the benchmark instead of silently removing that work.
### Measured endpoints
Workers are compiled plain-Node processes, each started fresh against a seeded root. They compose the production Session store, projection registry, JSONL persistence, SQLite Session query with `openAt: first-search` on an in-memory index, JSON storage and projection cache, the Web projection set, AgentLoop, and the real `SessionController`. Transport lookups, upload registration, default model selection, and the Workspace registry are inert providers, because no measured path depends on them.
| Case | Corpus | Samples | Endpoint |
|---|---|---:|---|
| List | 3,000 | 3 | Host boot; first `session.list`; repeated `session.list` in the same process |
| Content search | 1,000 | 1 | First `session.search`, including the index build; a second query with a different term |
| Fork | 1,000, separate root | 3, then 1 | `session.fork` return for rank midpoints of ten equal length strata and rank 988; then rank 999 once |
The file must finish within five minutes on standard hosted CI, including seeding, and its last case asserts that limit. Search over 1,000 Sessions and one fork of the longest Session take about 170 s of it, so each has one sample. The list corpus is the largest that fits the remainder. Budgets enforce medians of list boot, first, and repeat, and of the fork median over strata and rank 990; search and the longest fork enforce their single samples. Search and fork use separate 1,000-Session roots, so fork children never reach the search index. Rank 988 is the highest top-level rank at the measured p99 (7,229 events); forking a subagent child adds a lineage lookup, and rank 990 already falls in the 99.3% anchor. Peak RSS of the list, search, and fork processes has a budget with the shared 1.25 headroom and no time scale. Each child's deadline equals the file limit, and each test's deadline outlasts its sequential children, so a failed case reaps every child before the scratch root is removed.
## Calibration evidence
Budgets use standard two-CPU hosted CI expectations (Linux x64, Node 24.21, PR #5403), rounded above the higher recorded median across the AMD EPYC 7763 and EPYC 9V74 runners and multiplied by the shared 1.25 headroom. Samples varied by up to 15% within one run; the runner models differed by up to 25% per endpoint. The hosted-to-reference ratio is 2.4 to 3.4, above the shared reference scale of 2, so reference-machine scaling would reject ordinary hosted runs.
| Endpoint (7763 / 9V74) | Hosted medians | Expectation | Budget |
|---|---|---:|---:|
| List 3,000: boot | 2,317.8 / 2,204.5 ms | 2,400 ms | 3,000 ms |
| List 3,000: first | 3,228.7 / 2,633.6 ms | 3,300 ms | 4,125 ms |
| List 3,000: repeat | 2,636.4 / 2,235.1 ms | 2,700 ms | 3,375 ms |
| Search 1,000: first (one sample) | 144,359 / 135,065 ms | 145,000 ms | 181,250 ms |
| Search 1,000: repeat (one sample) | 3,426.3 / 2,704 ms | 3,500 ms | 4,375 ms |
| Fork: strata median | 95.5 / 73.5 ms | 100 ms | 125 ms |
| Fork: p99, rank 988 (7763 only) | 1,066.2 ms | 1,100 ms | 1,375 ms |
| Fork: longest (one sample) | 19,162.1 / 20,326.7 ms | 20,400 ms | 25,500 ms |
| Peak RSS: list / search / fork | 742.7 / 1,197.8 / 745.1 MB (higher model) | 750 / 1,200 / 750 MB | 938 / 1,500 / 938 MB |
With seeding in one process, the complete file took 294.5 s on the EPYC 7763 runner (85 s seeding, 26 s listing, 149 s searching, 34 s forking) and 266.6 s on the 9V74. With parallel preparation it took 276.8 s on the 7763: the two preparation processes took 59 s and 65 s, because they share two CPUs with Zstandard and garbage-collection threads, and writing the corpora took 4.6 s. The other cases varied by less than 3% between runs on one runner model. Each additional 1,000 listed Sessions costs about 9 s, so a larger list corpus would leave less than a tenth of the limit for runner variation.
On Apple M5 Pro with Node 26.5, isolated runs measured list 1,000 at 311–358 ms first, list 5,000 at 1,364–1,603 ms first, search at 57 s first, and fork of the longest Session at 6.0–6.4 s. A 10,000-Session list took 3.2–5.4 s for the first call with 1.9 GB peak RSS.
## Alternatives considered
- **Sanitized copies of real Sessions.** Rejected: benchmarks never use recorded user material.
- **Authoring every Session through `Session.append`.** Rejected: per-append validation and freezing, repeated per Session, made seeding take minutes.
- **Omitting the projection cache.** Rejected: rows would lack the values the Web list shows, which understates list work.
- **A separate CI job.** Rejected: benchmarks share one idle standard runner. This file is limited to five minutes, and the job timeout rises from 15 to 20 minutes.
- **Listing 5,000 Sessions with five samples, and two list sizes.** Rejected: with the other cases the file took 6.6 minutes on standard hosted CI.
## Consequences
The gate covers corpus-scale list, search, and fork costs that single-Session gates cannot see. On standard hosted CI it already shows that content search spends 139 s building its index on first use, that the first list took 5.1 s at 5,000 Sessions in calibration, and that forking the longest Session takes 17.5 to 20.3 s. Seeding writes about 1.5 GB.
Exclusions: the corpus is current-generation only, although most real Sessions are stored in older generations, which list revision hashing and migration-on-read would add. Only 22 distinct bodies exist, so search indexes repeated text. Fork runs without Agent presets or Workspace attachment. Calls are in-process, without Typert transport or a browser. Filesystem caches are not evicted, so cold means a fresh process, not cold storage.
@@ -0,0 +1,80 @@
# Agent Note: 大型 Session 语料的性能基线
Status: implemented
[English](2026-09-28-session-corpus-performance.md) | 中文
## 问题
现有门禁只测量单个长 Session:[打开](2026-09-04-session-open-performance-gate.zh.md)或[续聊](2026-09-06-backend-continuation-performance.zh.md)。列表、内容搜索和 fork 的成本随整个语料或源 Session 长度增长,此前没有门禁覆盖拥有大量长 Session 的用户。由均匀小 Session 组成的语料会低估工作量,因为真实语料呈重尾分布。
## 决定
[session-corpus 基准](../../../../benchmarks/session-corpus/session-corpus.bench.ts)在合成的当前代 Zstandard 语料上测量三个 Web Host 端点。产品实现不做任何改动。
### 工作负载
2026-09-28,一名开发者的本地 DSH home 在 88 个项目目录中保存了 1,650 个 Session。统计只从每个 Session 的最新代中提取聚合计数;内容、身份和路径都没有离开那台机器。[corpus-shape.ts](../../../../benchmarks/session-corpus/corpus-shape.ts) 为三个维度记录 22 个上分位锚点:逻辑事件数(展开紧凑 delta 行)、解压字节数和轮次数。
| 分位 | 事件数 | 逻辑字节数 | 轮次数 |
|---|---:|---:|---:|
| p50 | 53 | 86,195 | 1 |
| p90 | 709 | 1,849,503 | 3 |
| p99 | 7,229 | 8,479,554 | 18 |
| max | 84,467 | 48,139,877 | 2,181 |
`n` 个 Session 的语料中,排名 `r` 取不低于分位 `(r + 1) / n` 的第一个锚点,因此合成语料的每个分位都不低于实测分位。所有维度共享同一排名:长 Session 在每个维度上都长。在 1,000 个 Session 时,这种上阶梯采样比实测分位函数多出 16% 的事件和 14% 的字节;更粗的锚点最多多出 40%。
其他实测聚合值也用于确定夹具:每个项目目录 19 个 Session,每六个 Session 中一个 subagent 子会话(实测为 1,650 个中的 288 个),20,000 字符的 system prompt(实测最大事件的中位数为 21.9 KB),每个流式 delta 3.9 个字符,每个 Zstandard 帧 1.6 个事件。轮次中持续加入工具步骤(assistant 消息、工具调用、工具结果)直到达到事件目标;随后对负载字符数做割线搜索,从下方达到字节目标。文本来自 Zipf 分布的合成词表。其存储压缩比为 2.8,低于实测的 3.4,因此夹具存储的压缩字节多于源语料。
### 语料构建
两个准备进程(对应标准托管运行器的每个 CPU)把锚点分成字节均衡的两组。每个进程为每个锚点生成一份事件主体,只压缩一次,并与该锚点的投影记录一起保存。随后播种进程把每个 Session 写成独立的 header 帧加上其锚点的主体。帧压缩和文件写入每次并发八个:每个待完成的压缩都持有一个原生 Zstandard 上下文,不加限制的压缩峰值 RSS 达到 7.7 GB,超过标准托管运行器的内存。共享锚点的 Session 只在 header 身份、创建时间、项目目录和父链接上不同。创建时间是排名的固定置换。列表顺序跟随各主体的最后提示时间,共享锚点的 Session 也共享该时间;这不影响列表成本。事件携带 `Session.append` 记录的字段,紧凑流来自生产 `AssistantStreamAccumulator`,行通过持久化包的 `eventLines` 编码。保存之前,每个准备进程按 `sessionQuery.readSession` 的方式,通过 `Session.create` 回放其生成的主体。
投影缓存属于列表端点:每个冷行都携带缓存的投影值。准备过程通过公开的 `sessionProjectionCache.coldSnapshot` 回写,为每个锚点折叠一次。之后播种进程用 `serializeRecord` 为每个 Session 写入逐记录文档,并把身份重新绑定到各自的 header。通过 domain API 写入时每条记录一次 fsync,10,000 个 Session 耗时 89 s。列表 worker 要求返回的每一行都携带投影,因此存储布局变化会使基准失败,而不是静默去掉这部分工作。
### 测量端点
worker 是编译后的纯 Node 进程,每次都针对已播种根目录全新启动。它们组合生产 Session store、投影注册表、JSONL 持久化、使用 `openAt: first-search` 和内存索引的 SQLite Session query、JSON 存储和投影缓存、Web 投影集合、AgentLoop 以及真实的 `SessionController`。传输查找、上传注册、默认模型选择和 Workspace 注册表使用惰性提供者,因为没有被测路径依赖它们。
| 用例 | 语料 | 样本数 | 终点 |
|---|---|---:|---|
| 列表 | 3,000 | 3 | Host 启动;首次 `session.list`;同一进程中重复的 `session.list` |
| 内容搜索 | 1,000 | 1 | 首次 `session.search`,包括索引构建;使用不同词项的第二次查询 |
| Fork | 1,000,独立根目录 | 3,然后 1 | 十个等长度分层的中点排名和排名 988 的 `session.fork` 返回;然后排名 999 一次 |
此文件(包括播种)必须在标准托管 CI 上五分钟内完成,其最后一个用例断言该上限。1,000 个 Session 的搜索和最长 Session 的一次 fork 约占其中 170 s,因此两者各取一个样本。列表语料是剩余时间内能容纳的最大规模。预算约束列表启动、首次和重复的中位数,以及 fork 分层中位数和排名 990 的中位数;搜索和最长 fork 约束各自的单个样本。搜索和 fork 使用各自独立的 1,000 个 Session 根目录,因此 fork 子会话不会进入搜索索引。排名 988 是实测 p99(7,229 个事件)上最高的顶层排名;fork subagent 子会话会增加一次谱系查找,而排名 990 已落入 99.3% 锚点。列表、搜索和 fork 进程的峰值 RSS 设有预算,使用共享的 1.25 余量,不做时间缩放。每个子进程的截止时间等于文件上限,每个测试的截止时间长于其顺序子进程之和,因此失败的用例会在删除临时根目录之前回收所有子进程。
## 校准证据
预算使用标准双 CPU 托管 CI 的期望值(Linux x64、Node 24.21、PR #5403),取高于 AMD EPYC 7763 与 EPYC 9V74 运行器中较高记录中位数的整数,再乘以共享的 1.25 余量。同一次运行内的样本差异最多 15%;两种运行器型号在单个端点上最多相差 25%。托管与参考机器的比值为 2.4 到 3.4,高于共享的参考缩放系数 2,因此按参考机器缩放会拒绝正常的托管运行。
| 端点(7763 / 9V74) | 托管中位数 | 期望值 | 预算 |
|---|---|---:|---:|
| 列表 3,000:启动 | 2,317.8 / 2,204.5 ms | 2,400 ms | 3,000 ms |
| 列表 3,000:首次 | 3,228.7 / 2,633.6 ms | 3,300 ms | 4,125 ms |
| 列表 3,000:重复 | 2,636.4 / 2,235.1 ms | 2,700 ms | 3,375 ms |
| 搜索 1,000:首次(一个样本) | 144,359 / 135,065 ms | 145,000 ms | 181,250 ms |
| 搜索 1,000:重复(一个样本) | 3,426.3 / 2,704 ms | 3,500 ms | 4,375 ms |
| Fork:分层中位数 | 95.5 / 73.5 ms | 100 ms | 125 ms |
| Fork:p99,排名 988(仅 7763) | 1,066.2 ms | 1,100 ms | 1,375 ms |
| Fork:最长(一个样本) | 19,162.1 / 20,326.7 ms | 20,400 ms | 25,500 ms |
| 峰值 RSS:列表 / 搜索 / fork | 742.7 / 1,197.8 / 745.1 MB(较高型号) | 750 / 1,200 / 750 MB | 938 / 1,500 / 938 MB |
单进程播种时,整个文件在 EPYC 7763 运行器上耗时 294.5 s(播种 85 s,列表 26 s,搜索 149 s,fork 34 s),在 9V74 上耗时 266.6 s。并行准备后,它在 7763 上耗时 276.8 s:两个准备进程分别耗时 59 s 和 65 s,因为它们与 Zstandard 和垃圾回收线程共享两个 CPU;写入语料耗时 4.6 s。其他用例在同一运行器型号的多次运行之间差异小于 3%。列表语料每增加 1,000 个 Session 约多花 9 s,因此更大的列表语料会让留给运行器差异的时间不足上限的十分之一。
在 Apple M5 Pro 和 Node 26.5 上的隔离运行中,1,000 个 Session 的首次列表为 311–358 ms,5,000 个 Session 的首次列表为 1,364–1,603 ms,首次搜索为 57 s,fork 最长 Session 为 6.0–6.4 s。10,000 个 Session 的首次列表耗时 3.2–5.4 s,峰值 RSS 为 1.9 GB。
## 考虑过的替代方案
- **脱敏复制真实 Session。** 已否决:基准从不使用录制的用户材料。
- **通过 `Session.append` 生成每个 Session。** 已否决:逐次追加的验证和冻结在每个 Session 上重复,使播种耗时数分钟。
- **省略投影缓存。** 已否决:行会缺少 Web 列表显示的值,从而低估列表工作量。
- **单独的 CI 任务。** 已否决:基准共享一台空闲的标准运行器。此文件限制在五分钟内,任务超时从 15 分钟提高到 20 分钟。
- **以五个样本列出 5,000 个 Session,并测两种列表规模。** 已否决:加上其他用例后,此文件在标准托管 CI 上耗时 6.6 分钟。
## 后果
该门禁覆盖单 Session 门禁看不到的语料规模列表、搜索和 fork 成本。在标准托管 CI 上,它已经显示:内容搜索在首次使用时花 139 s 构建索引,校准时 5,000 个 Session 的首次列表耗时 5.1 s,fork 最长 Session 耗时 17.5 到 20.3 s。播种写入约 1.5 GB。
排除项:语料只包含当前代,而大多数真实 Session 以旧代存储,这会增加列表的修订哈希和读取时迁移。只有 22 份不同主体,因此搜索索引的是重复文本。Fork 不挂载 Agent preset,也不附加 Workspace。调用在进程内完成,不经过 Typert 传输或浏览器。不驱逐文件系统缓存,因此“冷”指全新进程,而不是冷存储。
+1 -1
View File
@@ -196,7 +196,7 @@ jobs:
name: node 24 / benchmarks
# Wall-clock budgets need an otherwise idle runner, so this job runs the
# benchmark lane alone instead of joining a concurrent gate aggregate.
timeout-minutes: 15
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
with:
+108 -10
View File
@@ -249,13 +249,25 @@ describe('web e2e: agent-preset selection', () => {
await rm(fixtureRoot, { recursive: true, force: true })
})
it('starts on the Standard default with the roster editable in Settings', async () => {
it('clears an unapplied hidden pick and creates a Standard session with the roster editable in Settings', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-hero'))
await connectFreshWorkspace(page, scaffold.workspaceCwd)
await page.getByRole('button', { name: 'Standard mode', exact: true }).waitFor({ timeout: 10_000 })
expect(await livePreset(scaffold)).toBeUndefined()
await page.getByRole('button', { name: 'Standard mode', exact: true }).click()
await page.getByRole('menuitem', { name: /^Minimal mode/ }).click()
await page.getByRole('button', { name: 'Minimal mode', exact: true }).waitFor()
await openSettings(page, 'en')
const dialog = page.getByRole('dialog', { name: 'Settings' })
const codingTools = dialog.getByRole('switch', { name: 'Show coding view' })
await codingTools.click()
await expect.poll(() => codingTools.getAttribute('aria-checked')).toBe('false')
await dialog.getByRole('button', { name: 'Close' }).last().click()
await connectFreshWorkspace(page, scaffold.workspaceCwd)
await page.getByRole('button', { name: 'Standard mode', exact: true }).waitFor({ timeout: 10_000 })
await expect.poll(() => livePreset(scaffold)).toBe('standard')
await openSettings(page, 'en')
await codingTools.click()
await expect.poll(() => codingTools.getAttribute('aria-checked')).toBe('true')
await dialog.getByRole('button', { name: 'Agent presets' }).click()
await dialog.getByRole('button', { name: 'New task default: Standard mode' }).waitFor({ timeout: 10_000 })
expect(await dialog.getByRole('switch').count()).toBe(0)
@@ -343,7 +355,7 @@ describe('web e2e: agent-preset selection', () => {
await writeComposerDraft(page, composer, '')
}, 90_000)
it('keeps the saved default composing sessions while Developer tools only gate the choice', async () => {
it('resets the hidden default to Standard while preserving the blank session until the user switches it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-disabled'))
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('standard')
@@ -359,21 +371,38 @@ describe('web e2e: agent-preset selection', () => {
await expect.poll(() => developerTools.getAttribute('aria-checked')).toBe('false')
await dialog.getByRole('button', { name: 'Close' }).last().click()
// The gate hides the choice; the blank task keeps its saved composition.
await expect.poll(() => page.getByRole('button', { name: / mode$/ }).count()).toBe(0)
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal')
await page.getByRole('button', { name: 'Minimal mode', exact: true }).click()
await page.getByRole('menu').waitFor()
expect(await page.getByRole('menuitem', { name: /^PTC mode|^Minimal mode/ }).count()).toBe(0)
expect(await page.getByRole('menuitem', { name: /^Standard mode|^Creator mode/ }).count()).toBe(2)
await page.keyboard.press('Escape')
await expect.poll(async () => (await scaffold.ctx.agentPresets.remoteExportList()).presets.find(preset => preset.isDefault)?.id).toBe('standard')
const reuse = page.waitForResponse('**/api/session/create')
await page.getByRole('button', { name: 'New session', exact: true }).last().click()
const reused = await reuse
expect(reused.request().postDataJSON()).toHaveProperty('payload.args.request.sessionId')
expect(await reused.json()).toMatchObject({ result: { ok: true, value: { agentPreset: 'minimal' } } })
await page.reload()
await page.getByRole('button', { name: 'Minimal mode', exact: true }).click()
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal')
await page.getByRole('menuitem', { name: /^Standard mode/ }).click()
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('standard')
await openSettings(page, 'en')
const reopened = page.getByRole('dialog', { name: 'Settings' })
await reopened.getByRole('button', { name: 'General', exact: true }).click()
const reopenedDeveloperTools = reopened.getByRole('switch', { name: 'Show coding view' })
await expect.poll(() => reopenedDeveloperTools.getAttribute('aria-checked')).toBe('false')
await reopenedDeveloperTools.click()
await expect.poll(() => reopenedDeveloperTools.getAttribute('aria-checked')).toBe('true')
await reopened.getByRole('button', { name: 'Agent presets' }).click()
await reopened.getByRole('button', { name: 'New task default: Minimal mode' }).waitFor({ timeout: 10_000 })
await reopened.getByRole('button', { name: 'New task default: Standard mode' }).waitFor({ timeout: 10_000 })
await reopened.getByRole('button', { name: 'Set as new task default: Minimal mode' }).waitFor({ timeout: 10_000 })
await reopened.getByRole('button', { name: 'Close' }).last().click()
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal')
await page.getByRole('button', { name: 'Minimal mode' }).waitFor({ timeout: 10_000 })
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('standard')
await page.getByRole('button', { name: 'Standard mode' }).waitFor({ timeout: 10_000 })
})
it('labels a resumed session with the preset it was created under', async () => {
@@ -401,3 +430,72 @@ describe('web e2e: agent-preset selection', () => {
expect(tripwire.warnings).toEqual([])
})
})
it.each([false, true])('starts Creator from Plugins with Coding Tools=%s without changing drafts or defaults', async (developerTools) => {
const scaffold = await launchWebScaffold({ developerTools, agentPresets: { default: 'standard' } })
let browser: Browser | undefined
try {
browser = await chromium.launch()
const page = await newEnglishPage(browser)
const tripwire = watchConsole(page)
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-creator'))
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
const creator = scaffold.ctx.agents.list()[0]
if (creator === undefined) throw new Error('Connecting the workspace did not create a Session')
expect(scaffold.ctx.agentPresets.composedPreset(creator.ctx)).toBe('standard')
await page.getByRole('button', { name: 'Standard mode', exact: true }).waitFor()
const composer = page.locator('[data-composer-input][contenteditable="true"]').last()
const draft = 'Keep this unsent plugin idea.'
await writeComposerDraft(page, composer, draft)
await page.getByRole('navigation', { name: 'Global panels' }).getByRole('button', { name: 'Plugins', exact: true }).click()
await page.getByRole('button', { name: 'Choose how to add a plugin', exact: true }).click()
await Promise.all([
page.waitForResponse('**/api/agentPresets/select'),
page.getByRole('menuitem', { name: /^Let the agent create a plugin/ }).click(),
])
const picker = page.getByTitle('Choose the agent preset for your new task', { exact: true }).filter({ hasText: 'Creator mode' })
await picker.waitFor()
await composer.filter({ hasText: draft }).waitFor()
expect(await composer.innerText()).toBe(draft)
expect(await picker.getAttribute('aria-haspopup')).toBe('menu')
expect(await page.getByRole('dialog', { name: 'Add plugin', exact: true }).count()).toBe(0)
expect(scaffold.ctx.sessionProjections.stateOf(creator.session, 'agentPreset')).toBe('cordis')
expect(scaffold.ctx.agentPresets.composedPreset(creator.ctx)).toBe('cordis')
expect(scaffold.ctx.tools.schemas(creator).map(tool => tool.name))
.toEqual(expect.arrayContaining(['cordis_inspect_list', 'cordis_inspect_query', 'plugin_manager']))
expect(creator.session.snapshotEvents().some(event => event.type === 'agent-preset/selected'
&& event.data.agentPreset === 'cordis')).toBe(true)
expect(creator.session.snapshotEvents().some(event => event.type === 'user/message' || event.type === 'turn/start')).toBe(false)
expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-settings')?.value).toMatchObject({ enabled: developerTools })
expect(scaffold.ctx.agentPresets.defaultId).toBe('standard')
// Close a fixture turn on the real Creator Session without requesting a model.
await writeComposerDraft(page, composer, '')
creator.session.append('turn/start', { turn: 1 })
creator.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Creator fixture completed without a model call.' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
creator.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await scaffold.ctx.sessions.flush(creator.session)
await page.getByText('Creator fixture completed without a model call.', { exact: true }).waitFor()
const newSession = page.getByRole('button', { name: 'New session', exact: true }).filter({ hasText: 'New Session' })
await Promise.all([page.waitForResponse('**/api/session/create'), newSession.click()])
expect(scaffold.ctx.agents.list().find(agent => agent.id !== creator.id)).toBeDefined()
const next = scaffold.ctx.agents.list().find(agent => agent.id !== creator.id)!
expect(next.id).not.toBe(creator.id)
expect(next.session.header.agentPreset).toBe('standard')
expect(scaffold.ctx.sessionProjections.stateOf(next.session, 'agentPreset')).toBe('standard')
expect(scaffold.ctx.agentPresets.composedPreset(next.ctx)).toBe('standard')
expect(scaffold.ctx.sessionProjections.stateOf(creator.session, 'agentPreset')).toBe('cordis')
await page.getByRole('button', { name: 'Standard mode', exact: true }).waitFor()
expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-settings')?.value).toMatchObject({ enabled: developerTools })
expect(scaffold.ctx.agentPresets.defaultId).toBe('standard')
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
} finally {
await browser?.close()
await scaffold.close()
}
})
+82 -14
View File
@@ -1,17 +1,23 @@
/** Developer tools default on for a fresh Host and survive browser reload through the ordinary settings UI. */
/** Coding Tools persist while Standard, Creator, and custom presets remain selectable when disabled. */
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import type {} from '@deepseek-ai/dsh-api-session-controller'
import { chromium } from 'playwright'
import { expect, it, onTestFinished } from 'vitest'
import { launchWebScaffold } from './scaffold.ts'
import { openSettings, newEnglishPage } from './support.ts'
import { launchWebScaffold, watchConsole } from './scaffold.ts'
import { connectFreshWorkspace, openSettings, newEnglishPage } from './support.ts'
it('persists developer tools in the Host settings document and restores the accepted choice', async () => {
const scaffold = await launchWebScaffold()
it('persists Coding Tools and limits the built-in PTC and Minimal choices in sessions and Settings', async () => {
const scaffold = await launchWebScaffold({
agentPresets: { default: 'standard', definitions: [{ id: 'custom', name: 'Custom mode', plugins: [] }] },
})
onTestFinished(() => scaffold.close())
const browser = await chromium.launch()
onTestFinished(() => browser.close())
const page = await newEnglishPage(browser)
const tripwire = watchConsole(page)
const livePresets = async () => (await scaffold.ctx.sessionController.list({}, new AbortController().signal))
.items.map(item => item.projections?.values.agentPreset)
await page.goto(scaffold.authenticatedUrl)
await openSettings(page, 'en')
const toggle = page.getByRole('switch', { name: 'Show coding view' })
@@ -26,14 +32,76 @@ it('persists developer tools in the Host settings document and restores the acce
await page.getByRole('button', { name: 'Agent presets', exact: true }).click()
await expect.poll(() => page.getByRole('heading', { name: 'Agent presets', exact: true }).count()).toBe(1)
// The page owns no selection switch; the cards and the Creator entry stay usable with Coding Tools off.
const section = page.locator('section')
.filter({ has: page.getByRole('heading', { name: 'Agent presets', exact: true }) })
const setDefault = section.getByRole('button', { name: 'Set as new task default: Minimal mode' })
expect(await section.getByRole('switch').count()).toBe(0)
await expect.poll(() => setDefault.isEnabled()).toBe(true)
expect(await section.getByRole('button', { name: 'Let the agent help me create a preset', exact: true }).isEnabled()).toBe(true)
const settings = page.getByRole('dialog', { name: 'Settings', exact: true })
expect(await settings.getByRole('switch').count()).toBe(0)
const visiblePresets = () => settings.locator('[data-agent-preset-id]').evaluateAll(cards => cards.map(card => card.getAttribute('data-agent-preset-id')))
await expect.poll(visiblePresets).toEqual(['standard', 'cordis', 'custom'])
await settings.getByRole('button', { name: 'Set as new task default: Custom mode', exact: true }).click()
await settings.getByRole('button', { name: 'New task default: Custom mode', exact: true }).waitFor()
await scaffold.ctx.settings.update('ui-settings', { enabled: true })
await expect.poll(() => setDefault.isEnabled()).toBe(true)
expect(await section.getByRole('switch').count()).toBe(0)
await expect.poll(visiblePresets).toEqual(['standard', 'ptc', 'minimal', 'cordis', 'custom'])
expect((await scaffold.ctx.agentPresets.remoteExportList()).presets.find(preset => preset.isDefault)?.id).toBe('custom')
await settings.getByRole('button', { name: 'Set as new task default: Minimal mode', exact: true }).click()
await settings.getByRole('button', { name: 'New task default: Minimal mode', exact: true }).waitFor()
await scaffold.ctx.settings.update('ui-settings', { enabled: false })
await expect.poll(visiblePresets).toEqual(['standard', 'cordis', 'custom'])
await settings.getByRole('button', { name: 'New task default: Standard mode', exact: true }).waitFor()
await expect.poll(async () => (await scaffold.ctx.agentPresets.remoteExportList()).presets.find(preset => preset.isDefault)?.id).toBe('standard')
expect(await settings.getByRole('button', { name: 'Let the agent help me create a preset', exact: true }).isEnabled()).toBe(true)
await settings.getByRole('button', { name: 'Close', exact: true }).last().click()
await connectFreshWorkspace(page, scaffold.workspaceCwd)
await expect.poll(livePresets).toEqual(['standard'])
const menu = page.getByRole('menu')
await page.getByRole('button', { name: 'Standard mode', exact: true }).click()
await expect.poll(() => menu.getByRole('menuitem').count()).toBe(3)
expect(await menu.getByRole('menuitem', { name: /^PTC mode|^Minimal mode/ }).count()).toBe(0)
expect(await menu.getByRole('menuitem', { name: /^Custom mode/ }).count()).toBe(1)
await menu.getByRole('menuitem', { name: /^Creator mode/ }).click()
await expect.poll(livePresets, { timeout: 15_000 }).toEqual(['cordis'])
await page.getByRole('button', { name: 'Creator mode', exact: true }).click()
await menu.waitFor()
await scaffold.ctx.settings.update('ui-settings', { enabled: true })
await menu.waitFor({ state: 'detached' })
await page.getByRole('button', { name: 'Creator mode', exact: true }).click()
await expect.poll(() => menu.getByRole('menuitem').count()).toBe(5)
expect(await menu.getByRole('menuitem', { name: /^PTC mode/ }).count()).toBe(1)
expect(await menu.getByRole('menuitem', { name: /^Minimal mode/ }).count()).toBe(1)
expect(await livePresets()).toEqual(['cordis'])
await scaffold.ctx.settings.update('ui-settings', { enabled: false })
await menu.waitFor({ state: 'detached' })
expect(await livePresets()).toEqual(['cordis'])
await page.getByRole('button', { name: 'Creator mode', exact: true }).click()
await menu.waitFor()
await scaffold.ctx.settings.update('ui-settings', { enabled: true })
await menu.waitFor({ state: 'detached' })
await page.getByRole('button', { name: 'Creator mode', exact: true }).click()
await menu.getByRole('menuitem', { name: /^PTC mode/ }).click()
await expect.poll(livePresets, { timeout: 15_000 }).toEqual(['ptc'])
await page.getByRole('button', { name: 'PTC mode', exact: true }).click()
await scaffold.ctx.settings.update('ui-settings', { enabled: false })
await menu.waitFor({ state: 'detached' })
expect(await livePresets()).toEqual(['ptc'])
await page.getByRole('button', { name: 'PTC mode', exact: true }).click()
await expect.poll(() => menu.getByRole('menuitem').count()).toBe(3)
expect(await menu.getByRole('menuitem', { name: /^PTC mode|^Minimal mode/ }).count()).toBe(0)
await menu.getByRole('menuitem', { name: /^Standard mode/ }).click()
await expect.poll(livePresets, { timeout: 15_000 }).toEqual(['standard'])
await page.reload()
await page.getByRole('button', { name: 'Standard mode', exact: true }).click()
await expect.poll(() => menu.getByRole('menuitem').count()).toBe(3)
await page.keyboard.press('Escape')
await openSettings(page, 'en')
await settings.getByRole('button', { name: 'General', exact: true }).click()
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false')
await settings.getByRole('button', { name: 'Agent presets', exact: true }).click()
await expect.poll(visiblePresets).toEqual(['standard', 'cordis', 'custom'])
await settings.getByRole('button', { name: 'New task default: Standard mode', exact: true }).waitFor()
await settings.getByRole('button', { name: 'Let the agent help me create a preset', exact: true }).click()
await settings.waitFor({ state: 'detached' })
await expect.poll(livePresets, { timeout: 15_000 }).toContain('cordis')
expect((await scaffold.ctx.agentPresets.remoteExportList()).presets.find(preset => preset.isDefault)?.id).toBe('standard')
expect(tripwire.pageErrors).toEqual([])
})
+4 -1
View File
@@ -610,7 +610,10 @@ else process.exit(1);
await mkdir(SHOT_DIR, { recursive: true })
await saveFailureShot(page, `screenshots/0908-document-preview/smoke-${process.pid}`)
})
expect(await page.locator('[data-slot="conversation.hero.agentPreset"] button').count()).toBe(0)
await page.locator('[data-slot="conversation.hero.agentPreset"]').getByRole('button', { name: 'Standard mode', exact: true }).click()
await expect.poll(() => page.getByRole('menuitem').count()).toBe(2)
expect(await page.getByRole('menuitem', { name: /^PTC mode|^Minimal mode/ }).count()).toBe(0)
await page.keyboard.press('Escape')
expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-settings')?.value).toEqual({ enabled: false })
const settled = scaffold.whenTurnSettled()
const input = page.locator('[data-composer-input]').first()
@@ -14,6 +14,12 @@
- code: fixture-live-client
- text: 运行中
- switch "启用组件 @fixture/live-client" [checked]
- heading "来源信息" [level=4]
- term: 代码来源
- definition:
- code: file:{{fixtures}}/fixture-live-client
- term: 当前版本
- definition: 0.0.1
- region "夹具区块":
- heading "夹具区块" [level=4]
- paragraph: 来自夹具的区块内容
@@ -2,7 +2,9 @@
- text: 安装、启用和配置插件
- button "插件说明"
- button "刷新"
- button "添加插件"
- group "添加插件":
- button "添加插件"
- button "选择添加插件方式"
- heading "官方" [level=3]
- text: "7"
- list:
@@ -22,3 +22,9 @@
- text: "@fixture/bundle/review"
- code: fixture-review
- text: "Off"
- heading "Source" [level=4]
- term: Code source
- definition:
- code: file:{{fixtures}}/fixture-bundle
- term: Current version
- definition: 0.0.1
@@ -23,3 +23,9 @@
- code: fixture-review
- code: "@fixture/bundle/review"
- text: 已关闭
- heading "来源信息" [level=4]
- term: 代码来源
- definition:
- code: file:{{fixtures}}/fixture-bundle
- term: 当前版本
- definition: 0.0.1
@@ -2,7 +2,9 @@
- text: 安装、启用和配置插件
- button "插件说明"
- button "刷新"
- button "添加插件"
- group "添加插件":
- button "添加插件"
- button "选择添加插件方式"
- heading "官方" [level=3]
- text: "7"
- list:
@@ -2,7 +2,9 @@
- text: 安装、启用和配置插件
- button "插件说明"
- button "刷新" [disabled]
- button "添加插件" [disabled]
- group "添加插件":
- button "添加插件" [disabled]
- button "选择添加插件方式" [disabled]
- status "正在读取插件…"
1680px: 3 rows; page/group headers, rows, icons, text lines and blank action spaces align within 0.1px
@@ -2,7 +2,9 @@
- text: 安装、启用和配置插件
- button "插件说明"
- button "刷新"
- button "添加插件"
- group "添加插件":
- button "添加插件"
- button "选择添加插件方式"
- heading "官方" [level=3]
- text: "7"
- list:
+3 -1
View File
@@ -317,7 +317,9 @@ describe('web e2e: plugin configuration pages', () => {
expect(await bundlePage.getByRole('button', { name: '夹具操作' }).count()).toBe(1)
expect(await bundlePage.locator('[data-live-badge="bundle"]').textContent()).toBe('夹具标签')
expect(await bundlePage.getByRole('region', { name: '夹具区块' }).getByText('来自夹具的区块内容').count()).toBe(1)
const bundleSnapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd)
const bundleSnapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd, {
replacements: [[FIXTURE_PLUGINS, '{{fixtures}}']],
})
await compareOrRefreshGolden(BUNDLE_EXPECTED, bundleSnapshot, MODE)
await configure.click()
+17 -3
View File
@@ -124,7 +124,7 @@ describe('web e2e: plugin manager', () => {
expect(await skeleton.getAttribute('aria-label')).toBe('正在读取插件…')
expect(await panel.getAttribute('aria-busy')).toBe('true')
const actions = panel.locator(':scope > header > div:last-child button')
expect(await actions.count()).toBe(2)
expect(await actions.count()).toBe(3)
for (const action of await actions.all()) expect(await action.isDisabled()).toBe(true)
expect(await panel.getByRole('button', { name: '插件说明' }).isEnabled()).toBe(true)
const loadingAria = await captureStableAria(probe, '[data-plugin-panel]', scaffold.workspaceCwd)
@@ -551,14 +551,18 @@ describe('web e2e: plugin manager', () => {
expect(await review.getByText('审查工作区中的改动。', { exact: true }).count()).toBe(1)
await panel.getByText('Registry description for the fixture bundle.', { exact: true }).first().waitFor()
expect([...scaffold.ctx.loader.entries()].some(entry => entry.options.name.startsWith('@fixture/bundle'))).toBe(false)
await compareOrRefreshGolden(EXPORTS_EXPECTED, await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd), MODE)
await compareOrRefreshGolden(EXPORTS_EXPECTED, await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd, {
replacements: [[FIXTURE_PLUGINS, '{{fixtures}}']],
}), MODE)
try {
await setLanguage('en')
await search.getByText('File Search', { exact: true }).waitFor()
expect(await review.getByText('@fixture/bundle/review', { exact: true }).count()).toBeGreaterThan(0)
expect(await search.getByText('Search package introduction.', { exact: true }).count()).toBe(1)
expect(await review.getByText('审查工作区中的改动。', { exact: true }).count()).toBe(0)
await compareOrRefreshGolden(EXPORTS_EN_EXPECTED, await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd), MODE)
await compareOrRefreshGolden(EXPORTS_EN_EXPECTED, await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd, {
replacements: [[FIXTURE_PLUGINS, '{{fixtures}}']],
}), MODE)
expect(await panel.locator('[data-plugin-name]').textContent()).toBe('@fixture/bundle')
} finally {
await setLanguage('zh')
@@ -820,6 +824,16 @@ describe('web e2e: plugin manager', () => {
const dialog = page.getByRole('dialog', { name: '添加插件' })
await dialog.waitFor({ timeout: 10_000 })
const field = dialog.getByRole('textbox', { name: '包名或地址' })
expect(await page.getByRole('menu').count()).toBe(0)
await expect.poll(() => field.evaluate(element => document.activeElement === element)).toBe(true)
await dialog.getByRole('button', { name: '关闭', exact: true }).click()
await dialog.waitFor({ state: 'hidden' })
await panel.getByRole('button', { name: '选择添加插件方式', exact: true }).click()
const menu = page.getByRole('menu')
await menu.getByRole('menuitem', { name: /^安装第三方插件/ }).click()
await menu.waitFor({ state: 'hidden' })
await dialog.waitFor({ state: 'visible' })
await expect.poll(() => field.evaluate(element => document.activeElement === element)).toBe(true)
const install = dialog.getByRole('button', { name: '安装', exact: true })
expect(await install.isDisabled()).toBe(true)
expect(await dialog.getByRole('note').textContent()).toContain('请确认插件来源可信')
+69
View File
@@ -29,6 +29,7 @@ const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
const SENDING_EXPECTED = join(SNAPSHOT_DIR, 'sending.expected.md')
const WRITER_HELD_EXPECTED = join(SNAPSHOT_DIR, 'writer-held.expected.md')
const FAILED_EXPECTED = join(SNAPSHOT_DIR, 'failed.expected.md')
const GOAL_STOP_EXPECTED = join(SNAPSHOT_DIR, 'goal-stop.expected.md')
const MODE = webSnapshotMode()
const ACTIVE_PROMPT = 'Reply with a one-sentence description of event sourcing, then stop.'
@@ -421,6 +422,73 @@ describe('web e2e: queue row actions', () => {
expect(tripwire.warnings).toEqual([])
}, 120_000)
it.skipIf(MODE === 'record')('runs a prompt sent after Stop when a Goal round was queued behind stopped work', async () => {
overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-goal-stop-'))
const roundReady = join(overrideDir, '.round-ready')
const humanReady = join(overrideDir, '.human-ready')
const overridePath = join(overrideDir, 'replay.override.json')
const recorded = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8')))
await writeFile(overridePath, JSON.stringify([
{ kind: 'hang', readyFile: roundReady },
{ kind: 'hang', readyFile: humanReady },
recorded[0]!,
] satisfies ReplayEntry[]))
const sessionEvents: SessionEvent[] = []
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride: overridePath, compareReplaySession: false })
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await newEnglishPage(browser)
const tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-stop'))
const input = page.locator('[data-composer-input]').first()
const editable = page.locator('[data-composer-input][contenteditable="true"]').first()
const stop = page.getByRole('button', { name: 'Stop generating', exact: true })
await editable.waitFor({ timeout: 10_000 })
await input.fill('/goal Keep working after Stop')
await input.press('Enter')
await expect.poll(() => existsSync(roundReady), { timeout: 15_000 }).toBe(true)
// Human work queued behind the running round survives the first Stop.
await editable.waitFor({ timeout: 10_000 })
await input.fill(REMOVE)
await input.press('Enter')
await page.getByRole('button', { name: 'Remove queued message', disabled: false }).waitFor({ timeout: 10_000 })
await stop.click()
await page.getByRole('button', { name: 'Resume goal' }).waitFor({ timeout: 10_000 })
// Resuming queues the next round behind that human prompt, which runs first.
await page.getByRole('button', { name: 'Resume goal' }).click()
await expect.poll(() => existsSync(humanReady), { timeout: 15_000 }).toBe(true)
await expect.poll(() => page.locator('[data-queue-dock] li', { hasText: '<goal_round>' }).count(), { timeout: 10_000 })
.toBe(1)
await stop.click()
await expect.poll(() => stop.count(), { timeout: 10_000 }).toBe(0)
await expect.poll(() => page.locator('[data-queue-dock]').count(), { timeout: 10_000 }).toBe(0)
const settled = scaffold.whenTurnSettled()
await editable.waitFor({ timeout: 10_000 })
await input.fill(WAKE)
await input.press('Enter')
await settled
await expect.poll(() => turnEndReasons(sessionEvents), { timeout: 15_000 })
.toEqual(['aborted', 'aborted', 'completed'])
expect(sessionEvents.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'user'
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
: [])).toEqual([REMOVE, WAKE])
expect(sessionEvents.some(event => event.type === 'agent/inbox/spliced' && event.data.target === 'next-turn'
&& event.data.outcome === 'canceled')).toBe(true)
await expect.poll(() => stop.count(), { timeout: 10_000 }).toBe(0)
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(GOAL_STOP_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 120_000)
it.skipIf(MODE === 'record')('keeps its snapshot inventory closed', async () => {
await assertFixtureInventory(
SNAPSHOT_DIR,
@@ -428,6 +496,7 @@ describe('web e2e: queue row actions', () => {
'collapsed.expected.md', 'editing.expected.md', 'layout.expected.md',
'preserved.expected.md', 'preserved-expanded.expected.md', 'ui.expected.md',
'sending.expected.md', 'failed.expected.md', 'writer-held.expected.md',
'goal-stop.expected.md',
],
)
})
+8 -8
View File
@@ -533,8 +533,8 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
const detail = manager.getByRole('complementary', { name: 'Task details' })
expect(await detail.getByRole('tab', { name: 'Rules', exact: true }).getAttribute('aria-selected')).toBe('true')
expect(await detail.getByRole('region', { name: 'Saved delivery record' }).count()).toBe(0)
await detail.getByRole('tab', { name: 'Delivery records', exact: true }).click()
await detail.getByRole('tabpanel', { name: 'Delivery records', exact: true }).waitFor()
await detail.getByRole('tab', { name: 'Records', exact: true }).click()
await detail.getByRole('tabpanel', { name: 'Records', exact: true }).waitFor()
const receipt = detail.getByRole('region', { name: 'Saved delivery record' })
await receipt.waitFor()
expect(await detail.getByText('Earlier delivery records have been cleared', { exact: true }).count()).toBe(0)
@@ -619,9 +619,9 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
const manager = page.getByTestId('task-manager-page')
await manager.getByRole('list', { name: 'Task catalog' }).getByRole('button', { name: original.title, exact: true }).click()
const detail = manager.getByRole('complementary', { name: 'Task details' })
await detail.getByRole('tab', { name: 'Delivery records', exact: true }).click()
await detail.getByRole('tab', { name: 'Records', exact: true }).click()
const receipts = detail.getByRole('region', { name: 'Saved delivery record' })
const recordsPanel = detail.getByRole('tabpanel', { name: 'Delivery records', exact: true })
const recordsPanel = detail.getByRole('tabpanel', { name: 'Records', exact: true })
await expect.poll(() => receipts.count()).toBe(1)
expect(await detail.getByText('Earlier delivery records have been cleared', { exact: true }).count()).toBe(0)
@@ -648,7 +648,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
// Each saved occurrence renders its own scheduled instant.
expect(await recordsPanel.locator(`time[datetime="${record.scheduledAt}"]`).count()).toBe(1)
}
expect(await detail.getByRole('tab', { name: 'Delivery records', exact: true }).getAttribute('aria-selected')).toBe('true')
expect(await detail.getByRole('tab', { name: 'Records', exact: true }).getAttribute('aria-selected')).toBe('true')
await detail.getByRole('tab', { name: 'Rules', exact: true }).click()
// The interval row states its quantity in the friendly unit the stored
// seconds select: 3600 seconds is one whole hour, so the row shows 1 and
@@ -665,7 +665,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
kind: 'every', everySeconds: EVERY_INTERVAL_SECONDS * 2, status: 'active',
})
expect(await scaffold.ctx.schedule.history({ sessionId: everyHandle.agent.id, id: original.id, limit: 20 })).toEqual(saved)
await detail.getByRole('tab', { name: 'Delivery records', exact: true }).click()
await detail.getByRole('tab', { name: 'Records', exact: true }).click()
await expect.poll(() => receipts.count()).toBe(2)
// The linked-session row renders on the Rules view only.
await detail.getByRole('tab', { name: 'Rules', exact: true }).click()
@@ -839,7 +839,7 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
const details = manager.getByRole('complementary', { name: 'Task details' })
expect(await details.textContent()).toContain(otherSessionId)
expect(scaffold.ctx.agents.get(otherSessionId)).toBeUndefined()
await details.getByRole('tab', { name: 'Delivery records', exact: true }).click()
await details.getByRole('tab', { name: 'Records', exact: true }).click()
await details.getByText('No delivery record available', { exact: true }).waitFor()
expect(await details.getByRole('region', { name: 'Saved delivery record' }).count()).toBe(0)
expect(scaffold.ctx.agents.get(otherSessionId)).toBeUndefined()
@@ -1107,7 +1107,7 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
await manager.getByRole('searchbox', { name: 'Search tasks' }).fill(record.title)
await manager.getByRole('list', { name: 'Task catalog' }).getByRole('button', { name: record.title, exact: true }).click()
const detail = manager.getByRole('complementary', { name: 'Task details' })
const recordsTab = detail.getByRole('tab', { name: 'Delivery records', exact: true })
const recordsTab = detail.getByRole('tab', { name: 'Records', exact: true })
const rulesTab = detail.getByRole('tab', { name: 'Rules', exact: true })
const notice = detail.getByText('Earlier delivery records have been cleared', { exact: true })
await recordsTab.click()
+8
View File
@@ -23,13 +23,21 @@
"@deepseek-ai/dsh-llm": "workspace:*",
"@deepseek-ai/dsh-sdk-client": "workspace:*",
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-session-format": "workspace:*",
"@deepseek-ai/dsh-session-format-catalog": "workspace:*",
"@deepseek-ai/dsh-session-format-v3-to-v4": "workspace:*",
"@deepseek-ai/dsh-session-persistence": "workspace:*",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*",
"@deepseek-ai/dsh-session-projection": "workspace:*",
"@deepseek-ai/dsh-session-projection-cache": "workspace:*",
"@deepseek-ai/dsh-session-query": "workspace:*",
"@deepseek-ai/dsh-session-query-sqlite": "workspace:*",
"@deepseek-ai/dsh-session-stats": "workspace:*",
"@deepseek-ai/dsh-session-title": "workspace:*",
"@deepseek-ai/dsh-session-turn-outline": "workspace:*",
"@deepseek-ai/dsh-storage": "workspace:*",
"@deepseek-ai/dsh-storage-domain": "workspace:*",
"@deepseek-ai/dsh-storage-json": "workspace:*",
"@deepseek-ai/dsh-subagent": "workspace:*",
"@deepseek-ai/dsh-tools": "workspace:*",
"@deepseek-ai/dsh-token-meter": "workspace:*",
@@ -0,0 +1,22 @@
# 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 benchmarks/session-corpus/README.md
/session-corpus-benchmarks:
en: 28ebbfcfa3ea913a
zh: 3ebad56e2c97c7c8
/session-corpus-benchmarks/summary:
en: 651a02571a0f6f42
zh: dcd33bbc5baa0977
/session-corpus-benchmarks/table-of-contents:
en: 66a769ce60d49ff3
zh: 7e0bfa7c598157fa
/session-corpus-benchmarks/run:
en: 2560e832041ae481
zh: 2fbbff71135e52ab
/session-corpus-benchmarks/measurements:
en: c357c231c3bfb588
zh: 3d901a2caccf2587
/session-corpus-benchmarks/dev-note:
en: b75c8458928b72b9
zh: 003ba4e17b4b5269
+39
View File
@@ -0,0 +1,39 @@
# Session corpus benchmarks
English | [中文](README.zh.md)
## Summary
Measure the Web Host's Session list, content search, and fork over synthetic corpora in which every Session is at least as long as the same quantile of a measured local DSH corpus. Search and fork run over separate 1,000-Session corpora; the list runs over 3,000, the largest count that keeps this file within five minutes on standard hosted CI. Content search models a deployment that opts in with `openAt: first-search`; shipped profiles disable it. No case uses network services, recorded Sessions, or a browser.
## Table of Contents
- [Run](#run)
- [Measurements](#measurements)
- [Dev Note](#dev-note)
<a id="run"></a>
## Run
From the repository root, build the libraries and workers with `pnpm run build:bench`, then run `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/session-corpus/session-corpus.bench.ts`. Do not overlap timing runs with builds or other benchmarks. Seeding writes about 1.5 GB of Zstandard logs under a private temporary root, which the test removes after success or failure.
The test reports every fresh-process sample, the CPU model, available parallelism, platform, and Node version, and enforces median budgets. Its last case fails when the file, including seeding, exceeds five minutes. A failed worker reports its exit, signal, timeout, and stderr tail. The required benchmark lane discovers this file automatically.
<a id="measurements"></a>
## Measurements
[corpus-shape.ts](corpus-shape.ts) holds the measured quantile anchors. [synthetic-corpus.ts](synthetic-corpus.ts) authors one event body per anchor and stores each Session as its own header frame followed by that body. [The Agent Note](../../.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.md) owns the workload derivation, timing endpoints, calibration, and exclusions.
| Case | Corpus | Timed endpoints |
|---|---|---|
| List | 3,000 Sessions | Host boot; first and repeated `session.list`, each returning every Session with cached projections |
| Content search | 1,000 Sessions | First `session.search`, which builds the in-memory index; a second query over the built index |
| Fork | 1,000 Sessions | `session.fork` return for ten length strata, the p99 Session, and the longest Session |
<a id="dev-note"></a>
## Dev Note
None.
+39
View File
@@ -0,0 +1,39 @@
# Session 语料基准
[English](README.md) | 中文
## 概述
在合成语料上测量 Web Host 的 Session 列表、内容搜索和 fork;语料中每个 Session 的长度都不短于一份实测本地 DSH 语料在同一分位上的长度。搜索和 fork 用例分别运行在独立的 1,000 个 Session 语料上;列表用例运行 3,000 个 Session,这是在标准托管 CI 上让此文件五分钟内完成的最大数量。内容搜索模拟通过 `openAt: first-search` 启用该功能的部署;已发布的 profile 默认禁用它。所有用例均不使用网络服务、录制的 Session 或浏览器。
## 目录
- [运行](#run)
- [测量](#measurements)
- [开发备注](#dev-note)
<a id="run"></a>
## 运行
在仓库根目录使用 `pnpm run build:bench` 构建库和 worker,然后运行 `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/session-corpus/session-corpus.bench.ts`。不要让计时运行与构建或其他基准重叠。播种过程会在私有临时根目录下写入约 1.5 GB 的 Zstandard 日志,测试成功或失败后都会删除该目录。
测试报告每个新进程样本、CPU 型号、可用并行度、平台和 Node 版本,并约束中位数预算。当整个文件(包括播种)超过五分钟时,最后一个用例失败。worker 失败时报告退出状态、信号、超时和 stderr 末尾。必需基准通道自动发现此文件。
<a id="measurements"></a>
## 测量
[corpus-shape.ts](corpus-shape.ts) 保存实测分位锚点。[synthetic-corpus.ts](synthetic-corpus.ts) 为每个锚点生成一份事件主体,并把每个 Session 存储为独立的 header 帧加上该主体。[Agent Note](../../.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.zh.md) 负责说明工作负载推导、计时终点、校准和排除项。
| 用例 | 语料 | 计时终点 |
|---|---|---|
| 列表 | 3,000 个 Session | Host 启动;首次和重复的 `session.list`,每次都返回全部 Session 及其缓存投影 |
| 内容搜索 | 1,000 个 Session | 构建内存索引的首次 `session.search`;在已构建索引上的第二次查询 |
| Fork | 1,000 个 Session | 十个长度分层、p99 Session 和最长 Session 的 `session.fork` 返回 |
<a id="dev-note"></a>
## 开发备注
无。
+76
View File
@@ -0,0 +1,76 @@
/** Per-Session length targets for the synthetic Session corpus. */
/**
* Upper-quantile anchors, in percent, of the measured corpus distribution.
* Each anchor pairs with one entry of every dimension table below.
*/
const QUANTILES = [10, 20, 30, 40, 50, 60, 70, 75, 80, 85, 90, 92, 94, 96, 97, 98, 99, 99.3, 99.6, 99.8, 99.9, 100] as const
/**
* Aggregate quantiles of 1,650 local DSH Sessions measured on 2026-09-28
* (newest generation per Session; top-level and subagent Sessions together).
* Only counts and byte totals were extracted; no content, identity, or path.
*/
const MEASURED = {
/** Logical events, expanding compact delta rows to their event count. */
events: [22, 22, 27, 32, 53, 63, 84, 154, 251, 387, 709, 975, 1398, 2398, 2652, 3492, 7229, 11620, 21995, 26580, 60699, 84467],
/** Decompressed JSONL bytes including the header line. */
logicalBytes: [
37_233, 46_642, 54_432, 67_432, 86_195, 95_561, 184_414, 572_320, 882_968, 1_250_060, 1_849_503,
2_341_019, 2_849_779, 3_902_295, 4_874_426, 6_691_370, 8_479_554, 10_570_582, 13_024_700, 17_646_397,
21_225_976, 48_139_877,
],
/** Started turns. */
turns: [1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 3, 4, 5, 7, 9, 11, 18, 20, 23, 39, 118, 2181],
} as const
/** Minimum length of one synthetic Session. */
export interface SessionShape {
/** Index of the measured anchor; Sessions with one anchor share one shape. */
readonly anchor: number
/** Logical events the Session must contain at least. */
readonly events: number
/** Decompressed log bytes the Session must contain at least. */
readonly logicalBytes: number
/** Completed turns. */
readonly turns: number
}
/** Number of measured anchors, and therefore of distinct Session shapes. */
export const ANCHOR_COUNT = QUANTILES.length
/**
* Read the length targets of one measured anchor.
* @param anchor - zero-based anchor index.
* @returns the anchor's minimum event, byte, and turn counts.
*/
export function anchorShape(anchor: number): SessionShape {
if (!Number.isInteger(anchor) || anchor < 0 || anchor >= ANCHOR_COUNT) {
throw new Error(`corpus anchor ${String(anchor)} does not exist`)
}
return {
anchor,
events: MEASURED.events[anchor] as number,
logicalBytes: MEASURED.logicalBytes[anchor] as number,
turns: MEASURED.turns[anchor] as number,
}
}
/**
* Select the length targets for one rank of an ascending corpus.
*
* Every dimension uses the next measured anchor at or above the rank's
* quantile, so each quantile of the generated corpus is at least the measured
* one. Dimensions share the rank, so a long Session is long in every dimension.
* @param rank - zero-based ascending length rank.
* @param count - corpus size.
* @returns the rank's minimum event, byte, and turn counts.
*/
export function sessionShape(rank: number, count: number): SessionShape {
const quantile = (100 * (rank + 1)) / count
const anchor = QUANTILES.findIndex(value => value >= quantile)
if (!Number.isInteger(rank) || rank < 0 || anchor === -1) {
throw new Error(`corpus rank ${String(rank)} is outside a ${String(count)}-Session corpus`)
}
return anchorShape(anchor)
}
@@ -0,0 +1,281 @@
/** Required budgets for listing, searching, and forking Sessions in corpora with the measured local length distribution. */
import { mkdtemp, rm } from 'node:fs/promises'
import { availableParallelism, cpus, tmpdir } from 'node:os'
import { join } from 'node:path'
import { performance } from 'node:perf_hooks'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { runBuiltBenchmarkWorker } from '../support/built-worker.ts'
import { PERFORMANCE_BUDGET_HEADROOM } from '../support/calibration.ts'
import { anchorShape, ANCHOR_COUNT, sessionShape } from './corpus-shape.ts'
import { subagentRank } from './synthetic-corpus.ts'
import type {
AnchorsReport,
ForkReport,
ListReport,
SearchReport,
SeedReport,
SessionCorpusReport,
} from './session-corpus.worker.ts'
/**
* Sessions per corpus. Search and fork use separate roots, so fork children never reach the search index;
* the list corpus is the largest that keeps this file within its time limit.
*/
const CORPUS = { search: 1_000, fork: 1_000, list: 3_000 } as const
/** Upper bound on this file's hosted CI wall time, from seeding through the last case. */
const FILE_LIMIT_MS = 300_000
/** Per-child deadline: an endpoint slower than its budget still reports a measurement instead of a kill. */
const WORKER_TIMEOUT_MS = FILE_LIMIT_MS
/** Fresh processes per list and fork-strata scenario; the median enforces each budget. */
const ATTEMPTS = { list: 3, fork: 3 } as const
/** Concurrent anchor-preparation processes; standard hosted runners have two CPUs. */
const PREPARATION_PROCESSES = 2
/** Midpoints of ten equal length strata of the fork corpus. */
const FORK_STRATA = Array.from({ length: 10 }, (_, stratum) => Math.floor(((stratum + 0.5) * CORPUS.fork) / 10))
/** The highest top-level rank at the measured p99; forking a subagent child adds a lineage lookup. */
const FORK_P99_RANK = topLevelRank(Math.ceil(0.99 * CORPUS.fork) - 1)
/** The longest Session; one sample, because one fork takes about 20 s on standard hosted CI. */
const FORK_LONGEST_RANK = CORPUS.fork - 1
const WORKER = join(import.meta.dirname, '..', '.dsh-build', 'session-corpus', 'session-corpus.worker.js')
/** Higher recorded median of the standard EPYC 7763 and EPYC 9V74 hosted runners, per budgeted endpoint. */
const RECORDED_CI_MS = {
listBoot: 2_317.8,
listFirst: 3_228.7,
listRepeat: 2_636.4,
searchFirst: 144_359,
searchRepeat: 3_426.3,
forkStratumMedian: 95.5,
forkP99: 1_066.2,
forkLongest: 20_326.7,
} as const
/** Hosted CI expectations, rounded above {@link RECORDED_CI_MS} before variance headroom. */
const EXPECTED_CI_MS = {
listBoot: 2_400,
listFirst: 3_300,
listRepeat: 2_700,
searchFirst: 145_000,
searchRepeat: 3_500,
forkStratumMedian: 100,
forkP99: 1_100,
forkLongest: 20_400,
} as const satisfies Record<keyof typeof RECORDED_CI_MS, number>
/** Higher recorded peak RSS of the two runner models; memory takes headroom but no time scale. */
const RECORDED_CI_PEAK_RSS_MB = { list: 742.7, search: 1_197.8, fork: 745.1 } as const
const EXPECTED_CI_PEAK_RSS_MB = {
list: 750,
search: 1_200,
fork: 750,
} as const satisfies Record<keyof typeof RECORDED_CI_PEAK_RSS_MB, number>
type CorpusName = keyof typeof CORPUS
function topLevelRank(rank: number): number {
return subagentRank(rank) ? rank - 1 : rank
}
function budget(expected: number): number {
return Math.ceil(expected * PERFORMANCE_BUDGET_HEADROOM)
}
/** Test deadline for sequential workers; it outlasts their deadlines, so every child is reaped before cleanup. */
function serialDeadline(workers: number): number {
return workers * WORKER_TIMEOUT_MS + 30_000
}
async function run<Report extends SessionCorpusReport>(args: readonly string[]): Promise<Report> {
const outcome = await runBuiltBenchmarkWorker<Report>({ worker: WORKER, args, timeoutMs: WORKER_TIMEOUT_MS, exposeGc: true })
if (outcome.report === undefined) {
const stderr = outcome.stderr.trim().split('\n').slice(-20).join('\n')
throw new Error(`session-corpus worker ${args[1] ?? ''} failed: exit=${String(outcome.exitCode)}, `
+ `signal=${String(outcome.signal)}, timedOut=${String(outcome.timedOut)}\n${stderr}`)
}
return outcome.report
}
/**
* Split anchors into byte-balanced groups, each in ascending order so its byte model starts on small bodies.
* @returns one anchor group per preparation process.
*/
function anchorGroups(): number[][] {
const groups = Array.from({ length: PREPARATION_PROCESSES }, () => ({ bytes: 0, anchors: [] as number[] }))
const bySize = Array.from({ length: ANCHOR_COUNT }, (_, anchor) => anchor)
.sort((left, right) => anchorShape(right).logicalBytes - anchorShape(left).logicalBytes)
for (const anchor of bySize) {
const group = groups.reduce((smallest, candidate) => candidate.bytes < smallest.bytes ? candidate : smallest)
group.bytes += anchorShape(anchor).logicalBytes
group.anchors.push(anchor)
}
return groups.map(group => group.anchors.sort((left, right) => left - right))
}
function median(values: readonly number[]): number {
return [...values].sort((left, right) => left - right)[Math.floor(values.length / 2)] as number
}
function rounded(values: readonly number[]): number[] {
return values.map(value => Math.round(value * 10) / 10)
}
function expectWithinBudget(value: number, limit: number): void {
expect(value).toBeLessThanOrEqual(limit)
}
function environment() {
return {
cpu: cpus()[0]?.model,
availableParallelism: availableParallelism(),
platform: `${process.platform}-${process.arch}`,
node: process.version,
}
}
describe('Session corpus workload', () => {
it('is at least the measured distribution at every rank of every corpus', () => {
for (const count of Object.values(CORPUS)) {
const shapes = Array.from({ length: count }, (_, rank) => sessionShape(rank, count))
expect(new Set(shapes.map(shape => shape.anchor)).size).toBe(ANCHOR_COUNT)
expect(shapes[Math.floor(count / 2) - 1]).toEqual(anchorShape(4))
expect(shapes.at(-1)).toEqual({ anchor: ANCHOR_COUNT - 1, events: 84_467, logicalBytes: 48_139_877, turns: 2_181 })
for (let rank = 1; rank < count; rank++) {
const [previous, current] = [shapes[rank - 1], shapes[rank]] as const
expect(current?.events).toBeGreaterThanOrEqual(previous?.events ?? 0)
expect(current?.logicalBytes).toBeGreaterThanOrEqual(previous?.logicalBytes ?? 0)
}
}
expect(() => sessionShape(CORPUS.fork, CORPUS.fork)).toThrow('outside')
})
it('forks top-level Sessions at the strata midpoints, the measured p99, and the maximum', () => {
const ranks = [...FORK_STRATA, FORK_P99_RANK, FORK_LONGEST_RANK]
expect(ranks.filter(subagentRank)).toEqual([])
expect(FORK_STRATA).toEqual([50, 150, 250, 350, 450, 550, 650, 750, 850, 950])
expect(sessionShape(FORK_P99_RANK, CORPUS.fork).events).toBe(7_229)
expect(sessionShape(FORK_LONGEST_RANK, CORPUS.fork).events).toBe(84_467)
})
it('accepts every recorded hosted median and rejects one a quarter above its expectation', () => {
const endpoints = [
...(Object.keys(EXPECTED_CI_MS) as (keyof typeof EXPECTED_CI_MS)[])
.map(key => [RECORDED_CI_MS[key], EXPECTED_CI_MS[key]] as const),
...(Object.keys(EXPECTED_CI_PEAK_RSS_MB) as (keyof typeof EXPECTED_CI_PEAK_RSS_MB)[])
.map(key => [RECORDED_CI_PEAK_RSS_MB[key], EXPECTED_CI_PEAK_RSS_MB[key]] as const),
]
expect(endpoints).toHaveLength(11)
for (const [recorded, expected] of endpoints) {
expect(recorded).toBeLessThanOrEqual(expected)
expectWithinBudget(recorded, budget(expected))
expect(() => expectWithinBudget(Math.ceil(expected * 1.26), budget(expected))).toThrow()
}
})
})
describe('Session corpus operations', () => {
let scratch = ''
let started = 0
let seeded: readonly SeedReport[] = []
const root = (name: CorpusName): string => join(scratch, `corpus-${name}`)
beforeAll(async () => {
started = performance.now()
scratch = await mkdtemp(join(tmpdir(), 'dsh-session-corpus-bench-'))
const prepared = await Promise.all(anchorGroups().map(group => run<AnchorsReport>([scratch, 'anchors', ...group.map(String)])))
seeded = await Promise.all((Object.keys(CORPUS) as CorpusName[])
.map(name => run<SeedReport>([scratch, 'seed', name, String(CORPUS[name])])))
console.log(JSON.stringify({ benchmark: 'session-corpus/seed', prepared, seeded, environment: environment() }))
}, serialDeadline(2))
afterAll(async () => {
if (scratch !== '') await rm(scratch, { recursive: true, force: true })
})
it('writes corpora at least as long as the measured distribution', () => {
expect(seeded.map(report => report.name).sort()).toEqual(Object.keys(CORPUS).sort())
for (const { name, corpus } of seeded) {
const count = CORPUS[name as CorpusName]
const shapes = Array.from({ length: count }, (_, rank) => sessionShape(rank, count))
expect(corpus.sessions).toBe(count)
expect(corpus.distinctBodies).toBe(ANCHOR_COUNT)
expect(corpus.events).toBeGreaterThanOrEqual(shapes.reduce((sum, shape) => sum + shape.events, 0))
expect(corpus.logicalBytes).toBeGreaterThanOrEqual(shapes.reduce((sum, shape) => sum + shape.logicalBytes, 0))
}
})
it(`lists ${String(CORPUS.list)} Sessions after a cold Host boot`, async () => {
const budgets = {
bootMs: budget(EXPECTED_CI_MS.listBoot),
firstMs: budget(EXPECTED_CI_MS.listFirst),
repeatMs: budget(EXPECTED_CI_MS.listRepeat),
peakRssMb: budget(EXPECTED_CI_PEAK_RSS_MB.list),
}
const reports: ListReport[] = []
for (let attempt = 0; attempt < ATTEMPTS.list; attempt++) {
reports.push(await run<ListReport>([root('list'), 'list', String(CORPUS.list)]))
}
const samples = {
bootMs: rounded(reports.map(report => report.bootMs)),
firstMs: rounded(reports.map(report => report.firstMs)),
repeatMs: rounded(reports.map(report => report.repeatMs)),
peakRssMb: reports.map(report => report.memory.peakRssMb),
heapUsedMb: reports.map(report => report.memory.heapUsedMb),
}
console.log(JSON.stringify({ benchmark: `session-corpus/list-${String(CORPUS.list)}`, samples, budgets, environment: environment() }))
for (const report of reports) expect(report.itemsWithProjections).toBe(CORPUS.list)
expectWithinBudget(median(samples.bootMs), budgets.bootMs)
expectWithinBudget(median(samples.firstMs), budgets.firstMs)
expectWithinBudget(median(samples.repeatMs), budgets.repeatMs)
expectWithinBudget(median(samples.peakRssMb), budgets.peakRssMb)
}, serialDeadline(ATTEMPTS.list))
it(`searches ${String(CORPUS.search)} Sessions with a cold and then a built content index`, async () => {
const budgets = {
firstMs: budget(EXPECTED_CI_MS.searchFirst),
repeatMs: budget(EXPECTED_CI_MS.searchRepeat),
peakRssMb: budget(EXPECTED_CI_PEAK_RSS_MB.search),
}
// One sample: the cold index build dominates this file's time.
const report = await run<SearchReport>([root('search'), 'search'])
console.log(JSON.stringify({ benchmark: 'session-corpus/search', report, budgets, environment: environment() }))
expectWithinBudget(report.firstMs, budgets.firstMs)
expectWithinBudget(report.repeatMs, budgets.repeatMs)
expectWithinBudget(report.memory.peakRssMb, budgets.peakRssMb)
}, serialDeadline(1))
it(`forks Sessions across the length distribution of ${String(CORPUS.fork)} Sessions`, async () => {
const budgets = {
stratumMedianMs: budget(EXPECTED_CI_MS.forkStratumMedian),
p99Ms: budget(EXPECTED_CI_MS.forkP99),
longestMs: budget(EXPECTED_CI_MS.forkLongest),
peakRssMb: budget(EXPECTED_CI_PEAK_RSS_MB.fork),
}
const reports: ForkReport[] = []
for (let attempt = 0; attempt < ATTEMPTS.fork; attempt++) {
reports.push(await run<ForkReport>([root('fork'), 'fork', ...[...FORK_STRATA, FORK_P99_RANK].map(String)]))
}
const longest = await run<ForkReport>([root('fork'), 'fork', String(FORK_LONGEST_RANK)])
const forkMs = (report: ForkReport, rank: number): number => {
const fork = report.forks.find(entry => entry.rank === rank)
if (fork === undefined) throw new Error(`fork report omits rank ${String(rank)}`)
return fork.forkMs
}
const samples = {
stratumMedianMs: rounded(reports.map(report => median(FORK_STRATA.map(rank => forkMs(report, rank))))),
p99Ms: rounded(reports.map(report => forkMs(report, FORK_P99_RANK))),
longestMs: Math.round(forkMs(longest, FORK_LONGEST_RANK) * 10) / 10,
perRank: reports.map(report => report.forks.map(({ rank, sourceEvents, forkMs }) => ({ rank, sourceEvents, forkMs: Math.round(forkMs) }))),
peakRssMb: [...reports, longest].map(report => report.memory.peakRssMb),
}
console.log(JSON.stringify({ benchmark: 'session-corpus/fork', samples, budgets, environment: environment() }))
expectWithinBudget(median(samples.stratumMedianMs), budgets.stratumMedianMs)
expectWithinBudget(median(samples.p99Ms), budgets.p99Ms)
expectWithinBudget(samples.longestMs, budgets.longestMs)
expectWithinBudget(Math.max(...samples.peakRssMb), budgets.peakRssMb)
}, serialDeadline(ATTEMPTS.fork + 1))
it(`completes within ${String(FILE_LIMIT_MS / 60_000)} minutes`, () => {
const elapsedMs = performance.now() - started
console.log(JSON.stringify({ benchmark: 'session-corpus/total', elapsedMs: Math.round(elapsedMs), limitMs: FILE_LIMIT_MS }))
expectWithinBudget(elapsedMs, FILE_LIMIT_MS)
})
})
@@ -0,0 +1,368 @@
/** Isolated worker that seeds the synthetic corpus or measures one Session-controller operation over it. */
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { performance } from 'node:perf_hooks'
import { scheduler } from 'node:timers/promises'
import { Context } from '@deepseek-ai/cordis'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry'
import SessionController from '@deepseek-ai/dsh-api-session-controller'
import { currentSessionMessageProjections } from '@deepseek-ai/dsh-session-format-catalog/message-projections'
import { Session, SessionLogOffset } from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SessionProjectionCache, { checkpointRecord, projectionCacheDomainSpec } from '@deepseek-ai/dsh-session-projection-cache'
import type { CheckpointRecord } from '@deepseek-ai/dsh-session-projection-cache'
import SqliteSessionQueryEngine from '@deepseek-ai/dsh-session-query-sqlite'
import * as SessionStatsPlugin from '@deepseek-ai/dsh-session-stats'
import SessionTitleService from '@deepseek-ai/dsh-session-title'
import * as SessionTurnOutlinePlugin from '@deepseek-ai/dsh-session-turn-outline'
import Storage from '@deepseek-ai/dsh-storage'
import * as StorageDomain from '@deepseek-ai/dsh-storage-domain'
import type { KvTable } from '@deepseek-ai/dsh-storage-domain'
import * as StorageJson from '@deepseek-ai/dsh-storage-json'
import TokenMeter from '@deepseek-ai/dsh-token-meter'
import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts'
import { serializeRecord } from '../../packages/storage/storage-json/src/format.ts'
import { ANCHOR_COUNT } from './corpus-shape.ts'
import {
anchorHeader,
corpusSessionId,
forEachConcurrently,
SEARCH_QUERIES,
SyntheticCorpusWriter,
type AnchorBodyFacts,
type SyntheticCorpusFacts,
type WrittenSession,
} from './synthetic-corpus.ts'
/** Process memory after the measured endpoint, with its result still reachable. */
export interface CorpusMemory {
readonly heapUsedMb: number
readonly rssMb: number
readonly peakRssMb: number
}
/** Anchor preparation report: authoring, replay validation, and projection folding of one anchor group. */
export interface AnchorsReport {
readonly mode: 'anchors'
readonly anchors: readonly number[]
readonly prepareMs: number
}
/** Seeding report: facts of one named corpus written from prepared anchors, and the time spent writing it. */
export interface SeedReport {
readonly mode: 'seed'
readonly name: string
readonly corpus: SyntheticCorpusFacts
readonly seedMs: number
}
/** List report: Host boot, then a cold and a repeated Session list. */
export interface ListReport {
readonly mode: 'list'
readonly bootMs: number
readonly firstMs: number
readonly repeatMs: number
readonly items: number
readonly itemsWithProjections: number
readonly memory: CorpusMemory
}
/** Search report: the first search builds the in-memory index; the second reuses it. */
export interface SearchReport {
readonly mode: 'search'
readonly bootMs: number
readonly firstMs: number
readonly repeatMs: number
readonly firstItems: number
readonly repeatItems: number
readonly memory: CorpusMemory
}
/** Fork report: one fork of each requested source, in request order. */
export interface ForkReport {
readonly mode: 'fork'
readonly bootMs: number
readonly forks: readonly {
readonly rank: number
readonly sourceEvents: number
readonly forkMs: number
}[]
readonly memory: CorpusMemory
}
/** Any worker report. */
export type SessionCorpusReport = AnchorsReport | SeedReport | ListReport | SearchReport | ForkReport
const BENCH_MODEL = { provider: 'bench', model: 'bench' } as const
/** Bound on waiting for fire-and-forget projection-cache write-backs after seeding. */
const SEED_DRAIN_TIMEOUT_MS = 120_000
function megabytes(bytes: number): number {
return Math.round(bytes / 104_857.6) / 10
}
async function memory(): Promise<CorpusMemory> {
const gc = (globalThis as typeof globalThis & { gc?: () => void }).gc
if (gc === undefined) throw new Error('Session corpus benchmark requires --expose-gc')
gc()
await scheduler.yield()
gc()
const usage = process.memoryUsage()
return {
heapUsedMb: megabytes(usage.heapUsed),
rssMb: megabytes(usage.rss),
peakRssMb: Math.round(process.resourceUsage().maxRSS / 102.4) / 10,
}
}
/** Mount the shipped Web Host's Session list, search, and fork dependencies. */
async function mountHost(root: string): Promise<{ readonly ctx: Context; readonly controller: SessionController }> {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(JsonlSessionPersistence, { root: join(root, 'sessions'), compression: 'zstd' })
await ctx.plugin(Storage)
await ctx.plugin(StorageJson, { root: join(root, 'storages') })
await ctx.plugin(StorageDomain, { backend: 'json' })
await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 200, writeIntervalMs: 5_000 })
// Content search is opt-in; enabled deployments defer the in-memory index to the first search.
await ctx.plugin(SqliteSessionQueryEngine, { path: ':memory:', openAt: 'first-search' })
ctx.sessionProjections.register(agentPresetProjectionDefinition)
await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 80 })
await ctx.plugin(SessionStatsPlugin)
await ctx.plugin(SessionTurnOutlinePlugin)
await ctx.plugin(TokenMeter)
await ctx.plugin(AgentLoop, { agents: [] })
// Transport, upload, model-default, and Workspace services are outside every measured path.
const dispose = (): void => {}
ctx.provide('typert', { lookups: { configure: () => dispose }, contexts: { configureHost: () => dispose } } as never)
ctx.provide('fileUploads', { registerAgentResolver: () => dispose } as never)
ctx.provide('agentDefaultModel', { currentSelection: () => BENCH_MODEL } as never)
ctx.provide('workspaceRegistry', { list: () => [], archivedSessionIds: [] } as never)
const controller = new SessionController(ctx, { nativeOpen: false }, { canOpenPath: () => false })
return { ctx, controller }
}
/** Run one operation against the projection-cache table stored under `root`. */
async function withCacheTable<Value>(
root: string,
operation: (table: KvTable<SessionId, CheckpointRecord>) => Promise<Value>,
): Promise<Value> {
const ctx = new Context()
try {
await ctx.plugin(Storage)
await ctx.plugin(StorageJson, { root: join(root, 'storages') })
await ctx.plugin(StorageDomain, { backend: 'json' })
const domain = await ctx.storageDomain.open(projectionCacheDomainSpec)
try {
return await operation(domain.table('sessions'))
} finally {
await domain.close()
}
} finally {
await ctx.fiber.dispose()
}
}
function anchorFactsPath(root: string, anchor: number): string {
return join(root, 'bodies', `anchor-${String(anchor).padStart(2, '0')}.json`)
}
/** Author, validate, and fold one group of anchors, saving bodies and projection records for `seed`. */
async function prepareAnchors(root: string, anchors: readonly number[]): Promise<AnchorsReport> {
const started = performance.now()
const writer = new SyntheticCorpusWriter()
const foldRoot = join(root, `fold-${anchors.join('-')}`)
const facts = new Map<number, AnchorBodyFacts>()
const host = await mountHost(foldRoot)
try {
for (const anchor of anchors) {
const header = anchorHeader(anchor)
const log = await writer.author(anchor)
// Replay validation, as in `sessionQuery.readSession`, rejects an invalid authored body before any write.
Session.create(header.id, log, header, SessionLogOffset(0), currentSessionMessageProjections)
// The production cold-read write-back folds each shared body once.
host.ctx.sessionProjectionCache.coldSnapshot(header, SessionLogOffset(0), log)
facts.set(anchor, await writer.saveBody(join(root, 'bodies'), anchor))
}
// Write-backs are fire-and-forget; the cache serves a record only after it is durable.
const deadline = performance.now() + SEED_DRAIN_TIMEOUT_MS
while (anchors.some(anchor => host.ctx.sessionProjectionCache.cachedSnapshot(anchorHeader(anchor)) === undefined)) {
if (performance.now() > deadline) throw new Error('projection-cache write-back did not become durable')
await scheduler.wait(20)
}
} finally {
await host.ctx.fiber.dispose()
}
await withCacheTable(foldRoot, async (table) => {
for (const anchor of anchors) {
const record = table.get(anchorHeader(anchor).id)
await writeFile(anchorFactsPath(root, anchor), JSON.stringify({ ...facts.get(anchor), record }))
}
})
return { mode: 'anchors', anchors, prepareMs: performance.now() - started }
}
/** Parse one prepared anchor file written by a sibling `anchors` process. */
async function readPreparedAnchor(root: string, anchor: number): Promise<AnchorBodyFacts & { readonly record: CheckpointRecord }> {
const value: unknown = JSON.parse(await readFile(anchorFactsPath(root, anchor), 'utf8'))
if (typeof value !== 'object' || value === null) throw new Error(`prepared anchor ${String(anchor)} is not an object`)
const { frameCount, events, logicalBytes, record } = value as Record<string, unknown>
for (const count of [frameCount, events, logicalBytes]) {
if (!Number.isSafeInteger(count)) throw new Error(`prepared anchor ${String(anchor)} has invalid body facts`)
}
return {
frameCount: frameCount as number,
events: events as number,
logicalBytes: logicalBytes as number,
record: checkpointRecord.parse(record),
}
}
/** Write one named corpus from prepared anchors, rebinding each projection record to its Session header. */
async function seed(root: string, name: string, count: number): Promise<SeedReport> {
const started = performance.now()
const writer = new SyntheticCorpusWriter()
const records = new Map<number, CheckpointRecord>()
for (let anchor = 0; anchor < ANCHOR_COUNT; anchor++) {
const { record, ...facts } = await readPreparedAnchor(root, anchor)
await writer.loadBody(join(root, 'bodies'), anchor, facts)
records.set(anchor, record)
}
const corpusRoot = join(root, `corpus-${name}`)
const written = await writer.writeCorpus(join(corpusRoot, 'sessions'), count)
// Sessions of one anchor share events, so their records differ only in the header-bound identity.
// Per-record documents are written directly: the domain's one-fsync-per-put chain would dominate seeding,
// and the list endpoint rejects a corpus whose rows the cache does not serve.
const table = join(corpusRoot, 'storages', projectionCacheDomainSpec.name, 'sessions')
await mkdir(table, { recursive: true })
await forEachConcurrently(written.sessions.length, async (rank) => {
const { header, anchor } = written.sessions[rank] as WrittenSession
const source = records.get(anchor)
if (source === undefined) throw new Error(`corpus anchor ${String(anchor)} has no folded projection record`)
await writeFile(join(table, `${header.id}.json`), serializeRecord(projectionCacheDomainSpec.version, {
identity: { ...source.identity, createdAt: header.createdAt, cwd: header.cwd },
rows: source.rows,
}))
})
return { mode: 'seed', name, corpus: written.facts, seedMs: performance.now() - started }
}
async function measureList(root: string, expected: number): Promise<ListReport> {
const started = performance.now()
const { ctx, controller } = await mountHost(root)
try {
const booted = performance.now()
const signal = new AbortController().signal
const first = await controller.list({}, signal)
const firstDone = performance.now()
const repeated = await controller.list({}, signal)
const done = performance.now()
if (first.items.length !== expected || repeated.items.length !== expected) {
throw new Error(`Session list returned ${String(first.items.length)} of ${String(expected)} Sessions`)
}
return {
mode: 'list',
bootMs: booted - started,
firstMs: firstDone - booted,
repeatMs: done - firstDone,
items: first.items.length,
itemsWithProjections: first.items.filter(item => item.projections !== undefined).length,
memory: await memory(),
}
} finally {
await ctx.fiber.dispose()
}
}
async function measureSearch(root: string): Promise<SearchReport> {
const started = performance.now()
const { ctx, controller } = await mountHost(root)
try {
const booted = performance.now()
const signal = new AbortController().signal
const first = await controller.search({ query: SEARCH_QUERIES[0] }, signal)
const firstDone = performance.now()
const repeated = await controller.search({ query: SEARCH_QUERIES[1] }, signal)
const done = performance.now()
if (first.items.length === 0 || repeated.items.length === 0) {
throw new Error('Session search matched no synthetic Session')
}
return {
mode: 'search',
bootMs: booted - started,
firstMs: firstDone - booted,
repeatMs: done - firstDone,
firstItems: first.items.length,
repeatItems: repeated.items.length,
memory: await memory(),
}
} finally {
await ctx.fiber.dispose()
}
}
async function measureFork(root: string, ranks: readonly number[]): Promise<ForkReport> {
const started = performance.now()
const { ctx, controller } = await mountHost(root)
try {
const booted = performance.now()
const forks: ForkReport['forks'][number][] = []
for (const rank of ranks) {
const forkStarted = performance.now()
const { sessionId } = await controller.fork({ sessionId: corpusSessionId(rank) })
const forkMs = performance.now() - forkStarted
const child = ctx.sessions.get(sessionId)
if (child === undefined) throw new Error(`fork of rank ${String(rank)} did not publish its child`)
forks.push({ rank, sourceEvents: child.inheritedEventCount, forkMs })
}
return { mode: 'fork', bootMs: booted - started, forks, memory: await memory() }
} finally {
await ctx.fiber.dispose()
}
}
assertBuiltBenchmarkRuntime(import.meta.url, Object.fromEntries([
'@deepseek-ai/dsh-api-session-controller',
'@deepseek-ai/dsh-session-persistence-jsonl',
'@deepseek-ai/dsh-session-query-sqlite',
'@deepseek-ai/dsh-session-projection-cache',
].map(name => [name, import.meta.resolve(name)])))
const USAGE = 'usage: session-corpus.worker.js <root> <anchors anchors...|seed name count|list count|search|fork ranks...>'
const [root, mode, ...rest] = process.argv.slice(2)
if (root === undefined) throw new Error(USAGE)
function counts(values: readonly string[]): number[] {
const parsed = values.map(Number)
if (parsed.some(value => !Number.isSafeInteger(value) || value < 0)) throw new Error(USAGE)
return parsed
}
let report: SessionCorpusReport
switch (mode) {
case 'anchors':
report = await prepareAnchors(root, counts(rest))
break
case 'seed': {
const [name, count] = rest
if (name === undefined || !/^[a-z]+$/.test(name) || count === undefined) throw new Error(USAGE)
report = await seed(root, name, counts([count])[0] as number)
break
}
case 'list':
report = await measureList(root, counts(rest)[0] ?? 0)
break
case 'search':
report = await measureSearch(root)
break
case 'fork':
report = await measureFork(root, counts(rest))
break
default:
throw new Error(`unknown Session corpus mode ${String(mode)}`)
}
process.stdout.write(JSON.stringify(report) + '\n')
@@ -0,0 +1,484 @@
/** Deterministic current-generation Session corpus whose length distribution follows `corpus-shape.ts`. */
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm/assistant-stream'
import { MessageId, ToolCallId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionEventMap, SessionEventType, SessionHeader } from '@deepseek-ai/dsh-session'
import {
eventLines,
generationLogPath,
toHeaderLine,
} from '../../packages/session/session-persistence-jsonl/src/format.ts'
import { compressZstdFrame } from '../../packages/session/session-persistence-jsonl/src/zstd.ts'
import { anchorShape, sessionShape, type SessionShape } from './corpus-shape.ts'
/** Time of every body's first event; Sessions are created in the preceding minutes. */
const TIME_ZERO = 1_700_000_000_000
/** Prime stride that permutes creation order independently of length rank. */
const CREATION_STRIDE = 7_919
/** Concurrent frame compressions and file writes while materializing a corpus. */
const IO_CONCURRENCY = 8
/** Measured local Sessions per project directory: 1,650 Sessions in 88 directories. */
const SESSIONS_PER_PROJECT = 19
/** Every sixth Session is a subagent child; the measured share is 288 of 1,650. */
const CHILD_EVERY = 6
/** Turn-one system prompt size; the measured median largest event is 21.9 KB. */
const SYSTEM_PROMPT_CHARS = 20_000
/** Characters per streamed text or reasoning delta; the measured mean is 3.9. */
const DELTA_CHARS = 4
/** Share of remaining payload characters by message slot. */
const PAYLOAD_SHARE = { user: 0.1, reasoning: 0.2, text: 0.25, toolResult: 0.45 } as const
/** Distinct words in the synthetic vocabulary and total words in the shared text pool. */
const VOCABULARY = { words: 8_192, poolWords: 2_000_000 } as const
/** Word ranks used by the measured searches: one frequent and one uncommon token. */
export const SEARCH_QUERIES = [vocabularyWord(40), vocabularyWord(1_500)] as const
/** Physical and logical facts of one written corpus. */
export interface SyntheticCorpusFacts {
/** Logical Sessions. */
readonly sessions: number
/** Distinct event bodies; Sessions of one anchor share one body. */
readonly distinctBodies: number
/** Project directories. */
readonly projects: number
/** Logical events across all Sessions. */
readonly events: number
/** Decompressed JSONL bytes across all Sessions. */
readonly logicalBytes: number
/** Stored Zstandard bytes across all Sessions. */
readonly compressedBytes: number
/** Zstandard frames across all Sessions. */
readonly frames: number
}
/** One written Session. */
export interface WrittenSession {
/** Stored header. */
readonly header: SessionHeader
/** Measured anchor whose body the Session stores. */
readonly anchor: number
}
/**
* Identify one Session by length rank.
* @param rank - zero-based ascending length rank.
* @returns stable Session id.
*/
export function corpusSessionId(rank: number): SessionId {
return SessionId('bench-' + String(rank).padStart(5, '0'))
}
function vocabularyWord(rank: number): string {
let value = rank + VOCABULARY.words
let word = ''
const letters = 'etaoinsrhdlucmfywgpbvkxqjz'
while (value > 0) {
word += letters[value % letters.length]
value = Math.floor(value / letters.length)
}
return word
}
/** Mulberry32: a small deterministic generator for fixture construction only. */
function random(seed: number): () => number {
let state = seed >>> 0
return () => {
state = (state + 0x6D2B79F5) >>> 0
let value = state
value = Math.imul(value ^ (value >>> 15), value | 1)
value ^= value + Math.imul(value ^ (value >>> 7), value | 61)
return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296
}
}
/** Zipf-distributed prose from which every message body takes a deterministic slice. */
class TextPool {
private readonly text: string
constructor() {
const next = random(1)
const cumulative: number[] = []
let total = 0
for (let rank = 1; rank <= VOCABULARY.words; rank++) {
total += 1 / rank
cumulative.push(total)
}
const words = Array.from({ length: VOCABULARY.words }, (_, rank) => vocabularyWord(rank))
const parts: string[] = []
for (let index = 0; index < VOCABULARY.poolWords; index++) {
const target = next() * total
let low = 0
let high = cumulative.length - 1
while (low < high) {
const middle = (low + high) >>> 1
if ((cumulative[middle] as number) < target) low = middle + 1
else high = middle
}
parts.push(words[low] as string, index % 13 === 12 ? '.\n' : ' ')
}
this.text = parts.join('')
}
slice(next: () => number, length: number): string {
if (length <= 0) return ''
const span = Math.min(length, this.text.length)
const start = Math.floor(next() * (this.text.length - span))
return this.text.slice(start, start + span)
}
}
interface PayloadPlan {
readonly userChars: number
readonly reasoningChars: number
readonly textChars: number
readonly resultChars: number
}
function planPayload(shape: SessionShape, toolSteps: number, payloadChars: number): PayloadPlan {
const replies = toolSteps + shape.turns
const textShare = toolSteps === 0 ? PAYLOAD_SHARE.text + PAYLOAD_SHARE.toolResult : PAYLOAD_SHARE.text
return {
userChars: Math.ceil((payloadChars * PAYLOAD_SHARE.user) / shape.turns),
reasoningChars: Math.ceil((payloadChars * PAYLOAD_SHARE.reasoning) / replies),
textChars: Math.ceil((payloadChars * textShare) / replies),
resultChars: toolSteps === 0 ? 0 : Math.ceil((payloadChars * PAYLOAD_SHARE.toolResult) / toolSteps),
}
}
function streamChunks(content: readonly ContentBlock[], finish: 'stop' | 'tool-calls'): StreamChunk[] {
const chunks: StreamChunk[] = []
content.forEach((block, index) => {
chunks.push({ type: 'block-start', index, blockType: block.type })
if (block.type === 'text' || block.type === 'reasoning') {
const type = block.type === 'text' ? 'text-delta' : 'reasoning-delta'
for (let offset = 0; offset < block.text.length; offset += DELTA_CHARS) {
chunks.push({ type, index, text: block.text.slice(offset, offset + DELTA_CHARS) })
}
} else if (block.type === 'tool-call') {
chunks.push({ type: 'tool-call-delta', index, id: block.id, name: block.name, argumentsDelta: block.arguments })
}
chunks.push({ type: 'block-end', index, block })
})
chunks.push({ type: 'usage', usage: { inputTokens: 20_000, outputTokens: 500 } })
chunks.push({ type: 'finish', reason: { kind: finish } })
return chunks
}
/** Raw log builder emitting the fields `Session.append` records, without its per-append validation. */
class RawLog {
readonly events: SessionEvent[] = []
readonly frameStarts = new Set<number>()
constructor(private readonly createdAt: number) {}
get seq(): number {
return this.events.length
}
/** Start a durable flush batch at the next event. */
flush(): void {
this.frameStarts.add(this.events.length)
}
append<T extends SessionEventType>(
type: T,
data: SessionEventMap[T],
surface: { readonly surfaceOp?: 'append'; readonly sourceEventSeqs?: readonly number[] } = {},
): number {
const seq = this.events.length
this.events.push({ type, seq, time: this.createdAt + seq * 1_000, data, ...surface } as SessionEvent<T>)
return seq
}
}
/** Authors one Session with production stream compaction; replay validation belongs to the fixture test. */
function authorEvents(
shape: SessionShape,
payloadChars: number,
pool: TextPool,
seed: number,
): { readonly events: SessionEvent[]; readonly frameStarts: ReadonlySet<number> } {
const next = random(seed)
const toolSteps = Math.max(0, Math.ceil((shape.events - 6 * shape.turns - 1) / 5))
const plan = planPayload(shape, toolSteps, payloadChars)
const log = new RawLog(TIME_ZERO)
for (let turn = 1; turn <= shape.turns; turn++) {
const turnSteps = Math.floor(toolSteps / shape.turns) + (turn <= toolSteps % shape.turns ? 1 : 0)
log.flush()
log.append('turn/start', { turn })
log.append('step/start', { turn, step: 1 })
if (turn === 1) log.append('system/message', {
turn, step: 1,
message: {
id: MessageId('system-head'), role: 'system',
content: [{ type: 'text', text: pool.slice(next, SYSTEM_PROMPT_CHARS) }],
source: { kind: 'system-prompt' },
},
}, { surfaceOp: 'append' })
log.append('user/message', {
id: MessageId('prompt-' + String(turn)), role: 'user',
content: [{ type: 'text', text: pool.slice(next, plan.userChars) }], source: { kind: 'user' },
}, { surfaceOp: 'append' })
for (let step = 1; step <= turnSteps + 1; step++) {
if (step > 1) log.append('step/start', { turn, step })
const callId = step <= turnSteps ? ToolCallId(`call-${String(turn)}-${String(step)}`) : undefined
const content: ContentBlock[] = [
{ type: 'reasoning', text: pool.slice(next, plan.reasoningChars) },
{ type: 'text', text: pool.slice(next, plan.textChars) },
...callId === undefined ? [] : [{
type: 'tool-call' as const, id: callId, name: 'read_file',
arguments: JSON.stringify({ path: `src/module-${String(turn)}-${String(step)}.ts` }),
}],
]
const stream = new AssistantStreamAccumulator()
const time = TIME_ZERO + log.seq * 1_000
for (const [index, chunk] of streamChunks(content, callId === undefined ? 'stop' : 'tool-calls').entries()) {
stream.push({ time: time + index, chunk })
}
log.flush()
log.append('assistant/message', {
turn, step,
message: {
id: MessageId(`reply-${String(turn)}-${String(step)}`), role: 'assistant', content,
source: { kind: 'model', provider: 'bench', model: 'bench' },
},
stream: [...stream.snapshot()],
}, { surfaceOp: 'append' })
if (callId !== undefined) {
log.flush()
const callSeq = log.append('tool/call', {
turn, step, callId, name: 'read_file', arguments: JSON.stringify({ path: `src/module-${String(turn)}-${String(step)}.ts` }),
})
log.flush()
log.append('tool/result', {
turn, step,
message: {
id: MessageId('result-' + callId), role: 'tool', toolCallId: callId, isError: false,
source: { kind: 'tool', callId },
content: [{ type: 'text', text: pool.slice(next, plan.resultChars) }],
},
}, { surfaceOp: 'append', sourceEventSeqs: [callSeq] })
}
log.append('step/end', { turn, step })
}
log.append('turn/end', { turn, reason: { kind: 'completed' } })
}
return { events: log.events, frameStarts: log.frameStarts }
}
function frameTexts(events: readonly SessionEvent[], frameStarts: ReadonlySet<number>): string[] {
const frames: string[] = []
let start = 0
for (let index = 1; index <= events.length; index++) {
if (index === events.length || frameStarts.has(index)) {
frames.push(eventLines(events.slice(start, index)) + '\n')
start = index
}
}
return frames
}
function headerLine(header: SessionHeader): string {
return JSON.stringify(toHeaderLine(header)) + '\n'
}
/** Size facts of one anchor body, saved beside its compressed frames. */
export interface AnchorBodyFacts {
readonly frameCount: number
readonly events: number
readonly logicalBytes: number
}
/** One compressed event body shared by every Session of one measured anchor. */
interface AnchorBody extends AnchorBodyFacts {
readonly frames: Buffer
}
/** One corpus written from authored anchor bodies. */
export interface WrittenCorpus {
/** Physical and logical totals. */
readonly facts: SyntheticCorpusFacts
/** Every stored Session in rank order. */
readonly sessions: readonly WrittenSession[]
}
/**
* Tell whether one corpus rank stores a subagent child.
* @param rank - zero-based ascending length rank.
* @returns whether the rank is a child of the preceding rank.
*/
export function subagentRank(rank: number): boolean {
return rank % CHILD_EVERY === CHILD_EVERY - 1
}
/**
* Build the stored header of one corpus rank.
* @param rank - zero-based ascending length rank.
* @param count - corpus size.
* @returns the rank's header; every sixth rank is a subagent child of the preceding rank.
*/
export function corpusHeader(rank: number, count: number): SessionHeader {
if (count % CREATION_STRIDE === 0) throw new Error('corpus size must be coprime with the creation stride')
const child = subagentRank(rank)
// Creation time is a fixed permutation of length rank. List order follows each body's last prompt time,
// which Sessions sharing an anchor also share.
const createdAt = TIME_ZERO - (((rank * CREATION_STRIDE) % count) + 1) * 60_000
return {
version: SESSION_FORMAT_VERSION,
id: corpusSessionId(rank),
createdAt,
cwd: `/bench/project-${String(rank % Math.ceil(count / SESSIONS_PER_PROJECT))}`,
isSeeded: false,
...child
? { origin: 'subagent' as const, parentSession: corpusSessionId(rank - 1), delegationDepth: 1 }
: { delegationDepth: 0 },
}
}
/**
* Build the header under which one anchor's projections are folded before identity rebinding.
* @param anchor - measured anchor index.
* @returns a top-level header outside every corpus.
*/
export function anchorHeader(anchor: number): SessionHeader {
return {
version: SESSION_FORMAT_VERSION,
id: SessionId(`bench-anchor-${String(anchor).padStart(2, '0')}`),
createdAt: TIME_ZERO - 60_000,
cwd: '/bench/anchors',
isSeeded: false,
delegationDepth: 0,
}
}
function anchorBodyPath(directory: string, anchor: number): string {
return join(directory, `anchor-${String(anchor).padStart(2, '0')}.zst`)
}
/**
* Run one asynchronous compression or file operation per index with bounded concurrency.
* @param count - number of indexes, starting at zero.
* @param operation - operation for one index.
*/
export async function forEachConcurrently(count: number, operation: (index: number) => Promise<void>): Promise<void> {
let next = 0
const worker = async (): Promise<void> => {
for (let index = next++; index < count; index = next++) await operation(index)
}
await Promise.all(Array.from({ length: IO_CONCURRENCY }, () => worker()))
}
/** Authors each anchor body once, exchanges bodies between seeding processes, and writes corpora that share them. */
export class SyntheticCorpusWriter {
private pool: TextPool | undefined
private readonly bodies = new Map<number, AnchorBody>()
/** Stored-byte model from the previous anchor: fixed system prompt, bytes per event, and bytes per payload character. */
private model = { bytesPerEvent: 0, bytesPerChar: 4 }
/**
* Author one anchor's body; its body bytes alone reach the anchor's logical-byte target.
* @param anchor - measured anchor index.
* @returns the authored events, which the writer does not retain.
*/
async author(anchor: number): Promise<readonly SessionEvent[]> {
const shape = anchorShape(anchor)
// Start from the previous anchor's model, then take secant steps from below.
const { bytesPerEvent, bytesPerChar } = this.model
let payloadChars = Math.max(0, Math.ceil(
(1.01 * (shape.logicalBytes - SYSTEM_PROMPT_CHARS - bytesPerEvent * shape.events)) / bytesPerChar,
))
let previous: { readonly payloadChars: number; readonly bytes: number } | undefined
for (let attempt = 0; attempt < 8; attempt++) {
this.pool ??= new TextPool()
const authored = authorEvents(shape, payloadChars, this.pool, anchor + 1)
if (authored.events.length < shape.events) {
throw new Error(`corpus anchor ${String(anchor)} authored ${String(authored.events.length)} of ${String(shape.events)} events`)
}
const frames = frameTexts(authored.events, authored.frameStarts)
const bytes = frames.reduce((sum, text) => sum + Buffer.byteLength(text), 0)
if (previous !== undefined && payloadChars > previous.payloadChars) {
this.model.bytesPerChar = (bytes - previous.bytes) / (payloadChars - previous.payloadChars)
}
this.model.bytesPerEvent = (bytes - SYSTEM_PROMPT_CHARS - this.model.bytesPerChar * payloadChars) / authored.events.length
if (bytes < shape.logicalBytes) {
previous = { payloadChars, bytes }
// Every message rounds its share up, so each step adds at least one character per event.
payloadChars += Math.ceil((1.01 * (shape.logicalBytes - bytes)) / this.model.bytesPerChar) + shape.events
continue
}
// Each pending compression owns a native Zstandard context, so compression is bounded like writes.
const compressed: Buffer[] = []
await forEachConcurrently(frames.length, async (index) => {
compressed[index] = await compressZstdFrame(frames[index] as string)
})
this.bodies.set(anchor, {
frames: Buffer.concat(compressed), frameCount: frames.length, events: authored.events.length, logicalBytes: bytes,
})
return authored.events
}
throw new Error(`corpus anchor ${String(anchor)} did not reach ${String(shape.logicalBytes)} logical bytes`)
}
/**
* Save one authored body's compressed frames for another process.
* @param directory - body directory shared by the seeding processes.
* @param anchor - authored anchor index.
* @returns the body's size facts.
*/
async saveBody(directory: string, anchor: number): Promise<AnchorBodyFacts> {
const body = this.bodies.get(anchor)
if (body === undefined) throw new Error(`corpus anchor ${String(anchor)} was not authored`)
await mkdir(directory, { recursive: true })
await writeFile(anchorBodyPath(directory, anchor), body.frames)
return { frameCount: body.frameCount, events: body.events, logicalBytes: body.logicalBytes }
}
/**
* Load one body saved by another process.
* @param directory - body directory shared by the seeding processes.
* @param anchor - anchor index.
* @param facts - size facts recorded with the body.
*/
async loadBody(directory: string, anchor: number, facts: AnchorBodyFacts): Promise<void> {
this.bodies.set(anchor, { ...facts, frames: await readFile(anchorBodyPath(directory, anchor)) })
}
/**
* Write every rank of one corpus from authored bodies.
* @param root - JSONL persistence root.
* @param count - corpus size; the length distribution is sampled at this many ranks.
* @returns totals and the stored headers.
*/
async writeCorpus(root: string, count: number): Promise<WrittenCorpus> {
const totals = { events: 0, logicalBytes: 0, compressedBytes: 0, frames: 0 }
const sessions: WrittenSession[] = []
await forEachConcurrently(count, async (rank) => {
const { anchor } = sessionShape(rank, count)
const body = this.bodies.get(anchor)
if (body === undefined) throw new Error(`corpus anchor ${String(anchor)} was not authored`)
const header = corpusHeader(rank, count)
const line = headerLine(header)
const physical = Buffer.concat([await compressZstdFrame(line), body.frames])
const path = generationLogPath(root, header.cwd, header.id, SESSION_FORMAT_VERSION, 'zstd')
await mkdir(dirname(path), { recursive: true })
await writeFile(path, physical)
sessions[rank] = { header, anchor }
totals.events += body.events
totals.logicalBytes += Buffer.byteLength(line) + body.logicalBytes
totals.compressedBytes += physical.byteLength
totals.frames += body.frameCount + 1
})
return {
facts: {
sessions: count,
projects: Math.ceil(count / SESSIONS_PER_PROJECT),
distinctBodies: new Set(sessions.map(session => session.anchor)).size,
...totals,
},
sessions,
}
}
}
+7
View File
@@ -47,6 +47,13 @@ export default defineConfig([
clean: true,
tsconfig: 'tsconfig.host.json',
},
{
...shared,
entry: { 'session-corpus.worker': 'session-corpus/session-corpus.worker.ts' },
outDir: '.dsh-build/session-corpus',
clean: true,
tsconfig: 'tsconfig.host.json',
},
{
...shared,
entry: {
+2 -2
View File
@@ -9,8 +9,8 @@
en: 2cc80a2a79e23005
zh: 87e43383055890ce
/tool-authoring-reference/rules-of-the-execute-contract:
en: 110fa68298c53f96
zh: f5762893eb615ddf
en: 0b85f46f6a5bd8af
zh: 56eb8d05198333dc
/tool-authoring-reference/long-running-work:
en: d81167332dfd05b7
zh: 1c878b60f70728c2
+1 -1
View File
@@ -46,7 +46,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool. S
- **Throwing or returning an invalid value means `isError`.** The registry catches throws and contains schema, renderer, metadata-projector, and lossless-JSON failures before observers run. Throw for infrastructure failures. Represent a successful domain outcome in the canonical value even when its Native renderer explains a non-ideal state, such as a non-zero process exit.
- **Honor `exec.signal`.** Cancel in-flight work when it fires.
- **Project durable card data with `presentationMeta` (optional).** `output.presentationMeta(args, value)` derives replayable JSON from the same canonical value. The core persists it on `tool/result` and hands it to `presentResult`, so a card that needs result-time facts—such as `write`/`edit` applied hunks—survives replay without persisting the canonical value. The projector is skipped for nested Code dispatches because they have no cards.
- **Use `exec.agent` for async notifications.** `agent.inject({ content, source: { kind: 'plugin', plugin: '<name>' } })` appends durable context the NEXT model request sees — it is not a wake-up (an idle agent stays idle). Guard against disposed agents (try/catch).
- **Use `exec.agent` for async notifications.** `agent.inject({ content, source: { kind: 'my-plugin' } })` appends durable context the NEXT model request sees — it is not a wake-up (an idle agent stays idle). Declare that `kind` through [`MessageSourceMap`](../subsystems/llm-streaming.md#content-blocks-and-messages) in your plugin: session format V4 rejects the retired `{ kind: 'plugin', plugin: '<name>' }` wrapper at message admission. Guard against disposed agents (try/catch).
## Long-running work
+1 -1
View File
@@ -46,7 +46,7 @@ export function apply(ctx: Context) {
- **抛出异常或返回无效值意味着 `isError`。** 注册表会捕获异常,并在观察者运行前收敛 schema、渲染器、元数据投影器和无损 JSON 失败。基础设施故障请抛异常。成功的领域结果即使表示不理想的状态,也应写入规范值;其 Native 渲染器可以解释该状态,例如进程以非零状态退出。
- **遵守 `exec.signal`。** 信号触发时取消进行中的工作。
- **使用 `presentationMeta` 投影持久化的卡片数据(可选)。** `output.presentationMeta(args, value)` 从同一个规范值派生可回放的 JSON。核心将其持久化在 `tool/result` 上并传给 `presentResult`,因此需要结果期事实的卡片——例如 `write`/`edit` 的已应用 hunk——无需持久化规范值也能在回放中重现。嵌套 Code 分发没有卡片,因此会跳过该投影器。
- **使用 `exec.agent` 发送异步通知。** `agent.inject({ content, source: { kind: 'plugin', plugin: '<name>' } })` 追加持久化上下文,下一次模型请求会看到它——这不是唤醒(空闲的 agent(智能体)保持空闲)。请防范已 dispose 的 agent(try/catch)。
- **使用 `exec.agent` 发送异步通知。** `agent.inject({ content, source: { kind: 'my-plugin' } })` 追加持久化上下文,下一次模型请求会看到它——这不是唤醒(空闲的 agent(智能体)保持空闲)。在插件里通过 [`MessageSourceMap`](../subsystems/llm-streaming.zh.md#content-blocks-and-messages) 声明该 `kind`:session format V4 在消息准入处拒绝退役的 `{ kind: 'plugin', plugin: '<name>' }` 包装。请防范已 dispose 的 agent(try/catch)。
## 长时间运行的工作
+2 -2
View File
@@ -21,5 +21,5 @@
en: f0a28d07c0bb4d1b
zh: 840962f512cb4031
/cookbook-extension-plugin-shapes/the-feature-mechanism-map:
en: 2d4511fc000afc42
zh: d4875bf1e3f2808f
en: 0504676a42dd1445
zh: b33fba88ec2ace9c
+1 -1
View File
@@ -124,7 +124,7 @@ Every product feature maps to a listener on a documented extension point — the
| MCP | one plugin per server: discover tools → `ctx.tools.register()` |
| Skills | section + tool registration; `inject()` skill content on invocation |
| Memory | section provider + tool |
| Scheduled tasks (cron) | a plugin registers model-callable scheduling tools; timer fires → `followup(…, {source: {kind: 'plugin', plugin: 'schedule'}})` when idle / `inject()` notification when busy |
| Scheduled tasks (cron) | a plugin registers model-callable scheduling tools; a due timer delivers through `followup(…, {source: {kind: 'schedule'}})` |
| UI (GUI; CLI emits JSONL) | listen to `agent/assistant-stream` for live chunks and `session/event` for durable settlements, boundaries, and tool activity; input → `followup()` |
| Web Client Chat business node | register a `ConversationNodeDefinition` and `conversation.chat.node` keyed renderer |
| SessionTelemetryBackend / replayable trace | `session/event` → JSONL; replay = `sessions.create(id, { seed })` |
+1 -1
View File
@@ -128,7 +128,7 @@ export function apply(ctx: Context) {
| MCP | 每个服务器一个插件:发现工具 → `ctx.tools.register()` |
| skill(技能) | section + 工具注册;调用时通过 `inject()` 注入 skill 内容 |
| 记忆 | section 提供方 + 工具 |
| 定时任务(cron) | 插件注册面向模型的调度工具;定时器触发 → 空闲时 `followup(…, {source: {kind: 'plugin', plugin: 'schedule'}})`/忙碌时 `inject()` 通知 |
| 定时任务(cron) | 插件注册面向模型的调度工具;定时器到期后通过 `followup(…, {source: {kind: 'schedule'}})` 投递 |
| UI(GUI;CLI(命令行界面)输出 JSONL) | 监听 `agent/assistant-stream` 的实时 chunk,并监听 `session/event` 的持久 settlement、边界与工具活动;输入 → `followup()` |
| Web Client Chat 业务节点 | 注册 `ConversationNodeDefinition` 与 `conversation.chat.node` keyed renderer |
| 遥测 / 可回放 trace | `session/event` → JSONL;回放 = `sessions.create(id, { seed })` |
+3 -3
View File
@@ -56,9 +56,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `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:243`](../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:249`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:256`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:248`](../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:254`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:261`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
| `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:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
+3 -3
View File
@@ -58,9 +58,9 @@
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `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:243`](../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:249`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:256`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:248`](../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:254`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:261`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
| `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:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
+2 -2
View File
@@ -6,5 +6,5 @@
en: 962b4398c9052959
zh: 14471c340a9f6e60
/profile-management/management-records:
en: c49daf51d1468b37
zh: f4964a074ca9002f
en: 6929d1001684c024
zh: 06d3d3f6eea848ec
+3 -3
View File
@@ -10,7 +10,7 @@ The [boot package group](../../packages/boot/README.md) owns launcher-provided p
`PluginInfo` carries module identity, effective enablement, fiber phase and optional display `meta`, plus a unique `patchId` or a `readOnlyReason`.
`BundleInfo` carries the package name, optional installed version, selected enablement, removal availability and optional resolution error. Its optional `meta` and each `BundleRowInfo.meta` contain display text or a metadata diagnostic; Clients select a language at render time.
`BundleInfo` carries the package name, optional installed version, selected enablement, removal availability, optional resolution error, and, for a bundle the profile's own dependency supplies and the installation does not, `source`: that dependency as a spec `pnpm add` accepts. Its optional `meta` and each `BundleRowInfo.meta` contain display text or a metadata diagnostic; Clients select a language at render time.
`InstallBundleOptions.enabled` defaults to true. False installs without selecting the bundle layer. `approvedBuilds` grants persistent script permission to the supplied pending package names before installation. `registry` names the registry asked first; absent, the configured one.
@@ -111,8 +111,8 @@ Manage profile files and apply their declared reload lifecycle.
/** Read the profile's installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles.
* A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected.
* @returns Package versions, manifest descriptions, rows, optional display metadata, activation selections,
* whether the installation offers the bundle, and removal availability.
* @returns Package versions, manifest descriptions, the installable spec of profile dependencies, rows, optional
* display metadata, activation selections, whether the installation offers the bundle, and removal availability.
*/
@Remote listBundles(): Promise<BundleInfo[]>
+3 -3
View File
@@ -10,7 +10,7 @@
`PluginInfo` 包含模块标识、实际启停状态、fiber 阶段和可选的展示 `meta`,以及唯一的 `patchId` 或 `readOnlyReason`。
`BundleInfo` 包含包名、可选的安装版本、组合层选择状态、删除可用性及可选的解析错误。它的可选 `meta` 与各行的 `BundleRowInfo.meta` 包含展示文本或元信息诊断;Client 在渲染时选择语言。
`BundleInfo` 包含包名、可选的安装版本、组合层选择状态、删除可用性、可选的解析错误;由 profile 自身依赖提供、且安装不提供的组合包还带有 `source`,即该依赖在 `pnpm add` 中可用的 spec。它的可选 `meta` 与各行的 `BundleRowInfo.meta` 包含展示文本或元信息诊断;Client 在渲染时选择语言。
`InstallBundleOptions.enabled` 默认为 true,false 表示安装但不选择组合包层。`approvedBuilds` 在安装前向指定的待审批包名授予持久脚本权限。`registry` 指定首先询问的注册表;缺省为配置的那个。
@@ -111,8 +111,8 @@ Manage profile files and apply their declared reload lifecycle.
/** Read the profile's installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles.
* A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected.
* @returns Package versions, manifest descriptions, rows, optional display metadata, activation selections,
* whether the installation offers the bundle, and removal availability.
* @returns Package versions, manifest descriptions, the installable spec of profile dependencies, rows, optional
* display metadata, activation selections, whether the installation offers the bundle, and removal availability.
*/
@Remote listBundles(): Promise<BundleInfo[]>
+2 -2
View File
@@ -27,8 +27,8 @@
en: 93798ad69f74ddd5
zh: 634126ad8accc356
/host-wide-schedule/web-catalog:
en: eb4c61818bb12594
zh: 47fe1f8da7f4aaae
en: b5e5ac8125e65f33
zh: 9ee064d833a4f838
/host-wide-schedule/timing-edits:
en: ce5d2095a3ad2e82
zh: 167b3715f8eaffc1
+1 -1
View File
@@ -339,7 +339,7 @@ type ScheduleCatalogEntry = ScheduleRecord & {
The Remote `schedule.list({ sessionId })`, model `schedule_list`, and Session-header catalog return only active tasks. A model view's derived timing `state` remains distinct from stored lifecycle `status`. Deletion uses `schedule.delete({ sessionId, id })` with the entry's original binding; a mismatched binding returns not found. Model tools supply the current Agent's Session, whereas the global user interface supplies the selected task's binding. The binding check alone does not establish caller authorization. The payload-free `schedule/changed` event invalidates client lists; reconnecting clients fetch current state again.
The shipped Web composition mounts `ui-schedule` with the Host capability. The [client package](../../packages/client/ui-schedule/README.md) owns the catalog, empty state, and deletion controls. The page separately filters all, active, and inactive tasks, retains inactive details and an original-Session control in the detail tab strip, and requires explicit confirmed deletion. Rules and Delivery records separate task settings from lazily paged saved receipts. Neither a receipt nor inactive status confirms model execution.
The shipped Web composition mounts `ui-schedule` with the Host capability. The [client package](../../packages/client/ui-schedule/README.md) owns the catalog, empty state, and deletion controls. The page separately filters all, active, and inactive tasks, retains inactive details and an original-Session control in the detail tab strip, and requires explicit confirmed deletion. The Rules and Records tabs separate task settings from lazily paged saved receipts. Neither a receipt nor inactive status confirms model execution.
## Timing edits
+1 -1
View File
@@ -339,7 +339,7 @@ type ScheduleCatalogEntry = ScheduleRecord & {
Remote 方法 `schedule.list({ sessionId })`、模型 `schedule_list` 和 Session 页头目录仅返回活动任务。模型视图派生的时间 `state` 与存储的生命周期 `status` 仍是不同概念。删除通过 `schedule.delete({ sessionId, id })` 使用条目的原始绑定;绑定不匹配时返回未找到。模型工具传入当前 Agent 的 Session,全局用户界面则传入所选任务的绑定。仅校验绑定并不构成调用者鉴权。无 payload 的 `schedule/changed` 事件通知客户端刷新列表;重新连接后,客户端再次获取当前状态。
随发行版交付的 Web 组合把 `ui-schedule` 与宿主能力一起挂载。[客户端包](../../packages/client/ui-schedule/README.zh.md) 负责目录、空状态与删除控件。页面单独筛选全部、活动和未运行任务,保留未运行任务详情及详情页签条内的原 Session 入口,并要求显式确认删除。“规则”和“发送记录”将任务设置与按需分页加载的已保存回执分开。回执与未运行状态均不确认模型执行。
随发行版交付的 Web 组合把 `ui-schedule` 与宿主能力一起挂载。[客户端包](../../packages/client/ui-schedule/README.zh.md) 负责目录、空状态与删除控件。页面单独筛选全部、活动和未运行任务,保留未运行任务详情及详情页签条内的原 Session 入口,并要求显式确认删除。“规则”和“任务运行记录”页签将任务设置与按需分页加载的已保存回执分开。回执与未运行状态均不确认模型执行。
## 修改时间
+2 -2
View File
@@ -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: bda026282132e290
zh: 3e3faf96c72420d6
en: 8d33b164230344fa
zh: 13a930ab96280744
/web-client-slots/declaration-and-lifecycle:
en: f2d2c0d3b8f14ec7
zh: 441ed13ff3f81d2a
+3
View File
@@ -8,6 +8,8 @@ This page documents slot ownership, component inputs, extension APIs, and the sh
`plugins.bundle.config` supplies bundle detail configuration, keyed by npm package name. `plugins.bundle.activation` renders optional guidance after user-requested enablement, with owner callbacks to dismiss or open that bundle’s details. `conversation.input.activity` supplies one action between the model selector and Send, with toolbar expansion released on unmount.
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.
## Declaration and lifecycle
`SlotMap` is the compile-time registry. A package declaration-merges the key, cardinality, scope, owner props, keyed props, and optional slot-level inject face. The runtime declaration is the matching `children` entry on the component that owns the render location.
@@ -133,6 +135,7 @@ root
│ ├─ settings.models.footer
│ └─ settings.plugins.tab
├─ main
│ ├─ plugins.add.actions
│ ├─ plugins.item
│ ├─ plugins.bundle.config
│ ├─ plugins.row.config
+3
View File
@@ -8,6 +8,8 @@ Slots 是 Web Client 的类型化 React 组合系统。[`dsh-client-ui-slots`](.
`plugins.bundle.config` 以 npm 包名为 key,提供 Bundle 详情配置。`plugins.bundle.activation` 在用户显式启用后渲染可选引导,由拥有者提供关闭引导和打开该 Bundle 详情的回调。`conversation.input.activity` 提供模型选择器与发送按钮之间的单个活动控件,可展开工具栏并在卸载时释放展开状态。
插件目录声明 `plugins.add.actions`,它是 root 作用域的 list,在添加插件菜单的安装项之后提供 `MenuItemButton` 行。owner 提供 `onDismiss()`,用于开始动作前关闭菜单。该 slot 遵循所属插件页面声明的生命周期。
## 声明与生命周期
`SlotMap` 是编译期注册表。包通过声明合并写入 key、cardinality(基数)、scope、owner props、keyed props 与可选的 slot 级 inject face。运行时声明则是拥有该渲染位置的组件在 `children` 中给出的对应条目。
@@ -133,6 +135,7 @@ root
│ ├─ settings.models.footer
│ └─ settings.plugins.tab
├─ main
│ ├─ plugins.add.actions
│ ├─ plugins.item
│ ├─ plugins.bundle.config
│ ├─ plugins.row.config
+2 -2
View File
@@ -24,8 +24,8 @@
en: c063711f8247c240
zh: 5e54758bbaf1670c
/web-client-architecture/conversation-and-presentation:
en: 9e0d6a3dfdbc4c12
zh: 0eea67d515ec56ef
en: 71362f4ac9490bcb
zh: 025507ca615f5e2a
/web-client-architecture/data-paths:
en: a254278432416473
zh: 567c2d3c6a20b304
+1 -1
View File
@@ -55,7 +55,7 @@ This pairing is not a second source of business truth. Host controllers decide d
## Conversation and presentation
Web and desktop share the [Coding Tools preference](../../packages/client/ui-settings/README.md#use-this-package). It controls diagnostic Views, new-session preset selection, changed-file cards, and the builtin HTML preview policy without changing Session records.
Web and desktop share the [Coding Tools preference](../../packages/client/ui-settings/README.md#use-this-package). It controls diagnostic Views, [available preset choices](../../packages/client/ui-agent-preset/README.md), changed-file cards, and the builtin HTML preview policy without changing Session records.
`ui-session` installs the Session scope adapter and publishes `useSessions`, `useSessionStatus`, `useSessionRetainInfo`, `useSession`, `sessionId`, and `useProjection`. `SessionProvider` inherits an outer binding or binds an explicit `SessionReference`, so concurrent subtrees can target different Sessions. Domain adapters add further standard sources without putting React hooks on the model objects.
+1 -1
View File
@@ -55,7 +55,7 @@ Connection 拥有请求 URL 解析、request correlation、`/api` carrier、trus
## Conversation 与 presentation
Web 和桌面端共享[代码工作工具偏好](../../packages/client/ui-settings/README.zh.md#use-this-package)。它控制诊断 View、新会话预设选择、改动文件卡片和内置 HTML 预览策略,不改变 Session 记录。
Web 和桌面端共享[代码工作工具偏好](../../packages/client/ui-settings/README.zh.md#use-this-package)。它控制诊断 View、[可用的预设选项](../../packages/client/ui-agent-preset/README.zh.md)、改动文件卡片和内置 HTML 预览策略,不改变 Session 记录。
`ui-session` 安装 Session scope adapter,并提供 `useSessions`、`useSessionStatus`、`useSessionRetainInfo`、`useSession`、`sessionId` 和 `useProjection`。`SessionProvider` 可以继承外围 binding,也可以绑定显式 `SessionReference`,因此并存子树可以指向不同 Session。领域 adapter 可以继续添加标准 source,但不会把 React hook 放进 model object。
+6 -6
View File
@@ -60,8 +60,8 @@
en: 76fbbf0fe38aef10
zh: 76fbbf0fe38aef10
/tool-schema-catalog/deepseek-ai-dsh-tools/run-code:
en: 8358b1f3dd09e202
zh: 0dbce569ba4b4c76
en: daae801c6b93b624
zh: 7cd6fd029b683d69
/tool-schema-catalog/deepseek-ai-dsh-plan-mode:
en: b500b530845e26a9
zh: b500b530845e26a9
@@ -72,8 +72,8 @@
en: 408ff0a5189bd7ec
zh: 408ff0a5189bd7ec
/tool-schema-catalog/deepseek-ai-dsh-tool-bash/bash:
en: c7be3094a90e8ce0
zh: b7f827b7f2511504
en: 5efe055aae48993c
zh: e0653e51ef1344ce
/tool-schema-catalog/deepseek-ai-dsh-tool-present:
en: f656575e6df094f6
zh: f656575e6df094f6
@@ -84,8 +84,8 @@
en: b222fc05f3f8ee4a
zh: b222fc05f3f8ee4a
/tool-schema-catalog/deepseek-ai-dsh-tool-pwsh/pwsh:
en: 447665e21ecb2aed
zh: 0de883d9438f6f38
en: 00489572a7e8cb3e
zh: c0ecb3909392ee89
/tool-schema-catalog/deepseek-ai-dsh-tool-cordis:
en: 76c7c62f9029b6f5
zh: 76c7c62f9029b6f5
+23 -23
View File
@@ -523,20 +523,20 @@ ask_user_question keeps the original blocking behavior by default; set `mode: ti
### `run_code`
Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return is program output — curate it. Image-bearing subtool results are attached after the run.
Execute a TypeScript program against the available tools. Takes two required arguments: `description`, a short summary of what the program does, and `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work). Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return is program output — curate it. Image-bearing subtool results are attached after the run.
```json
{
"type": "object",
"properties": {
"description": {
"type": "string",
"description": "Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Provide `description` before `code` in the arguments. Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."
},
"code": {
"type": "string",
"description": "The program: the body of an async TypeScript function."
},
"description": {
"type": "string",
"description": "Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."
},
"timeoutMs": {
"type": "number",
"description": "Positive elapsed-time budget in milliseconds, capped by the deployment maximum."
@@ -555,8 +555,8 @@ Execute a TypeScript program against the available tools. Takes two required arg
}
},
"required": [
"code",
"description"
"description",
"code"
]
}
```
@@ -598,20 +598,20 @@ exit_plan_mode stays in the model-facing schema while planning is inactive so tr
### `bash`
Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell; pass `workdir` instead of using `cd`. Managed `$DSH_*` variables expose current harness environment facts. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Before any delete or move, verify that the resolved absolute target path is the intended one; never run it against a computed path you have not checked. An unset variable expands to an empty string, so guard variables in such paths with `${VAR:?}`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]`, a policy denial: do not retry another way.
Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell; pass `workdir` instead of using `cd`. Managed `$DSH_*` variables expose current harness environment facts. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Provide `description` before `command` in the arguments. Before any delete or move, verify that the resolved absolute target path is the intended one; never run it against a computed path you have not checked. An unset variable expands to an empty string, so guard variables in such paths with `${VAR:?}`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]`, a policy denial: do not retry another way.
```json
{
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to execute."
},
"description": {
"type": "string",
"description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."
},
"command": {
"type": "string",
"description": "The bash command to execute."
},
"timeoutMs": {
"type": "number",
"description": "Timeout in milliseconds. The executor applies its configured default and cap; on expiry the command moves to the background as a job instead of being killed."
@@ -626,8 +626,8 @@ Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs
}
},
"required": [
"command",
"description"
"description",
"command"
]
}
```
@@ -686,20 +686,20 @@ Deliveries belong to the calling Session; Web ui-deliverables supplies source-fi
### `pwsh`
Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process; pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\...`); read environment variables with `$env:NAME`. Managed `$env:DSH_*` variables expose current harness environment facts. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. Before any delete or move, verify that the resolved absolute target path is the intended one; never run it against a computed path you have not checked. Do not assign to automatic variables such as `$HOME`; variable names are case-insensitive, so `$home` is the same read-only variable. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]`, a policy denial: do not retry another way.
Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process; pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\...`); read environment variables with `$env:NAME`. Managed `$env:DSH_*` variables expose current harness environment facts. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. Provide `description` before `command` in the arguments. Before any delete or move, verify that the resolved absolute target path is the intended one; never run it against a computed path you have not checked. Do not assign to automatic variables such as `$HOME`; variable names are case-insensitive, so `$home` is the same read-only variable. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]`, a policy denial: do not retry another way.
```json
{
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The PowerShell command to execute."
},
"description": {
"type": "string",
"description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"Get-Process\" → \"List running processes\"."
},
"command": {
"type": "string",
"description": "The PowerShell command to execute."
},
"timeoutMs": {
"type": "number",
"description": "Timeout in milliseconds. The executor applies its configured default and cap; on expiry the command moves to the background as a job instead of being killed."
@@ -714,8 +714,8 @@ Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Eac
}
},
"required": [
"command",
"description"
"description",
"command"
]
}
```
@@ -955,7 +955,7 @@ Edit an existing UTF-8 text file by replacing literal text.
"properties": {
"file_path": {
"type": "string",
"description": "Path to edit, resolved by the filesystem backend."
"description": "Path to edit, resolved by the filesystem backend. Provide `file_path` before `old_string` and `new_string` in the arguments."
},
"old_string": {
"type": "string",
@@ -1040,7 +1040,7 @@ Create or fully replace a UTF-8 text file.
"properties": {
"file_path": {
"type": "string",
"description": "Path to write, resolved by the filesystem backend."
"description": "Path to write, resolved by the filesystem backend. Provide `file_path` before `content` in the arguments."
},
"content": {
"type": "string",
+23 -23
View File
@@ -527,20 +527,20 @@ ask_user_question 默认保持原有阻塞行为;设置 `mode: timed` 后才
### `run_code`
针对可用工具执行 TypeScript 程序。接受两个必填参数:`code`,即异步函数的**函数体**(仅使用可擦除语法;支持顶层 `await` 和 `return`);以及 `description`,简要说明该程序做什么。请根据系统提示词中的声明,以 `await tools.name(args)` 形式调用工具。只有打印或返回的内容属于程序输出,请谨慎筛选。含图片的子工具结果会在运行结束后附加。
针对可用工具执行 TypeScript 程序。接受两个必填参数:`description`,简要说明该程序做什么;以及 `code`,即异步函数的**函数体**(仅使用可擦除语法;支持顶层 `await` 和 `return`)。请根据系统提示词中的声明,以 `await tools.name(args)` 形式调用工具。只有打印或返回的内容属于程序输出,请谨慎筛选。含图片的子工具结果会在运行结束后附加。
```json
{
"type": "object",
"properties": {
"description": {
"type": "string",
"description": "Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Provide `description` before `code` in the arguments. Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."
},
"code": {
"type": "string",
"description": "The program: the body of an async TypeScript function."
},
"description": {
"type": "string",
"description": "Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."
},
"timeoutMs": {
"type": "number",
"description": "Positive elapsed-time budget in milliseconds, capped by the deployment maximum."
@@ -559,8 +559,8 @@ ask_user_question 默认保持原有阻塞行为;设置 `mode: timed` 后才
}
},
"required": [
"code",
"description"
"description",
"code"
]
}
```
@@ -602,20 +602,20 @@ ask_user_question 默认保持原有阻塞行为;设置 `mode: timed` 后才
### `bash`
执行 bash 命令(`bash -c`)并返回 stdout/stderr。每次调用都在新 shell 中运行;请传入 `workdir`,不要使用 `cd`。托管的 `$DSH_*` 变量公开当前 harness 环境信息。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。在任何删除或移动之前,请确认解析后的绝对目标路径正是预期路径;绝不要对未经检查的计算路径执行此类操作。未设置的变量会展开为空字符串,因此请用 `${VAR:?}` 保护此类路径中的变量。命令可能在文件沙箱中运行;被阻止的文件操作报告为 `[sandbox: file access denied under <mode> mode]`,这是策略拒绝:请勿换一种方式重试。
执行 bash 命令(`bash -c`)并返回 stdout/stderr。每次调用都在新 shell 中运行;请传入 `workdir`,不要使用 `cd`。托管的 `$DSH_*` 变量公开当前 harness 环境信息。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。请在参数中先提供 `description`,再提供 `command`。在任何删除或移动之前,请确认解析后的绝对目标路径正是预期路径;绝不要对未经检查的计算路径执行此类操作。未设置的变量会展开为空字符串,因此请用 `${VAR:?}` 保护此类路径中的变量。命令可能在文件沙箱中运行;被阻止的文件操作报告为 `[sandbox: file access denied under <mode> mode]`,这是策略拒绝:请勿换一种方式重试。
```json
{
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to execute."
},
"description": {
"type": "string",
"description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."
},
"command": {
"type": "string",
"description": "The bash command to execute."
},
"timeoutMs": {
"type": "number",
"description": "Timeout in milliseconds. The executor applies its configured default and cap; on expiry the command moves to the background as a job instead of being killed."
@@ -630,8 +630,8 @@ ask_user_question 默认保持原有阻塞行为;设置 `mode: timed` 后才
}
},
"required": [
"command",
"description"
"description",
"command"
]
}
```
@@ -690,20 +690,20 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。组合中有 job
### `pwsh`
执行 PowerShell 命令(`pwsh -Command`)并返回 stdout/stderr。每次调用都在新的 pwsh 进程中运行;请传入 `workdir`,不要使用 `cd`。路径采用 Windows 原生形式(`C:\...`);使用 `$env:NAME` 读取环境变量。托管的 `$env:DSH_*` 变量公开当前 harness 环境信息。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。在 Windows 上,被强制终止的命令会以 `[exit code: 1]` 结算且不带信号标记,请将其视为中断,而不是命令失败。在任何删除或移动之前,请确认解析后的绝对目标路径正是预期路径;绝不要对未经检查的计算路径执行此类操作。不要给 `$HOME` 等自动变量赋值;变量名不区分大小写,因此 `$home` 就是同一个只读变量。命令可能在文件沙箱中运行;被阻止的文件操作报告为 `[sandbox: file access denied under <mode> mode]`,这是策略拒绝:请勿换一种方式重试。
执行 PowerShell 命令(`pwsh -Command`)并返回 stdout/stderr。每次调用都在新的 pwsh 进程中运行;请传入 `workdir`,不要使用 `cd`。路径采用 Windows 原生形式(`C:\...`);使用 `$env:NAME` 读取环境变量。托管的 `$env:DSH_*` 变量公开当前 harness 环境信息。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。在 Windows 上,被强制终止的命令会以 `[exit code: 1]` 结算且不带信号标记,请将其视为中断,而不是命令失败。请在参数中先提供 `description`,再提供 `command`。在任何删除或移动之前,请确认解析后的绝对目标路径正是预期路径;绝不要对未经检查的计算路径执行此类操作。不要给 `$HOME` 等自动变量赋值;变量名不区分大小写,因此 `$home` 就是同一个只读变量。命令可能在文件沙箱中运行;被阻止的文件操作报告为 `[sandbox: file access denied under <mode> mode]`,这是策略拒绝:请勿换一种方式重试。
```json
{
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The PowerShell command to execute."
},
"description": {
"type": "string",
"description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"Get-Process\" → \"List running processes\"."
},
"command": {
"type": "string",
"description": "The PowerShell command to execute."
},
"timeoutMs": {
"type": "number",
"description": "Timeout in milliseconds. The executor applies its configured default and cap; on expiry the command moves to the background as a job instead of being killed."
@@ -718,8 +718,8 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。组合中有 job
}
},
"required": [
"command",
"description"
"description",
"command"
]
}
```
@@ -961,7 +961,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费
"properties": {
"file_path": {
"type": "string",
"description": "Path to edit, resolved by the filesystem backend."
"description": "Path to edit, resolved by the filesystem backend. Provide `file_path` before `old_string` and `new_string` in the arguments."
},
"old_string": {
"type": "string",
@@ -1046,7 +1046,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费
"properties": {
"file_path": {
"type": "string",
"description": "Path to write, resolved by the filesystem backend."
"description": "Path to write, resolved by the filesystem backend. Provide `file_path` before `content` in the arguments."
},
"content": {
"type": "string",
+2 -2
View File
@@ -15,8 +15,8 @@
en: f6d1a21af61a44fc
zh: a92f177c97880b95
/schedule-reminders/inspect-and-delete-tasks:
en: a44fc1aabc2ab957
zh: 31f444d4c7fd5c7a
en: b74bc63a1273680b
zh: b6d806e91c361b99
/schedule-reminders/edit-an-active-task:
en: b3426f5dbc185958
zh: ccf118e2415a5e77
+1 -1
View File
@@ -30,7 +30,7 @@ For example, ask: “Remind me every day at 23:00 in Asia/Shanghai to check the
Open Automation tasks from the sidebar to view reminders across Sessions without activating their conversations. Search by the stored task name, the instruction, or the task's internal Session id, and filter by the single All, Enabled, or Inactive status row. An idle, unarchived Session row whose Session has at least one active task shows a clock mark; an archived row keeps that cell blank, with its live status on the hover card only. A long hover on a Session row with active tasks lists up to two of those tasks with their frequency and their next run as a device-zone time with its relative duration. The open Session's header shows an icon-only reminder clock while active reminders exist: it opens the sole task's details directly, or a list of the Session's reminders otherwise.
Select a task to open Rules, then choose Delivery records to load the newest 20 saved records. Each saved record reads as one compact row: a clock glyph, the occurrence time in the task's saved zone (the browser's zone for a one-shot or interval task), and the saved instruction when one was stored. Records without a saved instruction never show the current instruction in its place. Load older records adds the next page. Records follow newest-first saved order even if the clock moves backward; they do not describe model execution results. The original Session entry sits in the detail tab strip, so it stays visible while the Rules view scrolls. Unmodified Left/Right arrows and Home/End switch the focused tabs; Alt, Ctrl, Meta, and Shift combinations are not intercepted. Refreshing the task preserves the selected tab.
Select a task to open Rules, then choose Records to load the newest 20 saved records. Each saved record reads as one compact row: a clock glyph, the occurrence time in the task's saved zone (the browser's zone for a one-shot or interval task), and the saved instruction when one was stored. Records without a saved instruction never show the current instruction in its place. Load older records adds the next page. Records follow newest-first saved order even if the clock moves backward; they do not describe model execution results. The original Session entry sits in the detail tab strip, so it stays visible while the Rules view scrolls. Unmodified Left/Right arrows and Home/End switch the focused tabs; Alt, Ctrl, Meta, and Shift combinations are not intercepted. Refreshing the task preserves the selected tab.
An empty record view means a successful query returned no saved records. Loading, request failure, a missing task, and an invalid older-record cursor have separate messages. Use Retry delivery records after a failed request or missing-task response; use Refresh delivery records after an invalid cursor to reload the newest page. Previously loaded records may remain visible with a warning while a request fails. A new latest delivery refreshes the newest page. Late responses for a task or view you have left do not replace the current view.
+1 -1
View File
@@ -30,7 +30,7 @@
从侧栏打开自动化任务页面,可跨 Session 查看提醒,而不激活相应对话。按任务已存储的名称、指令或任务所属的内部 Session id 搜索,并用唯一的“全部/已开启/未运行”状态筛选行过滤。Session 行在其 Session 有至少一个活动任务、自身空闲且未归档时显示时钟标识;归档行该格留空,其实时状态只在悬浮卡片中显示。悬停任一带活动任务的 Session 行时,最多列出其中两个任务及其频率,以及设备时区下的下次运行时间与相对时长。已打开 Session 的顶部在有活动提醒时显示仅图标时钟入口:只有一个任务时直接打开其详情,否则列出该 Session 的提醒。
选择任务打开“规则”,再选择“发送记录”加载最新的 20 条已保存记录。每条已保存记录为一行紧凑信息:时钟图标、任务保存时区下的发生时间(一次性或间隔任务则为浏览器时区),以及保存过的指令。没有保存指令的记录不会用当前指令顶替。“加载更早的记录”增加下一页。记录按保存顺序从新到旧排列,即使时钟回拨也不改变顺序;它们不描述模型执行结果。原会话入口位于详情页签条内,因此在“规则”视图滚动时都保持可见。不带修饰键的左右方向键和 Home/End 可切换已聚焦的页签;不会拦截与 Alt、Ctrl、Meta 或 Shift 的组合。刷新任务会保留当前页签。
选择任务打开“规则”,再选择“任务运行记录”加载最新的 20 条已保存记录。每条已保存记录为一行紧凑信息:时钟图标、任务保存时区下的发生时间(一次性或间隔任务则为浏览器时区),以及保存过的指令。没有保存指令的记录不会用当前指令顶替。“加载更早的记录”增加下一页。记录按保存顺序从新到旧排列,即使时钟回拨也不改变顺序;它们不描述模型执行结果。原会话入口位于详情页签条内,因此在“规则”视图滚动时都保持可见。不带修饰键的左右方向键和 Home/End 可切换已聚焦的页签;不会拦截与 Alt、Ctrl、Meta 或 Shift 的组合。刷新任务会保留当前页签。
记录空态表示查询成功且没有返回已保存记录。加载中、请求失败、任务不存在和较早记录游标无效分别显示不同提示。请求失败或返回任务不存在时,使用“重试加载发送记录”;游标无效时,使用“刷新发送记录”重新加载最新一页。请求失败时,此前加载的记录可能仍会显示,并附有警告。新的最近一次投递会刷新最新一页。离开任务或视图后才到达的响应不会替换当前视图。
@@ -15,8 +15,8 @@
en: 87679c51aa3693f5
zh: 11684a48a5806afb
/deepseek-ai-dsh-plugin-manager/use-this-package:
en: b8d7061ee289b2a0
zh: 49aba7df4761fb23
en: d0f7d42dffe6d3c7
zh: b4bf81958cda876d
/deepseek-ai-dsh-plugin-manager/use-this-package/version-compatibility-and-exemptions:
en: 920c0acb3b9feaf3
zh: ba6e832a256880a7
+1 -1
View File
@@ -41,7 +41,7 @@ A plugin toggle updates only `disabled` in the last matching override in the pro
A selected bundle that cannot load remains in `listBundles` with an `error`; `enabled` records the saved selection, not successful loading. The plugin page shows the error and allows deselection. A broken bundle cannot be enabled. Management bundles remain protected if their files become unreadable.
`listBundles` includes optional display `meta` for each bundle and its declared plugin rows, including disabled bundles. The Client selects a language from these values. The separate `description` field is the bundle's raw `package.json.description`; a metadata diagnostic does not block management. The `plugin_manager` tool omits UI display metadata from list results.
`listBundles` includes optional display `meta` for each bundle and its declared plugin rows, including disabled bundles. The Client selects a language from these values. The separate `description` field is the bundle's raw `package.json.description`; a metadata diagnostic does not block management. `source` gives a profile dependency as a spec `pnpm add` accepts: a git address or URL as recorded without the user information of an http(s) URL (a query string stays as recorded), a `file:` or `link:` path made absolute against the profile or the home directory for `~`, and a registry range, tag, or alias after the package name, as is any spec of a package installed under another name; a bundle the installation supplies has none, even when the profile also depends on it, because the installation's copy is the one that loads. The `plugin_manager` tool omits UI display metadata from list results.
`inspect(spec, options)` reads what a spec names before anything installs: a registry name is asked of the registry through `pnpm view`, run in the profile directory so the same proxy and authentication settings apply as to the install; an absolute path has its `package.json` read; a git address or tarball answers only its form and the `host` it is fetched from. The answer carries the name, version, description, whether the package declares a bundle, and the `registry` that answered, or a `problem`: `invalid-spec`, `already-installed`, `not-found`, `not-a-package`, `not-a-bundle`, `network`, or `unknown`, with the `registries` asked. A caller's `signal` or `inspectTimeoutMs` ends the lookup.
+1 -1
View File
@@ -41,7 +41,7 @@ kind: "package-reference"
已选择但无法加载的组合包仍会出现在 `listBundles` 中,并携带 `error`;`enabled` 表示保存的选择,不代表加载成功。插件页面显示错误并允许取消选择。损坏的组合包无法启用。管理组合包的文件变得不可读后仍受保护。
`listBundles` 为各组合包及其声明的插件行提供可选的展示 `meta`,包括已禁用的组合包。Client 从这些值中选择语言。单独的 `description` 字段是该组合包原始的 `package.json.description`;元信息诊断不会阻止管理操作。`plugin_manager` 工具的列表结果不包含 UI 展示元信息。
`listBundles` 为各组合包及其声明的插件行提供可选的展示 `meta`,包括已禁用的组合包。Client 从这些值中选择语言。单独的 `description` 字段是该组合包原始的 `package.json.description`;元信息诊断不会阻止管理操作。`source` 以 `pnpm add` 接受的 spec 给出 profile 依赖:git 地址或 URL 按记录给出,但去掉 http(s) URL 的 user information(查询字符串保持原样);`file:` 或 `link:` 路径按 profile 目录解析为绝对路径,`~` 展开为主目录;注册表版本范围、标签或别名接在包名的 `@` 之后,以别名安装的包的 spec 也是如此。安装提供的组合包没有该字段,即使 profile 也依赖它,因为实际加载的是安装中的副本。`plugin_manager` 工具的列表结果不包含 UI 展示元信息。
`inspect(spec, options)` 在任何东西安装之前读出 spec 指向什么:注册表包名通过 `pnpm view` 询问注册表,在 profile 目录中运行,因而与安装使用同样的代理与认证设置;绝对路径读取其 `package.json`;git 地址或 tarball 只答复自己的形式和它被拉取的 `host`。答复携带名称、版本、描述、该包是否声明组合包,以及作答的 `registry`,否则给出 `problem`:`invalid-spec`、`already-installed`、`not-found`、`not-a-package`、`not-a-bundle`、`network` 或 `unknown`,并附上问过的 `registries`。调用方的 `signal` 或 `inspectTimeoutMs` 会结束查询。
+13 -7
View File
@@ -21,7 +21,7 @@ import type {} from '@deepseek-ai/dsh-hmr'
import type { ProfileContext, ProfileManifest } from '@deepseek-ai/dsh-app-boot'
import { bundleManifest, readProfileRegistry, registryArguments, runProfilePnpm, saveManifest, viewProfilePackage } from './operations.ts'
import { classifyInstallFailure } from './install-failure.ts'
import { InvalidInstallSpecError, parseInstallSpec, type ParsedInstallSpec } from './install-spec.ts'
import { dependencySpec, InvalidInstallSpecError, parseInstallSpec, type ParsedInstallSpec } from './install-spec.ts'
import { attributeFailure, normalizeRegistry, NPMMIRROR_REGISTRY, registryPlan } from './registry.ts'
import { writePluginEnabled } from './patch.ts'
import { incompatiblePlugin, ManagementFailure } from './failure.ts'
@@ -273,15 +273,16 @@ export class PluginManager extends TypertRemoteService {
/** Read the profile's installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles.
* A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected.
* @returns Package versions, manifest descriptions, rows, optional display metadata, activation selections,
* whether the installation offers the bundle, and removal availability.
* @returns Package versions, manifest descriptions, the installable spec of profile dependencies, rows, optional
* display metadata, activation selections, whether the installation offers the bundle, and removal availability.
*/
@Remote
listBundles(): Promise<BundleInfo[]> {
const manifest = readProfileManifest('dsh', this.profile.dir)
const exemptions = readProfileVersionExemptions(this.profile.dir)
const selected = manifest.dsh?.profile?.bundles ?? []
const dependencies = Object.keys(manifest.dependencies ?? {})
const recorded = manifest.dependencies ?? {}
const dependencies = Object.keys(recorded)
const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8')) as InstallationManifest
const names = [...new Set([...selected, ...dependencies, ...Object.keys(installation.dependencies ?? {})])]
const bundles: BundleInfo[] = []
@@ -289,12 +290,17 @@ export class PluginManager extends TypertRemoteService {
const installed = dependencies.includes(name)
const optional = OPTIONAL_BUNDLES.includes(name)
const removable = installed && !Object.hasOwn(installation.dependencies ?? {}, name)
// Bundle resolution reads the installation first, so a profile dependency the installation manifest also names,
// like one it forbids removing, is not the loaded copy.
const sourceOf = (packageName?: string): { source?: string } =>
removable ? { source: dependencySpec(name, recorded[name] as string, this.profile.dir, packageName) } : {}
const enabled = selected.includes(name)
const readOnlyReason = this.protectsManager(name) ? 'management-required' as const : undefined
try {
const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor)
if (info === undefined) {
if (enabled) bundles.push({ name, enabled, installed, optional, removable: removable && readOnlyReason === undefined,
if (enabled) bundles.push({ name, ...sourceOf(), enabled, installed, optional,
removable: removable && readOnlyReason === undefined,
...(readOnlyReason === undefined ? {} : { readOnlyReason }), error: { code: 'not-bundle' }, rows: [], overrides: [] })
continue
}
@@ -305,12 +311,12 @@ export class PluginManager extends TypertRemoteService {
bundles.push({ name, ...(info.version === undefined ? {} : { version: info.version }),
...(info.description === undefined || info.description === '' ? {} : { description: info.description }),
...meta === undefined ? {} : { meta },
enabled, installed, optional, removable: removable && readOnlyReason === undefined,
...sourceOf(info.name), enabled, installed, optional, removable: removable && readOnlyReason === undefined,
...(readOnlyReason === undefined ? {} : { readOnlyReason }),
...this.declaredRows(name, info) })
} catch (error) {
if (enabled || installed) {
bundles.push({ name, enabled, installed, optional, removable: removable && readOnlyReason === undefined,
bundles.push({ name, ...sourceOf(), enabled, installed, optional, removable: removable && readOnlyReason === undefined,
...(readOnlyReason === undefined ? {} : { readOnlyReason }), error: managementError(error), rows: [], overrides: [] })
}
}
@@ -4,7 +4,8 @@
* @module @deepseek-ai/dsh-plugin-manager/install-spec
*/
import { isAbsolute } from 'node:path'
import { homedir } from 'node:os'
import { isAbsolute, resolve } from 'node:path'
/**
* One spec read into its form and the parts an inspection needs. A git spec
@@ -89,3 +90,34 @@ export function parseInstallSpec(raw: string): ParsedInstallSpec {
if (range === '') throw invalid(spec, 'a version after @ must not be empty')
return range === undefined ? { kind: 'registry', spec, name } : { kind: 'registry', spec, name, range }
}
/** A recorded dependency value pnpm resolves outside a registry: a protocol other than a registry alias, or an scp-like git address. */
const NON_REGISTRY_VALUE = /^(?!(?:npm|jsr|workspace|catalog):)[a-z][a-z0-9+.-]*:|^[^@/:\s]+@[^:/\s]+:/i
/** The user information of an http(s) URL, up to the last `@` before the path, which holds a password or access token when present. */
const HTTP_USER_INFO = /^((?:git\+)?https?:\/\/)[^/]*@/i
/**
* The spec `pnpm add` accepts for a dependency the profile manifest records. A git address, URL, or other
* non-registry protocol is the recorded value without the user information of an http(s) URL, whose query string
* is kept; a `file:` or `link:` path, which pnpm records relative to the profile or with `~` for the home
* directory, becomes absolute. A registry range, tag, or alias follows the dependency name after `@`, as does a
* non-registry value installed under a name other than the package's own.
* @param name - the dependency name.
* @param recorded - the value the profile manifest records for it.
* @param profileDir - the profile directory a relative local path is resolved against.
* @param packageName - the name the installed package's manifest declares, when it could be read.
* @returns the installable spec.
*/
export function dependencySpec(name: string, recorded: string, profileDir: string, packageName = name): string {
const local = /^(file|link):(.*)$/s.exec(recorded)
let spec: string
if (local !== null) {
const path = (local[2] as string).replace(/^~(?=$|[\\/])/, () => homedir())
spec = `${local[1]}:${resolve(profileDir, path)}`
} else if (NON_REGISTRY_VALUE.test(recorded)) {
spec = recorded.replace(HTTP_USER_INFO, '$1')
} else {
return `${name}@${recorded}`
}
return packageName === name ? spec : `${name}@${spec}`
}
@@ -55,6 +55,11 @@ export interface BundleInfo {
enabled: boolean
/** Whether the profile's own dependencies hold the package; false for a bundle the dsh installation supplies. */
installed: boolean
/**
* Present for a profile dependency the installation does not also supply: the spec `pnpm add` accepts, with local
* paths made absolute and the user information of an http(s) URL removed.
*/
source?: string
/**
* Whether the installation ships the bundle for the person to switch on: named by the launcher's `OPTIONAL_BUNDLES`,
* held by the installation's dependencies, selected by no shipped template, and never removable.
@@ -4,8 +4,8 @@ import { realpath } from 'node:fs/promises'
import { once } from 'node:events'
import { createServer } from 'node:http'
import type { Socket } from 'node:net'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { homedir, tmpdir } from 'node:os'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { execa } from 'execa'
import type { Context } from '@deepseek-ai/cordis'
@@ -241,7 +241,7 @@ it.each(['missing package', 'invalid manifest', 'not a bundle', 'missing patch',
})
expect([...ctx.loader.entries()].some(entry => entry.id === 'include:managed')).toBe(false)
expect((await manager.listBundles()).find(row => row.name === 'extra')).toMatchObject({
enabled: true, error: { code: failure === 'not a bundle' ? 'not-bundle' : 'operation-error' }, rows: [],
enabled: true, source: 'extra@1.0.0', error: { code: failure === 'not a bundle' ? 'not-bundle' : 'operation-error' }, rows: [],
})
expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ changed: true, application: 'applied' })
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core'])
@@ -301,7 +301,7 @@ it('lists bundle versions and current-profile plugin targets', async () => {
rows: [{ rowId: 'manager', moduleName: 'cordis:manager', entryId: 'include:manager' }], overrides: [],
},
{
name: 'extra', version: '1.0.0', enabled: true, installed: true, optional: false, removable: true,
name: 'extra', version: '1.0.0', source: 'extra@1.0.0', enabled: true, installed: true, optional: false, removable: true,
meta: { title: 'extra' },
rows: [{ rowId: 'managed', moduleName: pathToFileURL(join(dir, 'node_modules', 'extra', 'plugin.mjs')).href, entryId: 'include:managed' }], overrides: [],
},
@@ -323,7 +323,7 @@ it('describes a bundle by its manifest and patch: one-liner, rows without a live
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
const moduleName = pathToFileURL(join(dir, 'node_modules', 'described', 'plugin.mjs')).href
expect((await manager.listBundles()).find(row => row.name === 'described')).toEqual({
name: 'described', version: '2.0.0', description: 'Describes itself.', enabled: false, installed: true, optional: false, removable: true,
name: 'described', version: '2.0.0', description: 'Describes itself.', source: 'described@2.0.0', enabled: false, installed: true, optional: false, removable: true,
meta: { title: 'described', description: 'Describes itself.' },
rows: [{ rowId: 'described-row', moduleName }], overrides: ['managed'],
})
@@ -336,6 +336,36 @@ it('describes a bundle by its manifest and patch: one-liner, rows without a live
expect((await manager.listBundles()).find(row => row.name === 'described')?.rows).toEqual([{ rowId: 'described-row', moduleName }])
})
it('names where each installed bundle comes from as a spec pnpm installs', async () => {
const { manager, dir, bundle, profile } = await fixture()
const recorded: Record<string, string> = {
extra: '^1.0.0', tagged: 'latest', aliased: 'npm:@acme/aliased@2', jsr: 'jsr:@acme/jsr@^1', github: 'github:someone/dsh-plugin#v1',
ssh: 'git@github.com:someone/dsh-plugin.git', deploy: 'deploy@git.corp:team/dsh-plugin.git',
sshUrl: 'git+ssh://git@github.com/someone/dsh-plugin.git', email: 'git+http://user@example.com:secret@git.example.com/repo.git',
tarball: 'https://cdn.example.com/dsh-x-1.0.0.tgz', token: 'https://ghp_secret@cdn.example.com/dsh-x-1.0.0.tgz',
password: 'git+https://someone:secret@git.example.com/someone/dsh-plugin.git#main',
relative: 'file:../plugins/relative', linked: 'link:/plugins/linked', home: 'file:~/plugins/home',
aliasLocal: 'file:../plugins/original', shadowed: '1.0.0',
}
for (const name of Object.keys(recorded)) bundle(name, [])
// Installed under a name other than its own, the package keeps that name only when the spec carries it.
writeFileSync(join(dir, 'node_modules', 'aliasLocal', 'package.json'), JSON.stringify({ name: 'original', version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } }))
const manifest = readProfileManifest('test', dir)
manifest.dependencies = recorded
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
// The installation's own copy of a bundle is the one that loads, so the profile's dependency on it names nothing.
writeFileSync(profile.installAnchor, JSON.stringify({ name: 'installation', dependencies: { shadowed: '1.0.0' } }))
expect(Object.fromEntries((await manager.listBundles()).map(row => [row.name, row.source]))).toEqual({
core: undefined, extra: 'extra@^1.0.0', tagged: 'tagged@latest', aliased: 'aliased@npm:@acme/aliased@2', jsr: 'jsr@jsr:@acme/jsr@^1',
github: 'github:someone/dsh-plugin#v1', ssh: 'git@github.com:someone/dsh-plugin.git', deploy: 'deploy@git.corp:team/dsh-plugin.git',
sshUrl: 'git+ssh://git@github.com/someone/dsh-plugin.git', email: 'git+http://git.example.com/repo.git',
tarball: 'https://cdn.example.com/dsh-x-1.0.0.tgz', token: 'https://cdn.example.com/dsh-x-1.0.0.tgz',
password: 'git+https://git.example.com/someone/dsh-plugin.git#main',
relative: `file:${resolve(dir, '../plugins/relative')}`, linked: `link:${resolve(dir, '/plugins/linked')}`,
home: `file:${resolve(homedir(), 'plugins/home')}`, aliasLocal: `aliasLocal@file:${resolve(dir, '../plugins/original')}`, shadowed: undefined,
})
})
it.each(['native', 'runtime'] as const)('reads a disabled bundle and its independent exported plugins without running them with %s resolution', async (mode) => {
const { ctx, manager, dir, bundle, profile } = await fixture('startup')
bundle('localized', [{ id: 'first', name: 'local-child/first' }, { id: 'second', name: 'local-child/second' }])
@@ -416,7 +446,7 @@ it.each([
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
expect((await manager.listBundles()).find(row => row.name === 'unnamed')).toEqual({
name: 'unnamed', version: '1.0.0', enabled: false, installed: true, optional: false, removable: true,
name: 'unnamed', version: '1.0.0', source: 'unnamed@1.0.0', enabled: false, installed: true, optional: false, removable: true,
rows: [], overrides: [], ...expected,
})
})
@@ -195,10 +195,10 @@ it('keeps UI translation metadata out of model-facing plugin and bundle lists',
entries: [{ entryId: 'include:plugin', enabled: true }], total: 1, nextOffset: null,
})
manager.listBundles.mockImplementationOnce(async () => [{
name: 'bundle', enabled: true, meta, rows: [{ rowId: 'plugin', moduleName: 'plugin', meta }],
name: 'bundle', source: 'bundle@^1.0.0', enabled: true, meta, rows: [{ rowId: 'plugin', moduleName: 'plugin', meta }],
}])
expect(JSON.parse(resultText(await call({ action: 'list_bundles' })))).toEqual({
entries: [{ name: 'bundle', enabled: true, rows: [{ rowId: 'plugin', moduleName: 'plugin' }] }], total: 1, nextOffset: null,
entries: [{ name: 'bundle', source: 'bundle@^1.0.0', enabled: true, rows: [{ rowId: 'plugin', moduleName: 'plugin' }] }], total: 1, nextOffset: null,
})
})
@@ -15,11 +15,11 @@
en: d152484eb41ac6b4
zh: b3ad7867f23dad1c
/deepseek-ai-dsh-client-ui-agent-preset/use-this-package:
en: a50da1a9dfdaf624
zh: faaffc5fbeec446b
en: abb24ddb24f5588d
zh: 2c58c8cd0e2a335e
/deepseek-ai-dsh-client-ui-agent-preset/understand-the-implementation:
en: 7813f0063c15e241
zh: 6eabc242edfc45ec
en: 4c88c83529317389
zh: 5125122bdacfa3da
/deepseek-ai-dsh-client-ui-agent-preset/further-exploration:
en: bbae8d79844bc481
zh: 4302107fd1f460e6
+9 -1
View File
@@ -27,10 +27,16 @@ Choose Agent presets and the new-task default in Web, read what each mode does a
Settings shows the built-in and custom card groups with default highlighting and card-body selection; a group without presets is omitted, except the custom group, which keeps its Creator entry on screen. Every card offers “View configuration”, which opens the preset's declared plugin list as read-only YAML in the Loader's own dialect (`!!js` conditions included); a failed preset stays readable because its diagnostic points into that YAML. Escape closes only the viewer and returns focus to its card; leaving Settings clears the viewer, and a late read does not reopen it. The page edits nothing: the Creator entry starts a Creator-mode task that authors or overrides a preset as a bundle, offered while the `cordis` preset is on the roster and a conversation flow exists.
Coding Tools in General Settings decide whether a mode can be chosen at all: with them off the new-session picker disappears, while the settings cards still choose the saved default that keeps composing new tasks and Creator stays available. With Coding Tools on, choosing a healthy default also synchronizes the blank session on the current new-task surface; with them off, a reused blank session keeps its preset. Creator starts a new task using the `cordis` preset.
With Coding Tools off in General Settings, the new-session picker and Settings roster offer Standard, Creator, and custom presets; turning them on also offers PTC and Minimal. Default selection and the Creator entry remain available in both states. Choosing a healthy default also synchronizes the blank session on the current new-task surface. Creator starts a new task using the `cordis` preset.
Turning Coding Tools off clears all unapplied choices through the existing Developer tools subscription. Once the off value is accepted, a host-backed connection resets a saved built-in PTC or Minimal default to Standard. Saved Standard, Creator, and custom defaults (including named PTC or Minimal overrides) stay unchanged. The user can select an available preset again after turning Coding Tools off. Existing sessions, including blank sessions reused by New Session, keep their selected mode; its label remains visible even when that mode is absent from the menu. Turning Coding Tools back on does not restore the old default or cleared choice. A remote connection using in-memory settings does not rewrite the Host's default. If Standard is missing or broken, the default remains unchanged and an error is shown.
Known shipped presets offer mode details and usage examples in a read-only dialog. Its tabs preserve each page's scroll position; closing returns focus to the opening action. Help does not change the new-task default. The default badge replaces the card's group badge, and the preset id appears beside the title. Guide copy and examples belong to this package.
**Let the agent create a plugin** in the Plugins page's **Add plugin** arrow menu enters the same Creator flow as Settings. It submits no message and preserves unsent drafts. While the roster loads or Creator is unavailable, the menu item is disabled with an explanation. Both entries show a Toast with the reason if the Host refuses the preset switch.
Entering Creator selects `cordis` for the receiving blank Session without changing the new-task default or Coding Tools setting. If no workspace or blank Session is bound yet, the choice waits for that binding. After the choice is applied, the Creator Session keeps its preset; a later newly created Session uses the configured default, such as Standard.
<a id="understand-the-implementation"></a>
## Understand the implementation
@@ -39,6 +45,8 @@ Known shipped presets offer mode details and usage examples in a read-only dialo
`agentPresets/list` supplies the roster and marks the current default, and `agentPresets/read` one declaration's YAML for the viewer; default changes write the `agent-preset-registry` settings namespace. The picker, blank-session synchronization and read-only session label use recorded preset identities. Connection resets and settings updates refresh the roster.
`CreatePluginMenuItem` contributes to `plugins.add.actions` using the shared roster store. Its selection calls the owner's `onDismiss()` before the same `startCreatorDraft` callback exposed to Settings as `creatorDraft`. That callback stages `cordis` through `AgentPresetSeatController` and opens the task through `uiWorkspace.startSession`; it can reuse a blank Session and applies the staged choice when a Session binding becomes available. The choice is consumed once applied. Both Coding Tools states use the same picker and selection store, and `AgentPresetSeat` renders refusals through the shared `Toast` component.
</details>
<a id="further-exploration"></a>
+9 -1
View File
@@ -27,10 +27,16 @@ kind: "package-reference"
设置页显示内置与自定义卡片分组、默认项高亮和点击卡片选择;没有 preset 的分组不显示,但自定义分组会保留其创造入口。每张卡片都提供「查看配置」,以只读 YAML 打开该 preset 声明的插件列表,使用 Loader 自己的方言(含 `!!js` 条件);加载失败的 preset 同样可读,因为其诊断信息正指向这份 YAML。Escape 只关闭查看器并将焦点还给卡片;离开设置页会清空查看器,迟到的读取结果不会重新打开它。本页不编辑任何内容:创造入口启动一个创造模式任务,以 bundle 形式创建或覆盖 preset;当 `cordis` preset 在列表中且存在会话流程时提供。
通用设置中的代码工作工具决定能否选择模式:关闭后新会话选择器消失,而设置卡片仍可选择继续用于新任务的已保存默认值,Creator 入口也保持可用。开启时,选择健康定义作为默认值也会同步当前新任务页面的空白会话;关闭时,被复用的空白会话保留原预设。Creator 入口开启一个使用 `cordis` preset 的新任务。
关闭通用设置中的代码工作工具后,新会话选择器和设置页列表提供标准、创造和自定义预设;开启后还提供 PTC 和极简模式。两种状态下均保留默认值选择和创造入口。选择健康定义作为默认值也会同步当前新任务页面的空白会话。Creator 入口开启一个使用 `cordis` preset 的新任务。
关闭代码工作工具时,沿用现有 Developer tools 订阅清空所有尚未应用的选择。关闭状态被接受后,使用 Host 持久化设置的连接会将已保存的内置 PTC 或极简默认值回退为标准模式。已保存的标准、创造和自定义默认值(包括有名称的 PTC 或极简覆盖项)保持不变。关闭后用户仍可重新选择可用预设。已有会话(包括点击新建会话时复用的空白会话)保留所选模式;即使该模式不在菜单中,其标签仍会显示。重新开启代码工作工具不会恢复原默认值或已清除的选择。使用内存设置的远程连接不会改写 Host 的默认值。标准模式缺失或损坏时,默认值保持不变,并显示错误。
已知的内置预设提供只读的模式说明与使用示例对话框。各页签保留各自的滚动位置;关闭后焦点回到打开它的操作。帮助不会改变新任务默认值。默认徽标取代卡片的分组徽标,预设 id 显示在标题旁。指南文案与示例归本包所有。
插件页**添加插件**箭头菜单中的**让 Agent 创建插件**进入与设置页相同的创造流程,不发送消息,并保留未发送的草稿。读取预设列表或创造模式不可用时,菜单项禁用并说明原因。Host 拒绝切换预设时,两处入口均通过 Toast 显示原因。
进入创造模式会为接收该选择的空白 Session 选择 `cordis`,不改变新任务默认值或代码工作工具设置。尚未绑定工作区或空白 Session 时,选择等待绑定完成后应用。应用后,创造 Session 保留该预设;之后真正创建的新 Session 使用配置的默认值,例如标准模式。
<a id="understand-the-implementation"></a>
## 理解实现
@@ -39,6 +45,8 @@ kind: "package-reference"
`agentPresets/list` 提供列表并标记当前默认值,`agentPresets/read` 为查看器提供一条声明的 YAML;默认值的修改写入 `agent-preset-registry` settings 命名空间。选择器、空白会话同步和只读会话标签使用记录的 preset 标识。连接重置和设置更新会刷新列表。
`CreatePluginMenuItem` 使用共享的预设列表 store,向 `plugins.add.actions` 贡献菜单项。选择时先调用页面传入的 `onDismiss()`,再调用与设置页 `creatorDraft` 相同的 `startCreatorDraft` 回调。该回调通过 `AgentPresetSeatController` 暂存 `cordis`,再通过 `uiWorkspace.startSession` 打开任务;它可以复用空白 Session,并在 Session 绑定可用时应用暂存选择。暂存选择应用后即清除。代码工作工具开启或关闭时均使用同一个选择器和选择 store,`AgentPresetSeat` 通过共享的 `Toast` 组件显示拒绝原因。
</details>
<a id="further-exploration"></a>
@@ -58,6 +58,7 @@
"@deepseek-ai/dsh-client-store": "workspace:*",
"@deepseek-ai/dsh-client-test-runtime": "workspace:*",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:*",
"@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:*",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
"@deepseek-ai/dsh-client-ui-session": "workspace:*",
@@ -12,7 +12,7 @@
* Picking stages; the choice reaches a session when one becomes current.
*/
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import {
@@ -22,12 +22,13 @@ import {
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { AgentPresetSeatState } from './seat-store.ts'
import { presetDisplayText } from './locales.ts'
import { requiresCodingTools } from './settings-store.ts'
import css from './AgentPresetSeat.module.css'
/** Registration-side business face for the hero chip. */
export interface AgentPresetSeatInjected {
hooks: {
/** Shared Developer tools preference; off hides the chip. */
/** Shared Developer tools preference; off hides the PTC and Minimal menu choices. */
developerTools: ObservableSnapshot<boolean>
/** Seat snapshot bound by the renderer as useAgentPresetSeat. */
agentPresetSeat: SnapshotStore<AgentPresetSeatState>
@@ -36,6 +37,8 @@ export interface AgentPresetSeatInjected {
load: () => Promise<void>
/** Stage one preset for the next session; resolves to a refusal, or undefined. */
select: (id: string) => Promise<string | undefined>
/** Acknowledge the refusal whose Toast finished. */
dismissRefusal: (error: AgentPresetSeatState['error']) => void
/** Clear the one-shot introduce cue once the chip has played it. */
introduced: () => void
}
@@ -73,11 +76,10 @@ export type AgentPresetSeatProps =
/**
* Render the new-session agent-preset chip.
* @param props - composed slot props.
* @returns The chip, or null outside the main view, with Developer tools off,
* or before the roster provides a preset choice.
* @returns The chip and any pending selection refusal, or null outside the main view.
*/
export function AgentPresetSeat({
sessionId, useSessionRetainInfo, load, select, introduced, useAgentPresetSeat, useDeveloperTools, t,
sessionId, useSessionRetainInfo, load, select, dismissRefusal, introduced, useAgentPresetSeat, useDeveloperTools, t,
}: AgentPresetSeatProps) {
const developerTools = useDeveloperTools(value => value)
const state = useAgentPresetSeat(snapshot => snapshot)
@@ -87,22 +89,27 @@ export function AgentPresetSeat({
// The seq keys the banner, so picking the same broken preset twice replays
// it rather than leaving the first one silently in place.
const toastSeq = useRef(0)
const [toast, setToast] = useState<{ seq: number; text: string } | null>(null)
const pickerVisible = useRef(developerTools)
pickerVisible.current = developerTools
const [toast, setToast] = useState<{ seq: number; error: Exclude<AgentPresetSeatState['error'], string | null> } | null>(null)
useEffect(() => {
if (state.error !== null && typeof state.error === 'object') {
toastSeq.current += 1
setToast({ seq: toastSeq.current, error: state.error })
} else setToast(null)
}, [state.error])
useEffect(() => {
void load()
}, [load])
// The component stays registered while hidden, so clear local disclosure
// state explicitly; otherwise an external off/on edit can revive an old
// menu or refusal banner.
const options = useMemo(
() => state.options.filter(option => developerTools || !requiresCodingTools(option)),
[state.options, developerTools],
)
useEffect(() => {
if (developerTools) return
setOpen(false)
setToast(null)
}, [developerTools])
}, [developerTools, options.length])
const chosen = state.options.find(option => option.id === state.current)
const chosenText = chosen === undefined ? undefined : presetDisplayText(chosen, t)
@@ -129,9 +136,8 @@ export function AgentPresetSeat({
return () => { window.clearTimeout(done) }
}, [state.introduce, ready, label, introduced])
// Nothing to choose between: the deployment composes no presets and every
// session shares the host composition.
if (!main || !developerTools || !ready) return null
// A refused initial composition still needs its Toast when there is no chip to show.
if (!main) return null
// One wrapper span: the chip is a flex row with a gap, so loose character
// spans would each pick up the gap between them.
@@ -155,10 +161,10 @@ export function AgentPresetSeat({
return (
<>
<Menu
open={open}
{ready && <Menu
open={open && options.length > 0}
onClose={() => { setOpen(false) }}
items={state.options.map((option) => {
items={options.map((option) => {
const text = presetDisplayText(option, t)
return {
id: option.id,
@@ -175,19 +181,7 @@ export function AgentPresetSeat({
selectedId={state.current}
onSelect={(id) => {
setOpen(false)
const picked = state.options.find(option => option.id === id)
// The fallback is for the row shape `find` cannot promise; the menu's
// items ARE `state.options`, so an emitted id is always one of them.
/* v8 ignore next */
const name = picked === undefined ? id : presetDisplayText(picked, t).name
void select(id).then((refusal) => {
// Announced only for a pick a person just made: `apply()` also runs
// when a session becomes current, and a banner over that would
// report a refusal nobody asked for.
if (refusal === undefined || !pickerVisible.current) return
toastSeq.current += 1
setToast({ seq: toastSeq.current, text: t('switchRefused', { name, reason: refusal }) })
})
void select(id)
}}
align="start"
portal
@@ -197,9 +191,9 @@ export function AgentPresetSeat({
type="button"
className={css.seat}
aria-haspopup="menu"
aria-expanded={open}
title={state.error ?? t('seatHint')}
disabled={state.busy}
aria-expanded={open && options.length > 0}
title={(typeof state.error === 'object' ? state.error?.reason : state.error) ?? t('seatHint')}
disabled={state.busy || options.length === 0}
onClick={() => { setOpen(value => !value) }}
>
<IconAgentPresetOutlineRegular className={introducing ? `${css.seatIcon} ${css.introIcon}` : css.seatIcon} />
@@ -207,11 +201,11 @@ export function AgentPresetSeat({
<IconChevronDownOutlineRegular className={css.chevron} />
</button>
)}
/>
/>}
{toast !== null && (
<Toast
key={toast.seq}
text={toast.text}
text={t('switchRefused', { name: presetDisplayText(toast.error.preset, t).name, reason: toast.error.reason })}
icon={<IconWarningOutlineRegular />}
holdMs={REFUSAL_HOLD_MS}
// The composer card, which is the content column this chip sits
@@ -219,7 +213,7 @@ export function AgentPresetSeat({
// Absent, the banner centers on the window, which is off-center
// whenever the sidebar is open.
anchor={document.querySelector<HTMLElement>('[data-composer-card]')}
onDone={() => { setToast(null) }}
onDone={() => { dismissRefusal(toast.error) }}
/>
)}
</>
@@ -4,10 +4,11 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import {
Button, IconBrowseOutlineRegular, IconPlusOutlineRegular, Modal, Tag, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { AgentPresetSectionState } from './section-store.ts'
import { isBuiltInPreset, presetDisplayText } from './locales.ts'
import { requiresCodingTools } from './settings-store.ts'
import { PresetGuideDialog, presetGuide, trapPresetReaderTab, type PresetGuidePage } from './PresetGuideDialog.tsx'
import css from './AgentPresetSection.module.css'
@@ -15,6 +16,8 @@ import css from './AgentPresetSection.module.css'
export interface AgentPresetSectionInjected {
hooks: {
agentPresetSection: SnapshotStore<AgentPresetSectionState>
/** Shared Coding Tools preference; off hides the built-in PTC and Minimal cards. */
developerTools: ObservableSnapshot<boolean>
}
/** Stage the `cordis` preset and start a Creator-mode task; absent without a conversation flow. */
startCreatorDraft?: () => void
@@ -60,9 +63,10 @@ function CardDescription({ text }: { text: string }): ReactNode {
*/
export function AgentPresetSection({
useAgentPresetSection, load, view, closeView, makeDefault, startCreatorDraft,
close: closeSettings, t,
close: closeSettings, useDeveloperTools, t,
}: AgentPresetSectionProps) {
const state = useAgentPresetSection(value => value)
const developerTools = useDeveloperTools(enabled => enabled)
const [guide, setGuide] = useState<{
content: NonNullable<ReturnType<typeof presetGuide>>
page: PresetGuidePage
@@ -99,7 +103,8 @@ export function AgentPresetSection({
<p className={css.intro}>{t('sectionIntro')}</p>
{state.error === null ? null : <p className={css.error} role="alert">{state.error}</p>}
{([true, false] as const).map((builtIn) => {
const rows = state.rows.filter(row => isBuiltInPreset(row) === builtIn)
const rows = state.rows.filter(row => isBuiltInPreset(row) === builtIn
&& (developerTools || !requiresCodingTools(row)))
const entry = builtIn ? null : creatorButton
if (rows.length === 0 && entry === null) return null
return <section key={String(builtIn)} className={css.group}>
@@ -0,0 +1,19 @@
.copy {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
white-space: normal;
}
.description {
display: -webkit-box;
max-height: 36px;
overflow: hidden;
overflow-wrap: anywhere;
color: var(--dsw-alias-label-secondary);
font-size: 12px;
line-height: 18px;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
@@ -0,0 +1,50 @@
/** Create a plugin through the existing Creator flow from the Add plugin menu. */
import { useEffect } from 'react'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client'
import { IconAgentPresetOutlineRegular, MenuItemButton } from '@deepseek-ai/dsh-client-ui-primitives'
import type { AgentPresetSettingsState } from './settings-store.ts'
import css from './CreatePluginMenuItem.module.css'
/** Roster and navigation supplied by the preset plugin. */
export interface CreatePluginMenuItemInjected {
hooks: {
agentPresets: SnapshotStore<AgentPresetSettingsState>
}
load: () => Promise<void>
startCreatorDraft: () => void
}
type CreatePluginMenuItemProps = PropsRuntime<'plugins.add.actions'>
& PropsLocale<'settings.agentPreset'> & InjectFace<CreatePluginMenuItemInjected>
/**
* Close the Add plugin menu before opening Creator without sending a message.
* @param props - locale, roster, dismissal and navigation callbacks.
* @returns a stable menu item that explains why Creator is unavailable.
*/
export function CreatePluginMenuItem({
t, useAgentPresets, load, onDismiss, startCreatorDraft,
}: CreatePluginMenuItemProps) {
const roster = useAgentPresets(state => state)
const enabled = roster.status === 'ready' && roster.options.some(option => option.id === 'cordis')
useEffect(() => { void load() }, [load])
let description = t('createPluginDescription')
if (roster.status === 'idle' || roster.status === 'loading') description = t('createPluginChecking')
else if (roster.status === 'error' || roster.status === 'unavailable') description = t('createPluginUnavailable')
else if (!enabled) description = t('createPluginMissing')
return (
<MenuItemButton icon={<IconAgentPresetOutlineRegular size={14} />} disabled={!enabled} onSelect={() => {
onDismiss()
startCreatorDraft()
}}>
<span className={css.copy}>
<span>{t('createPlugin')}</span>
<span className={css.description} title={description}>{description}</span>
</span>
</MenuItemButton>
)
}
@@ -12,9 +12,9 @@
* edited where the roster is visible — the settings section's "make default"
* — so General settings carries no duplicate control for the same field.
*
* Developer tools (General settings) are the single gate over selection: with
* them off the chip disappears and the card actions are disabled, while the
* saved default keeps composing new sessions.
* Coding Tools (General settings) hide PTC and Minimal from the hero menu
* and Settings roster when off. Hidden saved defaults fall back to Standard;
* the existing gate clears staged choices while existing sessions keep their composition.
*/
// Type-only: pulls the Session Controller service merge (ctx.sessions).
@@ -31,8 +31,10 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
// Type-only: pulls the Workspace UI navigation service merge (ctx.uiWorkspace).
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import { AgentPresetLabel } from './AgentPresetLabel.tsx'
import { CreatePluginMenuItem } from './CreatePluginMenuItem.tsx'
import type { AgentPresetLabelInjected } from './AgentPresetLabel.tsx'
import { AgentPresetSeat } from './AgentPresetSeat.tsx'
import type { AgentPresetSeatInjected } from './AgentPresetSeat.tsx'
@@ -68,6 +70,12 @@ export const inject = [
* @param ctx - the browser plugin context.
*/
export function apply(ctx: ClientContext): void {
const toolsSettings = ctx.configForms.get<{ enabled: boolean }>('ui-settings')
const presetSettings = ctx.configForms.get(AGENT_PRESET_SETTINGS_NS)
let active = true
// Host preferences start at false before their first accepted document.
const codingToolsDisabled = (): boolean => active && toolsSettings.getSnapshot().mode === 'host'
&& toolsSettings.getSnapshot().value?.enabled === false
const controller = new AgentPresetSettingsController(ctx)
const staged: AgentPresetStage = { id: undefined, introduce: false }
const seats = new WeakMapWithValues<SessionBinding, AgentPresetSeatController>()
@@ -122,6 +130,22 @@ export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register('settings.agentPreset', { zh, en }), 'ui-agent-preset: settings row dictionaries')
ctx.effect(() => {
let requested = 0
let pending: Promise<void> | undefined
// Settings invalidations may land while a correction is waiting on a
// person's selection. Recheck once that work settles using the latest values.
const reconcile = (): void => {
requested++
pending ??= Promise.resolve().then(async () => {
try {
for (;;) {
const revision = requested
await section.reconcileCodingTools(codingToolsDisabled)
if (!active || requested === revision) return
}
} finally { pending = undefined }
})
}
// The roster reflects live declarations and the default is a settings field, so
// both an external settings edit and a reconnect can move this row.
const refresh = (): void => {
@@ -131,17 +155,23 @@ export function apply(ctx: ClientContext): void {
if (section.store.getSnapshot().status !== 'idle') void section.load()
void unboundSeat.load()
for (const seat of seats.values) void seat.load()
reconcile()
}
const disposers = [
toolsSettings.subscribe(reconcile),
presetSettings.subscribe(reconcile),
ctx.remote.$on('settings/document-updated', (ns) => {
if (ns !== AGENT_PRESET_SETTINGS_NS) return
refresh()
}),
ctx.on('connection/reset', () => {
refresh()
}),
ctx.on('connection/reset', refresh),
]
return () => { for (const dispose of disposers) dispose() }
reconcile()
return async () => {
active = false
for (const dispose of disposers) dispose()
await pending
}
}, 'ui-agent-preset: settings refresh')
// The settings section's conversational authoring entry: stage the
@@ -158,6 +188,7 @@ export function apply(ctx: ClientContext): void {
hooks: { agentPresetSeat: seat.store, developerTools: ctx.configForms.developerTools.enabled },
load: () => seat.load(),
select: (id: string) => seat.select(id),
dismissRefusal: (error) => { seat.dismissRefusal(error) },
introduced: () => { seat.introduced() },
}
}
@@ -167,13 +198,15 @@ export function apply(ctx: ClientContext): void {
load: () => controller.load(),
})
const startCreatorDraft = () => {
const seat = mainBlankSeat() ?? unboundSeat
seat.stage('cordis', true)
scope.uiWorkspace.startSession()
void seat.apply()
}
scope.effect(() => {
creatorDraft = () => {
const seat = mainBlankSeat() ?? unboundSeat
seat.stage('cordis', true)
scope.uiWorkspace.startSession()
void seat.apply()
}
creatorDraft = startCreatorDraft
const chip = scope.slots.register({
name: 'conversation.hero.agentPreset',
locale: 'settings.agentPreset',
@@ -193,6 +226,17 @@ export function apply(ctx: ClientContext): void {
label()
}
}, 'ui-agent-preset: new-session chip and header label')
scope.slots.inject('plugins.add.actions', () => scope.slots.register({
name: 'plugins.add.actions',
id: 'create-plugin',
locale: 'settings.agentPreset',
inject: () => ({
hooks: { agentPresets: controller.store },
load: () => controller.load(),
startCreatorDraft,
}),
}, CreatePluginMenuItem))
})
/** Capture the exact blank Session one Settings action may update. */
@@ -210,7 +254,7 @@ export function apply(ctx: ClientContext): void {
}
const sectionInjected = (): AgentPresetSectionInjected => ({
hooks: { agentPresetSection: section.store },
hooks: { agentPresetSection: section.store, developerTools: ctx.configForms.developerTools.enabled },
load: () => section.load(),
view: (id: string) => section.view(id),
closeView: () => { section.closeView() },
@@ -25,8 +25,14 @@ export type AgentPresetSettingsKey =
| 'noDescription'
| 'brokenBadge'
| 'switchRefused'
| 'standardUnavailable'
| 'close'
| 'creatorDraft'
| 'createPlugin'
| 'createPluginDescription'
| 'createPluginChecking'
| 'createPluginUnavailable'
| 'createPluginMissing'
/** English copy. */
export const en: Record<AgentPresetSettingsKey, string> = {
@@ -60,10 +66,16 @@ export const en: Record<AgentPresetSettingsKey, string> = {
brokenBadge: 'Failed to load',
switchRefused: 'Could not switch to {name}: {reason}',
standardUnavailable: 'Standard mode is unavailable. Restore it or choose another available mode.',
close: 'Close',
creatorDraft: 'Let the agent help me create a preset',
createPlugin: 'Let the agent create a plugin',
createPluginDescription: 'Enter Creator mode and make your own DSH plugin',
createPluginChecking: 'Checking whether Creator mode is available',
createPluginUnavailable: 'Temporarily unavailable. Reopen this menu to retry',
createPluginMissing: 'Creator mode is not included in this configuration',
}
@@ -95,10 +107,16 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
brokenBadge: '加载失败',
switchRefused: '无法切换到「{name}」:{reason}',
standardUnavailable: '标准模式不可用,请恢复该模式或选择其他可用模式。',
close: '关闭',
creatorDraft: '让 Agent 帮我创建预设模式',
createPlugin: '让 Agent 创建插件',
createPluginDescription: '进入创造模式,制作属于你的 DSH 插件',
createPluginChecking: '正在确认创造模式是否可用',
createPluginUnavailable: '暂时不可用,请重新打开菜单重试',
createPluginMissing: '当前配置未提供创造模式',
}
@@ -13,8 +13,6 @@
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'
// Type-only: pulls the Developer tools preference (ctx.configForms) into this program.
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type {} from '@deepseek-ai/dsh-agent-preset-registry/types'
@@ -27,8 +25,8 @@ export interface AgentPresetSeatState {
options: readonly AgentPresetOption[]
/** The staged choice, empty until the roster loads. */
current: string
/** A rejected apply's message, cleared by the next attempt. */
error: string | null
/** An error message; explicit selection failures also carry the preset for a Toast. */
error: string | { readonly preset: AgentPresetOption; readonly reason: string } | null
busy: boolean
/**
* One-shot cue that the chip should introduce itself (the creator-draft
@@ -85,11 +83,6 @@ export class AgentPresetSeatController {
this.staged.introduce = false
}
/** Developer tools are the single gate over preset selection. */
private selectionAvailable(): boolean {
return this.ctx.configForms.developerTools.enabled.getSnapshot()
}
/**
* Read the roster and open the chip on the Host-effective default.
* @returns once the snapshot reflects the host.
@@ -98,13 +91,13 @@ export class AgentPresetSeatController {
const generation = ++this.loadGeneration
const roster = await readRoster(this.ctx)
if (generation !== this.loadGeneration) return
// A roster refresh can finish after a selection; leave its announcement for the chip.
const error = this.store.getSnapshot().error
if (!roster.ok) {
this.set({ error: roster.error })
if (typeof error !== 'object' || error === null) this.set({ error: roster.error })
return
}
const { presets } = roster.value
// A stage outlives the screen that made it; Developer tools may have gone off since.
if (!this.selectionAvailable()) this.clearStage()
this.fallback = presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? ''
const session = this.currentSession()
this.set({
@@ -116,7 +109,7 @@ export class AgentPresetSeatController {
// once the flow's session is current, so the reply can arrive after
// apply() already composed it.
current: this.staged.id ?? (session === undefined ? this.fallback : presetOf(session) ?? ''),
error: null,
error: typeof error === 'object' ? error : null,
introduce: this.staged.introduce,
})
await this.apply()
@@ -126,11 +119,8 @@ export class AgentPresetSeatController {
* Stage one preset for the next session, applying it immediately when a
* blank session is already current.
*
* The refusal is returned as well as stored, because the two readers need
* different things from it: the chip's own label carries the standing state,
* while the caller that made this pick is the one that has to say why the
* label came back — and only it knows the pick was a person's, not the
* applier catching up with a session that just became current.
* The refusal is stored for the chip's announcement and returned to callers
* such as Settings that also report the result of their own write.
* @param id - the preset to stage.
* @returns the refusal text, or undefined once the pick settled.
*/
@@ -157,6 +147,15 @@ export class AgentPresetSeatController {
this.set({ current: id, error: null, introduce })
}
/** Acknowledge a displayed refusal without dismissing a newer attempt.
* @param refusal - the selection error whose Toast finished.
*/
dismissRefusal(refusal: AgentPresetSeatState['error']): void {
if (refusal !== null && typeof refusal === 'object' && this.store.getSnapshot().error === refusal) {
this.set({ error: refusal.reason })
}
}
/**
* Capture the exact blank Session a Settings action may bring along.
* @returns its id, or undefined outside a blank Session.
@@ -201,18 +200,12 @@ export class AgentPresetSeatController {
*/
async apply(): Promise<string | undefined> {
if (this.store.getSnapshot().busy) return
const available = this.selectionAvailable()
// A stage made while Developer tools were on must not outlive them.
if (!available) this.clearStage()
const staged = this.staged.id
const session = this.currentSession()
if (staged === undefined) {
const current = session === undefined ? this.fallback : presetOf(session) ?? ''
const shown = this.store.getSnapshot()
// Hiding the chip also withdraws the introduction cue armed for it;
// ordinary list movement leaves that cue to the chip that plays it.
if (!available && shown.introduce) this.set({ current, introduce: false })
else if (current !== shown.current) this.set({ current })
if (current !== shown.current) this.set({ current })
return
}
if (session === undefined) return
@@ -225,26 +218,27 @@ export class AgentPresetSeatController {
const completion = Promise.withResolvers<undefined>()
this.pendingSelection = completion.promise
this.clearStage()
const refuse = (reason: string) => {
this.set({
error: { reason, preset: this.store.getSnapshot().options.find(option => option.id === staged) ?? { id: staged } },
current: this.staged.id ?? presetOf(session) ?? '',
})
return reason
}
try {
this.set({ busy: true, error: null })
const result = await this.ctx.remote.agentPresets.select(session.id, staged)
if (!result.ok) {
const { error } = result
// Prefer the bare cause; the Toast already names the rejected preset.
const refusal = 'reason' in error.details && typeof error.details.reason === 'string'
? error.details.reason
: error.message
this.set({
// A refusal carries its cause twice: `message` wraps it in the
// roster's own frame, which names the preset the surface reporting
// this already names, and a `reason` detail holds the same cause
// without it. Read by the detail rather than by the code, because
// every refusal that has a cause to give names it the same way.
error: refusal,
current: this.staged.id ?? presetOf(session) ?? '',
})
return refusal
return refuse(refusal)
}
this.set({ current: this.staged.id ?? result.value })
} catch (error) {
return refuse(error instanceof Error ? error.message : String(error))
} finally {
this.pendingSelection = undefined
this.set({ busy: false })
@@ -3,7 +3,7 @@ import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { AgentPresetRow } from '@deepseek-ai/dsh-agent-preset-registry/types'
import { writeDefaultPreset } from './settings-store.ts'
import { AGENT_PRESET_SETTINGS_NS, requiresCodingTools, writeDefaultPreset } from './settings-store.ts'
/** The read-only composition viewer over one preset. */
export interface PresetView {
@@ -31,6 +31,7 @@ export class AgentPresetSectionController {
/** Observable roster, selection and viewer state. */
readonly store: SnapshotStore<AgentPresetSectionState> = createSnapshotStore(INITIAL)
private loading: Promise<void> | undefined
private pendingSave: Promise<void> | undefined
private viewRequest = 0
constructor(private readonly ctx: Context) {}
@@ -76,8 +77,31 @@ export class AgentPresetSectionController {
async makeDefault(id: string, sync?: (id: string) => Promise<string | undefined>): Promise<void> {
await this.save(() => writeDefaultPreset(this.ctx, id), sync)
}
/** Replace a hidden built-in default after accepted Coding Tools changes.
* @param shouldReset Rechecked after waiting; false when tools are enabled or this owner is disposed.
* @returns Once the conditional save settles; errors remain visible in the section.
*/
async reconcileCodingTools(shouldReset: () => boolean): Promise<void> {
while (this.pendingSave !== undefined) await this.pendingSave
if (!shouldReset()) return
const settings = this.ctx.configForms.get(AGENT_PRESET_SETTINGS_NS).getSnapshot()
if (settings.mode !== 'host' || settings.status !== 'ready' || !settings.writable || settings.revision === undefined) return
await this.load()
if (!shouldReset() || this.store.getSnapshot().status !== 'ready') return
const rows = this.store.getSnapshot().rows
if (!requiresCodingTools(rows.find(row => row.isDefault))) return
if (!rows.some(row => row.id === 'standard' && row.broken === undefined)) {
this.set({ error: this.ctx.locale.bind('settings.agentPreset')('standardUnavailable') })
return
}
await this.save(() => writeDefaultPreset(this.ctx, 'standard', settings.revision))
}
private async save(write: () => Promise<string | undefined>, sync?: (id: string) => Promise<string | undefined>): Promise<void> {
if (this.store.getSnapshot().saving) return
const completion = Promise.withResolvers<void>()
this.pendingSave = completion.promise
this.set({ saving: true, error: null })
try {
const error = await write()
@@ -89,6 +113,10 @@ export class AgentPresetSectionController {
if (error !== undefined) throw new Error(error)
}
} catch (error) { this.set({ error: message(error) }) }
finally { this.set({ saving: false }) }
finally {
this.pendingSave = undefined
this.set({ saving: false })
completion.resolve()
}
}
}
@@ -11,6 +11,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { AgentPresetRoster } from '@deepseek-ai/dsh-agent-preset-registry/types'
import { isBuiltInPreset } from './locales.ts'
/** The agent-preset settings namespace on the host wire. */
export const AGENT_PRESET_SETTINGS_NS = 'agent-preset-registry'
@@ -23,13 +24,15 @@ export const AGENT_PRESET_SETTINGS_NS = 'agent-preset-registry'
* the host resolves at session creation.
* @param ctx - the browser plugin context carrying the Remote namespaces.
* @param id - the preset to make default.
* @param expectedRevision - optional fence for an automatic correction based on a previous settings read.
* @returns the failure message, or undefined once the write landed.
*/
export async function writeDefaultPreset(
ctx: ClientContext,
id: string,
expectedRevision?: number,
): Promise<string | undefined> {
const response = await ctx.remote.settings.update(AGENT_PRESET_SETTINGS_NS, { selectedDefault: id }, undefined)
const response = await ctx.remote.settings.update(AGENT_PRESET_SETTINGS_NS, { selectedDefault: id }, expectedRevision)
return response.ok ? undefined : response.error.message
}
@@ -43,6 +46,14 @@ export interface AgentPresetOption {
description?: string
}
/** Whether a shipped preset requires the Coding Tools preference.
* @param preset Roster entry; named custom overrides keep their own behavior.
* @returns True only for the built-in PTC and Minimal presets.
*/
export function requiresCodingTools(preset: AgentPresetOption | undefined): boolean {
return preset !== undefined && isBuiltInPreset(preset) && (preset.id === 'ptc' || preset.id === 'minimal')
}
/** One roster entry exactly as the host reports it. */
export type RosterPreset = AgentPresetRoster['presets'][number]
@@ -22,13 +22,16 @@ import type { AgentPresetSectionInjected } from '../src/client/AgentPresetSectio
import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx'
import type { AgentPresetSeatInjected } from '../src/client/AgentPresetSeat.tsx'
import { AgentPresetSeatController } from '../src/client/seat-store.ts'
import { CreatePluginMenuItem, type CreatePluginMenuItemInjected } from '../src/client/CreatePluginMenuItem.tsx'
import { AgentPresetSectionController } from '../src/client/section-store.ts'
import { apply as hostApply } from '../src/index.ts'
import type { AgentPresetRow } from '@deepseek-ai/dsh-agent-preset-registry/types'
// These specs assert the shipped Chinese copy. The lane has no jsdom `window`,
// so browser-language detection never runs and a fresh LocaleRuntime opens on
// FALLBACK_LOCALE (en); each bench stages zh explicitly on the locale instead.
/** The Developer tools half of `ctx.configForms`, which gates all selection. */
/** The Developer tools half of `ctx.configForms`, which filters preset choices. */
function developerTools(enabled = true): { configForms: { developerTools: { enabled: ObservableSnapshot<boolean> } } } {
return { configForms: { developerTools: { enabled: createSnapshotStore(enabled) } } }
}
@@ -66,31 +69,36 @@ async function bench(options: {
failSettingsUpdate?: boolean
selectGate?: Promise<void>
settingsRosterGate?: Promise<undefined>
describeGate?: Promise<undefined>
presets?: AgentPresetRow[]
developerToolsEnabled?: boolean
memory?: boolean
} = {}) {
const ctx = new Context()
// The host's answer, mutable so a spec can move the default the way the
// settings surface does and watch who re-reads it.
let ROSTER: typeof ROSTER_ONE | typeof ROSTER_MOVED | typeof ROSTER_AUTHORED = ROSTER_ONE
let ROSTER: { ok: true; value: { presets: AgentPresetRow[] } } = options.presets === undefined
? ROSTER_ONE : { ok: true, value: { presets: options.presets } }
const moveDefault = (): void => { ROSTER = ROSTER_MOVED }
await ctx.plugin(SlotRegistry).await()
const locale = new LocaleRuntime(ctx)
locale.setLocale('zh')
ctx.provide('locale', locale)
const calls: string[] = []
let savedDefault = 'standard'
let developerToolsEnabled = true
let savedDefault = ROSTER.value.presets.find(row => row.isDefault)?.id ?? 'standard'
let presetRevision = 0
let developerToolsEnabled = options.developerToolsEnabled ?? true
let settingsSaved = false
const settingsRosterStarted = Promise.withResolvers<undefined>()
// The row reads `describe` to learn whether this browser may write at all,
// and its default write is the one op this spec records.
const settings = {
describe: () => Promise.resolve({
describe: () => Promise.resolve(options.describeGate).then(() => ({
ok: true as const,
value: {
writable: true,
hasDocument: true,
// The shared Developer tools preference the surfaces read: this browser
// has it on, so the roster is editable.
// The accepted Developer tools preference shared by both preset surfaces.
namespaces: [{
ns: 'ui-settings',
schema: { type: 'object', dict: { enabled: { type: 'boolean' } } },
@@ -99,9 +107,17 @@ async function bench(options: {
applies: 'live',
secrets: [],
revision: 0,
}, {
ns: 'agent-preset-registry',
schema: { type: 'object', dict: { selectedDefault: { type: 'string', meta: {} } } },
value: { selectedDefault: savedDefault },
autoGenerate: false,
applies: 'live',
secrets: [],
revision: presetRevision,
}],
},
}),
})),
update: (_ns: string, patch: { selectedDefault?: unknown }) => {
calls.push(`settings:${JSON.stringify(patch)}`)
if (options.failSettingsUpdate === true) {
@@ -113,13 +129,16 @@ async function bench(options: {
if (typeof patch.selectedDefault === 'string') {
savedDefault = patch.selectedDefault
}
ROSTER = savedDefault === 'minimal' ? ROSTER_MOVED : ROSTER_ONE
presetRevision += 1
ROSTER = options.presets === undefined ? savedDefault === 'minimal' ? ROSTER_MOVED : ROSTER_ONE
: { ok: true, value: { presets: ROSTER.value.presets.map(row => ({ ...row, isDefault: row.id === savedDefault })) } }
settingsSaved = true
return Promise.resolve({ ok: true as const, value: {} })
},
}
const remote = new TestRemote(ctx, { settings })
if (options.memory === true) remote.$host = { home: undefined, isLoopback: false }
// The roster and the switch are the AgentPresets Remote namespace; the
// shared double carries no generated namespaces, so this spec stages its
// own. Registered twice on purpose: the nested key satisfies the plugin's
@@ -153,8 +172,12 @@ async function bench(options: {
ctx.provide('remote.agentPresets', agentPresets as never)
Object.assign(remote, { agentPresets })
await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
// Every spec below starts from the accepted "Developer tools on" value.
await vi.waitFor(() => { expect(ctx.configForms.developerTools.enabled.getSnapshot()).toBe(true) })
if (options.describeGate === undefined) {
await vi.waitFor(() => {
expect(ctx.configForms.developerTools.enabled.getSnapshot()).toBe(options.memory === true ? true : developerToolsEnabled)
})
if (options.memory !== true) await vi.waitFor(() => { expect(ctx.configForms.get('ui-settings').getSnapshot().status).toBe('ready') })
}
/** Publish a new Developer tools choice the way the settings mirror does. */
const setDeveloperTools = async (enabled: boolean): Promise<void> => {
developerToolsEnabled = enabled
@@ -351,14 +374,57 @@ describe('ui-agent-preset apply', () => {
await vi.waitFor(() => { expect(slots.entries('settings.section')).toHaveLength(1) })
})
it('hands the section its own store and default write', async () => {
const { ctx, slots } = await bench()
it('binds the Add plugin menu item to the roster and Creator flow and removes it with the feature', async () => {
const { ctx, slots, setDeveloperTools } = await bench()
try {
slots.register({
name: 'root',
children: {
'settings.section': { kind: 'list', scope: 'root' },
conversation: { kind: 'single', scope: 'root' },
'plugins.add.actions': { kind: 'list', scope: 'root' },
},
} as never, () => null)
declareConversation(slots)
ctx.provide('conversation', {} as never)
ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never)
const uiWorkspace = uiWorkspaceDouble()
ctx.provide('uiWorkspace', uiWorkspace as never)
const feature = ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply })
await feature.await()
const entry = slots.entries('plugins.add.actions')[0]!
expect(entry.component).toBe(CreatePluginMenuItem)
const action: Partial<CreatePluginMenuItemInjected> | undefined = entry.inject?.()
if (action?.load === undefined || action.hooks === undefined || action.startCreatorDraft === undefined) {
throw new Error('expected the injected Create plugin actions')
}
await action.load()
expect(action.hooks.agentPresets.getSnapshot().options).toEqual([{ id: 'standard' }])
await setDeveloperTools(false)
action.startCreatorDraft()
expect(uiWorkspace.starts).toHaveLength(1)
expect(ctx.configForms.developerTools.enabled.getSnapshot()).toBe(false)
await feature.dispose()
expect(slots.entries('plugins.add.actions')).toHaveLength(0)
} finally {
await ctx.fiber.dispose()
}
})
it('hands the section its own store, live Coding Tools preference, and default write', async () => {
const { ctx, slots, setDeveloperTools } = await bench()
ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never)
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
expect(section.hooks.developerTools).toBe(ctx.configForms.developerTools.enabled)
await setDeveloperTools(false)
expect(section.hooks.developerTools.getSnapshot()).toBe(false)
await section.load()
await section.makeDefault('standard')
expect(section.hooks.agentPresetSection.getSnapshot().rows)
@@ -735,65 +801,264 @@ describe('ui-agent-preset apply', () => {
expect(calls.filter(call => call === 'select:minimal')).toHaveLength(spent)
})
it('drops a cross-screen stage when Developer tools turn off, and releases its subscription with the fiber', async () => {
const { ctx, slots, calls, setDeveloperTools } = await bench()
declareRoot(slots)
const conversation = declareConversation(slots)
ctx.provide('conversation', {} as never)
const state: {
current?: string
byId: Record<string, { id: string; blank: boolean; projectionValues?: { agentPreset?: string | null } }>
} = {
current: 's0',
byId: {
s0: { id: 's0', blank: false, projectionValues: { agentPreset: 'standard' } },
s1: { id: 's1', blank: true, projectionValues: { agentPreset: 'standard' } },
},
it.each(['standard', 'cordis', 'mine', 'ptc', 'minimal']
.flatMap(preset => ['unbound', 'inactive bound'].map(source => ({ preset, source }))))(
'clears the staged $preset choice on Coding Tools off without changing the Session ($source)', async ({ preset, source }) => {
const { ctx, slots, calls, setDeveloperTools } = await bench({ presets: [
...ROSTER_MOVED.value.presets, { id: 'ptc', isDefault: false },
{ id: 'cordis', isDefault: false }, { id: 'mine', name: 'Mine', isDefault: false },
] })
declareRoot(slots)
declareConversation(slots)
ctx.provide('conversation', {} as never)
const state: {
current?: string
byId: Record<string, { id: string; blank: boolean; projectionValues?: { agentPreset?: string | null } }>
} = {
byId: { s1: { id: 's1', blank: true, projectionValues: { agentPreset: 'minimal' } } },
}
const sessions = sessionsDouble(ctx, state)
ctx.provide('sessions', sessions as never)
ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
const injectSeat = slots.entries('conversation.hero.agentPreset')[0]!
.inject as (sessionId?: SessionId) => AgentPresetSeatInjected & Record<string, unknown>
const chip = injectSeat(source === 'unbound' ? undefined : SessionId('s1'))
await chip.load()
await chip.select(preset)
await setDeveloperTools(false)
await vi.waitFor(() => { expect(calls.filter(call => call.startsWith('settings:'))).toEqual(['settings:{"selectedDefault":"standard"}']) })
await chip.load()
expect(chip.hooks.agentPresetSeat.getSnapshot().current).toBe('standard')
expect(calls.filter(call => call.startsWith('select:'))).toEqual([])
state.current = 's1'
const bound = injectSeat(SessionId('s1'))
sessions.notify()
await bound.load()
expect(calls.filter(call => call.startsWith('select:'))).toEqual([])
expect(bound.hooks.agentPresetSeat.getSnapshot().current).toBe('minimal')
if (['standard', 'cordis', 'mine'].includes(preset)) {
await bound.select(preset)
expect(calls.filter(call => call.startsWith('select:'))).toEqual([`select:${preset}`])
expect(bound.hooks.agentPresetSeat.getSnapshot().current).toBe(preset)
}
await ctx.fiber.dispose()
},
)
it('does not cancel an in-flight preset selection when Coding Tools turn off', async () => {
const selection = Promise.withResolvers<undefined>()
const b = await bench({ selectGate: selection.promise, presets: ROSTER_MOVED.value.presets })
try {
declareRoot(b.slots)
declareConversation(b.slots)
b.ctx.provide('conversation', {} as never)
b.ctx.provide('sessions', sessionsDouble(b.ctx, {
current: 's1', byId: { s1: { id: 's1', blank: true, projectionValues: { agentPreset: 'standard' } } },
}) as never)
b.ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const chip = (b.slots.entries('conversation.hero.agentPreset')[0]!
.inject as (sessionId: SessionId) => AgentPresetSeatInjected & Record<string, unknown>)(SessionId('s1'))
await chip.load()
const selecting = chip.select('minimal')
expect(chip.hooks.agentPresetSeat.getSnapshot().busy).toBe(true)
await b.setDeveloperTools(false)
await vi.waitFor(() => { expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual(['settings:{"selectedDefault":"standard"}']) })
expect(b.calls.filter(call => call.startsWith('select:'))).toEqual(['select:minimal'])
selection.resolve(undefined)
await selecting
expect(chip.hooks.agentPresetSeat.getSnapshot()).toMatchObject({ current: 'minimal', busy: false })
expect(b.calls.filter(call => call.startsWith('select:'))).toEqual(['select:minimal'])
} finally {
selection.resolve(undefined)
await b.ctx.fiber.dispose()
}
const sessions = sessionsDouble(ctx, state)
ctx.provide('sessions', sessions as never)
ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
// The gate's seat sweep runs over live Provider bindings; s1's binding is
// not the main view yet, so its seat keeps a stage it cannot apply.
let released = 0
const dispatch = ctx.configForms.developerTools.enabled.subscribe.bind(ctx.configForms.developerTools.enabled)
vi.spyOn(ctx.configForms.developerTools.enabled, 'subscribe').mockImplementation((listener) => {
const dispose = dispatch(listener)
return () => { released += 1; dispose() }
})
it('does not reset a default during bootstrap or after accepting Coding Tools on', async () => {
const described = Promise.withResolvers<undefined>()
const b = await bench({ describeGate: described.promise, presets: ROSTER_MOVED.value.presets })
b.ctx.provide('sessions', sessionsDouble(b.ctx, { byId: {} }) as never)
declareRoot(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
expect(b.ctx.configForms.developerTools.enabled.getSnapshot()).toBe(false)
await Promise.resolve()
expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual([])
described.resolve(undefined)
await vi.waitFor(() => { expect(b.ctx.configForms.developerTools.enabled.getSnapshot()).toBe(true) })
expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual([])
await b.ctx.fiber.dispose()
})
it.each(['ptc', 'minimal'])('resets an accepted disabled %s default once, without restoring it on enable', async (id) => {
const b = await bench({ developerToolsEnabled: false, presets: [
{ id: 'standard', isDefault: false }, { id, isDefault: true },
] })
b.ctx.provide('sessions', sessionsDouble(b.ctx, { byId: {} }) as never)
declareRoot(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
await vi.waitFor(() => { expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual(['settings:{"selectedDefault":"standard"}']) })
const section = (b.slots.entries('settings.section')[0]!.inject as () => AgentPresetSectionInjected & Record<string, unknown>)()
await vi.waitFor(() => { expect(section.hooks.agentPresetSection.getSnapshot().rows.find(row => row.isDefault)?.id).toBe('standard') })
await b.setDeveloperTools(true)
expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual(['settings:{"selectedDefault":"standard"}'])
expect(section.hooks.agentPresetSection.getSnapshot().rows.find(row => row.isDefault)?.id).toBe('standard')
await b.ctx.fiber.dispose()
})
it.each([['standard', undefined], ['cordis', undefined], ['mine', 'Mine'], ['ptc', 'Custom PTC'], ['minimal', 'Custom Minimal']] as const)(
'keeps the allowed default %s (%s) when Coding Tools are disabled', async (id, name) => {
const b = await bench({ developerToolsEnabled: false, presets: [
{ id, isDefault: true, ...(name === undefined ? {} : { name }) },
...(id === 'standard' ? [] : [{ id: 'standard', isDefault: false }]),
] })
b.ctx.provide('sessions', sessionsDouble(b.ctx, { byId: {} }) as never)
declareRoot(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const section = (b.slots.entries('settings.section')[0]!.inject as () => AgentPresetSectionInjected & Record<string, unknown>)()
await section.load()
await Promise.resolve()
expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual([])
expect(section.hooks.agentPresetSection.getSnapshot().rows.find(row => row.isDefault)?.id).toBe(id)
await b.ctx.fiber.dispose()
},
)
it('resets an existing hidden default when the accepted preference turns off', async () => {
const b = await bench({ presets: ROSTER_MOVED.value.presets })
b.ctx.provide('sessions', sessionsDouble(b.ctx, { byId: {} }) as never)
declareRoot(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual([])
await b.setDeveloperTools(false)
expect(b.ctx.configForms.get('agent-preset-registry').getSnapshot()).toMatchObject({ status: 'ready', mode: 'host', writable: true, revision: 0 })
await vi.waitFor(() => { expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual(['settings:{"selectedDefault":"standard"}']) })
await b.ctx.fiber.dispose()
})
it('corrects a hidden default when accepted settings arrive as the previous reconciliation settles', async () => {
const b = await bench({ presets: ROSTER_MOVED.value.presets })
b.ctx.provide('sessions', sessionsDouble(b.ctx, { byId: {} }) as never)
declareRoot(b.slots)
const answer = await b.ctx.remote.settings.describe()
if (!answer.ok) throw new Error(answer.error.message)
const describe = vi.spyOn(b.ctx.remote.settings, 'describe').mockResolvedValue({
...answer,
value: { ...answer.value, namespaces: answer.value.namespaces.map(row => row.ns === 'ui-settings'
? { ...row, value: { enabled: false } } : row) },
})
const feature = ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply })
const disabled = Promise.withResolvers<undefined>()
const reconcile = vi.spyOn(AgentPresetSectionController.prototype, 'reconcileCodingTools')
.mockImplementationOnce(function (this: AgentPresetSectionController, shouldReset) {
reconcile.mockRestore()
const result = this.reconcileCodingTools(shouldReset)
// The real settings mirror publishes its already-answered read while
// the prior reconciliation's promise is settling.
void result.then(() => { void b.setDeveloperTools(false).then(() => { disabled.resolve(undefined) }, disabled.reject) })
return result
})
try {
await b.ctx.plugin({ inject: [...inject], apply }).await()
await disabled.promise
expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual(['settings:{"selectedDefault":"standard"}'])
} finally {
await b.ctx.fiber.dispose()
reconcile.mockRestore()
describe.mockRestore()
}
})
it.each([['cordis', true], ['mine', true], ['ptc', true], ['minimal', true], ['ptc', false], ['minimal', false]] as const)(
'resets only the default while the current task uses %s (blank: %s)', async (preset, blank) => {
const b = await bench({ presets: [
{ id: 'standard', isDefault: false }, { id: 'minimal', isDefault: true }, { id: 'ptc', isDefault: false },
{ id: 'cordis', isDefault: false }, { id: 'mine', name: 'Mine', isDefault: false },
] })
declareRoot(b.slots)
declareConversation(b.slots)
b.ctx.provide('conversation', {} as never)
b.ctx.provide('sessions', sessionsDouble(b.ctx, {
current: 'blank', byId: { blank: { id: 'blank', blank, projectionValues: { agentPreset: preset } } },
}) as never)
b.ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const chip = (b.slots.entries('conversation.hero.agentPreset')[0]!.inject as (id: SessionId) => AgentPresetSeatInjected & Record<string, unknown>)(SessionId('blank'))
await chip.load()
expect(chip.hooks.agentPresetSeat.getSnapshot().current).toBe(preset)
await b.setDeveloperTools(false)
await vi.waitFor(() => { expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual(['settings:{"selectedDefault":"standard"}']) })
await chip.load()
expect(b.calls.filter(call => call.startsWith('select:'))).toEqual([])
expect(chip.hooks.agentPresetSeat.getSnapshot().current).toBe(preset)
if (blank) {
const section = (b.slots.entries('settings.section')[0]!.inject as () => AgentPresetSectionInjected & Record<string, unknown>)()
await section.makeDefault('cordis')
expect(b.calls.filter(call => call.startsWith('select:'))).toEqual(preset === 'cordis' ? [] : ['select:cordis'])
expect(chip.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis')
}
await b.ctx.fiber.dispose()
},
)
it('does not correct a default when settings first arrive after the feature is disposed', async () => {
const described = Promise.withResolvers<undefined>()
const b = await bench({ describeGate: described.promise, developerToolsEnabled: false, presets: ROSTER_MOVED.value.presets })
b.ctx.provide('sessions', sessionsDouble(b.ctx, { byId: {} }) as never)
declareRoot(b.slots)
const feature = b.ctx.plugin({ inject: [...inject], apply })
await feature.await()
const injectSeat = slots.entries('conversation.hero.agentPreset')[0]!
.inject as (sessionId?: SessionId) => AgentPresetSeatInjected & Record<string, unknown>
const chip = injectSeat(SessionId('s1'))
await chip.load()
await chip.select('minimal')
expect(chip.hooks.agentPresetSeat.getSnapshot()).toMatchObject({ current: 'minimal', introduce: false })
// The preference turns off with no seat load, so only the gate's own sweep
// can reconcile this live bound seat back to the roster default.
await setDeveloperTools(false)
await vi.waitFor(() => {
expect(chip.hooks.agentPresetSeat.getSnapshot()).toMatchObject({ current: 'standard', introduce: false })
})
expect(calls.filter(call => call.startsWith('select:'))).toEqual([])
// Turning back on must not resurrect the dropped stage when this blank
// Session becomes the main view's current one.
await setDeveloperTools(true)
state.current = 's1'
sessions.notify()
await chip.load()
expect(calls.filter(call => call.startsWith('select:'))).toEqual([])
expect(chip.hooks.agentPresetSeat.getSnapshot().current).toBe('standard')
const settled = chip.hooks.agentPresetSeat.getSnapshot()
await feature.dispose()
await setDeveloperTools(false)
expect(chip.hooks.agentPresetSeat.getSnapshot()).toBe(settled)
expect(released).toBe(1)
conversation()
described.resolve(undefined)
await vi.waitFor(() => { expect(b.ctx.configForms.get('ui-settings').getSnapshot().status).toBe('ready') })
expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual([])
expect(b.calls.filter(call => call.startsWith('select:'))).toEqual([])
await b.ctx.fiber.dispose()
})
it('exposes a refused correction without repeatedly writing the same default', async () => {
const b = await bench({ failSettingsUpdate: true, presets: ROSTER_MOVED.value.presets })
b.ctx.provide('sessions', sessionsDouble(b.ctx, { byId: {} }) as never)
declareRoot(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const section = (b.slots.entries('settings.section')[0]!.inject as () => AgentPresetSectionInjected & Record<string, unknown>)()
await b.setDeveloperTools(false)
await vi.waitFor(() => { expect(section.hooks.agentPresetSection.getSnapshot().error).toBe('settings write disconnected') })
expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual(['settings:{"selectedDefault":"standard"}'])
expect(section.hooks.agentPresetSection.getSnapshot().rows.find(row => row.isDefault)?.id).toBe('minimal')
await b.ctx.fiber.dispose()
})
it.each(['ptc', 'minimal'])('clears a browser-local staged %s choice without rewriting the Host default', async (preset) => {
const b = await bench({ memory: true, presets: [
...ROSTER_MOVED.value.presets, { id: 'ptc', isDefault: false },
] })
declareRoot(b.slots)
declareConversation(b.slots)
b.ctx.provide('conversation', {} as never)
const state: { current?: string; byId: Record<string, { id: string; blank: boolean }> } = {
byId: { s1: { id: 's1', blank: true } },
}
const sessions = sessionsDouble(b.ctx, state)
b.ctx.provide('sessions', sessions as never)
b.ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const injectSeat = b.slots.entries('conversation.hero.agentPreset')[0]!
.inject as (sessionId?: SessionId) => AgentPresetSeatInjected & Record<string, unknown>
const chip = injectSeat()
await chip.load()
await chip.select(preset)
await b.ctx.configForms.developerTools.setEnabled(false)
const section = (b.slots.entries('settings.section')[0]!.inject as () => AgentPresetSectionInjected & Record<string, unknown>)()
await section.load()
expect(section.hooks.agentPresetSection.getSnapshot().rows.find(row => row.isDefault)?.id).toBe('minimal')
expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual([])
state.current = 's1'
await injectSeat(SessionId('s1')).load()
sessions.notify()
expect(b.calls.filter(call => call.startsWith('select:'))).toEqual([])
await b.ctx.fiber.dispose()
})
it('loads the header label from the shared roster store', async () => {
@@ -875,8 +1140,9 @@ describe('ui-agent-preset apply', () => {
conversation()
})
it('keeps the applied composition when the roster load lands late', async () => {
const { ctx, slots, calls } = await bench()
it.each([false, true])('keeps the bound Creator outcome when the roster load lands late (refused: %s)', async (refused) => {
const selection = Promise.withResolvers<undefined>()
const { ctx, slots, calls } = await bench({ selectGate: selection.promise })
declareRoot(slots)
const conversation = declareConversation(slots)
ctx.provide('conversation', {} as never)
@@ -895,6 +1161,7 @@ describe('ui-agent-preset apply', () => {
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
const injectSeat = slots.entries('conversation.hero.agentPreset')[0]!
.inject as unknown as (sessionId?: SessionId) => AgentPresetSeatInjected
const unboundSeat = injectSeat()
await section.load()
section.startCreatorDraft?.()
@@ -902,18 +1169,28 @@ describe('ui-agent-preset apply', () => {
state.byId['s1'] = { id: 's1', blank: true }
sessions.notify()
const boundSeat = injectSeat(SessionId('s1'))
await boundSeat.load()
const loading = boundSeat.load()
await vi.waitFor(() => { expect(calls).toContain('select:cordis') })
if (refused) selection.reject(new Error('Creator failed to mount'))
else selection.resolve(undefined)
await loading
await vi.waitFor(() => { expect(boundSeat.hooks.agentPresetSeat.getSnapshot().busy).toBe(false) })
// The chip mounts with the flow's session, so its roster load can land
// AFTER the stage was consumed; the session's own composition is what
// the display must keep — not the deployment default.
state.byId['s1'] = {
id: 's1', blank: true, projectionValues: { agentPreset: 'cordis' },
id: 's1', blank: true, projectionValues: { agentPreset: refused ? null : 'cordis' },
}
await boundSeat.load()
expect(boundSeat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis')
expect(boundSeat.hooks.agentPresetSeat.getSnapshot()).toMatchObject({
current: refused ? '' : 'cordis',
error: refused ? { preset: { id: 'cordis' }, reason: 'Creator failed to mount' } : null,
})
expect(unboundSeat.hooks.agentPresetSeat.getSnapshot().error).toBeNull()
boundSeat.dismissRefusal(boundSeat.hooks.agentPresetSeat.getSnapshot().error)
expect(boundSeat.hooks.agentPresetSeat.getSnapshot().error).toBe(refused ? 'Creator failed to mount' : null)
conversation()
})
@@ -965,7 +1242,7 @@ describe('AgentPresetSeatController reconciliation', () => {
await expect(settings).resolves.toBe(refuseLatest ? 'selection refused' : undefined)
expect(controller.store.getSnapshot()).toMatchObject({
current: refuseLatest ? 'standard' : 'cordis', busy: false,
error: refuseLatest ? 'selection refused' : null,
error: refuseLatest ? { preset: { id: 'cordis' }, reason: 'selection refused' } : null,
})
})
@@ -1078,20 +1355,27 @@ describe('AgentPresetSeatController reconciliation', () => {
expect(controller.store.getSnapshot().current).toBe('')
})
it('restores an empty current value after a refused switch for an uncomposed Session', async () => {
const select = () => Promise.resolve({
ok: false as const, error: new RemoteError('gateway/internal', 'selection rejected', {}),
})
it.each(['returned', 'thrown'] as const)('restores an uncomposed Session after a %s selection failure', async (failure) => {
const select = () => failure === 'thrown'
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercise a transport rejection with a non-Error reason.
? Promise.reject('selection rejected')
: Promise.resolve({ ok: false as const, error: new RemoteError('gateway/internal', 'selection rejected', {}) })
const controller = new AgentPresetSeatController({
...developerTools(),
remote: { agentPresets: { select } },
remote: { agentPresets: {
select,
list: () => Promise.resolve({ ok: true, value: { presets: [{ id: 'standard', isDefault: true }] } }),
} },
} as never, () => ({ id: SessionId('uncomposed'), blank: true }))
await controller.select('minimal')
expect(controller.store.getSnapshot()).toMatchObject({
busy: false, current: '', error: 'selection rejected',
busy: false, current: '', error: { preset: { id: 'minimal' }, reason: 'selection rejected' },
})
controller.dismissRefusal(controller.store.getSnapshot().error)
await controller.load()
expect(controller.store.getSnapshot()).toMatchObject({ current: '', error: null })
})
it('keeps the bare cause of a mount failure, not the frame that names the preset again', async () => {
@@ -1100,6 +1384,7 @@ describe('AgentPresetSeatController reconciliation', () => {
...developerTools(),
remote: {
agentPresets: {
list: () => Promise.resolve({ ok: false as const, error: new RemoteError('gateway/internal', 'roster unavailable', {}) }),
select: () => Promise.resolve({
ok: false as const,
error: new RemoteError(
@@ -1115,6 +1400,17 @@ describe('AgentPresetSeatController reconciliation', () => {
// The surface reporting this names the preset itself, so carrying the
// roster's own "preset X failed to mount" frame would say it twice.
expect(await controller.select('broken')).toBe(reason)
const first = controller.store.getSnapshot().error
expect(first).toEqual({ preset: { id: 'broken' }, reason })
await controller.select('broken')
const second = controller.store.getSnapshot().error
expect(second).toEqual(first)
expect(second).not.toBe(first)
await controller.load()
expect(controller.store.getSnapshot().error).toBe(second)
controller.dismissRefusal(first)
expect(controller.store.getSnapshot().error).toBe(second)
controller.dismissRefusal(second)
expect(controller.store.getSnapshot().error).toBe(reason)
})
})
@@ -57,7 +57,14 @@ function renderSeat(
) {
const store = createSnapshotStore<AgentPresetSeatState>({ ...SEAT_READY, ...state })
const developerTools = createSnapshotStore(enabled)
const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(select), introduced: vi.fn() }
const actions = {
load: vi.fn(() => Promise.resolve()), select: vi.fn(select), introduced: vi.fn(),
dismissRefusal: vi.fn((error: AgentPresetSeatState['error']) => {
if (store.getSnapshot().error === error && error !== null && typeof error === 'object') {
store.set({ ...store.getSnapshot(), error: error.reason })
}
}),
}
const props = {
...actions,
sessionId: session === undefined ? undefined : SessionId(session.id),
@@ -69,7 +76,7 @@ function renderSeat(
t: translate,
} as AgentPresetSeatProps
render(<AgentPresetSeat {...props} />)
return { ...actions, developerTools }
return { ...actions, developerTools, store }
}
function renderLabel(
@@ -93,10 +100,52 @@ function renderLabel(
}
describe('the new-session chip', () => {
it('renders nothing while Developer tools are off', () => {
renderSeat({}, undefined, undefined, false)
it.each([false, true])('offers Standard, Creator and custom presets with Developer tools %s', (enabled) => {
const actions = renderSeat({ options: [
{ id: 'standard' }, { id: 'ptc' }, { id: 'minimal' }, { id: 'cordis' }, { id: 'mine' },
] }, undefined, undefined, enabled)
expect(screen.queryByRole('button')).toBeNull()
fireEvent.click(screen.getByRole('button'))
expect(screen.getAllByRole('menuitem')).toHaveLength(enabled ? 5 : 3)
expect(screen.getByRole('menuitem', { name: /^Standard mode/ })).toBeTruthy()
expect(screen.getByRole('menuitem', { name: /^mine/ })).toBeTruthy()
expect(screen.queryByRole('menuitem', { name: /^PTC mode/ }) !== null).toBe(enabled)
expect(screen.queryByRole('menuitem', { name: /^Minimal mode/ }) !== null).toBe(enabled)
fireEvent.click(screen.getByRole('menuitem', { name: /^Creator mode/ }))
expect(actions.select).toHaveBeenCalledWith('cordis')
})
it('keeps a named custom preset that overrides a development preset id', () => {
renderSeat({ options: [{ id: 'ptc', name: 'My workflow' }] }, undefined, undefined, false)
fireEvent.click(screen.getByRole('button'))
expect(screen.getByRole('menuitem', { name: /^My workflow/ })).toBeTruthy()
})
it('keeps the current mode visible without opening a picker when all options are hidden', () => {
const actions = renderSeat({ current: 'minimal', options: [{ id: 'ptc' }, { id: 'minimal' }] }, undefined, undefined, false)
const trigger = screen.getByRole<HTMLButtonElement>('button', { name: en.presetMinimalName })
expect(trigger.disabled).toBe(true)
fireEvent.click(trigger)
expect(trigger.getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByRole('menu')).toBeNull()
expect(actions.select).not.toHaveBeenCalled()
})
it('closes a picker when its last visible option disappears and keeps it closed when options return', () => {
const options = [{ id: 'minimal' }, { id: 'mine' }]
const actions = renderSeat({ current: 'minimal', options }, undefined, undefined, false)
const trigger = screen.getByRole<HTMLButtonElement>('button', { name: en.presetMinimalName })
fireEvent.click(trigger)
expect(screen.getByRole('menuitem', { name: /^mine/ })).toBeTruthy()
act(() => { actions.store.set({ ...actions.store.getSnapshot(), options: [{ id: 'ptc' }, { id: 'minimal' }] }) })
expect(trigger.disabled).toBe(true)
expect(trigger.getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByRole('menu')).toBeNull()
act(() => { actions.store.set({ ...actions.store.getSnapshot(), options }) })
expect(trigger.disabled).toBe(false)
expect(trigger.getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByRole('menu')).toBeNull()
expect(actions.select).not.toHaveBeenCalled()
})
it('renders only for a Session retained by the main view', () => {
@@ -137,7 +186,7 @@ describe('the new-session chip', () => {
fireEvent.click(screen.getByRole('button'))
expect(screen.getByText(en.presetStandardDescription)).toBeTruthy()
act(() => { actions.developerTools.set(false) })
expect(screen.queryByRole('button')).toBeNull()
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByText(en.presetStandardDescription)).toBeNull()
expect(actions.select).not.toHaveBeenCalled()
act(() => { actions.developerTools.set(true) })
@@ -200,28 +249,34 @@ describe('the new-session chip', () => {
})
describe('a refused switch', () => {
it('announces the reason instead of letting the label snap back in silence', async () => {
it('announces delayed and repeated refusals even without a composed preset or Coding Tools', () => {
// The banner's own timer has to be a fake one from the start, or the
// lifetime assertion below would wait out its real nine seconds.
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
const reason = 'failed to import loader entry live-on-mac (@deepseek-ai/dsh-also-gone)'
renderSeat({}, () => Promise.resolve(reason))
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByRole('menuitem', { name: /mine/ }))
const actions = renderSeat({ current: '' }, undefined, undefined, false)
const refusal = { preset: { id: 'cordis' }, reason }
act(() => { actions.store.set({ ...actions.store.getSnapshot(), error: refusal }) })
// The host refuses a mount discovery reported healthy, so this banner is
// the only place the cause appears — the chip has already reverted and
// the settings row shows the preset as fine.
const banner = await screen.findByRole('alert')
const banner = screen.getByRole('alert')
expect(banner.textContent).toContain(reason)
expect(banner.textContent).toContain('mine')
expect(banner.textContent).toContain(en.presetCordisName)
expect(screen.queryByRole('button')).toBeNull()
act(() => { vi.advanceTimersByTime(7000) })
act(() => { actions.store.set({ ...actions.store.getSnapshot(), error: { ...refusal } }) })
act(() => { vi.advanceTimersByTime(2001) })
expect(screen.getByRole('alert').textContent).toContain(reason)
// Transient by design: it holds long enough to read a cause that names
// packages, then leaves rather than sitting over the screen.
act(() => { vi.advanceTimersByTime(9001) })
expect(screen.queryByRole('alert')).toBeNull()
expect(actions.dismissRefusal).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
@@ -0,0 +1,118 @@
// @vitest-environment jsdom
import { useState, type ComponentProps } from 'react'
import { act, cleanup, fireEvent, render, screen } 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 { Menu, MenuItemButton } from '@deepseek-ai/dsh-client-ui-primitives'
import { CreatePluginMenuItem } from '../src/client/CreatePluginMenuItem.tsx'
import type { AgentPresetSettingsState } from '../src/client/settings-store.ts'
import { en } from '../src/client/locales.ts'
afterEach(cleanup)
const translations: ReadonlyMap<string, string> = new Map(Object.entries(en))
function unusedHook(): never {
throw new Error('Create plugin does not read this slot source')
}
function view(roster: Partial<AgentPresetSettingsState> = {}, inMenu = false) {
const agentPresets = createSnapshotStore<AgentPresetSettingsState>({
status: 'ready', error: null, options: [{ id: 'standard' }, { id: 'cordis' }], ...roster,
})
const calls: string[] = []
const startCreatorDraft = vi.fn(() => { calls.push('start') })
const onDismiss = vi.fn(() => { calls.push('dismiss') })
const load = vi.fn(async () => {})
const props: ComponentProps<typeof CreatePluginMenuItem> = {
t: key => translations.get(key) ?? key,
useAgentPresets: bindSnapshotSelector(agentPresets),
usePanelInfo: unusedHook, useSessions: unusedHook, useSessionStatus: unusedHook,
useSessionRetainInfo: unusedHook, useWorkspaces: unusedHook, useResource: unusedHook,
load, startCreatorDraft, onDismiss,
}
function TestMenu() {
const [open, setOpen] = useState(true)
return <Menu open={open} items={[]} autoFocus onClose={() => { setOpen(false) }}
anchor={<button type="button">Add plugin</button>}>
<MenuItemButton onSelect={() => {}}>Install a third-party plugin</MenuItemButton>
<CreatePluginMenuItem {...props} onDismiss={() => { onDismiss(); setOpen(false) }} />
</Menu>
}
render(inMenu ? <TestMenu /> : <CreatePluginMenuItem {...props} />)
return { agentPresets, load, startCreatorDraft, onDismiss, calls }
}
function item(): HTMLElement {
return screen.getByRole('menuitem', { name: new RegExp(en.createPlugin) })
}
describe('Create plugin menu item', () => {
it('explains Creator and dismisses the menu before starting it exactly once', () => {
const actions = view({}, true)
expect(actions.load).toHaveBeenCalledOnce()
expect(item().textContent).toContain(en.createPluginDescription)
expect(screen.queryByRole('dialog')).toBeNull()
expect(actions.startCreatorDraft).not.toHaveBeenCalled()
fireEvent.click(item())
expect(actions.calls).toEqual(['dismiss', 'start'])
expect(screen.queryByRole('menu')).toBeNull()
expect(screen.queryByRole('dialog')).toBeNull()
})
it('supports keyboard selection through the shared menu and closes before navigation', () => {
const actions = view({}, true)
const trigger = item()
trigger.focus()
fireEvent.keyDown(trigger, { key: 'Tab' })
expect(actions.calls).toEqual(['dismiss', 'start'])
expect(screen.queryByRole('menu')).toBeNull()
})
it.each(['idle', 'loading', 'error', 'unavailable'] as const)('keeps a disabled entry while the roster is %s', (status) => {
const actions = view({ status })
expect(item()).toHaveProperty('disabled', true)
const description = status === 'idle' || status === 'loading' ? en.createPluginChecking : en.createPluginUnavailable
expect(item().textContent).toContain(description)
fireEvent.click(item())
expect(actions.calls).toEqual([])
})
it('disables the same entry when Creator leaves the roster', () => {
const actions = view()
const original = item()
act(() => { actions.agentPresets.set({ status: 'ready', error: null, options: [{ id: 'standard' }] }) })
expect(item()).toBe(original)
expect(item()).toHaveProperty('disabled', true)
expect(item().textContent).toContain(en.createPluginMissing)
fireEvent.click(item())
expect(actions.calls).toEqual([])
})
it('keeps the same second menu item through ready, loading, and ready states', () => {
const actions = view({}, true)
const menu = screen.getByRole('menu')
const before = screen.getAllByRole('menuitem')
const original = item()
expect(before[1]).toBe(original)
expect(original).toHaveProperty('disabled', false)
act(() => { actions.agentPresets.set({ ...actions.agentPresets.getSnapshot(), status: 'loading' }) })
expect(screen.getByRole('menu')).toBe(menu)
expect(screen.getAllByRole('menuitem')).toEqual(before)
expect(item()).toBe(original)
expect(original).toHaveProperty('disabled', true)
expect(original.textContent).toContain(en.createPluginChecking)
fireEvent.click(original)
expect(actions.calls).toEqual([])
act(() => { actions.agentPresets.set({ ...actions.agentPresets.getSnapshot(), status: 'ready' }) })
expect(screen.getByRole('menu')).toBe(menu)
expect(screen.getAllByRole('menuitem')).toEqual(before)
expect(item()).toBe(original)
expect(original).toHaveProperty('disabled', false)
expect(original.textContent).toContain(en.createPluginDescription)
fireEvent.click(original)
expect(actions.calls).toEqual(['dismiss', 'start'])
})
})
@@ -1,13 +1,26 @@
import { describe, expect, it, vi } from 'vitest'
import { AgentPresetSectionController } from '../src/client/section-store.ts'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { ConfigFormSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
import type { AgentPresetRow } from '@deepseek-ai/dsh-agent-preset-registry/types'
import { en } from '../src/client/locales.ts'
function fixture() {
const form = createSnapshotStore<ConfigFormSnapshot<{ selectedDefault: string }>>({
status: 'ready', mode: 'host', writable: true, revision: 7, value: { selectedDefault: 'minimal' }, base: {}, user: {},
})
const remote = { agentPresets: {
list: vi.fn(async () => ({ ok: true as const, value: { presets: [{ id: 'standard', isDefault: true }] } })),
list: vi.fn(async (): Promise<{ ok: true; value: { presets: AgentPresetRow[] } }> => ({
ok: true, value: { presets: [{ id: 'standard', isDefault: true }] },
})),
read: vi.fn(async (id: string) => ({ ok: true as const, value: { agentPreset: id, name: 'Standard', content: '- name: fs\n' } })),
}, settings: { update: vi.fn(async () => ({ ok: true as const, value: {} })) } }
const controller = new AgentPresetSectionController({ remote } as never)
return { remote, controller, state: () => controller.store.getSnapshot() }
}, settings: {
update: vi.fn(async (_ns: string, _patch: { selectedDefault: string }, _revision?: number) => ({ ok: true as const, value: {} })),
} }
const controller = new AgentPresetSectionController({ remote, configForms: { get: () => form },
locale: { bind: () => (key: keyof typeof en) => en[key] },
} as never)
return { remote, controller, form, state: () => controller.store.getSnapshot() }
}
describe('the preset roster', () => {
@@ -99,3 +112,98 @@ describe('the preset roster', () => {
expect(sync).not.toHaveBeenCalled()
})
})
describe('Coding Tools default reconciliation', () => {
function setup(defaultId = 'minimal', name?: string) {
const f = fixture()
let rows: AgentPresetRow[] = [
{ id: 'standard', isDefault: defaultId === 'standard' },
...(defaultId === 'standard' ? [] : [{ id: defaultId, isDefault: true, ...(name === undefined ? {} : { name }) }]),
]
f.remote.agentPresets.list.mockImplementation(async () => ({ ok: true, value: { presets: rows } }))
f.remote.settings.update.mockImplementation(async (_ns, patch) => {
rows = rows.map(row => ({ ...row, isDefault: row.id === patch.selectedDefault }))
return { ok: true, value: {} }
})
return { ...f, rows: () => rows, setRows: (next: AgentPresetRow[]) => { rows = next } }
}
it.each(['ptc', 'minimal'])('saves Standard instead of built-in %s with the accepted revision', async (id) => {
const f = setup(id)
await f.controller.reconcileCodingTools(() => true)
expect(f.remote.settings.update).toHaveBeenCalledExactlyOnceWith('agent-preset-registry', { selectedDefault: 'standard' }, 7)
expect(f.state().rows.find(row => row.isDefault)?.id).toBe('standard')
expect(f.state().error).toBeNull()
await f.controller.reconcileCodingTools(() => true)
expect(f.remote.settings.update).toHaveBeenCalledOnce()
})
it.each([['standard', undefined], ['cordis', undefined], ['custom', 'Custom'], ['ptc', 'My PTC'], ['minimal', 'My Minimal']] as const)(
'preserves the allowed default %s (%s)', async (id, name) => {
const f = setup(id, name)
await f.controller.reconcileCodingTools(() => true)
expect(f.remote.settings.update).not.toHaveBeenCalled()
expect(f.rows().find(row => row.isDefault)?.id).toBe(id)
},
)
it.each([
{ status: 'loading' as const }, { status: 'unavailable' as const },
{ mode: 'memory' as const }, { writable: false }, { revision: undefined },
])('does not write without a writable accepted Host form: %j', async (patch) => {
const f = setup()
f.form.set({ ...f.form.getSnapshot(), ...patch })
await f.controller.reconcileCodingTools(() => true)
expect(f.remote.settings.update).not.toHaveBeenCalled()
})
it.each([false, true])('waits for a pending default write and checks the latest toggle (re-enabled: %s)', async (reEnabled) => {
const f = setup('standard')
f.setRows([{ id: 'standard', isDefault: true }, { id: 'minimal', isDefault: false }])
const reply = Promise.withResolvers<undefined>()
f.remote.settings.update.mockImplementationOnce(async () => {
await reply.promise
f.setRows([{ id: 'standard', isDefault: false }, { id: 'minimal', isDefault: true }])
return { ok: true, value: {} }
})
const pending = f.controller.makeDefault('minimal')
let off = true
const reconcile = f.controller.reconcileCodingTools(() => off)
expect(f.remote.settings.update).toHaveBeenCalledOnce()
if (reEnabled) off = false
reply.resolve(undefined)
await Promise.all([pending, reconcile])
expect(f.remote.settings.update).toHaveBeenCalledTimes(reEnabled ? 1 : 2)
expect(f.state().rows.find(row => row.isDefault)?.id).toBe(reEnabled ? 'minimal' : 'standard')
})
it('rechecks the toggle after a roster read', async () => {
const f = setup()
const roster = Promise.withResolvers<{ ok: true; value: { presets: AgentPresetRow[] } }>()
f.remote.agentPresets.list.mockImplementationOnce(() => roster.promise)
let off = true
const pending = f.controller.reconcileCodingTools(() => off)
off = false
roster.resolve({ ok: true, value: { presets: f.rows() } })
await pending
expect(f.remote.settings.update).not.toHaveBeenCalled()
})
it.each([false, true])('reports unavailable Standard without a default write (broken: %s)', async (broken) => {
const f = setup()
f.setRows([{ id: 'minimal', isDefault: true }, ...(broken ? [{ id: 'standard', isDefault: false, broken: 'Missing plugin' }] : [])])
await f.controller.reconcileCodingTools(() => true)
expect(f.remote.settings.update).not.toHaveBeenCalled()
expect(f.state().error).toBe(en.standardUnavailable)
expect(f.state().rows.find(row => row.isDefault)?.id).toBe('minimal')
})
it('reports one refused reset without hiding the actual default or retrying itself', async () => {
const f = setup()
f.remote.settings.update.mockResolvedValueOnce({ ok: false, error: { message: 'Revision changed' } } as never)
await f.controller.reconcileCodingTools(() => true)
expect(f.remote.settings.update).toHaveBeenCalledOnce()
expect(f.state()).toMatchObject({ saving: false, error: 'Revision changed' })
expect(f.state().rows.find(row => row.isDefault)?.id).toBe('minimal')
})
})
@@ -12,7 +12,8 @@ const translations: ReadonlyMap<string, string> = new Map(Object.entries(en))
function unusedHook(): never {
throw new Error('This section does not read global slot sources')
}
function view(partial: Partial<AgentPresetSectionState> = {}, startCreatorDraft?: () => void, outerClose?: () => void) {
function view(partial: Partial<AgentPresetSectionState> = {}, startCreatorDraft?: () => void, outerClose?: () => void, enabled = true) {
const developerTools = createSnapshotStore(enabled)
const store = createSnapshotStore<AgentPresetSectionState>({ status: 'ready', error: null,
saving: false, rows: [{ id: 'standard', isDefault: true }, { id: 'mine', name: 'Mine', isDefault: false }],
view: null, ...partial })
@@ -23,16 +24,55 @@ function view(partial: Partial<AgentPresetSectionState> = {}, startCreatorDraft?
usePanelInfo: unusedHook, useSessions: unusedHook, useSessionStatus: unusedHook, useSessionRetainInfo: unusedHook,
useWorkspaces: unusedHook, useResource: unusedHook,
useAgentPresetSection: bindSnapshotSelector(store),
useDeveloperTools: bindSnapshotSelector(developerTools),
t: key => translations.get(key) ?? key }
render(outerClose === undefined ? <AgentPresetSection {...props} />
: <Modal open onClose={outerClose} title="Settings" closeLabel="Close"><AgentPresetSection {...props} /></Modal>)
return { ...actions, store }
return { ...actions, store, developerTools }
}
function rowFor(id: string): HTMLElement {
const row = document.querySelector<HTMLElement>(`[data-agent-preset-id="${id}"]`)
if (row === null) throw new Error(`no card for ${id}`)
return row
}
it.each([false, true])('offers Standard, Creator and custom defaults with Coding Tools %s', (enabled) => {
const actions = view({ rows: [
{ id: 'standard', isDefault: false }, { id: 'ptc', isDefault: false },
{ id: 'minimal', isDefault: true }, { id: 'cordis', isDefault: false },
{ id: 'mine', name: 'Mine', isDefault: false },
] }, vi.fn(), undefined, enabled)
expect(screen.queryByRole('button', { name: `${en.setDefault}: ${en.presetPtcName}` }) !== null).toBe(enabled)
expect(screen.queryByRole('button', { name: `${en.inUse}: ${en.presetMinimalName}` }) !== null).toBe(enabled)
for (const [id, name] of [['standard', en.presetStandardName], ['cordis', en.presetCordisName], ['mine', 'Mine']]) {
const button = screen.getByRole<HTMLButtonElement>('button', { name: `${en.setDefault}: ${name}` })
expect(button.disabled).toBe(false)
fireEvent.click(button)
expect(actions.makeDefault).toHaveBeenLastCalledWith(id)
}
expect(screen.getByRole<HTMLButtonElement>('button', { name: en.creatorDraft }).disabled).toBe(false)
})
it('updates visible settings choices without writing defaults from the renderer', () => {
const actions = view({ rows: [
{ id: 'standard', isDefault: false }, { id: 'ptc', isDefault: false },
{ id: 'minimal', isDefault: true }, { id: 'cordis', isDefault: false },
] })
const rows = actions.store.getSnapshot().rows
act(() => { actions.developerTools.set(false) })
expect(document.querySelector('[data-agent-preset-id="ptc"]')).toBeNull()
expect(document.querySelector('[data-agent-preset-id="minimal"]')).toBeNull()
expect(rowFor('standard')).toBeTruthy()
expect(rowFor('cordis')).toBeTruthy()
expect(actions.makeDefault).not.toHaveBeenCalled()
expect(actions.store.getSnapshot().rows).toBe(rows)
act(() => { actions.developerTools.set(true) })
expect(screen.getByRole('button', { name: `${en.inUse}: ${en.presetMinimalName}` })).toBeTruthy()
expect(rowFor('ptc')).toBeTruthy()
})
it.each(['ptc', 'minimal'])('keeps a named custom %s override selectable with Coding Tools off', (id) => {
const actions = view({ rows: [{ id, name: 'My mode', isDefault: false }] }, undefined, undefined, false)
fireEvent.click(screen.getByRole('button', { name: `${en.setDefault}: My mode` }))
expect(actions.makeDefault).toHaveBeenCalledWith(id)
})
it('reads the roster once and sets a default from the card body', async () => {
const actions = view()
expect(screen.queryByRole('switch')).toBeNull()
@@ -75,6 +115,14 @@ it('offers no Creator entry without the conversation flow or the cordis preset',
view({}, vi.fn())
expect(screen.queryByRole('button', { name: en.creatorDraft })).toBeNull()
})
it('disables the Creator entry while a default is being saved', () => {
const launch = vi.fn()
view({ saving: true, rows: [{ id: 'cordis', isDefault: true }] }, launch)
const button = screen.getByRole<HTMLButtonElement>('button', { name: en.creatorDraft })
expect(button.disabled).toBe(true)
fireEvent.click(button)
expect(launch).not.toHaveBeenCalled()
})
it('reads a declared composition read-only from every card, broken ones included', () => {
const actions = view({ rows: [{ id: 'standard', isDefault: true }, { id: 'broken', isDefault: false, broken: 'Missing plugin' }] })
expect(screen.queryByRole('dialog')).toBeNull()
@@ -209,6 +257,12 @@ it('does not attach built-in claims to named or unknown presets', () => {
expect(screen.queryByRole('button', { name: new RegExp(en.modeExplanation) })).toBeNull()
expect(screen.queryByRole('button', { name: new RegExp(en.howToUse) })).toBeNull()
})
it('leaves help usable while a default is being saved', () => {
const actions = view({ saving: true })
fireEvent.click(within(rowFor('standard')).getByRole('button', { name: `${en.howToUse}: ${en.presetStandardName}` }))
expect(screen.getByRole('dialog', { name: en.presetStandardName })).toBeTruthy()
expect(actions.makeDefault).not.toHaveBeenCalled()
})
it('closes help even when the browser reports no previously focused element', () => {
const descriptor: TypedPropertyDescriptor<Element | null> = Object.getOwnPropertyDescriptor(Document.prototype, 'activeElement')!
const readActiveElement = descriptor.get!.bind(document)
@@ -11,7 +11,6 @@ import type { RemoteErrorCode } from '@deepseek-ai/dsh-api-remotes/client'
import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import {
AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController,
writeDefaultPreset,
@@ -206,12 +205,10 @@ describe('the new-session chip controller', () => {
failSelect?: string
failList?: string
failListCode?: RemoteErrorCode
developerTools?: ObservableSnapshot<boolean>
list?: () => Promise<ReturnType<typeof remoteRoster>>
} = {},
): AgentPresetSeatController {
const partial = {
configForms: { developerTools: { enabled: options.developerTools ?? createSnapshotStore(true) } },
remote: {
agentPresets: {
list: options.list ?? (() => {
@@ -435,7 +432,9 @@ describe('the new-session chip controller', () => {
// Showing `minimal` after a refusal would claim a composition the session
// never got.
expect(controller.store.getSnapshot()).toMatchObject({ current: 'standard', error: 'already started' })
expect(controller.store.getSnapshot()).toMatchObject({
current: 'standard', error: { preset: { id: 'minimal' }, reason: 'already started' },
})
})
it('ignores a pick while a switch is in flight', async () => {
@@ -466,51 +465,22 @@ describe('the new-session chip controller', () => {
expect(controller.store.getSnapshot().current).toBe('minimal')
})
it('clears an unconsumed stage when Developer tools are off', async () => {
it('keeps the introduction cue after applying a staged pick', async () => {
const writes: Recorded[] = []
const developerTools = createSnapshotStore(false)
const controller = chip(ROSTER, {
id: 's1' as SessionId,
blank: false,
projectionValues: { agentPreset: 'standard' },
}, { writes, developerTools })
controller.stage('minimal', true)
await controller.load()
await controller.apply()
expect(writes).toEqual([])
expect(controller.store.getSnapshot()).toMatchObject({
current: 'standard',
introduce: false,
})
})
it('drops a stage made while Developer tools were on once they turn off', async () => {
const writes: Recorded[] = []
const developerTools = createSnapshotStore(true)
const session = {
id: 's1' as SessionId,
blank: true,
projectionValues: { agentPreset: 'standard' },
}
const controller = chip(ROSTER, session, { writes, developerTools })
const controller = chip(ROSTER, session, { writes })
await controller.load()
// The blank session keeps the composition its own screen already names.
controller.stage('minimal', true)
developerTools.set(false)
await controller.apply()
expect(writes).toEqual([])
expect(controller.store.getSnapshot()).toMatchObject({
current: 'standard',
introduce: false,
})
expect(writes).toEqual([{ ns: 'select', ops: 'minimal' }])
expect(controller.store.getSnapshot()).toMatchObject({ current: 'minimal', introduce: true })
})
it('leaves the introduction cue to the chip while Developer tools stay on', async () => {
const developerTools = createSnapshotStore(true)
it('keeps the introduction cue until acknowledged when the Session already runs the staged preset', async () => {
const session = {
id: 's1' as SessionId,
blank: true,
@@ -518,7 +488,7 @@ describe('the new-session chip controller', () => {
// no-op that leaves the cue for the chip to play.
projectionValues: { agentPreset: 'minimal' },
}
const controller = chip(ROSTER, session, { developerTools })
const controller = chip(ROSTER, session)
await controller.load()
controller.stage('minimal', true)
@@ -26,6 +26,9 @@
{
"path": "../ui-primitives"
},
{
"path": "../ui-plugin-manager/tsconfig.client.json"
},
{
"path": "../ui-renderer"
},
@@ -18,8 +18,8 @@
en: 13ae68865fa315d7
zh: c152f020abf20f35
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
en: 7f1d7a29d2238f3a
zh: f7a66d2f34a1cd42
en: 2f44e26f8ff84268
zh: 879534cee58aaed5
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
en: bb1244310f5c0382
zh: 824b84077432b559
+1 -1
View File
@@ -72,7 +72,7 @@ Active transcripts in the main occurrence expose content-width drag handles in t
The width-handle indicator follows the pointer only during a captured drag; ordinary hover leaves its position unchanged.
The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` from the Session `inbox` projection, including cold recovered messages, and excludes only requestIds still owned by local transcript submissions. Other queued rows retain their normal display and actions. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text in a textarea that grows with its content, so line breaks survive re-editing; Enter saves, Shift+Enter breaks the line, and Escape cancels. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start.
The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` from the Session `inbox` projection, including cold recovered messages, and excludes only requestIds still owned by local transcript submissions. Other queued rows retain their normal display and actions. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text in the ui-primitives `InlineEditor`, so line breaks survive re-editing. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start.
Plain Enter uses the configured delivery mode, exactly Ctrl+Enter or Cmd+Enter uses its complement, and Shift+Enter inserts a line break. Enter with Alt, AltGraph, both Ctrl and Cmd, or Shift plus Ctrl/Cmd leaves the draft and command menu unchanged and keeps the DOM event available to application shortcuts. The Conversation plugin registers the fixed send, newline, complementary-delivery, command-menu, and reference-menu rows and reserves their bindings until it unloads.
+1 -1
View File
@@ -72,7 +72,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
宽度拖拽条的指示线只在已捕获指针的拖拽期间跟随指针,普通悬停不改变其位置。
常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息,仅排除仍由本地 transcript 提交承接的 requestId。其他排队行保留正常展示和操作。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态在可随内容增高的 textarea 中展示字面发送文本,因此重新编辑不会丢失换行;Enter 保存,Shift+Enter 换行,Escape 取消。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。
常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息,仅排除仍由本地 transcript 提交承接的 requestId。其他排队行保留正常展示和操作。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态在 ui-primitives 的 `InlineEditor` 中展示字面发送文本,因此重新编辑不会丢失换行。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。
普通 Enter 使用已配置的投递模式,严格的 Ctrl+Enter 或 Cmd+Enter 使用互补模式,Shift+Enter 插入换行。带 Alt、AltGraph、同时带 Ctrl 与 Cmd,或 Shift 与 Ctrl/Cmd 的 Enter 保持草稿和指令菜单不变,并将 DOM 事件留给应用快捷键。Conversation 插件注册发送、换行、互补投递、命令菜单和引用菜单的固定条目,并在卸载前保留这些键位。
@@ -194,8 +194,7 @@
object-fit: cover;
}
.preview,
.editor {
.preview {
flex: 1 1 auto;
min-width: 0;
font: var(--dsw-font-xs-13);
@@ -210,25 +209,6 @@
word-break: break-word;
}
.editor {
box-sizing: border-box;
min-height: 28px;
/* Six 20px text lines plus padding and border, then the editor scrolls. */
max-height: 127px;
padding: 3px 8px;
border: 0.5px solid var(--dsw-alias-border-l4);
border-radius: var(--dsw-radius-sm);
outline: none;
background: var(--dsw-alias-bg-base);
color: var(--dsw-alias-label-primary);
overflow-y: auto;
resize: none;
}
.editor:focus {
border-color: var(--dsw-alias-state-business-primary);
}
.actions {
display: flex;
flex: none;
@@ -1,11 +1,11 @@
import type { Context } from '@deepseek-ai/cordis'
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useId, useMemo, useState } from 'react'
import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import {
IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular, IconCloseOutlineRegular,
FileTypeIcon, fileSizeText, IconEditOutlineRegular, IconQueueOutlineRegular, IconSendOutlineRegular,
FileTypeIcon, fileSizeText, IconEditOutlineRegular, InlineEditor, IconQueueOutlineRegular, IconSendOutlineRegular,
IconTrashOutlineRegular, projectUserText, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
@@ -98,53 +98,6 @@ function QueueThumb({ attachment, loadImage, label }: {
: <img className={css.thumb} src={url} alt={label} />
}
/**
* Inline editor for one queued row. A textarea rather than an input: HTML
* strips newlines from single-line input values, so editing a multi-line
* queued message through one rewrites it as a single line. It grows with its
* content up to the CSS cap, then scrolls. Enter saves, Shift+Enter breaks the
* line, Escape cancels.
*/
function QueueEditor({ text, label, onChange, onSave, onCancel }: {
text: string
label: string
onChange: (text: string) => void
onSave: () => void
onCancel: () => void
}) {
const ref = useRef<HTMLTextAreaElement>(null)
useLayoutEffect(() => {
const node = ref.current
/* v8 ignore next -- the ref is attached before layout effects run. */
if (node === null) return
node.style.height = 'auto'
// scrollHeight excludes the border that the border-box height includes.
node.style.height = `${node.scrollHeight + node.offsetHeight - node.clientHeight}px`
}, [text])
return (
<textarea
ref={ref}
autoFocus
rows={1}
className={css.editor}
aria-label={label}
value={text}
onChange={(event) => { onChange(event.currentTarget.value) }}
onKeyDown={(event) => {
if (event.key === 'Escape') {
onCancel()
return
}
if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return
event.preventDefault()
onSave()
}}
/>
)
}
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat + the locale seat. */
export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDockInjected & PropsLocale<'conversation'>
@@ -248,8 +201,8 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load
{rowCount === 1 && <span className={css.lead} aria-hidden><IconQueueOutlineRegular /></span>}
{editing?.id === row.id
? (
<QueueEditor
text={editing.text}
<InlineEditor
value={editing.text}
label={t('queue.edit')}
onChange={(text) => { setEditing({ id: row.id, text }) }}
onSave={() => { void saveEdit() }}
+2 -2
View File
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-goal/use-this-package:
en: 8fa7519b40158cdd
zh: 218cd370f9afec19
en: 4f6e6353d793a0de
zh: cde6e470f7dd9057
/deepseek-ai-dsh-client-ui-goal/use-this-package/the-command-input-bubble:
en: 05c5db0eb58dffef
zh: 54e7bea0b810b1b8
+1 -1
View File
@@ -25,7 +25,7 @@ The Web GUI goal surface shows both the durable goal state and its current proce
<a id="use-this-package"></a>
## Use this package
Mount this plugin alongside `ui-conversation` and the goal domain package; the strip then appears as the second card in the composer-context stack (after Todo, before Queue) whenever the session has a goal. Todo and Goal use the same panel elevation above the composer. An armed active goal offers pause; an active-but-disarmed or paused goal offers resume; edit rewrites the objective; clear removes the goal and suppresses the strip until the projection catches up.
Mount this plugin alongside `ui-conversation` and the goal domain package; the strip then appears as the second card in the composer-context stack (after Todo, before Queue) whenever the session has a goal. Todo and Goal use the same panel elevation above the composer. An armed active goal offers pause; an active-but-disarmed or paused goal offers resume; edit rewrites the objective in the ui-primitives `InlineEditor`, so multi-line objectives survive editing; clear removes the goal and suppresses the strip until the projection catches up.
### The command-input bubble
+1 -1
View File
@@ -25,7 +25,7 @@ Web GUI 的 goal 界面同时显示持久 goal 状态及当前的进程本地激
<a id="use-this-package"></a>
## 使用本包
与 `ui-conversation` 及 goal 领域包一起挂载本插件;只要会话存在目标,条带就会作为 composer 上下文堆栈的第二张卡片出现(位于 Todo 之后、Queue 之前)。Todo 与 Goal 使用相同的面板 elevation,使其位于 composer 层级之上。已 armed 的 active goal 提供暂停动作;active-but-disarmed 或 paused 的 goal 提供恢复;编辑重写目标文本;清除移除目标,并在投影追上之前抑制条带。
与 `ui-conversation` 及 goal 领域包一起挂载本插件;只要会话存在目标,条带就会作为 composer 上下文堆栈的第二张卡片出现(位于 Todo 之后、Queue 之前)。Todo 与 Goal 使用相同的面板 elevation,使其位于 composer 层级之上。已 armed 的 active goal 提供暂停动作;active-but-disarmed 或 paused 的 goal 提供恢复;编辑在 ui-primitives 的 `InlineEditor` 中重写目标文本,多行目标编辑后仍保留换行;清除移除目标,并在投影追上之前抑制条带。
### 指令输入气泡
@@ -85,26 +85,14 @@
/* ---- Inline edit form ---- */
.objectiveInput {
flex: 1;
min-width: 0;
height: 26px;
padding: 0 8px;
border: 0.5px solid var(--dsw-alias-border-l4);
border-radius: var(--dsw-radius-sm);
background: var(--dsw-alias-bg-base);
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-label-primary);
outline: none;
}
.objectiveInput:focus {
border-color: var(--dsw-alias-state-business-primary);
}
.objectiveInput::placeholder {
color: var(--dsw-alias-label-caption);
/* The edit strip grows with a multi-line objective; actions stay on the last line. */
.editBar {
height: auto;
min-height: 36px;
align-items: flex-end;
/* The editor scrolls inside this elevated menu-surface strip. */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
/* ---- Icon actions ---- */
+7 -12
View File
@@ -12,7 +12,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import type { GoalActivation, GoalSnapshot } from '@deepseek-ai/dsh-goal/client'
import {
IconCheckOutlineRegular, IconCloseOutlineRegular, IconEditOutlineRegular, IconGoalOutlineRegular,
IconPauseOutlineRegular, IconPlayOutlineRegular, IconTrashOutlineRegular, Tooltip,
IconPauseOutlineRegular, IconPlayOutlineRegular, IconTrashOutlineRegular, InlineEditor, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { InjectFace, PropsLocale, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import type { GoalActionResult, GoalBarActions, GoalBarInjected } from './slots.ts'
@@ -88,18 +88,13 @@ export function GoalBar({ goal, activation, onEdit, onPause, onResume, onClear,
if (editing) {
return (
<div className={css.dock} data-goal-bar>
<div className={css.bar}>
<input
className={css.objectiveInput}
type="text"
aria-label={t('objective.aria')}
<div className={`${css.bar} ${css.editBar}`}>
<InlineEditor
value={draft}
onChange={(e) => { setDraft(e.target.value) }}
onKeyDown={(e) => {
if (e.key === 'Enter') void handleEdit()
if (e.key === 'Escape') setEditing(false)
}}
autoFocus
label={t('objective.aria')}
onChange={setDraft}
onSave={() => { void handleEdit() }}
onCancel={() => { setEditing(false) }}
/>
{actionError !== null && <span className={css.error} role="alert">{actionError}</span>}
<div className={css.actions}>
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { GoalSnapshot } from '@deepseek-ai/dsh-goal/client'
import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
@@ -94,6 +94,25 @@ describe('GoalBar', () => {
await waitFor(() => { expect(screen.getByText('进行中的目标')).toBeTruthy() })
})
it('edits a multi-line objective: Shift+Enter and IME confirmation do not save', () => {
const actions = makeActions()
render(<GoalBar goal={makeGoal({ objective: 'Line one\nLine two' })} {...actions} t={t} />)
fireEvent.click(screen.getByRole('button', { name: '编辑目标' }))
const box = screen.getByRole('textbox', { name: '目标内容' })
expect(box).toHaveProperty('value', 'Line one\nLine two')
fireEvent.keyDown(box, { key: 'Enter', shiftKey: true })
fireEvent.keyDown(box, { key: 'Enter', isComposing: true })
fireEvent.keyDown(box, { key: 'Enter', keyCode: 229 })
expect(actions.onEdit).not.toHaveBeenCalled()
fireEvent.change(box, { target: { value: 'Line one\nLine three' } })
const enter = createEvent.keyDown(box, { key: 'Enter' })
fireEvent(box, enter)
expect(enter.defaultPrevented).toBe(true)
expect(actions.onEdit).toHaveBeenCalledWith('Line one\nLine three')
})
it('Esc cancels the edit without calling onEdit', () => {
const actions = makeActions()
render(<GoalBar goal={makeGoal()} {...actions} t={t} />)
@@ -18,11 +18,11 @@
en: b7d2396da72ecac4
zh: 4ec8336478dd39c7
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle:
en: 7f4438da14f803bc
zh: 1728231acbd25d49
en: c3fad003bf7f8a5a
zh: 89a22aa6e8bb2986
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-a-bundle:
en: abc90531efa889d5
zh: 0562570f85e69c35
en: 2d29970dcd0b2836
zh: 73f6b519546d2b6b
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-one-row-of-a-bundle:
en: 620c6a3ee20848bd
zh: a9429f047a12bb4c
@@ -36,8 +36,8 @@
en: 8ca48e1365f18adf
zh: 91eadd683d2d8b5b
/deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation/registration:
en: c5a1f14d37433d07
zh: 75014f10a155fadb
en: 3d70281f082988a5
zh: 17c6f595e3b57260
/deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation/the-store:
en: 21a0bbe8eb92229c
zh: 096ff63db61992bb
+12 -1
View File
@@ -37,6 +37,15 @@ The **Refresh** button shows its tooltip after 500 ms of hovering or keyboard fo
### Installing a bundle
The **Add plugin** control has a main button and a separate arrow for choosing how to add a plugin.
| Button or menu item | Result |
| --- | --- |
| **Add plugin** | Opens the installation dialog with focus in its package field. |
| Arrow: **Choose how to add a plugin** | Opens the menu of installation and plugin-contributed actions. |
| **Install a third-party plugin** | Closes the menu and opens the same dialog for a package name, Git repository, or local directory. |
| **Let the agent create a plugin** | When [ui-agent-preset](../ui-agent-preset/README.md#use-this-package) contributes this item, closes the menu and opens Creator to author a DSH plugin. |
On first use, when pnpm uses the unconfigured official npm registry and npmmirror is offered, the Host probes both registries in parallel. The first successful HTTPS ping response selects the initial source. The dialog preserves remembered choices, manual selections, explicit manager configuration, and custom or unknown pnpm registries. An install clicked during initial probing waits for that bounded operation; a late result cannot overwrite a manual choice or reopen a closed dialog.
The Host sends GET requests to `https://registry.npmjs.org/-/ping` and `https://registry.npmmirror.com/-/ping` through its normal fetch proxy. A 2xx response wins; redirects and failures do not. The remaining request is cancelled, both response bodies are discarded, and cleanup completes before the result returns. The default deadline is 1500 ms and results, including unavailable results, are cached for five minutes. Configure `registryProbeTimeoutMs` and `registryProbeCacheTtlMs` on `ui-plugin-manager`; `registryProbeEnabled: false` disables probing. If both requests fail or time out, the existing default remains selected. No IP-location service or Session content is involved.
@@ -49,7 +58,7 @@ When the Host attributes a network failure or timeout to a GitHub address and of
### Switching a bundle
A bundle's page shows its full package name under the title, the spec that installs it elsewhere. A bundle's switch changes its layer selection. A profile with HMR recomposes before the operation completes; one without HMR, and a bundle a higher layer overrides, say so in a toast. A bundle the Host cannot read carries a problem tag and its reason on its page and cannot be switched on; one that provides the management components stays locked. The Host answers with error codes, which the page's dictionary words; pnpm's and the Loader's own diagnostics are shown as they are. The page excludes built-in profile bundles from cards and counts even when the profile holds them as dependencies or the Host reports an error. The Host inventory remains complete; the Settings Plugins section's Plugin list tab inspects their plugins.
A bundle's page shows its full package name under the title. Its **Source** section, after the rows, names the spec that installs it elsewhere, as `pnpm add` accepts it, or **Built in** for an official bundle the installation ships, then its current version. A bundle's switch changes its layer selection. A profile with HMR recomposes before the operation completes; one without HMR, and a bundle a higher layer overrides, say so in a toast. A bundle the Host cannot read carries a problem tag and its reason on its page and cannot be switched on; one that provides the management components stays locked. The Host answers with error codes, which the page's dictionary words; pnpm's and the Loader's own diagnostics are shown as they are. The page excludes built-in profile bundles from cards and counts even when the profile holds them as dependencies or the Host reports an error. The Host inventory remains complete; the Settings Plugins section's Plugin list tab inspects their plugins.
### Switching one row of a bundle
@@ -99,6 +108,8 @@ The Host entry exposes `pluginRegistryProbe.fastest()` through the generated Rem
The browser plugin registers the `plugins` sidebar entry and its `main` panel through `ctx.slots.inject()`, so both follow late slot declaration, locale changes and teardown. The page is global and belongs to no Session. Display text comes from package metadata and the page's dictionary.
The add control reuses `Button`, `Menu`, and `MenuItemButton`. Both installation actions call the same `openInstall` callback and use `InstallDialog` and `PluginManagerController`. The root-scoped list slot `plugins.add.actions` accepts contributed `MenuItemButton` rows after the install action; each contribution receives an `onDismiss()` callback to close the menu before starting its action.
### The store
`PluginManagerController` owns the bundle views, busy keys, notices, install progress and the uninstall confirmation. Each read asks the inventory whether the Host manages a profile, then joins `listBundles` with `listPlugins` into one view per bundle, whose rows carry the live entry's enablement and fiber phase. It coalesces overlapping reads, refreshes after operations, on `plugin-manager/changed`, and on reconnect, and ignores late results after disposal. Install output is grouped by job id. The install dialog moves `idle → checking → starting → running → done | failed`, with `cancelling` and `applying` as the Host reports them and `unconfirmed` when an installation or cancellation response is lost; confirmed `applying` progress never regresses. Recovery uses `waitForInstall`, settling to `unknown` if no active request remains; checking and every active phase use ongoing, while the final screens use done or error. The check runs under an `AbortController` that going back or closing aborts, and its settlement is dropped; a run is stopped only through `pluginManager.cancelInstall`. Closing hides a run without discarding its state. A cancellation that overtakes installation is retried when progress or output acknowledges the request. A change the Host could not apply, a restart it waits for, and an override by a higher layer become toasts that retire on their own.
Loaded 100 of 246 files, more files were not shown because too many files have changed in this diff. Show more