Merge master into named Codex instances
This commit is contained in:
commit
67266b07cf
100 files changed
+2740
-1246
No files matched your search
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md
|
||||
2026-08-12-plugin-owned-settings-surface.md: 3137cfe81ef3cb78a940f085c559ab4a7b62cce3
|
||||
2026-08-12-plugin-owned-settings-surface.zh.md: 8dd5e5ccebf1cfb80b55a615f6049dd391943bd7
|
||||
2026-08-12-plugin-owned-settings-surface.md: 722e6cfbe890418e8305f89790e76976027d7775
|
||||
2026-08-12-plugin-owned-settings-surface.zh.md: 93e5227d5f6a629fd32f5a2fe22e9882c7f7c5ac
|
||||
@@ -22,7 +22,7 @@ Together the two meant a user-authored plugin was configurable only by hand-edit
|
||||
|
||||
**`settings.plugin.item` is keyed on the settings namespace.** The slot moved from `list` to `keyed`, the key being the namespace the card edits, following the `tool.call.toolview` precedent where each tool plugin registers its renderer under the tool name. A card declares `key`, not `id`/`order`. The slot is declared by the Plugins section's `configurable` tab, which owns the card list.
|
||||
|
||||
**The tab drives dispatch from the served namespaces.** It reads `settings.describe` once, subscribes to the settings-document invalidation and to connection resets, and dispatches one key per served namespace. What renders is the intersection of two ledgers — namespaces a live Host plugin registered, and cards registered under those keys — computed in the tab's controller from the slot ledger (`ctx.slots.entries`, `ctx.slots.subscribe`) and the wire answer.
|
||||
**The tab drives dispatch from the served namespaces.** It derives the current served set from `ctx.settingsScope.describe()` and follows that shared settings mirror, while its own listener follows the card slot ledger. It dispatches one key per served namespace. What renders is the intersection of two ledgers — namespaces a live Host plugin registered, and cards registered under those keys — computed in the tab's controller from the slot ledger (`ctx.slots.entries`, `ctx.slots.subscribe`) and the mirror answer. The later [settings describe mirror decision](2026-08-17-settings-describe-mirror.md) owns the browser-wide read and invalidation lifecycle.
|
||||
|
||||
Keying makes absence the signal, and that is what removes the bookkeeping the previous shape needed. A namespace another surface owns (`ui-theme`, `permission`, `llm-*`, `agent-presets`) has no card under its key, so it renders nothing without declaring anything anywhere. A card whose namespace this deployment does not serve is never dispatched, which also fixes the old empty-state defect: the tab counted registered cards, including ones rendering nothing, so a deployment exposing none showed an empty list instead of its empty line.
|
||||
|
||||
@@ -56,6 +56,6 @@ A plugin distributed outside this repository is configurable from the settings p
|
||||
|
||||
Deferred, and larger than this change: the redactor returns a `role('secret')` reachable only through a union, intersection, or transform verbatim (its own `TODO(settings-wire-redaction)`), and `schema.toJSON()` carries a secret's default. That gap predates this change, but serving every registered namespace widens its blast radius from schemas audited in this repository to any third-party schema, so the wire should refuse a namespace it cannot prove it can redact. Also deferred: an assembled-composition test of the headline capability — an overlay-mounted fixture plugin whose Host half registers a namespace and whose `dsh.client` half registers a card, asserted end-to-end. The current coverage proves each half separately; the shipped cards' unchanged output cannot prove the new path.
|
||||
|
||||
The wire read the section adds is one `settings.describe` beside the per-scope reads the cards already make. Its invalidation is imprecise in one direction: the wire announces document commits and connection resets, not registrations, so a namespace registered after the section's read joins on the next commit or reconnect.
|
||||
The section and its cards add no `settings.describe` reads: both derive from the browser-wide mirror. Its invalidation is imprecise in one direction: the wire announces document commits and connection resets, not registrations, so a namespace registered after the mirror's current answer joins on the next commit or reconnect.
|
||||
|
||||
Two frictions remain for an author outside this repository, both recorded in the section's README. The browser half must be a `dsh.client` package built in the client module system's lazy-CJS factory format, and the `clientBundle` preset that emits it lives in `packages/client/tsdown.client.ts` rather than a published package. The bundle-purity gate forbids importing this package's card chrome or staged-form model as values, so such a card reimplements staging and revision fencing. Sharing them would mean either publishing the preset or declaring a child slot inside the card so the section supplies the chrome; neither is built.
|
||||
+2
-2
@@ -22,7 +22,7 @@ Status: implemented
|
||||
|
||||
**`settings.plugin.item` 以 settings 命名空间为键。** 该 slot 从 `list` 改为 `keyed`,键就是卡片所编辑的命名空间,沿用 `tool.call.toolview` 的先例——每个工具插件把自己的渲染器注册在工具名这个键上。卡片声明 `key`,不再声明 `id`/`order`。该 slot 由「插件」分区的 `configurable` 标签页声明,卡片列表归它所有。
|
||||
|
||||
**标签页以被服务的命名空间驱动派发。** 它读取一次 `settings.describe`,订阅 settings 文档失效通知与连接重置,并为每个被服务的命名空间派发一个键。渲染出来的是两份账本的交集——存活 Host 插件注册的命名空间,以及注册在这些键上的卡片——由标签页的 controller 从 slot 账本(`ctx.slots.entries`、`ctx.slots.subscribe`)与协议答复算出。
|
||||
**标签页以被服务的命名空间驱动派发。** 它从 `ctx.settingsScope.describe()` 派生当前被服务的集合并跟随该共享 settings 镜像,自身的监听器只跟随卡片 slot 账本;随后为每个被服务的命名空间派发一个键。渲染出来的是两份账本的交集——存活 Host 插件注册的命名空间,以及注册在这些键上的卡片——由标签页的 controller 从 slot 账本(`ctx.slots.entries`、`ctx.slots.subscribe`)与镜像应答算出。后续的 [settings describe 镜像决策](2026-08-17-settings-describe-mirror.md)持有浏览器全局的读取与失效生命周期。
|
||||
|
||||
以命名空间为键,让「缺席」本身成为信号,而这正是它消掉旧形态所需簿记的原因。归别的界面所有的命名空间(`ui-theme`、`permission`、`llm-*`、`agent-presets`)在其键上没有卡片,于是什么都不渲染,且无需在任何地方声明任何东西。命名空间未被本部署服务的卡片根本不会被派发,这同时修掉了旧的空态缺陷:标签页数的是已注册卡片,其中包含那些什么都不渲染的,因此一个都不暴露的部署看到的是空列表,而不是它那行空态文案。
|
||||
|
||||
@@ -56,6 +56,6 @@ Status: implemented
|
||||
|
||||
以下延后,且都大于本次改动:脱敏器对只能经由 union、intersection 或 transform 抵达的 `role('secret')` 原样返回(其自身的 `TODO(settings-wire-redaction)`),而 `schema.toJSON()` 会携带 secret 的默认值。该缺口早于本次改动,但服务每一个已注册命名空间,把它的影响面从本仓库内经审计的 schema 扩大到任意第三方 schema,因此协议应当拒绝服务它无法证明可安全脱敏的命名空间。同样延后的还有:对本次头号能力的组装态测试——用 overlay 挂载一个 fixture 插件(Host 半注册命名空间、`dsh.client` 半注册卡片)并在端到端断言。当前覆盖分别证明了两个半侧;已发卡片输出未变这一点,证明不了新路径。
|
||||
|
||||
分区新增的协议读取是一次 `settings.describe`,与卡片各自已有的 per-scope 读取并列。它的失效通知在一个方向上不精确:协议通告的是文档提交与连接重置,而非注册行为,因此在分区读取之后才被注册的命名空间,要等下一次提交或重连才会加入。
|
||||
分区与其中的卡片都不再新增 `settings.describe` 读取:两者都从浏览器全局的镜像派生。它的失效通知在一个方向上不精确:协议通告的是文档提交与连接重置,而非注册行为,因此在镜像当前应答之后才被注册的命名空间,要等下一次提交或重连才会加入。
|
||||
|
||||
对仓库之外的作者仍留有两处摩擦,均记在该分区的 README 里。浏览器半侧必须是按客户端模块系统的 lazy-CJS factory 格式构建的 `dsh.client` 包,而产出它的 `clientBundle` 预设位于 `packages/client/tsdown.client.ts`,并非已发布的包。bundle 纯净度门禁禁止以值的形式导入本包的卡片外观与暂存表单模型,因此这样的卡片要重新实现暂存与 revision 设栅。要共享它们,要么发布该预设,要么在卡片内部声明一层子 slot 让分区提供外观;两者都尚未构建。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.md
|
||||
2026-08-17-settings-describe-mirror.md: a3774699ff328a44aed192a16dea0fa19d03c83c
|
||||
2026-08-17-settings-describe-mirror.zh.md: c57f5630b4bee0cd77a29a6f5458cb439c7f0585
|
||||
@@ -0,0 +1,34 @@
|
||||
# Agent Note: Settings describe mirror
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-17-settings-describe-mirror.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A cold web boot issued `settings.describe` fifteen times inside ~200ms, and the count grew by two with every client plugin that owned a preference. Two mechanisms stacked: `SettingsScopeBinder.bind()` started a full-document read per bound scope (six scopes in the product composition, plus the plugin-directory tab, the welcome gate, and the models onboarding join), and `onConnected` emits `connection/reset` on the FIRST connection too, so every one of those readers immediately re-read the answer it had fetched milliseconds earlier. Each reader also carried its own invalidation subscriptions and its own `refreshIfLoaded`-style guard, and fifteen independent reads could in principle land on fifteen different document revisions.
|
||||
|
||||
## Decision
|
||||
|
||||
**One reader, many derivations.** `dsh-client-ui-settings` owns `SettingsDescribeMirror`, the single `settings.describe` reader in the browser: one snapshot store holding the whole answer, refreshed by the owning plugin's two subscriptions (`settings/document-updated`, `connection/reset`). Concurrent `load()` calls fold into the in-flight read plus at most one rerun. The in-flight slot owns a run before its loading publication can synchronously reenter `load()`, then clears inside the run's own try/finally in the same synchronous segment that observes the rerun flag; a `.finally()` on the returned promise would run one microtask later and let a refresh landing in that gap mark a rerun nobody reads.
|
||||
|
||||
`bind()` still returns the unchanged `SettingsScope<T>` face, but the controller is now a selector over the mirror: no read path of its own, the same decode rules, and the write queue kept. A committed write folds its answered view back into the mirror (`acceptView`), so sibling scopes see the new revision with no re-read; the fold invalidates any older in-flight answer, and a write before the first held document reruns that read instead of publishing a partial document. A failed latest write triggers one mirror recovery read. Cross-namespace surfaces — the plugin-directory tab, the permission row (its dynamic enum lives in the namespace schema, which scopes deliberately do not carry), the models join, the agent-preset row's writability, and `hasDocument` — consume `ctx.settingsScope.describe()`, the shared read/fold face (`getSnapshot`/`subscribe`/`ensure`/`acceptView`).
|
||||
|
||||
This decision updates the browser read and invalidation mechanics recorded by [Host-backed Web preferences](../bug-fix/2026-08-06-host-backed-web-preferences.md) and [plugin-owned settings surface](2026-08-12-plugin-owned-settings-surface.md), while preserving their preference-ownership and namespace-exposure decisions. It also replaces the direct settings-read description in [official DeepSeek first-run credential setup](../feature/2026-07-30-deepseek-onboarding-credential-setup.md); that join now derives its settings half from this mirror.
|
||||
|
||||
The cold-boot budget is pinned at two reads by `apps/web/tests/startup-rpc-budget.e2e.ts`: the mirror's eager bind-time read, plus the first-connection reset read, which is kept deliberately — it closes the window where a document commit lands between the eager HTTP read and the SSE subscription and its invalidation is lost. The plan's original target of one read is unreachable without either accepting that lost-invalidation window or delaying the first read until after the SSE stream opens.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Single-flight sharing inside `bind()` only** — deduplicates the concurrent bursts but keeps N direct readers, N subscription sets, and the revision skew; readers outside the binder (welcome, models, tab, permission) gain nothing. Rejected as treating the symptom.
|
||||
- **Boot-payload embedding** (host inlines the describe answer into the page boot) — saves the first read but adds a second acquisition path with its own staleness rules on top of the mirror it would still need. Deferred; it composes with the mirror if ever wanted.
|
||||
- **Per-namespace `settings.describe(ns)`** — shrinks each answer but keeps one read per consumer, so the fan-out and the growth rate stay. Rejected.
|
||||
- **One read (no first-reset re-read)** — reachable only by accepting the lost-invalidation window between the eager HTTP read and the SSE subscription, or by delaying the first read until the stream opens; both trade correctness or first-paint freshness for one loopback request. Rejected in favor of the pinned two.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Startup `settings.describe` went 15 → 2, and a new preference-owning plugin adds zero reads.
|
||||
- Every derived surface shows the same document revision at any moment; the per-reader guards (`refreshWelcomeIfLoaded`, `refreshPermissionIfLoaded`, `refreshDocumentIfLoaded`) and their subscriptions are gone.
|
||||
- The mirror refreshes on every document commit regardless of namespace, so an external settings edit now costs one background read even while no settings surface is open — the price of surfaces that open already fresh. The per-namespace `ns !== spec.namespace` filters are gone with the per-scope subscriptions.
|
||||
- `credentials.describe` (3 startup calls), `agentPreset.list` (2), and `llm.providers` are separate sources and stay direct; the same mirror pattern fits them if they ever need it.
|
||||
- A new direct `settings.describe` caller in client code is a budget regression; the e2e's failure message says to grep for callers outside `ui-settings`.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Agent Note:Settings describe 镜像
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-17-settings-describe-mirror.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
一次冷启动的 web boot 在约 200ms 内发出十五次 `settings.describe`,且每新增一个持有偏好设置的客户端插件,该计数再加二。两个机制叠加:`SettingsScopeBinder.bind()` 为每个绑定的 scope 启动一次全量文档读取(产品组合中有六个 scope,外加插件目录 tab、welcome 门与 models onboarding join),而 `onConnected` 在**首次**连接时同样发出 `connection/reset`,于是上述每个读取方都立即重读了几毫秒前刚取到的应答。每个读取方还各自持有失效订阅与各自的 `refreshIfLoaded` 式防护,且十五次独立读取原则上可能落在十五个不同的文档 revision 上。
|
||||
|
||||
## 决定
|
||||
|
||||
**一个读取方,多个派生面。**`dsh-client-ui-settings` 持有 `SettingsDescribeMirror`——浏览器中唯一的 `settings.describe` 读取方:一个持有完整应答的快照 store,由所属插件的两个订阅(`settings/document-updated`、`connection/reset`)负责刷新。并发的 `load()` 调用折叠进在飞读取加至多一次尾随重读。在飞槽位会在 loading 发布同步重入 `load()` 之前先取得 run 的所有权,随后在 run 自身 try/finally 内、与读取 rerun 标志相同的同步段中清空;若把清理挂在返回 promise 的 `.finally()` 上,它要晚一个微任务执行,落入该间隙的刷新会标记一个无人读取的 rerun。
|
||||
|
||||
`bind()` 返回的 `SettingsScope<T>` 面保持不变,但 controller 现在是镜像上的 selector:自身没有读路径,decode 规则不变,写队列保留。提交成功的写入把应答的 view 折回镜像(`acceptView`),兄弟 scope 无需重读即可看到新 revision;这次折叠会废弃更早发出的在飞应答,而首次完整文档尚未建立时到达的写入会让该读取重跑,不会把单个 namespace 发布成残缺文档。失败的最新写入触发一次镜像恢复读取。跨命名空间的表面——插件目录 tab、permission 行(其动态枚举位于命名空间 schema 中,而 scope 有意不携带 schema)、models join、agent-preset 行的可写性、以及 `hasDocument`——消费 `ctx.settingsScope.describe()` 提供的共享读/折叠面(`getSnapshot`/`subscribe`/`ensure`/`acceptView`)。
|
||||
|
||||
本决策更新了[通过 Host settings 持久化 Web 用户偏好](../bug-fix/2026-08-06-host-backed-web-preferences.md)和[由插件自己拥有的设置表层](2026-08-12-plugin-owned-settings-surface.md)所记录的浏览器读取与失效机制,同时保留其中关于偏好所有权与命名空间暴露的决策。它也取代了 [DeepSeek 官方首次使用凭据配置](../feature/2026-07-30-deepseek-onboarding-credential-setup.md)中的设置直读描述;该联接的 settings 部分现在从本镜像派生。
|
||||
|
||||
冷启动预算由 `apps/web/tests/startup-rpc-budget.e2e.ts` 钉在两次读取:镜像在绑定时的急切读取,加上首连 reset 触发的读取——后者是有意保留的:它关闭了「文档提交落在急切 HTTP 读取与 SSE 订阅之间、其失效通知丢失」的窗口。方案最初的一次读取目标,若不接受该失效丢失窗口、或不把首次读取推迟到 SSE 流建立之后,无法达成。
|
||||
|
||||
## 考虑过的备选
|
||||
|
||||
- **仅在 `bind()` 内做 single-flight 共享**——能去重并发风暴,但仍保留 N 个直连读取方、N 套订阅以及 revision 偏差;binder 之外的读取方(welcome、models、tab、permission)毫无受益。以治标为由否决。
|
||||
- **boot 载荷内嵌**(宿主把 describe 应答内联进页面 boot)——省下首次读取,却在镜像仍然需要的前提下增加第二条带自身陈旧规则的取数路径。推迟;若将来需要,它可与镜像叠加。
|
||||
- **按命名空间的 `settings.describe(ns)`**——缩小单次应答,但每个消费者仍各读一次,扇出与增长率原样保留。否决。
|
||||
- **一次读取(去掉首连 reset 重读)**——只有接受「急切 HTTP 读取与 SSE 订阅之间的失效丢失窗口」、或把首次读取推迟到流建立之后才可达成;两者都在用正确性或首屏新鲜度换一次环回请求。否决,保留钉住的两次。
|
||||
|
||||
## 后果
|
||||
|
||||
- 启动期 `settings.describe` 从 15 次降到 2 次,新增持有偏好设置的插件带来零次新增读取。
|
||||
- 任一时刻每个派生面看到的都是同一份文档 revision;各读取方的防护(`refreshWelcomeIfLoaded`、`refreshPermissionIfLoaded`、`refreshDocumentIfLoaded`)及其订阅随之消失。
|
||||
- 镜像对任何命名空间的文档提交都会刷新,因此在没有任何设置表面打开时,一次外部设置编辑现在也花费一次后台读取——这是「表面打开即新鲜」的代价。随着各 scope 订阅的删除,按命名空间的 `ns !== spec.namespace` 过滤一并消失。
|
||||
- `credentials.describe`(启动 3 次)、`agentPreset.list`(2 次)与 `llm.providers` 是另外的数据源,保持直连;若将来需要,同一镜像模式对它们同样适用。
|
||||
- 客户端代码中新增直连 `settings.describe` 调用即是预算回归;e2e 的失败信息会提示在 `ui-settings` 之外 grep 调用方。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md
|
||||
2026-08-06-host-backed-web-preferences.md: 5d90f2be7c8b4030e9bdc00eed2769491ec009e5
|
||||
2026-08-06-host-backed-web-preferences.zh.md: c861c45bff299e06841165a2b36d0781e8f54d99
|
||||
2026-08-06-host-backed-web-preferences.md: 2e33d05417bf6c347a57b5c0b6c7281ff1392b5b
|
||||
2026-08-06-host-backed-web-preferences.zh.md: 1d3518bb33d334d89408916a5f5210a45b938a01
|
||||
@@ -12,9 +12,9 @@ The first theme implementation moved only Appearance to Host settings but awaite
|
||||
|
||||
## Decision
|
||||
|
||||
The owning Host halves register three schemas: optional `locale.preference` (`zh` or `en`, where absence delegates to the browser), `ui-theme.preference` (`light`, `dark`, or `system`, default `system`), and `ui-conversation.busyEnter` (`queue` or `steer`, default `queue`). The local settings provider stores explicit choices in `$DSH_HOME/settings.yaml`, which resolves to `~/.dsh/settings.yaml` under the default home. The API proxy explicitly exposes all three namespaces beside the other Web settings; registration alone never crosses that configuration boundary.
|
||||
The owning Host halves register three schemas: optional `locale.preference` (`zh` or `en`, where absence delegates to the browser), `ui-theme.preference` (`light`, `dark`, or `system`, default `system`), and `ui-conversation.busyEnter` (`queue` or `steer`, default `queue`). The local settings provider stores explicit choices in `$DSH_HOME/settings.yaml`, which resolves to `~/.dsh/settings.yaml` under the default home. The API proxy serves every registered namespace to a loopback client; field roles still redact secrets.
|
||||
|
||||
`dsh-client-ui-settings` provides `ctx.settingsScope.bind(spec)`, which owns one lifecycle per namespace as the browser mirror of the Host-side settings owner seam. It installs `settings/document-updated` and `connection/reset` listeners before starting a background initial read, so no settings transport can block plugin activation and an invalidation cannot fall into a read-before-subscribe gap, and it publishes a snapshot store (status, section value, revision, writability, host/memory mode) the domain service subscribes to. The default decoder validates each incoming section against the namespace's own serialized wire schema, rehydrated through the colocated `ctx.settingsSchema` service, so domains carry no hand-written wire guards. Domain services take the scope as an ordinary constructor collaborator, publish their provisional defaults immediately—browser-derived locale, system theme, and Queue—then adopt an accepted Host section without writing it back; a service constructed without a scope (standalone dictionary or policy fixtures) simply stays process-local.
|
||||
`dsh-client-ui-settings` owns one browser-wide settings describe mirror and provides `ctx.settingsScope.bind(spec)` as a per-namespace selector over it. The mirror installs `settings/document-updated` and `connection/reset` listeners before starting its background read, so no settings transport can block plugin activation and an invalidation cannot fall into a read-before-subscribe gap. Each bound scope publishes a snapshot store (status, section value, revision, writability, host/memory mode) the domain service subscribes to, without adding a wire read or listener of its own. The default decoder validates each incoming section against the namespace's own serialized wire schema, rehydrated through the colocated `ctx.settingsSchema` service, so domains carry no hand-written wire guards. Domain services take the scope as an ordinary constructor collaborator, publish their provisional defaults immediately—browser-derived locale, system theme, and Queue—then adopt an accepted Host section without writing it back; a service constructed without a scope (standalone dictionary or policy fixtures) simply stays process-local. The shared read and invalidation lifecycle is specified by the later [settings describe mirror decision](../architecture/2026-08-17-settings-describe-mirror.md).
|
||||
|
||||
User changes update the live service synchronously and queue a `settings.mutate` path operation through `scope.set`. The scope serializes gestures, sends the latest known namespace revision as `expectedRevision`, records every successful revision, and lets only the latest write settlement republish live state. A rejected or failed latest write reloads Host state. Disposal rejects new work, skips queued operations, suppresses publication by the in-flight operation, and waits for that operation to settle before the plugin reaches quiescence.
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@ Web 的 Appearance、Language 和繁忙态 Enter 偏好原本存在浏览器 `lo
|
||||
|
||||
## 决策
|
||||
|
||||
各领域所属的 Host half 注册三份 schema:可选的 `locale.preference`(`zh` 或 `en`,缺失时交由浏览器决定)、`ui-theme.preference`(`light`、`dark` 或 `system`,默认为 `system`),以及 `ui-conversation.busyEnter`(`queue` 或 `steer`,默认为 `queue`)。本地 settings 提供方将显式选择存入 `$DSH_HOME/settings.yaml`,在使用默认 home 时,该路径解析为 `~/.dsh/settings.yaml`。API 代理会显式暴露这三个 namespace,与其他 Web settings 并列;仅注册它们,绝不会跨越该配置边界。
|
||||
各领域所属的 Host half 注册三份 schema:可选的 `locale.preference`(`zh` 或 `en`,缺失时交由浏览器决定)、`ui-theme.preference`(`light`、`dark` 或 `system`,默认为 `system`),以及 `ui-conversation.busyEnter`(`queue` 或 `steer`,默认为 `queue`)。本地 settings 提供方将显式选择存入 `$DSH_HOME/settings.yaml`,在使用默认 home 时,该路径解析为 `~/.dsh/settings.yaml`。API 代理会向回环客户端服务每一个已注册的 namespace;字段角色仍会脱敏机密值。
|
||||
|
||||
`dsh-client-ui-settings` 提供 `ctx.settingsScope.bind(spec)`,为每个 namespace 持有一份生命周期,作为 Host 侧 settings owner seam 的浏览器镜像。它在开始后台初始读取之前安装 `settings/document-updated` 和 `connection/reset` 监听器,因此任何 settings 传输都不会阻塞插件激活,失效通知也不会掉入先读取、后订阅的空档;它还会发布一个供领域服务订阅的快照 store(状态、分节值、revision、可写性、host/内存模式)。默认解码器会对照该 namespace 自身的序列化 wire schema(经同包的 `ctx.settingsSchema` 服务还原)校验每个传入分节,因此各领域无需携带手写的 wire 校验器。领域服务把 scope 当作普通的构造函数协作者接收,立即发布各自的暂定默认值:由浏览器派生的 locale、系统主题和 Queue;随后采纳已获接受的 Host 分节,但不将其写回;不带 scope 构造的服务——独立词典或政策 fixture(测试前置数据)——则仅停留在进程本地。
|
||||
`dsh-client-ui-settings` 持有一个浏览器全局的 settings describe 镜像,并提供 `ctx.settingsScope.bind(spec)` 作为该镜像上的逐 namespace selector。镜像在开始后台读取之前安装 `settings/document-updated` 和 `connection/reset` 监听器,因此任何 settings 传输都不会阻塞插件激活,失效通知也不会掉入先读取、后订阅的空档。每个绑定的 scope 会发布一个供领域服务订阅的快照 store(状态、分节值、revision、可写性、host/内存模式),自身不再增加协议读取或监听器。默认解码器会对照该 namespace 自身的序列化 wire schema(经同包的 `ctx.settingsSchema` 服务还原)校验每个传入分节,因此各领域无需携带手写的 wire 校验器。领域服务把 scope 当作普通的构造函数协作者接收,立即发布各自的暂定默认值:由浏览器派生的 locale、系统主题和 Queue;随后采纳已获接受的 Host 分节,但不将其写回;不带 scope 构造的服务——独立词典或政策 fixture(测试前置数据)——则仅停留在进程本地。共享读取与失效生命周期由后续的 [settings describe 镜像决策](../architecture/2026-08-17-settings-describe-mirror.md)规定。
|
||||
|
||||
用户变更会同步更新实时服务,并经 `scope.set` 将一项 `settings.mutate` 路径操作排入队列。scope 会串行处理手势,以最新已知 namespace revision 作为 `expectedRevision` 发送,记录每次成功写入的 revision,并且只允许最新写入的结算结果重新发布实时状态。最新写入被拒或失败时,scope 会重新加载 Host 状态。插件释放会拒绝新工作、跳过已排队操作、抑制运行中操作发布状态,并等待该操作结算后才让插件达到完全停稳。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md
|
||||
2026-07-24-provider-retry-policies.md: 1831ce6b96178d11e7c9927ceccbe07ea578cd2c
|
||||
2026-07-24-provider-retry-policies.zh.md: 22f18badcb47e4ce086ff2c68c7011612b2eabba
|
||||
2026-07-24-provider-retry-policies.md: 96979b219aebece96a1bcc09aa3dd572d2b9222d
|
||||
2026-07-24-provider-retry-policies.zh.md: 02fe13e0ddead035ec750c027e889da08e2557ae
|
||||
@@ -12,7 +12,7 @@ Provider policy must follow the request that actually failed, including a route
|
||||
|
||||
## Decision
|
||||
|
||||
Each concrete adapter accepts an optional `retryPolicy` inside its provider configuration. The adapter validates and resolves the policy, and `ctx.llm` captures it when that exact provider route registers. When a call enters its final adapter boundary, `ctx.llm` binds the serving registration's immutable policy to that call; the agent loop passes it to closed-step recovery even if the route is disposed or replaced while the request is in flight. `@deepseek-ai/dsh-llm-retry` combines that call-local policy with the failed step's durable provider identity. A call that never reaches a final adapter has no serving policy and delegates. A provider without `retryPolicy` uses the normal defaults.
|
||||
Each concrete adapter accepts an optional `retryPolicy` inside its provider configuration, validates and resolves it, and exposes that resolved route policy through `providerRetryPolicy()`. Omission selects the shared core normal default of five retries for every composition, including Web, headless, and custom profiles. The effective policy remains route-owned registration state rather than a retry-executor setting. Layered settings may retain normal-only `maxRetries` or `retryableCodes` after changing `mode` to `always`; the resolver ignores those inactive fields while still rejecting unknown keys, and the registered always policy omits them. When a call enters its final adapter boundary, `ctx.llm` binds the serving registration's immutable policy to that call; the agent loop passes it to closed-step recovery even if the route is disposed or replaced while the request is in flight. `@deepseek-ai/dsh-llm-retry` combines that call-local policy with the failed step's durable provider identity. A call that never reaches a final adapter has no serving policy and delegates.
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
@@ -44,22 +44,28 @@ Each scheduled retry appends a non-surface `llm/retry` event with the failed pro
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**One global `always` switch** — rejected because it cannot isolate the unbounded cost and latency risk to the provider that needs it and can silently apply after runtime rerouting.
|
||||
**One retry-executor-level `always` switch** — rejected because it cannot isolate the unbounded cost and latency risk to the provider that needs it and can silently apply after runtime rerouting. Provider route policies remain authoritative, and the effective policy is captured only after routing selects a registration.
|
||||
|
||||
**A separate exact-provider list on `dsh-llm-retry`** — rejected because it duplicates provider route names outside their owning adapter configuration and lets provider registration drift from recovery policy.
|
||||
|
||||
**A very large finite retry count** — rejected because it eventually violates the requested keep-retrying contract and serializes an arbitrary operational limit as if it were meaningful.
|
||||
|
||||
**Adapter-specific omission defaults** — rejected because a shared budget would have to be repeated by every adapter family and every future adapter, making equivalent model routes behave differently depending on their implementation.
|
||||
|
||||
**An LLM deployment-level default** — rejected because it introduces another configuration layer only to make Web differ from other compositions. The product default is uniform, while provider settings retain the existing per-route override.
|
||||
|
||||
**Stamp five retries into profiles when the Web UI writes them** — rejected because existing profiles, settings written outside that UI, and non-Web compositions would retain the old value.
|
||||
|
||||
**Provider-SDK retries** — rejected because hidden attempts multiply agent-level budgets, cannot use the closed-step durability boundary, and may splice or discard streamed output without a reconstructable retry record.
|
||||
|
||||
**Put the error into model context** — rejected because a transport or provider diagnostic is operational state, not conversation content. It can expose sensitive provider details and changes the retried request instead of repeating the failed request.
|
||||
|
||||
## Verification
|
||||
|
||||
Adapter tests validate nested policies at provider load, prove registration captures configured and default policies, and retain the serving policy across in-flight route replacement. Unit tests select policies from the failed request's serving registration, separate provider and changed-policy histories, exercise always mode beyond the normal budget, pin jitter and delay caps, prove downstream recovery ordering, prove cancellation and disposal drain delegated recovery before reaching quiescence, and prove both abort active backoff waits. Request-level coverage compares the complete messages of failed and retried attempts and rejects both provider error text and discarded partial output. A keyless headless `stream-json` snapshot runs failure, retry, and success through the assembled app, pins the complete `llm/retry` record, and rejects any model-message change between attempts. JSONL and SQLite tests round-trip an always event without `Infinity`; invariant tests bind provider identity to the request header, validate failure and mode-specific timer bounds, and bind retry numbers to provider-policy keys; TUI tests render finite and infinite limits.
|
||||
Adapter tests validate nested policies at provider load, prove explicit profile policies reach registration, prove omission resolves to five retries, and retain the serving policy across in-flight route replacement. LLM service tests prove adapter policies are captured and omission uses the shared five-retry behavior. Resolver tests prove always mode ignores retained normal-only fields but returns a pure always policy. Unit tests select policies from the failed request's serving registration, separate provider and changed-policy histories, exercise always mode beyond the normal budget, pin jitter and delay caps, prove downstream recovery ordering, prove cancellation and disposal drain delegated recovery before reaching quiescence, and prove both abort active backoff waits. Request-level coverage compares the complete messages of failed and retried attempts and rejects both provider error text and discarded partial output. A keyless headless `stream-json` snapshot runs failure, retry, and success through the assembled app, pins the complete `llm/retry` record, and rejects any model-message change between attempts. The shipped Web composition snapshot pins omitted DeepSeek and pi-ai policies at five retries, then proves settings can write `{ mode: 'always', maxRetries: 5 }` and obtain a pure always policy. JSONL and SQLite tests round-trip an always event without `Infinity`; invariant tests bind provider identity to the request header, validate failure and mode-specific timer bounds, and bind retry numbers to provider-policy keys; TUI tests render finite and infinite limits.
|
||||
|
||||
## Consequences
|
||||
|
||||
Normal mode remains a finite default, while an explicit always policy can spend unbounded requests and time on permanent authentication, quota, invalid-request, protocol, or context failures. Operators must pair always mode with a cancellable caller and provider-specific cost controls. Retry state stays observable and durable without becoming model-visible, and serving-registration capture prevents adapter lifecycle changes from retroactively changing an in-flight request's recovery contract.
|
||||
Normal mode remains a finite default, while an explicit always policy can spend unbounded requests and time on permanent authentication, quota, invalid-request, protocol, or context failures. Operators must pair always mode with a cancellable caller and provider-specific cost controls. Any model route using omission defaults may spend up to three more requests and their backoff time than under the former two-retry default, in exchange for recovering from longer transient outages. Retry state stays observable and durable without becoming model-visible, and serving-registration capture prevents adapter lifecycle changes from retroactively changing an in-flight request's recovery contract.
|
||||
|
||||
This decision extends the closed-step recovery, single visible adapter attempt, structured failure, and durable status design in [bounded recovery for transient LLM request failures](../architecture/2026-06-21-bounded-llm-request-recovery.md).
|
||||
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
每个具体适配器都在其提供方配置中接受可选的 `retryPolicy`。适配器负责校验并解析策略,`ctx.llm` 则在该特定提供方路由注册时捕获策略。当调用进入最终适配器边界时,`ctx.llm` 会把实际提供服务的注册项所持不可变策略绑定到该调用;即使路由在请求进行期间被 dispose(资源释放)或替换,agent loop(智能体循环)仍会把该策略传给已关闭步骤恢复。`@deepseek-ai/dsh-llm-retry` 会把绑定到该调用的策略与失败步骤的持久化提供方标识结合起来。未到达最终适配器的调用没有实际提供服务的策略,因而会委托后续处理。未配置 `retryPolicy` 的提供方使用 normal 默认值。
|
||||
每个具体适配器都在其提供方配置中接受可选的 `retryPolicy`,对它进行校验与解析,并通过 `providerRetryPolicy()` 公开解析后的路由策略。省略配置时,Web、headless 与自定义 profile 等所有组合都使用核心共享的 normal 模式五次重试默认值。有效策略仍然是路由拥有的注册状态,而不是重试执行器设置。分层 settings 在把 `mode` 改为 `always` 后可能保留仅属于 normal 的 `maxRetries` 或 `retryableCodes`;解析器会忽略这些未启用字段,同时仍拒绝未知键,注册后的 always 策略也不包含它们。当调用进入最终适配器边界时,`ctx.llm` 会把实际提供服务的注册项所持不可变策略绑定到该调用;即使路由在请求进行期间被 dispose(资源释放)或替换,agent loop(智能体循环)仍会把该策略传给已关闭步骤恢复。`@deepseek-ai/dsh-llm-retry` 会把绑定到该调用的策略与失败步骤的持久化提供方标识结合起来。未到达最终适配器的调用没有实际提供服务的策略,因而会委托后续处理。
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
@@ -44,22 +44,28 @@ always 模式先请求下游恢复,使上下文溢出压缩(compaction)之
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**单一全局 `always` 开关**:不予采纳,因为它无法把无界成本与延迟风险限制在确有需要的提供方,还可能在运行时重新路由后悄然生效。
|
||||
**重试执行器级的单一 `always` 开关**:不予采纳,因为它无法把无界成本与延迟风险限制在确有需要的提供方,还可能在运行时重新路由后悄然生效。提供方路由策略仍然权威,而且只有在路由选定注册后才捕获有效策略。
|
||||
|
||||
**在 `dsh-llm-retry` 上维护单独的指定提供方列表**:不予采纳,因为它会在所属适配器配置之外重复提供方路由名称,并让提供方注册与恢复策略发生偏差。
|
||||
|
||||
**设置很大的有限重试次数**:不予采纳,因为它最终仍会违反持续重试的约定,并把任意选取的运维上限序列化成看似有意义的数值。
|
||||
|
||||
**按适配器设置不同的省略默认值**:不予采纳,因为共享预算必须在每种适配器族以及未来的每个适配器中重复配置,同等模型路由也会因实现不同而表现不同。
|
||||
|
||||
**LLM 部署级默认值**:不予采纳,因为这只为区分 Web 与其他组合增加了一层配置。产品默认值保持统一,提供方 settings 则保留既有的逐路由覆盖能力。
|
||||
|
||||
**在 Web UI 写入 profile 时把五次重试写死进去**:不予采纳,因为现有 profile、从该 UI 之外写入的 settings 以及非 Web 组合仍会保留旧值。
|
||||
|
||||
**使用提供方 SDK 重试**:不予采纳,因为隐藏尝试会叠加 agent 层预算,无法利用已关闭步骤的持久性边界,还可能在没有可重建重试记录的情况下拼接或丢弃流式输出。
|
||||
|
||||
**把错误放入模型上下文**:不予采纳,因为传输或提供方诊断信息属于运维状态,而非对话内容。它可能暴露敏感的提供方细节,并会改变重试请求,无法重复原本失败的请求。
|
||||
|
||||
## 验证
|
||||
|
||||
适配器测试会在提供方加载时校验嵌套策略,证明注册流程会捕获已配置策略和默认策略,并证明请求进行期间替换路由后仍会保留实际提供服务的策略。单元测试根据失败请求实际使用的注册项选择策略、分离不同提供方和策略变更后的重试历史、验证 always 模式可越过 normal 预算、固定抖动和延迟上限、证明下游恢复顺序、证明取消与 dispose 会先排空已委托的恢复再达到完全停稳,并证明二者都会停止正在进行的退避等待。请求级覆盖会比较失败尝试与重试尝试的完整消息,并排除提供方错误文本和丢弃的部分输出。一个无密钥 headless `stream-json` 快照会通过组装后的应用执行失败、重试与成功流程,固定完整的 `llm/retry` 记录,并拒绝各次尝试之间出现任何模型消息变化。JSONL 与 SQLite 测试会往返读写不含 `Infinity` 的 always 事件;不变式测试会将提供方标识绑定到请求头、校验失败事实和各模式的计时器边界,并将重试编号绑定到提供方策略键;TUI 测试会渲染有限和无限上限。
|
||||
适配器测试会在提供方加载时校验嵌套策略,证明显式 profile 策略抵达注册流程,证明省略配置会解析为五次重试,并证明请求进行期间替换路由后仍会保留实际提供服务的策略。LLM 服务测试会证明适配器策略被捕获,且省略配置使用共享的五次重试行为。解析器测试会证明 always 模式忽略残留的 normal 专属字段,但返回纯 always 策略。单元测试根据失败请求实际使用的注册项选择策略、分离不同提供方和策略变更后的重试历史、验证 always 模式可越过 normal 预算、固定抖动和延迟上限、证明下游恢复顺序、证明取消与 dispose 会先排空已委托的恢复再达到完全停稳,并证明二者都会停止正在进行的退避等待。请求级覆盖会比较失败尝试与重试尝试的完整消息,并排除提供方错误文本和丢弃的部分输出。一个无密钥 headless `stream-json` 快照会通过组装后的应用执行失败、重试与成功流程,固定完整的 `llm/retry` 记录,并拒绝各次尝试之间出现任何模型消息变化。随附的 Web 组合快照会把省略配置的 DeepSeek 与 pi-ai 策略固定为五次重试,再证明 settings 可以写入 `{ mode: 'always', maxRetries: 5 }` 并得到纯 always 策略。JSONL 与 SQLite 测试会往返读写不含 `Infinity` 的 always 事件;不变式测试会将提供方标识绑定到请求头、校验失败事实和各模式的计时器边界,并将重试编号绑定到提供方策略键;TUI 测试会渲染有限和无限上限。
|
||||
|
||||
## 后果
|
||||
|
||||
normal 模式仍是有限的默认策略;显式的 always 策略可能在永久性的身份验证、配额、无效请求、协议或上下文错误上耗费无限次请求和无限时间。运维方必须为 always 模式配备可取消的调用方和针对提供方的成本控制。重试状态保持可观察且会持久化,但不会对模型可见;捕获实际提供服务的注册项,也能防止适配器生命周期变化反过来改变进行中请求的恢复约定。
|
||||
normal 模式仍是有限的默认策略;显式的 always 策略可能在永久性的身份验证、配额、无效请求、协议或上下文错误上耗费无限次请求和无限时间。运维方必须为 always 模式配备可取消的调用方和针对提供方的成本控制。任何使用省略默认值的模型路由相比原先的两次重试默认值,最多会多花费三次请求及其退避时间,以此换取从更长短暂故障中恢复的能力。重试状态保持可观察且会持久化,但不会对模型可见;捕获实际提供服务的注册项,也能防止适配器生命周期变化反过来改变进行中请求的恢复约定。
|
||||
|
||||
本决策扩展了[瞬态 LLM(大语言模型)请求失败的有界恢复](../architecture/2026-06-21-bounded-llm-request-recovery.md)中确定的已关闭步骤恢复、单次可见适配器尝试、结构化失败与持久化状态设计。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md
|
||||
2026-07-30-deepseek-onboarding-credential-setup.md: 823d10a723af70ec4ff51018b8b86198db0f5c29
|
||||
2026-07-30-deepseek-onboarding-credential-setup.zh.md: 7e8d79c23c4b1489bfd818c90558f36509635486
|
||||
2026-07-30-deepseek-onboarding-credential-setup.md: 87533e7a55f9b1f05f6a4ba58c3c9888780c158c
|
||||
2026-07-30-deepseek-onboarding-credential-setup.zh.md: 575d8232b4837b57d9508ed613530606c05db1f7
|
||||
+1
-1
@@ -10,7 +10,7 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma
|
||||
|
||||
## Decision
|
||||
|
||||
**One readiness projection owns both Models and onboarding facts.** `ui-settings-models` keeps a single store that joins `llm.providers({})`, redacted `settings.describe({})`, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured process-environment credential is ready and remains read-only.
|
||||
**One readiness projection owns both Models and onboarding facts.** `ui-settings-models` keeps a single store that joins `llm.providers({})`, the redacted namespace views held by the shared settings describe mirror, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured process-environment credential is ready and remains read-only. The later [settings describe mirror decision](../architecture/2026-08-17-settings-describe-mirror.md) owns that settings read and its invalidation ordering.
|
||||
|
||||
**The settings shell contributes ordering, not provider policy.** `ui-settings` declares a root-scoped `settings.onboarding` list slot and mounts one ordered step at a time while the current surface is the empty Hero. The active registrant receives `complete()` and a private `openSection(id)` callback; completion transfers ownership to the next entry. `ui-settings-models` registers the DeepSeek step, the preceding welcome notice, and its Models section through `slots.inject()`, so every contribution follows one client Cordis plugin's lifecycle and the dialogs cannot stack. Their common presentation is owned by the [shared-modal onboarding decision](2026-08-13-shared-modal-product-onboarding.md).
|
||||
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
**Models 与首次使用引导共享同一个就绪状态投影。**`ui-settings-models` 维护一个 store,把 `llm.providers({})`、脱敏后的 `settings.describe({})` 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。通过进程环境提供的凭据若已配置,则判定为就绪并保持只读。
|
||||
**Models 与首次使用引导共享同一个就绪状态投影。**`ui-settings-models` 维护一个 store,把 `llm.providers({})`、共享 settings describe 镜像持有的已脱敏 namespace views 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。通过进程环境提供的凭据若已配置,则判定为就绪并保持只读。后续的 [settings describe 镜像决策](../architecture/2026-08-17-settings-describe-mirror.md)持有这次 settings 读取及其失效顺序。
|
||||
|
||||
**设置外壳只贡献排序,不持有提供方策略。** `ui-settings` 声明一个根作用域的 `settings.onboarding` list slot,并在当前界面为空白 Hero 时,每次只挂载一个有序步骤。当前注册方会收到 `complete()` 和私有 `openSection(id)` 回调;完成当前步骤后,所有权转交给下一项。`ui-settings-models` 通过 `slots.inject()` 注册 DeepSeek 步骤、排在它之前的欢迎声明及 Models 分区,因此所有贡献都跟随同一个 client Cordis 插件的生命周期,两个弹窗也无法堆叠。它们的共用展示由[共用弹窗引导决策](2026-08-13-shared-modal-product-onboarding.md)持有。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.md
|
||||
2026-08-11-workflow-run-status-driven-disclosure.md: 2f452d25a8922bb6c275419af55e8af155dd2781
|
||||
2026-08-11-workflow-run-status-driven-disclosure.zh.md: 12cc106fea274a1681ee5615906ae6df266d567b
|
||||
2026-08-11-workflow-run-status-driven-disclosure.md: e97ead90460f21d4a27b49dcdabbcafc8f193cda
|
||||
2026-08-11-workflow-run-status-driven-disclosure.zh.md: ef3da3ee7cbffbb901a4b71a920bd53c2965c827
|
||||
+16
-14
@@ -6,38 +6,40 @@ English | [中文](2026-08-11-workflow-run-status-driven-disclosure.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A durable workflow Chat node updates in place from its running prefix to a terminal record. A disclosure choice initialized only at mount can hide a newly running phase, leave completed work occupying the conversation, or bury a failed, cancelled, or interrupted member behind two collapsed levels. Making openness a pure function of completion avoids those failures but also prevents users from reopening clean history for review.
|
||||
A durable workflow Chat node updates in place from its running prefix to a terminal record. The renderer must draw attention to new work, abnormal outcomes, and normal completion without repeatedly overriding a user's decision to reclaim conversation space.
|
||||
|
||||
The renderer already receives every required lifecycle fact from the workflow Conversation Node. Visibility therefore needs a component-local lifecycle that gives current execution and attention states priority without adding another durable fact or taking ownership of workflow outcomes.
|
||||
The renderer already receives every durable lifecycle fact from the workflow Conversation Node. Disclosure choice therefore belongs to the mounted presentation, but its lifecycle must also preserve nested phase choices when the outer run is hidden and avoid removing content that still contains keyboard focus.
|
||||
|
||||
## Decision
|
||||
|
||||
Each phase derives one visibility requirement from its current members. A running, failed, cancelled, or interrupted member forces that phase open; a phase whose members are all completed is clean. The workflow forces itself open when its own status requires attention or any phase is forced open, so an abnormal member remains visible even when the workflow outcome is recorded as completed. A completed sibling phase remains independently collapsible.
|
||||
`WorkflowRunPanel` owns one local disclosure state for the run and a map keyed by the existing phase key. A phase is clean when every member completed, abnormal when any member failed, was cancelled, or was interrupted, and running otherwise. The run is abnormal when its own status or any phase is abnormal, running when its own status or any phase is running, and clean only when the run and every phase completed normally. A mount opens running and abnormal levels and closes clean levels.
|
||||
|
||||
A forced-open level renders as an expanded static row. It exposes no button role, focus target, keyboard toggle, or `aria-expanded` value because collapsing cannot change the result. This keeps the visual hierarchy and status summaries while making the interaction promise match the available action.
|
||||
Each level records its current mode, append-only member count, open choice, and any pending clean close. Ordinary updates within a running or abnormal interval preserve the user's choice. A phase transition from clean to activity opens that phase and the outer run once, the first transition into abnormal opens once, and a transition into clean closes once. A member-count change while a phase remains clean represents a complete activity cycle delivered in one render: it closes an open phase review and, while the run remains active, opens the outer run once without adding an activity epoch or durable field. After an automatic action, mouse, Enter, and Space control the level until another defined edge occurs.
|
||||
|
||||
A clean level mounts an ordinary controlled disclosure in the closed state. Its local choice survives rerenders for the same continuous clean interval. New running or abnormal data replaces that manual interval with forced expansion; the next transition back to clean mounts a fresh closed disclosure, which produces one automatic fold per activity cycle. Closing the workflow naturally unmounts its phase controls, and a Session remount reconstructs every level from the current durable status rather than restoring an earlier choice.
|
||||
Phase state remains in `WorkflowRunPanel` while the outer disclosure hides its children, so closing and reopening the run restores each phase choice. Removing a phase deletes its entry; a renderer remount reconstructs every level from current durable facts rather than restoring an earlier choice.
|
||||
|
||||
For example, a running workflow exposes its active phase and member without clicks. When that phase completes, only the phase folds while the workflow remains open; when the workflow and every phase complete, the workflow also folds. The user can then reopen both levels for review. If another member starts under the same phase key, both affected levels immediately return to forced expansion and fold again only after the new activity completes.
|
||||
Normal completion checks whether focus is inside the content before closing. Focused content remains mounted with current completed status and closes after focus leaves. When a navigable member becomes terminal while its button holds focus, `MemberRow` keeps the same button mounted as `aria-disabled` until blur; later terminal review renders the ordinary non-interactive row. This preserves the active DOM target without allowing terminal navigation or adding a focus manager.
|
||||
|
||||
The renderer owns only this visibility lifecycle. It does not add Session events, stores, settings, acknowledgement state, timers, focus movement, automatic scrolling, or cross-remount persistence. It does not change workflow status derivation, phase grouping, member order, navigation eligibility, copy, or the shared `DisclosureRow` API. Shared `data-expandable` styling owns pointer cursors, so forced-open static rows do not advertise an unavailable action. An interrupted durable prefix remains an attention state and therefore stays visible until the underlying facts change.
|
||||
The renderer adds no Session events, store, setting, acknowledgement, timer, automatic scrolling, persistent activity identity, or `DisclosureRow` API. It does not change workflow status derivation, phase grouping, member order, navigation eligibility, copy, or visual tokens.
|
||||
|
||||
## Verification
|
||||
|
||||
Component tests drive the same keyed workflow and phase through running, clean completion, manual review, renewed activity, repeated clean completion, zero-member completion, and each abnormal status. They also verify abnormal-member propagation, clean-sibling independence, mouse and keyboard review, continuous-clean choice retention, and the absence of false button and ARIA semantics while expansion is mandatory.
|
||||
Component tests drive one keyed run and its phases through initial running controls, mouse and keyboard choices, ordinary running updates, outer hide and restore, phase completion, run completion, clean review, same-key renewed activity, a fully batched clean cycle, every abnormal status, first-abnormal escalation, later abnormal updates, zero-member completion, focused-member completion, sibling independence, and renderer remount. They also verify terminal navigation remains absent after the deferred focus path settles.
|
||||
|
||||
The shipped Web replay observes the real workflow, worker, Session log, browser plugin graph, and child navigation. It requires the live workflow and active phase to be visible without disclosure controls, the normally settled workflow and phase to fold, manual review to retain the terminal member without navigation, and a reload to reconstruct the folded history from durable facts.
|
||||
The shipped Web replay exercises the real workflow, worker, Session log, browser plugin graph, and child navigation. It collapses and reopens live run and phase controls, records the live collapsed status summary and ARIA state, verifies normal settlement folds both levels, confirms terminal review cannot navigate the member, and records the folded history reconstructed after reload.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep one manual state initialized from the first render.** Rejected because later lifecycle updates cannot reopen newly active or abnormal content and cannot fold normally settled work.
|
||||
**Force every running or abnormal level open as a static row.** Rejected because it makes the attention state impossible to dismiss and removes truthful mouse, keyboard, and ARIA disclosure semantics.
|
||||
|
||||
**Derive `open` directly from whether a level is clean.** Rejected because completed history would remain permanently closed and could not be reopened for review.
|
||||
**Keep one manual state initialized from the first render.** Rejected because later activity, abnormal escalation, and normal completion cannot perform their one-time automatic actions.
|
||||
|
||||
**Persist expansion, acknowledgement, or read state.** Rejected because current lifecycle facts already determine mandatory visibility, while review choice belongs only to the mounted presentation. Persistence would add a second state owner and require semantics for stale choices, abnormal acknowledgement, replay, and synchronization that the user result does not need.
|
||||
**Let each phase own state inside its disclosure content.** Rejected because hiding the outer run unmounts that content and discards independent phase choices during the same mounted workflow record.
|
||||
|
||||
**Persist expansion, acknowledgement, or an activity epoch.** Rejected because current workflow facts and the append-only member count provide every required edge. Persistence adds a second durable owner and synchronization semantics that this presentation choice does not need.
|
||||
|
||||
## Consequences
|
||||
|
||||
Workflow records expose current work and abnormal outcomes without preparatory clicks, then reclaim conversation space after normal completion without sacrificing review. Interaction semantics remain truthful during automatic control, and the same durable record produces the same initial state during live rendering, refresh, and history reconstruction.
|
||||
Workflow records call attention to lifecycle changes while remaining dismissible in every status. Normal completion reclaims space, current focus remains safe, nested phase choices survive outer hiding, and the same durable record reconstructs a deterministic initial state on refresh or history replay.
|
||||
|
||||
The trade-off is deliberate local reset behavior. A phase choice disappears when its parent workflow closes or the component unmounts, and abnormal records cannot be manually hidden because the product has no acknowledgement state. Supporting either behavior later requires a separate ownership and persistence decision rather than extending this local lifecycle implicitly.
|
||||
The local lifecycle deliberately resets on renderer remount and cannot remember a choice across refresh, devices, or users. Adding that behavior requires a separate persistence and stale-choice decision rather than extending this presentation state implicitly.
|
||||
+16
-14
@@ -6,38 +6,40 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
持久工作流 Chat 节点会在同一位置从运行前缀更新为终态记录。只在挂载时初始化的 disclosure 选择可能隐藏新开始运行的阶段,让已完成工作继续占据对话空间,或者把失败、已取消或已中断成员埋在两层折叠内容之后。若只把开合状态作为完成状态的纯派生结果,虽然能避免这些问题,却也会阻止用户重新打开干净历史进行复盘。
|
||||
持久工作流 Chat 节点会在同一位置从运行前缀更新为终态记录。renderer 必须提示新工作、异常结果和正常完成,同时不能在普通更新中反复覆盖用户回收对话空间的选择。
|
||||
|
||||
renderer 已经从工作流 Conversation Node 收到全部所需生命周期事实。因此,可见性需要一个组件本地生命周期:让当前执行与需注意状态优先,同时不增加另一项持久事实,也不取得工作流结果的所有权。
|
||||
renderer 已经从工作流 Conversation Node 收到全部持久生命周期事实。因此,disclosure 选择属于已挂载的展示层,但它的生命周期还必须在外层运行隐藏时保留嵌套 Phase 选择,并避免移除仍含键盘焦点的内容。
|
||||
|
||||
## 决策
|
||||
|
||||
每个阶段从当前成员派生一项可见性要求。存在运行中、失败、已取消或已中断成员时,该阶段强制展开;全部成员均已完成时,该阶段处于干净状态。工作流自身状态需要注意或任一阶段强制展开时,工作流也强制展开,因此即使工作流结果记录为已完成,异常成员仍保持可见。已完成的兄弟阶段继续可以独立折叠。
|
||||
`WorkflowRunPanel` 持有一项运行 disclosure 本地状态,以及一张按现有 phase key 索引的 Phase 状态表。全部成员都已完成时,Phase 为干净状态;任一成员失败、已取消或已中断时为异常状态;其余情况为运行状态。运行自身或任一 Phase 异常时,运行处于异常状态;运行自身或任一 Phase 正在运行时,运行处于运行状态;只有运行与全部 Phase 都正常完成时才处于干净状态。挂载时,运行和异常层级默认展开,干净层级默认折叠。
|
||||
|
||||
强制展开层级渲染为静态展开行。它不提供按钮 role、焦点目标、键盘切换或 `aria-expanded` 值,因为折叠操作无法改变结果。这样既保留视觉层级与状态摘要,也让交互承诺与实际可执行动作一致。
|
||||
每个层级记录当前模式、仅追加成员数、开合选择和待执行的干净折叠。Phase 从干净状态进入新活动时,该 Phase 与外层运行自动展开一次;连续运行或异常区间内的普通更新保留用户选择,首次进入异常状态时自动展开一次,进入干净状态时自动折叠一次。若新增成员及其正常完成在同一次渲染中送达,Phase 会保持干净但成员数改变;该变化会折叠已打开的 Phase 复盘,并在运行仍处于活动状态时自动展开外层运行一次,而无需增加 activity epoch 或持久字段。自动动作完成后,鼠标、Enter 和 Space 控制该层级,直到出现下一项约定边沿。
|
||||
|
||||
干净层级会以关闭状态挂载普通受控 disclosure。它的本地选择在同一段连续干净状态的 rerender 中保持。新的运行中或异常数据会用强制展开替代该手动区间;下一次回到干净状态时会挂载新的关闭 disclosure,从而让每个活动周期只自动折叠一次。关闭工作流会自然卸载其阶段控件;Session remount 会从当前持久状态重建每个层级,而不恢复更早的选择。
|
||||
外层 disclosure 隐藏子内容时,Phase 状态仍留在 `WorkflowRunPanel`,因此关闭并重新打开运行会恢复各 Phase 选择。Phase 被移除时,其表项同时清理;renderer remount 会从当前持久事实重建每个层级,而不恢复更早选择。
|
||||
|
||||
例如,运行中的工作流无需点击即可展示活跃阶段与成员。该阶段完成时,只有阶段折叠,工作流继续展开;工作流自身和全部阶段均完成时,工作流也会折叠。用户随后可以重新打开两个层级复盘。若同一阶段 key 下又开始新成员,受影响的两个层级会立即恢复强制展开,并且只在新活动完成后再次折叠。
|
||||
正常完成会在折叠前检查焦点是否位于内容内。仍含焦点的内容保持挂载并立即显示完成状态,焦点离开后再折叠。可导航成员的按钮持有焦点并变为终态时,`MemberRow` 会把同一个按钮以 `aria-disabled` 形式保留到 blur;之后的终态复盘渲染普通不可交互行。这样既保留当前 DOM 目标,也不允许终态导航,并且无需增加焦点管理器。
|
||||
|
||||
renderer 只拥有这项可见性生命周期。它不增加 Session 事件、store、设置、确认状态、计时器、焦点迁移、自动滚动或跨 remount 持久化。它不改变工作流状态派生、阶段分组、成员顺序、导航准入、文案或共享 `DisclosureRow` API。pointer 光标由共享的 `data-expandable` 样式拥有,因此强制展开的静态行不会提示无法执行的操作。持久记录中的中断前缀仍属于需注意状态,因此在底层事实改变前始终可见。
|
||||
renderer 不增加 Session 事件、store、设置、确认状态、计时器、自动滚动、持久活动身份或 `DisclosureRow` API。它不改变工作流状态派生、Phase 分组、成员顺序、导航准入、文案或视觉 token。
|
||||
|
||||
## 验证
|
||||
|
||||
组件测试驱动同一个 keyed 工作流与阶段依次经过运行、干净完成、手动复盘、新活动、再次干净完成、零成员完成以及每种异常状态。测试还验证异常成员向上展开、干净兄弟阶段独立、鼠标和键盘复盘、连续干净状态中的选择保持,以及强制展开时不存在虚假按钮和 ARIA 语义。
|
||||
组件测试驱动同一个 keyed 运行及其 Phase,覆盖初始运行控件、鼠标和键盘选择、普通运行更新、外层隐藏与恢复、Phase 完成、运行完成、干净复盘、同 key 新活动、同次渲染送达的完整干净周期、每种异常状态、首次异常升级、后续异常更新、零成员完成、成员持焦点时完成、兄弟 Phase 独立以及 renderer remount。测试还确认延后焦点路径结算后,终态导航仍不存在。
|
||||
|
||||
shipped Web 回放观察真实工作流、worker、Session 日志、浏览器插件图和子级导航。它要求实时工作流与活跃阶段无需 disclosure 控件即可见,正常结算的工作流与阶段会折叠,手动复盘仍能看到不再可导航的终态成员,并且刷新会从持久事实重建折叠历史。
|
||||
shipped Web 回放经过真实工作流、worker、Session 日志、浏览器插件图和子级导航。它折叠并重新打开实时运行与 Phase 控件,记录实时折叠标题的状态摘要和 ARIA 状态,验证正常结算会折叠两个层级,确认终态复盘不能导航成员,并记录刷新后从历史重建的折叠记录。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**保留一项从首次渲染初始化的手动状态。** 拒绝,因为后续生命周期更新无法重新打开新活动或异常内容,也无法折叠正常结算的工作。
|
||||
**把每个运行中或异常层级强制展开为静态行。** 拒绝,因为需注意状态将无法收起,也不会提供真实的鼠标、键盘和 ARIA disclosure 语义。
|
||||
|
||||
**只根据层级是否干净来派生 `open`。** 拒绝,因为已完成历史会永久保持关闭,无法重新打开复盘。
|
||||
**保留一项从首次渲染初始化的手动状态。** 拒绝,因为后续活动、异常升级和正常完成无法执行各自的一次性自动动作。
|
||||
|
||||
**持久化展开、确认或已读状态。** 拒绝,因为当前生命周期事实已经决定强制可见性,而复盘选择只属于已挂载的展示层。持久化会增加第二个状态归属方,并要求定义陈旧选择、异常确认、回放和同步语义,而用户结果不需要这些机制。
|
||||
**让每个 Phase 在自身 disclosure 内容中持有状态。** 拒绝,因为隐藏外层运行会卸载这些内容,并在同一条已挂载工作流记录中丢失独立的 Phase 选择。
|
||||
|
||||
**持久化展开、确认或 activity epoch。** 拒绝,因为当前工作流事实与仅追加成员数已经提供全部所需边沿。持久化会增加第二个持久归属方以及本展示选择不需要的同步语义。
|
||||
|
||||
## 后果
|
||||
|
||||
工作流记录无需预备点击即可展示当前工作与异常结果,并在正常完成后回收对话空间,同时不牺牲复盘能力。自动控制期间的交互语义保持真实,同一份持久记录在实时渲染、刷新和历史重建时得到相同初始状态。
|
||||
工作流记录会提示生命周期变化,同时在所有状态下都允许用户收起。正常完成会回收空间,当前焦点保持安全,嵌套 Phase 选择在外层隐藏期间保留;同一份持久记录在刷新或历史回放时会重建确定性的初始状态。
|
||||
|
||||
代价是有意保留的本地重置行为。父工作流关闭或组件卸载时,阶段选择会消失;由于产品没有确认状态,异常记录不能手动隐藏。以后若要支持任一行为,需要单独决定所有权与持久化,而不能隐式扩展这项本地生命周期。
|
||||
这项本地生命周期会在 renderer remount 时重置,无法跨刷新、设备或用户记住选择。若要增加该行为,需要单独决定持久化与陈旧选择语义,而不能隐式扩展这项展示状态。
|
||||
@@ -1,6 +1,6 @@
|
||||
// Boots the shipped Web composition over the built dist this lane already uses
|
||||
// and asserts what that composition produces: the model-visible tool catalog
|
||||
// and file-reference guidance plus the sandbox/approval knobs it ships with.
|
||||
// and file-reference guidance plus its retry, sandbox, and approval defaults.
|
||||
// No browser and no model call — these are composition facts, and the browser
|
||||
// scenarios in this lane cover the surface itself.
|
||||
import { readFileSync } from 'node:fs'
|
||||
@@ -10,6 +10,7 @@ import { afterEach, expect, it } from 'vitest'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
// Empty type imports carry the tools/sandboxPolicy/approval Context merges.
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
@@ -73,9 +74,66 @@ afterEach(async () => {
|
||||
scaffold = undefined
|
||||
})
|
||||
|
||||
it('assembles the shipped Web catalog, file-reference guidance, and confined access default', async () => {
|
||||
scaffold = await launchWebScaffold()
|
||||
it('assembles the shipped Web catalog, file-reference guidance, retry policy, and confined access default', async () => {
|
||||
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
|
||||
const ctx = scaffold.ctx
|
||||
expect(ctx.llm.providerRetryPolicy('deepseek-official')).toMatchInlineSnapshot(`
|
||||
{
|
||||
"initialDelayMs": 500,
|
||||
"jitterRatio": 0.1,
|
||||
"maxDelayMs": 10000,
|
||||
"maxRetries": 5,
|
||||
"mode": "normal",
|
||||
"retryableCodes": [
|
||||
"EMPTY_RESPONSE",
|
||||
"RATE_LIMIT",
|
||||
"SERVER",
|
||||
"TIMEOUT",
|
||||
"TRANSPORT",
|
||||
],
|
||||
}
|
||||
`)
|
||||
await ctx.settings.update(settingsNamespace('llm-deepseek'), {
|
||||
retryPolicy: { mode: 'always', maxRetries: 5 },
|
||||
})
|
||||
expect(ctx.llm.providerRetryPolicy('deepseek-official')).toMatchInlineSnapshot(`
|
||||
{
|
||||
"initialDelayMs": 500,
|
||||
"jitterRatio": 0.1,
|
||||
"maxDelayMs": 10000,
|
||||
"mode": "always",
|
||||
}
|
||||
`)
|
||||
await ctx.settings.update(settingsNamespace('llm-pi-ai'), {
|
||||
providers: {
|
||||
openai: {},
|
||||
anthropic: { retryPolicy: { mode: 'always' } },
|
||||
},
|
||||
})
|
||||
expect(ctx.llm.providerRetryPolicy('openai')).toMatchInlineSnapshot(`
|
||||
{
|
||||
"initialDelayMs": 500,
|
||||
"jitterRatio": 0.1,
|
||||
"maxDelayMs": 10000,
|
||||
"maxRetries": 5,
|
||||
"mode": "normal",
|
||||
"retryableCodes": [
|
||||
"EMPTY_RESPONSE",
|
||||
"RATE_LIMIT",
|
||||
"SERVER",
|
||||
"TIMEOUT",
|
||||
"TRANSPORT",
|
||||
],
|
||||
}
|
||||
`)
|
||||
expect(ctx.llm.providerRetryPolicy('anthropic')).toMatchInlineSnapshot(`
|
||||
{
|
||||
"initialDelayMs": 500,
|
||||
"jitterRatio": 0.1,
|
||||
"maxDelayMs": 10000,
|
||||
"mode": "always",
|
||||
}
|
||||
`)
|
||||
// The catalog belongs to an AGENT, not to the process: every model-facing row
|
||||
// now lives in a preset mounted under one session's scope, so the global
|
||||
// layer holds nothing and a caller must name the agent to see anything. This
|
||||
|
||||
@@ -372,7 +372,7 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
maxRetries: 5,
|
||||
failure: { code: 'TRANSPORT' },
|
||||
})
|
||||
expect(JSON.stringify(page.events)).toContain('WEB_RETRY_DISCARDED')
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
- img
|
||||
- text: Context injection @deepseek-ai/dsh-system-prompt
|
||||
- group:
|
||||
- status: Retried model request (1/2) · {{duration}}
|
||||
- status: Retried model request (1/5) · {{duration}}
|
||||
- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.":
|
||||
- img
|
||||
- img
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
- button "snapshot-flow 1 member Running" [expanded]:
|
||||
- img
|
||||
- text: snapshot-flow 1 member Running
|
||||
- button "Run 1 member Running 1":
|
||||
- img
|
||||
- text: Run 1 member Running 1
|
||||
@@ -13,13 +13,9 @@
|
||||
- img
|
||||
- img
|
||||
- text: Tool call workflow ·
|
||||
- button "snapshot-flow 1 member Completed" [expanded]:
|
||||
- button "snapshot-flow 1 member Completed":
|
||||
- img
|
||||
- text: snapshot-flow 1 member Completed
|
||||
- button "Run 1 member Completed 1" [expanded]:
|
||||
- img
|
||||
- text: Run 1 member Completed 1
|
||||
- text: Reply with exactly the word WF_CHILD_OK and not… Completed
|
||||
- button "Think The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop.":
|
||||
- img
|
||||
- img
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Cold-boot RPC budget. The describe mirror (packages/client/ui-settings) is
|
||||
// the one `settings.describe` reader in the browser, so startup describe
|
||||
// traffic stays bounded no matter how many client plugins own a preference.
|
||||
// A regression here means a consumer bypassed the mirror — grep for
|
||||
// `settings.describe(` outside ui-settings' client sources.
|
||||
//
|
||||
// Zero model calls: the lane only boots chrome, so no replay fixture mounts.
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
|
||||
import { newEnglishPage } from './support.ts'
|
||||
|
||||
/**
|
||||
* Both reads are the mirror's: once eagerly at bind time over HTTP, and once
|
||||
* on the first-connection reset — that second read closes the window where a
|
||||
* document commit lands between the eager read and the SSE subscription and
|
||||
* its invalidation is lost. Every settings consumer derives from these two.
|
||||
*/
|
||||
const DESCRIBE_BUDGET = 2
|
||||
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold()
|
||||
browser = await chromium.launch()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await page?.close()
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
describe('startup RPC budget', () => {
|
||||
it('keeps cold-boot settings.describe at the mirror count', async () => {
|
||||
page = await newEnglishPage(browser)
|
||||
watchConsole(page)
|
||||
const calls: string[] = []
|
||||
page.on('request', (request) => {
|
||||
const url = new URL(request.url())
|
||||
if (url.pathname.startsWith('/api/')) calls.push(url.pathname.slice('/api/'.length))
|
||||
})
|
||||
await page.goto(scaffold.baseUrl)
|
||||
// Boot settles when the workspace picker is interactive; the trailing wait
|
||||
// absorbs the first-connection reset wave the budget must include.
|
||||
await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: 30_000 })
|
||||
await page.waitForTimeout(3000)
|
||||
const describeCount = calls.filter(method => method === 'settings.describe').length
|
||||
expect(describeCount, `startup /api calls:\n${calls.join('\n')}`).toBe(DESCRIBE_BUDGET)
|
||||
})
|
||||
})
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
|
||||
const MODE = webSnapshotMode()
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/workflow-run', import.meta.url))
|
||||
const UI_LIVE_EXPECTED = join(SNAPSHOT_DIR, 'ui-live.expected.md')
|
||||
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
|
||||
const PARENT_FIXTURE = join(REPO_ROOT, 'examples/acp-agent/tests/snapshots/workflow-run/session.jsonl')
|
||||
const CHILD_FIXTURE = join(REPO_ROOT, 'examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl')
|
||||
@@ -52,7 +53,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
|
||||
scaffold = await launchWebScaffold({
|
||||
replayFixture: PARENT_FIXTURE,
|
||||
replayChildFixtures: [CHILD_FIXTURE],
|
||||
paceMs: 25,
|
||||
paceMs: 50,
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
@@ -78,14 +79,30 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
|
||||
await workflow.waitFor({ timeout: 30_000 })
|
||||
const disclosures = workflow.locator('[data-disclosure-row]')
|
||||
await disclosures.nth(1).waitFor({ timeout: 15_000 })
|
||||
expect(await disclosures.nth(0).getAttribute('role')).toBeNull()
|
||||
expect(await disclosures.nth(0).getAttribute('aria-expanded')).toBeNull()
|
||||
expect(await disclosures.nth(1).getAttribute('role')).toBeNull()
|
||||
expect(await disclosures.nth(1).getAttribute('aria-expanded')).toBeNull()
|
||||
expect(await disclosures.nth(0).evaluate(element => getComputedStyle(element).cursor)).not.toBe('pointer')
|
||||
expect(await disclosures.nth(1).evaluate(element => getComputedStyle(element).cursor)).not.toBe('pointer')
|
||||
const runDisclosure = disclosures.nth(0)
|
||||
const phaseDisclosure = disclosures.nth(1)
|
||||
expect(await runDisclosure.getAttribute('role')).toBe('button')
|
||||
expect(await runDisclosure.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(await phaseDisclosure.getAttribute('role')).toBe('button')
|
||||
expect(await phaseDisclosure.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(await runDisclosure.evaluate(element => getComputedStyle(element).cursor)).toBe('pointer')
|
||||
expect(await phaseDisclosure.evaluate(element => getComputedStyle(element).cursor)).toBe('pointer')
|
||||
const member = page.getByRole('button', { name: /^Open Reply with exactly the word/ })
|
||||
await member.waitFor({ timeout: 15_000 })
|
||||
|
||||
await phaseDisclosure.click()
|
||||
expect(await phaseDisclosure.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(await member.count()).toBe(0)
|
||||
const liveSnapshot = await captureStableAria(page, '[data-workflow-run]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_LIVE_EXPECTED, liveSnapshot, MODE)
|
||||
await phaseDisclosure.press('Enter')
|
||||
await member.waitFor()
|
||||
await runDisclosure.click()
|
||||
expect(await runDisclosure.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(await disclosures.count()).toBe(1)
|
||||
await runDisclosure.press('Space')
|
||||
expect(await disclosures.count()).toBe(2)
|
||||
expect(await phaseDisclosure.getAttribute('aria-expanded')).toBe('true')
|
||||
await member.focus()
|
||||
|
||||
const lightColor = await member.locator('[data-member-label]').evaluate(element => getComputedStyle(element).color)
|
||||
@@ -171,6 +188,8 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
|
||||
const workflow = page.getByRole('button', { name: /^snapshot-flow/ })
|
||||
await workflow.waitFor({ timeout: 15_000 })
|
||||
expect(await workflow.getAttribute('aria-expanded')).toBe('false')
|
||||
const snapshot = await captureStableAria(page, '[data-chat-flow]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
await workflow.click()
|
||||
const phase = page.getByRole('button', { name: /^Run/ })
|
||||
await phase.waitFor()
|
||||
@@ -179,13 +198,11 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
|
||||
await page.getByText(CHILD_PROMPT, { exact: false }).waitFor()
|
||||
expect(await page.getByRole('button', { name: /^Open Reply with exactly the word/ }).count()).toBe(0)
|
||||
|
||||
const snapshot = await captureStableAria(page, '[data-chat-flow]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
}, 60_000)
|
||||
|
||||
it('stays clean and owns only its one golden', async () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui-live.expected.md', 'ui.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -24,6 +24,7 @@
|
||||
"exclude": [
|
||||
"tests/scaffold.ts",
|
||||
"tests/scaffold-hermetic.e2e.ts",
|
||||
"tests/startup-rpc-budget.e2e.ts",
|
||||
"tests/minimal-preset.snapshot.ts",
|
||||
"tests/message-feedback-protocol.snapshot.ts",
|
||||
"tests/live-interactions.e2e.ts",
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/config-catalog.md
|
||||
config-catalog.md: 0d4b713bac34ebaa62d3e90edc565c55df8948c5
|
||||
config-catalog.zh.md: 71c1369acbc3fbfd6c821f6b6fda4c8b15a9e840
|
||||
config-catalog.md: c379a7a49e4aa670aac3aa203e216b2be8e1955d
|
||||
config-catalog.zh.md: e897f5d25a485133d4929061dce0b398edfa8c04
|
||||
@@ -870,7 +870,7 @@ export interface Config {
|
||||
models?: DeepSeekCatalogModel[]
|
||||
/** Maximum provider idle time while one stream read is outstanding (default five minutes). */
|
||||
streamIdleTimeoutMs?: number
|
||||
/** Provider-owned model-request retry policy; omission uses normal defaults. */
|
||||
/** Provider-owned model-request retry policy; omission uses normal mode with five retries. */
|
||||
retryPolicy?: RetryPolicyConfig
|
||||
}
|
||||
|
||||
@@ -992,7 +992,7 @@ export interface PiAiProviderProfile {
|
||||
* requests instead of being rejected by a request-size cap.
|
||||
*/
|
||||
maxRequestImageBytes?: number
|
||||
/** Provider-owned model-request retry policy; omission uses normal defaults. */
|
||||
/** Provider-owned model-request retry policy; omission uses normal mode with five retries. */
|
||||
retryPolicy?: RetryPolicyConfig
|
||||
}
|
||||
|
||||
|
||||
@@ -872,7 +872,7 @@ export interface Config {
|
||||
models?: DeepSeekCatalogModel[]
|
||||
/** Maximum provider idle time while one stream read is outstanding (default five minutes). */
|
||||
streamIdleTimeoutMs?: number
|
||||
/** Provider-owned model-request retry policy; omission uses normal defaults. */
|
||||
/** Provider-owned model-request retry policy; omission uses normal mode with five retries. */
|
||||
retryPolicy?: RetryPolicyConfig
|
||||
}
|
||||
|
||||
@@ -994,7 +994,7 @@ export interface PiAiProviderProfile {
|
||||
* requests instead of being rejected by a request-size cap.
|
||||
*/
|
||||
maxRequestImageBytes?: number
|
||||
/** Provider-owned model-request retry policy; omission uses normal defaults. */
|
||||
/** Provider-owned model-request retry policy; omission uses normal mode with five retries. */
|
||||
retryPolicy?: RetryPolicyConfig
|
||||
}
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md
|
||||
llm-streaming.md: 7c0e0865f8dcc0e7722bb2205d0129d9e0ca3086
|
||||
llm-streaming.zh.md: 5c31909ee79137c6c5eef101235b43a2419b1339
|
||||
llm-streaming.md: c1b2ab5f1e0926864f25409c021691d078df9e0f
|
||||
llm-streaming.zh.md: 7bf04bf7a4a7de63d20eb67461842ff49f3b189d
|
||||
@@ -240,7 +240,7 @@ Every adapter MUST obey these, and every consumer may rely on them:
|
||||
|
||||
## `ResolvedRetryPolicy`
|
||||
|
||||
Provider configuration resolves before route registration into an immutable discriminated union. Normal mode carries `mode: 'normal'`, finite `maxRetries`, `retryableCodes`, and required `initialDelayMs`, `maxDelayMs`, and `jitterRatio`; always mode carries `mode: 'always'` and the same required backoff fields without a finite maximum. `LlmRuntime.providerRetryPolicy(provider)` returns the currently registered value and supplies normal defaults when the adapter omits one; `llmRetryPolicyOf(stream)` returns the value captured from the serving registration after the call selects that registration, so later route disposal or replacement cannot change an in-flight failure's recovery policy. The [generated config catalog](../config-catalog.md) lists the optional input fields.
|
||||
Retry configuration resolves before route registration into an immutable discriminated union. Normal mode carries `mode: 'normal'`, finite `maxRetries`, `retryableCodes`, and required `initialDelayMs`, `maxDelayMs`, and `jitterRatio`; always mode carries `mode: 'always'` and the same required backoff fields without a finite maximum. Omitting a provider policy uses the normal default of five retries. Layered settings may retain normal-only `maxRetries` or `retryableCodes` after switching to always mode; the resolver ignores those inactive fields and captures the pure always policy. `LlmRuntime.providerRetryPolicy(provider)` returns the registered value, and `llmRetryPolicyOf(stream)` returns the value captured from the serving registration after the call selects it, so later route disposal or replacement cannot change an in-flight failure's recovery policy. The [generated config catalog](../config-catalog.md) lists the optional input fields.
|
||||
|
||||
## `AppIdentity` — app attribution
|
||||
|
||||
|
||||
@@ -242,7 +242,7 @@ interface LlmFailure {
|
||||
|
||||
## `ResolvedRetryPolicy`
|
||||
|
||||
提供方配置会在路由注册前解析为不可变的可辨识联合。normal mode 携带 `mode: 'normal'`、有限的 `maxRetries`、`retryableCodes`,以及必填的 `initialDelayMs`、`maxDelayMs` 与 `jitterRatio`;always mode 携带 `mode: 'always'` 和相同的必填退避字段,但没有有限上限。`LlmRuntime.providerRetryPolicy(provider)` 返回当前注册的值,并在适配器省略策略时提供 normal 默认值;调用选定该注册后,`llmRetryPolicyOf(stream)` 返回为该调用服务的注册所捕获的值,因此之后释放或替换路由都无法改变进行中失败的恢复策略。可选配置输入字段由[生成的配置目录](../config-catalog.md)列出。
|
||||
重试配置会在路由注册前解析为不可变的可辨识联合。normal mode 携带 `mode: 'normal'`、有限的 `maxRetries`、`retryableCodes`,以及必填的 `initialDelayMs`、`maxDelayMs` 与 `jitterRatio`;always mode 携带 `mode: 'always'` 和相同的必填退避字段,但没有有限上限。省略提供方策略时使用重试五次的 normal 默认值。分层 settings 在切换到 always 模式后可能保留仅属于 normal 的 `maxRetries` 或 `retryableCodes`;解析器会忽略这些未启用字段,并捕获纯 always 策略。`LlmRuntime.providerRetryPolicy(provider)` 返回注册值;调用选定实际提供服务的注册后,`llmRetryPolicyOf(stream)` 返回从中捕获的值,因此之后释放或替换路由都无法改变进行中失败的恢复策略。可选配置输入字段由[生成的配置目录](../config-catalog.md)列出。
|
||||
|
||||
## `AppIdentity`:应用归属
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md
|
||||
README.md: 90d1566b5a7a25f6a827079c4a8ab776e05cc7a3
|
||||
README.zh.md: b7156bcab66b53964bb2f0e1c87d9e9a806e3f75
|
||||
README.md: 28fb5b3dcfc7fbb912493a6b97495e2ed5a3eece
|
||||
README.zh.md: 92157f05497e53c48506666a3a53d638d98a7c9e
|
||||
@@ -4,6 +4,10 @@ English | [中文](README.zh.md)
|
||||
|
||||
The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, the always-on client-plugin reload chain ([`dsh-client-hmr`](../../client/hmr/README.md), idle until a rebuild watcher rewrites client bundles), and mounts this package's `web-runtime` glue plugin (config `{printUrl, surfaceContext, trustedHosts}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-web-frontend`'s exports, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL` runtime variable when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true, after its Loader tree settles so a sibling failure cannot announce a dead app. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, repeatable `--trusted-host`, and the app's `--help`, then provides `webStartup`. It rejects `--host 0.0.0.0` before publishing that service because the CLI intentionally does not support all-interfaces binding yet. Flag-configured rows inject the service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle.
|
||||
|
||||
## Model retry defaults
|
||||
|
||||
Web uses the shared bounded normal default of five eligible retries after the initial request. The `deepseek-official` route and settings-added pi-ai routes use that default when they omit `retryPolicy`; explicit provider policies still win. Web adds no retry-specific composition override, so the same omission behavior applies to non-Web profiles.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Harness-source and Web-surface context
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)、浏览器插件名录与始终挂载的客户端插件重载链([`dsh-client-hmr`](../../client/hmr/README.md),在重建 watcher 改写客户端 bundle 之前保持空闲),并挂载本包的 `web-runtime` 粘合插件(配置为 `{printUrl, surfaceContext, trustedHosts}`)。该插件通过 `@deepseek-ai/dsh-web-frontend` 的 exports 解析已构建的前端 dist,只采样一次依赖 bind 的 LAN 信任信息并将其作为 `webRuntime` 提供给浏览器信任栅栏和客户端名录,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL` 运行时变量,并在 `printUrl` 为 true 时等自身的 Loader 配置树结算后再打印 `dsh web:` URL 行,避免兄弟行失败时公告一个已失效的应用。本组合包还持有应用命令行:普通 `web-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),解析 `--host`、`--port`、可重复的 `--trusted-host` 以及应用自己的 `--help`,再提供 `webStartup`。它会在发布该服务前拒绝 `--host 0.0.0.0`,因为 CLI 目前有意不支持绑定所有网络接口。由 flag 配置的行会注入该服务,并在惰性配置中直接读取它,因此参数解析完成前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。
|
||||
|
||||
## 模型重试默认值
|
||||
|
||||
Web 使用共享的有界 normal 默认值,在首次请求后最多再重试五次符合条件的失败。`deepseek-official` 与由 settings 新增的 pi-ai 路由在省略 `retryPolicy` 时使用该默认值;显式提供方策略仍然优先。Web 不再增加重试专用的组合覆盖,因此非 Web profile 的省略行为与之相同。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### Harness 源码与 Web 表层上下文
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts'
|
||||
import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-scope.ts'
|
||||
import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import {
|
||||
apply, inject, SETTINGS_NS,
|
||||
@@ -48,7 +47,7 @@ async function bench() {
|
||||
ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback: true } as never)
|
||||
// The settings transport and the forwarded-event port the plugin injects.
|
||||
new TestRemote(ctx)
|
||||
await ctx.plugin(SettingsScopeBinder, new SettingsSchemaService(ctx)).await()
|
||||
await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
|
||||
return {
|
||||
ctx, slots: ctx.get('slots') as SlotRegistry, describe, mutate,
|
||||
setHostPreference: (next: string | undefined) => { preference = next; revision += 1 },
|
||||
@@ -134,7 +133,10 @@ describe('locale apply', () => {
|
||||
|
||||
it('loads and refreshes the explicit Host preference after nonblocking activation', async () => {
|
||||
const b = await bench()
|
||||
// The shared mirror read once at bench time; a Host-side change reaches it
|
||||
// through the document invalidation, exactly as production announces one.
|
||||
b.setHostPreference('en')
|
||||
b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0])
|
||||
declareItems(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const locale = b.ctx.get('locale') as LocaleRuntime
|
||||
@@ -145,7 +147,7 @@ describe('locale apply', () => {
|
||||
b.setHostPreference('en')
|
||||
b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0])
|
||||
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') })
|
||||
expect(b.describe).toHaveBeenCalledTimes(3)
|
||||
expect(b.describe).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => {
|
||||
|
||||
@@ -46,7 +46,7 @@ export type { AgentPresetOption, AgentPresetSettingsState } from './settings-sto
|
||||
export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.ts'
|
||||
|
||||
/** Required services (cordis fiber inject). */
|
||||
export const inject = ['slots', 'locale', 'connection', 'remote']
|
||||
export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope']
|
||||
|
||||
/**
|
||||
* Mount the General-settings row.
|
||||
@@ -54,7 +54,7 @@ export const inject = ['slots', 'locale', 'connection', 'remote']
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const { api } = ctx.get('connection') as ConnectionHandle
|
||||
const controller = new AgentPresetSettingsController(api)
|
||||
const controller = new AgentPresetSettingsController(api, ctx.settingsScope.describe())
|
||||
// One roster, four surfaces. The chip is registered in a later scope, so it
|
||||
// subscribes here rather than being reached from this one.
|
||||
const rosterReaders = new Set<() => void>()
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
|
||||
/** The agent-preset settings namespace on the host wire. */
|
||||
export const AGENT_PRESET_SETTINGS_NS = 'agent-presets'
|
||||
@@ -191,7 +192,14 @@ export class AgentPresetSettingsController {
|
||||
/** Row snapshot the renderer subscribes to. */
|
||||
readonly store: SnapshotStore<AgentPresetSettingsState> = createSnapshotStore(INITIAL)
|
||||
|
||||
constructor(private readonly api: IApiClient) {}
|
||||
/**
|
||||
* @param api - the agent-preset and settings wire faces (roster and default write).
|
||||
* @param describeFace - the shared mirror's describe face (writability source).
|
||||
*/
|
||||
constructor(
|
||||
private readonly api: IApiClient,
|
||||
private readonly describeFace: SettingsDescribeFace,
|
||||
) {}
|
||||
|
||||
private set(patch: Partial<AgentPresetSettingsState>): void {
|
||||
this.store.set({ ...this.store.getSnapshot(), ...patch })
|
||||
@@ -212,24 +220,20 @@ export class AgentPresetSettingsController {
|
||||
this.set({ status: 'unavailable', options: [], currentValue: '' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
// The roster says what may be chosen; `settings.describe` says whether
|
||||
// this browser may write the choice down. A non-loopback browser reaches
|
||||
// neither method, so a refused describe leaves the row read-only rather
|
||||
// than offering a control whose write the Host would refuse.
|
||||
const described = await this.api.settings.describe({})
|
||||
this.set({
|
||||
status: 'ready',
|
||||
error: null,
|
||||
writable: described.result.ok && described.result.value.writable,
|
||||
options: presetOptions(presets),
|
||||
// A roster can mark nothing default: settings can name a preset that
|
||||
// was since deleted, and the picker still has to show something.
|
||||
currentValue: presets.find(preset => preset.isDefault)?.id ?? first.id,
|
||||
})
|
||||
} catch (error) {
|
||||
this.set({ status: 'error', error: messageOf(error) })
|
||||
}
|
||||
// The roster says what may be chosen; the shared mirror says whether this
|
||||
// browser may write the choice down. A non-loopback browser's mirror never
|
||||
// answers, so the row stays read-only rather than offering a control
|
||||
// whose write the Host would refuse.
|
||||
await this.describeFace.ensure()
|
||||
this.set({
|
||||
status: 'ready',
|
||||
error: null,
|
||||
writable: this.describeFace.getSnapshot().view?.writable ?? false,
|
||||
options: presetOptions(presets),
|
||||
// A roster can mark nothing default: settings can name a preset that
|
||||
// was since deleted, and the picker still has to show something.
|
||||
currentValue: presets.find(preset => preset.isDefault)?.id ?? first.id,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-agent-preset/client'
|
||||
import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx'
|
||||
import type { AgentPresetLabelInjected } from '../src/client/AgentPresetLabel.tsx'
|
||||
@@ -117,6 +118,7 @@ async function bench() {
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
|
||||
return { ctx, slots: ctx.get('slots') as SlotRegistry, calls, moveDefault }
|
||||
}
|
||||
|
||||
@@ -178,7 +180,7 @@ function sessionsDouble(state: {
|
||||
|
||||
describe('ui-agent-preset apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope'])
|
||||
})
|
||||
|
||||
it('registers the General row and the settings section', async () => {
|
||||
|
||||
@@ -7,9 +7,15 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
|
||||
import {
|
||||
AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, messageOf,
|
||||
} from '../src/client/settings-store.ts'
|
||||
|
||||
/** Controller over a real mirror derived from the same fake wire. */
|
||||
function derivedController(api: IApiClient) {
|
||||
return new AgentPresetSettingsController(api, new SettingsDescribeMirror(api))
|
||||
}
|
||||
import { AgentPresetSeatController } from '../src/client/seat-store.ts'
|
||||
import type { SeatSessionSummary } from '../src/client/seat-store.ts'
|
||||
|
||||
@@ -60,7 +66,7 @@ function fakeApi(
|
||||
|
||||
describe('the agent-preset settings controller', () => {
|
||||
it('disables the control when this browser may not write settings', async () => {
|
||||
const controller = new AgentPresetSettingsController(fakeApi([
|
||||
const controller = derivedController(fakeApi([
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
], { readOnly: true }))
|
||||
|
||||
@@ -74,7 +80,7 @@ describe('the agent-preset settings controller', () => {
|
||||
})
|
||||
|
||||
it('derives options and the current default from one roster call', async () => {
|
||||
const controller = new AgentPresetSettingsController(fakeApi([
|
||||
const controller = derivedController(fakeApi([
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'mine', trust: 'user', isDefault: false },
|
||||
]))
|
||||
@@ -91,7 +97,7 @@ describe('the agent-preset settings controller', () => {
|
||||
})
|
||||
|
||||
it('offers no broken preset: the pickers choose the NEXT session\'s composition', async () => {
|
||||
const controller = new AgentPresetSettingsController(fakeApi([
|
||||
const controller = derivedController(fakeApi([
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'damaged', trust: 'user', isDefault: false, broken: 'the composition is not valid YAML' },
|
||||
] as never))
|
||||
@@ -105,7 +111,7 @@ describe('the agent-preset settings controller', () => {
|
||||
})
|
||||
|
||||
it('carries the display metadata a preset published', async () => {
|
||||
const controller = new AgentPresetSettingsController(fakeApi([
|
||||
const controller = derivedController(fakeApi([
|
||||
{ id: 'standard', trust: 'system', isDefault: true, name: '标准模式', description: '完整的编码 agent。' },
|
||||
] as never))
|
||||
|
||||
@@ -119,7 +125,7 @@ describe('the agent-preset settings controller', () => {
|
||||
})
|
||||
|
||||
it('reports an empty roster as unavailable, not as an error', async () => {
|
||||
const controller = new AgentPresetSettingsController(fakeApi([]))
|
||||
const controller = derivedController(fakeApi([]))
|
||||
|
||||
await controller.load()
|
||||
|
||||
@@ -131,7 +137,7 @@ describe('the agent-preset settings controller', () => {
|
||||
|
||||
it('writes only the default field, into the agent-presets namespace', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = new AgentPresetSettingsController(fakeApi([
|
||||
const controller = derivedController(fakeApi([
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'minimal', trust: 'system', isDefault: false },
|
||||
], { writes }))
|
||||
@@ -144,7 +150,7 @@ describe('the agent-preset settings controller', () => {
|
||||
})
|
||||
|
||||
it('restores the previous value and surfaces the message when the write fails', async () => {
|
||||
const controller = new AgentPresetSettingsController(fakeApi([
|
||||
const controller = derivedController(fakeApi([
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'minimal', trust: 'system', isDefault: false },
|
||||
], { failWrite: 'read-only settings' }))
|
||||
@@ -160,7 +166,7 @@ describe('the agent-preset settings controller', () => {
|
||||
|
||||
it('ignores a pick that is already the default', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = new AgentPresetSettingsController(fakeApi([
|
||||
const controller = derivedController(fakeApi([
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
], { writes }))
|
||||
await controller.load()
|
||||
@@ -171,7 +177,7 @@ describe('the agent-preset settings controller', () => {
|
||||
})
|
||||
|
||||
it('surfaces a roster failure without claiming the deployment has no presets', async () => {
|
||||
const controller = new AgentPresetSettingsController(fakeApi([], { failList: 'host down' }))
|
||||
const controller = derivedController(fakeApi([], { failList: 'host down' }))
|
||||
|
||||
await controller.load()
|
||||
|
||||
@@ -183,7 +189,7 @@ describe('the agent-preset settings controller', () => {
|
||||
it('shows the first preset when the roster marks none default', async () => {
|
||||
// Settings can name a preset that was since deleted; the picker still has
|
||||
// to show something rather than an empty control.
|
||||
const controller = new AgentPresetSettingsController(fakeApi([
|
||||
const controller = derivedController(fakeApi([
|
||||
{ id: 'standard', trust: 'system', isDefault: false },
|
||||
{ id: 'mine', trust: 'user', isDefault: false },
|
||||
]))
|
||||
@@ -195,7 +201,7 @@ describe('the agent-preset settings controller', () => {
|
||||
|
||||
it('ignores a load while one is already in flight', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = new AgentPresetSettingsController(fakeApi(
|
||||
const controller = derivedController(fakeApi(
|
||||
[{ id: 'standard', trust: 'system', isDefault: true }], { writes }))
|
||||
|
||||
await Promise.all([controller.load(), controller.load()])
|
||||
@@ -211,7 +217,7 @@ describe('the agent-preset settings controller', () => {
|
||||
})
|
||||
|
||||
it('reports a transport that rejects rather than answering', async () => {
|
||||
const controller = new AgentPresetSettingsController({
|
||||
const controller = derivedController({
|
||||
agentPresets: { list: () => Promise.reject(new Error('socket closed')) },
|
||||
} as unknown as IApiClient)
|
||||
|
||||
@@ -221,7 +227,7 @@ describe('the agent-preset settings controller', () => {
|
||||
})
|
||||
|
||||
it('reports a transport that rejects mid-write and keeps the old default showing', async () => {
|
||||
const controller = new AgentPresetSettingsController(fakeApi([
|
||||
const controller = derivedController(fakeApi([
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'mine', trust: 'user', isDefault: false },
|
||||
], { failWriteWith: new Error('socket closed') }))
|
||||
@@ -434,7 +440,7 @@ describe('the new-session chip controller', () => {
|
||||
expect(controller.store.getSnapshot().error).toBe('socket closed')
|
||||
})
|
||||
|
||||
it('reports a refused describe as a failure rather than a half-read row', async () => {
|
||||
it('degrades to a read-only row while the mirror holds no answer', async () => {
|
||||
const api = {
|
||||
agentPresets: {
|
||||
list: () => Promise.resolve({
|
||||
@@ -442,16 +448,19 @@ describe('the new-session chip controller', () => {
|
||||
result: { ok: true as const, value: { presets: [{ id: 'standard', trust: 'system', isDefault: true }], authorable: true } },
|
||||
}),
|
||||
},
|
||||
// The roster answered; `settings.describe` is what rejected, and the row
|
||||
// cannot claim a writable default it never confirmed.
|
||||
// The roster answered; the mirror's read is what failed, so the row
|
||||
// shows the current default without offering a write it never confirmed.
|
||||
settings: { describe: () => Promise.reject(new Error('socket closed')) },
|
||||
} as unknown as IApiClient
|
||||
const controller = new AgentPresetSettingsController(api)
|
||||
const controller = derivedController(api)
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot().status).toBe('error')
|
||||
expect(controller.store.getSnapshot().error).toBe('socket closed')
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
status: 'ready',
|
||||
writable: false,
|
||||
currentValue: 'standard',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -33,9 +33,7 @@ import {
|
||||
import {
|
||||
displayPermissionPreset, FULL_ACCESS_PRESET,
|
||||
} from './presentation.ts'
|
||||
import {
|
||||
PERMISSION_SETTINGS_NS, PermissionPresetSettingsController, refreshPermissionIfLoaded,
|
||||
} from './settings-store.ts'
|
||||
import { PermissionPresetSettingsController } from './settings-store.ts'
|
||||
|
||||
export type { PermissionRowInjected, PermissionRowProps } from './PermissionRow.tsx'
|
||||
export type {
|
||||
@@ -43,7 +41,7 @@ export type {
|
||||
} from './settings-store.ts'
|
||||
|
||||
/** Required services (cordis fiber inject). */
|
||||
export const inject = ['commandUi', 'sessions', 'slots', 'locale', 'connection', 'remote', 'settingsSchema']
|
||||
export const inject = ['commandUi', 'sessions', 'slots', 'locale', 'connection', 'remote', 'settingsScope', 'settingsSchema']
|
||||
|
||||
const ACCESS_NS = 'permission.access'
|
||||
|
||||
@@ -113,7 +111,10 @@ export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register('settings.permission', { zh, en }), 'ui-permission: settings row dictionaries')
|
||||
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const controller = new PermissionPresetSettingsController(connection.api, ctx.settingsSchema)
|
||||
// The row follows the shared describe mirror, whose owning plugin already
|
||||
// refreshes it on document commits and reconnects.
|
||||
const controller = new PermissionPresetSettingsController(
|
||||
ctx.settingsScope.describe(), connection.api, ctx.settingsSchema)
|
||||
const load = (): Promise<void> => controller.load()
|
||||
const select = (preset: string): Promise<void> => controller.select(preset)
|
||||
const injected = (): PermissionRowInjected => ({
|
||||
@@ -122,20 +123,7 @@ export function apply(ctx: ClientContext): void {
|
||||
select,
|
||||
})
|
||||
|
||||
ctx.effect(() => {
|
||||
const refresh = (): void => { refreshPermissionIfLoaded(controller) }
|
||||
const disposers = [
|
||||
ctx.remote.$on('settings/document-updated', (ns) => {
|
||||
if (ns !== PERMISSION_SETTINGS_NS) return
|
||||
refresh()
|
||||
}),
|
||||
ctx.on('connection/reset', () => { refresh() }),
|
||||
]
|
||||
return () => {
|
||||
controller.dispose()
|
||||
for (const dispose of disposers) dispose()
|
||||
}
|
||||
}, 'ui-permission: settings invalidations')
|
||||
ctx.effect(() => () => { controller.dispose() }, 'ui-permission: settings row directory')
|
||||
|
||||
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
|
||||
name: 'settings.general.item',
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* Permission default-settings controller. The host descriptor supplies the
|
||||
* current value and the dynamic preset enum; writes target only
|
||||
* `defaultPreset` and carry the descriptor revision.
|
||||
* Permission default-settings controller. The permission descriptor comes
|
||||
* from the shared describe mirror (the dynamic preset enum lives in the
|
||||
* namespace schema, which per-namespace scopes do not carry); writes target
|
||||
* only `defaultPreset`, carry the descriptor revision, and fold their answer
|
||||
* back into the mirror.
|
||||
*/
|
||||
|
||||
import type {
|
||||
@@ -10,7 +12,9 @@ import type {
|
||||
import {
|
||||
createSnapshotStore, type SnapshotStore,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SchemaNode, SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type {
|
||||
SchemaNode, SettingsDescribeFace, SettingsSchemaService,
|
||||
} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { displayPermissionPreset } from './presentation.ts'
|
||||
|
||||
/** Permission's settings namespace on the host wire. */
|
||||
@@ -74,7 +78,7 @@ export function permissionDefaultOf(view: SettingsNamespaceView, schema: Setting
|
||||
return { currentValue: value, options }
|
||||
}
|
||||
|
||||
/** Controller joining Settings reads, writes, and pushed invalidations. */
|
||||
/** Controller deriving the row from the shared mirror and writing the default through it. */
|
||||
export class PermissionPresetSettingsController {
|
||||
/** Row snapshot consumed through a bound selector hook. */
|
||||
readonly store: SnapshotStore<PermissionSettingsState> = createSnapshotStore({
|
||||
@@ -86,57 +90,50 @@ export class PermissionPresetSettingsController {
|
||||
revision: 0,
|
||||
})
|
||||
|
||||
private generation = 0
|
||||
private view: SettingsNamespaceView | undefined
|
||||
private following: (() => void) | undefined
|
||||
private saving = false
|
||||
private disposed = false
|
||||
|
||||
/** @param api - Settings wire face. */
|
||||
/**
|
||||
* @param describeFace - the shared mirror's read/fold face (descriptor and schema source).
|
||||
* @param api - settings wire face for the `defaultPreset` write.
|
||||
* @param schema - settings-owned schema operations.
|
||||
*/
|
||||
constructor(
|
||||
private readonly describeFace: SettingsDescribeFace,
|
||||
private readonly api: Pick<IApiClient, 'settings'>,
|
||||
private readonly schema: SettingsSchemaService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Refresh the permission descriptor. Latest request wins.
|
||||
* @returns nothing; {@link store} carries success or failure.
|
||||
* Begin following the mirror (idempotent) and reflect its current answer.
|
||||
* @returns settlement once the snapshot reflects the mirror.
|
||||
*/
|
||||
async load(): Promise<void> {
|
||||
const generation = ++this.generation
|
||||
if (this.disposed) return
|
||||
this.following ??= this.describeFace.subscribe(() => { this.derive() })
|
||||
this.store.update((state) => {
|
||||
state.status = 'loading'
|
||||
state.error = null
|
||||
})
|
||||
try {
|
||||
const response = await this.api.settings.describe({})
|
||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
||||
if (generation !== this.generation) return
|
||||
const view = response.result.value.namespaces.find(entry => entry.ns === PERMISSION_SETTINGS_NS)
|
||||
if (view === undefined) {
|
||||
this.view = undefined
|
||||
this.store.update((state) => {
|
||||
state.status = 'unavailable'
|
||||
state.writable = false
|
||||
state.currentValue = ''
|
||||
state.options = []
|
||||
})
|
||||
return
|
||||
}
|
||||
this.accept(view, response.result.value.writable)
|
||||
} catch (error) {
|
||||
if (generation !== this.generation) return
|
||||
this.fail(error)
|
||||
}
|
||||
await this.describeFace.ensure()
|
||||
this.derive()
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist one preset as the default for subsequently created sessions.
|
||||
* A selection made while one is already saving is ignored — the row's
|
||||
* control is disabled during the save, so this only drops programmatic
|
||||
* double-submits rather than user intent.
|
||||
* @param preset - advertised preset key.
|
||||
* @returns nothing; {@link store} carries success or failure.
|
||||
*/
|
||||
async select(preset: string): Promise<void> {
|
||||
const view = this.view
|
||||
const state = this.store.getSnapshot()
|
||||
if (view === undefined || !state.writable) return
|
||||
const generation = ++this.generation
|
||||
const view = this.describeFace.getSnapshot().view?.namespaces
|
||||
.find(entry => entry.ns === PERMISSION_SETTINGS_NS)
|
||||
if (view === undefined || !state.writable || this.saving) return
|
||||
this.saving = true
|
||||
this.store.update((draft) => {
|
||||
draft.status = 'saving'
|
||||
draft.error = null
|
||||
@@ -147,32 +144,70 @@ export class PermissionPresetSettingsController {
|
||||
ops: [{ op: 'set', path: ['defaultPreset'], value: preset }],
|
||||
expectedRevision: view.revision,
|
||||
})
|
||||
if (generation !== this.generation) return
|
||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
||||
this.accept(response.result.value, true)
|
||||
this.saving = false
|
||||
if (this.disposed) return
|
||||
// The mirror publish reaches this row's own subscription, so the fold
|
||||
// is also what republishes the accepted value here.
|
||||
this.describeFace.acceptView(response.result.value)
|
||||
} catch (error) {
|
||||
if (generation !== this.generation) return
|
||||
this.saving = false
|
||||
if (this.disposed) return
|
||||
this.fail(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop in-flight responses from publishing after plugin disposal. */
|
||||
/** Stop following the mirror; later publishes leave the snapshot alone. */
|
||||
dispose(): void {
|
||||
this.generation += 1
|
||||
this.view = undefined
|
||||
this.disposed = true
|
||||
this.following?.()
|
||||
this.following = undefined
|
||||
}
|
||||
|
||||
private accept(view: SettingsNamespaceView, writable: boolean): void {
|
||||
const resolved = permissionDefaultOf(view, this.schema)
|
||||
this.view = view
|
||||
this.store.update((state) => {
|
||||
state.status = 'ready'
|
||||
state.error = null
|
||||
state.writable = writable
|
||||
state.currentValue = resolved.currentValue
|
||||
state.options = resolved.options
|
||||
state.revision = view.revision
|
||||
})
|
||||
private derive(): void {
|
||||
if (this.disposed || this.saving) return
|
||||
const mirrored = this.describeFace.getSnapshot()
|
||||
if (mirrored.status === 'unavailable') {
|
||||
// The terminal non-loopback state: settings RPCs are loopback-only, so
|
||||
// the row hides itself exactly like an unserved namespace.
|
||||
this.store.update((state) => {
|
||||
state.status = 'unavailable'
|
||||
state.writable = false
|
||||
state.currentValue = ''
|
||||
state.options = []
|
||||
})
|
||||
return
|
||||
}
|
||||
if (mirrored.view === undefined) {
|
||||
// A held failure with no answer is a failed row; without one the read
|
||||
// is still in flight and the row keeps its loading state.
|
||||
if (mirrored.error !== null) this.fail(new Error(mirrored.error))
|
||||
return
|
||||
}
|
||||
const view = mirrored.view.namespaces.find(entry => entry.ns === PERMISSION_SETTINGS_NS)
|
||||
if (view === undefined) {
|
||||
this.store.update((state) => {
|
||||
state.status = 'unavailable'
|
||||
state.writable = false
|
||||
state.currentValue = ''
|
||||
state.options = []
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
const resolved = permissionDefaultOf(view, this.schema)
|
||||
const { writable } = mirrored.view
|
||||
this.store.update((state) => {
|
||||
state.status = 'ready'
|
||||
state.error = null
|
||||
state.writable = writable
|
||||
state.currentValue = resolved.currentValue
|
||||
state.options = resolved.options
|
||||
state.revision = view.revision
|
||||
})
|
||||
} catch (error) {
|
||||
this.fail(error)
|
||||
}
|
||||
}
|
||||
|
||||
private fail(error: unknown): void {
|
||||
@@ -182,12 +217,3 @@ export class PermissionPresetSettingsController {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refetch only after the row has opened once.
|
||||
* @param controller - permission settings controller.
|
||||
*/
|
||||
export function refreshPermissionIfLoaded(controller: PermissionPresetSettingsController): void {
|
||||
if (controller.store.getSnapshot().status === 'idle') return
|
||||
void controller.load()
|
||||
}
|
||||
@@ -13,8 +13,8 @@ import { describe, expect, it } from 'vitest'
|
||||
import { SlotRegistry, type SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { CommandDecoration } from '@deepseek-ai/dsh-client-ui-commands/client'
|
||||
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts'
|
||||
import type { PermissionSelect } from '@deepseek-ai/dsh-permission-presets/client'
|
||||
import {
|
||||
PermissionRow, type PermissionRowInjected,
|
||||
@@ -42,7 +42,6 @@ async function bench() {
|
||||
// The plugin injects `remote`; forwarded events reach it through the same
|
||||
// `$dispatch` handoff the connection sink makes.
|
||||
new TestRemote(ctx)
|
||||
new SettingsSchemaService(ctx)
|
||||
ctx.slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
@@ -60,6 +59,7 @@ async function bench() {
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
|
||||
let decoration: CommandDecoration | undefined
|
||||
ctx.provide('commandUi', {
|
||||
decorate(c: CommandDecoration) {
|
||||
|
||||
+19
-16
@@ -7,8 +7,17 @@ import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts'
|
||||
import { PermissionRow, type PermissionRowProps } from '../src/client/PermissionRow.tsx'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
|
||||
import { PermissionPresetSettingsController } from '../src/client/settings-store.ts'
|
||||
|
||||
const schema = new SettingsSchemaService(new Context())
|
||||
|
||||
/** Controller over a real mirror derived from the same fake wire. */
|
||||
function derivedController(api: { settings: object }) {
|
||||
const wire = api as never
|
||||
return new PermissionPresetSettingsController(new SettingsDescribeMirror(wire), wire, schema)
|
||||
}
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const SCHEMA = {
|
||||
@@ -22,12 +31,6 @@ const SCHEMA = {
|
||||
},
|
||||
}
|
||||
|
||||
const schema = new SettingsSchemaService(new Context())
|
||||
|
||||
function createController(api: ConstructorParameters<typeof PermissionPresetSettingsController>[0]) {
|
||||
return new PermissionPresetSettingsController(api, schema)
|
||||
}
|
||||
|
||||
function view(defaultPreset: string, revision = 0): SettingsNamespaceView {
|
||||
return {
|
||||
ns: 'permission',
|
||||
@@ -66,11 +69,11 @@ function mount(controller: PermissionPresetSettingsController) {
|
||||
describe('PermissionRow', () => {
|
||||
it('loads the descriptor, opens the menu, and selects a new default', async () => {
|
||||
const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 1))))
|
||||
const controller = createController({
|
||||
const controller = derivedController({
|
||||
settings: {
|
||||
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
|
||||
mutate,
|
||||
} as never,
|
||||
},
|
||||
})
|
||||
mount(controller)
|
||||
const button = await screen.findByRole('button', { name: 'Read Only' })
|
||||
@@ -93,11 +96,11 @@ describe('PermissionRow', () => {
|
||||
|
||||
it('requires explicit acknowledgement before saving Full access', async () => {
|
||||
const mutate = vi.fn(() => Promise.resolve(ok(view('danger-full-access', 1))))
|
||||
const controller = createController({
|
||||
const controller = derivedController({
|
||||
settings: {
|
||||
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
|
||||
mutate,
|
||||
} as never,
|
||||
},
|
||||
})
|
||||
mount(controller)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Read Only' }))
|
||||
@@ -117,21 +120,21 @@ describe('PermissionRow', () => {
|
||||
})
|
||||
|
||||
it('hides an unavailable namespace and disables a read-only provider', async () => {
|
||||
const absent = createController({
|
||||
const absent = derivedController({
|
||||
settings: {
|
||||
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })),
|
||||
mutate: vi.fn(),
|
||||
} as never,
|
||||
},
|
||||
})
|
||||
const rendered = mount(absent)
|
||||
await waitFor(() => { expect(rendered.container.textContent).toBe('') })
|
||||
rendered.unmount()
|
||||
|
||||
const readonly = createController({
|
||||
const readonly = derivedController({
|
||||
settings: {
|
||||
describe: () => Promise.resolve(ok({ writable: false, hasDocument: false, namespaces: [view('read-only')] })),
|
||||
mutate: vi.fn(),
|
||||
} as never,
|
||||
},
|
||||
})
|
||||
mount(readonly)
|
||||
expect((await screen.findByRole('button', { name: 'Read Only' })).hasAttribute('disabled')).toBe(true)
|
||||
@@ -142,7 +145,7 @@ describe('PermissionRow', () => {
|
||||
writable: boolean
|
||||
namespaces: SettingsNamespaceView[]
|
||||
}>>>()
|
||||
const controller = createController({
|
||||
const controller = derivedController({
|
||||
settings: {
|
||||
describe: () => describe.promise,
|
||||
mutate: () => Promise.resolve({
|
||||
@@ -152,7 +155,7 @@ describe('PermissionRow', () => {
|
||||
error: { code: 'settings-conflict', message: 'changed elsewhere', details: {} },
|
||||
},
|
||||
}),
|
||||
} as never,
|
||||
},
|
||||
})
|
||||
mount(controller)
|
||||
expect((await screen.findByRole('button', { name: 'Loading' })).hasAttribute('disabled')).toBe(true)
|
||||
|
||||
@@ -2,8 +2,9 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts'
|
||||
import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
|
||||
import {
|
||||
PermissionPresetSettingsController, permissionDefaultOf, refreshPermissionIfLoaded,
|
||||
PermissionPresetSettingsController, permissionDefaultOf,
|
||||
} from '../src/client/settings-store.ts'
|
||||
|
||||
const SCHEMA = {
|
||||
@@ -22,10 +23,6 @@ function resolveDefault(view: SettingsNamespaceView) {
|
||||
return permissionDefaultOf(view, schema)
|
||||
}
|
||||
|
||||
function createController(api: ConstructorParameters<typeof PermissionPresetSettingsController>[0]) {
|
||||
return new PermissionPresetSettingsController(api, schema)
|
||||
}
|
||||
|
||||
function view(defaultPreset: string, revision = 0, schema: SettingsNamespaceView['schema'] = SCHEMA): SettingsNamespaceView {
|
||||
return {
|
||||
ns: 'permission',
|
||||
@@ -42,6 +39,13 @@ function ok<T>(value: T) {
|
||||
return { rpcId: 'test', result: { ok: true as const, value } }
|
||||
}
|
||||
|
||||
/** The permission controller over a real mirror and one fake wire. */
|
||||
function permissionController(api: object) {
|
||||
const wire = { settings: api } as never
|
||||
const mirror = new SettingsDescribeMirror(wire)
|
||||
return { mirror, controller: new PermissionPresetSettingsController(mirror, wire, schema) }
|
||||
}
|
||||
|
||||
describe('permission settings store', () => {
|
||||
it('derives dynamic options and host labels from the descriptor schema', () => {
|
||||
expect(resolveDefault(view('read-only'))).toEqual({
|
||||
@@ -104,9 +108,7 @@ describe('permission settings store', () => {
|
||||
namespaces: [view('read-only', 4)],
|
||||
})))
|
||||
const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 5))))
|
||||
const controller = createController({
|
||||
settings: { describe, mutate } as never,
|
||||
})
|
||||
const { controller } = permissionController({ describe, mutate })
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
status: 'ready',
|
||||
@@ -125,126 +127,140 @@ describe('permission settings store', () => {
|
||||
currentValue: 'workspace-write',
|
||||
revision: 5,
|
||||
})
|
||||
// The write answer folded into the mirror; no re-read followed.
|
||||
expect(describe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('hides the row when the namespace is absent and contains write failures', async () => {
|
||||
const describe = vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })))
|
||||
const controller = createController({
|
||||
settings: { describe, mutate: vi.fn() } as never,
|
||||
})
|
||||
const { controller } = permissionController({ describe, mutate: vi.fn() })
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot().status).toBe('unavailable')
|
||||
|
||||
const failing = createController({
|
||||
settings: {
|
||||
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
|
||||
mutate: () => Promise.resolve({
|
||||
rpcId: 'test',
|
||||
result: {
|
||||
ok: false as const,
|
||||
error: { code: 'settings-conflict', message: 'stale', details: {} },
|
||||
},
|
||||
}),
|
||||
} as never,
|
||||
})
|
||||
const failing = permissionController({
|
||||
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
|
||||
mutate: () => Promise.resolve({
|
||||
rpcId: 'test',
|
||||
result: {
|
||||
ok: false as const,
|
||||
error: { code: 'settings-conflict', message: 'stale', details: {} },
|
||||
},
|
||||
}),
|
||||
}).controller
|
||||
await failing.load()
|
||||
await failing.select('workspace-write')
|
||||
expect(failing.store.getSnapshot()).toMatchObject({ status: 'error', error: 'stale' })
|
||||
})
|
||||
|
||||
it('contains read failures, no-ops without a writable view, and ignores stale responses', async () => {
|
||||
const first = Promise.withResolvers<ReturnType<typeof ok<{
|
||||
writable: boolean
|
||||
namespaces: SettingsNamespaceView[]
|
||||
}>>>()
|
||||
const describe = vi.fn()
|
||||
.mockImplementationOnce(() => first.promise)
|
||||
.mockResolvedValueOnce(ok({ writable: false, hasDocument: false, namespaces: [view('read-only', 2)] }))
|
||||
it('contains read failures and no-ops without a writable view', async () => {
|
||||
const mutate = vi.fn()
|
||||
const controller = createController({
|
||||
settings: { describe, mutate } as never,
|
||||
})
|
||||
const stale = controller.load()
|
||||
await controller.load()
|
||||
first.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('workspace-write', 1)] }))
|
||||
await stale
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
const readOnly = permissionController({
|
||||
describe: () => Promise.resolve(ok({
|
||||
writable: false, hasDocument: false, namespaces: [view('read-only', 2)],
|
||||
})),
|
||||
mutate,
|
||||
}).controller
|
||||
await readOnly.load()
|
||||
expect(readOnly.store.getSnapshot()).toMatchObject({
|
||||
currentValue: 'read-only',
|
||||
writable: false,
|
||||
revision: 2,
|
||||
})
|
||||
await controller.select('workspace-write')
|
||||
await readOnly.select('workspace-write')
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
|
||||
const rejected = createController({
|
||||
settings: {
|
||||
describe: () => Promise.resolve({
|
||||
rpcId: 'test',
|
||||
result: { ok: false as const, error: { code: 'internal', message: 'offline', details: {} } },
|
||||
}),
|
||||
mutate,
|
||||
} as never,
|
||||
})
|
||||
const rejected = permissionController({
|
||||
describe: () => Promise.resolve({
|
||||
rpcId: 'test',
|
||||
result: { ok: false as const, error: { code: 'internal', message: 'offline', details: {} } },
|
||||
}),
|
||||
mutate,
|
||||
}).controller
|
||||
await rejected.select('workspace-write')
|
||||
await rejected.load()
|
||||
expect(rejected.store.getSnapshot()).toMatchObject({ status: 'error', error: 'offline' })
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
|
||||
const thrown = createController({
|
||||
settings: {
|
||||
// Promise consumers must contain unknown rejection values from a
|
||||
// transport implementation, including non-Error legacy clients.
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
|
||||
describe: () => Promise.reject('disconnected'),
|
||||
mutate,
|
||||
} as never,
|
||||
})
|
||||
const thrown = permissionController({
|
||||
describe: async () => { throw 'disconnected' },
|
||||
mutate,
|
||||
}).controller
|
||||
await thrown.load()
|
||||
expect(thrown.store.getSnapshot()).toMatchObject({ status: 'error', error: 'disconnected' })
|
||||
|
||||
const wire = {
|
||||
settings: {
|
||||
describe: () => Promise.resolve(ok({
|
||||
writable: true, hasDocument: false, namespaces: [view('read-only')],
|
||||
})),
|
||||
mutate,
|
||||
},
|
||||
} as never
|
||||
const mirror = new SettingsDescribeMirror(wire)
|
||||
const malformed = new PermissionPresetSettingsController(mirror, wire, {
|
||||
rehydrate: () => { throw 'schema disconnected' },
|
||||
} as never)
|
||||
await malformed.load()
|
||||
expect(malformed.store.getSnapshot()).toMatchObject({
|
||||
status: 'error', error: 'schema disconnected',
|
||||
})
|
||||
})
|
||||
|
||||
it('disposal suppresses in-flight reads and writes, and loaded invalidations refetch', async () => {
|
||||
it('hides the row in a remote browser instead of loading forever', async () => {
|
||||
const describeCall = vi.fn()
|
||||
const mutate = vi.fn()
|
||||
const wire = { settings: { describe: describeCall, mutate } } as never
|
||||
const mirror = new SettingsDescribeMirror(wire, 'memory')
|
||||
const controller = new PermissionPresetSettingsController(mirror, wire, schema)
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot().status).toBe('unavailable')
|
||||
await controller.select('workspace-write')
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('follows a mirror refresh without an own read once loaded', async () => {
|
||||
const describe = vi.fn()
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: false, namespaces: [view('read-only', 1)] }))
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: false, namespaces: [view('workspace-write', 2)] }))
|
||||
const { mirror, controller } = permissionController({ describe, mutate: vi.fn() })
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ currentValue: 'read-only' })
|
||||
|
||||
await mirror.load()
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ currentValue: 'workspace-write', revision: 2 })
|
||||
})
|
||||
|
||||
it('disposal stops deriving and suppresses in-flight writes', async () => {
|
||||
const neverRead = vi.fn()
|
||||
const { controller: neverLoaded } = permissionController({ describe: neverRead, mutate: vi.fn() })
|
||||
neverLoaded.dispose()
|
||||
await neverLoaded.load()
|
||||
expect(neverLoaded.store.getSnapshot().status).toBe('idle')
|
||||
expect(neverRead).not.toHaveBeenCalled()
|
||||
|
||||
const read = Promise.withResolvers<ReturnType<typeof ok<{
|
||||
writable: boolean
|
||||
namespaces: SettingsNamespaceView[]
|
||||
}>>>()
|
||||
const describe = vi.fn(() => read.promise)
|
||||
const idle = createController({ settings: { describe, mutate: vi.fn() } as never })
|
||||
refreshPermissionIfLoaded(idle)
|
||||
expect(describe).not.toHaveBeenCalled()
|
||||
const { mirror, controller: idle } = permissionController({ describe: () => read.promise, mutate: vi.fn() })
|
||||
const loading = idle.load()
|
||||
idle.dispose()
|
||||
read.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] }))
|
||||
await loading
|
||||
await Promise.all([loading, mirror.load()])
|
||||
expect(idle.store.getSnapshot().status).toBe('loading')
|
||||
|
||||
const rejectedRead = Promise.withResolvers<ReturnType<typeof ok<{
|
||||
writable: boolean
|
||||
namespaces: SettingsNamespaceView[]
|
||||
}>>>()
|
||||
const disposedRead = createController({
|
||||
settings: { describe: () => rejectedRead.promise, mutate: vi.fn() } as never,
|
||||
})
|
||||
const reading = disposedRead.load()
|
||||
disposedRead.dispose()
|
||||
rejectedRead.reject(new Error('late read'))
|
||||
await reading
|
||||
expect(disposedRead.store.getSnapshot().status).toBe('loading')
|
||||
|
||||
const mutation = Promise.withResolvers<ReturnType<typeof ok<SettingsNamespaceView>>>()
|
||||
const activeDescribe = vi.fn(() => Promise.resolve(ok({
|
||||
writable: true,
|
||||
hasDocument: false,
|
||||
namespaces: [view('read-only')],
|
||||
})))
|
||||
const active = createController({
|
||||
settings: {
|
||||
describe: activeDescribe,
|
||||
mutate: () => mutation.promise,
|
||||
} as never,
|
||||
const { controller: active } = permissionController({
|
||||
describe: () => Promise.resolve(ok({
|
||||
writable: true,
|
||||
hasDocument: false,
|
||||
namespaces: [view('read-only')],
|
||||
})),
|
||||
mutate: () => mutation.promise,
|
||||
})
|
||||
await active.load()
|
||||
refreshPermissionIfLoaded(active)
|
||||
await vi.waitFor(() => { expect(activeDescribe).toHaveBeenCalledTimes(2) })
|
||||
const saving = active.select('workspace-write')
|
||||
active.dispose()
|
||||
mutation.resolve(ok(view('workspace-write', 1)))
|
||||
@@ -252,11 +268,9 @@ describe('permission settings store', () => {
|
||||
expect(active.store.getSnapshot().status).toBe('saving')
|
||||
|
||||
const rejectedMutation = Promise.withResolvers<ReturnType<typeof ok<SettingsNamespaceView>>>()
|
||||
const disposedWrite = createController({
|
||||
settings: {
|
||||
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
|
||||
mutate: () => rejectedMutation.promise,
|
||||
} as never,
|
||||
const { controller: disposedWrite } = permissionController({
|
||||
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
|
||||
mutate: () => rejectedMutation.promise,
|
||||
})
|
||||
await disposedWrite.load()
|
||||
const writing = disposedWrite.select('workspace-write')
|
||||
|
||||
@@ -24,7 +24,7 @@ import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx'
|
||||
import { GeneralSection } from './GeneralSection.tsx'
|
||||
import { SettingsDocumentAction } from './SettingsDocumentAction.tsx'
|
||||
import type { SettingsDocumentActionInjected } from './SettingsDocumentAction.tsx'
|
||||
import { refreshDocumentIfLoaded, SettingsDocumentStore } from './settings-document-store.ts'
|
||||
import { SettingsDocumentStore } from './settings-document-store.ts'
|
||||
import { en, zh, type SettingsKey } from './locales.ts'
|
||||
|
||||
export type {
|
||||
@@ -53,7 +53,7 @@ const NS = 'settings'
|
||||
* ui-settings' apply, whose activation order relative to this one is NOT
|
||||
* constrained; registrations depend on their slots through `slots.inject()`.
|
||||
*/
|
||||
export const inject = ['slots', 'locale', 'connection']
|
||||
export const inject = ['slots', 'locale', 'connection', 'settingsScope']
|
||||
|
||||
/**
|
||||
* Register the `settings` dictionaries, the chrome content, and the General
|
||||
@@ -68,8 +68,10 @@ export function apply(ctx: ClientContext): void {
|
||||
// locale/change re-registration wiring.
|
||||
const t = ctx.locale.bind(NS)
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
// The action follows the shared describe mirror, whose owning plugin
|
||||
// already refreshes it on document commits and reconnects.
|
||||
const documentController = connection.isLoopback
|
||||
? new SettingsDocumentStore(connection.api)
|
||||
? new SettingsDocumentStore(connection.api, ctx.settingsScope.describe())
|
||||
: undefined
|
||||
const documentInjected = documentController === undefined
|
||||
? undefined
|
||||
@@ -77,9 +79,7 @@ export function apply(ctx: ClientContext): void {
|
||||
controller: documentController,
|
||||
hooks: { snapshot: documentController.store },
|
||||
})
|
||||
ctx.effect(() => ctx.on('connection/reset', () => {
|
||||
refreshDocumentIfLoaded(documentController)
|
||||
}), 'ui-settings-general: metadata invalidations')
|
||||
ctx.effect(() => () => { documentController?.dispose() }, 'ui-settings-general: document action directory')
|
||||
// The settings shell: this package occupies the sidebar-owned hole and
|
||||
// declares the settings slots. Ledger → nav-row projection as an observable
|
||||
// source (uSES contract: getSnapshot returns the cached rows until the
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
|
||||
/** Browser state of the Host-owned settings document. */
|
||||
export interface SettingsDocumentState {
|
||||
@@ -17,51 +18,37 @@ function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
/** Loads local-document availability and invokes the pathless Host-owned open operation. */
|
||||
/** Derives local-document availability from the shared mirror and invokes the pathless Host-owned open operation. */
|
||||
export class SettingsDocumentStore {
|
||||
/** uSES-safe state source shared by the registered header action. */
|
||||
readonly store: SnapshotStore<SettingsDocumentState> = createSnapshotStore({
|
||||
status: 'idle', opening: false, error: null,
|
||||
})
|
||||
|
||||
private generation = 0
|
||||
private following: (() => void) | undefined
|
||||
|
||||
/**
|
||||
* @param api - loopback settings wire face that reports and opens the provider document.
|
||||
* @param api - loopback settings wire face that opens the provider document.
|
||||
* @param describeFace - the shared mirror's describe face (`hasDocument` source).
|
||||
*/
|
||||
constructor(private readonly api: Pick<IApiClient, 'settings'>) {}
|
||||
constructor(
|
||||
private readonly api: Pick<IApiClient, 'settings'>,
|
||||
private readonly describeFace: SettingsDescribeFace,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Load whether the current provider owns a local document.
|
||||
* @returns after the latest metadata response updates the store.
|
||||
* Begin following the mirror (idempotent) and reflect whether the current
|
||||
* provider owns a local document.
|
||||
* @returns settlement once the snapshot reflects the mirror.
|
||||
*/
|
||||
async load(): Promise<void> {
|
||||
const generation = ++this.generation
|
||||
this.following ??= this.describeFace.subscribe(() => { this.derive() })
|
||||
this.store.update((state) => {
|
||||
state.status = 'loading'
|
||||
state.error = null
|
||||
})
|
||||
try {
|
||||
const { result } = await this.api.settings.describe({})
|
||||
if (generation !== this.generation) return
|
||||
if (!result.ok) {
|
||||
this.store.update((state) => {
|
||||
state.status = 'unavailable'
|
||||
state.error = result.error.message
|
||||
})
|
||||
return
|
||||
}
|
||||
this.store.update((state) => {
|
||||
state.status = result.value.hasDocument ? 'ready' : 'unavailable'
|
||||
state.error = null
|
||||
})
|
||||
} catch (error) {
|
||||
if (generation !== this.generation) return
|
||||
this.store.update((state) => {
|
||||
state.status = 'unavailable'
|
||||
state.error = messageOf(error)
|
||||
})
|
||||
}
|
||||
await this.describeFace.ensure()
|
||||
this.derive()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,13 +71,30 @@ export class SettingsDocumentStore {
|
||||
this.store.update((state) => { state.opening = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh document availability after reconnect only when a surface has already requested it.
|
||||
* @param controller - optional loopback document state owner.
|
||||
*/
|
||||
export function refreshDocumentIfLoaded(controller: SettingsDocumentStore | undefined): void {
|
||||
if (controller === undefined || controller.store.getSnapshot().status === 'idle') return
|
||||
void controller.load()
|
||||
/** Stop following the mirror. */
|
||||
dispose(): void {
|
||||
this.following?.()
|
||||
this.following = undefined
|
||||
}
|
||||
|
||||
private derive(): void {
|
||||
const mirrored = this.describeFace.getSnapshot()
|
||||
if (mirrored.view === undefined) {
|
||||
// A held failure with no answer means the document cannot be located;
|
||||
// without one the read is still in flight and loading stands.
|
||||
if (mirrored.error !== null) {
|
||||
this.store.update((state) => {
|
||||
state.status = 'unavailable'
|
||||
state.error = mirrored.error
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
const { hasDocument } = mirrored.view
|
||||
this.store.update((state) => {
|
||||
state.status = hasDocument ? 'ready' : 'unavailable'
|
||||
state.error = null
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client'
|
||||
import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
|
||||
import { GeneralSection } from '../src/client/GeneralSection.tsx'
|
||||
@@ -48,6 +49,8 @@ async function bench(isLoopback = true) {
|
||||
api: { settings: { describe: settingsDescribe, openDocument: settingsOpenDocument } },
|
||||
isLoopback,
|
||||
} as never)
|
||||
new TestRemote(ctx)
|
||||
await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
|
||||
return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, settingsDescribe, settingsOpenDocument }
|
||||
}
|
||||
|
||||
@@ -75,7 +78,7 @@ function generalEntry(slots: SlotRegistry) {
|
||||
|
||||
describe('ui-settings-general apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'settingsScope'])
|
||||
})
|
||||
|
||||
it('fills all five seats for declarations before or after apply', async () => {
|
||||
@@ -149,16 +152,17 @@ describe('ui-settings-general apply', () => {
|
||||
expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('通用设置')
|
||||
})
|
||||
|
||||
it('refreshes loaded document availability on reconnect without reading it eagerly', async () => {
|
||||
it('reads availability from the shared mirror and follows its reconnect refresh', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const entry = b.slots.entries('settings.action')[0]!
|
||||
const { controller } = (entry.inject as unknown as () => SettingsDocumentActionInjected)()
|
||||
b.ctx.emit('connection/reset')
|
||||
expect(b.settingsDescribe).not.toHaveBeenCalled()
|
||||
// The mirror read once at its own boot; the action's load adds no read.
|
||||
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledOnce() })
|
||||
await controller.load()
|
||||
expect(b.settingsDescribe).toHaveBeenCalledOnce()
|
||||
expect(controller.store.getSnapshot().status).toBe('ready')
|
||||
b.ctx.emit('connection/reset')
|
||||
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) })
|
||||
})
|
||||
|
||||
@@ -7,7 +7,14 @@ import { GeneralSection } from '../src/client/GeneralSection.tsx'
|
||||
import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
|
||||
import type { TriggerContentProps } from '../src/client/chrome.tsx'
|
||||
import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx'
|
||||
import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
|
||||
import { SettingsDocumentStore } from '../src/client/settings-document-store.ts'
|
||||
|
||||
/** Store over a real mirror derived from the same fake wire. */
|
||||
function derivedDocumentStore(api: object) {
|
||||
const wire = api as never
|
||||
return new SettingsDocumentStore(wire, new SettingsDescribeMirror(wire))
|
||||
}
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -64,7 +71,7 @@ describe('SettingsDocumentAction', () => {
|
||||
rpcId: 'document-open' as never,
|
||||
result: { ok: true as const, value: { opened: true as const } },
|
||||
}))
|
||||
const controller = new SettingsDocumentStore({
|
||||
const controller = derivedDocumentStore({
|
||||
settings: {
|
||||
describe: vi.fn(() => Promise.resolve({
|
||||
rpcId: 'document-action' as never,
|
||||
@@ -75,7 +82,7 @@ describe('SettingsDocumentAction', () => {
|
||||
})),
|
||||
openDocument,
|
||||
},
|
||||
} as never)
|
||||
})
|
||||
render(<SettingsDocumentAction
|
||||
{...kit}
|
||||
t={t}
|
||||
@@ -87,7 +94,7 @@ describe('SettingsDocumentAction', () => {
|
||||
await waitFor(() => { expect(openDocument).toHaveBeenCalledWith({}) })
|
||||
})
|
||||
|
||||
it('stays absent without a document and retries availability after remount', async () => {
|
||||
it('stays absent without a document and follows a mirror refresh to available', async () => {
|
||||
const describe = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
rpcId: 'document-action-absent' as never,
|
||||
@@ -97,12 +104,9 @@ describe('SettingsDocumentAction', () => {
|
||||
rpcId: 'document-action-ready' as never,
|
||||
result: { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] } },
|
||||
})
|
||||
const controller = new SettingsDocumentStore({
|
||||
settings: {
|
||||
describe,
|
||||
openDocument: vi.fn(),
|
||||
},
|
||||
} as never)
|
||||
const wire = { settings: { describe, openDocument: vi.fn() } } as never
|
||||
const mirror = new SettingsDescribeMirror(wire)
|
||||
const controller = new SettingsDocumentStore(wire, mirror)
|
||||
const first = render(<SettingsDocumentAction
|
||||
{...kit}
|
||||
t={t}
|
||||
@@ -118,12 +122,17 @@ describe('SettingsDocumentAction', () => {
|
||||
controller={controller}
|
||||
useSnapshot={bindSnapshotSelector(controller.store)}
|
||||
/>)
|
||||
// A remount alone re-reads nothing; availability moves with the mirror's
|
||||
// own refresh (a document commit or reconnect in production).
|
||||
await waitFor(() => { expect(controller.store.getSnapshot().status).toBe('unavailable') })
|
||||
expect(describe).toHaveBeenCalledTimes(1)
|
||||
await mirror.load()
|
||||
expect(await screen.findByRole('button', { name: 'Open configuration file' })).toBeTruthy()
|
||||
expect(describe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps the action available and reports a native-open failure', async () => {
|
||||
const controller = new SettingsDocumentStore({
|
||||
const controller = derivedDocumentStore({
|
||||
settings: {
|
||||
describe: vi.fn(() => Promise.resolve({
|
||||
rpcId: 'document-action' as never,
|
||||
@@ -137,7 +146,7 @@ describe('SettingsDocumentAction', () => {
|
||||
result: { ok: false as const, error: { code: 'internal' as const, message: 'xdg-open missing', details: {} } },
|
||||
})),
|
||||
},
|
||||
} as never)
|
||||
})
|
||||
render(<SettingsDocumentAction
|
||||
{...kit}
|
||||
t={t}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
|
||||
import { SettingsDocumentStore } from '../src/client/settings-document-store.ts'
|
||||
|
||||
/** Store over a real mirror derived from the same fake wire. */
|
||||
function derivedDocumentStore(api: object) {
|
||||
const wire = api as never
|
||||
return new SettingsDocumentStore(wire, new SettingsDescribeMirror(wire))
|
||||
}
|
||||
|
||||
function response(hasDocument = false): RpcResponse<{
|
||||
writable: boolean
|
||||
hasDocument: boolean
|
||||
@@ -34,7 +41,7 @@ describe('SettingsDocumentStore', () => {
|
||||
it('loads provider metadata and asks the settings domain to open its document', async () => {
|
||||
const describe = vi.fn(() => Promise.resolve(response(true)))
|
||||
const openDocument = vi.fn(() => Promise.resolve(opened()))
|
||||
const controller = new SettingsDocumentStore({ settings: { describe, openDocument } } as never)
|
||||
const controller = derivedDocumentStore({ settings: { describe, openDocument } })
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot()).toEqual({
|
||||
status: 'ready', opening: false, error: null,
|
||||
@@ -45,23 +52,23 @@ describe('SettingsDocumentStore', () => {
|
||||
|
||||
it('marks absent or failed metadata unavailable without opening anything', async () => {
|
||||
const openDocument = vi.fn(() => Promise.resolve(opened()))
|
||||
const absent = new SettingsDocumentStore({
|
||||
const absent = derivedDocumentStore({
|
||||
settings: { describe: () => Promise.resolve(response()), openDocument },
|
||||
} as never)
|
||||
})
|
||||
await absent.load()
|
||||
await absent.open()
|
||||
expect(absent.store.getSnapshot().status).toBe('unavailable')
|
||||
expect(openDocument).not.toHaveBeenCalled()
|
||||
|
||||
const failed = new SettingsDocumentStore({
|
||||
const failed = derivedDocumentStore({
|
||||
settings: { describe: () => Promise.reject(new Error('offline')), openDocument },
|
||||
} as never)
|
||||
})
|
||||
await failed.load()
|
||||
expect(failed.store.getSnapshot()).toMatchObject({ status: 'unavailable', error: 'offline' })
|
||||
|
||||
const rejected = new SettingsDocumentStore({
|
||||
const rejected = derivedDocumentStore({
|
||||
settings: { describe: () => Promise.resolve(describeFailed('provider failed')), openDocument },
|
||||
} as never)
|
||||
})
|
||||
await rejected.load()
|
||||
expect(rejected.store.getSnapshot()).toMatchObject({
|
||||
status: 'unavailable', error: 'provider failed',
|
||||
@@ -71,9 +78,9 @@ describe('SettingsDocumentStore', () => {
|
||||
it('collapses concurrent open gestures and recovers after a failure', async () => {
|
||||
let resolveOpen!: (response: RpcResponse<{ opened: true }>) => void
|
||||
const openDocument = vi.fn(() => new Promise<RpcResponse<{ opened: true }>>((resolve) => { resolveOpen = resolve }))
|
||||
const controller = new SettingsDocumentStore({
|
||||
const controller = derivedDocumentStore({
|
||||
settings: { describe: () => Promise.resolve(response(true)), openDocument },
|
||||
} as never)
|
||||
})
|
||||
await controller.load()
|
||||
const first = controller.open()
|
||||
const second = controller.open()
|
||||
@@ -88,23 +95,15 @@ describe('SettingsDocumentStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores stale metadata completions and reports non-Error native failures', async () => {
|
||||
let resolveFirst!: (value: ReturnType<typeof response>) => void
|
||||
const first = new Promise<ReturnType<typeof response>>((resolve) => { resolveFirst = resolve })
|
||||
const describe = vi.fn()
|
||||
.mockReturnValueOnce(first)
|
||||
.mockResolvedValueOnce(response(true))
|
||||
it('reports non-Error native failures and recovers availability via a mirror refresh', async () => {
|
||||
let rejectOpen!: (reason?: unknown) => void
|
||||
const controller = new SettingsDocumentStore({
|
||||
const controller = derivedDocumentStore({
|
||||
settings: {
|
||||
describe,
|
||||
describe: vi.fn(() => Promise.resolve(response(true))),
|
||||
openDocument: () => new Promise((_, reject) => { rejectOpen = reject }),
|
||||
},
|
||||
} as never)
|
||||
const stale = controller.load()
|
||||
})
|
||||
await controller.load()
|
||||
resolveFirst(response())
|
||||
await stale
|
||||
expect(controller.store.getSnapshot().status).toBe('ready')
|
||||
const opening = controller.open()
|
||||
rejectOpen('native unavailable')
|
||||
@@ -113,20 +112,21 @@ describe('SettingsDocumentStore', () => {
|
||||
status: 'ready', opening: false, error: 'native unavailable',
|
||||
})
|
||||
|
||||
let rejectFirst!: (error: Error) => void
|
||||
const rejectedFirst = new Promise<ReturnType<typeof response>>((_, reject) => { rejectFirst = reject })
|
||||
const caught = new SettingsDocumentStore({
|
||||
// A first read that failed leaves the action unavailable with the miss
|
||||
// recorded; the mirror's next refresh (a commit or reconnect) recovers it.
|
||||
const wire = {
|
||||
settings: {
|
||||
describe: vi.fn()
|
||||
.mockReturnValueOnce(rejectedFirst)
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockResolvedValueOnce(response(true)),
|
||||
openDocument: vi.fn(),
|
||||
},
|
||||
} as never)
|
||||
const staleRejection = caught.load()
|
||||
} as never
|
||||
const mirror = new SettingsDescribeMirror(wire)
|
||||
const caught = new SettingsDocumentStore(wire, mirror)
|
||||
await caught.load()
|
||||
rejectFirst(new Error('stale offline'))
|
||||
await staleRejection
|
||||
expect(caught.store.getSnapshot()).toMatchObject({ status: 'unavailable', error: 'offline' })
|
||||
await mirror.load()
|
||||
expect(caught.store.getSnapshot()).toMatchObject({ status: 'ready', error: null })
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import type { SettingsRootInjected } from '../src/client/shell-contract.ts'
|
||||
import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
|
||||
@@ -22,6 +23,7 @@ async function bench() {
|
||||
isLoopback: false,
|
||||
} as never)
|
||||
ctx.provide('remote', { $on: () => () => {} } as never)
|
||||
await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
|
||||
return { ctx, slots: ctx.get('slots') as SlotRegistry }
|
||||
}
|
||||
|
||||
@@ -49,7 +51,7 @@ const CHILD_SPECS = {
|
||||
|
||||
describe('ui-settings apply', () => {
|
||||
it('declares only the slot registry (a pure composition face, no locale)', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'settingsScope'])
|
||||
})
|
||||
|
||||
it('registers the shell and declares every child slot, before or after the declaration', async () => {
|
||||
|
||||
@@ -21,7 +21,7 @@ import { DeepSeekOnboardingDialog } from './DeepSeekOnboardingDialog.tsx'
|
||||
import type { DeepSeekOnboardingInjected } from './DeepSeekOnboardingDialog.tsx'
|
||||
import { WelcomeNotice } from './WelcomeNotice.tsx'
|
||||
import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx'
|
||||
import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from './welcome-store.ts'
|
||||
import { decodeWelcomeSection, WelcomeNoticeStore } from './welcome-store.ts'
|
||||
import { ModelsSettingsStore } from './store.ts'
|
||||
import { createSettingsSchemaOperations } from './schema-operations.ts'
|
||||
import { en, zh, type ModelsKey } from './locales.ts'
|
||||
@@ -56,7 +56,7 @@ export function refreshIfLoaded(controller: ModelsSettingsStore): void {
|
||||
* ui-settings' apply, whose activation order relative to this one is NOT
|
||||
* constrained; registration depends on each slot through `slots.inject()`.
|
||||
*/
|
||||
export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsSchema']
|
||||
export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope', 'settingsSchema']
|
||||
|
||||
/**
|
||||
* Register the Models section once the `settings.section` declaration is on
|
||||
@@ -69,7 +69,7 @@ export function apply(ctx: ClientContext): void {
|
||||
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const schema = createSettingsSchemaOperations(ctx.settingsSchema)
|
||||
const controller = new ModelsSettingsStore(connection.api, schema)
|
||||
const controller = new ModelsSettingsStore(connection.api, schema, ctx.settingsScope.describe())
|
||||
// Registration-time text (the nav label thunk) and the inject faces share
|
||||
// one bound translate; copy freshness rides the locale revision.
|
||||
const t = ctx.locale.bind(NS) as ModelsSectionInjected['t']
|
||||
@@ -87,34 +87,35 @@ export function apply(ctx: ClientContext): void {
|
||||
schema,
|
||||
t,
|
||||
})
|
||||
const welcomeController = new WelcomeNoticeStore(
|
||||
connection.api,
|
||||
connection.isLoopback ? 'host' : 'memory',
|
||||
)
|
||||
// The scope's own memory mode is what keeps a remote browser process-local,
|
||||
// so the store needs no isLoopback branch of its own.
|
||||
const welcomeController = new WelcomeNoticeStore(ctx.settingsScope.bind({
|
||||
namespace: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
decode: decodeWelcomeSection,
|
||||
}))
|
||||
const welcomeInjected = (): WelcomeNoticeInjected => ({
|
||||
controller: welcomeController,
|
||||
hooks: { welcome: welcomeController.store },
|
||||
t,
|
||||
})
|
||||
|
||||
// Pushed invalidations converge every open surface without polling: any
|
||||
// settings/credentials/topology change refetches once the page loaded.
|
||||
// Pushed invalidations converge every open surface without polling. The
|
||||
// settingsScope injection makes ui-settings activate first, and remote
|
||||
// dispatch preserves listener order; its listener therefore starts the
|
||||
// mirror refresh before this store joins that refresh. The welcome notice
|
||||
// follows its settings scope, so it needs no subscription here.
|
||||
ctx.effect(() => {
|
||||
const refreshModels = (): void => { refreshIfLoaded(controller) }
|
||||
const refreshAll = (): void => {
|
||||
refreshModels()
|
||||
refreshWelcomeIfLoaded(welcomeController)
|
||||
}
|
||||
const disposers = [
|
||||
ctx.remote.$on('settings/document-updated', (ns) => {
|
||||
refreshModels()
|
||||
if (ns === WELCOME_NOTICE_SETTINGS_NAMESPACE) refreshWelcomeIfLoaded(welcomeController)
|
||||
}),
|
||||
ctx.remote.$on('settings/document-updated', () => { refreshModels() }),
|
||||
ctx.remote.$on('credentials/updated', refreshModels),
|
||||
ctx.remote.$on('llm/adapters-updated', refreshModels),
|
||||
ctx.on('connection/reset', refreshAll),
|
||||
ctx.on('connection/reset', refreshModels),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
return () => {
|
||||
welcomeController.dispose()
|
||||
for (const dispose of disposers) dispose()
|
||||
}
|
||||
}, 'ui-settings-models: pushed invalidations')
|
||||
|
||||
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Models settings page store: one snapshot joining the configurable-provider
|
||||
* directory (`llm.providers`), the settings namespaces (`settings.describe`),
|
||||
* directory (`llm.providers`), the settings namespaces (shared settings mirror),
|
||||
* and the referenced credentials (`credentials.describe`). The host stays the
|
||||
* single fact source — every mutation writes through the wire and the page
|
||||
* re-renders from the next describe, pushed or refetched.
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { SettingsSchemaOperations } from './schema-operations.ts'
|
||||
|
||||
/**
|
||||
@@ -114,17 +115,21 @@ export class ModelsSettingsStore {
|
||||
private generation = 0
|
||||
|
||||
/**
|
||||
* @param api - the wire face (settings/credentials/llm domains).
|
||||
* @param api - the wire face (credentials/llm domains, and settings writes).
|
||||
* @param describeFace - the shared mirror's describe face (namespace views and writability).
|
||||
*/
|
||||
constructor(
|
||||
private readonly api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>,
|
||||
private readonly schema: SettingsSchemaOperations,
|
||||
private readonly describeFace: SettingsDescribeFace,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Refresh the whole page snapshot: directory and namespaces in parallel,
|
||||
* then one batched credential describe over every referenced ref. A
|
||||
* failure keeps the last good rows and surfaces the error.
|
||||
* Refresh the whole page snapshot: the provider directory and the mirror's
|
||||
* settings answer in parallel, then one batched credential describe over
|
||||
* every referenced ref. Provider failure or absence of an initial settings
|
||||
* answer keeps the last good rows and surfaces an error; a failed settings
|
||||
* refresh reuses the mirror's held view.
|
||||
* @returns nothing; the snapshot carries the outcome.
|
||||
*/
|
||||
async load(): Promise<void> {
|
||||
@@ -132,17 +137,20 @@ export class ModelsSettingsStore {
|
||||
this.store.update((s) => { s.status = 'loading'; s.error = null })
|
||||
let providers: ConfigurableProviderView[]
|
||||
let writable: boolean
|
||||
let views: SettingsNamespaceView[]
|
||||
let views: readonly SettingsNamespaceView[]
|
||||
try {
|
||||
const [providersResponse, settingsResponse] = await Promise.all([
|
||||
const [providersResponse] = await Promise.all([
|
||||
this.api.llm.providers({}),
|
||||
this.api.settings.describe({}),
|
||||
this.describeFace.ensure(),
|
||||
])
|
||||
if (!providersResponse.result.ok) throw new Error(providersResponse.result.error.message)
|
||||
if (!settingsResponse.result.ok) throw new Error(settingsResponse.result.error.message)
|
||||
const mirrored = this.describeFace.getSnapshot()
|
||||
if (mirrored.view === undefined) {
|
||||
throw new Error(mirrored.error ?? 'settings are unavailable in this browser')
|
||||
}
|
||||
providers = providersResponse.result.value.providers
|
||||
writable = settingsResponse.result.value.writable
|
||||
views = settingsResponse.result.value.namespaces
|
||||
writable = mirrored.view.writable
|
||||
views = mirrored.view.namespaces
|
||||
} catch (error) {
|
||||
if (generation !== this.generation) return
|
||||
this.store.update((s) => {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
/** Welcome-notice state, durable when the browser may use Host settings. */
|
||||
/**
|
||||
* Welcome-notice state derived from the welcome settings scope. The scope is
|
||||
* the transport: a loopback browser follows the durable Host section, while a
|
||||
* remote browser's memory-mode scope never answers and the acknowledgement
|
||||
* stays process-local here.
|
||||
*/
|
||||
|
||||
import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_VERSION,
|
||||
} from '../onboarding-copy.ts'
|
||||
|
||||
/** State rendered by the welcome step. */
|
||||
@@ -14,113 +18,123 @@ export interface WelcomeNoticeState {
|
||||
error: string | null
|
||||
}
|
||||
|
||||
function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
/** The welcome section as the notice reads it. */
|
||||
export type WelcomeSection = Record<string, unknown>
|
||||
|
||||
/**
|
||||
* Accept any object section verbatim; a malformed durable value reads as an
|
||||
* empty section, so the notice treats it as unacknowledged instead of leaving
|
||||
* the scope stuck on its previous value.
|
||||
* @param section - the wire section value.
|
||||
* @returns the section object, or an empty one for non-object values.
|
||||
*/
|
||||
export function decodeWelcomeSection(section: unknown): WelcomeSection {
|
||||
return typeof section === 'object' && section !== null && !Array.isArray(section)
|
||||
? section as WelcomeSection
|
||||
: {}
|
||||
}
|
||||
|
||||
function acknowledgementOf(view: SettingsNamespaceView): string | undefined {
|
||||
if (typeof view.value !== 'object' || view.value === null) return undefined
|
||||
const value = (view.value as Record<string, unknown>)[WELCOME_NOTICE_ACK_FIELD]
|
||||
return typeof value === 'string' ? value : undefined
|
||||
/* v8 ignore next 3 -- closed-union default only defends future source widening */
|
||||
function assertNever(_value: never): never {
|
||||
throw new Error('unexpected welcome settings status')
|
||||
}
|
||||
|
||||
/** Coordinates durable Host acknowledgement or a process-local remote fallback. */
|
||||
export class WelcomeNoticeStore {
|
||||
/** uSES-safe state source shared by the registered welcome step. */
|
||||
readonly store: SnapshotStore<WelcomeNoticeState> = createSnapshotStore({
|
||||
readonly store: SnapshotStore<WelcomeNoticeState> = createSnapshotStore<WelcomeNoticeState>({
|
||||
status: 'idle', acknowledged: false, error: null,
|
||||
})
|
||||
|
||||
private generation = 0
|
||||
private localAcknowledged = false
|
||||
private saving = false
|
||||
private following: (() => void) | undefined
|
||||
|
||||
/**
|
||||
* @param api - settings wire face used for durable reads and writes.
|
||||
* @param persistence - remote browsers use memory because settings is loopback-only.
|
||||
* @param scope - the welcome settings namespace scope; its memory mode is
|
||||
* what keeps a remote browser process-local.
|
||||
*/
|
||||
constructor(
|
||||
private readonly api: Pick<IApiClient, 'settings'>,
|
||||
private readonly persistence: 'host' | 'memory' = 'host',
|
||||
) {}
|
||||
constructor(private readonly scope: SettingsScope<WelcomeSection>) {}
|
||||
|
||||
/** Load the acknowledgement from Host settings or initialize process-local state. */
|
||||
async load(): Promise<void> {
|
||||
const generation = ++this.generation
|
||||
if (this.persistence === 'memory') {
|
||||
this.store.update((state) => { state.status = 'ready'; state.error = null })
|
||||
return
|
||||
}
|
||||
this.store.update((state) => { state.status = 'loading'; state.error = null })
|
||||
try {
|
||||
const response = await this.api.settings.describe({})
|
||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
||||
const view = response.result.value.namespaces.find(
|
||||
candidate => candidate.ns === WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
)
|
||||
if (view === undefined) throw new Error('welcome acknowledgement settings are unavailable')
|
||||
if (generation !== this.generation) return
|
||||
this.store.update((state) => {
|
||||
state.status = 'ready'
|
||||
state.acknowledged = acknowledgementOf(view) === WELCOME_NOTICE_VERSION
|
||||
state.error = null
|
||||
})
|
||||
} catch (error) {
|
||||
if (generation !== this.generation) return
|
||||
this.store.update((state) => {
|
||||
state.status = 'error'
|
||||
state.acknowledged = false
|
||||
state.error = messageOf(error)
|
||||
})
|
||||
}
|
||||
/**
|
||||
* Begin following the bound scope (idempotent) and publish its current answer.
|
||||
* @returns settlement after the current answer is published.
|
||||
*/
|
||||
load(): Promise<void> {
|
||||
this.following ??= this.scope.subscribe(() => { this.derive() })
|
||||
this.derive()
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist this copy version, or advance only this process for a remote browser.
|
||||
* @returns true when the selected persistence mode accepted the acknowledgement.
|
||||
* Persist this copy version, or advance only this process for a remote
|
||||
* browser. Success is judged against the state the write left behind, so a
|
||||
* refused or failed write reports false after its recovery read settles.
|
||||
* @returns true when the selected persistence mode holds the acknowledgement.
|
||||
*/
|
||||
async acknowledge(): Promise<boolean> {
|
||||
const generation = ++this.generation
|
||||
if (this.persistence === 'memory') {
|
||||
this.store.update((state) => {
|
||||
state.status = 'ready'
|
||||
state.acknowledged = true
|
||||
state.error = null
|
||||
})
|
||||
if (this.scope.getSnapshot().mode === 'memory') {
|
||||
this.localAcknowledged = true
|
||||
this.derive()
|
||||
return true
|
||||
}
|
||||
this.saving = true
|
||||
this.store.update((state) => { state.status = 'saving'; state.error = null })
|
||||
try {
|
||||
const response = await this.api.settings.mutate({
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
ops: [{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION }],
|
||||
await this.scope.set(WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_VERSION)
|
||||
} finally {
|
||||
this.saving = false
|
||||
}
|
||||
this.derive()
|
||||
const { acknowledged } = this.store.getSnapshot()
|
||||
if (!acknowledged) {
|
||||
this.store.update((state) => {
|
||||
state.status = 'error'
|
||||
state.error = 'the acknowledgement did not persist'
|
||||
})
|
||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
||||
if (generation === this.generation) {
|
||||
this.store.update((state) => {
|
||||
state.status = 'ready'
|
||||
state.acknowledged = true
|
||||
state.error = null
|
||||
})
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
if (generation === this.generation) {
|
||||
}
|
||||
return acknowledged
|
||||
}
|
||||
|
||||
/** Stop following the scope. */
|
||||
dispose(): void {
|
||||
this.following?.()
|
||||
this.following = undefined
|
||||
}
|
||||
|
||||
private derive(): void {
|
||||
if (this.saving) return
|
||||
const scope = this.scope.getSnapshot()
|
||||
if (scope.mode === 'memory') {
|
||||
this.store.update((state) => {
|
||||
state.status = 'ready'
|
||||
state.acknowledged = this.localAcknowledged
|
||||
state.error = null
|
||||
})
|
||||
return
|
||||
}
|
||||
switch (scope.status) {
|
||||
case 'loading':
|
||||
this.store.update((state) => { state.status = 'loading'; state.error = null })
|
||||
return
|
||||
case 'unavailable':
|
||||
this.store.update((state) => {
|
||||
state.status = 'error'
|
||||
state.acknowledged = false
|
||||
state.error = messageOf(error)
|
||||
state.error = 'welcome acknowledgement settings are unavailable'
|
||||
})
|
||||
return
|
||||
case 'ready': {
|
||||
const acknowledged = scope.value?.[WELCOME_NOTICE_ACK_FIELD] === WELCOME_NOTICE_VERSION
|
||||
this.store.update((state) => {
|
||||
state.status = 'ready'
|
||||
state.acknowledged = acknowledged
|
||||
state.error = null
|
||||
})
|
||||
return
|
||||
}
|
||||
return false
|
||||
/* v8 ignore next -- every current settings scope status is handled above */
|
||||
default: return assertNever(scope.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh only after welcome state has left idle. A memory-mode load retains
|
||||
* acknowledgement so reconnect does not reopen a process-local notice.
|
||||
* @param controller - welcome state owner whose current status decides whether to load.
|
||||
*/
|
||||
export function refreshWelcomeIfLoaded(controller: WelcomeNoticeStore): void {
|
||||
if (controller.store.getSnapshot().status === 'idle') return
|
||||
void controller.load()
|
||||
}
|
||||
@@ -5,8 +5,11 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts'
|
||||
import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-settings-models/client'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
|
||||
} from '../src/onboarding-copy.ts'
|
||||
import { ModelsSection } from '../src/client/ModelsSection.tsx'
|
||||
import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
|
||||
import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
|
||||
@@ -15,7 +18,7 @@ import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
|
||||
// the shipped Chinese copy, so they state the browser they assume.
|
||||
usePinnedBrowserLanguages('zh-CN')
|
||||
|
||||
async function bench(isLoopback = true) {
|
||||
async function bench(isLoopback = true, settings?: object, services: object = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
const locale = new LocaleRuntime(ctx)
|
||||
@@ -23,10 +26,14 @@ async function bench(isLoopback = true) {
|
||||
// The plugins inject `remote`; forwarded events reach them through the
|
||||
// same `$dispatch` handoff the connection sink makes.
|
||||
new TestRemote(ctx)
|
||||
// The apply path only captures the wire face; no call leaves this fake
|
||||
// until a section actually loads.
|
||||
ctx.provide('connection', { api: {}, isLoopback } as never)
|
||||
new SettingsSchemaService(ctx)
|
||||
// Without a settings face the mirror's reads fail and stay contained; the
|
||||
// Models join itself never fetches until a section actually loads. The real
|
||||
// ui-settings apply also provides the settingsSchema service.
|
||||
ctx.provide('connection', {
|
||||
api: settings === undefined ? services : { ...services, settings },
|
||||
isLoopback,
|
||||
} as never)
|
||||
await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
|
||||
return { ctx, slots: ctx.get('slots') as SlotRegistry, locale }
|
||||
}
|
||||
|
||||
@@ -45,7 +52,7 @@ function declare(slots: SlotRegistry): () => void {
|
||||
|
||||
describe('ui-settings-models apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsSchema'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope', 'settingsSchema'])
|
||||
})
|
||||
|
||||
it('registers the models nav entry for declarations before or after apply', async () => {
|
||||
@@ -206,8 +213,31 @@ describe('pushed invalidations', () => {
|
||||
expect(load).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('routes only the onboarding namespace invalidation into welcome state', async () => {
|
||||
const b = await bench()
|
||||
it('welcome state follows the shared mirror across document commits', async () => {
|
||||
// The welcome notice derives from its settings scope: a document commit
|
||||
// reaches it through the mirror's one refresh, with no routing here.
|
||||
const acknowledgement = { current: undefined as string | undefined }
|
||||
const settings = {
|
||||
describe: vi.fn(() => Promise.resolve({
|
||||
rpcId: 'apply-welcome' as never,
|
||||
result: {
|
||||
ok: true as const,
|
||||
value: {
|
||||
writable: true,
|
||||
hasDocument: false,
|
||||
namespaces: [{
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
schema: {},
|
||||
value: acknowledgement.current === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: acknowledgement.current },
|
||||
applies: 'live' as const,
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
}],
|
||||
},
|
||||
},
|
||||
})),
|
||||
}
|
||||
const b = await bench(true, settings)
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const entry = b.slots.entries('settings.onboarding')
|
||||
@@ -216,14 +246,59 @@ describe('pushed invalidations', () => {
|
||||
entry.inject as unknown as
|
||||
() => import('../src/client/WelcomeNotice.tsx').WelcomeNoticeInjected
|
||||
)()
|
||||
injected.hooks.welcome.update((state) => { state.status = 'ready' })
|
||||
const load = vi.spyOn(injected.controller, 'load').mockResolvedValue()
|
||||
await injected.controller.load()
|
||||
await vi.waitFor(() => {
|
||||
expect(injected.hooks.welcome.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: false })
|
||||
})
|
||||
acknowledgement.current = WELCOME_NOTICE_VERSION
|
||||
b.ctx.remote.$dispatch('settings/document-updated', ['ui-onboarding', 1])
|
||||
await vi.waitFor(() => {
|
||||
expect(injected.hooks.welcome.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
|
||||
})
|
||||
})
|
||||
|
||||
b.ctx.remote.$dispatch('settings/document-updated', ['llm-deepseek', 1])
|
||||
expect(load).not.toHaveBeenCalled()
|
||||
b.ctx.remote.$dispatch('settings/document-updated', ['ui-onboarding', 2])
|
||||
expect(load).toHaveBeenCalledOnce()
|
||||
b.ctx.emit('connection/reset')
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
it('joins the refreshed mirror view on a settings invalidation', async () => {
|
||||
let revision = 1
|
||||
const describe = vi.fn(() => Promise.resolve({
|
||||
rpcId: `apply-models-${revision}` as never,
|
||||
result: {
|
||||
ok: true as const,
|
||||
value: {
|
||||
writable: true,
|
||||
hasDocument: false,
|
||||
namespaces: [{
|
||||
ns: 'llm-test',
|
||||
schema: {},
|
||||
value: {},
|
||||
applies: 'live' as const,
|
||||
secrets: [],
|
||||
revision,
|
||||
}],
|
||||
},
|
||||
},
|
||||
}))
|
||||
const providers = vi.fn(() => Promise.resolve({
|
||||
rpcId: 'apply-models-providers' as never,
|
||||
result: { ok: true as const, value: { providers: [] } },
|
||||
}))
|
||||
const b = await bench(true, { describe }, { llm: { providers } })
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const entry = b.slots.entries('settings.section')
|
||||
.find(candidate => candidate.options.id === 'models')!
|
||||
const injected = (
|
||||
entry.inject as unknown as
|
||||
() => import('../src/client/ModelsSection.tsx').ModelsSectionInjected
|
||||
)()
|
||||
await injected.controller.load()
|
||||
expect(injected.hooks.snapshot.getSnapshot().namespaces.get('llm-test')?.revision).toBe(1)
|
||||
|
||||
revision = 2
|
||||
b.ctx.remote.$dispatch('settings/document-updated', ['llm-test', revision])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(injected.hooks.snapshot.getSnapshot().namespaces.get('llm-test')?.revision).toBe(2)
|
||||
})
|
||||
expect(describe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
DeepSeekModelsEditor, formatCapacity, modelDrafts, parseCapacity, validateDeepSeekModels,
|
||||
} from '../src/client/DeepSeekModelsEditor.tsx'
|
||||
import { apiKeyFailure } from '../src/client/apiKey.ts'
|
||||
import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
|
||||
import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts'
|
||||
import type { ProviderRow } from '../src/client/store.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
@@ -186,7 +187,8 @@ type WireFace = ConstructorParameters<typeof ModelsSettingsStore>[0]
|
||||
|
||||
async function mountFace(scripted: ReturnType<typeof scriptedFace>) {
|
||||
const { face, update, replace, mutate, set, unset } = scripted
|
||||
const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema)
|
||||
const mirror = new SettingsDescribeMirror(face as never)
|
||||
const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema, mirror)
|
||||
await controller.load()
|
||||
const injected: ModelsSectionProps = {
|
||||
controller,
|
||||
@@ -196,7 +198,7 @@ async function mountFace(scripted: ReturnType<typeof scriptedFace>) {
|
||||
t,
|
||||
}
|
||||
const view = render(<ModelsSection {...injected} />)
|
||||
return { view, face, update, replace, mutate, set, unset, controller }
|
||||
return { view, face, update, replace, mutate, set, unset, controller, mirror }
|
||||
}
|
||||
|
||||
async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) {
|
||||
@@ -267,7 +269,7 @@ describe('ModelsSection', () => {
|
||||
face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
|
||||
credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: false, writable: true }])),
|
||||
})))
|
||||
const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema)
|
||||
const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(face as never))
|
||||
await controller.load()
|
||||
render(<ModelsSection
|
||||
controller={controller}
|
||||
@@ -290,7 +292,7 @@ describe('ModelsSection', () => {
|
||||
face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
|
||||
credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: true, writable: true }])),
|
||||
})))
|
||||
const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema)
|
||||
const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(face as never))
|
||||
await controller.load()
|
||||
cleanup()
|
||||
render(<ModelsSection
|
||||
@@ -351,7 +353,9 @@ describe('ModelsSection', () => {
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'sk-live' }) })
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
await waitFor(() => { expect(face.settings.describe.mock.calls.length).toBeGreaterThan(1) })
|
||||
// The saved key re-loads the join; the settings answer rides the shared
|
||||
// mirror, so the reload shows as a directory read rather than a describe.
|
||||
await waitFor(() => { expect(face.llm.providers.mock.calls.length).toBeGreaterThan(1) })
|
||||
expect((await screen.findByRole('status')).textContent).toBe(
|
||||
providerCopy(en.savedProvider, { provider: 'deepseek-official', displayName: 'DeepSeek' }),
|
||||
)
|
||||
@@ -959,7 +963,7 @@ describe('ModelsSection', () => {
|
||||
const set = vi.fn()
|
||||
.mockResolvedValueOnce(fail('credential store unavailable', 'credential-rejected'))
|
||||
.mockResolvedValueOnce(ok({}))
|
||||
const { face, controller } = await mountSection({ mutate, set })
|
||||
const { face, controller, mirror } = await mountSection({ mutate, set })
|
||||
fireEvent.click(screen.getByText(en.add))
|
||||
await screen.findByLabelText(en.provider)
|
||||
fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.keyInput), { target: { value: 'sk-ant' } })
|
||||
@@ -971,7 +975,12 @@ describe('ModelsSection', () => {
|
||||
hasDocument: false,
|
||||
namespaces: wireNamespaces().map(namespace => namespace.ns === 'llm-pi-ai' ? afterSettings : namespace),
|
||||
}))
|
||||
await act(async () => { await controller.load() })
|
||||
// The refreshed settings answer reaches the page through the mirror's own
|
||||
// refresh (the document commit's invalidation in production).
|
||||
await act(async () => {
|
||||
await mirror.load()
|
||||
await controller.load()
|
||||
})
|
||||
expect(controller.store.getSnapshot().namespaces.get('llm-pi-ai')?.revision).toBe(1)
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(set).toHaveBeenCalledTimes(2) })
|
||||
@@ -1014,7 +1023,7 @@ describe('ModelsSection', () => {
|
||||
const unhandled = vi.fn()
|
||||
process.on('unhandledRejection', unhandled)
|
||||
try {
|
||||
const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema)
|
||||
const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(face as never))
|
||||
await controller.load()
|
||||
render(<ModelsSection
|
||||
controller={controller}
|
||||
@@ -1152,7 +1161,8 @@ describe('ModelsSection', () => {
|
||||
it('renders the load failure with a retry control', async () => {
|
||||
const face = scriptedFace()
|
||||
face.face.llm.providers = vi.fn(() => Promise.resolve(fail('directory down', 'internal'))) as never
|
||||
const controller = new ModelsSettingsStore(face.face as unknown as WireFace, settingsSchema)
|
||||
const controller = new ModelsSettingsStore(
|
||||
face.face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(face.face as never))
|
||||
await controller.load()
|
||||
render(<ModelsSection
|
||||
controller={controller}
|
||||
@@ -1173,7 +1183,7 @@ describe('ModelsSection', () => {
|
||||
hasDocument: false,
|
||||
namespaces: wireNamespaces(),
|
||||
})))
|
||||
const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema)
|
||||
const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(face as never))
|
||||
await controller.load()
|
||||
cleanup()
|
||||
render(<ModelsSection
|
||||
@@ -1236,7 +1246,7 @@ describe('ModelsSection', () => {
|
||||
|
||||
it('loads on first render of an idle controller', async () => {
|
||||
const { face } = scriptedFace()
|
||||
const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema)
|
||||
const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(face as never))
|
||||
render(<ModelsSection
|
||||
controller={controller}
|
||||
useSnapshot={bindSnapshotSelector(controller.store)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-re
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
|
||||
import type { DeepSeekOnboardingDialogProps } from '../src/client/DeepSeekOnboardingDialog.tsx'
|
||||
import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
|
||||
import { ModelsSettingsStore } from '../src/client/store.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
import { settingsSchema } from './settings-schema.client.ts'
|
||||
@@ -125,7 +126,7 @@ function harness(options: {
|
||||
set,
|
||||
},
|
||||
}
|
||||
const controller = new ModelsSettingsStore(face as never, settingsSchema)
|
||||
const controller = new ModelsSettingsStore(face as never, settingsSchema, new SettingsDescribeMirror(face as never))
|
||||
const openSection = vi.fn()
|
||||
const complete = vi.fn()
|
||||
const unusedHook = (() => { throw new Error('unused standard hook') }) as never
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ModelsSection, providerCopy } from '../src/client/ModelsSection.tsx'
|
||||
import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx'
|
||||
import { CustomProviderCard } from '../src/client/CustomProviderCard.tsx'
|
||||
import { formatCapacity, parseCapacity } from '../src/client/DeepSeekModelsEditor.tsx'
|
||||
import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
|
||||
import { ModelsSettingsStore, deriveKeyRef, protocolChoices } from '../src/client/store.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
import { settingsSchema } from './settings-schema.client.ts'
|
||||
@@ -140,7 +141,8 @@ function firstMutate(mutate: ReturnType<typeof vi.fn>): MutateCall {
|
||||
|
||||
async function mountSection(options: Parameters<typeof scriptedFace>[0] = {}) {
|
||||
const scripted = scriptedFace(options)
|
||||
const controller = new ModelsSettingsStore(scripted.face as unknown as WireFace, settingsSchema)
|
||||
const controller = new ModelsSettingsStore(
|
||||
scripted.face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(scripted.face as never))
|
||||
await controller.load()
|
||||
const injected: ModelsSectionProps = {
|
||||
controller,
|
||||
@@ -660,7 +662,8 @@ describe('provider rows', () => {
|
||||
active: true,
|
||||
}],
|
||||
}))) as never
|
||||
const controller = new ModelsSettingsStore(scripted.face as unknown as WireFace, settingsSchema)
|
||||
const controller = new ModelsSettingsStore(
|
||||
scripted.face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(scripted.face as never))
|
||||
await controller.load()
|
||||
render(<ModelsSection
|
||||
controller={controller}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/** Page-store join: directory × namespaces × credentials, with last-good rows on failure. */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { messageOf, ModelsSettingsStore } from '../src/client/store.ts'
|
||||
import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
|
||||
import { settingsSchema } from './settings-schema.client.ts'
|
||||
import { messageOf, ModelsSettingsStore } from '../src/client/store.ts'
|
||||
|
||||
let nextRpc = 0
|
||||
function ok<T>(value: T): RpcResponse<T> {
|
||||
@@ -67,13 +68,14 @@ function api(overrides: {
|
||||
unset: () => Promise.resolve(ok({})),
|
||||
},
|
||||
}
|
||||
return { face: face as never, seenRefs }
|
||||
const wire = face as never
|
||||
return { face: wire, mirror: new SettingsDescribeMirror(wire), seenRefs }
|
||||
}
|
||||
|
||||
describe('ModelsSettingsStore', () => {
|
||||
it('joins rows with configured, removable, and credential state', async () => {
|
||||
const { face, seenRefs } = api()
|
||||
const store = new ModelsSettingsStore(face, settingsSchema)
|
||||
const { face, mirror, seenRefs } = api()
|
||||
const store = new ModelsSettingsStore(face, settingsSchema, mirror)
|
||||
await store.load()
|
||||
const state = store.store.getSnapshot()
|
||||
expect(state.status).toBe('ready')
|
||||
@@ -100,8 +102,8 @@ describe('ModelsSettingsStore', () => {
|
||||
})
|
||||
|
||||
it('degrades the credential badge, not the page, when the credential domain fails', async () => {
|
||||
const { face } = api({ describeCredentials: () => Promise.resolve(fail('no provider')) })
|
||||
const store = new ModelsSettingsStore(face, settingsSchema)
|
||||
const { face, mirror } = api({ describeCredentials: () => Promise.resolve(fail('no provider')) })
|
||||
const store = new ModelsSettingsStore(face, settingsSchema, mirror)
|
||||
await store.load()
|
||||
const state = store.store.getSnapshot()
|
||||
expect(state.status).toBe('ready')
|
||||
@@ -110,10 +112,10 @@ describe('ModelsSettingsStore', () => {
|
||||
})
|
||||
|
||||
it('settles a credential transport rejection without leaving the store loading', async () => {
|
||||
const { face } = api({
|
||||
const { face, mirror } = api({
|
||||
describeCredentials: () => Promise.reject(new Error('credential transport down')),
|
||||
})
|
||||
const store = new ModelsSettingsStore(face, settingsSchema)
|
||||
const store = new ModelsSettingsStore(face, settingsSchema, mirror)
|
||||
await expect(store.load()).resolves.toBeUndefined()
|
||||
expect(store.store.getSnapshot()).toMatchObject({
|
||||
status: 'ready',
|
||||
@@ -122,22 +124,21 @@ describe('ModelsSettingsStore', () => {
|
||||
})
|
||||
|
||||
it('stringifies a non-Error credential transport rejection', async () => {
|
||||
const { face } = api({
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario
|
||||
describeCredentials: () => Promise.reject('credential transport refusal'),
|
||||
const { face, mirror } = api({
|
||||
describeCredentials: async () => { throw 'credential transport refusal' },
|
||||
})
|
||||
const store = new ModelsSettingsStore(face, settingsSchema)
|
||||
const store = new ModelsSettingsStore(face, settingsSchema, mirror)
|
||||
await expect(store.load()).resolves.toBeUndefined()
|
||||
expect(store.store.getSnapshot().credentialError).toBe('credential transport refusal')
|
||||
})
|
||||
|
||||
it('surfaces a directory failure and keeps the last good rows', async () => {
|
||||
const { face } = api()
|
||||
const store = new ModelsSettingsStore(face, settingsSchema)
|
||||
const { face, mirror } = api()
|
||||
const store = new ModelsSettingsStore(face, settingsSchema, mirror)
|
||||
await store.load()
|
||||
expect(store.store.getSnapshot().rows).toHaveLength(4)
|
||||
const broken = api({ providers: () => Promise.resolve(fail('directory down')) })
|
||||
const failing = new ModelsSettingsStore(broken.face, settingsSchema)
|
||||
const failing = new ModelsSettingsStore(broken.face, settingsSchema, broken.mirror)
|
||||
await failing.load()
|
||||
expect(failing.store.getSnapshot()).toMatchObject({ status: 'error', error: 'directory down' })
|
||||
// The first store's snapshot is untouched by the second's failure.
|
||||
@@ -148,7 +149,7 @@ describe('ModelsSettingsStore', () => {
|
||||
let release: (() => void) | undefined
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
let call = 0
|
||||
const { face } = api({
|
||||
const { face, mirror } = api({
|
||||
providers: async () => {
|
||||
call += 1
|
||||
if (call === 1) {
|
||||
@@ -158,7 +159,7 @@ describe('ModelsSettingsStore', () => {
|
||||
return ok({ providers: DIRECTORY })
|
||||
},
|
||||
})
|
||||
const store = new ModelsSettingsStore(face, settingsSchema)
|
||||
const store = new ModelsSettingsStore(face, settingsSchema, mirror)
|
||||
const first = store.load()
|
||||
const second = store.load()
|
||||
release?.()
|
||||
@@ -169,7 +170,7 @@ describe('ModelsSettingsStore', () => {
|
||||
|
||||
describe('edge joins', () => {
|
||||
it('treats a non-object profile as having no credential reference', async () => {
|
||||
const { face } = api({
|
||||
const { face, mirror } = api({
|
||||
describeSettings: () => Promise.resolve(ok({
|
||||
writable: true,
|
||||
hasDocument: false,
|
||||
@@ -188,7 +189,7 @@ describe('edge joins', () => {
|
||||
] as never,
|
||||
})),
|
||||
})
|
||||
const store = new ModelsSettingsStore(face, settingsSchema)
|
||||
const store = new ModelsSettingsStore(face, settingsSchema, mirror)
|
||||
await store.load()
|
||||
const state = store.store.getSnapshot()
|
||||
expect(state.rows[0]).toMatchObject({ configured: true, removable: false })
|
||||
@@ -196,7 +197,7 @@ describe('edge joins', () => {
|
||||
})
|
||||
|
||||
it('skips the credential describe entirely when no row names a reference', async () => {
|
||||
const { face, seenRefs } = api({
|
||||
const { face, mirror, seenRefs } = api({
|
||||
describeSettings: () => Promise.resolve(ok({
|
||||
writable: true,
|
||||
hasDocument: false,
|
||||
@@ -208,24 +209,56 @@ describe('edge joins', () => {
|
||||
] as never,
|
||||
})),
|
||||
})
|
||||
const store = new ModelsSettingsStore(face, settingsSchema)
|
||||
const store = new ModelsSettingsStore(face, settingsSchema, mirror)
|
||||
await store.load()
|
||||
expect(seenRefs).toEqual([])
|
||||
expect(store.store.getSnapshot().status).toBe('ready')
|
||||
})
|
||||
|
||||
it('surfaces a settings describe failure', async () => {
|
||||
const { face } = api({ describeSettings: () => Promise.resolve(fail('settings down')) })
|
||||
const store = new ModelsSettingsStore(face, settingsSchema)
|
||||
const { face, mirror } = api({ describeSettings: () => Promise.resolve(fail('settings down')) })
|
||||
const store = new ModelsSettingsStore(face, settingsSchema, mirror)
|
||||
await store.load()
|
||||
expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'settings down' })
|
||||
})
|
||||
|
||||
it('reports a terminally unavailable settings mirror precisely', async () => {
|
||||
const { face } = api()
|
||||
const store = new ModelsSettingsStore(
|
||||
face,
|
||||
settingsSchema,
|
||||
new SettingsDescribeMirror(face, 'memory'),
|
||||
)
|
||||
await store.load()
|
||||
expect(store.store.getSnapshot()).toMatchObject({
|
||||
status: 'error',
|
||||
error: 'settings are unavailable in this browser',
|
||||
})
|
||||
})
|
||||
|
||||
it('reuses a held settings view after its refresh fails', async () => {
|
||||
let settingsCall = 0
|
||||
const { face, mirror } = api({
|
||||
describeSettings: () => {
|
||||
settingsCall += 1
|
||||
return Promise.resolve(settingsCall === 1
|
||||
? ok({ writable: true, hasDocument: false, namespaces: NAMESPACES })
|
||||
: fail('settings refresh down'))
|
||||
},
|
||||
})
|
||||
const store = new ModelsSettingsStore(face, settingsSchema, mirror)
|
||||
await store.load()
|
||||
await mirror.load()
|
||||
expect(mirror.getSnapshot().error).toBe('settings refresh down')
|
||||
await store.load()
|
||||
expect(store.store.getSnapshot()).toMatchObject({ status: 'ready', error: null })
|
||||
expect(store.store.getSnapshot().rows).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('stringifies a non-Error load failure', async () => {
|
||||
// The wire can surface non-Error throwables; the store must stringify them.
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario
|
||||
const { face } = api({ providers: () => Promise.reject('plain refusal') })
|
||||
const store = new ModelsSettingsStore(face, settingsSchema)
|
||||
const { face, mirror } = api({ providers: async () => { throw 'plain refusal' } })
|
||||
const store = new ModelsSettingsStore(face, settingsSchema, mirror)
|
||||
await store.load()
|
||||
expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'plain refusal' })
|
||||
})
|
||||
@@ -234,7 +267,7 @@ describe('edge joins', () => {
|
||||
let release: (() => void) | undefined
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
let call = 0
|
||||
const { face } = api({
|
||||
const { face, mirror } = api({
|
||||
providers: async () => {
|
||||
call += 1
|
||||
if (call === 1) {
|
||||
@@ -244,7 +277,7 @@ describe('edge joins', () => {
|
||||
return ok({ providers: DIRECTORY })
|
||||
},
|
||||
})
|
||||
const store = new ModelsSettingsStore(face, settingsSchema)
|
||||
const store = new ModelsSettingsStore(face, settingsSchema, mirror)
|
||||
const first = store.load()
|
||||
const second = store.load()
|
||||
await second
|
||||
|
||||
@@ -2,9 +2,17 @@
|
||||
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 { Context } from '@deepseek-ai/cordis'
|
||||
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts'
|
||||
import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
|
||||
import { SettingsScopeController } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-scope.ts'
|
||||
|
||||
/** Stateless schema service for scope construction in this jsdom fixture. */
|
||||
const schemaService = new SettingsSchemaService(new Context())
|
||||
import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
|
||||
import type { WelcomeNoticeProps } from '../src/client/WelcomeNotice.tsx'
|
||||
import { WelcomeNoticeStore } from '../src/client/welcome-store.ts'
|
||||
import { decodeWelcomeSection, WelcomeNoticeStore } from '../src/client/welcome-store.ts'
|
||||
import type { WelcomeSection } from '../src/client/welcome-store.ts'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
@@ -20,7 +28,24 @@ function response<T>(value: T) {
|
||||
return { rpcId: 'welcome-rpc' as never, result: { ok: true as const, value } }
|
||||
}
|
||||
|
||||
function mount(version?: string, mutateImpl: () => Promise<unknown> = () => Promise.resolve(response({}))) {
|
||||
function welcomeView(value: unknown, revision = 0) {
|
||||
return {
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
schema: {},
|
||||
value,
|
||||
base: {},
|
||||
user: {},
|
||||
applies: 'live' as const,
|
||||
secrets: [],
|
||||
revision,
|
||||
}
|
||||
}
|
||||
|
||||
function mount(
|
||||
version?: string,
|
||||
mutateImpl: () => Promise<unknown> = () =>
|
||||
Promise.resolve(response(welcomeView({ [WELCOME_NOTICE_ACK_FIELD]: WELCOME_NOTICE_VERSION }, 1))),
|
||||
) {
|
||||
const appRoot = document.createElement('div')
|
||||
appRoot.id = 'root'
|
||||
document.body.append(appRoot)
|
||||
@@ -30,21 +55,21 @@ function mount(version?: string, mutateImpl: () => Promise<unknown> = () => Prom
|
||||
describe: () => Promise.resolve(response({
|
||||
writable: true,
|
||||
hasDocument: false,
|
||||
namespaces: [{
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
schema: {},
|
||||
value: version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version },
|
||||
base: {},
|
||||
user: {},
|
||||
applies: 'live' as const,
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
}],
|
||||
namespaces: [welcomeView(version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version })],
|
||||
})),
|
||||
mutate,
|
||||
},
|
||||
}
|
||||
const controller = new WelcomeNoticeStore(api as never)
|
||||
const mirror = new SettingsDescribeMirror(api as never)
|
||||
const scope = new SettingsScopeController<WelcomeSection>(
|
||||
api as never,
|
||||
{ namespace: WELCOME_NOTICE_SETTINGS_NAMESPACE, decode: decodeWelcomeSection },
|
||||
mirror,
|
||||
'host',
|
||||
schemaService,
|
||||
)
|
||||
const controller = new WelcomeNoticeStore(scope)
|
||||
void mirror.load()
|
||||
const complete = vi.fn()
|
||||
const unusedHook = (() => { throw new Error('unused standard hook') }) as never
|
||||
const props: WelcomeNoticeProps = {
|
||||
@@ -57,7 +82,7 @@ function mount(version?: string, mutateImpl: () => Promise<unknown> = () => Prom
|
||||
useWelcome: bindSnapshotSelector(controller.store),
|
||||
t: key => zh[key],
|
||||
}
|
||||
return { ...render(<WelcomeNotice {...props} />), complete, controller, mutate, appRoot }
|
||||
return { ...render(<WelcomeNotice {...props} />), complete, controller, mirror, mutate, appRoot }
|
||||
}
|
||||
|
||||
describe('WelcomeNotice', () => {
|
||||
@@ -100,7 +125,10 @@ describe('WelcomeNotice', () => {
|
||||
|
||||
it('skips itself when this exact version was already acknowledged', async () => {
|
||||
const h = mount(WELCOME_NOTICE_VERSION)
|
||||
await act(async () => { await h.controller.load() })
|
||||
await act(async () => {
|
||||
await h.mirror.load()
|
||||
await h.controller.load()
|
||||
})
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(h.complete).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -1,40 +1,58 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from '../src/client/welcome-store.ts'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts'
|
||||
import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
|
||||
import { SettingsScopeController } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-scope.ts'
|
||||
import { decodeWelcomeSection, WelcomeNoticeStore } from '../src/client/welcome-store.ts'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
|
||||
} from '../src/onboarding-copy.ts'
|
||||
|
||||
const schemaService = new SettingsSchemaService(new Context())
|
||||
|
||||
let rpc = 0
|
||||
function ok<T>(value: T): RpcResponse<T> {
|
||||
return { rpcId: `welcome-${rpc++}` as never, result: { ok: true, value } }
|
||||
}
|
||||
|
||||
function namespace(version?: string) {
|
||||
function namespace(value: unknown = {}, revision = 0) {
|
||||
return {
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
schema: {},
|
||||
value: version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version },
|
||||
base: {},
|
||||
user: {},
|
||||
value,
|
||||
applies: 'live' as const,
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
revision,
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
|
||||
return { promise, resolve, reject }
|
||||
function acknowledgedNamespace(version: string, revision = 1) {
|
||||
return namespace({ [WELCOME_NOTICE_ACK_FIELD]: version }, revision)
|
||||
}
|
||||
|
||||
/** The welcome store over a real mirror-derived scope and a fake wire. */
|
||||
function buildWelcome(
|
||||
api: { describe?: ReturnType<typeof vi.fn>; mutate?: ReturnType<typeof vi.fn> },
|
||||
persistence: 'host' | 'memory' = 'host',
|
||||
) {
|
||||
const wire = { settings: api } as never
|
||||
const mirror = new SettingsDescribeMirror(wire, persistence)
|
||||
const scope = new SettingsScopeController(
|
||||
wire,
|
||||
{ namespace: WELCOME_NOTICE_SETTINGS_NAMESPACE, decode: decodeWelcomeSection },
|
||||
mirror,
|
||||
persistence,
|
||||
schemaService,
|
||||
)
|
||||
return { mirror, controller: new WelcomeNoticeStore(scope) }
|
||||
}
|
||||
|
||||
describe('WelcomeNoticeStore', () => {
|
||||
it('acknowledges in memory without calling loopback-only settings APIs', async () => {
|
||||
const describe = vi.fn()
|
||||
const describeCall = vi.fn()
|
||||
const mutate = vi.fn()
|
||||
const controller = new WelcomeNoticeStore({ settings: { describe, mutate } } as never, 'memory')
|
||||
const { controller } = buildWelcome({ describe: describeCall, mutate }, 'memory')
|
||||
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: false, error: null })
|
||||
@@ -42,7 +60,7 @@ describe('WelcomeNoticeStore', () => {
|
||||
expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null })
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null })
|
||||
expect(describe).not.toHaveBeenCalled()
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -52,148 +70,101 @@ describe('WelcomeNoticeStore', () => {
|
||||
['older-copy', false],
|
||||
[WELCOME_NOTICE_VERSION, true],
|
||||
] as const) {
|
||||
const api = {
|
||||
settings: {
|
||||
describe: vi.fn(() => Promise.resolve(ok({
|
||||
writable: true, hasDocument: false, namespaces: [namespace(version)],
|
||||
}))),
|
||||
},
|
||||
}
|
||||
const controller = new WelcomeNoticeStore(api as never)
|
||||
const describeCall = vi.fn(() => Promise.resolve(ok({
|
||||
writable: true,
|
||||
hasDocument: false,
|
||||
namespaces: [version === undefined ? namespace() : acknowledgedNamespace(version)],
|
||||
})))
|
||||
const { mirror, controller } = buildWelcome({ describe: describeCall })
|
||||
await mirror.load()
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged })
|
||||
}
|
||||
})
|
||||
|
||||
it('persists the owner version through one idempotent path mutation', async () => {
|
||||
const mutate = vi.fn(() => Promise.resolve(ok(namespace(WELCOME_NOTICE_VERSION))))
|
||||
const controller = new WelcomeNoticeStore({ settings: { mutate } } as never)
|
||||
it('persists the owner version through one revision-fenced mutation', async () => {
|
||||
const describeCall = vi.fn(() => Promise.resolve(ok({
|
||||
writable: true, hasDocument: false, namespaces: [namespace({}, 3)],
|
||||
})))
|
||||
const mutate = vi.fn(() => Promise.resolve(ok(acknowledgedNamespace(WELCOME_NOTICE_VERSION, 4))))
|
||||
const { mirror, controller } = buildWelcome({ describe: describeCall, mutate })
|
||||
await mirror.load()
|
||||
await controller.load()
|
||||
await expect(controller.acknowledge()).resolves.toBe(true)
|
||||
expect(mutate).toHaveBeenCalledWith({
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
ops: [{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION }],
|
||||
expectedRevision: 3,
|
||||
})
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
|
||||
// The write answer folded into the mirror; no re-read followed.
|
||||
expect(describeCall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps the notice pending when loading or persistence fails', async () => {
|
||||
const load = new WelcomeNoticeStore({
|
||||
settings: { describe: () => Promise.reject(new Error('offline')) },
|
||||
} as never)
|
||||
await load.load()
|
||||
expect(load.store.getSnapshot()).toEqual({ status: 'error', acknowledged: false, error: 'offline' })
|
||||
|
||||
const save = new WelcomeNoticeStore({
|
||||
settings: { mutate: () => Promise.reject(new Error('disk full')) },
|
||||
} as never)
|
||||
await expect(save.acknowledge()).resolves.toBe(false)
|
||||
expect(save.store.getSnapshot()).toEqual({ status: 'error', acknowledged: false, error: 'disk full' })
|
||||
|
||||
const nonError = new WelcomeNoticeStore({
|
||||
// Durable/wire failures are unknown; exercise containment of a non-Error rejection.
|
||||
settings: { describe: () => Promise.reject(new Error('offline string')) },
|
||||
} as never)
|
||||
await nonError.load()
|
||||
expect(nonError.store.getSnapshot().error).toBe('offline string')
|
||||
it('keeps the notice pending while the settings read has not answered', async () => {
|
||||
const describeCall = vi.fn(() => Promise.reject(new Error('offline')))
|
||||
const { mirror, controller } = buildWelcome({ describe: describeCall })
|
||||
await mirror.load()
|
||||
await controller.load()
|
||||
// No answer stands, so the step renders nothing and never acknowledges.
|
||||
expect(controller.store.getSnapshot()).toEqual({ status: 'loading', acknowledged: false, error: null })
|
||||
})
|
||||
|
||||
it('reports business failures, missing namespaces, and malformed durable values', async () => {
|
||||
for (const describe of [
|
||||
() => Promise.resolve({
|
||||
rpcId: 'failed' as never,
|
||||
result: { ok: false as const, error: { code: 'internal' as const, message: 'denied', details: {} } },
|
||||
}),
|
||||
() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })),
|
||||
]) {
|
||||
const controller = new WelcomeNoticeStore({ settings: { describe } } as never)
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot().status).toBe('error')
|
||||
}
|
||||
it('reports a failed or refused persistence attempt after its recovery read', async () => {
|
||||
const describeCall = vi.fn(() => Promise.resolve(ok({
|
||||
writable: true, hasDocument: false, namespaces: [namespace()],
|
||||
})))
|
||||
const mutate = vi.fn(() => Promise.reject(new Error('disk full')))
|
||||
const { mirror, controller } = buildWelcome({ describe: describeCall, mutate })
|
||||
await mirror.load()
|
||||
await controller.load()
|
||||
await expect(controller.acknowledge()).resolves.toBe(false)
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
status: 'error',
|
||||
acknowledged: false,
|
||||
error: 'the acknowledgement did not persist',
|
||||
})
|
||||
// The failed latest write triggered one mirror recovery read.
|
||||
expect(describeCall).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reports a missing namespace as an error instead of a silent skip', async () => {
|
||||
const describeCall = vi.fn(() => Promise.resolve(ok({
|
||||
writable: true, hasDocument: false, namespaces: [],
|
||||
})))
|
||||
const { mirror, controller } = buildWelcome({ describe: describeCall })
|
||||
await mirror.load()
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
status: 'error',
|
||||
error: 'welcome acknowledgement settings are unavailable',
|
||||
})
|
||||
})
|
||||
|
||||
it('reads malformed durable values as unacknowledged', async () => {
|
||||
for (const value of [null, 42, { [WELCOME_NOTICE_ACK_FIELD]: 42 }]) {
|
||||
const controller = new WelcomeNoticeStore({
|
||||
settings: { describe: () => Promise.resolve(ok({
|
||||
writable: true,
|
||||
hasDocument: false,
|
||||
namespaces: [{ ...namespace(), value }],
|
||||
})) },
|
||||
} as never)
|
||||
const describeCall = vi.fn(() => Promise.resolve(ok({
|
||||
writable: true, hasDocument: false, namespaces: [namespace(value)],
|
||||
})))
|
||||
const { mirror, controller } = buildWelcome({ describe: describeCall })
|
||||
await mirror.load()
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: false })
|
||||
}
|
||||
|
||||
const save = new WelcomeNoticeStore({
|
||||
settings: { mutate: () => Promise.resolve({
|
||||
rpcId: 'failed-save' as never,
|
||||
result: {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'settings-rejected',
|
||||
message: 'denied',
|
||||
details: { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE },
|
||||
},
|
||||
},
|
||||
}) },
|
||||
} as never)
|
||||
await expect(save.acknowledge()).resolves.toBe(false)
|
||||
expect(save.store.getSnapshot().error).toBe('denied')
|
||||
})
|
||||
|
||||
it('lets the latest load win over stale success and failure', async () => {
|
||||
const first = deferred<ReturnType<typeof ok>>()
|
||||
const describe = vi.fn()
|
||||
.mockImplementationOnce(() => first.promise)
|
||||
.mockImplementationOnce(() => Promise.resolve(ok({
|
||||
writable: true, hasDocument: false, namespaces: [namespace()],
|
||||
})))
|
||||
const controller = new WelcomeNoticeStore({ settings: { describe } } as never)
|
||||
const stale = controller.load()
|
||||
it('follows a later document change without an own read', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: false, namespaces: [namespace()] }))
|
||||
.mockResolvedValueOnce(ok({
|
||||
writable: true, hasDocument: false,
|
||||
namespaces: [acknowledgedNamespace(WELCOME_NOTICE_VERSION)],
|
||||
}))
|
||||
const { mirror, controller } = buildWelcome({ describe: describeCall })
|
||||
await mirror.load()
|
||||
await controller.load()
|
||||
first.resolve(ok({
|
||||
writable: true, hasDocument: false, namespaces: [namespace(WELCOME_NOTICE_VERSION)],
|
||||
}))
|
||||
await stale
|
||||
expect(controller.store.getSnapshot().acknowledged).toBe(false)
|
||||
|
||||
const failed = deferred<ReturnType<typeof ok>>()
|
||||
describe
|
||||
.mockImplementationOnce(() => failed.promise)
|
||||
.mockImplementationOnce(() => Promise.resolve(ok({
|
||||
writable: true, hasDocument: false, namespaces: [namespace(WELCOME_NOTICE_VERSION)],
|
||||
})))
|
||||
const staleFailure = controller.load()
|
||||
await controller.load()
|
||||
failed.reject('stale failure')
|
||||
await staleFailure
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true, error: null })
|
||||
})
|
||||
|
||||
it('contains stale acknowledgement settlements and refreshes only a loaded store', async () => {
|
||||
const write = deferred<ReturnType<typeof ok>>()
|
||||
const describe = vi.fn(() => Promise.resolve(ok({
|
||||
writable: true, hasDocument: false, namespaces: [namespace()],
|
||||
})))
|
||||
const controller = new WelcomeNoticeStore({
|
||||
settings: { mutate: () => write.promise, describe },
|
||||
} as never)
|
||||
refreshWelcomeIfLoaded(controller)
|
||||
expect(describe).not.toHaveBeenCalled()
|
||||
const staleWrite = controller.acknowledge()
|
||||
await controller.load()
|
||||
write.resolve(ok(namespace(WELCOME_NOTICE_VERSION)))
|
||||
await expect(staleWrite).resolves.toBe(true)
|
||||
expect(controller.store.getSnapshot().acknowledged).toBe(false)
|
||||
refreshWelcomeIfLoaded(controller)
|
||||
await vi.waitFor(() => { expect(describe).toHaveBeenCalledTimes(2) })
|
||||
|
||||
const failedWrite = deferred<ReturnType<typeof ok>>()
|
||||
const staleFailure = new WelcomeNoticeStore({
|
||||
settings: { mutate: () => failedWrite.promise, describe },
|
||||
} as never)
|
||||
const pending = staleFailure.acknowledge()
|
||||
await staleFailure.load()
|
||||
failedWrite.reject('late failure')
|
||||
await expect(pending).resolves.toBe(false)
|
||||
expect(staleFailure.store.getSnapshot().status).toBe('ready')
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ acknowledged: false })
|
||||
await mirror.load()
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
|
||||
})
|
||||
})
|
||||
@@ -72,26 +72,17 @@ export function apply(ctx: ClientContext): void {
|
||||
'ui-settings-plugins: credential invalidations',
|
||||
)
|
||||
|
||||
// Which namespaces the Host serves is a registration fact the wire does not
|
||||
// announce, so the directory re-reads on the two signals that can carry a
|
||||
// changed composition: a settings document commit and a reconnect.
|
||||
// Which namespaces the Host serves comes from the shared describe mirror,
|
||||
// whose owning plugin already refreshes it on document commits and
|
||||
// reconnects — the tab only derives.
|
||||
const configurable = new ConfigurablePluginsTabController(
|
||||
api, () => ctx.slots.entries('settings.plugin.item'))
|
||||
ctx.settingsScope.describe(), () => ctx.slots.entries('settings.plugin.item'))
|
||||
ctx.effect(() => () => { configurable.dispose() }, 'ui-settings-plugins: tab directory')
|
||||
ctx.effect(
|
||||
() => ctx.remote.$on('settings/document-updated', () => { void configurable.load() }),
|
||||
'ui-settings-plugins: served-namespace invalidations',
|
||||
)
|
||||
ctx.effect(
|
||||
() => ctx.on('connection/reset', () => { void configurable.load() }),
|
||||
'ui-settings-plugins: served-namespace reconnect',
|
||||
)
|
||||
// A card registered after the first read joins the list without a wire call.
|
||||
ctx.effect(
|
||||
() => ctx.slots.subscribe('settings.plugin.item', () => { configurable.refresh() }),
|
||||
'ui-settings-plugins: card ledger',
|
||||
)
|
||||
void configurable.load()
|
||||
|
||||
let tabsVersion = -1
|
||||
let tabsRevision = -1
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* trace and does not count toward the empty line.
|
||||
*/
|
||||
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
@@ -42,47 +42,23 @@ export interface ConfigurablePluginsTabFace {
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads the served namespaces and pairs them with the cards that claim them. */
|
||||
/** Derives the served namespaces from the shared describe mirror and pairs them with the cards that claim them. */
|
||||
export class ConfigurablePluginsTabController {
|
||||
private readonly store = createSnapshotStore<ConfigurablePluginsTabState>({ loaded: false, namespaces: [] })
|
||||
/** Last Host answer; kept so a slot mutation republishes without a wire read. */
|
||||
private served: readonly string[] = []
|
||||
private loaded = false
|
||||
private generation = 0
|
||||
private disposed = false
|
||||
private readonly unsubscribe: () => void
|
||||
|
||||
/**
|
||||
* @param api - settings wire face.
|
||||
* @param describeFace - the shared mirror's describe face; its refreshes
|
||||
* (document commits, reconnects) are what keep the served set current.
|
||||
* @param entries - reads the cards currently registered into the section's slot.
|
||||
*/
|
||||
constructor(
|
||||
private readonly api: Pick<IApiClient, 'settings'>,
|
||||
private readonly describeFace: SettingsDescribeFace,
|
||||
private readonly entries: () => readonly StoredEntry[],
|
||||
) {}
|
||||
|
||||
/** Opaque read of {@link disposed}: control flow cannot narrow it across awaits. */
|
||||
private isDisposed(): boolean {
|
||||
return this.disposed
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read the served namespaces from the Host and republish.
|
||||
* @returns settlement after the read, or immediately once disposed.
|
||||
*/
|
||||
async load(): Promise<void> {
|
||||
if (this.isDisposed()) return
|
||||
const generation = ++this.generation
|
||||
let response: Awaited<ReturnType<IApiClient['settings']['describe']>>
|
||||
try {
|
||||
response = await this.api.settings.describe({})
|
||||
} catch (_settingsReadFailure) {
|
||||
// The tab keeps the namespaces it last knew; the next invalidation
|
||||
// or reconnect reads again.
|
||||
return
|
||||
}
|
||||
if (this.isDisposed() || generation !== this.generation || !response.result.ok) return
|
||||
this.served = response.result.value.namespaces.map(view => view.ns)
|
||||
this.loaded = true
|
||||
) {
|
||||
this.unsubscribe = describeFace.subscribe(() => { this.publish() })
|
||||
void describeFace.ensure()
|
||||
this.publish()
|
||||
}
|
||||
|
||||
@@ -92,10 +68,10 @@ export class ConfigurablePluginsTabController {
|
||||
this.publish()
|
||||
}
|
||||
|
||||
/** Stop publishing; an in-flight read settles without touching the store. */
|
||||
/** Stop publishing and stop following the mirror. */
|
||||
dispose(): void {
|
||||
this.disposed = true
|
||||
this.generation += 1
|
||||
this.unsubscribe()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,17 +83,20 @@ export class ConfigurablePluginsTabController {
|
||||
}
|
||||
|
||||
private publish(): void {
|
||||
const served = new Set(this.served)
|
||||
if (this.disposed) return
|
||||
const mirrored = this.describeFace.getSnapshot()
|
||||
const loaded = mirrored.view !== undefined
|
||||
const served = new Set(mirrored.view?.namespaces.map(view => view.ns) ?? [])
|
||||
const namespaces = this.entries().flatMap(entry =>
|
||||
entry.options.key !== undefined && served.has(entry.options.key) ? [entry.options.key] : [])
|
||||
const previous = this.store.getSnapshot()
|
||||
// Every settings-document commit re-reads, and most of them change nothing
|
||||
// this section shows. An observable source must keep its snapshot
|
||||
// reference until the fact moves, or each unrelated save re-renders the
|
||||
// whole card list (packages/client/AGENTS.md reactive rule 5).
|
||||
if (previous.loaded === this.loaded
|
||||
// Every settings-document commit refreshes the mirror, and most commits
|
||||
// change nothing this section shows. An observable source must keep its
|
||||
// snapshot reference until the fact moves, or each unrelated save
|
||||
// re-renders the whole card list (packages/client/AGENTS.md reactive rule 5).
|
||||
if (previous.loaded === loaded
|
||||
&& previous.namespaces.length === namespaces.length
|
||||
&& previous.namespaces.every((ns, index) => ns === namespaces[index])) return
|
||||
this.store.set({ loaded: this.loaded, namespaces })
|
||||
this.store.set({ loaded, namespaces })
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,7 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts'
|
||||
import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-scope.ts'
|
||||
import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
|
||||
import type {
|
||||
ConfigurablePluginsTabFace, PluginsSettingsSectionInjected,
|
||||
@@ -53,7 +52,7 @@ async function bench(served?: string[]) {
|
||||
credentials: { describe: describeCredentials },
|
||||
},
|
||||
} as never)
|
||||
await ctx.plugin(SettingsScopeBinder, new SettingsSchemaService(ctx)).await()
|
||||
await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
|
||||
return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings }
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-clie
|
||||
import { CardForm, numberField, textField } from '../src/client/card-form.ts'
|
||||
import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-card-controller.ts'
|
||||
import { BashCardController, type BashSettings } from '../src/client/bash-card-controller.ts'
|
||||
import {
|
||||
SettingsDescribeMirror, type SettingsMirrorSnapshot,
|
||||
} from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
|
||||
import { ConfigurablePluginsTabController } from '../src/client/tab-store.ts'
|
||||
import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-card-controller.ts'
|
||||
|
||||
@@ -555,7 +558,7 @@ describe('ConfigurablePluginsTabController', () => {
|
||||
},
|
||||
},
|
||||
}))
|
||||
return { api: { settings: { describe } } as never, describe }
|
||||
return { mirror: new SettingsDescribeMirror({ settings: { describe } } as never), describe }
|
||||
}
|
||||
|
||||
/** Slot ledger stand-in: one stored entry per registered card key. */
|
||||
@@ -565,9 +568,9 @@ describe('ConfigurablePluginsTabController', () => {
|
||||
|
||||
it('dispatches the served namespaces a card claims, in card registration order', async () => {
|
||||
const settings = settingsApi(['bash', 'ui-theme', 'agent-loop'])
|
||||
const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('agent-loop', 'bash'))
|
||||
const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('agent-loop', 'bash'))
|
||||
|
||||
await controller.load()
|
||||
await settings.mirror.ensure()
|
||||
|
||||
// ui-theme is served but claimed by no card here — another surface owns
|
||||
// it. The order is the cards', not the Host's: plugin activation can
|
||||
@@ -578,9 +581,9 @@ describe('ConfigurablePluginsTabController', () => {
|
||||
|
||||
it('never dispatches a card whose namespace this deployment does not serve', async () => {
|
||||
const settings = settingsApi(['bash'])
|
||||
const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash', 'web-search-deepseek'))
|
||||
const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('bash', 'web-search-deepseek'))
|
||||
|
||||
await controller.load()
|
||||
await settings.mirror.ensure()
|
||||
|
||||
expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash'])
|
||||
})
|
||||
@@ -588,8 +591,8 @@ describe('ConfigurablePluginsTabController', () => {
|
||||
it('takes a card registered after the read without asking the Host again', async () => {
|
||||
const settings = settingsApi(['bash'])
|
||||
let entries = ledger()
|
||||
const controller = new ConfigurablePluginsTabController(settings.api, () => entries)
|
||||
await controller.load()
|
||||
const controller = new ConfigurablePluginsTabController(settings.mirror, () => entries)
|
||||
await settings.mirror.ensure()
|
||||
expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual([])
|
||||
|
||||
entries = ledger('bash')
|
||||
@@ -599,34 +602,33 @@ describe('ConfigurablePluginsTabController', () => {
|
||||
expect(settings.describe).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the namespaces it knew when a read fails', async () => {
|
||||
it('keeps the namespaces it knew when a refresh fails', async () => {
|
||||
const settings = settingsApi(['bash'])
|
||||
const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash'))
|
||||
await controller.load()
|
||||
const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('bash'))
|
||||
await settings.mirror.ensure()
|
||||
settings.describe.mockRejectedValueOnce(new Error('offline'))
|
||||
|
||||
await controller.load()
|
||||
await settings.mirror.load()
|
||||
|
||||
expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash'])
|
||||
})
|
||||
|
||||
it('publishes nothing once disposed, and never claims it was answered', async () => {
|
||||
it('stops following the mirror once disposed, and never claims it was answered', async () => {
|
||||
const settings = settingsApi(['bash'])
|
||||
const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash'))
|
||||
const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('bash'))
|
||||
|
||||
controller.dispose()
|
||||
await controller.load()
|
||||
await settings.mirror.load()
|
||||
|
||||
expect(controller.inject().hooks.configurablePlugins.getSnapshot())
|
||||
.toEqual({ loaded: false, namespaces: [] })
|
||||
expect(settings.describe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores a slot-ledger change that arrives after disposal', async () => {
|
||||
const settings = settingsApi(['bash'])
|
||||
let entries = ledger()
|
||||
const controller = new ConfigurablePluginsTabController(settings.api, () => entries)
|
||||
await controller.load()
|
||||
const controller = new ConfigurablePluginsTabController(settings.mirror, () => entries)
|
||||
await settings.mirror.ensure()
|
||||
|
||||
controller.dispose()
|
||||
entries = ledger('bash')
|
||||
@@ -635,33 +637,49 @@ describe('ConfigurablePluginsTabController', () => {
|
||||
expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual([])
|
||||
})
|
||||
|
||||
it('drops a read a newer one superseded', async () => {
|
||||
// The section re-reads on every settings-document invalidation, so a slow
|
||||
// first answer must not overwrite the newer one that already landed.
|
||||
const settings = settingsApi(['bash'])
|
||||
const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash', 'agent-loop'))
|
||||
const slow = Promise.withResolvers<unknown>()
|
||||
settings.describe.mockReturnValueOnce(slow.promise as never)
|
||||
const stale = controller.load()
|
||||
it('ignores a mirror notification already queued when disposal starts', () => {
|
||||
let notify = (): void => {}
|
||||
let snapshot: SettingsMirrorSnapshot = {
|
||||
status: 'ready' as const,
|
||||
view: { writable: true, hasDocument: true, namespaces: [] },
|
||||
error: null,
|
||||
}
|
||||
const describeFace = {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe: (listener: () => void) => {
|
||||
notify = listener
|
||||
return () => {}
|
||||
},
|
||||
ensure: () => Promise.resolve(),
|
||||
acceptView: vi.fn(),
|
||||
} as never
|
||||
const controller = new ConfigurablePluginsTabController(describeFace, () => ledger('bash'))
|
||||
expect(controller.inject().hooks.configurablePlugins.getSnapshot())
|
||||
.toEqual({ loaded: true, namespaces: [] })
|
||||
|
||||
await controller.load()
|
||||
expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash'])
|
||||
slow.resolve({
|
||||
rpcId: 's-0',
|
||||
result: { ok: true, value: { writable: true, hasDocument: true, namespaces: [
|
||||
{ ns: 'agent-loop', schema: {}, value: {}, applies: 'live', secrets: [], revision: 0 },
|
||||
] } },
|
||||
})
|
||||
await stale
|
||||
controller.dispose()
|
||||
snapshot = {
|
||||
status: 'ready',
|
||||
view: {
|
||||
writable: true,
|
||||
hasDocument: true,
|
||||
namespaces: [{
|
||||
ns: 'bash', schema: {}, value: {}, applies: 'live', secrets: [], revision: 1,
|
||||
}],
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
notify()
|
||||
|
||||
expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash'])
|
||||
expect(controller.inject().hooks.configurablePlugins.getSnapshot())
|
||||
.toEqual({ loaded: true, namespaces: [] })
|
||||
})
|
||||
|
||||
it('reports the Host answered even when it serves nothing this tab shows', async () => {
|
||||
const settings = settingsApi(['ui-theme'])
|
||||
const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash'))
|
||||
const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('bash'))
|
||||
|
||||
await controller.load()
|
||||
await settings.mirror.ensure()
|
||||
|
||||
expect(controller.inject().hooks.configurablePlugins.getSnapshot())
|
||||
.toEqual({ loaded: true, namespaces: [] })
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-settings/README.md
|
||||
README.md: 723e6f7120c256406daedd2085eef055b72263fe
|
||||
README.zh.md: 42f6f6e09ee520542ddc74344be5619adf5adf02
|
||||
README.md: 990469573309b3a92b5eeb8bc41d89b226dbd0d4
|
||||
README.zh.md: a0ce9d5cf6d4633cec9fbf466083c7a11a947e8e
|
||||
@@ -4,8 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
The settings domain's base layer, with no presentation of its own. It provides `ctx.settingsScope`, the Host transport every preference row binds its durable namespace section through; `ctx.settingsSchema`, the synchronous schema-rehydration, validation, and immutable path-editing service used by settings plugins; and the settings slot types registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.action` (ordered content-header actions), `settings.section` (one page per feature), `settings.plugins.tab` (feature-owned pages inside the Plugins section), and `settings.onboarding` (ordered feature-owned pages). It depends on no `ui-*` presentation package, so any feature that owns a preference can reach it; the settings SHELL — the `sidebar.settings` occupant, its navigation, and the chrome — lives in ui-settings-general, because a shell dependency on ui-sidebar would close a reference graph cycle through ui-layout and ui-theme. The shell's own contract types live beside the shell for the same reason.
|
||||
|
||||
The plugin injects nothing and waits for nothing: schema operations are synchronous, while `ctx.settingsScope.bind(spec)` resolves the wire face through the caller's context at call time. The bound scope's disposer belongs to the calling fiber, and the caller injects `connection` for the transport and `remote` for invalidation. Listeners exist before the first background read starts, so a row's activation never blocks on the settings transport. A bound scope reloads on the forwarded `settings/document-updated` event for its own namespace and on `connection/reset`. Writes carry one field path and the last known namespace revision as `expectedRevision`; a rejected or failed write re-reads unless a newer write already superseded it, and a stale read never publishes over a newer one. The snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode. A field is overridden when it is present in `user`, even when its value equals `base`; `unset` clears that override. Without a `decode` in the spec, a section that is not a plain object, fails its rehydrated schema, or carries a schema envelope this client cannot rehydrate publishes no value at all, so a row renders its own absent state instead of a half-decoded one.
|
||||
|
||||
The plugin injects `connection` and `remote` and owns the one `settings.describe` reader in the browser: a shared mirror holding the whole answer, refreshed on every forwarded `settings/document-updated` event and on `connection/reset` (the first connection included — that read closes the window where a commit lands between the eager read and the SSE subscription). Schema operations are synchronous and live on the `settingsSchema` service. `ctx.settingsScope.bind(spec)` returns a per-namespace scope DERIVED from the mirror on the CALLER's context — the scope's disposer belongs to the calling fiber, binding adds no wire read, a row's activation never blocks on the settings transport, and every derived surface shows the same document revision at any moment. Cross-namespace surfaces (schema introspection, the served-namespace directory, `hasDocument`) read the same mirror through `ctx.settingsScope.describe()`, a read/fold face (`getSnapshot`/`subscribe`/`ensure`, plus `acceptView` folding a write answer in). The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes stay per-scope: one field path fenced by the namespace revision as `expectedRevision`; a committed write folds its answer back into the mirror with no re-read, a rejected or failed latest write triggers one mirror recovery read, and a superseded one leaves recovery to its successor. Without a `decode` in the spec, a section that is not a plain object, fails its rehydrated schema, or carries a schema envelope this client cannot rehydrate publishes no value at all, so a row renders its own absent state instead of a half-decoded one. The cold-boot read count is pinned by `apps/web/tests/startup-rpc-budget.e2e.ts`; a new direct `settings.describe` caller in client code is a regression against it.
|
||||
## Model Experience
|
||||
|
||||
None, as the settings domain base serves browser preference storage and slot declarations; nothing here reaches a model request.
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
|
||||
设置领域的底座,本身不含任何呈现内容。它提供 `ctx.settingsScope`——每个偏好设置行绑定自己那份持久化命名空间分区所用的宿主传输层;`ctx.settingsSchema`——设置插件使用的同步 schema 重建、校验与不可变路径编辑服务;并声明由注册方填充的设置 slot 类型:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.action`(内容标题栏中的有序操作)、`settings.section`(每项功能一页)、`settings.plugins.tab`(“插件”分区内由各功能持有的页面)和 `settings.onboarding`(由各功能持有的有序页面)。它不依赖任何 `ui-*` 呈现包,因此任何持有偏好设置的功能都能够到它;设置**外壳**——`sidebar.settings` 占位方、它的导航与界面框架——位于 ui-settings-general,因为外壳一旦依赖 ui-sidebar,就会经 ui-layout 与 ui-theme 闭合出一条引用图环路。外壳自身的契约类型出于同一原因与外壳放在一起。
|
||||
|
||||
该插件不注入任何服务、也不等待任何服务:schema 操作为同步调用,而 `ctx.settingsScope.bind(spec)` 在调用时经调用方的 context 解析线路面。绑定所得 scope 的 disposer 归调用方 fiber 所有,而由调用方注入 `connection` 取得传输层、注入 `remote` 取得失效通知。监听器在首次后台读取启动之前就已存在,因此某一行的激活绝不会阻塞在设置传输层上。已绑定的 scope 会在收到属于自己命名空间的转发 `settings/document-updated` 事件时、以及在 `connection/reset` 时重新读取。写入携带单一字段路径以及最近已知的命名空间 revision 作为 `expectedRevision`;被拒绝或失败的写入会重新读取,除非已有更新的写入取代了它,而陈旧的读取绝不会覆盖更新的发布结果。快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式。字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等;`unset` 会清除该覆盖。若 spec 未提供 `decode`,则分区不是普通对象、未通过其重建后的 schema 校验、或携带本客户端无法重建的 schema 信封时,一律不发布任何值,于是行渲染自己的缺失状态,而不是一份半解码的值。
|
||||
|
||||
该插件注入 `connection` 与 `remote`,并持有浏览器中唯一的 `settings.describe` 读取方:一面持有完整应答的共享镜像,在每次转发的 `settings/document-updated` 事件与 `connection/reset` 时刷新(首次连接也包含在内——这次读取关闭了「提交落在急切读取与 SSE 订阅之间、其失效通知丢失」的窗口)。schema 操作为同步调用,由 `settingsSchema` 服务承载。`ctx.settingsScope.bind(spec)` 在**调用方**的 context 上返回一个由镜像**派生**的按命名空间 scope——scope 的 disposer 归调用方 fiber 所有,绑定不新增任何线路读取,某一行的激活绝不会阻塞在设置传输层上,且任一时刻每个派生面看到的都是同一份文档 revision。跨命名空间的表面(schema 内省、已服务命名空间目录、`hasDocument`)通过 `ctx.settingsScope.describe()` 读同一面镜像,这是一个读取/折叠面(`getSnapshot`/`subscribe`/`ensure`,另有把写应答折入的 `acceptView`)。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入仍归各 scope:单一字段路径,以命名空间 revision 作为 `expectedRevision` 围栏;提交成功的写入将应答折回镜像、不再重读,被拒绝或失败的最新写入触发一次镜像恢复读取,被取代的写入则把恢复留给后继者。若 spec 未提供 `decode`,则分区不是普通对象、未通过其重建后的 schema 校验、或携带本客户端无法重建的 schema 信封时,一律不发布任何值,于是行渲染自己的缺失状态,而不是一份半解码的值。冷启动读取次数由 `apps/web/tests/startup-rpc-budget.e2e.ts` 钉住;客户端代码中新增直连 `settings.describe` 调用即是对它的回归。
|
||||
## 模型体验
|
||||
|
||||
无。设置领域底座为浏览器提供偏好设置存储与 slot 声明;这里没有任何内容进入模型请求。
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
/**
|
||||
* Settings domain base plugin, browser half. Provides `ctx.settingsScope`, the
|
||||
* settings-namespace Host transport every preference row binds its durable
|
||||
* section through, and owns the canonical slot-type contract for the settings
|
||||
* surface. It depends on no `ui-*` presentation package, so any feature that
|
||||
* owns a preference can reach it: the settings SHELL — the `sidebar.settings`
|
||||
* occupant, its navigation, and the chrome — lives in ui-settings-general,
|
||||
* because a shell dependency on ui-sidebar would close a reference cycle
|
||||
* through ui-layout and ui-theme. Export discipline: packages/client/AGENTS.md.
|
||||
* settings-namespace scope service every preference row binds its durable
|
||||
* section through, and owns the one `settings.describe` reader in the browser:
|
||||
* the describe mirror, whose invalidation subscriptions
|
||||
* (`settings/document-updated`, `connection/reset`) live here so every derived
|
||||
* surface refreshes from a single wire read. It depends on no `ui-*`
|
||||
* presentation package, so any feature that owns a preference can reach it:
|
||||
* the settings SHELL — the `sidebar.settings` occupant, its navigation, and
|
||||
* the chrome — lives in ui-settings-general, because a shell dependency on
|
||||
* ui-sidebar would close a reference cycle through ui-layout and ui-theme.
|
||||
* Export discipline: packages/client/AGENTS.md.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
// Type-only pair supplying `$on` and its key face without dragging a build
|
||||
// artifact into the Host graph (rationale beside the same pair in
|
||||
// settings-scope.ts).
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/types'
|
||||
import type {} from '@deepseek-ai/dsh-settings/types'
|
||||
import { SettingsSchemaService } from './schema.ts'
|
||||
import { SettingsScopeBinder } from './settings-scope.ts'
|
||||
import { SettingsDescribeMirror } from './settings-mirror.ts'
|
||||
|
||||
export type {
|
||||
SettingsGeneralItemOwnerProps, SettingsHeaderOwnerProps, SettingsOnboardingOwnerProps,
|
||||
@@ -19,15 +29,18 @@ export type {
|
||||
export type { SettingsScopeController, SettingsScopeBinder } from './settings-scope.ts'
|
||||
export type { SettingsSchemaService } from './schema.ts'
|
||||
export type { SchemaNode } from './schema.ts'
|
||||
export type { SettingsDescribeFace, SettingsDescribeView, SettingsMirrorSnapshot } from './settings-mirror.ts'
|
||||
|
||||
/**
|
||||
* Required services: none. The transport is resolved per caller through
|
||||
* `this.ctx` at `bind` time, so this plugin waits for nothing.
|
||||
* Required services: the wire handle for the mirror's reads and the forwarded
|
||||
* settings invalidation the mirror refreshes on.
|
||||
*/
|
||||
export const inject = []
|
||||
export const inject = ['connection', 'remote']
|
||||
|
||||
/**
|
||||
* Provide the settings-namespace scope service.
|
||||
* Provide the settings-namespace scope service over one shared describe
|
||||
* mirror, and keep that mirror fresh on the two signals that can move the
|
||||
* settings document: a document commit and a (re)connect.
|
||||
*
|
||||
* Constructing the service in this plugin's fiber keeps its traced methods
|
||||
* bound to each consuming plugin's context.
|
||||
@@ -35,5 +48,22 @@ export const inject = []
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const schema = new SettingsSchemaService(ctx)
|
||||
new SettingsScopeBinder(ctx, schema)
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const mirror = new SettingsDescribeMirror(
|
||||
connection.api,
|
||||
connection.isLoopback ? 'host' : 'memory',
|
||||
)
|
||||
ctx.effect(() => {
|
||||
const disposers = [
|
||||
(ctx.get('remote') as ClientContext['remote']).$on('settings/document-updated', () => { void mirror.load() }),
|
||||
ctx.on('connection/reset', () => { void mirror.load() }),
|
||||
]
|
||||
// The first connection also emits connection/reset, so startup normally
|
||||
// costs two reads (budgeted in startup-rpc-budget.e2e.ts). The in-flight
|
||||
// fold does not merge them into one; it guarantees at most one pending
|
||||
// read at a time and that no invalidation arriving mid-read is lost.
|
||||
void mirror.ensure()
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-settings: describe mirror invalidations')
|
||||
new SettingsScopeBinder(ctx, { mirror, schema })
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Client mirror of the Host settings document: the one `settings.describe`
|
||||
* reader in the browser. Every settings consumer derives from this store —
|
||||
* per-namespace scopes through `SettingsScopeBinder.bind`, cross-namespace
|
||||
* surfaces through the binder's shared describe face — so startup cost and
|
||||
* freshness are properties of this class, not of how many features own a
|
||||
* preference. The Host stays the fact source: the mirror re-reads on the
|
||||
* invalidations its owning plugin subscribes to and folds write answers in
|
||||
* through {@link SettingsDescribeMirror.acceptView}.
|
||||
*/
|
||||
|
||||
import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
type SettingsFace = Pick<IApiClient, 'settings'>
|
||||
|
||||
/** The full `settings.describe` answer the mirror serves. */
|
||||
export interface SettingsDescribeView {
|
||||
/** Every namespace a live Host plugin registered, as the Host reported it. */
|
||||
namespaces: readonly SettingsNamespaceView[]
|
||||
/** Whether the settings provider accepts writes. */
|
||||
writable: boolean
|
||||
/** Whether a native settings document exists for the Host to open. */
|
||||
hasDocument: boolean
|
||||
}
|
||||
|
||||
/** Mirror state every derived settings surface renders from. */
|
||||
export interface SettingsMirrorSnapshot {
|
||||
/**
|
||||
* `unavailable` is the terminal non-loopback state; `ready` persists across
|
||||
* later failed refreshes (the held view keeps serving); `idle` means no
|
||||
* answer is held and no read is running, so `ensure` will start one.
|
||||
*/
|
||||
status: 'idle' | 'loading' | 'ready' | 'unavailable'
|
||||
/** The last good answer; undefined until the first success. */
|
||||
view: SettingsDescribeView | undefined
|
||||
/** The latest refresh failure message, cleared by the next success. */
|
||||
error: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The mirror as cross-namespace surfaces consume it: current answer,
|
||||
* subscription, first-use read, and the write-answer fold. `load` stays off
|
||||
* this face — invalidation refreshes belong to the mirror's owning plugin.
|
||||
*/
|
||||
export interface SettingsDescribeFace {
|
||||
/** @returns the current sync snapshot (stable reference until the next change). */
|
||||
getSnapshot(): SettingsMirrorSnapshot
|
||||
/**
|
||||
* Observe snapshot replacements.
|
||||
* @param listener - invoked after each snapshot change.
|
||||
* @returns the disposer removing this listener.
|
||||
*/
|
||||
subscribe(listener: () => void): () => void
|
||||
/**
|
||||
* Resolve once an answer is held (or the mirror is terminally unavailable),
|
||||
* reading only from `idle`.
|
||||
* @returns settlement of the current or newly started read, if any.
|
||||
*/
|
||||
ensure(): Promise<void>
|
||||
/**
|
||||
* Fold one write answer's namespace view into the held view without a wire
|
||||
* read, invalidating any older read still in flight.
|
||||
* @param view - the namespace view a settings write answered with.
|
||||
*/
|
||||
acceptView(view: SettingsNamespaceView): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes every Host `settings.describe` read behind one snapshot store.
|
||||
* Concurrent {@link load} calls fold into the in-flight read plus one rerun,
|
||||
* so an invalidation arriving mid-read is never lost and never duplicated.
|
||||
*/
|
||||
export class SettingsDescribeMirror implements SettingsDescribeFace {
|
||||
private readonly store: SnapshotStore<SettingsMirrorSnapshot>
|
||||
private inFlight: Promise<void> | undefined
|
||||
private rerun = false
|
||||
private generation = 0
|
||||
|
||||
/**
|
||||
* @param api - settings wire face.
|
||||
* @param persistence - remote browsers stay process-local because settings RPCs are loopback-only.
|
||||
*/
|
||||
constructor(
|
||||
private readonly api: SettingsFace,
|
||||
private readonly persistence: 'host' | 'memory' = 'host',
|
||||
) {
|
||||
this.store = createSnapshotStore<SettingsMirrorSnapshot>({
|
||||
status: persistence === 'host' ? 'idle' : 'unavailable',
|
||||
view: undefined,
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
|
||||
/** @returns the current sync snapshot (stable reference until the next change). */
|
||||
getSnapshot(): SettingsMirrorSnapshot {
|
||||
return this.store.getSnapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe snapshot replacements.
|
||||
* @param listener - invoked after each snapshot change.
|
||||
* @returns the disposer removing this listener.
|
||||
*/
|
||||
subscribe(listener: () => void): () => void {
|
||||
return this.store.subscribe(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh from the Host. A call during an in-flight read marks one rerun
|
||||
* after it settles instead of racing a second wire read.
|
||||
* @returns settlement after this call's freshness is reflected.
|
||||
*/
|
||||
load(): Promise<void> {
|
||||
if (this.persistence === 'memory') return Promise.resolve()
|
||||
if (this.inFlight !== undefined) {
|
||||
this.rerun = true
|
||||
return this.inFlight
|
||||
}
|
||||
// Own the slot before the loading publication can synchronously reenter load().
|
||||
const run = Promise.resolve().then(() => this.run())
|
||||
this.inFlight = run
|
||||
return run
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve once an answer is held (or the mirror is terminally unavailable),
|
||||
* reading only from `idle`. The cheap idempotent entry for surfaces that
|
||||
* render on first use.
|
||||
* @returns settlement of the current or newly started read, if any.
|
||||
*/
|
||||
ensure(): Promise<void> {
|
||||
if (this.persistence === 'memory') return Promise.resolve()
|
||||
if (this.inFlight !== undefined) return this.inFlight
|
||||
if (this.getSnapshot().status === 'idle') return this.load()
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one write answer's namespace view into the held view without a wire
|
||||
* read, and invalidate any read still in flight. With no held document, the
|
||||
* answer is not published as a partial document; an in-flight read reruns so
|
||||
* it cannot publish a document fetched before the write committed.
|
||||
* @param view - the namespace view a settings write answered with.
|
||||
*/
|
||||
acceptView(view: SettingsNamespaceView): void {
|
||||
const before = this.store.getSnapshot()
|
||||
this.generation += 1
|
||||
if (this.inFlight !== undefined) this.rerun = true
|
||||
if (before.view === undefined) return
|
||||
const namespaces = before.view.namespaces.some(row => row.ns === view.ns)
|
||||
? before.view.namespaces.map(row => row.ns === view.ns ? view : row)
|
||||
: [...before.view.namespaces, view]
|
||||
this.store.set({ ...before, view: { ...before.view, namespaces } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience row lookup on the held view.
|
||||
* @param ns - namespace identity.
|
||||
* @returns the namespace view, or undefined while unanswered or unregistered.
|
||||
*/
|
||||
namespace(ns: string): SettingsNamespaceView | undefined {
|
||||
return this.store.getSnapshot().view?.namespaces.find(row => row.ns === ns)
|
||||
}
|
||||
|
||||
private async run(): Promise<void> {
|
||||
// The in-flight slot must clear in the same synchronous segment that
|
||||
// observes `rerun` false (and on abrupt exit): a `.finally()` on the
|
||||
// returned promise runs one microtask later, and a `load()` landing in
|
||||
// that gap would mark a rerun nobody reads, losing the read.
|
||||
try {
|
||||
do {
|
||||
const before = this.store.getSnapshot()
|
||||
if (before.status === 'idle') this.store.set({ ...before, status: 'loading' })
|
||||
// Cleared immediately before the wire read goes out: a load() marked
|
||||
// earlier (including one reentering from the loading publish above)
|
||||
// is covered by this very read, while one landing after needs the
|
||||
// rerun.
|
||||
this.rerun = false
|
||||
const generation = ++this.generation
|
||||
let outcome: { view: SettingsDescribeView } | { failure: string }
|
||||
try {
|
||||
const response = await this.api.settings.describe({})
|
||||
outcome = response.result.ok
|
||||
? { view: response.result.value }
|
||||
: { failure: response.result.error.message }
|
||||
} catch (error) {
|
||||
outcome = { failure: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
// A write answer invalidates a document read before that write committed.
|
||||
if (generation !== this.generation) continue
|
||||
if ('view' in outcome) {
|
||||
this.store.set({ status: 'ready', view: outcome.view, error: null })
|
||||
} else {
|
||||
const held = this.store.getSnapshot()
|
||||
// No answer yet: fall back to idle so `ensure` retries; with one, the
|
||||
// held view keeps serving and only the error field reports the miss.
|
||||
this.store.set({
|
||||
status: held.view === undefined ? 'idle' : 'ready',
|
||||
view: held.view,
|
||||
error: outcome.failure,
|
||||
})
|
||||
}
|
||||
} while (this.shouldRerun())
|
||||
} finally {
|
||||
this.inFlight = undefined
|
||||
}
|
||||
}
|
||||
|
||||
private shouldRerun(): boolean {
|
||||
return this.rerun
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
/**
|
||||
* Host transport for the settings-namespace scope contract. The contract types
|
||||
* live in `dsh-client-runtime` (the common dependency of every feature that
|
||||
* owns a preference); this file owns the wire behavior and the invalidation
|
||||
* subscription, both of which are Settings-surface concerns.
|
||||
* owns a preference); this file owns the per-namespace derivation over the
|
||||
* shared {@link SettingsDescribeMirror} and the serialized write path, both of
|
||||
* which are Settings-surface concerns. Reads never touch the wire here: the
|
||||
* mirror is the one `settings.describe` reader, and every scope is a selector
|
||||
* over its snapshot.
|
||||
*/
|
||||
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
@@ -21,8 +24,8 @@ import {
|
||||
// Client half declares `ctx.remote` with no generated import, and the
|
||||
// allowlist's `types` subpath is a pure-type source file, so the pair supplies
|
||||
// `$on` and its key face without dragging a build artifact in. The runtime
|
||||
// `remote` injection belongs to whoever calls `ctx.settingsScope.bind(spec)`: the
|
||||
// subscription is registered on the caller's own context.
|
||||
// `remote` injection belongs to the providing plugin's apply, which registers
|
||||
// the mirror's invalidation subscriptions.
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/types'
|
||||
// The forwarded event's own declaration: `$on`'s key face is
|
||||
@@ -31,30 +34,40 @@ import type {} from '@deepseek-ai/dsh-api-remotes/types'
|
||||
// cordis `Events` entry (and with it the branded `SettingsNamespace`).
|
||||
import type {} from '@deepseek-ai/dsh-settings/types'
|
||||
import type { SettingsSchemaService } from './schema.ts'
|
||||
import { SettingsDescribeMirror, type SettingsDescribeFace } from './settings-mirror.ts'
|
||||
|
||||
type SettingsFace = Pick<IApiClient, 'settings'>
|
||||
|
||||
/**
|
||||
* Serializes one namespace's Host reads and writes behind a snapshot store.
|
||||
* Reads never block plugin activation; writes carry the latest known
|
||||
* namespace revision and teardown waits for the operation already crossing
|
||||
* the wire.
|
||||
* One namespace's derived view over the shared describe mirror, plus that
|
||||
* namespace's serialized Host writes. Writes carry the latest known namespace
|
||||
* revision, fold their answers back into the mirror, and teardown waits for
|
||||
* the operation already crossing the wire.
|
||||
*/
|
||||
export class SettingsScopeController<T> implements SettingsScope<T> {
|
||||
private readonly store: SnapshotStore<SettingsScopeSnapshot<T>>
|
||||
private tail: Promise<void> = Promise.resolve()
|
||||
private readGeneration = 0
|
||||
private writeGeneration = 0
|
||||
private disposed = false
|
||||
private readonly unsubscribe: (() => void) | undefined
|
||||
/**
|
||||
* Revision answered by a superseded write still ahead of the mirror: the
|
||||
* mirror only folds the LATEST settlement in, so a queued successor takes
|
||||
* its fence from here first.
|
||||
*/
|
||||
private pendingRevision: number | undefined
|
||||
|
||||
/**
|
||||
* @param api - settings wire face.
|
||||
* @param api - settings wire face (writes only; reads ride the mirror).
|
||||
* @param spec - namespace identity and optional narrowing decoder.
|
||||
* @param mirror - the shared describe mirror this scope derives from.
|
||||
* @param persistence - remote browsers remain process-local because settings RPCs are loopback-only.
|
||||
* @param schema - settings-owned schema operations.
|
||||
*/
|
||||
constructor(
|
||||
private readonly api: SettingsFace,
|
||||
private readonly spec: SettingsScopeSpec<T>,
|
||||
private readonly mirror: SettingsDescribeMirror,
|
||||
private readonly persistence: 'host' | 'memory',
|
||||
private readonly schema: SettingsSchemaService,
|
||||
) {
|
||||
@@ -67,6 +80,10 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
|
||||
writable: false,
|
||||
mode: persistence,
|
||||
})
|
||||
if (persistence === 'host') {
|
||||
this.unsubscribe = mirror.subscribe(() => { this.derive() })
|
||||
this.derive()
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns the current sync snapshot (stable reference until the next change). */
|
||||
@@ -83,15 +100,6 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
|
||||
return this.store.subscribe(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a Host refresh; a newer read or user write suppresses stale publication.
|
||||
* @returns settlement after the queued read completes or is skipped.
|
||||
*/
|
||||
load(): Promise<void> {
|
||||
const generation = ++this.readGeneration
|
||||
return this.enqueue(() => this.read(generation))
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue one field write; see {@link SettingsScope.set} for the ordering,
|
||||
* revision, and recovery contract.
|
||||
@@ -114,10 +122,9 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
|
||||
}
|
||||
|
||||
private write(op: SettingsPathOpView): Promise<void> {
|
||||
this.readGeneration += 1
|
||||
const generation = ++this.writeGeneration
|
||||
return this.enqueue(async () => {
|
||||
const revision = this.getSnapshot().revision
|
||||
const revision = this.pendingRevision ?? this.getSnapshot().revision
|
||||
let response: Awaited<ReturnType<SettingsFace['settings']['mutate']>>
|
||||
try {
|
||||
response = await this.api.settings.mutate({
|
||||
@@ -126,25 +133,39 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
|
||||
...(revision === undefined ? {} : { expectedRevision: revision }),
|
||||
})
|
||||
} catch (_settingsWriteFailure) {
|
||||
if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration)
|
||||
await this.recover(generation)
|
||||
return
|
||||
}
|
||||
if (!response.result.ok) {
|
||||
if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration)
|
||||
await this.recover(generation)
|
||||
return
|
||||
}
|
||||
this.accept(response.result.value, generation === this.writeGeneration)
|
||||
if (this.disposed) return
|
||||
if (generation === this.writeGeneration) {
|
||||
this.pendingRevision = undefined
|
||||
this.mirror.acceptView(response.result.value)
|
||||
} else {
|
||||
this.pendingRevision = response.result.value.revision
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Reload Host state for the latest failed write; superseded failures leave recovery to it. */
|
||||
private async recover(generation: number): Promise<void> {
|
||||
if (this.disposed || generation !== this.writeGeneration) return
|
||||
this.pendingRevision = undefined
|
||||
await this.mirror.load()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop queued operations and wait for the current wire call to settle.
|
||||
* Stop queued operations, stop deriving, and wait for the current wire call
|
||||
* to settle.
|
||||
* @returns settlement after the controller reaches quiescence.
|
||||
*/
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true
|
||||
this.readGeneration += 1
|
||||
this.writeGeneration += 1
|
||||
this.unsubscribe?.()
|
||||
await this.tail
|
||||
}
|
||||
|
||||
@@ -160,36 +181,25 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
|
||||
return task
|
||||
}
|
||||
|
||||
private async read(generation: number): Promise<void> {
|
||||
let response: Awaited<ReturnType<SettingsFace['settings']['describe']>>
|
||||
try {
|
||||
response = await this.api.settings.describe({})
|
||||
} catch (_settingsReadFailure) {
|
||||
return
|
||||
}
|
||||
if (!response.result.ok || this.disposed) return
|
||||
const { namespaces, writable } = response.result.value
|
||||
const view = namespaces.find(candidate => candidate.ns === this.spec.namespace)
|
||||
const publish = generation === this.readGeneration
|
||||
private derive(): void {
|
||||
if (this.disposed) return
|
||||
const mirrored = this.mirror.getSnapshot()
|
||||
if (mirrored.view === undefined) return
|
||||
const { writable } = mirrored.view
|
||||
const view = mirrored.view.namespaces.find(candidate => candidate.ns === this.spec.namespace)
|
||||
if (view === undefined) {
|
||||
if (publish) {
|
||||
this.store.update((draft) => {
|
||||
draft.status = 'unavailable'
|
||||
draft.writable = writable
|
||||
})
|
||||
}
|
||||
this.store.update((draft) => {
|
||||
draft.status = 'unavailable'
|
||||
draft.writable = writable
|
||||
})
|
||||
return
|
||||
}
|
||||
this.accept(view, publish, writable)
|
||||
}
|
||||
|
||||
private accept(view: SettingsNamespaceView, publish: boolean, writable?: boolean): void {
|
||||
const decoded = publish ? this.decode(view) : undefined
|
||||
const decoded = this.decode(view)
|
||||
this.store.update((draft) => {
|
||||
draft.revision = view.revision
|
||||
draft.base = view.base
|
||||
draft.user = view.user
|
||||
if (writable !== undefined) draft.writable = writable
|
||||
draft.writable = writable
|
||||
if (decoded === undefined) return
|
||||
draft.status = 'ready'
|
||||
draft.value = decoded
|
||||
@@ -227,20 +237,38 @@ declare module '@deepseek-ai/cordis' {
|
||||
* (`packages/client/tsdown.client.ts`).
|
||||
*/
|
||||
export class SettingsScopeBinder extends Service {
|
||||
private readonly mirror: SettingsDescribeMirror
|
||||
private readonly schema: SettingsSchemaService
|
||||
|
||||
/**
|
||||
* @param ctx - the providing plugin's context.
|
||||
* @param config - the shared describe mirror every bound scope derives from,
|
||||
* plus the settings-owned schema operations.
|
||||
*/
|
||||
constructor(ctx: Context, private readonly schema: SettingsSchemaService) {
|
||||
constructor(ctx: Context, config: { mirror: SettingsDescribeMirror; schema: SettingsSchemaService }) {
|
||||
super(ctx, 'settingsScope')
|
||||
this.mirror = config.mirror
|
||||
this.schema = config.schema
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind one namespace scope to settings and connection invalidations on the
|
||||
* CALLER's plugin lifecycle — the service proxy binds `this.ctx` to the
|
||||
* caller at call time, so the scope's disposer belongs to the calling fiber.
|
||||
* Listeners exist before the initial background read starts, so activation
|
||||
* never blocks on the settings transport. The caller injects `connection`
|
||||
* for the transport and `remote` for the forwarded settings invalidation.
|
||||
* The shared mirror's read/fold face for cross-namespace surfaces (schema
|
||||
* introspection, the served-namespace directory). Per-namespace consumers
|
||||
* use {@link bind}; both derive from the same snapshot, so they can never
|
||||
* disagree about the document.
|
||||
* @returns the describe face over the shared mirror.
|
||||
*/
|
||||
describe(): SettingsDescribeFace {
|
||||
return this.mirror
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind one namespace scope on the CALLER's plugin lifecycle — the service
|
||||
* proxy binds `this.ctx` to the caller at call time, so the scope's disposer
|
||||
* belongs to the calling fiber. The scope derives from the shared mirror
|
||||
* (whose invalidation subscriptions live with the providing plugin), so
|
||||
* binding adds no wire read of its own and activation never blocks on the
|
||||
* settings transport.
|
||||
* @param spec - domain-owned namespace contract.
|
||||
* @returns the bound scope consumed by the domain's services and rows.
|
||||
*/
|
||||
@@ -250,21 +278,13 @@ export class SettingsScopeBinder extends Service {
|
||||
const controller = new SettingsScopeController<T>(
|
||||
connection.api,
|
||||
spec,
|
||||
this.mirror,
|
||||
connection.isLoopback ? 'host' : 'memory',
|
||||
this.schema,
|
||||
)
|
||||
ctx.effect(() => {
|
||||
const refresh = (namespace?: string): void => {
|
||||
if (namespace !== undefined && namespace !== spec.namespace) return
|
||||
void controller.load()
|
||||
}
|
||||
const disposers = [
|
||||
(ctx.get('remote') as Context['remote']).$on('settings/document-updated', refresh),
|
||||
ctx.on('connection/reset', () => { refresh() }),
|
||||
]
|
||||
void controller.load()
|
||||
void this.mirror.ensure()
|
||||
return async () => {
|
||||
for (const dispose of disposers) dispose()
|
||||
await controller.dispose()
|
||||
}
|
||||
}, `ui-settings: ${spec.namespace} settings scope`)
|
||||
|
||||
@@ -1,33 +1,60 @@
|
||||
/**
|
||||
* The settings domain base plugin's own mounting behavior: it stands up
|
||||
* `ctx.settingsScope` for every feature that owns a preference row, and the
|
||||
* service retires with its fiber.
|
||||
* `ctx.settingsScope` over one shared describe mirror, keeps that mirror
|
||||
* fresh on settings-document and connection-reset invalidations, and retires
|
||||
* both the service and the subscriptions with its fiber.
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { SettingsSchemaService } from '../src/client/schema.ts'
|
||||
import { SettingsScopeBinder } from '../src/client/settings-scope.ts'
|
||||
|
||||
/** Boot the browser half over a bare root context; it injects nothing. */
|
||||
/** Boot the browser half over a fake loopback connection and test remote. */
|
||||
function bench() {
|
||||
const describeCall = vi.fn().mockResolvedValue({
|
||||
rpcId: 'plugin-bench' as never,
|
||||
result: { ok: true, value: { writable: true, hasDocument: true, namespaces: [] } },
|
||||
})
|
||||
const ctx = new Context()
|
||||
return { ctx, fiber: ctx.plugin({ inject: [...inject], apply }) }
|
||||
ctx.provide('connection', {
|
||||
api: { settings: { describe: describeCall } },
|
||||
isLoopback: true,
|
||||
} as never)
|
||||
new TestRemote(ctx)
|
||||
return { ctx, describeCall, fiber: ctx.plugin({ inject: [...inject], apply }) }
|
||||
}
|
||||
|
||||
describe('settings domain base plugin', () => {
|
||||
it('mounts the scope service under settingsScope', async () => {
|
||||
const { ctx, fiber } = bench()
|
||||
it('mounts the scope service under settingsScope and reads once eagerly', async () => {
|
||||
const { ctx, describeCall, fiber } = bench()
|
||||
await fiber.await()
|
||||
expect(ctx.get('settingsScope')).toBeInstanceOf(SettingsScopeBinder)
|
||||
expect(ctx.get('settingsSchema')).toBeInstanceOf(SettingsSchemaService)
|
||||
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(1) })
|
||||
})
|
||||
|
||||
it('fiber disposal retires the service', async () => {
|
||||
const { ctx, fiber } = bench()
|
||||
it('refreshes the mirror on document commits and connection resets, once each', async () => {
|
||||
const { ctx, describeCall, fiber } = bench()
|
||||
await fiber.await()
|
||||
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(1) })
|
||||
ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0])
|
||||
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(2) })
|
||||
ctx.emit('connection/reset')
|
||||
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) })
|
||||
})
|
||||
|
||||
it('fiber disposal retires the service and its invalidation subscriptions', async () => {
|
||||
const { ctx, describeCall, fiber } = bench()
|
||||
await fiber.await()
|
||||
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(1) })
|
||||
await fiber.dispose()
|
||||
expect(ctx.get('settingsScope')).toBeUndefined()
|
||||
expect(ctx.get('settingsSchema')).toBeUndefined()
|
||||
ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0])
|
||||
ctx.emit('connection/reset')
|
||||
await Promise.resolve()
|
||||
expect(describeCall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,216 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { SettingsDescribeMirror, type SettingsDescribeView } from '../src/client/settings-mirror.ts'
|
||||
|
||||
let rpc = 0
|
||||
|
||||
function ok<T>(value: T): RpcResponse<T> {
|
||||
return { rpcId: `mirror-${rpc++}` as never, result: { ok: true, value } }
|
||||
}
|
||||
|
||||
function rejected<T>(message: string): RpcResponse<T> {
|
||||
return {
|
||||
rpcId: `mirror-${rpc++}` as never,
|
||||
result: {
|
||||
ok: false,
|
||||
error: { code: 'settings-rejected', message, details: { ns: 'theme' } },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function view(ns: string, revision = 0): SettingsNamespaceView {
|
||||
return { ns, schema: {}, value: { field: ns }, applies: 'live', secrets: [], revision }
|
||||
}
|
||||
|
||||
function described(namespaces: SettingsNamespaceView[]): RpcResponse<SettingsDescribeView> {
|
||||
return ok({ writable: true, hasDocument: true, namespaces })
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((res) => { resolve = res })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('SettingsDescribeMirror', () => {
|
||||
it('folds loads before the wire read into it, and mid-flight loads into one rerun', async () => {
|
||||
const gate = deferred<RpcResponse<SettingsDescribeView>>()
|
||||
const describeCall = vi.fn()
|
||||
.mockReturnValueOnce(gate.promise)
|
||||
.mockResolvedValue(described([view('theme', 1)]))
|
||||
const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
|
||||
const first = mirror.load()
|
||||
// Issued before the wire read goes out: covered by that read, no rerun.
|
||||
const early = mirror.load()
|
||||
await Promise.resolve()
|
||||
expect(describeCall).toHaveBeenCalledTimes(1)
|
||||
// Issued while the read is on the wire: exactly one rerun, however many.
|
||||
const mid = mirror.load()
|
||||
const midToo = mirror.load()
|
||||
gate.resolve(described([view('theme', 0)]))
|
||||
await Promise.all([first, early, mid, midToo])
|
||||
expect(describeCall).toHaveBeenCalledTimes(2)
|
||||
expect(mirror.getSnapshot().status).toBe('ready')
|
||||
expect(mirror.namespace('theme')?.revision).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps the last good view when a later refresh fails, recording the failure', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described([view('theme', 2)]))
|
||||
.mockRejectedValueOnce(new Error('host gone'))
|
||||
.mockResolvedValueOnce(rejected('busy'))
|
||||
const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
|
||||
await mirror.load()
|
||||
expect(mirror.getSnapshot()).toMatchObject({ status: 'ready', error: null })
|
||||
await mirror.load()
|
||||
expect(mirror.getSnapshot()).toMatchObject({ status: 'ready', error: 'host gone' })
|
||||
expect(mirror.namespace('theme')?.revision).toBe(2)
|
||||
await mirror.load()
|
||||
expect(mirror.getSnapshot()).toMatchObject({ status: 'ready', error: 'busy' })
|
||||
expect(mirror.getSnapshot().view?.namespaces).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('returns to idle after a first read that never succeeded, so ensure retries', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockResolvedValueOnce(described([view('theme', 1)]))
|
||||
const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
|
||||
await mirror.ensure()
|
||||
expect(mirror.getSnapshot()).toMatchObject({ status: 'idle', view: undefined, error: 'offline' })
|
||||
await mirror.ensure()
|
||||
expect(mirror.getSnapshot()).toMatchObject({ status: 'ready', error: null })
|
||||
expect(describeCall).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('treats ensure as a no-op once ready', async () => {
|
||||
const describeCall = vi.fn().mockResolvedValue(described([view('theme', 1)]))
|
||||
const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
|
||||
await mirror.ensure()
|
||||
await mirror.ensure()
|
||||
await mirror.ensure()
|
||||
expect(describeCall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('memory persistence is terminally unavailable and never touches the wire', async () => {
|
||||
const describeCall = vi.fn()
|
||||
const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never, 'memory')
|
||||
await mirror.ensure()
|
||||
await mirror.load()
|
||||
expect(mirror.getSnapshot()).toEqual({ status: 'unavailable', view: undefined, error: null })
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('acceptView folds one write answer into the held view without a wire read', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described([view('theme', 1), view('locale', 4)]))
|
||||
const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
|
||||
await mirror.load()
|
||||
const seen: number[] = []
|
||||
mirror.subscribe(() => { seen.push(mirror.namespace('theme')?.revision ?? -1) })
|
||||
mirror.acceptView(view('theme', 9))
|
||||
expect(mirror.namespace('theme')?.revision).toBe(9)
|
||||
expect(mirror.namespace('locale')?.revision).toBe(4)
|
||||
expect(seen).toEqual([9])
|
||||
expect(describeCall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('acceptView before any answer is a no-op instead of inventing a document', () => {
|
||||
const describeCall = vi.fn()
|
||||
const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
|
||||
mirror.acceptView(view('theme', 1))
|
||||
expect(mirror.getSnapshot()).toEqual({ status: 'idle', view: undefined, error: null })
|
||||
})
|
||||
|
||||
it('acceptView appends a namespace the held view has not seen yet', async () => {
|
||||
const describeCall = vi.fn().mockResolvedValueOnce(described([view('theme', 1)]))
|
||||
const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
|
||||
await mirror.load()
|
||||
mirror.acceptView(view('fresh-ns', 0))
|
||||
expect(mirror.namespace('fresh-ns')).toBeDefined()
|
||||
expect(mirror.getSnapshot().view?.namespaces).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('never loses a load landing between a run settling and its slot clearing', async () => {
|
||||
// Regression: with the in-flight slot cleared by a promise .finally(),
|
||||
// a load() in the one-microtask gap after the rerun check marked a rerun
|
||||
// nobody read, and that refresh never reached the wire.
|
||||
const describeCall = vi.fn().mockResolvedValue(described([view('theme', 1)]))
|
||||
const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
|
||||
void mirror.load()
|
||||
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(1) })
|
||||
void mirror.load()
|
||||
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(2) })
|
||||
void mirror.load()
|
||||
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) })
|
||||
})
|
||||
|
||||
it('starts no second run for a load issued inside the loading publish', async () => {
|
||||
const gate = deferred<RpcResponse<SettingsDescribeView>>()
|
||||
const describeCall = vi.fn().mockReturnValue(gate.promise)
|
||||
const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
|
||||
let reentered = false
|
||||
const unsubscribe = mirror.subscribe(() => {
|
||||
if (reentered) return
|
||||
reentered = true
|
||||
void mirror.load()
|
||||
})
|
||||
const loading = mirror.load()
|
||||
await Promise.resolve()
|
||||
expect(describeCall).toHaveBeenCalledTimes(1)
|
||||
gate.resolve(described([view('theme', 1)]))
|
||||
await loading
|
||||
unsubscribe()
|
||||
// The reentrant load folded into the first run rather than racing it.
|
||||
expect(describeCall).toHaveBeenCalledTimes(1)
|
||||
expect(mirror.getSnapshot().status).toBe('ready')
|
||||
})
|
||||
|
||||
it('lets the first read cover a write folded inside the loading publish', async () => {
|
||||
const describeCall = vi.fn().mockResolvedValue(described([view('theme', 2)]))
|
||||
const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
|
||||
const unsubscribe = mirror.subscribe(() => {
|
||||
unsubscribe()
|
||||
mirror.acceptView(view('theme', 2))
|
||||
})
|
||||
|
||||
await mirror.load()
|
||||
|
||||
expect(describeCall).toHaveBeenCalledTimes(1)
|
||||
expect(mirror.getSnapshot().status).toBe('ready')
|
||||
expect(mirror.namespace('theme')?.revision).toBe(2)
|
||||
})
|
||||
|
||||
it('re-reads after a folded write invalidates an in-flight document', async () => {
|
||||
const slow = deferred<RpcResponse<SettingsDescribeView>>()
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described([view('theme', 4), view('locale', 1)]))
|
||||
.mockReturnValueOnce(slow.promise)
|
||||
.mockResolvedValueOnce(described([view('theme', 5), view('locale', 2)]))
|
||||
const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
|
||||
await mirror.load()
|
||||
expect(describeCall).toHaveBeenCalledTimes(1)
|
||||
const stale = mirror.load()
|
||||
await Promise.resolve()
|
||||
mirror.acceptView(view('theme', 5))
|
||||
slow.resolve(described([view('theme', 4), view('locale', 2)]))
|
||||
await stale
|
||||
expect(describeCall).toHaveBeenCalledTimes(3)
|
||||
expect(mirror.namespace('theme')?.revision).toBe(5)
|
||||
expect(mirror.namespace('locale')?.revision).toBe(2)
|
||||
})
|
||||
|
||||
it('re-reads after a pre-answer write invalidates the in-flight document', async () => {
|
||||
const slow = deferred<RpcResponse<SettingsDescribeView>>()
|
||||
const describeCall = vi.fn()
|
||||
.mockReturnValueOnce(slow.promise)
|
||||
.mockResolvedValueOnce(described([view('theme', 2)]))
|
||||
const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
|
||||
const loading = mirror.load()
|
||||
await Promise.resolve()
|
||||
mirror.acceptView(view('theme', 2))
|
||||
slow.resolve(described([view('theme', 1)]))
|
||||
await loading
|
||||
expect(describeCall).toHaveBeenCalledTimes(2)
|
||||
expect(mirror.namespace('theme')?.revision).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { IApiClient, RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SettingsScope } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SettingsSchemaService } from '../src/client/schema.ts'
|
||||
import {
|
||||
SettingsScopeBinder, SettingsScopeController as ProductionSettingsScopeController,
|
||||
} from '../src/client/settings-scope.ts'
|
||||
import { SettingsScopeController, SettingsScopeBinder } from '../src/client/settings-scope.ts'
|
||||
import { SettingsDescribeMirror } from '../src/client/settings-mirror.ts'
|
||||
|
||||
const settingsSchema = new SettingsSchemaService(new Context())
|
||||
|
||||
interface UiTestSettings {
|
||||
preference: 'light' | 'dark' | 'system'
|
||||
@@ -17,17 +18,6 @@ const ENVELOPE = z.object({
|
||||
preference: z.union(['light', 'dark', 'system']).default('system'),
|
||||
}).toJSON()
|
||||
|
||||
const settingsSchema = new SettingsSchemaService(new Context())
|
||||
const SettingsScopeController = class<T> extends ProductionSettingsScopeController<T> {
|
||||
constructor(
|
||||
api: Pick<IApiClient, 'settings'>,
|
||||
spec: SettingsScopeSpec<T>,
|
||||
persistence: 'host' | 'memory' = 'host',
|
||||
) {
|
||||
super(api, spec, persistence, settingsSchema)
|
||||
}
|
||||
}
|
||||
|
||||
let rpc = 0
|
||||
|
||||
function ok<T>(value: T): RpcResponse<T> {
|
||||
@@ -66,6 +56,17 @@ function deferred<T>() {
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
/** A host-mode mirror plus a controller derived from it, over one fake wire. */
|
||||
function derivedScope(
|
||||
api: { describe?: ReturnType<typeof vi.fn>; mutate?: ReturnType<typeof vi.fn> },
|
||||
spec: { namespace: string; decode?: (section: unknown) => UiTestSettings | undefined } = { namespace: 'ui-test' },
|
||||
) {
|
||||
const wire = { settings: api } as never
|
||||
const mirror = new SettingsDescribeMirror(wire)
|
||||
const scope = new SettingsScopeController<UiTestSettings>(wire, spec, mirror, 'host', settingsSchema)
|
||||
return { mirror, scope }
|
||||
}
|
||||
|
||||
/** Record each distinct published section, starting from the current one. */
|
||||
function trackValues(scope: SettingsScope<UiTestSettings>): Array<UiTestSettings | undefined> {
|
||||
const seen: Array<UiTestSettings | undefined> = [scope.getSnapshot().value]
|
||||
@@ -77,16 +78,13 @@ function trackValues(scope: SettingsScope<UiTestSettings>): Array<UiTestSettings
|
||||
}
|
||||
|
||||
describe('SettingsScopeController', () => {
|
||||
it('starts loading and publishes a schema-valid section with revision and writability', async () => {
|
||||
it('starts loading and derives a schema-valid section with revision and writability', async () => {
|
||||
const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall })
|
||||
expect(scope.getSnapshot()).toEqual({
|
||||
status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host',
|
||||
})
|
||||
await scope.load()
|
||||
await mirror.load()
|
||||
expect(scope.getSnapshot()).toEqual({
|
||||
status: 'ready', value: { preference: 'dark' }, revision: 3, writable: true, mode: 'host',
|
||||
})
|
||||
@@ -101,12 +99,9 @@ describe('SettingsScopeController', () => {
|
||||
.mockResolvedValueOnce(described(['queue'], 7))
|
||||
.mockResolvedValueOnce(rejected())
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall })
|
||||
const good = trackValues(scope)
|
||||
for (let i = 0; i < 7; i++) await scope.load()
|
||||
for (let i = 0; i < 7; i++) await mirror.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({
|
||||
status: 'ready', value: { preference: 'dark' }, revision: 7,
|
||||
})
|
||||
@@ -117,45 +112,22 @@ describe('SettingsScopeController', () => {
|
||||
const broken = { ...view({ preference: 'dark' }, 2), schema: null }
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [broken] }))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
await scope.load()
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall })
|
||||
await mirror.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 2 })
|
||||
})
|
||||
|
||||
it('suppresses a superseded read of an unexposed namespace', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 1))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const statuses: string[] = []
|
||||
scope.subscribe(() => { statuses.push(scope.getSnapshot().status) })
|
||||
const stale = scope.load()
|
||||
const fresh = scope.load()
|
||||
await Promise.all([stale, fresh])
|
||||
expect(statuses).not.toContain('unavailable')
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } })
|
||||
})
|
||||
|
||||
it('reports an unexposed namespace as unavailable and recovers when it reappears', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 1))
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
|
||||
.mockResolvedValueOnce(described({ preference: 'system' }, 2))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
await scope.load()
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall })
|
||||
await mirror.load()
|
||||
expect(scope.getSnapshot().status).toBe('ready')
|
||||
await scope.load()
|
||||
await mirror.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', value: { preference: 'light' } })
|
||||
await scope.load()
|
||||
await mirror.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'system' }, revision: 2 })
|
||||
})
|
||||
|
||||
@@ -163,18 +135,15 @@ describe('SettingsScopeController', () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 1))
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 2))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{
|
||||
namespace: 'ui-test',
|
||||
decode: section => (section as UiTestSettings).preference === 'dark'
|
||||
? section as UiTestSettings
|
||||
: undefined,
|
||||
},
|
||||
)
|
||||
await scope.load()
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall }, {
|
||||
namespace: 'ui-test',
|
||||
decode: section => (section as UiTestSettings).preference === 'dark'
|
||||
? section as UiTestSettings
|
||||
: undefined,
|
||||
})
|
||||
await mirror.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 1 })
|
||||
await scope.load()
|
||||
await mirror.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' }, revision: 2 })
|
||||
})
|
||||
|
||||
@@ -184,12 +153,9 @@ describe('SettingsScopeController', () => {
|
||||
const mutate = vi.fn()
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockResolvedValueOnce(ok(view({ preference: 'light' }, 6)))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
|
||||
const published = trackValues(scope)
|
||||
await scope.load()
|
||||
await mirror.load()
|
||||
const dark = scope.set('preference', 'dark')
|
||||
const light = scope.set('preference', 'light')
|
||||
await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
@@ -209,6 +175,41 @@ describe('SettingsScopeController', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('folds the latest write answer into the mirror so a sibling scope sees it', async () => {
|
||||
const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 4))
|
||||
const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'dark' }, 5)))
|
||||
const wire = { settings: { describe: describeCall, mutate } } as never
|
||||
const mirror = new SettingsDescribeMirror(wire)
|
||||
const writer = new SettingsScopeController<UiTestSettings>(wire, { namespace: 'ui-test' }, mirror, 'host', settingsSchema)
|
||||
const sibling = new SettingsScopeController<UiTestSettings>(wire, { namespace: 'ui-test' }, mirror, 'host', settingsSchema)
|
||||
await mirror.load()
|
||||
await writer.set('preference', 'dark')
|
||||
expect(describeCall).toHaveBeenCalledTimes(1)
|
||||
expect(sibling.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 5 })
|
||||
})
|
||||
|
||||
it('re-reads after a revisionless first write lands during the initial read', async () => {
|
||||
const initial = deferred<ReturnType<typeof described>>()
|
||||
const describeCall = vi.fn()
|
||||
.mockReturnValueOnce(initial.promise)
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 2))
|
||||
const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'dark' }, 2)))
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
|
||||
const loading = mirror.load()
|
||||
await Promise.resolve()
|
||||
|
||||
await scope.set('preference', 'dark')
|
||||
initial.resolve(described({ preference: 'system' }, 1))
|
||||
await loading
|
||||
|
||||
expect(mutate).toHaveBeenCalledWith({
|
||||
ns: 'ui-test',
|
||||
ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
|
||||
})
|
||||
expect(describeCall).toHaveBeenCalledTimes(2)
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 2 })
|
||||
})
|
||||
|
||||
it('recovers the latest rejected or thrown write from Host state', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'system' }, 2))
|
||||
@@ -216,63 +217,74 @@ describe('SettingsScopeController', () => {
|
||||
const mutate = vi.fn()
|
||||
.mockResolvedValueOnce(rejected())
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
|
||||
const published = trackValues(scope)
|
||||
await mirror.load()
|
||||
await scope.set('preference', 'dark')
|
||||
await scope.set('preference', 'system')
|
||||
expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
|
||||
})
|
||||
|
||||
it('does not recover superseded rejected or thrown writes', async () => {
|
||||
const describeCall = vi.fn()
|
||||
const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 2))
|
||||
const mutate = vi.fn()
|
||||
.mockResolvedValueOnce(rejected())
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockResolvedValueOnce(ok(view({ preference: 'light' }, 3)))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
|
||||
const published = trackValues(scope)
|
||||
await mirror.load()
|
||||
await Promise.all([
|
||||
scope.set('preference', 'dark'),
|
||||
scope.set('preference', 'system'),
|
||||
scope.set('preference', 'light'),
|
||||
])
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
expect(published.map(section => section?.preference)).toEqual([undefined, 'light'])
|
||||
expect(describeCall).toHaveBeenCalledTimes(1)
|
||||
expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
|
||||
})
|
||||
|
||||
it('keeps the write queue usable when a subscriber throws', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 1))
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 2))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall })
|
||||
let thrown = false
|
||||
scope.subscribe(() => {
|
||||
if (thrown) return
|
||||
thrown = true
|
||||
throw new Error('subscriber failed')
|
||||
})
|
||||
await expect(scope.load()).rejects.toThrow('subscriber failed')
|
||||
await expect(scope.load()).resolves.toBeUndefined()
|
||||
await expect(mirror.load()).rejects.toThrow('subscriber failed')
|
||||
await expect(mirror.load()).resolves.toBeUndefined()
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 2 })
|
||||
})
|
||||
|
||||
it('keeps the write queue usable when a write publication listener throws', async () => {
|
||||
const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 1))
|
||||
const mutate = vi.fn()
|
||||
.mockResolvedValueOnce(ok(view({ preference: 'dark' }, 2)))
|
||||
.mockResolvedValueOnce(ok(view({ preference: 'light' }, 3)))
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
|
||||
await mirror.load()
|
||||
let shouldThrow = true
|
||||
mirror.subscribe(() => {
|
||||
if (!shouldThrow) return
|
||||
shouldThrow = false
|
||||
throw new Error('write subscriber failed')
|
||||
})
|
||||
|
||||
await expect(scope.set('preference', 'dark')).rejects.toThrow('write subscriber failed')
|
||||
await expect(scope.set('preference', 'light')).resolves.toBeUndefined()
|
||||
|
||||
expect(mutate).toHaveBeenCalledTimes(2)
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 3 })
|
||||
})
|
||||
|
||||
it('cancels queued and post-dispose writes while draining the in-flight mutation', async () => {
|
||||
const first = deferred<RpcResponse<SettingsNamespaceView>>()
|
||||
const mutate = vi.fn().mockReturnValue(first.promise)
|
||||
const describeCall = vi.fn()
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const { scope } = derivedScope({ describe: describeCall, mutate })
|
||||
const published = trackValues(scope)
|
||||
const dark = scope.set('preference', 'dark')
|
||||
await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
@@ -284,24 +296,66 @@ describe('SettingsScopeController', () => {
|
||||
first.resolve(ok(view({ preference: 'dark' }, 1)))
|
||||
await Promise.all([dark, light, stop])
|
||||
await scope.set('preference', 'system')
|
||||
await scope.load()
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
expect(published).toEqual([undefined])
|
||||
})
|
||||
|
||||
it('stops deriving from the mirror after dispose', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 1))
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 2))
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall })
|
||||
await mirror.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' } })
|
||||
await scope.dispose()
|
||||
await mirror.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 1 })
|
||||
})
|
||||
|
||||
it('ignores a mirror notification already queued when disposal starts', async () => {
|
||||
let notify = (): void => {}
|
||||
let snapshot = {
|
||||
status: 'ready' as const,
|
||||
view: {
|
||||
writable: true, hasDocument: true,
|
||||
namespaces: [view({ preference: 'dark' }, 1)],
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
const mirror = {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe: (listener: () => void) => {
|
||||
notify = listener
|
||||
return () => {}
|
||||
},
|
||||
} as never
|
||||
const wire = { settings: {} } as never
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
wire, { namespace: 'ui-test' }, mirror, 'host', settingsSchema)
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 1 })
|
||||
|
||||
await scope.dispose()
|
||||
snapshot = {
|
||||
...snapshot,
|
||||
view: { ...snapshot.view, namespaces: [view({ preference: 'light' }, 2)] },
|
||||
}
|
||||
notify()
|
||||
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 1 })
|
||||
})
|
||||
|
||||
it('keeps a remote browser in memory mode without Host calls', async () => {
|
||||
const describeCall = vi.fn()
|
||||
const mutate = vi.fn()
|
||||
const wire = { settings: { describe: describeCall, mutate } } as never
|
||||
const mirror = new SettingsDescribeMirror(wire, 'memory')
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
'memory',
|
||||
)
|
||||
wire, { namespace: 'ui-test' }, mirror, 'memory', settingsSchema)
|
||||
expect(scope.getSnapshot()).toEqual({
|
||||
status: 'unavailable', value: undefined, revision: undefined, writable: false, mode: 'memory',
|
||||
})
|
||||
await scope.load()
|
||||
await mirror.load()
|
||||
await scope.set('preference', 'dark')
|
||||
await scope.dispose()
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
@@ -316,12 +370,9 @@ describe('SettingsScopeController', () => {
|
||||
}
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [layered] }))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall })
|
||||
|
||||
await scope.load()
|
||||
await mirror.load()
|
||||
|
||||
expect(scope.getSnapshot()).toMatchObject({
|
||||
value: { preference: 'dark' },
|
||||
@@ -334,12 +385,9 @@ describe('SettingsScopeController', () => {
|
||||
const inherited: SettingsNamespaceView = { ...view({ preference: 'system' }, 1), base: { preference: 'system' } }
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [inherited] }))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall })
|
||||
|
||||
await scope.load()
|
||||
await mirror.load()
|
||||
|
||||
expect(scope.getSnapshot().user).toBeUndefined()
|
||||
})
|
||||
@@ -347,11 +395,8 @@ describe('SettingsScopeController', () => {
|
||||
it('clears one field through an unset op fenced by the held revision', async () => {
|
||||
const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'system' }, 4)))
|
||||
const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
await scope.load()
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
|
||||
await mirror.load()
|
||||
|
||||
await scope.unset('preference')
|
||||
|
||||
@@ -368,64 +413,54 @@ describe('SettingsScopeController', () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 3))
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 5))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
await scope.load()
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
|
||||
await mirror.load()
|
||||
|
||||
await scope.unset('preference')
|
||||
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 5 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('SettingsScopeBinder.bind', () => {
|
||||
it('subscribes before the initial read and converges to the latest queued invalidation', async () => {
|
||||
const initial = deferred<ReturnType<typeof described>>()
|
||||
const describeCall = vi.fn()
|
||||
.mockReturnValueOnce(initial.promise)
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 2))
|
||||
.mockResolvedValueOnce(described({ preference: 'system' }, 3))
|
||||
it('shares one mirror read across bound scopes and disposes each with its fiber', async () => {
|
||||
const describeCall = vi.fn().mockResolvedValue(described({ preference: 'dark' }, 1))
|
||||
const wire = { settings: { describe: describeCall } }
|
||||
const mirror = new SettingsDescribeMirror(wire as never)
|
||||
const ctx = new Context()
|
||||
ctx.provide('connection', {
|
||||
api: { settings: { describe: describeCall } },
|
||||
isLoopback: true,
|
||||
} as never)
|
||||
let scope!: SettingsScope<UiTestSettings>
|
||||
ctx.provide('connection', { api: wire, isLoopback: true } as never)
|
||||
let theme!: SettingsScope<UiTestSettings>
|
||||
let locale!: SettingsScope<UiTestSettings>
|
||||
new TestRemote(ctx)
|
||||
await ctx.plugin(SettingsScopeBinder, new SettingsSchemaService(ctx)).await()
|
||||
await ctx.plugin(SettingsScopeBinder, { mirror, schema: settingsSchema }).await()
|
||||
expect(ctx.settingsScope.describe()).toBe(mirror)
|
||||
const fiber = ctx.plugin({
|
||||
inject: ['connection', 'remote', 'settingsScope'],
|
||||
apply: (plugin: Context) => {
|
||||
scope = plugin.settingsScope.bind<UiTestSettings>({ namespace: 'ui-test' })
|
||||
theme = plugin.settingsScope.bind<UiTestSettings>({ namespace: 'ui-test' })
|
||||
locale = plugin.settingsScope.bind<UiTestSettings>({ namespace: 'ui-test' })
|
||||
},
|
||||
})
|
||||
await fiber.await()
|
||||
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledOnce() })
|
||||
ctx.remote.$dispatch('settings/document-updated', ['unrelated', 0])
|
||||
ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0])
|
||||
ctx.emit('connection/reset')
|
||||
initial.resolve(described({ preference: 'dark' }, 1))
|
||||
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) })
|
||||
await vi.waitFor(() => {
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'system' }, revision: 3 })
|
||||
expect(theme.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } })
|
||||
expect(locale.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } })
|
||||
})
|
||||
expect(describeCall).toHaveBeenCalledTimes(1)
|
||||
await fiber.dispose()
|
||||
ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0])
|
||||
await Promise.resolve()
|
||||
expect(describeCall).toHaveBeenCalledTimes(3)
|
||||
await mirror.load()
|
||||
expect(theme.getSnapshot()).toMatchObject({ revision: 1 })
|
||||
})
|
||||
|
||||
it('binds a remote browser in memory mode without starting a settings read', async () => {
|
||||
const describeCall = vi.fn()
|
||||
const wire = { settings: { describe: describeCall } }
|
||||
const mirror = new SettingsDescribeMirror(wire as never, 'memory')
|
||||
const ctx = new Context()
|
||||
ctx.provide('connection', {
|
||||
api: { settings: { describe: describeCall } },
|
||||
isLoopback: false,
|
||||
} as never)
|
||||
ctx.provide('connection', { api: wire, isLoopback: false } as never)
|
||||
let scope!: SettingsScope<UiTestSettings>
|
||||
new TestRemote(ctx)
|
||||
await ctx.plugin(SettingsScopeBinder, new SettingsSchemaService(ctx)).await()
|
||||
await ctx.plugin(SettingsScopeBinder, { mirror, schema: settingsSchema }).await()
|
||||
const fiber = ctx.plugin({
|
||||
inject: ['connection', 'remote', 'settingsScope'],
|
||||
apply: (plugin: Context) => {
|
||||
|
||||
@@ -6,8 +6,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts'
|
||||
import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-scope.ts'
|
||||
import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import type { AppearanceRowInjected, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from '../src/theme-settings.ts'
|
||||
@@ -57,7 +56,7 @@ async function bench(isLoopback = true) {
|
||||
ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback } as never)
|
||||
// The settings transport and the forwarded-event port the plugin injects.
|
||||
new TestRemote(ctx)
|
||||
await ctx.plugin(SettingsScopeBinder, new SettingsSchemaService(ctx)).await()
|
||||
await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
|
||||
return {
|
||||
ctx, slots: ctx.get('slots') as SlotRegistry, locale, describe, mutate,
|
||||
setHostPreference: (next: string) => { preference = next },
|
||||
@@ -128,13 +127,19 @@ describe('ui-theme apply', () => {
|
||||
|
||||
it('loads Host settings at boot, refreshes its namespace, and keeps remote browsers process-local', async () => {
|
||||
const b = await bench()
|
||||
// The shared mirror read once at bench time; a Host-side change reaches it
|
||||
// through the document invalidation, exactly as production announces one.
|
||||
b.setHostPreference('dark')
|
||||
b.ctx.remote.$dispatch('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0])
|
||||
declareItems(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const theme = b.ctx.get('theme') as ThemeRuntime
|
||||
await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') })
|
||||
// The mirror refreshes on every document commit (ns-agnostic); the scope's
|
||||
// derived value only moves when its own namespace changed.
|
||||
b.ctx.remote.$dispatch('settings/document-updated', ['unrelated', 0])
|
||||
expect(b.describe).toHaveBeenCalledOnce()
|
||||
await vi.waitFor(() => { expect(b.describe).toHaveBeenCalledTimes(3) })
|
||||
expect(theme.getTheme().preference).toBe('dark')
|
||||
b.setHostPreference('light')
|
||||
b.ctx.remote.$dispatch('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0])
|
||||
await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('light') })
|
||||
@@ -152,12 +157,15 @@ describe('ui-theme apply', () => {
|
||||
expect(remote.mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('activates before a slow initial settings read and converges when it settles', async () => {
|
||||
it('activates before a slow settings refresh and converges when it settles', async () => {
|
||||
const b = await bench()
|
||||
b.setHostPreference('dark')
|
||||
const describe = b.describe.getMockImplementation()!
|
||||
const pending = deferred<Awaited<ReturnType<typeof describe>>>()
|
||||
b.describe.mockImplementationOnce(() => pending.promise)
|
||||
// The refresh hangs on the wire; the mirror keeps serving the last good
|
||||
// answer, so activation never blocks on the settings transport.
|
||||
b.ctx.remote.$dispatch('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0])
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
const theme = b.ctx.get('theme') as ThemeRuntime
|
||||
@@ -170,9 +178,10 @@ describe('ui-theme apply', () => {
|
||||
it('ignores an invalid preference crossing the settings wire', async () => {
|
||||
const b = await bench()
|
||||
b.setHostPreference('sepia')
|
||||
b.ctx.remote.$dispatch('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0])
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const theme = b.ctx.get('theme') as ThemeRuntime
|
||||
await vi.waitFor(() => { expect(b.describe).toHaveBeenCalledOnce() })
|
||||
await vi.waitFor(() => { expect(b.describe).toHaveBeenCalledTimes(2) })
|
||||
expect(theme.getTheme().preference).toBe('system')
|
||||
})
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-workflow-run/README.md
|
||||
README.md: 3fcdc632b54be7d1f8f452c6f8b3b2e1525953aa
|
||||
README.zh.md: 5f0ba0c8f8a251d0ce5977d469ee60637f0454db
|
||||
README.md: 5093050ac7bd7e5d8a8779c4fe9848ee4f7da28b
|
||||
README.zh.md: f7b652d87dc01e5161eac8ea3fc9025136feca90
|
||||
@@ -12,7 +12,7 @@ Phase groups come only from members that actually started. Exact phase strings s
|
||||
|
||||
## Presentation and navigation
|
||||
|
||||
The run and each phase derive disclosure control from their current lifecycle facts. The run stays expanded while its own status is running, failed, cancelled, or interrupted, or while any phase contains such a member; each affected phase also stays expanded. Forced-open headers are static expanded rows without button, keyboard, or `aria-expanded` promises. A phase folds once when every member completes, and the run folds once when it and every phase complete. Each clean layer then exposes an ordinary disclosure control whose local choice survives clean rerenders; new activity takes control again, and a remount derives the initial state from current data. The run uses a 32-pixel `--dsw-alias-bg-module-platform` row with persistent right/down chevrons and an inline state dot plus status text, without a badge. Phases use 32-pixel disclosure rows with title and member count in the flexible main area and a fixed precise aggregate-status tail, without another dot. Members use a 16-pixel dot slot, a truncating name area, and a fixed 64-pixel status column.
|
||||
The run and each phase are controlled disclosures in every status. A mount opens running, failed, cancelled, and interrupted levels and closes fully completed levels; users can then toggle either level with the full row, Enter, or Space. Ordinary running updates preserve the current choice, the first abnormal edge opens once, normal completion closes once, and a completed phase plus the outer run open again when a new running member starts under the same phase key. If an entire new clean cycle arrives in one render while the run remains active, the phase finishes folded but the outer run opens once to expose its updated summary. Completion updates the visible status immediately but delays its automatic close while focus remains inside the content. `WorkflowRunPanel` owns the phase choices, so closing and reopening the outer run does not reset them; a renderer remount reconstructs every initial choice from durable facts. The run uses a 32-pixel `--dsw-alias-bg-module-platform` row with persistent right/down chevrons and an inline state dot plus status text, without a badge. Phases use 32-pixel disclosure rows with title and member count in the flexible main area and a fixed precise aggregate-status tail, without another dot. Members use a 16-pixel dot slot, a truncating name area, and a fixed 64-pixel status column.
|
||||
|
||||
A member opens a child Session only while every current fact agrees: the member is running, the child id is in the ordinary Session list, the row has `origin: 'subagent'`, its `parentId` is the current Session, and the list row is still running. Underlined member text is the only visible navigation affordance; keyboard focus draws a two-pixel business-primary ring around the name area, while status copy remains `Running`. The component calls only the injected ordinary `sessions.open(id)` action; remote, addressed-only, wrong-parent, or terminal rows remain non-interactive.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
## 展示与导航
|
||||
|
||||
运行和每个阶段都从当前生命周期事实派生 disclosure 控制。运行自身处于运行中、失败、已取消或已中断,或者任一阶段包含这些状态的成员时,运行保持展开;受影响的阶段也保持展开。强制展开的标题行只是静态展开行,不承诺按钮、键盘操作或 `aria-expanded`。阶段在全部成员完成时折叠一次;运行在自身和全部阶段都完成时折叠一次。每个干净层级随后恢复普通 disclosure 控件,其本地选择在干净状态的 rerender 中保持;新活动会重新取得控制,remount 则从当前数据派生初始状态。运行使用 32 像素 `--dsw-alias-bg-module-platform` 背景行,常驻向右/向下 chevron,并以内联状态点加状态文字表达结局,不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。
|
||||
运行和每个阶段在所有状态下都是受控 disclosure。挂载时,运行中、失败、已取消和已中断层级默认展开,全部完成的层级默认折叠;此后用户可以点击整行,或按 Enter、Space 切换任一层级。普通运行更新保留当前选择,首次异常边沿只自动展开一次,正常完成只自动折叠一次;已完成阶段在同一 phase key 下开始新的运行成员时,该 Phase 与外层运行会再次自动展开。若一个完整的新干净周期在同一次渲染中送达,且运行仍处于活动状态,Phase 保持折叠,但外层运行会自动展开一次以展示更新后的摘要。完成状态会立即更新,但只要焦点仍位于展开内容内,自动折叠就会等待焦点离开。Phase 选择由 `WorkflowRunPanel` 持有,因此关闭并重新打开外层运行不会重置它们;renderer remount 会从持久事实重建每层的初始选择。运行使用 32 像素 `--dsw-alias-bg-module-platform` 背景行,常驻向右/向下 chevron,并以内联状态点加状态文字表达结局,不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。
|
||||
|
||||
只有所有实时事实同时成立时,成员才可打开子 Session:成员仍在运行、子 id 位于普通 Session 列表、列表行为 `origin: 'subagent'`、`parentId` 等于当前 Session,且列表行仍标记运行。带下划线的成员文字是唯一可见导航提示;键盘聚焦时,名称区显示 2 像素 business-primary 焦点环,右侧状态仍只显示“运行中”。组件只调用注入的普通 `sessions.open(id)`;远程、仅地址化、父级不符或终态的行都不可交互。
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import {
|
||||
useLayoutEffect, useMemo, useRef, useState,
|
||||
type FocusEvent, type MouseEvent, type ReactNode,
|
||||
} from 'react'
|
||||
import {
|
||||
DisclosureRow, IconChevronRightOutline14, StateDot,
|
||||
type DisclosureRowProps, type StateDotState,
|
||||
@@ -63,34 +66,102 @@ function memberCount(count: number, t: WorkflowRunPanelProps['t']): string {
|
||||
return t(count === 1 ? 'run.members.one' : 'run.members.other', { count })
|
||||
}
|
||||
|
||||
function phaseRequiresExpansion(phase: WorkflowRunPhaseData): boolean {
|
||||
return phase.members.some(member => member.status !== 'completed')
|
||||
type DisclosureMode = 'clean' | 'running' | 'abnormal'
|
||||
|
||||
interface DisclosureFacts {
|
||||
readonly mode: DisclosureMode
|
||||
readonly activityCount: number
|
||||
}
|
||||
|
||||
type StatusDisclosureProps = Omit<DisclosureRowProps, 'open' | 'expandable' | 'onToggle'>
|
||||
|
||||
/* v8 ignore next -- DisclosureRow requires the callback but cannot invoke it when expandable is false. */
|
||||
const forcedOpenToggle = (): void => {}
|
||||
|
||||
function ManualDisclosure(props: StatusDisclosureProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<DisclosureRow
|
||||
{...props}
|
||||
open={open}
|
||||
expandable
|
||||
onToggle={() => { setOpen(value => !value) }}
|
||||
/>
|
||||
)
|
||||
interface DisclosureState extends DisclosureFacts {
|
||||
readonly open: boolean
|
||||
readonly pendingCleanCollapse: boolean
|
||||
}
|
||||
|
||||
function StatusDisclosure({ cleanCycleKey, requiresExpansion, ...props }: StatusDisclosureProps & {
|
||||
/** Remount a clean Phase when its append-only member count changes between batched renders. */
|
||||
readonly cleanCycleKey?: number | undefined
|
||||
readonly requiresExpansion: boolean
|
||||
}) {
|
||||
if (!requiresExpansion) return <ManualDisclosure key={cleanCycleKey} {...props} />
|
||||
return <DisclosureRow {...props} open expandable={false} onToggle={forcedOpenToggle} />
|
||||
interface WorkflowDisclosureState {
|
||||
readonly run: DisclosureState
|
||||
readonly phases: ReadonlyMap<string, DisclosureState>
|
||||
}
|
||||
|
||||
type StatusDisclosureProps = Omit<DisclosureRowProps, 'expandable'>
|
||||
|
||||
function StatusDisclosure(props: StatusDisclosureProps) {
|
||||
return <DisclosureRow {...props} expandable />
|
||||
}
|
||||
|
||||
function abnormal(status: WorkflowRunStatus): boolean {
|
||||
return status === 'failed' || status === 'cancelled' || status === 'interrupted'
|
||||
}
|
||||
|
||||
function phaseDisclosureFacts(phase: WorkflowRunPhaseData): DisclosureFacts {
|
||||
const mode = phase.members.some(member => abnormal(member.status))
|
||||
? 'abnormal'
|
||||
: phase.members.some(member => member.status === 'running') ? 'running' : 'clean'
|
||||
return { mode, activityCount: phase.members.length }
|
||||
}
|
||||
|
||||
function runDisclosureFacts(
|
||||
status: WorkflowRunStatus,
|
||||
phases: readonly (readonly [string, DisclosureFacts])[],
|
||||
): DisclosureFacts {
|
||||
const mode = abnormal(status) || phases.some(([, facts]) => facts.mode === 'abnormal')
|
||||
? 'abnormal'
|
||||
: status === 'running' || phases.some(([, facts]) => facts.mode === 'running')
|
||||
? 'running'
|
||||
: 'clean'
|
||||
const activityCount = phases.reduce((count, [, facts]) => count + facts.activityCount, 0)
|
||||
return { mode, activityCount }
|
||||
}
|
||||
|
||||
function initialDisclosureState(facts: DisclosureFacts): DisclosureState {
|
||||
return { ...facts, open: facts.mode !== 'clean', pendingCleanCollapse: false }
|
||||
}
|
||||
|
||||
function advanceDisclosureState(
|
||||
current: DisclosureState,
|
||||
facts: DisclosureFacts,
|
||||
focusWithin: boolean,
|
||||
): DisclosureState {
|
||||
const sameFacts = current.mode === facts.mode && current.activityCount === facts.activityCount
|
||||
if (sameFacts) {
|
||||
if (!current.pendingCleanCollapse || focusWithin) return current
|
||||
return { ...current, open: false, pendingCleanCollapse: false }
|
||||
}
|
||||
if (facts.mode === 'clean') {
|
||||
const deferCollapse = current.open && focusWithin
|
||||
return { ...facts, open: deferCollapse, pendingCleanCollapse: deferCollapse }
|
||||
}
|
||||
if (current.mode === 'clean' || (facts.mode === 'abnormal' && current.mode !== 'abnormal')) {
|
||||
return { ...facts, open: true, pendingCleanCollapse: false }
|
||||
}
|
||||
return { ...facts, open: current.open, pendingCleanCollapse: false }
|
||||
}
|
||||
|
||||
function focusIsWithin(element: HTMLElement | null | undefined): boolean {
|
||||
if (element === null || element === undefined) return false
|
||||
return element.contains(element.ownerDocument.activeElement)
|
||||
}
|
||||
|
||||
function collapsePending(state: DisclosureState): DisclosureState {
|
||||
if (!state.pendingCleanCollapse) return state
|
||||
return { ...state, open: false, pendingCleanCollapse: false }
|
||||
}
|
||||
|
||||
function existingPhaseState(
|
||||
phases: ReadonlyMap<string, DisclosureState>,
|
||||
key: string,
|
||||
): DisclosureState {
|
||||
const phase = phases.get(key)
|
||||
/* v8 ignore next -- mounted phase callbacks are created from this owner map. */
|
||||
if (phase === undefined) throw new Error(`Missing disclosure state for phase ${key}`)
|
||||
return phase
|
||||
}
|
||||
|
||||
function preventPendingHeaderFocus(event: MouseEvent<HTMLElement>): void {
|
||||
const header = event.currentTarget.querySelector('[data-disclosure-row]')
|
||||
/* v8 ignore next -- DisclosureRow always renders its header before the content. */
|
||||
if (header === null) throw new Error('Missing disclosure header')
|
||||
if (header.contains(event.target as Node)) event.preventDefault()
|
||||
}
|
||||
|
||||
function phaseStatusSummary(members: readonly WorkflowRunMemberData[], t: WorkflowRunPanelProps['t']): string {
|
||||
@@ -128,11 +199,12 @@ function navigableMembers(
|
||||
return result
|
||||
}
|
||||
|
||||
function RunHeader({ children, count, name, requiresExpansion, status, t }: {
|
||||
function RunHeader({ children, count, name, onToggle, open, status, t }: {
|
||||
readonly children: ReactNode
|
||||
readonly count: number
|
||||
readonly name: string
|
||||
readonly requiresExpansion: boolean
|
||||
readonly onToggle: () => void
|
||||
readonly open: boolean
|
||||
readonly status: WorkflowRunStatus
|
||||
readonly t: WorkflowRunPanelProps['t']
|
||||
}) {
|
||||
@@ -140,7 +212,8 @@ function RunHeader({ children, count, name, requiresExpansion, status, t }: {
|
||||
<StatusDisclosure
|
||||
icon={<IconChevronRightOutline14 />}
|
||||
title={t('run.title', { name })}
|
||||
requiresExpansion={requiresExpansion}
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
expandOnRowClick
|
||||
previewChevron={false}
|
||||
keepContentWhenOpen
|
||||
@@ -170,6 +243,9 @@ function MemberRow({ member, navigable, openSession, t }: {
|
||||
readonly t: WorkflowRunPanelProps['t']
|
||||
}) {
|
||||
const name = readableMember(member.label, t)
|
||||
const [focused, setFocused] = useState(false)
|
||||
const renderButton = navigable || focused
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<span className={css.dotSlot}><StateDot state={dotState(member.status)} /></span>
|
||||
@@ -177,94 +253,213 @@ function MemberRow({ member, navigable, openSession, t }: {
|
||||
<span className={css.memberStatus} data-member-status-text>{t(STATUS_KEYS[member.status])}</span>
|
||||
</>
|
||||
)
|
||||
if (!navigable) {
|
||||
if (!renderButton) {
|
||||
return <div className={css.memberRow} data-member-status={member.status}>{content}</div>
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={css.memberButton}
|
||||
className={navigable ? css.memberButton : css.memberRow}
|
||||
data-member-status={member.status}
|
||||
aria-label={t('member.open', { name })}
|
||||
onClick={() => { openSession(member.childId) }}
|
||||
aria-disabled={navigable ? undefined : true}
|
||||
aria-label={navigable ? t('member.open', { name }) : name}
|
||||
tabIndex={navigable ? undefined : -1}
|
||||
onFocus={() => { setFocused(true) }}
|
||||
onBlur={() => { setFocused(false) }}
|
||||
onClick={navigable ? () => { openSession(member.childId) } : undefined}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function PhaseSection({ phase, navigable, openSession, t }: {
|
||||
function PhaseSection({
|
||||
contentRef, onContentBlur, onToggle, open, pendingCleanCollapse,
|
||||
phase, navigable, openSession, t,
|
||||
}: {
|
||||
readonly contentRef: (element: HTMLDivElement | null) => void
|
||||
readonly onContentBlur: (event: FocusEvent<HTMLDivElement>) => void
|
||||
readonly onToggle: () => void
|
||||
readonly open: boolean
|
||||
readonly pendingCleanCollapse: boolean
|
||||
readonly phase: WorkflowRunPhaseData
|
||||
readonly navigable: readonly SessionId[]
|
||||
readonly openSession: WorkflowRunInjected['openSession']
|
||||
readonly t: WorkflowRunPanelProps['t']
|
||||
}) {
|
||||
return (
|
||||
<StatusDisclosure
|
||||
icon={<IconChevronRightOutline14 />}
|
||||
title={readablePhase(phase.phase, t)}
|
||||
cleanCycleKey={phase.members.length}
|
||||
requiresExpansion={phaseRequiresExpansion(phase)}
|
||||
expandOnRowClick
|
||||
previewChevron={false}
|
||||
keepContentWhenOpen
|
||||
<div
|
||||
className={css.phase}
|
||||
rowClassName={css.phaseHeader}
|
||||
leadingClassName={css.phaseLeading}
|
||||
titleClassName={css.phaseTitle}
|
||||
collapsedContent={(
|
||||
<>
|
||||
<span className={css.separator} aria-hidden />
|
||||
<span className={css.phaseCount} data-phase-count>{memberCount(phase.members.length, t)}</span>
|
||||
<span className={css.phaseStatus} data-phase-status-text>{phaseStatusSummary(phase.members, t)}</span>
|
||||
</>
|
||||
)}
|
||||
onMouseDownCapture={pendingCleanCollapse ? preventPendingHeaderFocus : undefined}
|
||||
>
|
||||
<div className={css.members}>
|
||||
{phase.members.map(member => (
|
||||
<MemberRow
|
||||
key={member.seq}
|
||||
member={member}
|
||||
navigable={navigable.includes(member.childId)}
|
||||
openSession={openSession}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</StatusDisclosure>
|
||||
<StatusDisclosure
|
||||
icon={<IconChevronRightOutline14 />}
|
||||
title={readablePhase(phase.phase, t)}
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
expandOnRowClick
|
||||
previewChevron={false}
|
||||
keepContentWhenOpen
|
||||
rowClassName={css.phaseHeader}
|
||||
leadingClassName={css.phaseLeading}
|
||||
titleClassName={css.phaseTitle}
|
||||
collapsedContent={(
|
||||
<>
|
||||
<span className={css.separator} aria-hidden />
|
||||
<span className={css.phaseCount} data-phase-count>{memberCount(phase.members.length, t)}</span>
|
||||
<span className={css.phaseStatus} data-phase-status-text>{phaseStatusSummary(phase.members, t)}</span>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div ref={contentRef} className={css.members} onBlur={onContentBlur}>
|
||||
{phase.members.map(member => (
|
||||
<MemberRow
|
||||
key={member.seq}
|
||||
member={member}
|
||||
navigable={navigable.includes(member.childId)}
|
||||
openSession={openSession}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</StatusDisclosure>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Render one durable workflow run with status-driven run and phase disclosure. */
|
||||
export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t }: WorkflowRunPanelProps) {
|
||||
const totalMembers = node.data.phases.reduce((count, phase) => count + phase.members.length, 0)
|
||||
const requiresExpansion = node.data.status !== 'completed'
|
||||
|| node.data.phases.some(phaseRequiresExpansion)
|
||||
const phaseFacts = useMemo(() => node.data.phases.map(phase => (
|
||||
[phase.key, phaseDisclosureFacts(phase)] as const
|
||||
)), [node.data.phases])
|
||||
const runFacts = useMemo(
|
||||
() => runDisclosureFacts(node.data.status, phaseFacts),
|
||||
[node.data.status, phaseFacts],
|
||||
)
|
||||
const totalMembers = runFacts.activityCount
|
||||
const [disclosures, setDisclosures] = useState<WorkflowDisclosureState>(() => ({
|
||||
run: initialDisclosureState(runFacts),
|
||||
phases: new Map(phaseFacts.map(([key, facts]) => [key, initialDisclosureState(facts)])),
|
||||
}))
|
||||
const runContentRef = useRef<HTMLDivElement>(null)
|
||||
const phaseContentRefs = useRef(new Map<string, HTMLDivElement>())
|
||||
const navigable = useSessions(
|
||||
sessions => navigableMembers(sessions, node.data.phases, sessionId),
|
||||
shallowEqual,
|
||||
)
|
||||
|
||||
// Outer hiding unmounts Phase content without a dependable blur event, so this edge settles deferred closes.
|
||||
useLayoutEffect(() => {
|
||||
setDisclosures((current) => {
|
||||
const phases = new Map<string, DisclosureState>()
|
||||
let phasesChanged = current.phases.size !== phaseFacts.length
|
||||
let phaseStartedCycle = false
|
||||
for (const [key, facts] of phaseFacts) {
|
||||
const previous = current.phases.get(key)
|
||||
const next = previous === undefined
|
||||
? initialDisclosureState(facts)
|
||||
: advanceDisclosureState(previous, facts, focusIsWithin(phaseContentRefs.current.get(key)))
|
||||
phases.set(key, next)
|
||||
if (next !== previous) phasesChanged = true
|
||||
if (previous?.mode === 'clean'
|
||||
&& (facts.mode !== 'clean' || facts.activityCount !== previous.activityCount)) {
|
||||
phaseStartedCycle = true
|
||||
}
|
||||
}
|
||||
const advancedRun = advanceDisclosureState(
|
||||
current.run,
|
||||
runFacts,
|
||||
focusIsWithin(runContentRef.current),
|
||||
)
|
||||
const run = phaseStartedCycle && runFacts.mode !== 'clean' && !advancedRun.open
|
||||
? { ...advancedRun, open: true, pendingCleanCollapse: false }
|
||||
: advancedRun
|
||||
return run !== current.run || phasesChanged ? { run, phases } : current
|
||||
})
|
||||
}, [disclosures.run.open, phaseFacts, runFacts])
|
||||
|
||||
const toggleRun = (): void => {
|
||||
setDisclosures(current => ({
|
||||
...current,
|
||||
run: {
|
||||
...current.run,
|
||||
open: !current.run.open,
|
||||
pendingCleanCollapse: false,
|
||||
},
|
||||
}))
|
||||
}
|
||||
const togglePhase = (key: string): void => {
|
||||
setDisclosures((current) => {
|
||||
const phases = new Map(current.phases)
|
||||
const phase = existingPhaseState(phases, key)
|
||||
phases.set(key, {
|
||||
...phase,
|
||||
open: !phase.open,
|
||||
pendingCleanCollapse: false,
|
||||
})
|
||||
return { ...current, phases }
|
||||
})
|
||||
}
|
||||
const settleRunBlur = (event: FocusEvent<HTMLDivElement>): void => {
|
||||
if (event.currentTarget.contains(event.relatedTarget)) return
|
||||
setDisclosures((current) => {
|
||||
const run = collapsePending(current.run)
|
||||
return run === current.run ? current : { ...current, run }
|
||||
})
|
||||
}
|
||||
const settlePhaseBlur = (key: string, event: FocusEvent<HTMLDivElement>): void => {
|
||||
if (event.currentTarget.contains(event.relatedTarget)) return
|
||||
setDisclosures((current) => {
|
||||
const phase = existingPhaseState(current.phases, key)
|
||||
const next = collapsePending(phase)
|
||||
if (next === phase) return current
|
||||
const phases = new Map(current.phases)
|
||||
phases.set(key, next)
|
||||
return { ...current, phases }
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={css.root} data-workflow-run data-run-status={node.data.status}>
|
||||
<section
|
||||
className={css.root}
|
||||
data-workflow-run
|
||||
data-run-status={node.data.status}
|
||||
onMouseDownCapture={disclosures.run.pendingCleanCollapse
|
||||
? preventPendingHeaderFocus
|
||||
: undefined}
|
||||
>
|
||||
<RunHeader
|
||||
count={totalMembers}
|
||||
name={node.data.name}
|
||||
requiresExpansion={requiresExpansion}
|
||||
open={disclosures.run.open}
|
||||
onToggle={toggleRun}
|
||||
status={node.data.status}
|
||||
t={t}
|
||||
>
|
||||
<div className={css.phaseList}>
|
||||
<div ref={runContentRef} className={css.phaseList} onBlur={settleRunBlur}>
|
||||
{node.data.phases.length === 0
|
||||
? <span className={css.empty}>{t('run.empty')}</span>
|
||||
: node.data.phases.map(phase => (
|
||||
<PhaseSection
|
||||
key={phase.key}
|
||||
phase={phase}
|
||||
navigable={navigable}
|
||||
openSession={openSession}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
: node.data.phases.map((phase) => {
|
||||
const facts = phaseDisclosureFacts(phase)
|
||||
const disclosure = disclosures.phases.get(phase.key) ?? initialDisclosureState(facts)
|
||||
return (
|
||||
<PhaseSection
|
||||
key={phase.key}
|
||||
contentRef={(element) => {
|
||||
if (element === null) phaseContentRefs.current.delete(phase.key)
|
||||
else phaseContentRefs.current.set(phase.key, element)
|
||||
}}
|
||||
onContentBlur={(event) => { settlePhaseBlur(phase.key, event) }}
|
||||
onToggle={() => { togglePhase(phase.key) }}
|
||||
open={disclosure.open}
|
||||
pendingCleanCollapse={disclosure.pendingCleanCollapse}
|
||||
phase={phase}
|
||||
navigable={navigable}
|
||||
openSession={openSession}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</RunHeader>
|
||||
</section>
|
||||
|
||||
@@ -27,6 +27,7 @@ afterEach(cleanup)
|
||||
|
||||
const PARENT_ID = 'parent' as SessionId
|
||||
const CHILD_ID = 'child-1' as SessionId
|
||||
const SECOND_ID = 'child-2' as SessionId
|
||||
|
||||
interface ChatSnapshot {
|
||||
readonly nodes: ReadonlyMap<string, ChatConversationViewNode>
|
||||
@@ -301,28 +302,62 @@ function panelProps(data: WorkflowRunChatData, sessions = listState(), openSessi
|
||||
}
|
||||
|
||||
describe('WorkflowRunPanel', () => {
|
||||
it('forces running run and phase content open without false disclosure controls', () => {
|
||||
const view = render(<WorkflowRunPanel {...panelProps({
|
||||
it('keeps live run and phase controls manual across ordinary updates and outer hiding', () => {
|
||||
const running: WorkflowRunChatData = {
|
||||
name: 'audit', status: 'running', phases: [phase({ key: 'research', phase: 'Research' })],
|
||||
})} />)
|
||||
expect(screen.getByText('worker')).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: /^audit/ })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /Research/ })).toBeNull()
|
||||
const rows = [...view.container.querySelectorAll('[data-disclosure-row]')]
|
||||
expect(rows).toHaveLength(2)
|
||||
for (const row of rows) {
|
||||
expect(row.getAttribute('role')).toBeNull()
|
||||
expect(row.getAttribute('tabindex')).toBeNull()
|
||||
expect(row.getAttribute('aria-expanded')).toBeNull()
|
||||
expect(row.getAttribute('data-expandable')).toBeNull()
|
||||
}
|
||||
const view = render(<WorkflowRunPanel {...panelProps(running)} />)
|
||||
const runHeader = screen.getByRole('button', { name: /^audit/ })
|
||||
const phaseHeader = screen.getByRole('button', { name: /Research/ })
|
||||
expect(runHeader.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(phaseHeader.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(screen.getByText('worker')).toBeTruthy()
|
||||
|
||||
fireEvent.click(phaseHeader)
|
||||
expect(phaseHeader.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.queryByText('worker')).toBeNull()
|
||||
fireEvent.click(runHeader)
|
||||
expect(runHeader.getAttribute('aria-expanded')).toBe('false')
|
||||
|
||||
view.rerender(<WorkflowRunPanel {...panelProps({
|
||||
...running,
|
||||
phases: [phase({
|
||||
key: 'research', phase: 'Research',
|
||||
members: [
|
||||
{ seq: 1, label: 'worker', childId: CHILD_ID, status: 'running' },
|
||||
{ seq: 2, label: 'second', childId: 'child-2' as SessionId, status: 'running' },
|
||||
],
|
||||
})],
|
||||
})} />)
|
||||
expect(runHeader.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.queryByRole('button', { name: /Research/ })).toBeNull()
|
||||
fireEvent.keyDown(runHeader, { key: 'ArrowDown' })
|
||||
expect(runHeader.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.keyDown(runHeader, { key: ' ' })
|
||||
const updatedPhase = screen.getByRole('button', { name: /Research/ })
|
||||
expect(updatedPhase.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.getByText('运行中 2')).toBeTruthy()
|
||||
fireEvent.keyDown(updatedPhase, { key: 'Enter' })
|
||||
expect(screen.getByText('worker')).toBeTruthy()
|
||||
expect(screen.getByText('second')).toBeTruthy()
|
||||
|
||||
fireEvent.click(runHeader)
|
||||
expect(runHeader.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.queryByRole('button', { name: /Research/ })).toBeNull()
|
||||
fireEvent.keyDown(runHeader, { key: ' ' })
|
||||
expect(screen.getByRole('button', { name: /Research/ }).getAttribute('aria-expanded')).toBe('true')
|
||||
})
|
||||
|
||||
it('folds each clean transition once and preserves review choices until activity returns', () => {
|
||||
it('folds each normal completion once and opens a new same-key activity cycle', () => {
|
||||
const running: WorkflowRunChatData = {
|
||||
name: 'audit', status: 'running', phases: [phase()],
|
||||
}
|
||||
const view = render(<WorkflowRunPanel {...panelProps(running)} />)
|
||||
const runningPhase = screen.getByRole('button', { name: /未分阶段/ })
|
||||
fireEvent.click(runningPhase)
|
||||
fireEvent.keyDown(runningPhase, { key: 'Enter' })
|
||||
expect(screen.getByText('worker')).toBeTruthy()
|
||||
|
||||
const phaseCompleted: WorkflowRunChatData = {
|
||||
...running,
|
||||
phases: [phase({
|
||||
@@ -338,27 +373,8 @@ describe('WorkflowRunPanel', () => {
|
||||
fireEvent.click(phaseHeader)
|
||||
expect(screen.getByText('done')).toBeTruthy()
|
||||
|
||||
const completed: WorkflowRunChatData = { ...phaseCompleted, status: 'completed' }
|
||||
view.rerender(<WorkflowRunPanel {...panelProps(completed)} />)
|
||||
const runHeader = screen.getByRole('button', { name: /^audit/ })
|
||||
expect(runHeader.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.queryByText('未分阶段')).toBeNull()
|
||||
fireEvent.keyDown(runHeader, { key: 'ArrowDown' })
|
||||
expect(runHeader.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.keyDown(runHeader, { key: 'Enter' })
|
||||
expect(runHeader.getAttribute('aria-expanded')).toBe('true')
|
||||
const completedPhase = screen.getByRole('button', { name: /未分阶段/ })
|
||||
fireEvent.keyDown(completedPhase, { key: 'Enter' })
|
||||
expect(screen.getByText('done')).toBeTruthy()
|
||||
fireEvent.keyDown(runHeader, { key: ' ' })
|
||||
expect(runHeader.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.keyDown(runHeader, { key: ' ' })
|
||||
expect(runHeader.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
|
||||
expect(screen.getByText('done')).toBeTruthy()
|
||||
|
||||
const cleanUpdate: WorkflowRunChatData = {
|
||||
...completed,
|
||||
...phaseCompleted,
|
||||
phases: [phase({
|
||||
members: [{
|
||||
seq: 1, label: 'reviewed', childId: 'child-1' as SessionId, status: 'completed',
|
||||
@@ -368,13 +384,44 @@ describe('WorkflowRunPanel', () => {
|
||||
view.rerender(<WorkflowRunPanel {...panelProps(cleanUpdate)} />)
|
||||
expect(screen.getByText('reviewed')).toBeTruthy()
|
||||
|
||||
view.rerender(<WorkflowRunPanel {...panelProps(running)} />)
|
||||
expect(screen.queryByRole('button', { name: /^audit/ })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull()
|
||||
expect(screen.getByText('worker')).toBeTruthy()
|
||||
view.rerender(<WorkflowRunPanel {...panelProps(completed)} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
|
||||
const runHeader = screen.getByRole('button', { name: /^audit/ })
|
||||
fireEvent.click(runHeader)
|
||||
const renewed: WorkflowRunChatData = {
|
||||
name: 'audit', status: 'running',
|
||||
phases: [phase({
|
||||
members: [
|
||||
{ seq: 1, label: 'reviewed', childId: CHILD_ID, status: 'completed' },
|
||||
{ seq: 2, label: 'new', childId: 'child-2' as SessionId, status: 'running' },
|
||||
],
|
||||
})],
|
||||
}
|
||||
view.rerender(<WorkflowRunPanel {...panelProps(renewed)} />)
|
||||
expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('true')
|
||||
expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('true')
|
||||
expect(screen.getByText('new')).toBeTruthy()
|
||||
|
||||
const renewedPhaseCompleted: WorkflowRunChatData = {
|
||||
...renewed,
|
||||
phases: [phase({
|
||||
members: [
|
||||
{ seq: 1, label: 'reviewed', childId: CHILD_ID, status: 'completed' },
|
||||
{ seq: 2, label: 'new', childId: 'child-2' as SessionId, status: 'completed' },
|
||||
],
|
||||
})],
|
||||
}
|
||||
view.rerender(<WorkflowRunPanel {...panelProps(renewedPhaseCompleted)} />)
|
||||
expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('true')
|
||||
expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
|
||||
|
||||
view.rerender(<WorkflowRunPanel {...panelProps({
|
||||
...renewedPhaseCompleted,
|
||||
status: 'completed',
|
||||
})} />)
|
||||
expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.queryByText('未分阶段')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: /^audit/ }))
|
||||
expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('true')
|
||||
})
|
||||
|
||||
it('refolds a phase when a complete activity cycle arrives as one clean update', () => {
|
||||
@@ -388,21 +435,59 @@ describe('WorkflowRunPanel', () => {
|
||||
const phaseView = render(<WorkflowRunPanel {...panelProps(phaseClean)} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
|
||||
expect(screen.getByText('first')).toBeTruthy()
|
||||
const runHeader = screen.getByRole('button', { name: /^phase-cycle/ })
|
||||
fireEvent.click(runHeader)
|
||||
expect(runHeader.getAttribute('aria-expanded')).toBe('false')
|
||||
phaseView.rerender(<WorkflowRunPanel {...panelProps({
|
||||
...phaseClean,
|
||||
phases: [phase({ members: [firstMember, {
|
||||
seq: 2, label: 'second', childId: 'child-2' as SessionId, status: 'completed',
|
||||
}] })],
|
||||
})} />)
|
||||
expect(runHeader.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.queryByText('first')).toBeNull()
|
||||
expect(screen.queryByText('second')).toBeNull()
|
||||
|
||||
phaseView.rerender(<WorkflowRunPanel {...panelProps({
|
||||
...phaseClean,
|
||||
status: 'completed',
|
||||
phases: [phase({ members: [firstMember, {
|
||||
seq: 2, label: 'second', childId: SECOND_ID, status: 'completed',
|
||||
}, {
|
||||
seq: 3, label: 'final', childId: 'child-3' as SessionId, status: 'completed',
|
||||
}] })],
|
||||
})} />)
|
||||
expect(runHeader.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('initializes a newly observed phase before it becomes interactive', () => {
|
||||
const running: WorkflowRunChatData = {
|
||||
name: 'dynamic-phase', status: 'running',
|
||||
phases: [phase({ key: 'research', phase: 'Research' })],
|
||||
}
|
||||
const view = render(<WorkflowRunPanel {...panelProps(running)} />)
|
||||
view.rerender(<WorkflowRunPanel {...panelProps({
|
||||
...running,
|
||||
phases: [
|
||||
...running.phases,
|
||||
phase({
|
||||
key: 'build', phase: 'Build',
|
||||
members: [{ seq: 2, label: 'builder', childId: SECOND_ID, status: 'running' }],
|
||||
}),
|
||||
],
|
||||
})} />)
|
||||
const build = screen.getByRole('button', { name: /Build/ })
|
||||
expect(build.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.click(build)
|
||||
expect(build.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('derives the zero-member running and completed states from the current run status', () => {
|
||||
const running: WorkflowRunChatData = { name: 'empty', status: 'running', phases: [] }
|
||||
const view = render(<WorkflowRunPanel {...panelProps(running)} />)
|
||||
expect(screen.queryByRole('button', { name: /^empty/ })).toBeNull()
|
||||
expect(screen.getByRole('button', { name: /^empty/ }).getAttribute('aria-expanded')).toBe('true')
|
||||
expect(screen.getByText('没有启动成员')).toBeTruthy()
|
||||
view.rerender(<WorkflowRunPanel {...panelProps({ ...running, status: 'completed' })} />)
|
||||
const header = screen.getByRole('button', { name: /^empty/ })
|
||||
@@ -413,31 +498,60 @@ describe('WorkflowRunPanel', () => {
|
||||
})
|
||||
|
||||
it.each(['failed', 'cancelled', 'interrupted'] as const)(
|
||||
'bubbles a %s member to the run and keeps a matching run outcome open',
|
||||
'initializes %s attention as an expanded disclosure that remains manually collapsible',
|
||||
(status) => {
|
||||
const memberView = render(<WorkflowRunPanel {...panelProps({
|
||||
name: 'member-outcome', status: 'completed',
|
||||
render(<WorkflowRunPanel {...panelProps({
|
||||
name: 'member-outcome', status,
|
||||
phases: [phase({
|
||||
members: [{ seq: 1, label: status, childId: CHILD_ID, status }],
|
||||
})],
|
||||
})} />)
|
||||
expect(screen.queryByRole('button', { name: /^member-outcome/ })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull()
|
||||
const runHeader = screen.getByRole('button', { name: /^member-outcome/ })
|
||||
const phaseHeader = screen.getByRole('button', { name: /未分阶段/ })
|
||||
expect(runHeader.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(phaseHeader.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(screen.getByText(status)).toBeTruthy()
|
||||
memberView.unmount()
|
||||
|
||||
render(<WorkflowRunPanel {...panelProps({
|
||||
name: 'run-outcome', status,
|
||||
phases: [phase({
|
||||
members: [{ seq: 1, label: 'done', childId: CHILD_ID, status: 'completed' }],
|
||||
})],
|
||||
})} />)
|
||||
expect(screen.queryByRole('button', { name: /^run-outcome/ })).toBeNull()
|
||||
expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.queryByText('done')).toBeNull()
|
||||
fireEvent.click(phaseHeader)
|
||||
fireEvent.click(runHeader)
|
||||
expect(runHeader.getAttribute('aria-expanded')).toBe('false')
|
||||
},
|
||||
)
|
||||
|
||||
it('opens the first abnormal edge once and preserves later abnormal choices', () => {
|
||||
const running: WorkflowRunChatData = {
|
||||
name: 'audit', status: 'running', phases: [phase()],
|
||||
}
|
||||
const view = render(<WorkflowRunPanel {...panelProps(running)} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /^audit/ }))
|
||||
|
||||
const failed: WorkflowRunChatData = {
|
||||
name: 'audit', status: 'running',
|
||||
phases: [phase({
|
||||
members: [{ seq: 1, label: 'failed', childId: CHILD_ID, status: 'failed' }],
|
||||
})],
|
||||
}
|
||||
view.rerender(<WorkflowRunPanel {...panelProps(failed)} />)
|
||||
expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('true')
|
||||
expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /^audit/ }))
|
||||
|
||||
view.rerender(<WorkflowRunPanel {...panelProps({
|
||||
...failed,
|
||||
phases: [phase({
|
||||
members: [
|
||||
{ seq: 1, label: 'failed', childId: CHILD_ID, status: 'failed' },
|
||||
{ seq: 2, label: 'cancelled', childId: 'child-2' as SessionId, status: 'cancelled' },
|
||||
],
|
||||
})],
|
||||
})} />)
|
||||
expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.click(screen.getByRole('button', { name: /^audit/ }))
|
||||
expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.getByText('失败 1 · 已取消 1')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps clean sibling phases independent and preserves empty versus absent names', () => {
|
||||
render(<WorkflowRunPanel {...panelProps({
|
||||
name: 'audit', status: 'completed',
|
||||
@@ -450,18 +564,26 @@ describe('WorkflowRunPanel', () => {
|
||||
}] }),
|
||||
],
|
||||
})} />)
|
||||
expect(screen.queryByRole('button', { name: /^audit/ })).toBeNull()
|
||||
const runHeader = screen.getByRole('button', { name: /^audit/ })
|
||||
expect(runHeader.getAttribute('aria-expanded')).toBe('true')
|
||||
const cleanPhase = screen.getByRole('button', { name: /空阶段名/ })
|
||||
expect(cleanPhase.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull()
|
||||
const activePhase = screen.getByRole('button', { name: /未分阶段/ })
|
||||
expect(activePhase.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(screen.queryByText('空成员名')).toBeNull()
|
||||
expect(screen.getByText('second')).toBeTruthy()
|
||||
fireEvent.click(cleanPhase)
|
||||
expect(screen.getByText('空成员名')).toBeTruthy()
|
||||
expect(screen.getByText('second')).toBeTruthy()
|
||||
fireEvent.click(cleanPhase)
|
||||
fireEvent.click(activePhase)
|
||||
expect(screen.queryByText('second')).toBeNull()
|
||||
expect(screen.getByText('空成员名')).toBeTruthy()
|
||||
fireEvent.click(runHeader)
|
||||
fireEvent.click(runHeader)
|
||||
expect(screen.getByRole('button', { name: /空阶段名/ }).getAttribute('aria-expanded')).toBe('true')
|
||||
expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.click(screen.getByRole('button', { name: /空阶段名/ }))
|
||||
expect(screen.queryByText('空成员名')).toBeNull()
|
||||
expect(screen.getByText('second')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders mixed and interrupted aggregate status while attention stays visible', () => {
|
||||
@@ -496,6 +618,170 @@ describe('WorkflowRunPanel', () => {
|
||||
expect(interruptedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('defers normal completion collapse until focused member content loses focus', () => {
|
||||
const sessions = listState({
|
||||
ids: [PARENT_ID, CHILD_ID, SECOND_ID],
|
||||
byId: {
|
||||
...listState().byId,
|
||||
[SECOND_ID]: {
|
||||
id: SECOND_ID, displayTitle: 'second', parentId: PARENT_ID, origin: 'subagent',
|
||||
running: true, blank: false, updatedAt: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
const running: WorkflowRunChatData = {
|
||||
name: 'audit', status: 'running', phases: [phase({
|
||||
members: [
|
||||
{ seq: 1, label: 'worker', childId: CHILD_ID, status: 'running' },
|
||||
{ seq: 2, label: 'second', childId: SECOND_ID, status: 'running' },
|
||||
],
|
||||
})],
|
||||
}
|
||||
const view = render(<WorkflowRunPanel {...panelProps(running, sessions)} />)
|
||||
const member = screen.getByRole('button', { name: '打开 worker' })
|
||||
const second = screen.getByRole('button', { name: '打开 second' })
|
||||
const runHeader = screen.getByRole('button', { name: /^audit/ })
|
||||
const phaseHeader = screen.getByRole('button', { name: /未分阶段/ })
|
||||
member.focus()
|
||||
expect(document.activeElement).toBe(member)
|
||||
fireEvent.blur(member, { relatedTarget: second })
|
||||
second.focus()
|
||||
expect(runHeader.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(phaseHeader.getAttribute('aria-expanded')).toBe('true')
|
||||
|
||||
const outside = document.createElement('button')
|
||||
document.body.append(outside)
|
||||
fireEvent.blur(second, { relatedTarget: outside })
|
||||
outside.focus()
|
||||
expect(runHeader.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(phaseHeader.getAttribute('aria-expanded')).toBe('true')
|
||||
member.focus()
|
||||
|
||||
view.rerender(<WorkflowRunPanel {...panelProps({
|
||||
name: 'audit', status: 'completed',
|
||||
phases: [phase({
|
||||
members: [
|
||||
{ seq: 1, label: 'worker', childId: CHILD_ID, status: 'completed' },
|
||||
{ seq: 2, label: 'second', childId: SECOND_ID, status: 'completed' },
|
||||
],
|
||||
})],
|
||||
}, sessions)} />)
|
||||
expect(runHeader.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(phaseHeader.getAttribute('aria-expanded')).toBe('true')
|
||||
const retained = screen.getByRole('button', { name: 'worker' })
|
||||
expect(retained.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(document.activeElement).toBe(retained)
|
||||
|
||||
fireEvent.blur(retained, { relatedTarget: outside })
|
||||
outside.focus()
|
||||
expect(document.activeElement).toBe(outside)
|
||||
expect(runHeader.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.click(runHeader)
|
||||
const completedPhase = screen.getByRole('button', { name: /未分阶段/ })
|
||||
expect(completedPhase.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.click(completedPhase)
|
||||
expect(screen.queryByRole('button', { name: '打开 worker' })).toBeNull()
|
||||
expect(screen.getByText('worker')).toBeTruthy()
|
||||
outside.remove()
|
||||
})
|
||||
|
||||
it('handles a pointer blur and header click as one pending-completion close', () => {
|
||||
const running: WorkflowRunChatData = {
|
||||
name: 'audit', status: 'running', phases: [phase()],
|
||||
}
|
||||
const view = render(<WorkflowRunPanel {...panelProps(running)} />)
|
||||
const member = screen.getByRole('button', { name: '打开 worker' })
|
||||
member.focus()
|
||||
view.rerender(<WorkflowRunPanel {...panelProps({
|
||||
name: 'audit', status: 'completed',
|
||||
phases: [phase({
|
||||
members: [{ seq: 1, label: 'worker', childId: CHILD_ID, status: 'completed' }],
|
||||
})],
|
||||
})} />)
|
||||
const retained = screen.getByRole('button', { name: 'worker' })
|
||||
const phaseHeader = screen.getByRole('button', { name: /未分阶段/ })
|
||||
const runHeader = screen.getByRole('button', { name: /^audit/ })
|
||||
|
||||
expect(fireEvent.mouseDown(retained)).toBe(true)
|
||||
expect(document.activeElement).toBe(retained)
|
||||
expect(fireEvent.mouseDown(phaseHeader)).toBe(false)
|
||||
fireEvent.click(phaseHeader)
|
||||
expect(phaseHeader.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(runHeader.getAttribute('aria-expanded')).toBe('true')
|
||||
|
||||
expect(fireEvent.mouseDown(runHeader)).toBe(false)
|
||||
fireEvent.click(runHeader)
|
||||
expect(runHeader.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('settles pending completion when keyboard focus moves from content to its header', () => {
|
||||
const running: WorkflowRunChatData = {
|
||||
name: 'audit', status: 'running', phases: [phase()],
|
||||
}
|
||||
const view = render(<WorkflowRunPanel {...panelProps(running)} />)
|
||||
const member = screen.getByRole('button', { name: '打开 worker' })
|
||||
member.focus()
|
||||
view.rerender(<WorkflowRunPanel {...panelProps({
|
||||
...running,
|
||||
phases: [phase({
|
||||
members: [{ seq: 1, label: 'worker', childId: CHILD_ID, status: 'completed' }],
|
||||
})],
|
||||
})} />)
|
||||
const retained = screen.getByRole('button', { name: 'worker' })
|
||||
const phaseHeader = screen.getByRole('button', { name: /未分阶段/ })
|
||||
const runHeader = screen.getByRole('button', { name: /^audit/ })
|
||||
fireEvent.blur(retained, { relatedTarget: phaseHeader })
|
||||
phaseHeader.focus()
|
||||
expect(phaseHeader.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(runHeader.getAttribute('aria-expanded')).toBe('true')
|
||||
|
||||
view.rerender(<WorkflowRunPanel {...panelProps({
|
||||
...running,
|
||||
status: 'completed',
|
||||
phases: [phase({
|
||||
members: [{ seq: 1, label: 'worker', childId: CHILD_ID, status: 'completed' }],
|
||||
})],
|
||||
})} />)
|
||||
expect(runHeader.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.blur(phaseHeader, { relatedTarget: runHeader })
|
||||
runHeader.focus()
|
||||
expect(runHeader.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('settles a deferred phase close when the user hides the outer run', () => {
|
||||
const running: WorkflowRunChatData = {
|
||||
name: 'audit', status: 'running', phases: [phase()],
|
||||
}
|
||||
const view = render(<WorkflowRunPanel {...panelProps(running)} />)
|
||||
const member = screen.getByRole('button', { name: '打开 worker' })
|
||||
member.focus()
|
||||
view.rerender(<WorkflowRunPanel {...panelProps({
|
||||
...running,
|
||||
phases: [phase({
|
||||
members: [{ seq: 1, label: 'worker', childId: CHILD_ID, status: 'completed' }],
|
||||
})],
|
||||
})} />)
|
||||
const runHeader = screen.getByRole('button', { name: /^audit/ })
|
||||
expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.click(runHeader)
|
||||
expect(runHeader.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.click(runHeader)
|
||||
expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('reinitializes manual choices from durable facts after a renderer remount', () => {
|
||||
const data: WorkflowRunChatData = {
|
||||
name: 'audit', status: 'running', phases: [phase()],
|
||||
}
|
||||
const view = render(<WorkflowRunPanel {...panelProps(data)} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /^audit/ }))
|
||||
view.unmount()
|
||||
render(<WorkflowRunPanel {...panelProps(data)} />)
|
||||
expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('true')
|
||||
expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('true')
|
||||
})
|
||||
|
||||
it('opens only a running ordinary-list subagent proven to have this parent', () => {
|
||||
const data: WorkflowRunChatData = {
|
||||
name: 'audit', status: 'running', phases: [phase()],
|
||||
@@ -506,6 +792,16 @@ describe('WorkflowRunPanel', () => {
|
||||
expect(openSession).toHaveBeenCalledWith('child-1')
|
||||
})
|
||||
|
||||
it('promotes a running member when its ordinary Session row arrives', () => {
|
||||
const data: WorkflowRunChatData = {
|
||||
name: 'audit', status: 'running', phases: [phase()],
|
||||
}
|
||||
const view = render(<WorkflowRunPanel {...panelProps(data, listState({ ids: [PARENT_ID] }))} />)
|
||||
expect(screen.queryByRole('button', { name: '打开 worker' })).toBeNull()
|
||||
view.rerender(<WorkflowRunPanel {...panelProps(data, listState())} />)
|
||||
expect(screen.getByRole('button', { name: '打开 worker' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['not in ordinary list', listState({ ids: [PARENT_ID] }), 'running'],
|
||||
['remote row', listState({ byId: {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md
|
||||
README.md: af89e7ee3bab6ec209349d047f81308eb6e87cef
|
||||
README.zh.md: e9ce3206027ffeee9bc49eb7a2ed76ddcbe7bfc8
|
||||
README.md: 9bb28e6876b82c521341769123a8b2d0e5d98e09
|
||||
README.zh.md: 21c55cfa32bf68e0cac4c0bd72c94c86d955fc00
|
||||
@@ -20,7 +20,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire
|
||||
reasoningEffort: high # optional; off | low | high | max — omitted ⇒ high
|
||||
maxTokens: 256000 # optional positive per-request output cap; this is the default
|
||||
streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default
|
||||
retryPolicy: # optional; omission uses bounded normal defaults
|
||||
retryPolicy: # optional; omission uses normal mode with five retries
|
||||
mode: always # normal | always
|
||||
backoff:
|
||||
initialDelayMs: 500
|
||||
@@ -35,7 +35,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire
|
||||
contextWindow: 512000
|
||||
```
|
||||
|
||||
The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` as `DeepSeek-V4-Flash` and `deepseek-v4-pro` as `DeepSeek-V4-Pro`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id.
|
||||
The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`; omission resolves to normal mode with five retries. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` as `DeepSeek-V4-Flash` and `deepseek-v4-pro` as `DeepSeek-V4-Pro`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id.
|
||||
|
||||
`contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelInfo('deepseek-official', model).context` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. The adapter default is 1,000,000; pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek-official` throws `LlmError('DUPLICATE_ADAPTER')`.
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
|
||||
reasoningEffort: high # optional; off | low | high | max — omitted ⇒ high
|
||||
maxTokens: 256000 # optional positive per-request output cap; this is the default
|
||||
streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default
|
||||
retryPolicy: # optional; omission uses bounded normal defaults
|
||||
retryPolicy: # optional; omission uses normal mode with five retries
|
||||
mode: always # normal | always
|
||||
backoff:
|
||||
initialDelayMs: 500
|
||||
@@ -35,7 +35,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
|
||||
contextWindow: 512000
|
||||
```
|
||||
|
||||
该插件注册唯一提供方路由 `deepseek-official`,同时注册解析后的 `retryPolicy`。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`(名称为 `DeepSeek-V4-Flash`)和 `deepseek-v4-pro`(名称为 `DeepSeek-V4-Pro`),两者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id。
|
||||
该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`(名称为 `DeepSeek-V4-Flash`)和 `deepseek-v4-pro`(名称为 `DeepSeek-V4-Pro`),两者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id。
|
||||
|
||||
`contextWindow` 对每个已配置模型都可选,不会通过建议 catalog 公开。`ctx.llm.resolveModelInfo('deepseek-official', model).context` 先返回精确模型值,再对不含容量的配置项或未列出原样传递 id 返回 `defaultContextWindow`。适配器默认值为 1,000,000;因此,压力敏感插件可以获得由部署决定的容量,不会将模型 selector 视为权威。为 `deepseek-official` 注册另一个适配器会抛出 `LlmError('DUPLICATE_ADAPTER')`。
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ export interface Config {
|
||||
models?: DeepSeekCatalogModel[]
|
||||
/** Maximum provider idle time while one stream read is outstanding (default five minutes). */
|
||||
streamIdleTimeoutMs?: number
|
||||
/** Provider-owned model-request retry policy; omission uses normal defaults. */
|
||||
/** Provider-owned model-request retry policy; omission uses normal mode with five retries. */
|
||||
retryPolicy?: RetryPolicyConfig
|
||||
}
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md
|
||||
README.md: 5dbcb905451f72a700dd09b4052dcb2f88e858c9
|
||||
README.zh.md: 217244c7b4d7ecd5aa88427990feb58c96e5acaf
|
||||
README.md: f696b6bee50b844bfbc6bab7f9def0e785d450c9
|
||||
README.zh.md: cace9d1fdfd85a559674b121b79b3dbb86b4337b
|
||||
@@ -8,7 +8,7 @@ The package root exposes the Cordis plugin contract, `PiAiAdapter`, and `support
|
||||
|
||||
## Config
|
||||
|
||||
Configure credentials, the model catalog, and deployment-specific transport settings per provider, keyed by the provider route itself. `apiKeyEnv` is a credential *reference* resolved per request, so no secret enters this file. Omitting it leaves the route unauthenticated, which for an installed catalog route means pi-ai's provider-native ambient discovery; a configured reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` instead, because falling through would authenticate with whatever unrelated key the environment happens to hold. One credential serves every model on its route.
|
||||
Configure credentials, the model catalog, and deployment-specific transport settings per provider, keyed by the provider route itself. Each profile may set a `retryPolicy`; omission uses normal mode with five retries. `apiKeyEnv` is a credential *reference* resolved per request, so no secret enters this file. Omitting it leaves the route unauthenticated, which for an installed catalog route means pi-ai's provider-native ambient discovery; a configured reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` instead, because falling through would authenticate with whatever unrelated key the environment happens to hold. One credential serves every model on its route.
|
||||
|
||||
```yaml
|
||||
- id: llm
|
||||
@@ -113,7 +113,7 @@ A model that carries reasoning metadata — from the installed catalog or from i
|
||||
|
||||
A model **without** that metadata — a hand-declared one whose entry declares no `reasoningEfforts`, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`.
|
||||
|
||||
Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. `maxRequestImageBytes` bounds one request's base64-encoded image payload (default 20MiB, a positive integer): every image in history is re-encoded into every request, so when the accumulated payload exceeds the bound, the oldest images are replaced by a fixed text placeholder until the request fits, keeping an image-heavy session serviceable instead of permanently rejected by a gateway request-size cap. The default leaves capacity for system prompts, history, tools, and JSON; deployments behind stricter gateways lower it per route. Harness app attribution wins a conflicting configured header name.
|
||||
Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, and `retryPolicy`. Each resolved profile retry policy is captured with that provider route; omission uses the shared bounded normal default of five retries. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. `maxRequestImageBytes` bounds one request's base64-encoded image payload (default 20MiB, a positive integer): every image in history is re-encoded into every request, so when the accumulated payload exceeds the bound, the oldest images are replaced by a fixed text placeholder until the request fits, keeping an image-heavy session serviceable instead of permanently rejected by a gateway request-size cap. The default leaves capacity for system prompts, history, tools, and JSON; deployments behind stricter gateways lower it per route. Harness app attribution wins a conflicting configured header name.
|
||||
|
||||
The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`.
|
||||
|
||||
@@ -202,4 +202,4 @@ Recorded response content appends to the next request and does not invalidate it
|
||||
- **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field.
|
||||
- **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override.
|
||||
- **Provider HTTP status is unavailable** — pi-ai error events do not expose a stable HTTP status across providers; failures expose only stable harness error codes.
|
||||
- **Retry policy is provider-owned, not an SDK retry** — each provider profile may configure nested `retryPolicy`, which `dsh-llm-retry` executes at the agent failed-step extension point; pi-ai SDK retries stay disabled so durable agent steps and `llm/retry` events own every visible attempt, and direct `ctx.llm.stream()` calls remain single-attempt.
|
||||
- **Retry policy is provider-owned, not an SDK retry** — each provider profile may supply nested `retryPolicy`; omission resolves to normal mode with five retries, and the effective route policy is what `dsh-llm-retry` executes at the agent failed-step extension point. pi-ai SDK retries stay disabled so durable agent steps and `llm/retry` events own every visible attempt, and direct `ctx.llm.stream()` calls remain single-attempt.
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## 配置
|
||||
|
||||
按提供方配置凭据、模型 catalog 与部署特定传输设置,并以提供方路由本身为键。`apiKeyEnv` 是按请求解析的凭据*引用*,因此机密不进入该文件。省略它会让该路由处于未认证状态;对已安装 catalog 路由而言,这意味着交给 pi-ai 的提供方原生环境发现。已配置却解析不出任何值的引用则相反,会让请求以 `MISSING_CREDENTIAL` 失败,因为放行下去就会用环境里恰好持有的某个无关密钥完成认证。一条凭据服务该路由下的全部模型。
|
||||
按提供方配置凭据、模型 catalog 与部署特定传输设置,并以提供方路由本身为键。每个 profile 都可以设置 `retryPolicy`;省略时使用 normal 模式并重试五次。`apiKeyEnv` 是按请求解析的凭据*引用*,因此机密不进入该文件。省略它会让该路由处于未认证状态;对已安装 catalog 路由而言,这意味着交给 pi-ai 的提供方原生环境发现。已配置却解析不出任何值的引用则相反,会让请求以 `MISSING_CREDENTIAL` 失败,因为放行下去就会用环境里恰好持有的某个无关密钥完成认证。一条凭据服务该路由下的全部模型。
|
||||
|
||||
```yaml
|
||||
- id: llm
|
||||
@@ -114,7 +114,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩
|
||||
|
||||
**没有**这份元数据的模型——条目未声明 `reasoningEfforts` 的手工声明模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。
|
||||
|
||||
受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。`maxRequestImageBytes` 约束单个请求的 base64 编码图片载荷(默认 20MiB,正整数):历史中的每张图片都会重新编码进每个请求,累积载荷超过上限时,从最老的图片开始替换为固定文本占位,直到请求装得下,使图片较多的会话保持可用,而不是被网关请求体上限永久拒绝。默认值为系统提示词、历史、工具与 JSON 保留请求容量;网关更严格的部署按路由调低该值。若已配置标头中有同名项,则以 Harness 应用归因为准。
|
||||
受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes` 和 `retryPolicy`。每条 profile 解析后的重试策略会随该提供方路由一同捕获;省略时使用共享的有界 normal 默认值并重试五次。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。`maxRequestImageBytes` 约束单个请求的 base64 编码图片载荷(默认 20MiB,正整数):历史中的每张图片都会重新编码进每个请求,累积载荷超过上限时,从最老的图片开始替换为固定文本占位,直到请求装得下,使图片较多的会话保持可用,而不是被网关请求体上限永久拒绝。默认值为系统提示词、历史、工具与 JSON 保留请求容量;网关更严格的部署按路由调低该值。若已配置标头中有同名项,则以 Harness 应用归因为准。
|
||||
|
||||
适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。
|
||||
|
||||
@@ -203,4 +203,4 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish
|
||||
- **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。
|
||||
- **历史中的 `system` 消息使用 pi-ai 通用上下文转换**:提供方特定位置由 pi-ai 决定,而非由 harness 拥有的协议覆盖决定。
|
||||
- **无法获取提供方 HTTP 状态**:pi-ai 错误事件不会在所有提供方上公开稳定 HTTP 状态;失败只公开稳定 harness 错误 code。
|
||||
- **重试策略由提供方持有,而不是 SDK 重试**:每个提供方 profile 都可以配置嵌套的 `retryPolicy`,由 `dsh-llm-retry` 在 agent 的失败步骤扩展点上执行;pi-ai SDK 重试仍保持禁用,因此持久化的 agent 步骤与 `llm/retry` 事件记录每次可见尝试,直接 `ctx.llm.stream()` 调用仍只尝试一次。
|
||||
- **重试策略由提供方持有,而不是 SDK 重试**:每个提供方 profile 都可以提供嵌套的 `retryPolicy`;省略时解析为 normal 模式并重试五次,`dsh-llm-retry` 会在 agent 的失败步骤扩展点上执行有效路由策略。pi-ai SDK 重试仍保持禁用,因此持久化的 agent 步骤与 `llm/retry` 事件记录每次可见尝试,直接 `ctx.llm.stream()` 调用仍只尝试一次。
|
||||
@@ -154,7 +154,7 @@ export interface PiAiProviderProfile {
|
||||
* requests instead of being rejected by a request-size cap.
|
||||
*/
|
||||
maxRequestImageBytes?: number
|
||||
/** Provider-owned model-request retry policy; omission uses normal defaults. */
|
||||
/** Provider-owned model-request retry policy; omission uses normal mode with five retries. */
|
||||
retryPolicy?: RetryPolicyConfig
|
||||
}
|
||||
|
||||
|
||||
@@ -392,7 +392,7 @@ describe('provider profile lifecycle', () => {
|
||||
})
|
||||
expect(ctx.llm.providerRetryPolicy('anthropic')).toMatchObject({
|
||||
mode: 'normal',
|
||||
maxRetries: 2,
|
||||
maxRetries: 5,
|
||||
})
|
||||
await fiber.dispose()
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/llm/llm-retry/README.md
|
||||
README.md: 0a907b5505650f7c2cd5e9933750be3701f8e34b
|
||||
README.zh.md: 024545e1ed52bb8d4aeac8114ffafc26a04a2ddb
|
||||
README.md: 1dd530051e387230510414e35701db10c7c7c063
|
||||
README.zh.md: 52badf6a6e7d2c2571fa951865f6b4b3f08ae4ad
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Function plugin that applies exact-provider retry policy through the agent loop's closed-step `agent/request-error` waterfall. It does not wrap `ctx.llm.stream()`: every adapter call remains one provider attempt, and every retry opens a fresh numbered turn.
|
||||
|
||||
Each provider adapter owns an optional nested `retryPolicy`, captured when its route registers on `ctx.llm` and carried with each call that reaches that registration's final adapter boundary. An in-flight failure retains that serving policy if the route is later disposed or replaced; a failure before any final adapter is selected has no provider policy and delegates. Omission uses normal mode: two retries for `EMPTY_RESPONSE`, `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`, with bounded exponential backoff from 500 ms to 10 seconds and 10 percent jitter. `EMPTY_RESPONSE` is the adapters' classification of a degenerate provider completion that produced no durable content, so repeating it is safe. A normal policy can change its finite budget, eligible codes, and backoff. Always mode asks downstream recovery first, then retries every model-request failure without an attempt limit; success, cancellation, or plugin disposal stops it after active delegated recovery reaches quiescence.
|
||||
Each provider adapter owns an optional nested `retryPolicy`, captured when its route registers on `ctx.llm` and carried with each call that reaches that registration's final adapter boundary. An in-flight failure retains that serving policy if the route is later disposed or replaced; a failure before any final adapter is selected has no provider policy and delegates. Omission uses normal mode: five retries for `EMPTY_RESPONSE`, `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`, with bounded exponential backoff from 500 ms to 10 seconds and 10 percent jitter. `EMPTY_RESPONSE` is the adapters' classification of a degenerate provider completion that produced no durable content, so repeating it is safe. A normal policy can change its finite budget, eligible codes, and backoff. Always mode asks downstream recovery first, then retries every model-request failure without an attempt limit; success, cancellation, or plugin disposal stops it after active delegated recovery reaches quiescence.
|
||||
|
||||
Both modes use bounded exponential backoff with symmetric jitter. A valid `providerRetryAfterMs` at or below `maxDelayMs` replaces local backoff without jitter. An over-cap provider delay makes normal mode delegate, while always mode uses its configured local backoff so it cannot terminate on that instruction.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
一个函数插件,通过 agent loop(智能体循环)在已关闭步骤上触发的 `agent/request-error` waterfall(瀑布式事件)应用确切提供方重试策略。它不包装 `ctx.llm.stream()`:每次适配器调用仍是一次提供方尝试,每次重试都会开启新的编号轮次。
|
||||
|
||||
每个提供方适配器都拥有可选的嵌套 `retryPolicy`;路由在 `ctx.llm` 上注册时会捕获该策略,任何到达该注册最终适配器边界的调用都会携带它。如果之后释放或替换路由,进行中的失败仍会保留当时为其提供服务的策略;在选中任何最终适配器前发生的失败没有提供方策略,会继续委托。省略策略时使用 normal mode:为 `EMPTY_RESPONSE`、`RATE_LIMIT`、`SERVER`、`TIMEOUT` 和 `TRANSPORT` 重试两次,并采用从 500 ms 到 10 秒的有界指数退避与 10% jitter。`EMPTY_RESPONSE` 是适配器对未产生任何持久内容的退化提供方完成所作的分类,因此可安全重复。normal 策略可以更改其有限预算、符合条件的 code 和退避配置。always mode 会先请求下游恢复,再无次数上限地重试每个模型请求失败;成功、取消或插件 dispose(资源释放)会在活跃的委托恢复完全停稳后终止它。
|
||||
每个提供方适配器都拥有可选的嵌套 `retryPolicy`;路由在 `ctx.llm` 上注册时会捕获该策略,任何到达该注册最终适配器边界的调用都会携带它。如果之后释放或替换路由,进行中的失败仍会保留当时为其提供服务的策略;在选中任何最终适配器前发生的失败没有提供方策略,会继续委托。省略策略时使用 normal mode:为 `EMPTY_RESPONSE`、`RATE_LIMIT`、`SERVER`、`TIMEOUT` 和 `TRANSPORT` 重试五次,并采用从 500 ms 到 10 秒的有界指数退避与 10% jitter。`EMPTY_RESPONSE` 是适配器对未产生任何持久内容的退化提供方完成所作的分类,因此可安全重复。normal 策略可以更改其有限预算、符合条件的 code 和退避配置。always mode 会先请求下游恢复,再无次数上限地重试每个模型请求失败;成功、取消或插件 dispose(资源释放)会在活跃的委托恢复完全停稳后终止它。
|
||||
|
||||
两种 mode 都使用带对称 jitter 的有界指数退避。有效 `providerRetryAfterMs` 不超过 `maxDelayMs` 时会替换本地退避,并且不加 jitter。超出上限的提供方延迟会使 normal mode 继续委托;always mode 则改用已配置的本地退避,避免该指令终止重试。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/llm/llm/README.md
|
||||
README.md: fb6bd84240b41dd730d45b3eb34c35827dc4c991
|
||||
README.zh.md: 5c22767a7c654972cbf614505382fa4755d7d318
|
||||
README.md: e6b3c4924ad4e7cf115abdbfb38d22d0f524e377
|
||||
README.zh.md: 51f034af632c19ca91c1d2f91a7c9c22714870e0
|
||||
@@ -8,6 +8,10 @@ Provider-neutral LLM vocabulary and abstract service. This package defines the c
|
||||
|
||||
An adapter registry plus a single streaming call API, interceptable via a waterfall event.
|
||||
|
||||
### Retry policy
|
||||
|
||||
Each provider adapter supplies its resolved route policy. Omitting provider configuration uses bounded normal mode with five retries after the first request. Layered configuration may retain `maxRetries` or `retryableCodes` after changing `mode` to `always`; resolution ignores those inactive normal-mode fields and captures a pure always policy. This service stores the effective policy but does not execute retries.
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber. The returned disposer also carries `replace(providers)`: the candidate route set is validated in full before anything moves, so a conflict with another adapter leaves the current routes registered and serving, and the swap itself is one synchronous section with no observable gap. `replace([])` is legal — a registration holding zero routes — unlike an empty initial registration.
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
|
||||
一个适配器注册表加单一流式调用接口,可通过 waterfall(瀑布式事件)拦截。
|
||||
|
||||
### 重试策略
|
||||
|
||||
每个提供方适配器都会提供解析后的路由策略。省略提供方配置时使用有界 normal mode,在首次请求后最多重试五次。分层配置把 `mode` 改为 `always` 后可能残留 `maxRetries` 或 `retryableCodes`;解析过程会忽略这些不再生效的 normal-mode 字段,并捕获纯 always 策略。本服务存储有效策略,但不执行重试。
|
||||
|
||||
### 公开 API
|
||||
|
||||
- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` 为给定提供方路由注册一个适配器实例。注册要么全部成功,要么全部不生效,并且会随调用 fiber 一起 dispose(资源释放)。返回的句柄还提供 `replace(providers)`:候选路由集合会在注册状态发生任何变化前完成整体验证,因此与另一适配器发生冲突时,当前路由仍保持注册并继续提供服务。替换会在一次同步操作中完成,不会出现可观察的空档。`replace([])` 合法,表示保留注册但不持有任何路由;初始注册则不得为空。
|
||||
|
||||
@@ -11,7 +11,7 @@ import z from '@deepseek-ai/schemastery'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import { EMPTY_RESPONSE_CODE } from './error.ts'
|
||||
|
||||
const DEFAULT_MAX_RETRIES = 2
|
||||
const DEFAULT_MAX_RETRIES = 5
|
||||
const DEFAULT_INITIAL_DELAY_MS = 500
|
||||
const DEFAULT_MAX_DELAY_MS = 10_000
|
||||
const DEFAULT_JITTER_RATIO = 0.1
|
||||
@@ -37,7 +37,7 @@ export interface BackoffConfig {
|
||||
export interface NormalRetryPolicyConfig {
|
||||
/** Retry only configured transient failure codes. */
|
||||
mode: 'normal'
|
||||
/** Maximum eligible retries after the first request (default 2). */
|
||||
/** Maximum eligible retries after the first request (default 5). */
|
||||
maxRetries?: number
|
||||
/** Stable failure codes eligible for this policy. */
|
||||
retryableCodes?: string[]
|
||||
@@ -105,7 +105,11 @@ export const RetryPolicySchema: z<RetryPolicyConfig> = z.union([
|
||||
const NORMAL_POLICY_KEYS: ReadonlySet<string> = new Set([
|
||||
'mode', 'maxRetries', 'retryableCodes', 'backoff',
|
||||
])
|
||||
const ALWAYS_POLICY_KEYS: ReadonlySet<string> = new Set(['mode', 'backoff'])
|
||||
// Layered configuration can retain normal-only fields after switching modes;
|
||||
// always mode ignores those inactive values while still rejecting unknown keys.
|
||||
const ALWAYS_POLICY_KEYS: ReadonlySet<string> = new Set([
|
||||
'mode', 'maxRetries', 'retryableCodes', 'backoff',
|
||||
])
|
||||
const BACKOFF_KEYS: ReadonlySet<string> = new Set(['initialDelayMs', 'maxDelayMs', 'jitterRatio'])
|
||||
|
||||
function validateKeys(value: object, allowed: ReadonlySet<string>, path: string): void {
|
||||
|
||||
@@ -12,7 +12,7 @@ describe('provider retry policy', () => {
|
||||
|
||||
expect(policy).toEqual({
|
||||
mode: 'normal',
|
||||
maxRetries: 2,
|
||||
maxRetries: 5,
|
||||
retryableCodes: ['EMPTY_RESPONSE', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT'],
|
||||
initialDelayMs: 500,
|
||||
maxDelayMs: 10_000,
|
||||
@@ -59,6 +59,21 @@ describe('provider retry policy', () => {
|
||||
expect(RetryPolicySchema).toBeDefined()
|
||||
})
|
||||
|
||||
it('ignores normal-only fields retained after switching to always mode', () => {
|
||||
const layered = {
|
||||
mode: 'always',
|
||||
maxRetries: 5,
|
||||
retryableCodes: ['SERVER'],
|
||||
} as unknown as RetryPolicyConfig
|
||||
|
||||
expect(resolveRetryPolicy(layered, 'provider.retryPolicy')).toEqual({
|
||||
mode: 'always',
|
||||
initialDelayMs: 500,
|
||||
maxDelayMs: 10_000,
|
||||
jitterRatio: 0.1,
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ mode: 'normal', maxRetries: -1 }, /maxRetries/],
|
||||
[{ mode: 'normal', maxRetries: 1.5 }, /maxRetries/],
|
||||
@@ -74,7 +89,6 @@ describe('provider retry policy', () => {
|
||||
[{ mode: 'normal', retryableCodes: [''] }, /non-empty strings/],
|
||||
[{ mode: 'normal', retryableCodes: [429] }, /non-empty strings/],
|
||||
[{ mode: 'normal', maxRetires: 1 }, /unknown key "maxRetires"/],
|
||||
[{ mode: 'always', maxRetries: 1 }, /unknown key "maxRetries"/],
|
||||
[{ mode: 'always', backoff: { initialDelay: 1 } }, /unknown key "initialDelay"/],
|
||||
[{ mode: 'sometimes' }, /mode must be "normal" or "always"/],
|
||||
] as const)('rejects invalid policy %#', (config, message) => {
|
||||
|
||||
@@ -217,7 +217,7 @@ describe('LlmRuntime', () => {
|
||||
expect(ctx.llm.providerRetryPolicy('configured')).toBe(configured)
|
||||
expect(ctx.llm.providerRetryPolicy('defaulted')).toMatchObject({
|
||||
mode: 'normal',
|
||||
maxRetries: 2,
|
||||
maxRetries: 5,
|
||||
})
|
||||
expect(() => ctx.llm.providerRetryPolicy('missing')).toThrow(
|
||||
expect.objectContaining({ code: 'NO_ADAPTER' }),
|
||||
|
||||
@@ -644,7 +644,7 @@ describe('installLlmReplay (through the real LlmRuntime)', () => {
|
||||
})
|
||||
expect(ctx.llm.providerRetryPolicy('empty')).toMatchObject({
|
||||
mode: 'normal',
|
||||
maxRetries: 2,
|
||||
maxRetries: 5,
|
||||
initialDelayMs: 500,
|
||||
maxDelayMs: 10_000,
|
||||
jitterRatio: 0.1,
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"apps/web/tests/declared-reasoning.e2e.ts",
|
||||
"apps/web/tests/support.ts",
|
||||
"apps/web/tests/scaffold-hermetic.e2e.ts",
|
||||
"apps/web/tests/startup-rpc-budget.e2e.ts",
|
||||
"apps/web/tests/minimal-preset.snapshot.ts",
|
||||
"apps/web/tests/message-feedback-protocol.snapshot.ts",
|
||||
"apps/web/tests/live-interactions.e2e.ts",
|
||||
|
||||
Reference in new issue
Block a user