From 4f5c73003263418c76dc88e2bb8ea6f164f0950c Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:38:10 -0700 Subject: [PATCH 01/13] feat(web): combine plugin settings into tabs --- .../2026-08-11-plugin-settings-tabs.i18n.yaml | 6 ++ .../2026-08-11-plugin-settings-tabs.md | 37 +++++++ .../2026-08-11-plugin-settings-tabs.zh.md | 37 +++++++ ...6-08-10-web-plugin-configuration.i18n.yaml | 4 +- .../2026-08-10-web-plugin-configuration.md | 4 +- .../2026-08-10-web-plugin-configuration.zh.md | 4 +- apps/web/tests/plugin-config.e2e.ts | 11 +- apps/web/tests/settings-chrome.e2e.ts | 2 + .../created.expected.md | 3 - .../damaged.expected.md | 3 - .../section.expected.md | 3 - .../models-settings/configured.expected.md | 3 - .../models-settings/declared-edit.expected.md | 3 - .../models-settings/declared.expected.md | 3 - .../models-settings/empty.expected.md | 3 - .../models.expected.md | 3 - .../dismissed.expected.md | 3 - .../plugin-config/section.expected.md | 37 +++---- .../settings-chrome/dialog.expected.md | 3 - packages/client/README.i18n.yaml | 4 +- packages/client/README.md | 4 +- packages/client/README.zh.md | 4 +- .../client/ui-plugin-config/README.i18n.yaml | 4 +- packages/client/ui-plugin-config/README.md | 6 +- packages/client/ui-plugin-config/README.zh.md | 6 +- packages/client/ui-plugin-config/package.json | 2 +- .../src/client/ConfigurablePluginsTab.tsx | 25 +++++ .../src/client/PluginConfigSection.module.css | 51 ++++++++- .../src/client/PluginConfigSection.tsx | 100 ++++++++++++++---- .../ui-plugin-config/src/client/index.ts | 84 +++++++++++---- .../ui-plugin-config/src/client/locales.ts | 18 ++-- packages/client/ui-plugin-config/src/index.ts | 6 +- .../tests/apply.client.spec.ts | 23 +++- .../tests/section.client.spec.tsx | 66 +++++++++--- packages/client/ui-plugins/README.i18n.yaml | 4 +- packages/client/ui-plugins/README.md | 6 +- packages/client/ui-plugins/README.zh.md | 6 +- packages/client/ui-plugins/package.json | 2 +- .../client/PluginSettingsSection.module.css | 7 -- .../src/client/PluginSettingsSection.tsx | 46 ++++---- .../client/ui-plugins/src/client/index.ts | 12 +-- .../client/ui-plugins/src/client/locales.ts | 6 +- packages/client/ui-plugins/src/index.ts | 4 +- .../tests/browser-plugin.client.spec.tsx | 22 ++-- .../tests/components.client.spec.tsx | 14 ++- packages/client/ui-settings/README.i18n.yaml | 4 +- packages/client/ui-settings/README.md | 2 +- packages/client/ui-settings/README.zh.md | 2 +- .../ui-settings/src/client/contract/slots.ts | 15 +++ .../client/ui-settings/src/client/index.ts | 2 +- 50 files changed, 512 insertions(+), 217 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.md create mode 100644 .agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.zh.md create mode 100644 packages/client/ui-plugin-config/src/client/ConfigurablePluginsTab.tsx diff --git a/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.i18n.yaml new file mode 100644 index 0000000000..cdfdc3afde --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.md +2026-08-11-plugin-settings-tabs.md: 987e9f49e750a026c38a0d73c255cd335c53a4fe +2026-08-11-plugin-settings-tabs.zh.md: 887ca20bc09871d4bac5650a5d5eb71d5fcb81ee diff --git a/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.md b/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.md new file mode 100644 index 0000000000..987e9f49e7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.md @@ -0,0 +1,37 @@ +# Agent Note: Feature-owned tabs in Plugins settings + +Status: implemented + +English | [中文](2026-08-11-plugin-settings-tabs.zh.md) + +## Problem + +Plugin configuration and the read-only Loader inventory each registered a top-level `settings.section`. They described the same Plugins domain but occupied two navigation rows, split search and configuration into unrelated pages, and gave the Settings shell no principled way to present them together. Combining their components directly would instead make one feature plugin import and own the other feature's data lifecycle. + +## Decision + +`@deepseek-ai/dsh-client-ui-plugin-config` owns the single `settings.section` contribution with id `plugins`. It renders the shared title and compact tab chrome, declares the root-scoped list slot `settings.plugins.tab`, and projects that ledger's id, order, and locale-following label into its tabs. The slot's canonical type lives in `ui-settings`, so a tab contributor depends on the Settings domain contract rather than on another feature plugin. + +The section owner contributes a `configurable` tab that declares the existing nested `settings.plugin.item` list. Configuration cards keep their namespace bindings, draft state, validation, and writes unchanged. `@deepseek-ai/dsh-client-ui-plugins` contributes an `all` tab to `settings.plugins.tab`; its Host Loader observer, generated Remote namespace, DTO, search semantics, and read-only disclosure cards remain unchanged. + +The first ordered tab is selected by default. A tab mounts only when first selected and then remains mounted but hidden while the Plugins section stays mounted. This delays the inventory RPC until the user opens **Plugin list** and preserves drafts, search text, disclosure state, and the fetched snapshot while switching tabs. Closing Settings unmounts the section, so reopening it obtains a fresh inventory snapshot when that tab is selected again. + +Both registrations use `ctx.slots.inject()`. If the section declarer unloads, the tab declaration and every contribution collapse with it; redeclaration lets each feature re-register without a static import or activation-order dependency. + +## Alternatives considered + +**Keep two Settings navigation rows and only rename them.** Rejected because the duplication is structural, not copy-related: both pages still represent the same Plugins domain and compete for navigation space. + +**Import the inventory component into `ui-plugin-config`.** Rejected because the configuration plugin would then own another plugin's Remote dependency and lifecycle. It would also turn an optional browser contribution into a package-level dependency. + +**Hard-code the two tab labels and components in the section owner.** Rejected because a third feature would require editing the owner, and HMR teardown could leave chrome for a contribution that no longer exists. The slot ledger already provides identity, ordering, localization, and cascade semantics. + +**Move Plugins aggregation into `ui-settings-general`.** Rejected because the Settings shell owns generic navigation and modal chrome, not feature content. Adding Plugins-specific tabs there would make every future Plugins view a shell change. + +## Consequences + +Settings has one Plugins navigation row, ordered before Agent Presets, with **Plugin configuration** and **Plugin list** tabs. Agent Presets remains an independent section because it edits per-session agent compositions rather than the live Host Loader tree. + +Feature ownership remains explicit: `ui-plugin-config` owns the Plugins page and editable cards, `ui-plugins` owns the read-only inventory view, and the Host/RPC path does not change. A new Plugins view can join by registering one `settings.plugins.tab` contribution. + +The aggregation depends on the section owner being composed: without `ui-plugin-config`, `ui-plugins` waits for a tab declaration and renders nothing. That is an intentional composition dependency carried by the slot registry rather than a static package import. diff --git a/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.zh.md b/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.zh.md new file mode 100644 index 0000000000..887ca20bc0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.zh.md @@ -0,0 +1,37 @@ +# Agent Note: “插件”设置中的功能自有标签页 + +Status: implemented + +[English](2026-08-11-plugin-settings-tabs.md) | 中文 + +## 问题 + +插件配置与只读 Loader 清单各自注册了一个顶层 `settings.section`。两者描述同一个“插件”领域,却占据两行导航,把搜索与配置拆成互不相关的页面,也没有给 Settings 外壳一个有原则的聚合方式。若直接合并两者的组件,则会让一个功能插件 import 并拥有另一个功能的数据生命周期。 + +## 决策 + +`@deepseek-ai/dsh-client-ui-plugin-config` 拥有唯一一个 id 为 `plugins` 的 `settings.section` 贡献。它渲染共享标题和紧凑标签栏,声明根级列表 slot `settings.plugins.tab`,并把该记录中的 id、order 与跟随语言的 label 投影成标签页。该 slot 的规范类型位于 `ui-settings`,因此标签页贡献方依赖设置领域约定,而不是依赖另一个功能插件。 + +分区拥有方贡献 `configurable` 标签页,由它声明既有的嵌套 `settings.plugin.item` 列表。配置卡片原有的命名空间绑定、草稿状态、校验与写入均保持不变。`@deepseek-ai/dsh-client-ui-plugins` 向 `settings.plugins.tab` 贡献 `all` 标签页;它的 Host Loader 观察器、生成的 Remote 命名空间、DTO、搜索语义与只读折叠卡片均保持不变。 + +默认选择顺序中的第一个标签页。某个标签页只有首次被选择时才挂载,之后在“插件”分区保持挂载期间只隐藏而不卸载。这样会把清单 RPC 延迟到用户打开**插件列表**时,并在切换标签页时保留草稿、搜索文本、折叠状态和已读取的快照。关闭 Settings 会卸载该分区,因此再次打开后,重新选择该标签页时会取得新的清单快照。 + +两项注册都使用 `ctx.slots.inject()`。分区声明方卸载时,标签 slot 及其全部贡献随之折叠;重新声明后,每项功能都能重新注册,无需静态 import,也不依赖激活顺序。 + +## 备选方案 + +**保留两行 Settings 导航,只改名称。** 否决,因为重复是结构问题,而非文案问题:两个页面仍然代表同一个“插件”领域,并继续争夺导航空间。 + +**把清单组件 import 进 `ui-plugin-config`。** 否决,因为配置插件会因此拥有另一个插件的 Remote 依赖与生命周期,也会把可选的浏览器贡献变成包级依赖。 + +**在分区拥有方硬编码两个标签页的名称和组件。** 否决,因为第三项功能需要修改拥有方,HMR teardown 也可能留下已不存在贡献的界面框架。slot 记录已经提供标识、顺序、本地化与级联语义。 + +**把“插件”聚合移入 `ui-settings-general`。** 否决,因为 Settings 外壳拥有通用导航与模态界面框架,而不拥有功能内容。把“插件”专属标签页放在那里,会让今后每一种“插件”视图都需要修改外壳。 + +## 影响 + +Settings 只有一行“插件”导航,排在“Agent 预设”之前,包含**插件配置**与**插件列表**两个标签页。“Agent 预设”仍是独立分区,因为它编辑每个会话的 agent 组装,而非实时 Host Loader 树。 + +功能所有权保持明确:`ui-plugin-config` 拥有“插件”页面与可编辑卡片,`ui-plugins` 拥有只读清单视图,Host/RPC 路径不变。新的“插件”视图只需注册一个 `settings.plugins.tab` 贡献即可加入。 + +该聚合依赖分区拥有方被组装:没有 `ui-plugin-config` 时,`ui-plugins` 会等待标签 slot 的声明且不渲染任何内容。这是通过 slot 注册表承载的有意组合依赖,而不是静态包 import。 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml index a27cb812e9..bea499a69a 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md -2026-08-10-web-plugin-configuration.md: 7375f496c7af1a695243444fe56aca7262d3dedd -2026-08-10-web-plugin-configuration.zh.md: 59d65db39bcc2306983f2a26dcf252164d7a6f37 +2026-08-10-web-plugin-configuration.md: 29c69695141d4fd00f5b323232522c5298ce4e9b +2026-08-10-web-plugin-configuration.zh.md: d7df9c332fc3cbca20ec75cb543600601bbac141 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md index 7375f496c7..29c6969514 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md @@ -12,7 +12,7 @@ The seam that made the Models page possible was already general: any plugin may ## Decision -Three host-plane plugins register their own settings namespace, and one browser-side section renders whatever the deployment exposes. +Three host-plane plugins register their own settings namespace, and one browser-side Plugins section aggregates feature-owned tabs. Its configurable tab renders whatever editable settings the deployment exposes. **Layering, unchanged.** A section resolves as schema defaults → the plugin's composition entry → the user layer. Each plugin passes its `cordis.yml` entry as the `base` and reads its config through a source thunk, so a stored change reaches the next use and a detaching settings provider leaves the composition entry running. Constraints the schema cannot express — positive and finite, the timer bound on `graceMs`, the parallel cap being a positive integer — become the section validator, so a bad value is refused at the write instead of at the next command. @@ -24,7 +24,7 @@ Three host-plane plugins register their own settings namespace, and one browser- **Exposure stays a Host allowlist.** The three namespaces join `WEB_SETTINGS_NAMESPACES`; registration alone still never crosses the transport, and a namespace absent from that list answers `settings-not-exposed` exactly as an unregistered one does. -**The section knows no namespace.** `dsh-client-ui-plugin-config` declares a `settings.plugin.item` slot and renders the cards registered into it, so a plugin that ships a browser half owns its card and its controls. Each card binds its namespace through the client settings scope, which gained the two things a form needs: the raw `user` layer, whose key PRESENCE is what marks a field overridden, and `unset`, which clears one field back to the composition layer. A card renders nothing while its namespace is unavailable, so a deployment that does not compose the owning plugin shows no trace of it. +**The configurable tab knows no namespace.** `dsh-client-ui-plugin-config` owns the Plugins section, contributes its `configurable` page through `settings.plugins.tab`, and declares a nested `settings.plugin.item` slot there. It renders the cards registered into that nested slot, so a plugin that ships a browser half owns its card and its controls. Each card binds its namespace through the client settings scope, which gained the two things a form needs: the raw `user` layer, whose key PRESENCE is what marks a field overridden, and `unset`, which clears one field back to the composition layer. A card renders nothing while its namespace is unavailable, so a deployment that does not compose the owning plugin shows no trace of it. **A card stages its edits and writes them on save.** Controls hold no draft of their own: the card's form owns the staged text, every control renders it, and only **Save** turns it into document mutations. A settings write is durable and revision-fenced, so a control that committed as it settled spent a revision on a value the user had not decided to store and could not preview; the reset stages the composed default the same way. Because the Host's validators own the constraints no schema can express, the form reads the section back after writing and reports a save that did not land instead of predicting the outcome, keeping those drafts for the user to correct. The credential control is staged with the rest even though it writes through the credentials domain, so one save covers everything the card shows. diff --git a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md index 59d65db39b..d7df9c332f 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -三个宿主平面插件各自注册 settings 命名空间,一个浏览器侧分区渲染该部署所暴露的一切。 +三个宿主平面插件各自注册 settings 命名空间,一个浏览器侧“插件”分区聚合由各功能持有的标签页。它的“可配置”标签页渲染该部署所暴露的一切可编辑设置。 **分层不变。** 一个分节按 schema 默认值 → 插件的组装条目 → 用户层解析。每个插件把自己的 `cordis.yml` 条目作为 `base` 传入,并通过 source thunk 读取配置,因此存储的变更会作用于下一次使用,而脱离的 settings 提供方会让组装条目继续运行。schema 无法表达的约束——正有限、`graceMs` 的定时器上界、并行上限必须是正整数——成为分节的校验器,因此错误的值在写入时被拒绝,而不是到下一条命令时才失败。 @@ -24,7 +24,7 @@ Status: implemented **暴露仍是 Host 的白名单。** 这三个命名空间加入 `WEB_SETTINGS_NAMESPACES`;仅有注册依然不会跨越传输边界,而不在该名单中的命名空间会与未注册的命名空间得到完全相同的 `settings-not-exposed`。 -**该分区不认识任何命名空间。** `dsh-client-ui-plugin-config` 声明 `settings.plugin.item` slot 并渲染注册进来的卡片,因此带浏览器半侧的插件拥有自己的卡片与控件。每张卡片通过客户端 settings scope 绑定其命名空间,而该 scope 补上了表单所需的两样东西:原始 `user` 层——键的**存在**才标记字段被覆盖——以及把单个字段清回组装层的 `unset`。命名空间不可用时卡片什么都不渲染,因此未组装该插件的部署不会显示它的任何痕迹。 +**“可配置”标签页不认识任何命名空间。** `dsh-client-ui-plugin-config` 拥有“插件”分区,通过 `settings.plugins.tab` 贡献自己的 `configurable` 页面,并在其中声明嵌套的 `settings.plugin.item` slot。它渲染注册进这个嵌套 slot 的卡片,因此带浏览器半侧的插件拥有自己的卡片与控件。每张卡片通过客户端 settings scope 绑定其命名空间,而该 scope 补上了表单所需的两样东西:原始 `user` 层——键的**存在**才标记字段被覆盖——以及把单个字段清回组装层的 `unset`。命名空间不可用时卡片什么都不渲染,因此未组装该插件的部署不会显示它的任何痕迹。 **卡片暂存修改,保存时才写入。** 控件不持有自己的草稿:暂存文本归卡片的表单所有,所有控件渲染的都是它,只有**保存**才把它变成文档变更。settings 写入是持久且带 revision 栅栏的,因此「失焦即提交」的控件会为用户尚未决定存储、也无从预览的值花掉一个 revision;重置同样只是暂存组装默认值。schema 表达不了的约束归 Host 的校验器所有,所以表单在写入后回读分节、报告没有落盘的保存,而不是自行预测结果,并保留这些草稿供用户修改。密钥控件虽然经由 credentials 领域写入,也和其余字段一起暂存,因此一次保存覆盖卡片上的全部内容。 diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index 15956877f4..3c97f4b414 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -1,5 +1,5 @@ -// Web e2e scenario: the Plugins settings section — the cards a deployment's -// exposed host-plane namespaces produce, one field edited through the real +// Web e2e scenario: the configurable tab in Plugins settings — the cards a +// deployment's exposed host-plane namespaces produce, one field edited through the real // wire down to `$DSH_HOME/settings.yaml`, and the override badge and reset // that layering produces. Zero model calls: everything is client state plus // the settings document on a blank frame, so there is no fixture and a stray @@ -56,9 +56,12 @@ describe('web e2e: plugin configuration section', () => { await page.getByRole('button', { name: '设置', exact: true }).click() const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.waitFor({ timeout: 10_000 }) - await dialog.getByRole('button', { name: '插件配置', exact: true }).click() + await dialog.getByRole('button', { name: '插件', exact: true }).click() await expect - .poll(() => dialog.getByRole('button', { name: '插件配置', exact: true }).getAttribute('aria-current'), { timeout: 5_000 }) + .poll(() => dialog.getByRole('button', { name: '插件', exact: true }).getAttribute('aria-current'), { timeout: 5_000 }) + .toBe('true') + await expect + .poll(() => dialog.getByRole('tab', { name: '插件配置', exact: true }).getAttribute('aria-selected'), { timeout: 5_000 }) .toBe('true') return dialog } diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 5d057a7bed..d84f314bf5 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -99,6 +99,7 @@ describe('web e2e: settings modal and General preferences', () => { // an unrelated plugin does not rewrite this surface's golden. await dialog.getByRole('button', { name: '插件', exact: true }).click() await dialog.getByRole('heading', { name: '插件', exact: true }).waitFor({ timeout: 10_000 }) + await dialog.getByRole('tab', { name: '插件列表', exact: true }).click() const pluginRow = dialog.locator(PLUGIN_ROW_SELECTOR) await pluginRow.waitFor({ timeout: 10_000 }) const expectedPluginCount = [...scaffold.ctx.loader.entries()] @@ -109,6 +110,7 @@ describe('web e2e: settings modal and General preferences', () => { expect(await dialog.locator('[data-plugin-count]').getAttribute('data-plugin-count')) .toBe(String(expectedPluginCount)) expect(await dialog.getByRole('button', { name: '插件', exact: true }).getAttribute('aria-current')).toBe('true') + expect(await dialog.getByRole('tab', { name: '插件列表', exact: true }).getAttribute('aria-selected')).toBe('true') expect(await dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current')).toBeNull() const pluginsSnapshot = await captureStableAria( page, diff --git a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md index 40b9497831..c29b1cf478 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md @@ -13,9 +13,6 @@ - button "Agent 预设": - img - text: Agent 预设 - - button "插件配置": - - img - - text: 插件配置 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md index c3e9035098..15d4cc8f94 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md @@ -13,9 +13,6 @@ - button "Agent 预设": - img - text: Agent 预设 - - button "插件配置": - - img - - text: 插件配置 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md index e411e8ea28..312958187b 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md @@ -13,9 +13,6 @@ - button "Agent 预设": - img - text: Agent 预设 - - button "插件配置": - - img - - text: 插件配置 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/models-settings/configured.expected.md b/apps/web/tests/snapshots/models-settings/configured.expected.md index 66cc7c88b6..3c3be0922c 100644 --- a/apps/web/tests/snapshots/models-settings/configured.expected.md +++ b/apps/web/tests/snapshots/models-settings/configured.expected.md @@ -13,9 +13,6 @@ - button "Agent 预设": - img - text: Agent 预设 - - button "插件配置": - - img - - text: 插件配置 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/models-settings/declared-edit.expected.md b/apps/web/tests/snapshots/models-settings/declared-edit.expected.md index 86f8b77fe8..1d538bcfe4 100644 --- a/apps/web/tests/snapshots/models-settings/declared-edit.expected.md +++ b/apps/web/tests/snapshots/models-settings/declared-edit.expected.md @@ -13,9 +13,6 @@ - button "Agent 预设": - img - text: Agent 预设 - - button "插件配置": - - img - - text: 插件配置 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/models-settings/declared.expected.md b/apps/web/tests/snapshots/models-settings/declared.expected.md index b9e5dca61f..df48328fd3 100644 --- a/apps/web/tests/snapshots/models-settings/declared.expected.md +++ b/apps/web/tests/snapshots/models-settings/declared.expected.md @@ -13,9 +13,6 @@ - button "Agent 预设": - img - text: Agent 预设 - - button "插件配置": - - img - - text: 插件配置 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/models-settings/empty.expected.md b/apps/web/tests/snapshots/models-settings/empty.expected.md index 03e87a7a51..cea14113ac 100644 --- a/apps/web/tests/snapshots/models-settings/empty.expected.md +++ b/apps/web/tests/snapshots/models-settings/empty.expected.md @@ -13,9 +13,6 @@ - button "Agent 预设": - img - text: Agent 预设 - - button "插件配置": - - img - - text: 插件配置 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md index 562b54d837..5ebf0b1456 100644 --- a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md @@ -13,9 +13,6 @@ - button "Agent 预设": - img - text: Agent 预设 - - button "插件配置": - - img - - text: 插件配置 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md b/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md index b3e1141abc..84c7358b54 100644 --- a/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md +++ b/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md @@ -13,9 +13,6 @@ - button "Agent 预设": - img - text: Agent 预设 - - button "插件配置": - - img - - text: 插件配置 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/plugin-config/section.expected.md b/apps/web/tests/snapshots/plugin-config/section.expected.md index 18cdef13d5..54ac42b4a7 100644 --- a/apps/web/tests/snapshots/plugin-config/section.expected.md +++ b/apps/web/tests/snapshots/plugin-config/section.expected.md @@ -13,25 +13,26 @@ - button "Agent 预设": - img - text: Agent 预设 - - button "插件配置": - - img - - text: 插件配置 - button "打开配置文件" - button "关闭": - img - text: 关闭 - - heading "插件配置" [level=2] - - paragraph: 配置本部署已安装的插件。 - - list: - - listitem: - - 'button "展开设置: 终端"': - - text: 终端 限制 agent 运行的每一条命令。 - - img - - listitem: - - 'button "展开设置: Agent 循环"': - - text: Agent 循环 Agent 如何派发工具调用。 - - img - - listitem: - - 'button "展开设置: 网页搜索"': - - text: 网页搜索 DeepSeek 搜索提供方。 - - img + - heading "插件" [level=2] + - paragraph: 配置和查看本部署已安装的插件。 + - tablist "插件视图": + - tab "插件配置" [selected] + - tab "插件列表" + - tabpanel "插件配置": + - list: + - listitem: + - 'button "展开设置: 终端"': + - text: 终端 限制 agent 运行的每一条命令。 + - img + - listitem: + - 'button "展开设置: Agent 循环"': + - text: Agent 循环 Agent 如何派发工具调用。 + - img + - listitem: + - 'button "展开设置: 网页搜索"': + - text: 网页搜索 DeepSeek 搜索提供方。 + - img diff --git a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md index 2a7c767bf8..89cff5df3f 100644 --- a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md +++ b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md @@ -13,9 +13,6 @@ - button "Agent 预设": - img - text: Agent 预设 - - button "插件配置": - - img - - text: 插件配置 - button "打开配置文件" - button "关闭": - img diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index cd9f668fad..be4a65a085 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/README.md -README.md: 236531281c17ef982982e97caad99491584bd0b5 -README.zh.md: e619ffaa6341f509537342bde90344141d4c8f64 +README.md: b9452d1f763be5be6953cb7973da8a2c909ed979 +README.zh.md: 1225101cff05a3f5655c4660d3d6d86be47d9a30 diff --git a/packages/client/README.md b/packages/client/README.md index 236531281c..b9452d1f76 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -35,13 +35,13 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha | [`ui-model/`](ui-model/README.md) | Provides model selection in conversation surfaces. | | [`ui-permission/`](ui-permission/README.md) | Configures default permissions and switches the current session's access. | | [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control. | -| [`ui-plugin-config/`](ui-plugin-config/README.md) | The Plugins settings section: host-plane plugin configuration as expandable cards. | +| [`ui-plugin-config/`](ui-plugin-config/README.md) | Owns the Plugins settings section, its tab extension point, and configurable host-plane plugin cards. | | [`ui-question/`](ui-question/README.md) | Presents interactive questions requested by the agent. | | [`ui-agent-preset/`](ui-agent-preset/README.md) | Selects a session's agent preset and authors preset compositions. | | [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas. | | [`ui-settings-general/`](ui-settings-general/README.md) | Provides the general settings section. | | [`ui-models/`](ui-models/README.md) | Provides model-provider configuration and DeepSeek onboarding. | -| [`ui-plugins/`](ui-plugins/README.md) | Shows the current Host Loader entries in a read-only Settings section. | +| [`ui-plugins/`](ui-plugins/README.md) | Contributes the read-only Host Loader inventory tab to Plugins settings. | Each child reference owns its contract and detailed behavior. The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) and [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) own the cross-package composition and loading decisions. diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index e619ffaa63..1225101cff 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -35,13 +35,13 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U | [`ui-model/`](ui-model/README.md) | 在会话界面中提供模型选择。 | | [`ui-permission/`](ui-permission/README.md) | 配置默认权限并切换当前会话的访问模式。 | | [`ui-plan/`](ui-plan/README.md) | 展示生效中的 plan mode 状态及其退出控件。 | -| [`ui-plugin-config/`](ui-plugin-config/README.md) | 插件设置分区:把宿主平面的插件配置呈现为可展开卡片。 | +| [`ui-plugin-config/`](ui-plugin-config/README.md) | 拥有“插件”设置分区、它的标签页扩展点,以及可配置的宿主平面插件卡片。 | | [`ui-question/`](ui-question/README.md) | 展示 agent 请求的交互式问题。 | | [`ui-agent-preset/`](ui-agent-preset/README.md) | 选择会话的 agent 预设,并创作预设组装。 | | [`ui-settings/`](ui-settings/README.md) | 承载设置界面及其扩展区域。 | | [`ui-settings-general/`](ui-settings-general/README.md) | 提供常规设置分区。 | | [`ui-models/`](ui-models/README.md) | 提供模型提供方配置与 DeepSeek 配置引导。 | -| [`ui-plugins/`](ui-plugins/README.md) | 在只读设置分区中展示当前 Host Loader 条目。 | +| [`ui-plugins/`](ui-plugins/README.md) | 向“插件”设置贡献只读的 Host Loader 清单标签页。 | 每个子文档负责自身的约定和详细行为。[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)与 [Web 客户端架构 Agent Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)负责跨包组合与加载决策。 diff --git a/packages/client/ui-plugin-config/README.i18n.yaml b/packages/client/ui-plugin-config/README.i18n.yaml index d112523b42..9b3486623f 100644 --- a/packages/client/ui-plugin-config/README.i18n.yaml +++ b/packages/client/ui-plugin-config/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-plugin-config/README.md -README.md: 7e530d70f6573d619378e43b0245345b45d6db18 -README.zh.md: fd4f980fcf71c00c2357017fb40c76a9ca7a72cc +README.md: 1bf8dd60b966ee0c0e28ae931556182c5c2b2e87 +README.zh.md: bd87e06202b2b083a6e9e089eb91681deea9d678 diff --git a/packages/client/ui-plugin-config/README.md b/packages/client/ui-plugin-config/README.md index 7e530d70f6..1bf8dd60b9 100644 --- a/packages/client/ui-plugin-config/README.md +++ b/packages/client/ui-plugin-config/README.md @@ -2,17 +2,17 @@ English | [中文](README.zh.md) -The **Plugins** settings section: one expandable card per Host plugin whose configuration a user owns. A card shows the plugin's name and what it governs; expanding it in place reveals hand-written controls bound to that plugin's settings namespace, each field marking whether the user overrode it and offering a reset back to the value the deployment composed. +The **Plugins** settings section and its **Plugin configuration** tab. The section owns the heading and compact tab chrome; feature plugins contribute pages through `settings.plugins.tab`. This package's own tab shows one expandable card per Host plugin whose configuration a user owns. A card shows the plugin's name and what it governs; expanding it in place reveals hand-written controls bound to that plugin's settings namespace, each field marking whether the user overrode it and offering a reset back to the value the deployment composed. ## What appears here -A card renders only when its namespace is both registered by a live Host plugin and served to the browser. A deployment that does not compose the owning plugin — or serves the namespace to no client — renders nothing for it rather than an empty or disabled card, so the section reflects what this deployment actually runs. +A card renders only when its namespace is both registered by a live Host plugin and served to the browser. A deployment that does not compose the owning plugin — or serves the namespace to no client — renders nothing for it rather than an empty or disabled card, so the configurable tab reflects what this deployment actually runs. The first batch covers the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), and the DeepSeek search provider (`web-search-deepseek`). ## Extension point -The section declares `settings.plugin.item`, a root list slot. A plugin that ships a browser half registers its own card into that slot and owns its controls; this package neither enumerates namespaces nor renders a form it was not given. Ordering follows the slot's `order`. +The section declares `settings.plugins.tab`, a root list slot whose labels become ordered tabs. It keeps a tab mounted after its first selection, so local drafts and read-only snapshots survive tab switches. The package registers its own `configurable` contribution, which declares the nested `settings.plugin.item` list slot. A plugin that ships a browser half registers its own card into that nested slot and owns its controls; this package neither enumerates namespaces nor renders a form it was not given. Both levels follow the contribution's `order`. ## Writes diff --git a/packages/client/ui-plugin-config/README.zh.md b/packages/client/ui-plugin-config/README.zh.md index fd4f980fcf..bd87e06202 100644 --- a/packages/client/ui-plugin-config/README.zh.md +++ b/packages/client/ui-plugin-config/README.zh.md @@ -2,17 +2,17 @@ [English](README.md) | 中文 -**插件**设置分区:每个配置由用户拥有的 Host 插件占一张可展开卡片。卡片展示插件名称及其管辖范围;就地展开后是绑定到该插件 settings 命名空间的手写控件,每个字段标注用户是否覆盖过它,并提供重置回部署组装值的入口。 +**插件**设置分区及其**插件配置**标签页。该分区拥有标题与紧凑的标签栏;功能插件通过 `settings.plugins.tab` 贡献页面。本包自己的标签页为每个配置由用户拥有的 Host 插件展示一张可展开卡片。卡片展示插件名称及其管辖范围;就地展开后是绑定到该插件 settings 命名空间的手写控件,每个字段标注用户是否覆盖过它,并提供重置回部署组装值的入口。 ## 这里会出现什么 -只有当某个命名空间既被存活的 Host 插件注册、又被服务给浏览器时,它的卡片才会渲染。未组装该插件的部署——或未向任何客户端服务该命名空间的部署——不会渲染空卡片或禁用卡片,而是什么都不渲染,因此这一分区反映的是该部署实际运行的东西。 +只有当某个命名空间既被存活的 Host 插件注册、又被服务给浏览器时,它的卡片才会渲染。未组装该插件的部署——或未向任何客户端服务该命名空间的部署——不会渲染空卡片或禁用卡片,而是什么都不渲染,因此“插件配置”标签页反映的是该部署实际运行的东西。 第一批覆盖 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。 ## 扩展点 -本分区声明了根级列表 slot `settings.plugin.item`。带浏览器半侧的插件把自己的卡片注册进该 slot 并拥有其控件;本包既不枚举命名空间,也不渲染未被交给它的表单。排序遵循 slot 的 `order`。 +本分区声明根级列表 slot `settings.plugins.tab`,其标签会成为有序标签页。某个标签页首次被选择后会保持挂载,因此本地草稿与只读快照在切换标签页时不会丢失。本包注册自己的 `configurable` 贡献,由它声明嵌套的 `settings.plugin.item` 列表 slot。带浏览器半侧的插件把自己的卡片注册进这个嵌套 slot 并拥有其控件;本包既不枚举命名空间,也不渲染未被交给它的表单。两层排序都遵循贡献的 `order`。 ## 写入 diff --git a/packages/client/ui-plugin-config/package.json b/packages/client/ui-plugin-config/package.json index dd4d808789..9637702da7 100644 --- a/packages/client/ui-plugin-config/package.json +++ b/packages/client/ui-plugin-config/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-plugin-config", - "description": "Plugin configuration section: host-plane plugin settings as expandable cards", + "description": "Plugins settings section with feature-owned tabs and configurable host-plane plugin cards", "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" diff --git a/packages/client/ui-plugin-config/src/client/ConfigurablePluginsTab.tsx b/packages/client/ui-plugin-config/src/client/ConfigurablePluginsTab.tsx new file mode 100644 index 0000000000..1531e2ad0a --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/ConfigurablePluginsTab.tsx @@ -0,0 +1,25 @@ +/** Configurable Host plugins contributed to the shared Plugins section. */ + +import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from './slot-contract.ts' +import css from './PluginConfigSection.module.css' + +/** Registration-side business face for the configurable tab. */ +export interface ConfigurablePluginsTabInjected { + /** How many cards the slot ledger held when the tab registration mounted. */ + cardCount: number +} + +/** Props the renderer binds for the configurable tab. */ +export type ConfigurablePluginsTabProps = + PropsRuntime<'settings.plugins.tab'> + & PropsLocale<'settings.pluginConfig'> + & PropsRenderSlots<'settings.plugin.item'> + & InjectFace + +/** Render cards registered by plugins that expose editable settings. */ +export function ConfigurablePluginsTab({ t, renderSlot, cardCount }: ConfigurablePluginsTabProps) { + return cardCount === 0 + ?

{t('empty')}

+ : +} diff --git a/packages/client/ui-plugin-config/src/client/PluginConfigSection.module.css b/packages/client/ui-plugin-config/src/client/PluginConfigSection.module.css index 45c9a78f00..f5a162eee8 100644 --- a/packages/client/ui-plugin-config/src/client/PluginConfigSection.module.css +++ b/packages/client/ui-plugin-config/src/client/PluginConfigSection.module.css @@ -1,10 +1,10 @@ -/* Plugin configuration section: heading, intro, and the card list. */ +/* Plugins section: compact tabs plus the configurable plugin card list. */ .section { display: flex; flex-direction: column; gap: 12px; - max-width: 720px; + max-width: 760px; color: var(--dsw-alias-label-primary); } @@ -20,6 +20,53 @@ color: var(--dsw-alias-label-tertiary); } +.tabs { + display: flex; + align-items: flex-end; + gap: 22px; + border-bottom: 1px solid var(--dsw-alias-border-l2); + margin-top: 2px; +} + +.tab { + position: relative; + border: 0; + padding: 7px 1px 9px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + font: inherit; + font-size: 13px; + line-height: 20px; + cursor: pointer; +} + +.tab:hover, +.tab[data-active='true'] { + color: var(--dsw-alias-label-primary); +} + +.tab[data-active='true']::after, +.tab:focus-visible::after { + position: absolute; + right: 0; + bottom: -1px; + left: 0; + height: 2px; + border-radius: 2px 2px 0 0; + background: var(--dsw-alias-label-primary); + content: ''; +} + +.tab:focus-visible { + outline: none; + color: var(--dsw-alias-label-primary); +} + +.panel { + min-width: 0; + padding-top: 2px; +} + .cards { list-style: none; margin: 0; diff --git a/packages/client/ui-plugin-config/src/client/PluginConfigSection.tsx b/packages/client/ui-plugin-config/src/client/PluginConfigSection.tsx index 68de45eff3..a75816b92e 100644 --- a/packages/client/ui-plugin-config/src/client/PluginConfigSection.tsx +++ b/packages/client/ui-plugin-config/src/client/PluginConfigSection.tsx @@ -1,49 +1,105 @@ -/** - * Plugin configuration section: the shell around the per-plugin cards. It - * enumerates nothing itself — cards arrive through the `settings.plugin.item` - * slot it declares, so a plugin that ships a browser half owns its own card - * and this section never learns what a namespace means. - */ +/** Plugins settings section: localized tabs around feature-owned pages. */ -import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import type {} from './slot-contract.ts' +import { useEffect, useId, useState } from 'react' +import type { + HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, +} from '@deepseek-ai/dsh-client-ui-slots' import type { PluginConfigKey } from './locales.ts' import css from './PluginConfigSection.module.css' +/** One tab projected from a `settings.plugins.tab` contribution. */ +export interface PluginSettingsTabRow { + id: string + order: number + label: string +} + /** Registration-side business face for the section. */ export interface PluginConfigSectionInjected { - /** How many cards the slot ledger currently holds; zero renders the empty line. */ - cardCount: number + hooks: { + /** Ordered, locale-aware projection of the Plugins tab ledger. */ + tabs: HostObservable + } } /** Props the renderer binds for the section. */ export type PluginConfigSectionProps = PropsRuntime<'settings.section'> & PropsLocale<'settings.pluginConfig'> - & PropsRenderSlots<'settings.plugin.item'> + & PropsRenderSlots<'settings.plugins.tab'> & InjectFace -/** - * Render the plugin configuration section. - * @param props - runtime slot rendering, locale copy, and the card count. - * @returns the section. - */ -export function PluginConfigSection(props: PluginConfigSectionProps) { - const { t, renderSlot, cardCount } = props +/** Render one Plugins page whose contents arrive from feature-owned tabs. */ +export function PluginConfigSection({ t, renderSlot, useTabs }: PluginConfigSectionProps) { + const tabsId = useId() + const rows = useTabs(value => value) + const [activeId, setActiveId] = useState() + const [visitedIds, setVisitedIds] = useState>(() => new Set()) + const active = rows.find(row => row.id === activeId)?.id ?? rows[0]?.id + + // A tab mounts only when first selected, then stays mounted while hidden so + // local drafts, disclosure state, search, and the inventory snapshot survive + // switching between the two views. + useEffect(() => { + if (active === undefined) return + setVisitedIds((previous) => { + if (previous.has(active)) return previous + return new Set([...previous, active]) + }) + }, [active]) + return (

{t('title')}

{t('intro')}

- {cardCount === 0 - ?

{t('empty')}

- :
    {renderSlot('settings.plugin.item', {})}
} + {rows.length === 0 ?

{t('empty')}

: ( + <> +
+ {rows.map((row) => { + const selected = row.id === active + return ( + + ) + })} +
+ {rows + .filter(row => row.id === active || visitedIds.has(row.id)) + .map((row) => { + const selected = row.id === active + return ( + + ) + })} + + )}
) } declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { - /** Plugin configuration section and card copy. */ + /** Plugins section, configurable-tab, and card copy. */ 'settings.pluginConfig': PluginConfigKey } } diff --git a/packages/client/ui-plugin-config/src/client/index.ts b/packages/client/ui-plugin-config/src/client/index.ts index f3425634ef..a83bff8c11 100644 --- a/packages/client/ui-plugin-config/src/client/index.ts +++ b/packages/client/ui-plugin-config/src/client/index.ts @@ -1,13 +1,12 @@ /** - * Plugin configuration surface, browser half — one settings section holding - * an expandable card per Host plugin whose configuration a user owns. + * Plugins settings surface, browser half — one section whose feature-owned + * tabs include configurable Host plugin cards and read-only inventory. * - * The section owns no knowledge of any namespace: it declares the - * `settings.plugin.item` slot and renders whatever cards were registered into - * it, so a plugin that ships a browser half contributes its own card and its - * own controls. The three cards this package registers are the host-plane - * sections the deployment already exposes; each binds its namespace through - * the client settings scope, which keeps them unaware of one another. + * The section declares `settings.plugins.tab`; its own `configurable` tab then + * declares `settings.plugin.item` and renders whatever cards were registered + * into it. The three cards this package ships are the host-plane sections the + * deployment already exposes; each binds its namespace through the client + * settings scope, which keeps them unaware of one another and of other tabs. */ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' @@ -18,11 +17,15 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' // through the service, never a value import (client bundle purity gate). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: the ctx.remote Context merge and the forwarded-event key face. import type {} from '@deepseek-ai/dsh-api-remotes/client' import { AgentLoopCard } from './AgentLoopCard.tsx' import { BashCard } from './BashCard.tsx' +import { ConfigurablePluginsTab } from './ConfigurablePluginsTab.tsx' +import type { ConfigurablePluginsTabInjected } from './ConfigurablePluginsTab.tsx' import { PluginConfigSection } from './PluginConfigSection.tsx' +import type { PluginConfigSectionInjected, PluginSettingsTabRow } from './PluginConfigSection.tsx' import { WebSearchCard } from './WebSearchCard.tsx' import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-store.ts' import { BASH_NS, BashCardController } from './bash-store.ts' @@ -30,6 +33,7 @@ import { WEB_SEARCH_NS, WebSearchCardController } from './web-search-store.ts' import { en, zh } from './locales.ts' export type { PluginConfigSectionInjected, PluginConfigSectionProps } from './PluginConfigSection.tsx' +export type { ConfigurablePluginsTabInjected, ConfigurablePluginsTabProps } from './ConfigurablePluginsTab.tsx' export type { PluginCardProps } from './PluginCard.tsx' export type { SettingsPluginItemOwnerProps } from './slot-contract.ts' export type { FieldProps } from './fields.tsx' @@ -67,23 +71,67 @@ export function apply(ctx: ClientContext): void { 'ui-plugin-config: credential invalidations', ) - // The section renders the empty line rather than an empty list when no plugin - // contributed a card. The count is read once: the renderer caches a root - // entry's inject face per registration, so this reports what was registered - // when the section mounted, not what is visible now. Both gaps are bounded by - // this deployment always registering the three cards below — a card that - // arrives later would not raise the count, and a namespace this deployment - // does not expose leaves its card rendering nothing inside a non-empty list. + let tabsVersion = -1 + let tabsRevision = -1 + let tabs: readonly PluginSettingsTabRow[] = [] + const sectionInjected = (): PluginConfigSectionInjected => ({ + hooks: { + tabs: { + getSnapshot: () => { + const version = ctx.slots.getVersion('settings.plugins.tab') + const revision = ctx.locale.getSnapshot().revision + if (version !== tabsVersion || revision !== tabsRevision) { + tabsVersion = version + tabsRevision = revision + tabs = ctx.slots.entries('settings.plugins.tab') + .map(entry => ({ + /* v8 ignore next -- list-slot registration requires id */ + id: entry.options.id ?? '', + order: entry.options.order ?? 0, + label: resolveSlotLabel(entry.options.label) ?? '', + })) + .sort((a, b) => a.order - b.order) + } + return tabs + }, + subscribe: (listener) => { + const offLedger = ctx.slots.subscribe('settings.plugins.tab', listener) + const offLocale = ctx.locale.subscribe(listener) + return () => { + offLedger() + offLocale() + } + }, + }, + }, + }) + + // This package owns the one Plugins navigation entry and the tab chrome; + // feature plugins contribute pages without competing for Settings nav rows. ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'plugins', - order: 30, + order: 15, label: () => t('nav'), locale: NS, - inject: () => ({ cardCount: ctx.slots.entries('settings.plugin.item').length }), - children: { 'settings.plugin.item': { kind: 'list', scope: 'root' } }, + inject: sectionInjected, + children: { 'settings.plugins.tab': { kind: 'list', scope: 'root' } }, }, PluginConfigSection)) + // The existing configuration page is one ordinary tab. It keeps ownership + // of the card slot and the three shipped card contributions below. + ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({ + name: 'settings.plugins.tab', + id: 'configurable', + order: 0, + label: () => t('configurableTab'), + locale: NS, + inject: (): ConfigurablePluginsTabInjected => ({ + cardCount: ctx.slots.entries('settings.plugin.item').length, + }), + children: { 'settings.plugin.item': { kind: 'list', scope: 'root' } }, + }, ConfigurablePluginsTab)) + ctx.slots.inject('settings.plugin.item', function* () { yield ctx.slots.register({ name: 'settings.plugin.item', diff --git a/packages/client/ui-plugin-config/src/client/locales.ts b/packages/client/ui-plugin-config/src/client/locales.ts index 18fc943f7b..763b9986c4 100644 --- a/packages/client/ui-plugin-config/src/client/locales.ts +++ b/packages/client/ui-plugin-config/src/client/locales.ts @@ -2,7 +2,7 @@ /** Locale keys these surfaces render. */ export type PluginConfigKey = - | 'nav' | 'title' | 'intro' | 'empty' + | 'nav' | 'title' | 'intro' | 'tabs' | 'configurableTab' | 'empty' | 'overridden' | 'reset' | 'readOnly' | 'expand' | 'collapse' | 'save' | 'saving' | 'discard' | 'unsaved' | 'saveFailed' | 'invalidNumber' | 'bashTitle' | 'bashDescription' | 'bashTimeoutMs' | 'bashTimeoutMsHint' @@ -14,9 +14,11 @@ export type PluginConfigKey = /** English copy. */ export const en: Record = { - nav: 'Plugin config', - title: 'Plugin configuration', - intro: 'Configure the plugins this deployment installed.', + nav: 'Plugins', + title: 'Plugins', + intro: 'Configure and inspect the plugins installed in this deployment.', + tabs: 'Plugin views', + configurableTab: 'Plugin configuration', empty: 'This deployment exposes no plugin settings.', overridden: 'Overridden', reset: 'Reset to default', @@ -53,9 +55,11 @@ export const en: Record = { /** Simplified Chinese copy. */ export const zh: Record = { - nav: '插件配置', - title: '插件配置', - intro: '配置本部署已安装的插件。', + nav: '插件', + title: '插件', + intro: '配置和查看本部署已安装的插件。', + tabs: '插件视图', + configurableTab: '插件配置', empty: '本部署没有开放任何插件设置。', overridden: '已覆盖', reset: '恢复默认', diff --git a/packages/client/ui-plugin-config/src/index.ts b/packages/client/ui-plugin-config/src/index.ts index 0bb50fba35..ce0f6e034d 100644 --- a/packages/client/ui-plugin-config/src/index.ts +++ b/packages/client/ui-plugin-config/src/index.ts @@ -1,7 +1,7 @@ /** - * Plugin configuration surface, node half. The empty apply exists so the - * plugin appears in the host cordis.yml / Loader; the browser half ships the - * settings section through exports["./client"], discovered from the + * Plugins settings surface, node half. The empty apply exists so the plugin + * appears in the host cordis.yml / Loader; the browser half owns the section + * and its configurable tab through exports["./client"], discovered from the * package.json dsh.client declaration. Every section this page edits is owned * by the Host plugin that registered it, so this package registers no * namespace of its own. diff --git a/packages/client/ui-plugin-config/tests/apply.client.spec.ts b/packages/client/ui-plugin-config/tests/apply.client.spec.ts index a880446e8c..8c5e768c45 100644 --- a/packages/client/ui-plugin-config/tests/apply.client.spec.ts +++ b/packages/client/ui-plugin-config/tests/apply.client.spec.ts @@ -8,6 +8,9 @@ import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { SettingsScopeService } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-plugin-config/client' +import type { + ConfigurablePluginsTabInjected, PluginConfigSectionInjected, +} from '@deepseek-ai/dsh-client-ui-plugin-config/client' // The service reads its initial locale from the browser; these specs assert // the shipped Chinese copy, so they state the browser they assume. @@ -46,16 +49,20 @@ describe('ui-plugin-config apply', () => { expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope']) }) - it('registers the section and declares the per-plugin card slot', async () => { + it('registers one Plugins section and declares the tab and card slots', async () => { const { ctx, slots } = await bench() declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() const section = slots.entries('settings.section')[0]! - expect(section.options).toMatchObject({ id: 'plugins', order: 30 }) + expect(section.options).toMatchObject({ id: 'plugins', order: 15 }) // The nav label is a locale-following thunk; owners resolve it at read time. - expect(resolveSlotLabel(section.options.label)).toBe('插件配置') + expect(resolveSlotLabel(section.options.label)).toBe('插件') + expect(slots.spec('settings.plugins.tab')).toMatchObject({ kind: 'list', scope: 'root' }) + const tab = slots.entries('settings.plugins.tab')[0]! + expect(tab.options).toMatchObject({ id: 'configurable', order: 0 }) + expect(resolveSlotLabel(tab.options.label)).toBe('插件配置') expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'list', scope: 'root' }) }) @@ -69,13 +76,18 @@ describe('ui-plugin-config apply', () => { .toEqual(['bash', 'agent-loop', 'web-search']) }) - it('injects a live card count and one business face per card', async () => { + it('injects a live tab projection, a card count, and one business face per card', async () => { const { ctx, slots } = await bench() declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() const section = slots.entries('settings.section')[0]! - expect((section as { inject?: () => unknown }).inject?.()).toEqual({ cardCount: 3 }) + const sectionFace = (section.inject as unknown as () => PluginConfigSectionInjected)() + expect(sectionFace.hooks.tabs.getSnapshot()).toEqual([ + { id: 'configurable', order: 0, label: '插件配置' }, + ]) + const tab = slots.entries('settings.plugins.tab')[0]! + expect((tab.inject as unknown as () => ConfigurablePluginsTabInjected)()).toEqual({ cardCount: 3 }) for (const entry of slots.entries('settings.plugin.item')) { const face = (entry as { inject?: () => unknown }).inject?.() as { hooks: Record } // Each card injects exactly one snapshot store plus its own actions. @@ -129,6 +141,7 @@ describe('ui-plugin-config apply', () => { await fiber.dispose() expect(slots.entries('settings.section')).toHaveLength(0) + expect(slots.spec('settings.plugins.tab')).toBeUndefined() expect(slots.spec('settings.plugin.item')).toBeUndefined() }) }) diff --git a/packages/client/ui-plugin-config/tests/section.client.spec.tsx b/packages/client/ui-plugin-config/tests/section.client.spec.tsx index 3945092587..8e0986892f 100644 --- a/packages/client/ui-plugin-config/tests/section.client.spec.tsx +++ b/packages/client/ui-plugin-config/tests/section.client.spec.tsx @@ -13,8 +13,10 @@ import { AgentLoopCard } from '../src/client/AgentLoopCard.tsx' import type { AgentLoopCardProps } from '../src/client/AgentLoopCard.tsx' import { BashCard } from '../src/client/BashCard.tsx' import type { BashCardProps } from '../src/client/BashCard.tsx' +import { ConfigurablePluginsTab } from '../src/client/ConfigurablePluginsTab.tsx' +import type { ConfigurablePluginsTabProps } from '../src/client/ConfigurablePluginsTab.tsx' import { PluginConfigSection } from '../src/client/PluginConfigSection.tsx' -import type { PluginConfigSectionProps } from '../src/client/PluginConfigSection.tsx' +import type { PluginConfigSectionProps, PluginSettingsTabRow } from '../src/client/PluginConfigSection.tsx' import { WebSearchCard } from '../src/client/WebSearchCard.tsx' import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx' import type { AgentLoopCardState } from '../src/client/agent-loop-store.ts' @@ -46,13 +48,24 @@ function cardActions() { return { edit: vi.fn(), resetField: vi.fn(), save: vi.fn(), discard: vi.fn() } } -function renderSection(cardCount: number, cards = 'cards') { +function renderSection(rows: readonly PluginSettingsTabRow[]) { + const props = { + t, + useTabs: (selector: (value: readonly PluginSettingsTabRow[]) => unknown) => selector(rows), + renderSlot: (_name: string, _owner: unknown, options: { only?: string }) => ( + {options.only} + ), + } as unknown as PluginConfigSectionProps + render() +} + +function renderConfigurable(cardCount: number, cards = 'cards') { const props = { t, cardCount, renderSlot: () =>
  • {cards}
  • , - } as unknown as PluginConfigSectionProps - render() + } as unknown as ConfigurablePluginsTabProps + render() } function renderBash(state: Partial = {}) { @@ -69,26 +82,53 @@ function renderBash(state: Partial = {}) { } describe('PluginConfigSection', () => { + it('says so when no plugin contributed a tab', () => { + renderSection([]) + + expect(screen.getByText(en.empty)).toBeTruthy() + expect(screen.queryByRole('tab')).toBeNull() + }) + + it('defaults to the first ordered tab and mounts another only after selection', () => { + renderSection([ + { id: 'configurable', order: 0, label: en.configurableTab }, + { id: 'all', order: 10, label: 'Plugin list' }, + ]) + + const configurable = screen.getByRole('tab', { name: en.configurableTab }) + const all = screen.getByRole('tab', { name: 'Plugin list' }) + expect(configurable.getAttribute('aria-selected')).toBe('true') + expect(screen.getByText('configurable')).toBeTruthy() + expect(screen.queryByText('all')).toBeNull() + + fireEvent.click(all) + expect(all.getAttribute('aria-selected')).toBe('true') + expect(screen.getByText('all')).toBeTruthy() + expect(screen.getByText('configurable').closest('[role="tabpanel"]')).toHaveProperty('hidden', true) + }) + + it('leads with its own heading and intro', () => { + renderSection([{ id: 'configurable', order: 0, label: en.configurableTab }]) + + expect(screen.getByRole('heading', { name: en.title })).toBeTruthy() + expect(screen.getByText(en.intro)).toBeTruthy() + }) +}) + +describe('ConfigurablePluginsTab', () => { it('says so when no plugin contributed a card', () => { - renderSection(0) + renderConfigurable(0) expect(screen.getByText(en.empty)).toBeTruthy() expect(screen.queryByText('cards')).toBeNull() }) it('renders the card list once a plugin contributed one', () => { - renderSection(1) + renderConfigurable(1) expect(screen.getByText('cards')).toBeTruthy() expect(screen.queryByText(en.empty)).toBeNull() }) - - it('leads with its own heading and intro', () => { - renderSection(1) - - expect(screen.getByRole('heading', { name: en.title })).toBeTruthy() - expect(screen.getByText(en.intro)).toBeTruthy() - }) }) describe('BashCard', () => { diff --git a/packages/client/ui-plugins/README.i18n.yaml b/packages/client/ui-plugins/README.i18n.yaml index 62085c3d6b..04e1faa8c5 100644 --- a/packages/client/ui-plugins/README.i18n.yaml +++ b/packages/client/ui-plugins/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-plugins/README.md -README.md: bb487d5e2cbd34406d83867997ede4d70b190d70 -README.zh.md: 48a11911509ea260aa9727d55c0b4df6efbfb1c9 +README.md: a663665cc2d7ce26a2724da9aecc25f51515327a +README.zh.md: 852ecbe6546f76a76fc6d9d3a8bb891539b0844f diff --git a/packages/client/ui-plugins/README.md b/packages/client/ui-plugins/README.md index bb487d5e2c..a663665cc2 100644 --- a/packages/client/ui-plugins/README.md +++ b/packages/client/ui-plugins/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -Read-only Plugins section for Web Settings. The browser plugin registers one localized `settings.section` contribution with id `plugin-inventory`, after Models, and lets the Settings shell supply its ordinary fallback icon. It performs no Remote read during plugin activation; mounting the section lazily calls `ctx.remote.pluginInventory.list()` through [`api-remotes`](../../api/remotes/README.md). +Read-only **Plugin list** tab for Web Settings. The browser plugin registers one localized `settings.plugins.tab` contribution with id `all`; the Plugins section owns the navigation entry and tab chrome. It performs no Remote read during plugin activation. Selecting the tab for the first time mounts it and lazily calls `ctx.remote.pluginInventory.list()` through [`api-remotes`](../../api/remotes/README.md). -The page renders a searchable two-column catalog of compact disclosure cards. Each collapsed card uses the local Loader id as its title, a colored root-Fiber status dot, and a small effective-enablement tag. Expanding one card reveals its Loader-tree entry value without a redundant field label, followed by the effective configuration and Cordis status. Loading, empty, no-match, and generic failure states stay local to the mounted component, and a failed read can be retried without exposing transport details. The registration uses `ctx.slots.inject()`, so it follows late Settings declaration, redeclaration, locale changes, and teardown without owning another global store. +The tab renders a searchable two-column catalog of compact disclosure cards. Each collapsed card uses the short module name as its title and a small effective-enablement tag; enabled entries also show a colored root-fiber status dot. Expanding one card reveals its Loader-tree entry id without a redundant field label, followed by the effective configuration and, for enabled entries, Cordis status. Disabled entries omit the redundant unmounted runtime state. The entry id remains the React key, disclosure identity, detail value, and an additional search target; it is never classified by string shape. Loading, empty, no-match, and generic failure states stay local to the mounted component, and a failed read can be retried without exposing transport details. The registration uses `ctx.slots.inject()`, so it follows late tab declaration, redeclaration, locale changes, and teardown without importing the section owner. ## Model Experience @@ -16,5 +16,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **One snapshot per mount or retry** — the page does not subscribe to Loader changes or automatically refetch after reconnect; reopening the section obtains a new snapshot. +- **One snapshot per Settings mount or retry** — the tab does not subscribe to Loader changes or automatically refetch after reconnect; switching tabs preserves the current snapshot, while reopening Settings obtains a new one. - **Read-only Loader view** — local search does not add provenance, current-browser activation diagnosis, grouping by source, or plugin mutation controls. diff --git a/packages/client/ui-plugins/README.zh.md b/packages/client/ui-plugins/README.zh.md index 48a1191150..852ecbe654 100644 --- a/packages/client/ui-plugins/README.zh.md +++ b/packages/client/ui-plugins/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -Web 设置中的只读“插件”分区。浏览器插件在“模型”之后注册一个 id 为 `plugin-inventory` 的本地化 `settings.section` 贡献,并由 Settings shell 提供常规的回退图标。插件激活期间不会读取 Remote;挂载该分区时,组件才通过 [`api-remotes`](../../api/remotes/README.md) 懒调用 `ctx.remote.pluginInventory.list()`。 +Web 设置中的只读**插件列表**标签页。浏览器插件注册一个 id 为 `all` 的本地化 `settings.plugins.tab` 贡献;“插件”分区拥有导航入口与标签栏。插件激活期间不会读取 Remote;首次选择该标签页时才挂载组件,并通过 [`api-remotes`](../../api/remotes/README.md) 懒调用 `ctx.remote.pluginInventory.list()`。 -页面以可搜索的双列紧凑折叠卡片展示清单。每张收起的卡片使用 Loader 本地 id 作为标题,以彩色圆点表示根 Fiber 状态,以小标签表示有效启停状态。展开卡片后会直接展示 Loader 树条目值,不附加重复的字段标题,并列出有效配置状态与 Cordis 状态。加载、空结果、无匹配结果与通用失败状态只属于已挂载组件;读取失败后可以重试,且不会暴露传输细节。注册使用 `ctx.slots.inject()`,因此能跟随 Settings 的延迟声明、重新声明、本地化变化与 teardown,而不拥有另一份全局 store。 +该标签页以可搜索的双列紧凑折叠卡片展示清单。每张收起的卡片使用模块短名称作为标题,以小标签表示有效启停状态;已启用的条目还会以彩色圆点表示根 fiber 状态。展开卡片后会直接展示 Loader 树条目 id,不附加重复的字段标题,并列出有效配置状态;已启用的条目还会列出 Cordis 状态,已停用的条目则省略重复的“未挂载”运行状态。条目 id 仍作为 React key、展开标识、详情值与额外的搜索目标;代码不按字符串形状对它分类。加载、空结果、无匹配结果与通用失败状态只属于已挂载组件;读取失败后可以重试,且不会暴露传输细节。注册使用 `ctx.slots.inject()`,因此能跟随标签 slot 的延迟声明、重新声明、本地化变化与 teardown,而无需 import 分区拥有方。 ## 模型体验 @@ -16,5 +16,5 @@ Web 设置中的只读“插件”分区。浏览器插件在“模型”之后 ## 已知限制与暂缓事项 -- **每次挂载或重试只读取一份快照** —— 页面不订阅 Loader 变化,也不会在重连后自动重新读取;重新打开分区会取得新快照。 +- **每次 Settings 挂载或重试只读取一份快照** —— 标签页不订阅 Loader 变化,也不会在重连后自动重新读取;切换标签页会保留当前快照,重新打开 Settings 则会取得新快照。 - **只读 Loader 视图** —— 本地搜索不会额外引入来源、按来源分组、当前浏览器激活诊断或插件修改控件。 diff --git a/packages/client/ui-plugins/package.json b/packages/client/ui-plugins/package.json index 07fb9d162c..965a1ef415 100644 --- a/packages/client/ui-plugins/package.json +++ b/packages/client/ui-plugins/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-plugins", - "description": "Read-only Cordis Loader plugin inventory in Web settings", + "description": "Read-only Cordis Loader inventory tab in Web Plugins settings", "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" diff --git a/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css b/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css index 9429b60bb5..de10a3bd36 100644 --- a/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css +++ b/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css @@ -7,19 +7,12 @@ color: var(--dsw-alias-label-primary); } -.heading h2, .catalogHeading h3, .status, .failure p { margin: 0; } -.heading h2 { - font-size: 16px; - line-height: 24px; - font-weight: 600; -} - .status, .failure { font-size: 13px; diff --git a/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx b/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx index 87d6486000..4febea4814 100644 --- a/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx +++ b/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx @@ -19,7 +19,7 @@ type PluginFiberPhase = PluginInventoryEntry['fiberPhase'] /** Full component props assembled by the Settings slot renderer. */ export type PluginSettingsSectionProps = - PropsRuntime<'settings.section'> + PropsRuntime<'settings.plugins.tab'> & PropsLocale<'settings.plugins'> & InjectFace @@ -62,7 +62,7 @@ function matches(entry: PluginInventoryEntry, normalizedQuery: string): boolean /** Render the read-only current Loader inventory. */ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps): ReactNode { - const titleId = useId() + const catalogId = useId() const [request, setRequest] = useState(0) const [query, setQuery] = useState('') const [expanded, setExpanded] = useState(null) @@ -97,10 +97,7 @@ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps): } return ( -
    -
    -

    {t('title')}

    -
    +
    {state.status === 'loading' ?

    {t('loading')}

    : null} {state.status === 'error' ? (
    @@ -134,8 +131,9 @@ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps): {filteredEntries.map((entry) => { const status = phaseLabel(entry.fiberPhase, t) const title = moduleShortName(entry.moduleName) + const configuration = t(entry.enabled ? 'enabledTag' : 'disabledTag') const open = expanded === entry.entryId - const detailId = `${titleId}-details-${encodeURIComponent(entry.entryId)}` + const detailId = `${catalogId}-details-${encodeURIComponent(entry.entryId)}` return (
  • { setExpanded(current => current === entry.entryId ? null : entry.entryId) }} > {title} - + {entry.enabled ? ( + + ) : null} - {t(entry.enabled ? 'enabledTag' : 'disabledTag')} + {configuration} @@ -174,12 +174,14 @@ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps):
    {t('configuration')}
    -
    {t(entry.enabled ? 'enabledTag' : 'disabledTag')}
    -
    -
    -
    {t('cordis')}
    -
    {status}
    +
    {configuration}
    + {entry.enabled ? ( +
    +
    {t('cordis')}
    +
    {status}
    +
    + ) : null}
  • ) : null} @@ -190,6 +192,6 @@ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps): ) : null}
    ) : null} -
    + ) } diff --git a/packages/client/ui-plugins/src/client/index.ts b/packages/client/ui-plugins/src/client/index.ts index ccf12ab989..f2261086b7 100644 --- a/packages/client/ui-plugins/src/client/index.ts +++ b/packages/client/ui-plugins/src/client/index.ts @@ -22,7 +22,7 @@ export const NS = 'settings.plugins' /** Services required by the Settings registration and generated Remote face. */ export const inject = ['slots', 'locale', 'remote', 'remote.pluginInventory'] -/** Register the lazy plugin inventory page below Models in Settings. */ +/** Contribute the lazy inventory tab to the Plugins settings section. */ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plugins: dictionaries') @@ -36,11 +36,11 @@ export function apply(ctx: ClientContext): void { } const injected = (): PluginSettingsSectionInjected => ({ list }) - ctx.slots.inject('settings.section', () => ctx.slots.register({ - name: 'settings.section', - id: 'plugin-inventory', - order: 15, - label: () => t('nav'), + ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({ + name: 'settings.plugins.tab', + id: 'all', + order: 10, + label: () => t('tab'), locale: NS, inject: injected, }, PluginSettingsSection)) diff --git a/packages/client/ui-plugins/src/client/locales.ts b/packages/client/ui-plugins/src/client/locales.ts index c505296f38..a95d7b95cb 100644 --- a/packages/client/ui-plugins/src/client/locales.ts +++ b/packages/client/ui-plugins/src/client/locales.ts @@ -2,8 +2,7 @@ /** Simplified Chinese dictionary and key source of truth. */ export const zh = { - nav: '插件', - title: '插件', + tab: '插件列表', loading: '正在读取插件…', error: '暂时无法读取插件。', retry: '重试', @@ -28,8 +27,7 @@ export type PluginsKey = keyof typeof zh /** English dictionary checked against the Chinese key set. */ export const en = { - nav: 'Plugins', - title: 'Plugins', + tab: 'Plugin list', loading: 'Reading plugins…', error: 'Plugins are temporarily unavailable.', retry: 'Retry', diff --git a/packages/client/ui-plugins/src/index.ts b/packages/client/ui-plugins/src/index.ts index 489544a421..473386a9ec 100644 --- a/packages/client/ui-plugins/src/index.ts +++ b/packages/client/ui-plugins/src/index.ts @@ -1,4 +1,4 @@ -/** Host loader entry for the browser implementation exported from `./client`. */ +/** Host loader entry for the inventory-tab browser implementation exported from `./client`. */ -/** Host plugin body — no host-side behavior for the plugin settings section. */ +/** Host plugin body — no host-side behavior for the plugin inventory tab. */ export function apply(): void {} diff --git a/packages/client/ui-plugins/tests/browser-plugin.client.spec.tsx b/packages/client/ui-plugins/tests/browser-plugin.client.spec.tsx index d9d8a43cd8..e0feb50e69 100644 --- a/packages/client/ui-plugins/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-plugins/tests/browser-plugin.client.spec.tsx @@ -38,7 +38,7 @@ async function bench() { function declare(slots: SlotsService): () => void { return slots.register({ name: 'root', - children: { 'settings.section': { kind: 'list', scope: 'root' } }, + children: { 'settings.plugins.tab': { kind: 'list', scope: 'root' } }, } as never, () => null) } @@ -47,16 +47,16 @@ describe('ui-plugins browser plugin', () => { expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginInventory']) }) - it('registers a localized section without reading the Remote eagerly', async () => { + it('registers a localized tab without reading the Remote eagerly', async () => { const b = await bench() declare(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() - const entry = b.slots.entries('settings.section')[0]! + const entry = b.slots.entries('settings.plugins.tab')[0]! expect(entry.component).toBe(PluginSettingsSection) - expect(entry.options).toMatchObject({ id: 'plugin-inventory', order: 15 }) + expect(entry.options).toMatchObject({ id: 'all', order: 10 }) expect(entry.locale).toBe(NS) - expect(resolveSlotLabel(entry.options.label)).toBe('插件') + expect(resolveSlotLabel(entry.options.label)).toBe('插件列表') expect(b.list).not.toHaveBeenCalled() const injected = (entry.inject as unknown as () => PluginSettingsSectionInjected)() @@ -71,22 +71,22 @@ describe('ui-plugins browser plugin', () => { const b = await bench() const fiber = b.ctx.plugin({ inject: [...inject], apply }) await fiber.await() - expect(b.slots.entries('settings.section')).toHaveLength(0) + expect(b.slots.entries('settings.plugins.tab')).toHaveLength(0) const stop = declare(b.slots) - await vi.waitFor(() => { expect(b.slots.entries('settings.section')).toHaveLength(1) }) + await vi.waitFor(() => { expect(b.slots.entries('settings.plugins.tab')).toHaveLength(1) }) b.locale.setLocale('en') - expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Plugins') + expect(resolveSlotLabel(b.slots.entries('settings.plugins.tab')[0]!.options.label)).toBe('Plugin list') stop() - expect(b.slots.entries('settings.section')).toHaveLength(0) + expect(b.slots.entries('settings.plugins.tab')).toHaveLength(0) declare(b.slots) await vi.waitFor(() => { - expect(b.slots.entries('settings.section')[0]?.component).toBe(PluginSettingsSection) + expect(b.slots.entries('settings.plugins.tab')[0]?.component).toBe(PluginSettingsSection) }) await fiber.dispose() - expect(b.slots.entries('settings.section')).toHaveLength(0) + expect(b.slots.entries('settings.plugins.tab')).toHaveLength(0) expect(() => b.locale.register(NS, 'zh', {})).not.toThrow() await b.ctx.fiber.dispose() }) diff --git a/packages/client/ui-plugins/tests/components.client.spec.tsx b/packages/client/ui-plugins/tests/components.client.spec.tsx index 9da8a79b0d..bf104abcdb 100644 --- a/packages/client/ui-plugins/tests/components.client.spec.tsx +++ b/packages/client/ui-plugins/tests/components.client.spec.tsx @@ -12,16 +12,12 @@ afterEach(cleanup) type Snapshot = Awaited> const t = ((key: PluginsKey): string => en[key]) as PluginSettingsSectionProps['t'] -const unusedHook = (() => { throw new Error('unused by plugin inventory') }) as never function props(list: PluginSettingsSectionInjected['list']): PluginSettingsSectionProps { return { - close: vi.fn(), - useSessions: unusedHook, - useWorkspaces: unusedHook, t, list, - } + } as PluginSettingsSectionProps } const SNAPSHOT = { @@ -36,7 +32,7 @@ const SNAPSHOT = { } as unknown as Snapshot describe('PluginSettingsSection', () => { - it('renders searchable two-column-card semantics with dots and tags', async () => { + it('renders runtime status only for enabled plugins', async () => { const deferred = Promise.withResolvers() const list = vi.fn(() => deferred.promise) const view = render() @@ -56,10 +52,10 @@ describe('PluginSettingsSection', () => { 'Loading', 'Mount failed', 'Unloading', - 'Not mounted', ]) { expect(screen.getByRole('img', { name: value })).toBeTruthy() } + expect(screen.queryByRole('img', { name: 'Not mounted' })).toBeNull() const active = screen.getByRole('button', { name: 'hmr, Mounted, Enabled' }) expect(active.getAttribute('aria-expanded')).toBe('false') fireEvent.click(active) @@ -75,8 +71,10 @@ describe('PluginSettingsSection', () => { target: { value: 'disabled-entry' }, }) expect(view.container.querySelector('[data-loader-entry]')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: 'directory-picker-native, Not mounted, Disabled' })) + fireEvent.click(screen.getByRole('button', { name: 'directory-picker-native, Disabled' })) expect(screen.getAllByText(en.disabledTag)).toHaveLength(2) + expect(screen.queryByText(en.cordis)).toBeNull() + expect(screen.queryByText(en.unobserved)).toBeNull() }) it('filters by module name or Loader entry id', async () => { diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml index 392f2b52a1..ec7c333250 100644 --- a/packages/client/ui-settings/README.i18n.yaml +++ b/packages/client/ui-settings/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings/README.md -README.md: 8bf085fd02e3c76148674065bb4f5708e9a6e8d8 -README.zh.md: f7b6f18809c64be6830ea23c3968e9af70b70c41 +README.md: 950585c4957cd59fe3a38dc37cdd4084f7c5541c +README.zh.md: dce8dbf5c8e0939142fed8a3df84c47acd7c8a1e diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md index 8bf085fd02..950585c495 100644 --- a/packages/client/ui-settings/README.md +++ b/packages/client/ui-settings/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The settings domain's base layer, with two roles and no presentation of its own. It provides `ctx.settingsScope`, the Host transport every preference row binds its durable namespace section through, and it declares 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), 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 settings domain's base layer, with two roles and no presentation of its own. It provides `ctx.settingsScope`, the Host transport every preference row binds its durable namespace section through, and it declares 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: `ctx.settingsScope.bind(spec)` resolves the wire face through the CALLER's context at call time, so the bound scope's disposer belongs to the calling fiber, and the caller injects `connection` for the transport and `remote` for the 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. 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. diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md index f7b6f18809..dce8dbf5c8 100644 --- a/packages/client/ui-settings/README.zh.md +++ b/packages/client/ui-settings/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -设置领域的底座,承担两项职责,本身不含任何呈现内容。它提供 `ctx.settingsScope`——每个偏好设置行绑定自己那份持久化命名空间分区所用的宿主传输层;并声明由注册方填充的设置 slot 类型:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.action`(内容标题栏中的有序操作)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有的有序页面)。它不依赖任何 `ui-*` 呈现包,因此任何持有偏好设置的功能都能够到它;设置**外壳**——`sidebar.settings` 占位方、它的导航与界面框架——位于 ui-settings-general,因为外壳一旦依赖 ui-sidebar,就会经 ui-layout 与 ui-theme 闭合出一条引用图环路。外壳自身的契约类型出于同一原因与外壳放在一起。 +设置领域的底座,承担两项职责,本身不含任何呈现内容。它提供 `ctx.settingsScope`——每个偏好设置行绑定自己那份持久化命名空间分区所用的宿主传输层;并声明由注册方填充的设置 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 闭合出一条引用图环路。外壳自身的契约类型出于同一原因与外壳放在一起。 该插件不注入任何服务、也不等待任何服务:`ctx.settingsScope.bind(spec)` 在调用时经**调用方**的 context 解析线路面,因此绑定所得 scope 的 disposer 归调用方 fiber 所有,而由调用方注入 `connection` 取得传输层、注入 `remote` 取得失效通知。监听器在首次后台读取启动之前就已存在,因此某一行的激活绝不会阻塞在设置传输层上。已绑定的 scope 会在收到属于自己命名空间的转发 `settings/document-updated` 事件时、以及在 `connection/reset` 时重新读取。写入携带单一字段路径以及最近已知的命名空间 revision 作为 `expectedRevision`;被拒绝或失败的写入会重新读取,除非已有更新的写入取代了它,而过期的读取绝不会覆盖发布更新的结果。若 spec 未提供 `decode`,则分区不是普通对象、未通过其重建后的 schema 校验、或携带本客户端无法重建的 schema 信封时,一律不发布任何值,于是行渲染自己的缺失状态,而不是一份半解码的值。 diff --git a/packages/client/ui-settings/src/client/contract/slots.ts b/packages/client/ui-settings/src/client/contract/slots.ts index e648fb2244..79ec682959 100644 --- a/packages/client/ui-settings/src/client/contract/slots.ts +++ b/packages/client/ui-settings/src/client/contract/slots.ts @@ -51,6 +51,15 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * item registrant; the shell neither declares nor renders it.) */ 'settings.section': { kind: 'list'; scope: 'root'; owner: SettingsSectionOwnerProps } + /** + * One page inside the Plugins settings section. The section owner renders + * localized entry labels as tabs and mounts each contribution inside its + * corresponding tab panel. Options: `id` (tab key), `order` (tab order), + * and `label` (registrant-localized tab text). Declared at runtime by the + * feature that owns the Plugins section; the type lives here so inventory + * and configuration plugins collaborate without depending on one another. + */ + 'settings.plugins.tab': { kind: 'list'; scope: 'root'; owner: SettingsPluginsTabOwnerProps } /** * Root-scoped onboarding steps contributed by settings features. The * shell mounts one ordered step at a time; the active registrant either @@ -83,6 +92,12 @@ export interface SettingsGeneralItemOwnerProps { children?: never } +/** Owner share of a Plugins tab (the section supplies nothing). */ +export interface SettingsPluginsTabOwnerProps { + /** Marker field: tab owner props are intentionally empty. */ + children?: never +} + /** Owner share of the trigger content seat: the sidebar column state. */ export interface SettingsTriggerOwnerProps { /** Whether the sidebar renders wide content (false = 56px rail, icon only). */ diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index 8a6fbf25c9..2a670a7396 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -13,7 +13,7 @@ import { SettingsScopeService } from './settings-scope.ts' export type { SettingsGeneralItemOwnerProps, SettingsHeaderOwnerProps, SettingsOnboardingOwnerProps, - SettingsSectionOwnerProps, SettingsTriggerOwnerProps, + SettingsPluginsTabOwnerProps, SettingsSectionOwnerProps, SettingsTriggerOwnerProps, } from './contract/slots.ts' export { SettingsScopeController, SettingsScopeService } from './settings-scope.ts' From 14e70b1bf4e33a566ed93f153798b9f9d4bfe22e Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:18:11 -0700 Subject: [PATCH 02/13] test(web): cover plugin settings tab states --- .../ui-plugin-config/tests/apply.client.spec.ts | 14 +++++++++++++- .../ui-plugin-config/tests/section.client.spec.tsx | 4 ++++ .../ui-plugins/tests/components.client.spec.tsx | 9 +++++---- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/client/ui-plugin-config/tests/apply.client.spec.ts b/packages/client/ui-plugin-config/tests/apply.client.spec.ts index 8c5e768c45..b0d7053e83 100644 --- a/packages/client/ui-plugin-config/tests/apply.client.spec.ts +++ b/packages/client/ui-plugin-config/tests/apply.client.spec.ts @@ -83,9 +83,21 @@ describe('ui-plugin-config apply', () => { const section = slots.entries('settings.section')[0]! const sectionFace = (section.inject as unknown as () => PluginConfigSectionInjected)() - expect(sectionFace.hooks.tabs.getSnapshot()).toEqual([ + const initialTabs = sectionFace.hooks.tabs.getSnapshot() + expect(initialTabs).toEqual([ { id: 'configurable', order: 0, label: '插件配置' }, ]) + expect(sectionFace.hooks.tabs.getSnapshot()).toBe(initialTabs) + + const listener = vi.fn() + const unsubscribe = sectionFace.hooks.tabs.subscribe(listener) + slots.register({ name: 'settings.plugins.tab', id: 'plain' } as never, () => null) + expect(sectionFace.hooks.tabs.getSnapshot()).toEqual([ + { id: 'configurable', order: 0, label: '插件配置' }, + { id: 'plain', order: 0, label: '' }, + ]) + unsubscribe() + const tab = slots.entries('settings.plugins.tab')[0]! expect((tab.inject as unknown as () => ConfigurablePluginsTabInjected)()).toEqual({ cardCount: 3 }) for (const entry of slots.entries('settings.plugin.item')) { diff --git a/packages/client/ui-plugin-config/tests/section.client.spec.tsx b/packages/client/ui-plugin-config/tests/section.client.spec.tsx index 8e0986892f..e00fd7d448 100644 --- a/packages/client/ui-plugin-config/tests/section.client.spec.tsx +++ b/packages/client/ui-plugin-config/tests/section.client.spec.tsx @@ -105,6 +105,10 @@ describe('PluginConfigSection', () => { expect(all.getAttribute('aria-selected')).toBe('true') expect(screen.getByText('all')).toBeTruthy() expect(screen.getByText('configurable').closest('[role="tabpanel"]')).toHaveProperty('hidden', true) + + fireEvent.click(configurable) + expect(configurable.getAttribute('aria-selected')).toBe('true') + expect(screen.getByText('all').closest('[role="tabpanel"]')).toHaveProperty('hidden', true) }) it('leads with its own heading and intro', () => { diff --git a/packages/client/ui-plugins/tests/components.client.spec.tsx b/packages/client/ui-plugins/tests/components.client.spec.tsx index bf104abcdb..7989837065 100644 --- a/packages/client/ui-plugins/tests/components.client.spec.tsx +++ b/packages/client/ui-plugins/tests/components.client.spec.tsx @@ -27,6 +27,7 @@ const SNAPSHOT = { { entryId: 'loading', moduleName: '@fixture/loading-name', enabled: true, fiberPhase: 'loading' }, { entryId: 'failed', moduleName: '@fixture/failed-name', enabled: true, fiberPhase: 'failed' }, { entryId: 'unloading', moduleName: '@fixture/unloading-name', enabled: true, fiberPhase: 'unloading' }, + { entryId: 'unobserved', moduleName: '@fixture/unobserved-name', enabled: true, fiberPhase: null }, { entryId: 'disabled-entry', moduleName: '@deepseek-ai/dsh-host-directory-picker-native', enabled: false, fiberPhase: null }, ], } as unknown as Snapshot @@ -42,9 +43,9 @@ describe('PluginSettingsSection', () => { expect(list).toHaveBeenCalledOnce() expect(screen.getByRole('searchbox', { name: en.search })).toBeTruthy() expect(screen.getByRole('heading', { name: en.catalog })).toBeTruthy() - expect(view.container.querySelector('[data-plugin-count]')?.textContent).toBe('6') - expect(screen.getAllByRole('listitem')).toHaveLength(6) - expect(screen.getAllByText(en.enabledTag)).toHaveLength(5) + expect(view.container.querySelector('[data-plugin-count]')?.textContent).toBe('7') + expect(screen.getAllByRole('listitem')).toHaveLength(7) + expect(screen.getAllByText(en.enabledTag)).toHaveLength(6) expect(screen.getByText(en.disabledTag)).toBeTruthy() for (const value of [ 'Mounted', @@ -52,10 +53,10 @@ describe('PluginSettingsSection', () => { 'Loading', 'Mount failed', 'Unloading', + 'Not mounted', ]) { expect(screen.getByRole('img', { name: value })).toBeTruthy() } - expect(screen.queryByRole('img', { name: 'Not mounted' })).toBeNull() const active = screen.getByRole('button', { name: 'hmr, Mounted, Enabled' }) expect(active.getAttribute('aria-expanded')).toBe('false') fireEvent.click(active) From de0d16d3d163241c22e0d073892b897d2eeec5a8 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:41:29 -0700 Subject: [PATCH 03/13] fix(web): complete plugin tab accessibility --- .../2026-08-11-plugin-settings-tabs.i18n.yaml | 4 +-- .../2026-08-11-plugin-settings-tabs.md | 2 +- .../2026-08-11-plugin-settings-tabs.zh.md | 2 +- .../src/client/PluginConfigSection.module.css | 4 ++- .../src/client/PluginConfigSection.tsx | 22 +++++++++++-- .../tests/section.client.spec.tsx | 32 +++++++++++++++++++ 6 files changed, 59 insertions(+), 7 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.i18n.yaml index cdfdc3afde..980999fc1a 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.md -2026-08-11-plugin-settings-tabs.md: 987e9f49e750a026c38a0d73c255cd335c53a4fe -2026-08-11-plugin-settings-tabs.zh.md: 887ca20bc09871d4bac5650a5d5eb71d5fcb81ee +2026-08-11-plugin-settings-tabs.md: c46276de2d0ffee141377190ccacf0e7884ec994 +2026-08-11-plugin-settings-tabs.zh.md: 00feb44f7b612e191c5b4c7dd3675ef34bf6e5ac diff --git a/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.md b/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.md index 987e9f49e7..c46276de2d 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.md +++ b/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.md @@ -12,7 +12,7 @@ Plugin configuration and the read-only Loader inventory each registered a top-le `@deepseek-ai/dsh-client-ui-plugin-config` owns the single `settings.section` contribution with id `plugins`. It renders the shared title and compact tab chrome, declares the root-scoped list slot `settings.plugins.tab`, and projects that ledger's id, order, and locale-following label into its tabs. The slot's canonical type lives in `ui-settings`, so a tab contributor depends on the Settings domain contract rather than on another feature plugin. -The section owner contributes a `configurable` tab that declares the existing nested `settings.plugin.item` list. Configuration cards keep their namespace bindings, draft state, validation, and writes unchanged. `@deepseek-ai/dsh-client-ui-plugins` contributes an `all` tab to `settings.plugins.tab`; its Host Loader observer, generated Remote namespace, DTO, search semantics, and read-only disclosure cards remain unchanged. +The section owner contributes a `configurable` tab that declares the existing nested `settings.plugin.item` list. Configuration cards keep their namespace bindings, draft state, validation, and writes unchanged. `@deepseek-ai/dsh-client-ui-plugins` contributes an `all` tab to `settings.plugins.tab`; its Host Loader observer, generated Remote namespace, DTO, and search semantics remain unchanged. Disabled inventory entries omit the redundant unmounted runtime state from summaries and details, while enabled entries continue to expose their Cordis phase. The first ordered tab is selected by default. A tab mounts only when first selected and then remains mounted but hidden while the Plugins section stays mounted. This delays the inventory RPC until the user opens **Plugin list** and preserves drafts, search text, disclosure state, and the fetched snapshot while switching tabs. Closing Settings unmounts the section, so reopening it obtains a fresh inventory snapshot when that tab is selected again. diff --git a/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.zh.md b/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.zh.md index 887ca20bc0..00feb44f7b 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.zh.md @@ -12,7 +12,7 @@ Status: implemented `@deepseek-ai/dsh-client-ui-plugin-config` 拥有唯一一个 id 为 `plugins` 的 `settings.section` 贡献。它渲染共享标题和紧凑标签栏,声明根级列表 slot `settings.plugins.tab`,并把该记录中的 id、order 与跟随语言的 label 投影成标签页。该 slot 的规范类型位于 `ui-settings`,因此标签页贡献方依赖设置领域约定,而不是依赖另一个功能插件。 -分区拥有方贡献 `configurable` 标签页,由它声明既有的嵌套 `settings.plugin.item` 列表。配置卡片原有的命名空间绑定、草稿状态、校验与写入均保持不变。`@deepseek-ai/dsh-client-ui-plugins` 向 `settings.plugins.tab` 贡献 `all` 标签页;它的 Host Loader 观察器、生成的 Remote 命名空间、DTO、搜索语义与只读折叠卡片均保持不变。 +分区拥有方贡献 `configurable` 标签页,由它声明既有的嵌套 `settings.plugin.item` 列表。配置卡片原有的命名空间绑定、草稿状态、校验与写入均保持不变。`@deepseek-ai/dsh-client-ui-plugins` 向 `settings.plugins.tab` 贡献 `all` 标签页;它的 Host Loader 观察器、生成的 Remote 命名空间、DTO 与搜索语义保持不变。已停用的清单条目会在摘要和详情中省略重复的“未挂载”运行状态,已启用条目仍显示其 Cordis 阶段。 默认选择顺序中的第一个标签页。某个标签页只有首次被选择时才挂载,之后在“插件”分区保持挂载期间只隐藏而不卸载。这样会把清单 RPC 延迟到用户打开**插件列表**时,并在切换标签页时保留草稿、搜索文本、折叠状态和已读取的快照。关闭 Settings 会卸载该分区,因此再次打开后,重新选择该标签页时会取得新的清单快照。 diff --git a/packages/client/ui-plugin-config/src/client/PluginConfigSection.module.css b/packages/client/ui-plugin-config/src/client/PluginConfigSection.module.css index f5a162eee8..5760403c4a 100644 --- a/packages/client/ui-plugin-config/src/client/PluginConfigSection.module.css +++ b/packages/client/ui-plugin-config/src/client/PluginConfigSection.module.css @@ -58,7 +58,9 @@ } .tab:focus-visible { - outline: none; + outline: 2px solid var(--dsw-alias-state-business-primary); + outline-offset: 2px; + border-radius: 2px; color: var(--dsw-alias-label-primary); } diff --git a/packages/client/ui-plugin-config/src/client/PluginConfigSection.tsx b/packages/client/ui-plugin-config/src/client/PluginConfigSection.tsx index a75816b92e..592e495488 100644 --- a/packages/client/ui-plugin-config/src/client/PluginConfigSection.tsx +++ b/packages/client/ui-plugin-config/src/client/PluginConfigSection.tsx @@ -1,6 +1,6 @@ /** Plugins settings section: localized tabs around feature-owned pages. */ -import { useEffect, useId, useState } from 'react' +import { useEffect, useId, useRef, useState } from 'react' import type { HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, } from '@deepseek-ai/dsh-client-ui-slots' @@ -32,6 +32,7 @@ export type PluginConfigSectionProps = /** Render one Plugins page whose contents arrive from feature-owned tabs. */ export function PluginConfigSection({ t, renderSlot, useTabs }: PluginConfigSectionProps) { const tabsId = useId() + const tabRefs = useRef>([]) const rows = useTabs(value => value) const [activeId, setActiveId] = useState() const [visitedIds, setVisitedIds] = useState>(() => new Set()) @@ -55,11 +56,12 @@ export function PluginConfigSection({ t, renderSlot, useTabs }: PluginConfigSect {rows.length === 0 ?

    {t('empty')}

    : ( <>
    - {rows.map((row) => { + {rows.map((row, index) => { const selected = row.id === active return ( diff --git a/packages/client/ui-plugin-config/tests/section.client.spec.tsx b/packages/client/ui-plugin-config/tests/section.client.spec.tsx index e00fd7d448..1191fc3aea 100644 --- a/packages/client/ui-plugin-config/tests/section.client.spec.tsx +++ b/packages/client/ui-plugin-config/tests/section.client.spec.tsx @@ -117,6 +117,38 @@ describe('PluginConfigSection', () => { expect(screen.getByRole('heading', { name: en.title })).toBeTruthy() expect(screen.getByText(en.intro)).toBeTruthy() }) + + it('moves focus and selection with standard horizontal tab keys', () => { + renderSection([ + { id: 'configurable', order: 0, label: en.configurableTab }, + { id: 'all', order: 10, label: 'Plugin list' }, + { id: 'diagnostics', order: 20, label: 'Diagnostics' }, + ]) + + const configurable = screen.getByRole('tab', { name: en.configurableTab }) + const all = screen.getByRole('tab', { name: 'Plugin list' }) + const diagnostics = screen.getByRole('tab', { name: 'Diagnostics' }) + expect(configurable.getAttribute('tabindex')).toBe('0') + expect(all.getAttribute('tabindex')).toBe('-1') + + configurable.focus() + fireEvent.keyDown(configurable, { key: 'ArrowRight' }) + expect(document.activeElement).toBe(all) + expect(all.getAttribute('aria-selected')).toBe('true') + + fireEvent.keyDown(all, { key: 'End' }) + expect(document.activeElement).toBe(diagnostics) + fireEvent.keyDown(diagnostics, { key: 'ArrowRight' }) + expect(document.activeElement).toBe(configurable) + fireEvent.keyDown(configurable, { key: 'ArrowLeft' }) + expect(document.activeElement).toBe(diagnostics) + fireEvent.keyDown(diagnostics, { key: 'Home' }) + expect(document.activeElement).toBe(configurable) + + fireEvent.keyDown(configurable, { key: 'Escape' }) + expect(document.activeElement).toBe(configurable) + expect(configurable.getAttribute('aria-selected')).toBe('true') + }) }) describe('ConfigurablePluginsTab', () => { From ba1b0e15fc75c2a6dffa047817a6c0c2c328e1e0 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 12 Aug 2026 16:37:11 +0800 Subject: [PATCH 04/13] ci: exempt only push from concurrency cancellation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two self-hosted standby drills each run their complete unsharded aggregate with one gate worker, which takes longer than the interval between master merges, so unconditional cancel-in-progress supersedes a drill before it reaches a verdict and the lane yields no readiness evidence for the failover runbook to point a responder at. Exempt push and nothing else. This has to be decided at workflow level: cancellation applies to the whole superseded run, so a job-level concurrency group cannot exempt its job. The negated form is load-bearing — naming pull_request alone would also stop cancelling workflow_dispatch, and each runner benchmark fans out to twelve larger runners for up to fifteen minutes in this same group on master, so a re-dispatch would queue ahead of a drill instead of replacing a stale measurement. It does not promise every push run finishes: a newer pending run still displaces an older one, only that the lanes periodically reach a verdict. A master push carries only wine-apt-cache and the two drills; every other job is pull-request-gated, workflow_dispatch-gated, or if: false. The spec pins that set and classifies by exact condition, since a negated event test mentions the event it excludes. --- .../2026-07-26-ci-failover-runbook.i18n.yaml | 4 +- .../process/2026-07-26-ci-failover-runbook.md | 4 ++ .../2026-07-26-ci-failover-runbook.zh.md | 4 ++ .github/workflows/ci.yml | 14 +++- scripts/ci-workflow.spec.ts | 65 +++++++++++++++++++ 5 files changed, 88 insertions(+), 3 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index 8615d248fc..47fb2a0572 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md -2026-07-26-ci-failover-runbook.md: 47901844f4ec581dff8500cc429c54a076b7642b -2026-07-26-ci-failover-runbook.zh.md: 88a793e144a4d06718ffe323a94f8e81f8e62b82 +2026-07-26-ci-failover-runbook.md: 6f2b4bfba9255549b0887aab9061a6513cb3a68f +2026-07-26-ci-failover-runbook.zh.md: 7bb1a025a71eb690d2d90fcdbddbdd56355786fc diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index 47901844f4..6f2b4bfba9 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -12,6 +12,10 @@ The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.ym Each of the three required Linux worker jobs, the independent native Windows job, and the `all checks passed` verdict job — which would otherwise stay queued on the failed pool even after every worker passed — resolves its runner pool through the `DSH_CI_FAILOVER` repository variable. Unset (normal), they run on the hosted enterprise pools. Set to `selfhosted` by any repository writer, all five retarget onto the in-house self-hosted pools: the Linux jobs and verdict onto the `vm-backup` pool, coverage and snapshot concurrency drop to shared-VM bounds, and the hosted-path pnpm cache restores are skipped; the native Windows job onto the `dsh-win-ci` pool. The switch is writer-manageable repository state, not a merge, so it works while every check is red. The in-house pools' readiness is continuously re-proven by the `serial / linux (self-hosted standby)` and `serial / windows (self-hosted standby)` lanes, which run the complete unsharded aggregates on every master push. +`ci.yml` exempts exactly one event from `cancel-in-progress` (`${{ github.event_name != 'push' }}`), so a superseded pull-request run is still cancelled while a push run is left to run. Each drill runs its complete unsharded aggregate with one gate worker, which takes longer than the interval between master merges; under unconditional cancellation a drill is superseded before reaching a verdict and the lane yields no readiness evidence for a responder to check. This is not a guarantee that every push run finishes: GitHub keeps a single pending entry per group, so a newer pending run displaces an older one, and during busy periods intermediate push runs still end as `cancelled`. What the carve-out buys is that the lane periodically reaches a verdict at all, which is what makes it usable as evidence. + +The decision belongs at workflow level because cancellation applies to the whole superseded run: a job-level `concurrency` group does not exempt its job. The negated form is load-bearing rather than cosmetic: naming `pull_request` alone would also stop cancelling `workflow_dispatch`, and each runner benchmark fans out to twelve larger runners for up to fifteen minutes inside this same group on master, so a re-dispatch would queue ahead of a drill instead of replacing a stale measurement. What bounds the cost is that a master push carries only `wine-apt-cache` and these two drills; every other job is pull-request-gated, `workflow_dispatch`-gated, or `if: false`, and `scripts/ci-workflow.spec.ts` pins that set — classifying by exact condition, since a negated event test mentions the event it excludes — so a new push-reachable job cannot quietly start accumulating uncancelled runs. + ### What the in-house pool is `vm-backup`: one 64-core VM, six always-on systemd-managed runner instances. Its image must preinstall Playwright Chromium's Linux system packages; CI downloads the lockfile-selected browser but never runs `apt` on this persistent shared host. Check the latest `serial / linux (self-hosted standby)` run before switching: its aggregate includes browser replay, so a green standby verifies both ordinary capacity and this browser prerequisite. diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index 88a793e144..7bb1a025a7 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -12,6 +12,10 @@ Status: implemented 三个必需的 Linux 工作作业、独立的原生 Windows 作业,以及 `all checks passed` 判定作业(若不随切换,即使全部工作作业通过,它仍会滞留在故障池的队列中)——各自通过仓库变量 `DSH_CI_FAILOVER` 解析运行器池。变量不存在(正常)时它们运行在托管企业池上;由任何具备写权限的协作者设为 `selfhosted` 时,五个作业全部切换到公司自有的自托管池:Linux 作业与判定作业切到 `vm-backup` 池,覆盖率与快照的并发降到共享虚拟机上限,并跳过托管路径的 pnpm 缓存恢复;原生 Windows 作业切到 `dsh-win-ci` 池。这个开关是写者可管理的仓库状态而非一次合并,因此在所有检查都是红色时仍然有效。自有池的就绪状态由 `serial / linux (self-hosted standby)` 与 `serial / windows (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。 +`ci.yml` 只豁免一个事件不做取消(`${{ github.event_name != 'push' }}`),因此被取代的拉取请求运行仍会取消,而推送运行则留待执行。每次演练以单门禁工作进程执行完整的未分片聚合流程,耗时长于 master 合并的间隔;在无条件取消下,演练会在得出结论前被后续运行取代,该通道无法产出供响应者查看的就绪证据。这并不保证每次推送运行都能跑完:GitHub 每个组只保留一个待运行条目,更新的待运行条目会顶掉更早的,繁忙时段中间的推送运行仍会以 `cancelled` 结束。这项豁免换来的是该通道**周期性**地得出结论,而这正是它能作为证据的前提。 + +这个决定必须放在工作流级:取消作用于被取代的整个运行,作业级 `concurrency` 组并不能豁免其所属作业。采用否定式写法而非仅指名 `pull_request`,是有实质作用的:后者会连 `workflow_dispatch` 一起停止取消,而每次运行器基准测试会在 master 上的同一并发组内同时占用 12 台大规格运行器、最长 15 分钟,届时重复派发会排在演练之前,而不是替换掉已过时的测量。成本之所以可控,是因为一次 master 推送只承载 `wine-apt-cache` 和这两条演练;其余作业都受拉取请求门控、`workflow_dispatch` 门控或 `if: false`,并且 `scripts/ci-workflow.spec.ts` 会锁定这个集合——按条件精确匹配,因为否定式事件判断会包含它所排除的事件名——使新的推送可达作业无法悄悄开始累积未取消的运行。 + ### 自有池是什么 `vm-backup`:一台 64 核虚拟机,6 个常驻 systemd 管理的运行器实例。其镜像必须预装 Playwright Chromium 的 Linux 系统软件包;CI 会下载锁文件选定的浏览器,但绝不在这台持久化共享主机上运行 `apt`。切换前先看 `serial / linux (self-hosted standby)` 最近一次运行:其聚合流程包含浏览器回放,因此绿色热备同时验证常规容量和这项浏览器先决条件。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd3bbb4544..c3549c343a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,9 +15,21 @@ on: - larger-runner-benchmark - consolidated-runner-benchmark +# Cancel a superseded run on every event EXCEPT push. A push run carries the two +# self-hosted standby drills, which take longer than the interval between master +# merges, so cancelling supersedes a drill before it reaches a verdict and the +# lane yields no readiness evidence. Must be decided here: cancellation applies +# to the whole superseded run, so a job-level group cannot exempt its job. +# Negated rather than `== 'pull_request'` so workflow_dispatch keeps cancelling: +# a re-dispatched runner benchmark holds up to 12 larger runners for 15 minutes +# and shares this group with the drills on master, so queueing it would delay +# them. This does not promise every push run finishes (a newer pending run still +# displaces an older one) — only that the lanes periodically reach a verdict. +# Rationale and the bound on this carve-out: +# .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name != 'push' }} permissions: contents: read diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 2642f234c4..06fb9c2a27 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -84,6 +84,71 @@ describe('CI workflow', () => { expect(aggregate.needs).not.toContain('serial-windows') }) + it('leaves push runs uncancelled, so the self-hosted standby drills reach a verdict', () => { + const workflow = loadWorkflow('.github/workflows/ci.yml') + if (!isRecord(workflow.jobs) || !isRecord(workflow.concurrency)) { + throw new TypeError('CI workflow must define jobs and a workflow-level concurrency block') + } + + // Cancellation applies to the whole superseded RUN, so this has to be + // decided at workflow level and gated on the event: a job-level group + // cannot exempt its job from its run being cancelled. Only push is exempt — + // a drill takes longer than the interval between master merges. The negated + // form is load-bearing: `== 'pull_request'` would also stop cancelling + // workflow_dispatch, and a re-dispatched runner benchmark holds up to 12 + // larger runners for 15 minutes in this same group on master. + expect(workflow.concurrency['cancel-in-progress']).toBe("${{ github.event_name != 'push' }}") + + // Neither drill may re-introduce a job-level group: it would not help, and + // it would imply the run-scoped cancellation had been solved locally. + for (const name of ['serial-linux-selfhosted', 'serial-windows']) { + const job = workflow.jobs[name] + if (!isRecord(job)) throw new TypeError(`${name} must be defined`) + expect(job.concurrency).toBeUndefined() + // Both stay master-push-only; that is what makes the push carve-out safe. + expect(job.if).toBe("github.event_name == 'push' && github.ref == 'refs/heads/master'") + } + + // What bounds the cost of never cancelling a push run: a master push may + // only carry the cache seeder and the two drills. Any job reachable on push + // would start accumulating uncancelled runs, so the set is pinned here. + // + // Classification is an exact allowlist of the conditions in use, not a + // substring match: `github.event_name != 'pull_request'` mentions + // `pull_request` yet IS push-reachable, so matching on the event name alone + // would silently misclassify it as gated. + const NOT_PUSH_REACHABLE = new Set([ + "github.event_name == 'pull_request'", + "always() && github.event_name == 'pull_request'", + "github.event_name == 'workflow_dispatch' && inputs.suite == 'larger-runner-benchmark'", + "github.event_name == 'workflow_dispatch' && inputs.suite == 'consolidated-runner-benchmark'", + ]) + const pushReachable = Object.entries(workflow.jobs) + .filter(([, job]) => { + if (!isRecord(job)) return false + if (job.if === undefined) return true // unconditional: runs on every event + if (job.if === false) return false // `if: false` parses as a boolean + if (typeof job.if !== 'string') return true // unrecognized shape: surface it + return !NOT_PUSH_REACHABLE.has(job.if.trim()) + }) + .map(([name]) => name) + .sort() + expect(pushReachable).toEqual(['serial-linux-selfhosted', 'serial-windows', 'wine-apt-cache']) + + // Why workflow_dispatch must keep cancelling: each benchmark fans out to a + // dozen larger runners at once, in this same group on master. If it stopped + // cancelling, a re-dispatch would queue ahead of a drill instead of + // replacing the stale measurement. + for (const name of ['larger-runner-benchmark', 'consolidated-runner-benchmark']) { + const job = workflow.jobs[name] + if (!isRecord(job) || !isRecord(job.strategy)) { + throw new TypeError(`${name} must define a matrix strategy`) + } + expect(job.strategy['max-parallel']).toBe(12) + expect(job['timeout-minutes']).toBe(15) + } + }) + it('keeps supported LSP source under native Windows coverage', () => { const config = readFileSync(resolve(root, 'vitest.config.ts'), 'utf8') From 1caca08301a0b2a96bad3e6831238847f803edcc Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 12 Aug 2026 18:16:24 +0800 Subject: [PATCH 05/13] docs: narrow the push-exemption guarantee, unbreak two static gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cancel-in-progress is evaluated against the newly triggered run, so exempting push means one master merge does not cancel the drill still running from the previous one — not that a drill always finishes. A benchmark dispatched on master shares the group and does cancel a mid-flight drill. Record that bound in the runbook and drop the overstated wording from the workflow comment and the spec name. Also repair two gates that fail on master and block every pull request: the telemetry note referenced an SDK proposal deleted in 408721954a, and the ui-settings-general README pair carried stale recorded hashes after both sides were updated together in aa1ec02bc6. --- .../2026-08-10-telemetry-default-off.i18n.yaml | 4 ++-- .../feature/2026-08-10-telemetry-default-off.md | 2 +- .../2026-08-10-telemetry-default-off.zh.md | 2 +- .../2026-07-26-ci-failover-runbook.i18n.yaml | 4 ++-- .../process/2026-07-26-ci-failover-runbook.md | 4 +++- .../process/2026-07-26-ci-failover-runbook.zh.md | 4 +++- .github/workflows/ci.yml | 6 +++--- .../client/ui-settings-general/README.i18n.yaml | 4 ++-- scripts/ci-workflow.spec.ts | 16 +++++++++------- 9 files changed, 26 insertions(+), 20 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml index 7c4995a88d..5fbc0483e4 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md -2026-08-10-telemetry-default-off.md: 4bda346c2b05a94106eb5658c3ee558a4b32407f -2026-08-10-telemetry-default-off.zh.md: 706f2c18fbbf226e0357fa99bf3fd61c39fce08a +2026-08-10-telemetry-default-off.md: 3b9cd4bc3e9c98ee96ae036a0e981aa585a02403 +2026-08-10-telemetry-default-off.zh.md: 18f83869243b3d5c65278e654f4ac10e9bfe7d30 diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md index 4bda346c2b..3b9cd4bc3e 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md @@ -12,7 +12,7 @@ DeepSeek Harness has two outbound telemetry feeds. During internal testing, the Both feeds use `DSH_TELEMETRY_MODE` as their positive consent setting. Unset and empty values resolve to `DISABLED`. `@deepseek-ai/dsh-session-telemetry-otel` also resolves an omitted `mode` to `DISABLED`, which constructs no OTel provider, processor, or exporter and leaves feedback in the local session log. The shared dsh base keeps the backend row mounted so disabled feedback can still explain that nothing was shared. A deployment opts into Session Log sharing through `FULL` or `FEEDBACK_ONLY`; only `FULL` also permits dsh-sdk launcher reporting. Any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative pre-load hard opt-out. The [default-mount decision](2026-07-31-web-telemetry-default-mount.md) continues to own the endpoint, batching cadence, and exit-drain settings. -The dsh-sdk launcher reads the same variable without parsing `cordis.yml` or booting Cordis. `FULL` permits reporting; `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny it. Consent is frozen from the launching environment before the command runs, because `dsh-sdk start` loads a project `.env` and project code can mutate `process.env`: resolving afterwards would let a project grant reporting of its own configuration, which the [configuration source ownership decision](../architecture/2026-08-04-configuration-source-ownership.md) denies for the whole `DSH_*` namespace. An unsupported mode denies rather than throwing at that boundary, since telemetry may never change a command's result. This rule supersedes only the default-on launcher consent in the [SDK follow-up proposal](../../proposed/feature/2026-07-17-sdk-follow-up-capabilities.md); its other capabilities remain proposed. +The dsh-sdk launcher reads the same variable without parsing `cordis.yml` or booting Cordis. `FULL` permits reporting; `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny it. Consent is frozen from the launching environment before the command runs, because `dsh-sdk start` loads a project `.env` and project code can mutate `process.env`: resolving afterwards would let a project grant reporting of its own configuration, which the [configuration source ownership decision](../architecture/2026-08-04-configuration-source-ownership.md) denies for the whole `DSH_*` namespace. An unsupported mode denies rather than throwing at that boundary, since telemetry may never change a command's result. The versioned Web welcome notice states that Session Log upload is off by default, names `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` and `DSH_TELEMETRY_MODE=FULL` as the two opt-in choices, and discloses that `FULL` also enables dsh-sdk command telemetry. Its version changes with that material privacy statement so every profile acknowledges the current copy. diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md index 706f2c18fb..18f8386924 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md @@ -12,7 +12,7 @@ DeepSeek Harness 有两路出站遥测数据流。在内测阶段,共享基础 两路数据流都使用 `DSH_TELEMETRY_MODE` 作为正向授权配置。未设置和空值都解析为 `DISABLED`。`@deepseek-ai/dsh-session-telemetry-otel` 也将省略的 `mode` 解析为 `DISABLED`;该模式不构造 OTel 提供方、处理器或导出器,并将反馈留在本地会话日志中。dsh 共享基础配置继续挂载后端配置行,使禁用模式仍可在记录反馈时说明没有共享任何内容。部署方通过 `FULL` 或 `FEEDBACK_ONLY` 显式启用 Session Log 共享;只有 `FULL` 还允许 dsh-sdk 启动器上报。任何非空 `DSH_TELEMETRY_DISABLED` 仍是具有最高优先级的加载前硬性退出开关。[默认挂载决策](2026-07-31-web-telemetry-default-mount.md)继续负责 endpoint、批处理节奏和退出排空设置。 -dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cordis。`FULL` 允许上报;`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝。授权在命令执行前从启动环境冻结:`dsh-sdk start` 会加载项目 `.env`,项目代码也能修改 `process.env`,若在执行后解析,项目便能自行授权上报其自身配置,而[配置来源所有权决策](../architecture/2026-08-04-configuration-source-ownership.md)对整个 `DSH_*` 命名空间禁止这种行为。在该边界上,不受支持的模式按拒绝处理而非抛出,因为遥测不得改变命令结果。此规则仅取代 [SDK 后续功能提案](../../proposed/feature/2026-07-17-sdk-follow-up-capabilities.md)中启动器默认允许上报的规则;其余能力仍处于提案状态。 +dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cordis。`FULL` 允许上报;`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝。授权在命令执行前从启动环境冻结:`dsh-sdk start` 会加载项目 `.env`,项目代码也能修改 `process.env`,若在执行后解析,项目便能自行授权上报其自身配置,而[配置来源所有权决策](../architecture/2026-08-04-configuration-source-ownership.md)对整个 `DSH_*` 命名空间禁止这种行为。在该边界上,不受支持的模式按拒绝处理而非抛出,因为遥测不得改变命令结果。 带版本的 Web 欢迎通知说明会话日志上传默认关闭,将 `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 和 `DSH_TELEMETRY_MODE=FULL` 列为两种显式启用选项,并披露 `FULL` 同时会启用 dsh-sdk 命令遥测。其版本随这项重要的隐私声明一同变更,使每个 profile 都确认当前文案。 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index 47fb2a0572..ed819bc19e 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md -2026-07-26-ci-failover-runbook.md: 6f2b4bfba9255549b0887aab9061a6513cb3a68f -2026-07-26-ci-failover-runbook.zh.md: 7bb1a025a71eb690d2d90fcdbddbdd56355786fc +2026-07-26-ci-failover-runbook.md: 90ef2905bec86911697e552c0c1eac96ea0d3a18 +2026-07-26-ci-failover-runbook.zh.md: 5c8fd0e90ae31e3622ae6b49217591a54b84c351 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index 6f2b4bfba9..90ef2905be 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -12,7 +12,9 @@ The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.ym Each of the three required Linux worker jobs, the independent native Windows job, and the `all checks passed` verdict job — which would otherwise stay queued on the failed pool even after every worker passed — resolves its runner pool through the `DSH_CI_FAILOVER` repository variable. Unset (normal), they run on the hosted enterprise pools. Set to `selfhosted` by any repository writer, all five retarget onto the in-house self-hosted pools: the Linux jobs and verdict onto the `vm-backup` pool, coverage and snapshot concurrency drop to shared-VM bounds, and the hosted-path pnpm cache restores are skipped; the native Windows job onto the `dsh-win-ci` pool. The switch is writer-manageable repository state, not a merge, so it works while every check is red. The in-house pools' readiness is continuously re-proven by the `serial / linux (self-hosted standby)` and `serial / windows (self-hosted standby)` lanes, which run the complete unsharded aggregates on every master push. -`ci.yml` exempts exactly one event from `cancel-in-progress` (`${{ github.event_name != 'push' }}`), so a superseded pull-request run is still cancelled while a push run is left to run. Each drill runs its complete unsharded aggregate with one gate worker, which takes longer than the interval between master merges; under unconditional cancellation a drill is superseded before reaching a verdict and the lane yields no readiness evidence for a responder to check. This is not a guarantee that every push run finishes: GitHub keeps a single pending entry per group, so a newer pending run displaces an older one, and during busy periods intermediate push runs still end as `cancelled`. What the carve-out buys is that the lane periodically reaches a verdict at all, which is what makes it usable as evidence. +`ci.yml` exempts exactly one event from `cancel-in-progress` (`${{ github.event_name != 'push' }}`), so one master push does not cancel the drill still running from the previous one. Each drill runs its complete unsharded aggregate with one gate worker, which takes longer than the interval between master merges; under unconditional cancellation a drill is superseded before reaching a verdict and the lane yields no readiness evidence for a responder to check. + +The exemption is narrower than "a drill always finishes", in two ways. GitHub keeps a single pending entry per group, so a newer pending run displaces an older one and intermediate push runs still end as `cancelled` during busy periods. And the expression is evaluated against the *newly triggered* run, so a run whose own event is not `push` — a benchmark dispatched on master, sharing the group `CI-` — evaluates to `true` and does cancel a drill that is mid-flight. That is a rare manual action and the next master push restores the evidence, so it does not warrant further mechanism. What the carve-out buys is that the lane periodically reaches a verdict at all, which is what makes it usable as evidence. The decision belongs at workflow level because cancellation applies to the whole superseded run: a job-level `concurrency` group does not exempt its job. The negated form is load-bearing rather than cosmetic: naming `pull_request` alone would also stop cancelling `workflow_dispatch`, and each runner benchmark fans out to twelve larger runners for up to fifteen minutes inside this same group on master, so a re-dispatch would queue ahead of a drill instead of replacing a stale measurement. What bounds the cost is that a master push carries only `wine-apt-cache` and these two drills; every other job is pull-request-gated, `workflow_dispatch`-gated, or `if: false`, and `scripts/ci-workflow.spec.ts` pins that set — classifying by exact condition, since a negated event test mentions the event it excludes — so a new push-reachable job cannot quietly start accumulating uncancelled runs. diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index 7bb1a025a7..5c8fd0e90a 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -12,7 +12,9 @@ Status: implemented 三个必需的 Linux 工作作业、独立的原生 Windows 作业,以及 `all checks passed` 判定作业(若不随切换,即使全部工作作业通过,它仍会滞留在故障池的队列中)——各自通过仓库变量 `DSH_CI_FAILOVER` 解析运行器池。变量不存在(正常)时它们运行在托管企业池上;由任何具备写权限的协作者设为 `selfhosted` 时,五个作业全部切换到公司自有的自托管池:Linux 作业与判定作业切到 `vm-backup` 池,覆盖率与快照的并发降到共享虚拟机上限,并跳过托管路径的 pnpm 缓存恢复;原生 Windows 作业切到 `dsh-win-ci` 池。这个开关是写者可管理的仓库状态而非一次合并,因此在所有检查都是红色时仍然有效。自有池的就绪状态由 `serial / linux (self-hosted standby)` 与 `serial / windows (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。 -`ci.yml` 只豁免一个事件不做取消(`${{ github.event_name != 'push' }}`),因此被取代的拉取请求运行仍会取消,而推送运行则留待执行。每次演练以单门禁工作进程执行完整的未分片聚合流程,耗时长于 master 合并的间隔;在无条件取消下,演练会在得出结论前被后续运行取代,该通道无法产出供响应者查看的就绪证据。这并不保证每次推送运行都能跑完:GitHub 每个组只保留一个待运行条目,更新的待运行条目会顶掉更早的,繁忙时段中间的推送运行仍会以 `cancelled` 结束。这项豁免换来的是该通道**周期性**地得出结论,而这正是它能作为证据的前提。 +`ci.yml` 只豁免一个事件不做取消(`${{ github.event_name != 'push' }}`),因此一次 master 推送不会取消上一次推送留下的、仍在运行的演练。每次演练以单门禁工作进程执行完整的未分片聚合流程,耗时长于 master 合并的间隔;在无条件取消下,演练会在得出结论前被后续运行取代,该通道无法产出供响应者查看的就绪证据。 + +这项豁免比「演练总能跑完」要窄,有两点限制。其一,GitHub 每个组只保留一个待运行条目,更新的待运行条目会顶掉更早的,繁忙时段中间的推送运行仍会以 `cancelled` 结束。其二,该表达式是针对**新触发的运行**求值的,因此自身事件不是 `push` 的运行——例如在 master 上派发的基准测试,与演练共用 `CI-` 组——求值为 `true`,会取消正在运行中的演练。这属于罕见的手动操作,且下一次 master 推送即可恢复证据,因此不值得为它再加机制。这项豁免换来的是该通道**周期性**地得出结论,而这正是它能作为证据的前提。 这个决定必须放在工作流级:取消作用于被取代的整个运行,作业级 `concurrency` 组并不能豁免其所属作业。采用否定式写法而非仅指名 `pull_request`,是有实质作用的:后者会连 `workflow_dispatch` 一起停止取消,而每次运行器基准测试会在 master 上的同一并发组内同时占用 12 台大规格运行器、最长 15 分钟,届时重复派发会排在演练之前,而不是替换掉已过时的测量。成本之所以可控,是因为一次 master 推送只承载 `wine-apt-cache` 和这两条演练;其余作业都受拉取请求门控、`workflow_dispatch` 门控或 `if: false`,并且 `scripts/ci-workflow.spec.ts` 会锁定这个集合——按条件精确匹配,因为否定式事件判断会包含它所排除的事件名——使新的推送可达作业无法悄悄开始累积未取消的运行。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7100da5b1..08dbcc44f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,9 +23,9 @@ on: # Negated rather than `== 'pull_request'` so workflow_dispatch keeps cancelling: # a re-dispatched runner benchmark holds up to 12 larger runners for 15 minutes # and shares this group with the drills on master, so queueing it would delay -# them. This does not promise every push run finishes (a newer pending run still -# displaces an older one) — only that the lanes periodically reach a verdict. -# Rationale and the bound on this carve-out: +# them. The guarantee is narrow — evaluated against the newly triggered run, so a +# dispatch on master still cancels a mid-flight drill, and a newer pending push +# displaces an older one. Bounds and rationale: # .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 961fb0de13..3a0fae8d41 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md -README.md: d8578a7fbd451c1b7ec54dadeb3d391d597cc18e -README.zh.md: 246c04193e79f46f1e8035c6a40f55a20f1d0c26 +README.md: d02230d281482d03545a7dd9bb06fd5f1085d017 +README.zh.md: 9e2011902227c8d656f57813d4ecec92147d0f6f diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 3749601e59..d21dbc4b49 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -84,7 +84,7 @@ describe('CI workflow', () => { expect(aggregate.needs).not.toContain('serial-windows') }) - it('leaves push runs uncancelled, so the self-hosted standby drills reach a verdict', () => { + it('exempts push from cancellation, so one master merge does not kill the running drill', () => { const workflow = loadWorkflow('.github/workflows/ci.yml') if (!isRecord(workflow.jobs) || !isRecord(workflow.concurrency)) { throw new TypeError('CI workflow must define jobs and a workflow-level concurrency block') @@ -96,11 +96,13 @@ describe('CI workflow', () => { // a drill takes longer than the interval between master merges. The negated // form is load-bearing: `== 'pull_request'` would also stop cancelling // workflow_dispatch, and a re-dispatched runner benchmark holds up to 12 - // larger runners for 15 minutes in this same group on master. + // larger runners for 15 minutes in this same group on master. The + // expression is evaluated against the NEWLY TRIGGERED run, so a dispatch on + // master still cancels a mid-flight drill; the runbook records that bound. expect(workflow.concurrency['cancel-in-progress']).toBe("${{ github.event_name != 'push' }}") - // Neither drill may re-introduce a job-level group: it would not help, and - // it would imply the run-scoped cancellation had been solved locally. + // Neither drill may carry a job-level group: it would not exempt the job + // from run-scoped cancellation. for (const name of ['serial-linux-selfhosted', 'serial-windows']) { const job = workflow.jobs[name] if (!isRecord(job)) throw new TypeError(`${name} must be defined`) @@ -109,9 +111,9 @@ describe('CI workflow', () => { expect(job.if).toBe("github.event_name == 'push' && github.ref == 'refs/heads/master'") } - // What bounds the cost of never cancelling a push run: a master push may - // only carry the cache seeder and the two drills. Any job reachable on push - // would start accumulating uncancelled runs, so the set is pinned here. + // What bounds the cost of exempting push: a master push may only carry the + // cache seeder and the two drills. Any job reachable on push would start + // accumulating uncancelled runs, so the set is pinned here. // // Classification is an exact allowlist of the conditions in use, not a // substring match: `github.event_name != 'pull_request'` mentions From fb0fae65a8be25d1e3dca2fac56e96bc76ea1fe0 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 12 Aug 2026 18:51:38 +0800 Subject: [PATCH 06/13] test(ci-workflow): name the cancellation assertion literally Replace 'kill' with 'cancel' in the test name to match the mechanism and the runbook wording. --- scripts/ci-workflow.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index d21dbc4b49..1073b35018 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -84,7 +84,7 @@ describe('CI workflow', () => { expect(aggregate.needs).not.toContain('serial-windows') }) - it('exempts push from cancellation, so one master merge does not kill the running drill', () => { + it('exempts push from cancellation, so one master merge does not cancel the running drill', () => { const workflow = loadWorkflow('.github/workflows/ci.yml') if (!isRecord(workflow.jobs) || !isRecord(workflow.concurrency)) { throw new TypeError('CI workflow must define jobs and a workflow-level concurrency block') From 5ed98d2da9526d231acf63f53b0b62e506f441ae Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Wed, 12 Aug 2026 03:54:02 -0700 Subject: [PATCH 07/13] fix(docs): restore static gate consistency --- .../feature/2026-08-10-telemetry-default-off.i18n.yaml | 4 ++-- .../implemented/feature/2026-08-10-telemetry-default-off.md | 2 +- .../feature/2026-08-10-telemetry-default-off.zh.md | 2 +- packages/client/ui-settings-general/README.i18n.yaml | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml index 7c4995a88d..fabb554407 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md -2026-08-10-telemetry-default-off.md: 4bda346c2b05a94106eb5658c3ee558a4b32407f -2026-08-10-telemetry-default-off.zh.md: 706f2c18fbbf226e0357fa99bf3fd61c39fce08a +2026-08-10-telemetry-default-off.md: c3e5d9e0b65449f91044f16e7649f4a5ff2f5b61 +2026-08-10-telemetry-default-off.zh.md: 8e2544eb7dee8b9bd6b8a4c81a28a8a03d3404d4 diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md index 4bda346c2b..c3e5d9e0b6 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md @@ -12,7 +12,7 @@ DeepSeek Harness has two outbound telemetry feeds. During internal testing, the Both feeds use `DSH_TELEMETRY_MODE` as their positive consent setting. Unset and empty values resolve to `DISABLED`. `@deepseek-ai/dsh-session-telemetry-otel` also resolves an omitted `mode` to `DISABLED`, which constructs no OTel provider, processor, or exporter and leaves feedback in the local session log. The shared dsh base keeps the backend row mounted so disabled feedback can still explain that nothing was shared. A deployment opts into Session Log sharing through `FULL` or `FEEDBACK_ONLY`; only `FULL` also permits dsh-sdk launcher reporting. Any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative pre-load hard opt-out. The [default-mount decision](2026-07-31-web-telemetry-default-mount.md) continues to own the endpoint, batching cadence, and exit-drain settings. -The dsh-sdk launcher reads the same variable without parsing `cordis.yml` or booting Cordis. `FULL` permits reporting; `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny it. Consent is frozen from the launching environment before the command runs, because `dsh-sdk start` loads a project `.env` and project code can mutate `process.env`: resolving afterwards would let a project grant reporting of its own configuration, which the [configuration source ownership decision](../architecture/2026-08-04-configuration-source-ownership.md) denies for the whole `DSH_*` namespace. An unsupported mode denies rather than throwing at that boundary, since telemetry may never change a command's result. This rule supersedes only the default-on launcher consent in the [SDK follow-up proposal](../../proposed/feature/2026-07-17-sdk-follow-up-capabilities.md); its other capabilities remain proposed. +The dsh-sdk launcher reads the same variable without parsing `cordis.yml` or booting Cordis. `FULL` permits reporting; `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny it. Consent is frozen from the launching environment before the command runs, because `dsh-sdk start` loads a project `.env` and project code can mutate `process.env`: resolving afterwards would let a project grant reporting of its own configuration, which the [configuration source ownership decision](../architecture/2026-08-04-configuration-source-ownership.md) denies for the whole `DSH_*` namespace. An unsupported mode denies rather than throwing at that boundary, since telemetry may never change a command's result. This rule superseded the default-on launcher consent before the launcher and its proposal were deleted by the [SDK project toolchain removal](../simplification/2026-08-11-remove-sdk-project-toolchain.md). The versioned Web welcome notice states that Session Log upload is off by default, names `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` and `DSH_TELEMETRY_MODE=FULL` as the two opt-in choices, and discloses that `FULL` also enables dsh-sdk command telemetry. Its version changes with that material privacy statement so every profile acknowledges the current copy. diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md index 706f2c18fb..8e2544eb7d 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md @@ -12,7 +12,7 @@ DeepSeek Harness 有两路出站遥测数据流。在内测阶段,共享基础 两路数据流都使用 `DSH_TELEMETRY_MODE` 作为正向授权配置。未设置和空值都解析为 `DISABLED`。`@deepseek-ai/dsh-session-telemetry-otel` 也将省略的 `mode` 解析为 `DISABLED`;该模式不构造 OTel 提供方、处理器或导出器,并将反馈留在本地会话日志中。dsh 共享基础配置继续挂载后端配置行,使禁用模式仍可在记录反馈时说明没有共享任何内容。部署方通过 `FULL` 或 `FEEDBACK_ONLY` 显式启用 Session Log 共享;只有 `FULL` 还允许 dsh-sdk 启动器上报。任何非空 `DSH_TELEMETRY_DISABLED` 仍是具有最高优先级的加载前硬性退出开关。[默认挂载决策](2026-07-31-web-telemetry-default-mount.md)继续负责 endpoint、批处理节奏和退出排空设置。 -dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cordis。`FULL` 允许上报;`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝。授权在命令执行前从启动环境冻结:`dsh-sdk start` 会加载项目 `.env`,项目代码也能修改 `process.env`,若在执行后解析,项目便能自行授权上报其自身配置,而[配置来源所有权决策](../architecture/2026-08-04-configuration-source-ownership.md)对整个 `DSH_*` 命名空间禁止这种行为。在该边界上,不受支持的模式按拒绝处理而非抛出,因为遥测不得改变命令结果。此规则仅取代 [SDK 后续功能提案](../../proposed/feature/2026-07-17-sdk-follow-up-capabilities.md)中启动器默认允许上报的规则;其余能力仍处于提案状态。 +dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cordis。`FULL` 允许上报;`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝。授权在命令执行前从启动环境冻结:`dsh-sdk start` 会加载项目 `.env`,项目代码也能修改 `process.env`,若在执行后解析,项目便能自行授权上报其自身配置,而[配置来源所有权决策](../architecture/2026-08-04-configuration-source-ownership.md)对整个 `DSH_*` 命名空间禁止这种行为。在该边界上,不受支持的模式按拒绝处理而非抛出,因为遥测不得改变命令结果。此规则在启动器及其提案被[SDK 项目工具链移除决策](../simplification/2026-08-11-remove-sdk-project-toolchain.md)删除之前,仅取代了启动器默认允许上报的规则。 带版本的 Web 欢迎通知说明会话日志上传默认关闭,将 `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 和 `DSH_TELEMETRY_MODE=FULL` 列为两种显式启用选项,并披露 `FULL` 同时会启用 dsh-sdk 命令遥测。其版本随这项重要的隐私声明一同变更,使每个 profile 都确认当前文案。 diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 961fb0de13..3a0fae8d41 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md -README.md: d8578a7fbd451c1b7ec54dadeb3d391d597cc18e -README.zh.md: 246c04193e79f46f1e8035c6a40f55a20f1d0c26 +README.md: d02230d281482d03545a7dd9bb06fd5f1085d017 +README.zh.md: 9e2011902227c8d656f57813d4ecec92147d0f6f From be693f3bc7143ec6338e20752cf1ccff3362e2c2 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Wed, 12 Aug 2026 04:23:03 -0700 Subject: [PATCH 08/13] test(plugin-inventory): avoid assuming loader sibling order --- .../plugin-inventory/tests/inventory.spec.ts | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/host/plugin-inventory/tests/inventory.spec.ts b/packages/host/plugin-inventory/tests/inventory.spec.ts index e979d34306..6de2943197 100644 --- a/packages/host/plugin-inventory/tests/inventory.spec.ts +++ b/packages/host/plugin-inventory/tests/inventory.spec.ts @@ -52,28 +52,28 @@ describe('PluginInventoryService', () => { }) await ctx.loader.create({ name: 'cordis:active', group: true }) - expect(inventory.list()).toEqual({ - entries: [ - { - entryId: activeId, - moduleName: 'cordis:active', - enabled: true, - fiberPhase: 'active', - }, - { - entryId: pendingId, - moduleName: 'cordis:pending', - enabled: true, - fiberPhase: 'pending', - }, - { - entryId: disabledId, - moduleName: 'cordis:not-installed', - enabled: false, - fiberPhase: null, - }, - ], - }) + const snapshot = inventory.list() + expect(snapshot.entries).toHaveLength(3) + expect(snapshot.entries).toEqual(expect.arrayContaining([ + { + entryId: activeId, + moduleName: 'cordis:active', + enabled: true, + fiberPhase: 'active', + }, + { + entryId: pendingId, + moduleName: 'cordis:pending', + enabled: true, + fiberPhase: 'pending', + }, + { + entryId: disabledId, + moduleName: 'cordis:not-installed', + enabled: false, + fiberPhase: null, + }, + ])) await ctx.loader.update(activeId, { disabled: true }) expect(inventory.list().entries.find(entry => entry.entryId === activeId)).toEqual({ From dabe6207f55603cff4c7da2cee546d97b24357e0 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Wed, 12 Aug 2026 20:26:47 +0800 Subject: [PATCH 09/13] =?UTF-8?q?fix:=20=E5=88=86=E9=A1=B5=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...12-full-session-turn-step-counts.i18n.yaml | 6 + ...026-08-12-full-session-turn-step-counts.md | 38 +++ ...-08-12-full-session-turn-step-counts.zh.md | 38 +++ apps/web/tests/chat-scroll-contract.e2e.ts | 31 +- apps/web/tests/complex-history.perf.ts | 11 +- apps/web/tests/seeded-history.e2e.ts | 6 + .../live-interactions/error-auth.expected.md | 1 + .../stats-paged-history/ui.expected.md | 242 ++++++++++++++++ apps/web/tests/stats-paged-history.e2e.ts | 136 +++++++++ apps/web/tsconfig.json | 1 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 1 + docs/config-catalog.zh.md | 1 + packages/bundle/web-app/cordis.patch.yml | 5 + packages/bundle/web-app/package.json | 1 + .../client/connection/src/client/fixture.ts | 84 ++++++ .../connection/tests/fixture.client.spec.ts | 16 +- .../src/client/sessions/assistant-timing.ts | 23 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 4 +- packages/client/ui-conversation/README.zh.md | 4 +- packages/client/ui-conversation/package.json | 2 + .../src/client/chat/StatsLine.tsx | 23 +- .../tests/chat-stats.client.spec.tsx | 68 ++++- .../tests/gate-branch-tails.client.spec.tsx | 8 +- packages/client/ui-conversation/tsconfig.json | 3 + packages/llm/llm/src/message.ts | 22 +- packages/session/README.i18n.yaml | 4 +- packages/session/README.md | 1 + packages/session/README.zh.md | 1 + .../session/session-stats/README.i18n.yaml | 6 + packages/session/session-stats/README.md | 39 +++ packages/session/session-stats/README.zh.md | 39 +++ packages/session/session-stats/package.json | 62 ++++ packages/session/session-stats/src/client.ts | 10 + packages/session/session-stats/src/index.ts | 29 ++ .../session/session-stats/src/invariant.ts | 35 +++ .../session/session-stats/src/projection.ts | 179 ++++++++++++ packages/session/session-stats/src/types.ts | 46 +++ .../tests/loader-composition.spec.ts | 86 ++++++ .../session-stats/tests/projection.spec.ts | 271 ++++++++++++++++++ packages/session/session-stats/tsconfig.json | 30 ++ pnpm-lock.yaml | 34 +++ .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 2 + tsconfig.host.json | 2 + 46 files changed, 1594 insertions(+), 66 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.zh.md create mode 100644 apps/web/tests/snapshots/stats-paged-history/ui.expected.md create mode 100644 apps/web/tests/stats-paged-history.e2e.ts create mode 100644 packages/session/session-stats/README.i18n.yaml create mode 100644 packages/session/session-stats/README.md create mode 100644 packages/session/session-stats/README.zh.md create mode 100644 packages/session/session-stats/package.json create mode 100644 packages/session/session-stats/src/client.ts create mode 100644 packages/session/session-stats/src/index.ts create mode 100644 packages/session/session-stats/src/invariant.ts create mode 100644 packages/session/session-stats/src/projection.ts create mode 100644 packages/session/session-stats/src/types.ts create mode 100644 packages/session/session-stats/tests/loader-composition.spec.ts create mode 100644 packages/session/session-stats/tests/projection.spec.ts create mode 100644 packages/session/session-stats/tsconfig.json diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.i18n.yaml new file mode 100644 index 0000000000..f50df929a7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.md +2026-08-12-full-session-turn-step-counts.md: 7cea57e429d3ffc2da49c4a1359ee3489663ccc5 +2026-08-12-full-session-turn-step-counts.zh.md: 93e46cfb7ffa4f397b85ee80b26cca9f664d246a diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.md b/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.md new file mode 100644 index 0000000000..7cea57e429 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.md @@ -0,0 +1,38 @@ +# Agent Note: Full-session stats-strip figures through a sessionStats projection + +Status: implemented + +English | [中文](2026-08-12-full-session-turn-step-counts.zh.md) + +## Problem + +The web chat stats strip folded `StatsLine`'s loaded conversation window (`deriveStats` over `chat.legacy.nodes`) for every non-token figure: the "N turns · M steps" counter, the LLM and tool wall times, and the TTFT/throughput averages. History is paged 50 messages at a time, so each 加载更早 (Load earlier) click grew the window and every figure with it — 7 turns · 44 steps became 10 turns · 89 steps after one page, and the LLM duration climbed the same way. The product expectation is whole-session figures independent of how much history a client has loaded. Token accounting in the same strip already had the correct architecture: the durable `tokenUsage` projection. + +## Decision + +A new function plugin `@deepseek-ai/dsh-session-stats` registers a `sessionStats` projection unit on `ctx.sessionProjections`, mounted as a web-app bundle row. The value carries the strip's whole non-token figure set — `{ turns, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens }`, field names mirroring the window fold so the two swap wholesale. `steps` counts `step/end` events and `turns` counts distinct turns carrying at least one (turn numbers are monotonic, so one `lastTurn` slot suffices); `llmMs` sums `step/start` → `assistant/message`; TTFT records the first non-empty delta chunk per step (surviving in-step `llm/retry`, the window `resetForRetry` parity); decode spans first token → assembled message on usage-reporting steps; `toolMs` pairs `tool/call` → `tool/result` by callId with unresolved calls dropped at `turn/end`. The first-token predicate `isTokenDelta` moved to `@deepseek-ai/dsh-llm/message` (the `StreamChunk` vocabulary owner) so the host fold and the client timing index share one implementation; client-runtime re-exports it. Delivery is entirely the existing projection seam — history tail-page block, `session/projection` push frames, list rows — with zero changes to apiproxy, wire schemas, or the client runtime. `StatsLine` reads `useProjection('sessionStats')` and falls back to the window fold when the key is undefined (an assembly without the unit). The client connection fixture mirrors the fold as `sessionStatsOf` under its existing every-composed-key discipline. + +`step/end` — not `assistant/message` — is the counted event, for two correctness reasons found while reviewing the obvious message-counting design: + +1. A max-tokens step appends an empty-content `assistant/message` that exists only to host usage and never reaches the surface; counting messages would count a step the transcript does not show. +2. A cancelled step aborts before its message assembles (no `assistant/message` at all), yet the client synthesizes a visible interrupted assistant node; counting messages would silently drop common cancelled steps. + +`step/end` is appended exactly once per entered step, in the loop's `finally`, so completed, failed, cancelled, and max-tokens steps all land one — and the counter advances at step settlement, the same moment the window fold advanced, so live behavior does not shift. + +## Alternatives considered + +**Count `assistant/message` events.** Rejected for the two correctness defects above (overcounts usage-host messages, undercounts cancelled steps). + +**Count `step/start` events.** Equivalent coverage (it precedes every `step/end`), but the counter would advance when a step begins instead of when it settles — a visible live-behavior change with no benefit; `step/end`'s `finally` placement gives the same completeness. + +**Register the unit in `core/agent-loop` (the event producer).** The loop is the product spine; a UI read model there adds a session-projection dependency to every assembly, against "plugins, not loop changes" and "keep opt-ins out of shipped defaults". + +**Register the unit in `token-meter` (an existing fold over the same events).** Turn/step counting is not token measurement; every projection key lives in the package owning its domain. + +**Fold the full log client-side.** The client holds only the paged window by design; the projection RFC's no-client-folding rule exists exactly so figures survive paging, compaction, and cold reads. + +**Keep wall times, TTFT, and throughput window-scoped.** The first shipped cut did, reading them as "what is on screen"; the same paging complaint immediately applied to the LLM duration, and a strip mixing whole-log counts with window-scoped times reads as one inconsistent figure set. The projection now carries the whole set, with the window fold demoted to the no-unit fallback. + +## Consequences + +The strip shows whole-log figures from the first tail page; paging leaves every group fixed. Defined edge differences from the old window semantics are documented in the package README: a step that produced no visible output (failed before content) still counts, a step truncated by a crash between `step/start` and `step/end` does not, a cancelled step is counted but contributes no wall time (no message assembled), and a max-tokens usage-host message contributes model time the surface does not show. Every web tail page and list row carries one more small key, and the unit's internal state changes on step boundaries and first-token chunks, so the change feed emits a few value-identical frames per step; TUI and headless assemblies serve no `sessionStats` key and any consumer falls back to window folding. Two e2e probes that had parsed the strip as a loaded-window measure (`chat-scroll-contract`, `complex-history.perf`) now count mounted flow rows / turn-tail footers instead. The `stats-paged-history` web scenario seeds a 28-turn log cold and pins that the whole strip reads full totals on a partial tail page and does not move across Load earlier. diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.zh.md b/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.zh.md new file mode 100644 index 0000000000..93e46cfb7f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.zh.md @@ -0,0 +1,38 @@ +# Agent Note: 通过 sessionStats 投影提供全会话统计条数字 + +Status: implemented + +[English](2026-08-12-full-session-turn-step-counts.md) | 中文 + +## 问题 + +Web 聊天统计条的每个非 token 数字都折算自 `StatsLine` 已加载的会话窗口(`deriveStats` 遍历 `chat.legacy.nodes`):「N 轮 · M 步」计数、LLM 与工具墙钟时间、TTFT/吞吐平均值。历史按每页 50 条消息分页,因此每点一次「加载更早」窗口变大、所有数字随之增长——7 轮 · 44 步在翻一页后变成 10 轮 · 89 步,LLM 时长同样攀升。产品预期是与客户端加载了多少历史无关的全会话数字。同一统计条里的 token 账目早已采用正确架构:持久的 `tokenUsage` 投影。 + +## 决定 + +新的函数插件 `@deepseek-ai/dsh-session-stats` 在 `ctx.sessionProjections` 上注册 `sessionStats` 投影单元,作为 web-app bundle 行挂载。值携带统计条完整的非 token 数字集——`{ turns, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens }`,字段名与窗口折叠一一对应以便整体互换。`steps` 统计 `step/end` 事件,`turns` 统计含至少一条该事件的不同 turn(turn 号单调递增,一个 `lastTurn` 槽即可);`llmMs` 累加 `step/start` → `assistant/message`;TTFT 记录每步首个非空 delta chunk(在步内 `llm/retry` 后保留,与窗口 `resetForRetry` 对齐);解码时长覆盖首 token → 已组装消息、仅统计上报 usage 的步;`toolMs` 按 callId 配对 `tool/call` → `tool/result`,未解决的调用在 `turn/end` 时丢弃。首 token 谓词 `isTokenDelta` 移入 `@deepseek-ai/dsh-llm/message`(`StreamChunk` 词汇的属主),Host 折叠与客户端计时索引共用同一实现;client-runtime 转发导出。投递完全复用现有投影缝——history 尾页块、`session/projection` 推送帧、列表行——apiproxy、wire schema 与客户端运行时零改动。`StatsLine` 读取 `useProjection('sessionStats')`,键为 undefined(未组合该单元的装配)时整体回退到窗口折叠。客户端 connection fixture 按其「镜像每个已组合键」的既有纪律以 `sessionStatsOf` 平行实现该折叠。 + +计数事件选 `step/end` 而非 `assistant/message`,源于评审直觉方案(按消息计数)时发现的两个正确性问题: + +1. max-tokens 步会追加一条仅为承载 usage 而存在的空内容 `assistant/message`,它从不进入 surface;按消息计数会把 transcript 上看不到的步计进去。 +2. 被取消的步在消息组装前就中止(完全没有 `assistant/message`),但客户端会合成可见的 interrupted assistant 节点;按消息计数会悄悄丢掉常见的取消步。 + +`step/end` 对每个进入的步在循环的 `finally` 中恰好追加一次,因此完成、失败、取消、max-tokens 的步都恰好落一条——且计数在步结算时推进,与窗口折算推进的时机相同,直播期行为不发生变化。 + +## 备选方案 + +**统计 `assistant/message` 事件。** 因上述两个正确性缺陷否决(多计 usage 宿主消息、少计被取消的步)。 + +**统计 `step/start` 事件。** 覆盖等价(它先于每条 `step/end`),但计数会在步开始而非结算时推进——一个没有收益的可见直播期行为变化;`step/end` 的 `finally` 位置给出同等完整性。 + +**把单元注册进 `core/agent-loop`(事件生产方)。** 循环是产品主干;把 UI 读模型放进去会给每个装配加上 session-projection 依赖,违反「用插件而非改循环」与「默认组合不带可选项」。 + +**把单元注册进 `token-meter`(折叠同批事件的现有单元)。** 轮/步计数不是 token 度量;每个投影键都住在拥有其领域的包里。 + +**在客户端折叠全量日志。** 客户端按设计只持有分页窗口;投影 RFC 的「不在客户端折叠」规则正是为了让数字在分页、压缩与冷读之间存活。 + +**墙钟时间、TTFT 与吞吐保持窗口口径。** 首个交付版本如此,将其解读为「屏幕上有什么」;同样的分页问题立刻落在 LLM 时长上,且全量计数与窗口时间混在一条统计条里读起来是一套自相矛盾的数字。投影现在携带完整集合,窗口折叠降级为无单元时的回退。 + +## 后果 + +统计条从第一个尾页起就显示全日志数字;翻页不再改变任何分组。与旧窗口语义的已定义边缘差异记录在包 README 中:未产生可见输出的步(在内容之前失败)仍计入;崩溃恰好截断在 `step/start` 与 `step/end` 之间的步不计;被取消的步计数但不计时(没有组装出消息);max-tokens 的 usage 宿主消息贡献 surface 上看不到的模型时间。每个 web 尾页与列表行多携带一个小键,且单元内部状态在步边界与首 token chunk 处变化,变更流每步会多发几帧值相同的推送;TUI 与 headless 装配不提供 `sessionStats` 键,其消费者回退窗口折叠。两个曾把统计条当作已加载窗口探针解析的 e2e(`chat-scroll-contract`、`complex-history.perf`)改为统计已挂载的消息流行/turn-tail 页脚。`stats-paged-history` web 场景冷种一份 28 轮日志,钉住整条统计条在不完整尾页上即读出全量数字、且「加载更早」前后不变。 diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts index 5274759f47..6ce469a9c0 100644 --- a/apps/web/tests/chat-scroll-contract.e2e.ts +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -250,13 +250,16 @@ function scrollGeometry(page: Page): Promise { })) } -async function conversationTurns(page: Page): Promise { - const stats = page.getByText(/\d+ turns · \d+ steps/, { exact: true }).last() - await stats.waitFor({ timeout: 15_000 }) - const value = await stats.textContent() - const match = value?.match(/^(\d+) turns · \d+ steps$/) - if (match?.[1] === undefined) throw new Error(`unexpected conversation stats ${JSON.stringify(value)}`) - return Number(match[1]) +/** + * Rendered transcript rows in the loaded window. The stats strip cannot serve + * as this probe: its turn/step counts ride the whole-log sessionStats + * projection and stay fixed across paging by design, while the row count is + * exactly what grows when an older page prepends or a live turn streams in. + * @param page - the scenario page. + * @returns the number of mounted chat flow rows. + */ +async function loadedFlowRows(page: Page): Promise { + return page.locator('[data-chat-flow-key]').count() } async function openSeed(page: Page, fixture: ChatScrollFixture, tailMarker?: string): Promise { @@ -425,9 +428,9 @@ async function loadEarlierWithAnchor(page: Page): Promise { const older = page.getByRole('button', { name: 'Load earlier', exact: true }) await older.waitFor({ timeout: 10_000 }) const anchor = await visibleFlowAnchor(page) - const before = await conversationTurns(page) + const before = await loadedFlowRows(page) await older.click() - await expect.poll(() => conversationTurns(page), { timeout: 30_000 }).toBeGreaterThan(before) + await expect.poll(() => loadedFlowRows(page), { timeout: 30_000 }).toBeGreaterThan(before) await nextPaint(page) await expectSameFlowTop(page, anchor) } @@ -498,7 +501,7 @@ describe('web e2e: long Chat scroll contract', () => { await world.page.getByRole('button', { name: 'Send message', exact: true }).click() await world.page.getByText(LIVE_TEXT_FIRST, { exact: false }).last().waitFor({ timeout: 15_000 }) await wheelToHistoryStart(world.page) - const beforeTurns = await conversationTurns(world.page) + const beforeRows = await loadedFlowRows(world.page) await world.page.getByRole('button', { name: 'Load earlier', exact: true }).click() await expect.poll(() => held, { timeout: 10_000 }).toBe(true) @@ -511,7 +514,7 @@ describe('web e2e: long Chat scroll contract', () => { ).toBeGreaterThan(chunksAfterAnchor + 5) releaseHistory() - await expect.poll(() => conversationTurns(world.page), { timeout: 30_000 }).toBeGreaterThan(beforeTurns) + await expect.poll(() => loadedFlowRows(world.page), { timeout: 30_000 }).toBeGreaterThan(beforeRows) await nextPaint(world.page) await expectSameFlowTop(world.page, readerAnchor) } finally { @@ -531,7 +534,11 @@ describe('web e2e: long Chat scroll contract', () => { additionalPages += 1 } expect(additionalPages).toBeGreaterThan(0) - expect(await conversationTurns(world.page)).toBe(HISTORY_FIXTURE.turns + 1) + // The whole log is loaded: turn 1's unique marker renders in the + // transcript (scoped: the sidebar search row also carries it) and no + // page remains. + expect(await world.page.locator('[data-conversation-scroll]') + .getByText(HISTORY_FIXTURE.markers.user(1), { exact: false }).count()).toBe(1) expect(await world.page.getByRole('button', { name: 'Load earlier', exact: true }).count()).toBe(0) assertClean(world) }) diff --git a/apps/web/tests/complex-history.perf.ts b/apps/web/tests/complex-history.perf.ts index d3c27f0ce0..eeb9931473 100644 --- a/apps/web/tests/complex-history.perf.ts +++ b/apps/web/tests/complex-history.perf.ts @@ -797,12 +797,11 @@ async function stableCount( } async function conversationTurns(page: Page): Promise { - const stats = page.getByText(/\d+ turns · \d+ steps/, { exact: true }).last() - await stats.waitFor({ timeout: 15_000 }) - const value = await stats.textContent() - const match = value?.match(/^(\d+) turns · \d+ steps$/) - if (match?.[1] === undefined) throw new Error(`unexpected conversation stats ${JSON.stringify(value)}`) - return Number(match[1]) + // Loaded-window turn count: one mounted turn-tail footer per settled turn in + // the window (context keys are `${kind.length}:${kind}${id}`). The stats + // strip cannot serve as this probe: its counts ride the whole-log + // sessionStats projection and stay fixed across paging by design. + return stableCount(page.locator('[data-chat-flow-key^="9:turn-tail"]'), count => count > 0) } function retainedDelta( diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 9ec978fb97..a385059cf6 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -256,6 +256,12 @@ describe('web e2e: seeded history renders through cold resume', () => { // client's "omitted key = capability absent → clear the row" rule from // wiping preset-owned projections on cold reads. expect(projections?.values).toHaveProperty('todos', null) + // The session-stats unit is a shipped web-app bundle row: whole-log + // turn/step counts ride the same tail block (the stats strip's source). + const sessionStats = projections?.values.sessionStats as { turns: number; steps: number } | undefined + expect(sessionStats).toBeDefined() + expect(sessionStats?.turns).toBeGreaterThanOrEqual(1) + expect(sessionStats?.steps).toBeGreaterThanOrEqual(sessionStats?.turns ?? 0) }) it.skipIf(MODE === 'record')('lists the seeded session cold and renders its history from the log', async () => { diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 83be4dc961..d8ef99349f 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -24,3 +24,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] +- text: 1 turns · 1 steps Input 0 tok · Output 0 tok diff --git a/apps/web/tests/snapshots/stats-paged-history/ui.expected.md b/apps/web/tests/snapshots/stats-paged-history/ui.expected.md new file mode 100644 index 0000000000..35c74aadd7 --- /dev/null +++ b/apps/web/tests/snapshots/stats-paged-history/ui.expected.md @@ -0,0 +1,242 @@ +- banner: + - navigation "Session hierarchy": + - button "{{workspace}}" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: m1 7/25 {{clock}} +- button "Copy": + - img +- paragraph: r1 +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: 7/25 {{clock}} Ran for {{duration}} m2 7/25 {{clock}} +- button "Copy": + - img +- paragraph: r2 +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: 7/25 {{clock}} Ran for {{duration}} m3 7/25 {{clock}} +- button "Copy": + - img +- paragraph: r3 +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: 7/25 {{clock}} Ran for {{duration}} m4 7/25 {{clock}} +- button "Copy": + - img +- paragraph: r4 +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: 7/25 {{clock}} Ran for {{duration}} m5 7/25 {{clock}} +- button "Copy": + - img +- paragraph: r5 +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: 7/25 {{clock}} Ran for {{duration}} m6 7/25 {{clock}} +- button "Copy": + - img +- paragraph: r6 +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: 7/25 {{clock}} Ran for {{duration}} m7 7/25 {{clock}} +- button "Copy": + - img +- paragraph: r7 +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: 7/25 {{clock}} Ran for {{duration}} m8 7/25 {{clock}} +- button "Copy": + - img +- paragraph: r8 +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: 7/25 {{clock}} Ran for {{duration}} m9 7/25 {{clock}} +- button "Copy": + - img +- paragraph: r9 +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: 7/25 {{clock}} Ran for {{duration}} m10 7/25 {{clock}} +- button "Copy": + - img +- paragraph: r10 +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: 7/25 {{clock}} Ran for {{duration}} m11 7/25 {{clock}} +- button "Copy": + - img +- paragraph: r11 +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: 7/25 {{clock}} Ran for {{duration}} m12 7/25 {{clock}} +- button "Copy": + - img +- paragraph: r12 +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: 7/25 {{clock}} Ran for {{duration}} m13 7/25 {{clock}} +- button "Copy": + - img +- paragraph: r13 +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: 7/25 {{clock}} Ran for {{duration}} m14 7/25 {{clock}} +- button "Copy": + - img +- paragraph: r14 +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: 7/25 {{clock}} Ran for {{duration}} m15 7/25 {{clock}} +- button "Copy": + - img +- paragraph: r15 +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: 7/25 {{clock}} Ran for {{duration}} m16 7/25 {{clock}} +- button "Copy": + - img +- paragraph: r16 +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: 7/25 {{clock}} Ran for {{duration}} m17 7/25 {{clock}} +- button "Copy": + - img +- paragraph: r17 +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: 7/25 {{clock}} Ran for {{duration}} m18 7/25 {{clock}} +- button "Copy": + - img +- paragraph: r18 +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: 7/25 {{clock}} Ran for {{duration}} m19 7/25 {{clock}} +- button "Copy": + - img +- paragraph: r19 +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: 7/25 {{clock}} Ran for {{duration}} m20 7/25 {{clock}} +- button "Copy": + - img +- paragraph: r20 +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: 7/25 {{clock}} Ran for {{duration}} m21 7/25 {{clock}} +- button "Copy": + - img +- paragraph: r21 +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: 7/25 {{clock}} Ran for {{duration}} m22 7/25 {{clock}} +- button "Copy": + - img +- paragraph: r22 +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: 7/25 {{clock}} Ran for {{duration}} m23 7/25 {{clock}} +- button "Copy": + - img +- paragraph: r23 +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: 7/25 {{clock}} Ran for {{duration}} m24 7/25 {{clock}} +- button "Copy": + - img +- paragraph: r24 +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: 7/25 {{clock}} Ran for {{duration}} m25 7/25 {{clock}} +- button "Copy": + - img +- paragraph: r25 +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: 7/25 {{clock}} Ran for {{duration}} m26 7/25 {{clock}} +- button "Copy": + - img +- paragraph: r26 +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: 7/25 {{clock}} Ran for {{duration}} m27 7/25 {{clock}} +- button "Copy": + - img +- paragraph: r27 +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: 7/25 {{clock}} Ran for {{duration}} m28 7/25 {{clock}} +- button "Copy": + - img +- paragraph: r28 +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: 7/25 {{clock}} Ran for {{duration}} +- button "Back to bottom": + - img +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "Send message" [disabled] +- text: 28 turns · 28 steps LLM {{duration}} Input 0 tok · Output 0 tok diff --git a/apps/web/tests/stats-paged-history.e2e.ts b/apps/web/tests/stats-paged-history.e2e.ts new file mode 100644 index 0000000000..e7ce2cdd06 --- /dev/null +++ b/apps/web/tests/stats-paged-history.e2e.ts @@ -0,0 +1,136 @@ +// Web e2e scenario: full-session stats over paged history. A deterministic +// 28-turn log (56 surface messages — more than one 50-message history page) +// seeded cold through the REAL persistence API must render whole-log turn/step +// counts from the sessionStats projection on first open, and loading the +// older page must NOT change them. This pins the bug the projection fixed: +// the pre-projection window fold recounted per loaded page, so 加载更早 grew +// the counter. Zero model calls; the seed is generated, not recorded, because +// no line of it is model output. +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/stats-paged-history', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('./snapshots/stats-paged-history/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'stats-paged-history-web-e2e' + +/** Turn count: 2 surface messages per turn, so 28 turns overflow one 50-message page. */ +const TURNS = 28 +const FULL_COUNTS = `${TURNS} turns · ${TURNS} steps` + +/** + * Generate the seed: TURNS closed single-step turns of one short user prompt + * and one short assistant reply each. Times are fixed so the fixture is + * byte-deterministic; message ids are synthetic uuids (aria normalizes them). + * @param turns - closed turns to generate. + * @returns session.jsonl text for {@link seedSession}. + */ +function buildSeed(turns: number): string { + const lines = [JSON.stringify({ + type: 'session', version: 0, id: '{{sessionId}}', createdAt: 1784974100000, cwd: '{{cwd}}/workspace', + })] + let seq = 0 + let time = 1784974100000 + const at = (event: Record): void => { + lines.push(JSON.stringify({ ...event, seq: seq++, time: time++ })) + } + for (let turn = 1; turn <= turns; turn++) { + at({ type: 'turn/start', data: { turn } }) + at({ + type: 'user/message', + data: { content: [{ type: 'text', text: `m${turn}` }], source: { kind: 'user' } }, + surfaceOp: 'append', + }) + at({ type: 'step/start', data: { turn, step: 1 } }) + at({ + type: 'assistant/message', + data: { + turn, + step: 1, + message: { + id: `00000000-0000-4000-8000-${String(turn).padStart(12, '0')}`, + role: 'assistant', + content: [{ type: 'text', text: `r${turn}` }], + source: { kind: 'model', provider: 'snapshot', model: 'snapshot-replier' }, + }, + }, + sourceEventSeqs: [], + surfaceOp: 'append', + }) + at({ type: 'step/end', data: { turn, step: 1 } }) + at({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) + } + return `${lines.join('\n')}\n` +} + +describe('web e2e: whole-session stats survive history paging', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + if (MODE === 'record') throw new Error('stats-paged-history is a keyless assembled snapshot') + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, buildSeed(TURNS), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('renders full-session counts on the partial tail page and keeps them across load-older', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-stats-paged')) + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + // Settled barrier: the newest recorded reply renders from the tail page. + await expect.poll(() => page.getByText(`r${TURNS}`, { exact: true }).count(), { timeout: 15_000 }).toBe(1) + // The tail page is partial (56 messages > one 50-message page): the first + // turns are NOT loaded, yet the strip already reports the whole log — + // the sessionStats projection, not the window fold. + expect(await page.getByText('m1', { exact: true }).count()).toBe(0) + await expect.poll(() => page.getByText(FULL_COUNTS, { exact: false }).count(), { timeout: 10_000 }).toBe(1) + const strip = page.getByText(FULL_COUNTS, { exact: false }).locator('..') + const stripBeforePaging = await strip.textContent() + + // 加载更早: prepending the older page must not move ANY strip figure — + // counts, wall times, or token groups. + await page.getByRole('button', { name: 'Load earlier' }).click() + await expect.poll(() => page.getByText('m1', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + expect(await strip.textContent()).toBe(stripBeforePaging) + // With the whole log loaded, the window mounts one turn-tail footer per + // settled turn — the loaded-window probe the scroll/perf lanes count now + // that the strip is whole-log-scoped. + expect(await page.locator('[data-chat-flow-key^="9:turn-tail"]').count()).toBe(TURNS) + }, 60_000) + + it('matches the paged-stats aria golden', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-stats-paged-aria')) + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) + .split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + }) + + it('issued zero model calls and stayed clean', async () => { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index e656b099ea..e7772882b1 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -48,6 +48,7 @@ "tests/replay-round-trip.e2e.ts", "tests/hmr-live.e2e.ts", "tests/seeded-history.e2e.ts", + "tests/stats-paged-history.e2e.ts", "tests/sidebar-scrollbar.e2e.ts", "tests/conversation-column-overflow.e2e.ts", "tests/code-mode-round.e2e.ts", diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 7b380a0698..389707aed7 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 2bb1f315e02c3f4bab379593227f1c381818a9fa -config-catalog.zh.md: 4be51942de65a7e8afdaf71a44573ba1cdd6230d +config-catalog.md: f4feae3a31d9aa1798e0b06c2b72f43278a01281 +config-catalog.zh.md: a0068a6c0372022d05eae1a49ec6cd89f2625261 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2bb1f315e0..f4feae3a31 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2798,6 +2798,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts)) - `@deepseek-ai/dsh-session-projection` ([`packages/session/session-projection/src/index.ts`](../packages/session/session-projection/src/index.ts)) +- `@deepseek-ai/dsh-session-stats` — requires `sessionProjections` ([`packages/session/session-stats/src/index.ts`](../packages/session/session-stats/src/index.ts)) - `@deepseek-ai/dsh-skill-badge` — requires `skills` ([`packages/skill/skill-badge/src/index.ts`](../packages/skill/skill-badge/src/index.ts)) - `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4be51942de..a0068a6c03 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2799,6 +2799,7 @@ export interface Config { - `@deepseek-ai/dsh-session`([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — 需要 `llm` · `sessionPersistence` · `sessions` · `tools`([`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts)) - `@deepseek-ai/dsh-session-projection`([`packages/session/session-projection/src/index.ts`](../packages/session/session-projection/src/index.ts)) +- `@deepseek-ai/dsh-session-stats` — 需要 `sessionProjections`([`packages/session/session-stats/src/index.ts`](../packages/session/session-stats/src/index.ts)) - `@deepseek-ai/dsh-skill-badge` — 需要 `skills`([`packages/skill/skill-badge/src/index.ts`](../packages/skill/skill-badge/src/index.ts)) - `@deepseek-ai/dsh-storage`([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) - `@deepseek-ai/dsh-subagent`([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index c696b41503..144d6ec299 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -74,6 +74,11 @@ writeEveryEvents: 200 writeIntervalMs: 5000 + # Whole-log turn/step counts for the chat stats strip (the sessionStats + # projection key); the projection registry itself is a base-layer row. + - id: session-stats + name: '@deepseek-ai/dsh-session-stats' + # Resolve bind host, SSH launch, and display once at boot, then mount the # matching dual-face directory picker. Mount -native or -browse directly in # an overlay to pin the interaction. diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 3375fed71a..fece731d89 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -89,6 +89,7 @@ "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", + "@deepseek-ai/dsh-session-stats": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-storage-json": "workspace:^", diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 8085c7d323..4103fc45f2 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -9,6 +9,7 @@ import { createAssistantMessage, createToolResultMessage, createUserMessage, + isTokenDelta, } from '@deepseek-ai/dsh-llm/message' import { CallId } from '@deepseek-ai/dsh-llm/brand' import type { @@ -861,6 +862,76 @@ function tokenUsageOf(log: readonly SessionEvent[]): FixtureTokenUsageProjection return totals } +/** Fixture parallel of session-stats' whole-log counting and wall-time fold. */ +function sessionStatsOf(log: readonly SessionEvent[]): { + turns: number + steps: number + llmMs: number + toolMs: number + ttftMs: number + ttftSteps: number + decodeMs: number + decodeTokens: number +} { + const value = { turns: 0, steps: 0, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0 } + let lastTurn: number | null = null + let openStep: { turn: number; step: number; startTime: number; firstTokenTime: number | null } | null = null + const pendingCalls = new Map() + for (const event of log) { + switch (event.type) { + case 'step/start': + openStep = { turn: event.data.turn, step: event.data.step, startTime: event.time, firstTokenTime: null } + break + case 'assistant/chunk': + if (openStep !== null && openStep.turn === event.data.turn && openStep.step === event.data.step + && openStep.firstTokenTime === null && isTokenDelta(event.data.chunk)) { + openStep.firstTokenTime = event.time + } + break + case 'assistant/message': { + if (openStep === null || openStep.turn !== event.data.turn || openStep.step !== event.data.step) break + value.llmMs += Math.max(0, event.time - openStep.startTime) + if (openStep.firstTokenTime !== null) { + value.ttftMs += Math.max(0, openStep.firstTokenTime - openStep.startTime) + value.ttftSteps += 1 + const outputTokens = event.data.usage?.outputTokens + if (typeof outputTokens === 'number' && Number.isFinite(outputTokens) && outputTokens >= 0) { + value.decodeMs += Math.max(0, event.time - openStep.firstTokenTime) + value.decodeTokens += outputTokens + } + } + openStep = null + break + } + case 'tool/call': + pendingCalls.set(event.data.callId, event.time) + break + case 'tool/result': { + const callId = event.data.message.source.callId + const dispatched = pendingCalls.get(callId) + if (dispatched === undefined) break + pendingCalls.delete(callId) + value.toolMs += Math.max(0, event.time - dispatched) + break + } + case 'step/end': + if (event.data.turn !== lastTurn) { + value.turns += 1 + lastTurn = event.data.turn + } + value.steps += 1 + openStep = null + break + case 'turn/end': + pendingCalls.clear() + break + default: + break + } + } + return value +} + interface FixtureRequestContext { provider: string model: string @@ -979,6 +1050,8 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record 0) return frames if (type === 'session/title') { const values = projectionValuesOf(log) diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts index 109e7acd93..1f842383d8 100644 --- a/packages/client/connection/tests/fixture.client.spec.ts +++ b/packages/client/connection/tests/fixture.client.spec.ts @@ -164,6 +164,10 @@ describe('createFixtureApi', () => { toolsTokens: 0, messageTokens: 0, }, + // Session-stats unit composed: no figure accrues on the empty log. + sessionStats: { + turns: 0, steps: 0, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0, + }, } }, }) }) @@ -353,7 +357,7 @@ describe('createFixtureApi', () => { const envelopes: RpcRequest[] = [] for await (const envelope of api.events.mux(req({}), abort.signal)) { envelopes.push(envelope) - if (envelopes.length >= 11) abort.abort() + if (envelopes.length >= 12) abort.abort() } return envelopes } @@ -374,10 +378,12 @@ describe('createFixtureApi', () => { value: { systemTokens: 0, toolsTokens: 0 }, }) expect((first[8]?.payload as { value: { messageTokens: number } }).value.messageTokens).toBeGreaterThan(0) - expect(first[9]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' }) - expect(second[9]?.rpcId).toBe(first[9]?.rpcId) // stable rpcId across replays (host replay semantics) - expect(first[10]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' }) - expect(second[10]?.rpcId).toBe(first[10]?.rpcId) + expect(first[9]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'sessionStats' }) + expect((first[9]?.payload as { value: { turns: number; steps: number } }).value.steps).toBeGreaterThan(0) + expect(first[10]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' }) + expect(second[10]?.rpcId).toBe(first[10]?.rpcId) // stable rpcId across replays (host replay semantics) + expect(first[11]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' }) + expect(second[11]?.rpcId).toBe(first[11]?.rpcId) }) it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => { diff --git a/packages/client/runtime/src/client/sessions/assistant-timing.ts b/packages/client/runtime/src/client/sessions/assistant-timing.ts index 3c54c9c36d..8f58f8daab 100644 --- a/packages/client/runtime/src/client/sessions/assistant-timing.ts +++ b/packages/client/runtime/src/client/sessions/assistant-timing.ts @@ -2,9 +2,14 @@ // history fold derive AssistantTiming from the same step/start -> first token // delta -> assistant/message sequence. +import { isTokenDelta } from '@deepseek-ai/dsh-llm/message' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { AssistantTiming } from './conversation.ts' +// The first-token predicate lives with the StreamChunk vocabulary in dsh-llm; +// re-exported here so Chat Definitions keep their client-runtime import. +export { isTokenDelta } from '@deepseek-ai/dsh-llm/message' + /** Pre-finalize timing boundaries for one assistant step (start + first token). */ export interface AssistantStepMetadata { stepStartTime: number | null @@ -21,24 +26,6 @@ export function assistantStepKey(turn: number, step: number): string { return `${turn}\u0000${step}` } -/** - * Whether a chunk carries visible model output (first-token boundary). Empty - * deltas (heartbeats, empty tool-call frames) do not count as a first token. - * @param chunk - the assistant/chunk payload. - * @returns true when the chunk contains a non-empty text/reasoning/tool delta. - */ -export function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean { - switch (chunk.type) { - case 'text-delta': - case 'reasoning-delta': - return chunk.text !== '' - case 'tool-call-delta': - return chunk.argumentsDelta !== '' || chunk.name !== undefined - default: - return false - } -} - /** * Fold one event into the per-step timing index: step/start opens the entry, * the first non-empty token delta stamps first-token time once. Other event diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 896a2fe608..0201a50494 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: ed8f888d35693ecaa2667ea432b462f4bb3369cf -README.zh.md: e6a2dd0b545b66ab01b213b5ebc937e22af8ac1a +README.md: 6d0f3d0a088ad580d55d524699a7bf8d2806b724 +README.zh.md: 70f828d29512eb5e25a873a8fce2b4ffad6380fc diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index ed8f888d35..6d0f3d0a08 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -38,7 +38,7 @@ Per-session UI state for selection and the active view lives in the declared cha The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop controls), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar keeps message actions inert (machine faces absent, `disabled` owner prop), while the whole dashed card opens the existing Workspace picker by pointer and the read-only textarea opens it through Enter or Space. Disabled controls release pointer events to the card, and the card contains `pointerdown` so the open picker's outside-close cannot race a reopen. The bar never swaps in a parallel tree, so the textarea DOM survives Workspace selection; strict-session control seats stay empty until a session exists. -The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. The same window fold averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy renders as the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation. +The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. The turn and step counts, the LLM and tool wall times, and the latency/throughput group all ride the whole-log `sessionStats` projection (host-folded from step boundaries, first-token chunks, tool pairs, and assembled messages), so paging and compaction cannot change any strip figure; an assembly without that unit falls back to the window fold over visible nodes, whose fields mirror the projection's. The strip averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them, and durable count, token, and context groups remain visible when compaction leaves no assistant node in the loaded window. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy renders as the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation. `src/client/` is organized by domain. `contract/` is the shared face for slot declarations, composed props, and cross-domain types; `skeleton/`, `chat/`, `input/`, `queue/`, and `settings/` keep their implementations internal, while `apply.ts` is their assembly point. The `/client` exports contain only loader entries, service classes, and contract types; components and store factories reach the page through slot registrations. @@ -54,7 +54,7 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Stats-line durations and speeds cover the in-window flow only** — LLM and tool wall times plus the TTFT and throughput averages fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted. +- **The stats-line fallback fold covers the in-window flow only** — without the `sessionStats` projection (an assembly that does not mount the unit), every figure folds the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted and the numbers grow per loaded page. - **The details panel has no entry point** — `ChatViewInjected.openDetails` is implemented but uncalled, so the raw selected-call display is unreachable in the assembled application. There is no Input/Output/Metadata switch, Prev/Next stepping, or trajectory deep link. - **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / clock / branch) ships under the last content-text assistant of each turn that has ended; mid-turn narration, Think-only nodes, and every node of a turn still producing steps stay chrome-free. Branch stays disabled unless that message is also the last transcript node of a completed turn; when enabled, it forks through that turn, increments the inherited title on the client, and opens the child. A fork or rename failure leaves the source selected ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)). - **Sent user messages cannot be edited** — user bubbles retain clock and copy; branch lives only under assistant answers ([decision](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md)). Editing returns with the capability behind it: a client mutation over a settled user message, plus the host behavior for the turn that already consumed it ([decision](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index e6a2dd0b54..70f828d295 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -38,7 +38,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu 输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止控件之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `SlashController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 会让消息操作保持不可交互(machine face 均缺席、`disabled` owner prop),整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。禁用控件会把指针事件交给卡片,卡片也会拦下 `pointerdown`,避免已打开 picker 的外点关闭与重新打开发生竞态。它不会换入一棵平行树,因此选择 Workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 -聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。可见节点只提供轮次与步骤计数,以及 LLM(大语言模型)和工具的墙钟时间:这些是关于「屏幕上有什么」的窗口作用域事实,而非账目;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久 token 与上下文分组仍保持可见。同一次窗口折算还会把每个有完整记录的步骤的 TTFT(首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`);缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率渲染为 composer 尾部的 ContextMeter:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。 +聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。轮次与步骤计数、LLM(大语言模型)与工具墙钟时间、以及延迟/吞吐分组都来自全日志的 `sessionStats` 投影(Host 端从步边界、首 token chunk、工具配对与已组装消息折算),因此分页与压缩都无法改变统计条的任何数字;未组合该单元的装配回退为对可见节点做窗口折算,其字段与投影一一对应。统计条把每个有完整记录的步骤的 TTFT(首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`);缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久计数、token 与上下文分组仍保持可见。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率渲染为 composer 尾部的 ContextMeter:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。 `src/client/` 按领域组织。`contract/` 是 slot 声明、组合 props 与跨领域类型的共享表层;`skeleton/`、`chat/`、`input/`、`queue/` 和 `settings/` 保持内部实现,`apply.ts` 是它们的组装点。`/client` 导出表层只包含 loader entry、service class 和 contract 类型;组件与 store factory 经 slot 注册抵达页面。 @@ -54,7 +54,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu ## 已知限制与暂缓事项 -- **统计行的耗时与速率只覆盖窗口内消息流**:LLM 与工具墙钟时间以及 TTFT 与吞吐平均值由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。 +- **统计行的回退折算只覆盖窗口内消息流**:未组合 `sessionStats` 投影单元的装配中,所有数字由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入,数字随加载页数增长。 - **详情面板没有入口**:`ChatViewInjected.openDetails` 虽已实现却无人调用,因此以原始形式显示已选择调用的那部分在组装后的应用中不可达。没有 Input/Output/Metadata 切换、Prev/Next 步进,也没有 trajectory 深链接。 - **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/时钟/分支)只挂在每个已结束轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述、纯 Think 节点,以及仍在产出步骤的轮次里的所有节点都不带 chrome。除非该消息同时也是已完成轮次的最后一个 transcript 节点,否则分支保持禁用;启用后,它会 fork 到该轮次末尾,在 client 端递增继承标题并打开子会话。fork 或改名失败时源会话保持选中([决策](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md))。 - **已发送的 user 消息无法编辑**:user 气泡保留时钟和复制;分支只存在于 assistant 回答之下([决策](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md))。编辑功能要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md))。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index a9996ce09a..3fe601174d 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -70,6 +70,7 @@ "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", + "@deepseek-ai/dsh-session-stats": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "react": "^18.2.0" @@ -98,6 +99,7 @@ "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-session-stats": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx index 8e43f3c0b4..177afdb05a 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx @@ -6,6 +6,8 @@ import { Fragment, memo, useLayoutEffect, useMemo, useRef, useState } from 'reac import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { ConversationSnapshot, UseProjection } from '@deepseek-ai/dsh-client-runtime/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' +// Type-only: merges the sessionStats key into SessionProjectionMap for useProjection. +import type {} from '@deepseek-ai/dsh-session-stats/client' import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client' import type { ComposerBarProps } from '../contract/slots.ts' import { formatTokensPerSecond } from './message-chrome.ts' @@ -30,14 +32,16 @@ interface WindowStats { } /** - * Fold assistant and tool-result nodes into the window-scoped display totals. + * Fold assistant and tool-result nodes into window-scoped display totals — + * the FALLBACK for assemblies without the `sessionStats` projection. * - * Counts and wall times describe the loaded window on purpose — they answer - * "what is on screen". Token accounting deliberately does NOT come from here: - * the window is paged and compaction rewrites it, so billing rides the durable - * `tokenUsage` projection instead. + * Every displayed figure rides that durable whole-log projection (and token + * accounting rides `tokenUsage`) because the window is paged and compaction + * rewrites it; this fold answers "what is on screen" only when no projection + * value is served. Its field names deliberately mirror the projection's so + * the two swap wholesale. * @param nodes - snapshot nodes. - * @returns visible counts and summed wall times. + * @returns fallback counts and summed wall times. */ export function deriveStats(nodes: ConversationSnapshot['nodes']): WindowStats { const turns = new Set() @@ -158,8 +162,12 @@ export interface StatsLineProps { export const StatsLine = memo(function StatsLine({ useSession, useProjection, t }: StatsLineProps) { const settledNodes = useSession(s => s.chat.legacy.nodes) - const stats = useMemo(() => deriveStats(settledNodes), [settledNodes]) + const windowStats = useMemo(() => deriveStats(settledNodes), [settledNodes]) const usage = useProjection('tokenUsage') + // Every figure rides the durable sessionStats projection, so paging and + // compaction cannot change any of them; an assembly without the unit falls + // back to the window-scoped fold wholesale (same field names). + const stats = useProjection('sessionStats') ?? windowStats // Pipe-separated groups (figma stats strip); a group with no data drops out whole. const groups: string[] = [] if (stats.steps > 0) { @@ -168,7 +176,6 @@ export const StatsLine = memo(function StatsLine({ useSession, useProjection, t if (stats.llmMs > 0) durations.push(t('stats.llm', { duration: formatDuration(stats.llmMs) })) if (stats.toolMs > 0) durations.push(t('stats.toolCall', { duration: formatDuration(stats.toolMs) })) if (durations.length > 0) groups.push(durations.join(' · ')) - // Window-scoped like the wall times above: averages describe loaded steps. const speeds: string[] = [] if (stats.ttftSteps > 0) { speeds.push(t('stats.ttftAverage', { duration: formatDuration(stats.ttftMs / stats.ttftSteps) })) diff --git a/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx b/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx index b53a1aa739..6abcf611d2 100644 --- a/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx @@ -98,9 +98,11 @@ describe('deriveStats', () => { ]) expect(stats.turns).toBe(2) expect(stats.steps).toBe(3) - // Window-scoped by design: the paged window is not an accounting source, so - // the fold exposes no billing fields (billing rides the projection); - // decodeTokens is a throughput input, not a billed total. + // The window fold's counts are only the fallback for assemblies without + // the sessionStats projection; the paged window is not an accounting + // source either, so the fold exposes no billing fields (billing rides the + // tokenUsage projection); decodeTokens is a throughput input, not a + // billed total. expect(Object.keys(stats).sort()).toEqual( ['decodeMs', 'decodeTokens', 'llmMs', 'steps', 'toolMs', 'ttftMs', 'ttftSteps', 'turns'], ) @@ -169,6 +171,14 @@ describe('formatters', () => { describe('StatsLine', () => { const USAGE = { uncachedInputTokens: 10, outputTokens: 5, cacheReadTokens: 90, cacheWriteTokens: 0 } + /** A whole-log sessionStats value: zeros plus overrides. */ + function sessionStats(overrides: Record): Record { + return { + turns: 0, steps: 0, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0, + ...overrides, + } + } + /** Stub the projection seat: a key-addressed table of whole values. */ function projections(values: Record): StatsLineProps['useProjection'] { return (key: string) => values[key] @@ -281,6 +291,58 @@ describe('StatsLine', () => { expect(view.container.textContent).toBe('1 turns · 1 steps') }) + it('renders whole-session counts from the sessionStats projection over the paged window', () => { + // The bug's acceptance at unit level: one loaded page must not scope the + // counter — the durable projection's totals win over the window fold. + const { source } = makeSource({ nodes: [assistant(1, 1)] }) + const view = render() + expect(view.container.textContent) + .toBe('10 turns · 89 steps| Cache hit 90%| Input 100 tok · Output 5 tok') + }) + + it('treats a defined zero-count projection as empty, not as fallback', () => { + // A composed unit always serves the key; all-zero genuinely means no + // closed step in the whole log, so nothing renders on a brand-new session. + const empty = makeSource() + const view = render() + expect(view.container.textContent).toBe('') + }) + + it('keeps the counts group over an empty visible window when the projection carries totals', () => { + // Extends the durable-groups guarantee: full-session counts survive a + // window that compaction (or paging) left without assistant nodes. + const { source } = makeSource() + const view = render() + expect(view.container.textContent) + .toBe('7 turns · 44 steps| Cache hit 90%| Input 100 tok · Output 5 tok') + }) + + it('renders whole-log wall times and speeds from the projection, not the loaded window', () => { + // The 加载更早 hazard beyond counts: LLM/tool durations and the TTFT and + // throughput figures must not grow per loaded page either. An untimed + // 1-node window renders the projection's whole-log figures verbatim. + const { source } = makeSource({ nodes: [assistant(1, 1)] }) + const view = render() + expect(view.container.textContent).toBe( + '200 turns · 200 steps| LLM 1m40s · Tool call 1m2s| TTFT avg 0.8s · 20 tok/s| Cache hit 90%| Input 100 tok · Output 5 tok', + ) + }) + it('omits cache hit when nothing was billed on the input side', () => { const { source } = makeSource({ nodes: [assistant(1, 1)] }) const view = render( { expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull() }) - it('StatsLine counts window nodes but drops every token group without a projection', () => { - // Node `usage` is deliberately ignored: billing rides the durable - // tokenUsage projection, so an absent projection leaves counts only. + it('StatsLine falls back to window-node counts and drops every token group without projections', () => { + // No sessionStats key → the window fold supplies the counts (the + // assembly-without-the-unit fallback). Node `usage` is deliberately + // ignored: billing rides the durable tokenUsage projection, so an absent + // projection leaves counts only. const nodes = [ { kind: 'assistant', seq: 1, time: 1, turn: 1, step: 1, blocks: [] }, { kind: 'assistant', seq: 2, time: 2, turn: 1, step: 2, blocks: [], usage: { inputTokens: 4, outputTokens: 6 } }, diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 0ef0bd8a19..b9ac320842 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -44,6 +44,9 @@ { "path": "../../session/session-projection" }, + { + "path": "../../session/session-stats" + }, { "path": "../../llm/token-meter" }, diff --git a/packages/llm/llm/src/message.ts b/packages/llm/llm/src/message.ts index 7863e66d58..45c0315a2a 100644 --- a/packages/llm/llm/src/message.ts +++ b/packages/llm/llm/src/message.ts @@ -2,7 +2,7 @@ import { MessageId, type CallId } from './brand.ts' import { deepFreeze } from './call-config.ts' -import type { ContentBlock, ToolResultBlock } from './types.ts' +import type { ContentBlock, StreamChunk, ToolResultBlock } from './types.ts' /** Provider/model identity and adapter-private replay data for an assistant message. */ export interface AssistantProvenance { @@ -239,3 +239,23 @@ export function createToolResultMessage(input: ToolResultMessageInput): ToolResu }], }) } + +/** + * Whether a stream chunk carries visible model output (the first-token + * boundary shared by client step timing and the whole-log sessionStats + * projection). Empty deltas (heartbeats, empty tool-call frames) do not count + * as a first token. + * @param chunk - the stream chunk to test. + * @returns true when the chunk contains a non-empty text/reasoning/tool delta. + */ +export function isTokenDelta(chunk: StreamChunk): boolean { + switch (chunk.type) { + case 'text-delta': + case 'reasoning-delta': + return chunk.text !== '' + case 'tool-call-delta': + return chunk.argumentsDelta !== '' || chunk.name !== undefined + default: + return false + } +} diff --git a/packages/session/README.i18n.yaml b/packages/session/README.i18n.yaml index 3829d946f0..e9312ecd0e 100644 --- a/packages/session/README.i18n.yaml +++ b/packages/session/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/README.md -README.md: 586d1be0286a0de935b0b08313e6965452b85376 -README.zh.md: 60e58e6d471a48d3ced03a518e96aeeae79110e8 +README.md: 64aa8e4fdf1e85d74dfa0a77803a04e881b547c4 +README.zh.md: 520e969cbf623ac4a0a3dc82b6fb54f12ba8ffa3 diff --git a/packages/session/README.md b/packages/session/README.md index 586d1be028..64aa8e4fdf 100644 --- a/packages/session/README.md +++ b/packages/session/README.md @@ -25,6 +25,7 @@ Serves current, log-derived per-session state to client carriers. |---|---|---| | [`session-projection/`](session-projection/README.md) | Defines and drives session projection units | `ctx.sessionProjections` | | [`session-projection-cache/`](session-projection-cache/README.md) | Persists and restores projection checkpoints | `ctx.sessionProjectionCache` | +| [`session-stats/`](session-stats/README.md) | Serves whole-log conversation counts and wall times (`sessionStats` unit) | registers on `ctx.sessionProjections` | ## Titles diff --git a/packages/session/README.zh.md b/packages/session/README.zh.md index 60e58e6d47..520e969cbf 100644 --- a/packages/session/README.zh.md +++ b/packages/session/README.zh.md @@ -25,6 +25,7 @@ |---|---|---| | [`session-projection/`](session-projection/README.md) | 定义并驱动会话投影单元 | `ctx.sessionProjections` | | [`session-projection-cache/`](session-projection-cache/README.md) | 持久化并恢复投影检查点 | `ctx.sessionProjectionCache` | +| [`session-stats/`](session-stats/README.md) | 提供全日志会话计数与墙钟时间(`sessionStats` 单元) | 注册到 `ctx.sessionProjections` | ## 标题 diff --git a/packages/session/session-stats/README.i18n.yaml b/packages/session/session-stats/README.i18n.yaml new file mode 100644 index 0000000000..8896173827 --- /dev/null +++ b/packages/session/session-stats/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/session/session-stats/README.md +README.md: a42d5b1b6efe8ff522a43431f1ccb68b71607876 +README.zh.md: 5929280a8a619fb37a6f6468fb839962702a68c3 diff --git a/packages/session/session-stats/README.md b/packages/session/session-stats/README.md new file mode 100644 index 0000000000..a42d5b1b6e --- /dev/null +++ b/packages/session/session-stats/README.md @@ -0,0 +1,39 @@ +# @deepseek-ai/dsh-session-stats + +English | [中文](README.zh.md) + +Function plugin registering the `sessionStats` projection unit: whole-log conversation figures — turn/step counts and the LLM, tool, first-token, and decode wall times — folded from step boundaries, stream chunks, tool pairs, and assembled assistant messages, and served through the session-projection seam (registry snapshot, change feed, and every projection carrier: history tail page, `session/projection` push frames, session list rows). Clients render full-session figures that paging and compaction cannot change; the reference consumer is the web chat stats strip, whose window fold mirrors these field names as its no-unit fallback. + +## Fold semantics + +- `steps` counts `step/end` events. The agent loop appends exactly one per entered step, in a `finally`, so completed, failed, cancelled, and max-tokens steps all count. Counting assembled assistant messages instead would overcount max-tokens usage-host messages (empty content, excluded from the surface) and undercount cancelled steps (aborted before the message assembles). +- `turns` counts distinct turns carrying at least one closed step; rejected or empty turns (closed with no step) are uncounted. Turn numbers are host-assigned and monotonic per session, so the fold keeps only the last counted turn. +- `llmMs` sums `step/start` → `assistant/message` per step that assembled a message (retry waits inside the step are model time, as in the window fold). +- `ttftMs`/`ttftSteps` sum and count `step/start` → first non-empty delta chunk; the first attempt's boundary survives an in-step `llm/retry` (window `resetForRetry` parity). +- `decodeMs`/`decodeTokens` sum first token → assembled message and the provider-reported output tokens, only over steps carrying both. +- `toolMs` sums `tool/call` → `tool/result` pairs matched by callId; unresolved calls are dropped at `turn/end` (results land within their turn). +- Every field is 0 until its first contributing event. A composed registry always serves the key, so clients read the value, never key presence. + +## Composition + +```yaml +- id: session-stats + name: '@deepseek-ai/dsh-session-stats' +``` + +Injects `sessionProjections` — the plugin's whole purpose; in assemblies without the registry the fiber stays pending and nothing registers. + +## Model Experience + +None, as the plugin only computes a client-facing read model of already-logged session events and touches no prompt, message, schema, stream, or tool result. + +#### KV Cache effect + +None; the plugin never assembles or sends provider requests. + +## Known Limitations and Deferred Work + +- **Steps count work attempted, not visible output** — a step that failed before producing any visible content still closed with `step/end` and counts; a step truncated by a crash between `step/start` and `step/end` does not. +- **A cancelled step is counted but untimed** — no assistant message assembles, so its partial stream time enters no wall-time figure, matching the window fold's untimed interrupted node; a max-tokens usage-host message conversely contributes model time the surface does not show. +- **Counts are log-scoped, not surface-scoped** — steps whose messages were later compacted away stay counted; the figures describe the whole session, not the current model-visible surface. +- **Mounted only in the web-app bundle** — other assemblies serve no `sessionStats` key, and their consumers fall back to window-scoped counting (the web stats strip's fallback path). diff --git a/packages/session/session-stats/README.zh.md b/packages/session/session-stats/README.zh.md new file mode 100644 index 0000000000..5929280a8a --- /dev/null +++ b/packages/session/session-stats/README.zh.md @@ -0,0 +1,39 @@ +# @deepseek-ai/dsh-session-stats + +[English](README.md) | 中文 + +注册 `sessionStats` projection 单元的函数插件:从步边界、流式 chunk、工具配对与已组装的 assistant 消息折叠出全日志会话数字——轮/步计数以及 LLM、工具、首 token、解码墙钟时间——经 session-projection 缝对外提供(registry 快照、变更流,以及每一个 projection 载体:history 尾页、`session/projection` 推送帧、会话列表行)。客户端由此渲染分页与压缩都无法改变的全会话数字;参考消费者是 Web 聊天统计条,其窗口折叠以相同字段名充当无单元时的回退。 + +## 折叠语义 + +- `steps` 统计 `step/end` 事件。agent loop 对每个进入的步在 `finally` 中恰好追加一条,因此完成、失败、取消、max-tokens 的步全部计入。若改按已组装的 assistant 消息计数,则会多算 max-tokens 的 usage 宿主消息(空内容、被排除在 surface 之外),并少算被取消的步(在消息组装前已中止)。 +- `turns` 统计含至少一个已关闭步的不同 turn;被拒绝或空轮(未进入任何步即关闭)不计。turn 号由宿主分配、按会话单调递增,因此折叠只需保留最近计入的 turn。 +- `llmMs` 按步累加 `step/start` → `assistant/message`(组装出消息的步;步内重试的等待与窗口折叠一样计入模型时间)。 +- `ttftMs`/`ttftSteps` 累加并统计 `step/start` → 首个非空 delta chunk;首次尝试的边界在步内 `llm/retry` 后保留(与窗口 `resetForRetry` 对齐)。 +- `decodeMs`/`decodeTokens` 累加首 token → 已组装消息的时长与提供方上报的输出 token,仅统计两者兼备的步。 +- `toolMs` 按 callId 配对累加 `tool/call` → `tool/result`;未解决的调用在 `turn/end` 时丢弃(结果总在其轮内落地)。 +- 每个字段在首个贡献事件之前均为 0。已装配的 registry 恒提供该键,客户端读取值本身,而非键的存在性。 + +## 组合 + +```yaml +- id: session-stats + name: '@deepseek-ai/dsh-session-stats' +``` + +注入 `sessionProjections`——这是插件的全部用途;在没有 registry 的装配中 fiber 保持挂起,不注册任何内容。 + +## 模型体验 + +无,因为插件只计算面向客户端的、由已写入日志的会话事件派生的读模型,不触碰任何提示词、消息、schema、流或工具结果。 + +#### KV Cache 影响 + +无;插件从不组装或发送提供方请求。 + +## 已知局限与延后工作 + +- **步数统计的是已发生的工作,而非可见输出**——在产生任何可见内容前就失败的步仍以 `step/end` 关闭并计入;进程崩溃恰好截断在 `step/start` 与 `step/end` 之间的步不计。 +- **被取消的步计数但不计时**——没有组装出 assistant 消息,其部分流式时间不进入任何墙钟数字,与窗口折叠的无计时 interrupted 节点一致;反之 max-tokens 的 usage 宿主消息贡献 surface 上看不到的模型时间。 +- **计数是日志口径,不是 surface 口径**——消息后来被压缩掉的步仍然计入;数字描述整个会话,而非当前模型可见 surface。 +- **仅挂载于 web-app bundle**——其他装配不提供 `sessionStats` 键,其消费者回退到窗口口径计数(Web 统计条的回退路径)。 diff --git a/packages/session/session-stats/package.json b/packages/session/session-stats/package.json new file mode 100644 index 0000000000..f029ce83b9 --- /dev/null +++ b/packages/session/session-stats/package.json @@ -0,0 +1,62 @@ +{ + "name": "@deepseek-ai/dsh-session-stats", + "description": "Whole-log conversation counts and wall times projection (sessionStats) for the DeepSeek Harness", + "version": "0.0.1-rc.2", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/session/session-stats" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./client": { + "types": "./lib/types/client.d.ts", + "default": "./lib/types/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "dependencies": { + "zod": "^4.4.3" + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/session/session-stats/src/client.ts b/packages/session/session-stats/src/client.ts new file mode 100644 index 0000000000..9f6adc5356 --- /dev/null +++ b/packages/session/session-stats/src/client.ts @@ -0,0 +1,10 @@ +/** + * Client-namespace projection of the session-stats domain: a pure re-export + * of the package's types outlet. Client code imports ONLY the client + * namespace (repo discipline), so `./client` projects the same single-source + * content `./types` serves to host consumers — zero duplication. + * + * @module @deepseek-ai/dsh-session-stats/client + */ + +export type * from './types.ts' diff --git a/packages/session/session-stats/src/index.ts b/packages/session/session-stats/src/index.ts new file mode 100644 index 0000000000..54679b6e78 --- /dev/null +++ b/packages/session/session-stats/src/index.ts @@ -0,0 +1,29 @@ +/** + * Function plugin registering the `sessionStats` projection unit: whole-log + * turn/step counts and LLM/tool/first-token/decode wall times served through + * the session-projection seam (registry snapshot, change feed, and every + * projection carrier), so clients render full-session figures that paging and + * compaction cannot change. The plugin owns only the fold; delivery is the + * seam's. + * + * @module @deepseek-ai/dsh-session-stats + */ + +import type { Context } from '@deepseek-ai/cordis' +import { sessionStatsProjectionDefinition } from './projection.ts' + +export type * from './types.ts' + +/** Cordis plugin name. */ +export const name = 'session-stats' +/** The projection registry is the plugin's whole purpose; without it the fiber stays pending. */ +export const inject = ['sessionProjections'] + +/** + * Register the `sessionStats` unit; the registration is an effect on this + * plugin's fiber, so unloading removes the key. + * @param ctx - registrant context carrying the projection registry. + */ +export function apply(ctx: Context): void { + ctx.sessionProjections.register(sessionStatsProjectionDefinition) +} diff --git a/packages/session/session-stats/src/invariant.ts b/packages/session/session-stats/src/invariant.ts new file mode 100644 index 0000000000..582e5dcdb4 --- /dev/null +++ b/packages/session/session-stats/src/invariant.ts @@ -0,0 +1,35 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-session-stats`. + * @module @deepseek-ai/dsh-session-stats/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-session-stats' + +/** Cordis companion plugin name. */ +export const name = 'session-stats-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the package owns a single pure projection fold whose + * wire payload is schema-validated by the projection registry at every + * snapshot and change-feed emission, and the event relations the fold relies + * on (`step/end` exactly once per entered step, monotonic host-assigned turn + * numbers, chunk and tool events carrying their step coordinates and call + * ids) are owned and runtime-checked by dsh-agent-loop and the session + * surface, not here. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/session/session-stats/src/projection.ts b/packages/session/session-stats/src/projection.ts new file mode 100644 index 0000000000..e155622920 --- /dev/null +++ b/packages/session/session-stats/src/projection.ts @@ -0,0 +1,179 @@ +/** + * The `sessionStats` projection unit: a pure fold of step boundaries, stream + * chunks, tool pairs, and assembled assistant messages into whole-log counts + * and wall times. + * + * `step/end` — not `assistant/message` — is the counted step event because it + * is the step lifecycle authority: the loop appends exactly one per entered + * step, in a `finally`, so completed, failed, cancelled, and max-tokens steps + * all land one. Counting assembled assistant messages instead would overcount + * max-tokens usage-host messages (empty content, excluded from the surface) + * and undercount cancelled steps (aborted before the message assembles). + * + * The wall-time folds mirror the client window fold field by field + * (`deriveStats` in dsh-client-ui-conversation, that fold's whole-window + * fallback role): model time is `step/start` → `assistant/message`, first + * token is the first non-empty delta chunk and survives an in-step + * `llm/retry`, decode spans first token → assembled message on steps that + * also report output tokens, and tool time pairs `tool/call` → `tool/result` + * by callId. A cancelled step assembles no message, so its partial stream + * time stays uncounted in every time figure — matching the window, which + * renders it as an untimed interrupted node. + * + * @module @deepseek-ai/dsh-session-stats/projection + */ + +import { z } from 'zod' +import { isTokenDelta } from '@deepseek-ai/dsh-llm/message' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' + +/** Accumulated whole-log figures (the view is exactly these totals). */ +interface SessionStatsTotals { + /** Distinct turns with at least one closed step so far. */ + turns: number + /** Closed steps so far. */ + steps: number + /** Summed model wall time over message-assembling steps, ms. */ + llmMs: number + /** Summed matched tool call→result wall time, ms. */ + toolMs: number + /** Summed first-token latency over `ttftSteps`, ms. */ + ttftMs: number + /** Steps carrying a recorded first token. */ + ttftSteps: number + /** Summed decode wall time over usage-reporting steps, ms. */ + decodeMs: number + /** Summed provider output tokens over the same steps. */ + decodeTokens: number +} + +/** + * Fold state: the totals plus the in-flight boundaries they accrue from. + * Turn numbers are host-assigned and monotonic per session, so a single + * `lastTurn` slot decides "first closed step of a new turn"; the state is + * plain JSON per the unit contract (persisted-cache precondition). + */ +interface SessionStatsState extends SessionStatsTotals { + /** Turn of the last counted `step/end`; null before the first. */ + lastTurn: number | null + /** The open step's boundary facts; null outside a step or after its message assembled. */ + openStep: { turn: number; step: number; startTime: number; firstTokenTime: number | null } | null + /** Dispatch times of tool calls whose result has not landed, by callId. */ + pendingCalls: Record +} + +const sessionStatsSchema = z.object({ + turns: z.number().int().nonnegative(), + steps: z.number().int().nonnegative(), + llmMs: z.number().nonnegative(), + toolMs: z.number().nonnegative(), + ttftMs: z.number().nonnegative(), + ttftSteps: z.number().int().nonnegative(), + decodeMs: z.number().nonnegative(), + decodeTokens: z.number().nonnegative(), +}).strict() + +/** + * Provider-reported completion tokens, guarded the way the window fold guards + * node usage. + * @param usage - the assistant/message event's optional usage record. + * @returns the output-token count, or null when unreported or invalid. + */ +function usageOutputTokens(usage: unknown): number | null { + if (typeof usage !== 'object' || usage === null) return null + const value = (usage as { outputTokens?: unknown }).outputTokens + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null +} + +/** The `sessionStats` unit registered on `ctx.sessionProjections` (exported for the unit spec). */ +export const sessionStatsProjectionDefinition: ProjectionDefinition<'sessionStats', SessionStatsState> = { + key: 'sessionStats', + schema: sessionStatsSchema, + init: () => ({ + turns: 0, + steps: 0, + llmMs: 0, + toolMs: 0, + ttftMs: 0, + ttftSteps: 0, + decodeMs: 0, + decodeTokens: 0, + lastTurn: null, + openStep: null, + pendingCalls: {}, + }), + apply: (state, event) => { + // Every uninteresting event returns the same reference (Object.is gates the change feed). + switch (event.type) { + case 'step/start': + return { + ...state, + openStep: { turn: event.data.turn, step: event.data.step, startTime: event.time, firstTokenTime: null }, + } + case 'assistant/chunk': { + const open = state.openStep + if (open === null || open.turn !== event.data.turn || open.step !== event.data.step) return state + if (open.firstTokenTime !== null || !isTokenDelta(event.data.chunk)) return state + return { ...state, openStep: { ...open, firstTokenTime: event.time } } + } + case 'assistant/message': { + const open = state.openStep + if (open === null || open.turn !== event.data.turn || open.step !== event.data.step) return state + // One assembled message per step: closing the boundary means a + // defensive duplicate cannot accrue twice. + const next: SessionStatsState = { + ...state, + llmMs: state.llmMs + Math.max(0, event.time - open.startTime), + openStep: null, + } + if (open.firstTokenTime !== null) { + next.ttftMs += Math.max(0, open.firstTokenTime - open.startTime) + next.ttftSteps += 1 + const outputTokens = usageOutputTokens(event.data.usage) + if (outputTokens !== null) { + next.decodeMs += Math.max(0, event.time - open.firstTokenTime) + next.decodeTokens += outputTokens + } + } + return next + } + case 'tool/call': + return { ...state, pendingCalls: { ...state.pendingCalls, [event.data.callId]: event.time } } + case 'tool/result': { + const callId = event.data.message.source.callId + const dispatched = state.pendingCalls[callId] + if (dispatched === undefined) return state + const pendingCalls = Object.fromEntries( + Object.entries(state.pendingCalls).filter(([id]) => id !== callId), + ) + return { ...state, toolMs: state.toolMs + Math.max(0, event.time - dispatched), pendingCalls } + } + case 'step/end': + return { + ...state, + turns: state.lastTurn === event.data.turn ? state.turns : state.turns + 1, + steps: state.steps + 1, + lastTurn: event.data.turn, + openStep: null, + } + case 'turn/end': + // A call whose result never landed belongs to a cancelled or failed + // turn; results always land within their turn, so drop the leftovers + // instead of growing persisted state forever. + return Object.keys(state.pendingCalls).length === 0 ? state : { ...state, pendingCalls: {} } + default: + return state + } + }, + view: state => ({ + turns: state.turns, + steps: state.steps, + llmMs: state.llmMs, + toolMs: state.toolMs, + ttftMs: state.ttftMs, + ttftSteps: state.ttftSteps, + decodeMs: state.decodeMs, + decodeTokens: state.decodeTokens, + }), + stateVersion: 2, +} diff --git a/packages/session/session-stats/src/types.ts b/packages/session/session-stats/src/types.ts new file mode 100644 index 0000000000..e11a300b19 --- /dev/null +++ b/packages/session/session-stats/src/types.ts @@ -0,0 +1,46 @@ +/** + * Pure types of the session-stats domain: the ONE home of the `sessionStats` + * projection-key declaration, free of this package's host-side value imports + * (cordis context, zod, the llm chunk predicate). Two namespace projections + * serve it — `./types` for host consumers, `./client` for client aggregates — + * with zero content duplication. + * + * @module @deepseek-ai/dsh-session-stats/types + */ + +// Marks this file a module so the declaration below AUGMENTS the projection +// table instead of declaring an ambient module. +export {} + +/** + * Whole-log conversation figures, independent of how much history a client + * has paged in. Counts and wall times all fold from the complete durable log; + * every field is 0 until its first contributing event lands. Field names + * mirror the client window fold so an assembly without this unit can fall + * back to it wholesale. + */ +export interface SessionStatsProjection { + /** Distinct turns carrying at least one closed step (`step/end`); rejected or empty turns are uncounted. */ + turns: number + /** Closed steps (`step/end` events) — completed, failed, and cancelled steps alike. */ + steps: number + /** Summed model wall time (`step/start` → `assistant/message`) over steps that assembled a message. */ + llmMs: number + /** Summed tool wall time over `tool/call` → `tool/result` pairs matched by callId. */ + toolMs: number + /** Summed first-token latency (`step/start` → first non-empty delta chunk) over `ttftSteps`. */ + ttftMs: number + /** Steps carrying a recorded first token. */ + ttftSteps: number + /** Summed decode wall time (first token → `assistant/message`) over steps that also report output tokens. */ + decodeMs: number + /** Summed provider output tokens over the same decode-timed steps. */ + decodeTokens: number +} + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + /** Whole-log turn/step counts and wall times; see {@link SessionStatsProjection}. */ + sessionStats: SessionStatsProjection + } +} diff --git a/packages/session/session-stats/tests/loader-composition.spec.ts b/packages/session/session-stats/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..e3889eaeb4 --- /dev/null +++ b/packages/session/session-stats/tests/loader-composition.spec.ts @@ -0,0 +1,86 @@ +/** + * REAL-composition proof: the shipped YAML shape (session + projection + * registry + session-stats) boots through the vendored Loader, the function + * plugin's namespace survives (no default export), and a full logged turn + * serves `{turns: 1, steps: 1}` through the composed registry. + */ + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import * as SessionStatsPlugin from '@deepseek-ai/dsh-session-stats' + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +async function loadYaml(lines: readonly string[]): Promise { + root = await mkdtemp(join(tmpdir(), 'dsh-session-stats-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [...lines, ''].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + context.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-session', SessionStore], + ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], + ['@deepseek-ai/dsh-session-stats', SessionStatsPlugin], + ]) + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await context.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await context.loader.await() + return context +} + +describe('real Loader composition', () => { + it('loads the shipped session-stats YAML shape and serves whole-log counts', async () => { + const loaded = await loadYaml([ + "- name: '@deepseek-ai/dsh-session'", + "- name: '@deepseek-ai/dsh-session-projection'", + "- name: '@deepseek-ai/dsh-session-stats'", + ]) + + const unloaded = [...loaded.loader.entries()] + .filter(entry => entry.fiber === undefined && !entry.disabled) + .map(entry => entry.options.name) + expect(unloaded).toEqual([]) + + const session = loaded.sessions.create(SessionId('composed')) + session.append('turn/start', { turn: 1 }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + expect(loaded.sessionProjections.snapshot(session).values.sessionStats) + .toMatchObject({ turns: 1, steps: 1 }) + }) + + it('keeps the function-plugin namespace free of a default export', () => { + // A default export beside the named form makes the Loader discard the + // namespace (postmortem 0001) — pin its absence. + expect('default' in SessionStatsPlugin).toBe(false) + }) +}) diff --git a/packages/session/session-stats/tests/projection.spec.ts b/packages/session/session-stats/tests/projection.spec.ts new file mode 100644 index 0000000000..fce09b4e9e --- /dev/null +++ b/packages/session/session-stats/tests/projection.spec.ts @@ -0,0 +1,271 @@ +/** + * The `sessionStats` projection unit: mounting the plugin beside the + * projection registry serves whole-log counts and wall times folded from step + * boundaries, chunks, tool pairs, and assembled messages; compositions + * without the registry are unaffected; unmounting the plugin removes the key + * (HMR safety). The two counting regressions pinned here are the reasons the + * fold counts step boundaries instead of assistant messages: a cancelled step + * never assembles a message but still counts, and a max-tokens usage-host + * message (empty content) adds no extra step. Wall-time math runs against the + * exported definition directly, where event times are controlled. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { createMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import * as SessionStatsPlugin from '@deepseek-ai/dsh-session-stats' +import { sessionStatsProjectionDefinition } from '@deepseek-ai/dsh-session-stats/src/projection.ts' +import type { SessionStatsProjection } from '@deepseek-ai/dsh-session-stats/types' + +async function harness(withStatsPlugin: boolean): Promise<{ ctx: Context; session: Session }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + if (withStatsPlugin) await ctx.plugin(SessionStatsPlugin) + return { ctx, session: ctx.sessions.create(SessionId('counted')) } +} + +/** Close one step; returns the counted `step/end` seq. */ +function closeStep(session: Session, turn: number, step: number): number { + session.append('step/start', { turn, step }) + return session.append('step/end', { turn, step }).seq +} + +/** Append the max-tokens usage-host shape: an assistant/message with empty content. */ +function appendEmptyAssistantMessage(session: Session, turn: number, step: number): void { + session.append('assistant/message', { + turn, + step, + message: createMessage({ + role: 'assistant', + content: [], + source: { kind: 'model', provider: 'mock', model: 'mock' }, + }), + }, { surfaceOp: 'append', sourceEventSeqs: [] }) +} + +/** The all-zero projection value plus overrides, for exact fold expectations. */ +function totals(overrides: Partial = {}): SessionStatsProjection { + return { + turns: 0, steps: 0, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0, + ...overrides, + } +} + +describe('sessionStats projection unit (registry drive)', () => { + it('serves zero figures on the empty log', async () => { + const { ctx, session } = await harness(true) + expect(ctx.sessionProjections.snapshot(session).values.sessionStats).toEqual(totals()) + }) + + it('counts distinct turns and closed steps and notifies the change feed with the causing seq', async () => { + const { ctx, session } = await harness(true) + const changes: { key: string; value: unknown; seq: number }[] = [] + ctx.sessionProjections.onChanged((_session, key, value, seq) => { + changes.push({ key, value, seq }) + }) + session.append('turn/start', { turn: 1 }) + const firstSeq = closeStep(session, 1, 1) + const secondSeq = closeStep(session, 1, 2) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + session.append('turn/start', { turn: 2 }) + const thirdSeq = closeStep(session, 2, 1) + session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + // Boundary events that carry no figure change (turn/start, empty-prune + // turn/end, user input) fold to the same reference and stay silent; + // step/start opens a boundary (internal state) and step/end commits the + // counts, so each closed step notifies twice with the step/end value last. + const counted = changes.filter(change => (change.value as SessionStatsProjection).steps > 0 + || change.seq === firstSeq) + expect(changes.every(change => change.key === 'sessionStats')).toBe(true) + expect(counted.map(change => ({ seq: change.seq, value: change.value }))).toContainEqual( + { seq: firstSeq, value: totals({ turns: 1, steps: 1 }) }, + ) + expect(changes.at(-1)).toEqual({ key: 'sessionStats', value: totals({ turns: 2, steps: 3 }), seq: thirdSeq }) + const snapshot = ctx.sessionProjections.snapshot(session) + expect(snapshot.values.sessionStats).toEqual(totals({ turns: 2, steps: 3 })) + expect(snapshot.asOfSeq).toBe(session.seq - 1) + expect(changes.map(change => change.seq)).toContain(secondSeq) + }) + + it('does not count a rejected or empty turn that closes with no step', async () => { + const { ctx, session } = await harness(true) + session.append('turn/start', { turn: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'blocked' } }) + expect(ctx.sessionProjections.snapshot(session).values.sessionStats).toEqual(totals()) + }) + + it('counts a cancelled step that closed without an assistant message', async () => { + // Regression: an aborted stream never assembles assistant/message, but the + // loop's finally still appends step/end — the step happened and counts. + const { ctx, session } = await harness(true) + session.append('turn/start', { turn: 1 }) + closeStep(session, 1, 1) + session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'legacy' } } }) + expect(ctx.sessionProjections.snapshot(session).values.sessionStats) + .toMatchObject({ turns: 1, steps: 1 }) + }) + + it('adds no extra step for a max-tokens usage-host assistant message', async () => { + // Regression: the empty-content assistant/message exists only to host + // usage and is excluded from the surface; the step counts once, from its + // step/end, while the message contributes only its model wall time. + const { ctx, session } = await harness(true) + session.append('turn/start', { turn: 1 }) + session.append('step/start', { turn: 1, step: 1 }) + appendEmptyAssistantMessage(session, 1, 1) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } }) + expect(ctx.sessionProjections.snapshot(session).values.sessionStats) + .toMatchObject({ turns: 1, steps: 1, ttftSteps: 0, decodeTokens: 0 }) + }) + + it('folds steps already in the log when the plugin mounts late (lazy cell build)', async () => { + const { ctx, session } = await harness(false) + session.append('turn/start', { turn: 1 }) + closeStep(session, 1, 1) + closeStep(session, 1, 2) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await ctx.plugin(SessionStatsPlugin) + expect(ctx.sessionProjections.snapshot(session).values.sessionStats) + .toMatchObject({ turns: 1, steps: 2 }) + }) + + it('has no sessionStats key without the plugin, and drops it when the plugin unloads (HMR safety)', async () => { + const { ctx, session } = await harness(false) + expect('sessionStats' in ctx.sessionProjections.snapshot(session).values).toBe(false) + const fiber = await ctx.plugin(SessionStatsPlugin) + session.append('turn/start', { turn: 1 }) + closeStep(session, 1, 1) + expect(ctx.sessionProjections.snapshot(session).values.sessionStats) + .toMatchObject({ turns: 1, steps: 1 }) + await fiber.dispose() + expect('sessionStats' in ctx.sessionProjections.snapshot(session).values).toBe(false) + }) +}) + +/** Build one synthetic committed event with a controlled timestamp. */ +function at(time: number, type: string, data: unknown): SessionEvent { + return { type, seq: time, time, data } as unknown as SessionEvent +} + +/** Fold a synthetic event list through the definition and view the result. */ +function fold(events: readonly SessionEvent[]): SessionStatsProjection { + const state = events.reduce( + (folded, event) => sessionStatsProjectionDefinition.apply(folded, event), + sessionStatsProjectionDefinition.init(), + ) + return sessionStatsProjectionDefinition.view(state) +} + +describe('sessionStats wall-time fold (controlled timestamps)', () => { + const message = createMessage({ + role: 'assistant', + content: [{ type: 'text', text: 'answer' }], + source: { kind: 'model', provider: 'mock', model: 'mock' }, + }) + + it('accrues model, first-token, and decode time from one fully recorded step', () => { + expect(fold([ + at(1_000, 'step/start', { turn: 1, step: 1 }), + at(1_800, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'a' } }), + at(4_800, 'assistant/message', { turn: 1, step: 1, message, usage: { inputTokens: 10, outputTokens: 60 } }), + at(4_900, 'step/end', { turn: 1, step: 1 }), + ])).toEqual(totals({ + turns: 1, steps: 1, llmMs: 3_800, ttftMs: 800, ttftSteps: 1, decodeMs: 3_000, decodeTokens: 60, + })) + }) + + it('keeps the first attempt token boundary across an in-step retry (window resetForRetry parity)', () => { + expect(fold([ + at(1_000, 'step/start', { turn: 1, step: 1 }), + at(1_200, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'x' } }), + at(2_000, 'llm/retry', { turn: 1, step: 1 }), + at(3_000, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'y' } }), + at(5_000, 'assistant/message', { turn: 1, step: 1, message }), + at(5_100, 'step/end', { turn: 1, step: 1 }), + ])).toEqual(totals({ turns: 1, steps: 1, llmMs: 4_000, ttftMs: 200, ttftSteps: 1 })) + }) + + it('ignores empty deltas, non-token chunks, and chunks outside the open step', () => { + expect(fold([ + // Chunk before any step/start: no open boundary. + at(500, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'stray' } }), + at(1_000, 'step/start', { turn: 1, step: 1 }), + at(1_100, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } }), + at(1_200, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '' } }), + at(1_300, 'assistant/chunk', { turn: 2, step: 9, chunk: { type: 'text-delta', index: 0, text: 'other' } }), + at(1_400, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' } }), + at(2_000, 'assistant/message', { turn: 1, step: 1, message }), + at(2_100, 'step/end', { turn: 1, step: 1 }), + ])).toEqual(totals({ turns: 1, steps: 1, llmMs: 1_000, ttftMs: 400, ttftSteps: 1 })) + }) + + it('leaves a cancelled step untimed: counted by step/end, no assembled message to accrue from', () => { + expect(fold([ + at(1_000, 'step/start', { turn: 1, step: 1 }), + at(1_500, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'partial' } }), + at(2_000, 'step/end', { turn: 1, step: 1 }), + ])).toEqual(totals({ turns: 1, steps: 1 })) + }) + + it('pairs tool wall time by callId, ignores orphan results, and prunes leftovers at turn/end', () => { + const result = (callId: string): unknown => + ({ turn: 1, step: 1, message: { source: { kind: 'tool', callId } } }) + const paired = fold([ + at(1_000, 'step/start', { turn: 1, step: 1 }), + at(1_100, 'tool/call', { turn: 1, step: 1, callId: 'a', name: 'read', arguments: '{}' }), + at(1_200, 'tool/call', { turn: 1, step: 1, callId: 'b', name: 'read', arguments: '{}' }), + // Out-of-order settlement pairs by id, not adjacency. + at(4_200, 'tool/result', result('b')), + at(1_600, 'tool/result', result('a')), + at(5_000, 'tool/result', result('ghost')), + at(5_100, 'step/end', { turn: 1, step: 1 }), + ]) + expect(paired).toEqual(totals({ turns: 1, steps: 1, toolMs: 3_500 })) + // An unresolved call is dropped at turn/end; a later result cannot pair. + const pruned = fold([ + at(1_000, 'step/start', { turn: 1, step: 1 }), + at(1_100, 'tool/call', { turn: 1, step: 1, callId: 'orphan', name: 'read', arguments: '{}' }), + at(2_000, 'step/end', { turn: 1, step: 1 }), + at(2_100, 'turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'legacy' } } }), + at(9_000, 'tool/result', result('orphan')), + ]) + expect(pruned).toEqual(totals({ turns: 1, steps: 1 })) + }) + + it('skips decode for an invalid usage report and ignores a duplicate assembled message', () => { + const events = [ + at(1_000, 'step/start', { turn: 1, step: 1 }), + at(1_400, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'a' } }), + // A malformed provider report: guarded like the window fold guards node usage. + at(2_000, 'assistant/message', { turn: 1, step: 1, message, usage: { inputTokens: 1, outputTokens: -5 } }), + ] + expect(fold([...events, at(2_100, 'step/end', { turn: 1, step: 1 })])) + .toEqual(totals({ turns: 1, steps: 1, llmMs: 1_000, ttftMs: 400, ttftSteps: 1 })) + // The first message closed the step boundary; a defensive duplicate finds + // no open step and folds to the same reference. + const state = events.reduce( + (folded, event) => sessionStatsProjectionDefinition.apply(folded, event), + sessionStatsProjectionDefinition.init(), + ) + expect(sessionStatsProjectionDefinition.apply( + state, + at(2_050, 'assistant/message', { turn: 1, step: 1, message }), + )).toBe(state) + }) + + it('accrues nothing for unrelated events and clamps negative clock skew to zero', () => { + const state = sessionStatsProjectionDefinition.init() + const untouched = sessionStatsProjectionDefinition.apply(state, at(1, 'user/message', { content: [] })) + expect(untouched).toBe(state) + expect(fold([ + at(2_000, 'step/start', { turn: 1, step: 1 }), + at(1_000, 'assistant/message', { turn: 1, step: 1, message }), + at(2_100, 'step/end', { turn: 1, step: 1 }), + ])).toEqual(totals({ turns: 1, steps: 1 })) + }) +}) diff --git a/packages/session/session-stats/tsconfig.json b/packages/session/session-stats/tsconfig.json new file mode 100644 index 0000000000..a6ed34022e --- /dev/null +++ b/packages/session/session-stats/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../support/invariants" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../session-projection" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e71c12536..fa1a181ac9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1665,6 +1665,9 @@ importers: '@deepseek-ai/dsh-session-projection-cache': specifier: workspace:^ version: link:../../session/session-projection-cache + '@deepseek-ai/dsh-session-stats': + specifier: workspace:^ + version: link:../../session/session-stats '@deepseek-ai/dsh-storage': specifier: workspace:^ version: link:../../storage/storage @@ -2149,6 +2152,9 @@ importers: '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../session/session-projection + '@deepseek-ai/dsh-session-stats': + specifier: workspace:^ + version: link:../../session/session-stats '@deepseek-ai/dsh-token-meter': specifier: workspace:^ version: link:../../llm/token-meter @@ -6195,6 +6201,34 @@ importers: specifier: workspace:^ version: link:../../storage/storage-domain + packages/session/session-stats: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../session-projection + packages/session/session-telemetry: devDependencies: '@deepseek-ai/cordis': diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index b11a8845e8..3a9ada1fb9 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -120,6 +120,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/sdk/protocol': { kind: 'none', reason: 'Client-facing wire library; the runtime plugins behind the serving entry own model-facing behavior.' }, 'packages/session/session-projection': { kind: 'none', reason: 'The projection registry serves client-facing read models of already-logged session state and registers nothing model-facing.' }, 'packages/session/session-projection-cache': { kind: 'none', reason: 'The persisted cache accelerates host-side cold reads of projection state and registers nothing model-facing.' }, + 'packages/session/session-stats': { kind: 'none', reason: 'The sessionStats unit folds already-logged step boundaries into a client-facing read model and registers nothing model-facing.' }, 'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers nothing model-facing.' }, 'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers nothing model-facing.' }, 'packages/settings/settings': { kind: 'indirect', reason: 'The seam stores and resolves user settings; consumer plugins own any model-facing content fed by a value.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index ff8e58e361..605abb00d9 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -58,6 +58,8 @@ "@deepseek-ai/dsh-tool-todo/client": ["./packages/todo/tool-todo/src/client.ts"], "@deepseek-ai/dsh-session-title/types": ["./packages/session/session-title/src/types.ts"], "@deepseek-ai/dsh-session-title/client": ["./packages/session/session-title/src/client.ts"], + "@deepseek-ai/dsh-session-stats/types": ["./packages/session/session-stats/src/types.ts"], + "@deepseek-ai/dsh-session-stats/client": ["./packages/session/session-stats/src/client.ts"], "@deepseek-ai/dsh-plan-mode/types": ["./packages/plan/plan-mode/src/types.ts"], "@deepseek-ai/dsh-plan-mode/client": ["./packages/plan/plan-mode/src/client.ts"], "@deepseek-ai/dsh-agent-presets/types": ["./packages/preset/agent-presets/src/types.ts"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 118078eb0d..51b46420fa 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -35,6 +35,7 @@ "apps/web/tests/replay-round-trip.e2e.ts", "apps/web/tests/hmr-live.e2e.ts", "apps/web/tests/seeded-history.e2e.ts", + "apps/web/tests/stats-paged-history.e2e.ts", "apps/web/tests/sidebar-scrollbar.e2e.ts", "apps/web/tests/conversation-column-overflow.e2e.ts", "apps/web/tests/code-mode-round.e2e.ts", @@ -137,6 +138,7 @@ { "path": "./packages/session/session-persistence-sqlite" }, { "path": "./packages/session/session-projection" }, { "path": "./packages/session/session-projection-cache" }, + { "path": "./packages/session/session-stats" }, { "path": "./packages/session-query/session-query" }, { "path": "./packages/session-query/session-query-sqlite" }, { "path": "./packages/settings/settings" }, From ca5e67490e0ae650823abc6e2994d651eb6b63c4 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 12 Aug 2026 21:23:06 +0800 Subject: [PATCH 10/13] docs: recommend agent-assisted architecture exploration --- docs/architecture.i18n.yaml | 4 ++-- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index d348645119..5b69ce4564 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: e0909c7924fcb9a8b29b3a3426c847e9ca7f4dfb -architecture.zh.md: b35088aa2a00623eacb2d53b19c86d9c192cd1d0 +architecture.md: bd14a0645c13f9831f57e1966b53b82bd691dbb7 +architecture.zh.md: fd449382d7989c6874e8e8cc04096976fe920418 diff --git a/docs/architecture.md b/docs/architecture.md index e0909c7924..bd14a0645c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,7 +4,7 @@ English | [中文](architecture.zh.md) Read this before changing anything under `packages/`. It assumes you know Cordis; if you do not, start with the [primer](cordis-primer.md) or the [tutorial](cordis-tutorial/index.md). -The repository is large; use an agent to explore it. +We recommend using an agent to explore the codebase and understand its architecture. ## Cordis diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index b35088aa2a..fd449382d7 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -4,7 +4,7 @@ 改动 `packages/` 下的任何内容之前,请先阅读本文。本文假定你已了解 Cordis;如果尚未了解,请先阅读[入门](cordis-primer.md)或[教程](cordis-tutorial/index.md)。 -仓库很大;请借助 agent(智能体)来探索它。 +建议使用 agent(智能体)探索代码库并理解其架构。 ## Cordis From bf9eb7b5381a64b605d33bbd466545eeac9a8b09 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Wed, 12 Aug 2026 06:37:56 -0700 Subject: [PATCH 11/13] docs: repair merged translation records --- .../feature/2026-08-10-telemetry-default-off.i18n.yaml | 2 -- .../feature/2026-08-10-telemetry-default-off.zh.md | 1 - packages/client/README.i18n.yaml | 4 +--- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml index 751b4454f8..fabb554407 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml @@ -4,5 +4,3 @@ # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md 2026-08-10-telemetry-default-off.md: c3e5d9e0b65449f91044f16e7649f4a5ff2f5b61 2026-08-10-telemetry-default-off.zh.md: 8e2544eb7dee8b9bd6b8a4c81a28a8a03d3404d4 -2026-08-10-telemetry-default-off.md: 8163079eb5f8d6170e329c164141364681030793 -2026-08-10-telemetry-default-off.zh.md: c8e16f84248bde5bdc2c1d4bdb814f0d80fcf77e diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md index 4671e1f6a1..8e2544eb7d 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md @@ -13,7 +13,6 @@ DeepSeek Harness 有两路出站遥测数据流。在内测阶段,共享基础 两路数据流都使用 `DSH_TELEMETRY_MODE` 作为正向授权配置。未设置和空值都解析为 `DISABLED`。`@deepseek-ai/dsh-session-telemetry-otel` 也将省略的 `mode` 解析为 `DISABLED`;该模式不构造 OTel 提供方、处理器或导出器,并将反馈留在本地会话日志中。dsh 共享基础配置继续挂载后端配置行,使禁用模式仍可在记录反馈时说明没有共享任何内容。部署方通过 `FULL` 或 `FEEDBACK_ONLY` 显式启用 Session Log 共享;只有 `FULL` 还允许 dsh-sdk 启动器上报。任何非空 `DSH_TELEMETRY_DISABLED` 仍是具有最高优先级的加载前硬性退出开关。[默认挂载决策](2026-07-31-web-telemetry-default-mount.md)继续负责 endpoint、批处理节奏和退出排空设置。 dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cordis。`FULL` 允许上报;`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝。授权在命令执行前从启动环境冻结:`dsh-sdk start` 会加载项目 `.env`,项目代码也能修改 `process.env`,若在执行后解析,项目便能自行授权上报其自身配置,而[配置来源所有权决策](../architecture/2026-08-04-configuration-source-ownership.md)对整个 `DSH_*` 命名空间禁止这种行为。在该边界上,不受支持的模式按拒绝处理而非抛出,因为遥测不得改变命令结果。此规则在启动器及其提案被[SDK 项目工具链移除决策](../simplification/2026-08-11-remove-sdk-project-toolchain.md)删除之前,仅取代了启动器默认允许上报的规则。 -dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cordis。`FULL` 允许上报;`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝。授权在命令执行前从启动环境冻结:`dsh-sdk start` 会加载项目 `.env`,项目代码也能修改 `process.env`,若在执行后解析,项目便能自行授权上报其自身配置,而[配置来源所有权决策](../architecture/2026-08-04-configuration-source-ownership.md)对整个 `DSH_*` 命名空间禁止这种行为。在该边界上,不受支持的模式按拒绝处理而非抛出,因为遥测不得改变命令结果。遥测授权由本说明持有;SDK 项目配置或工具链不得代替启动环境显式启用遥测。 带版本的 Web 欢迎通知说明会话日志上传默认关闭,将 `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 和 `DSH_TELEMETRY_MODE=FULL` 列为两种显式启用选项,并披露 `FULL` 同时会启用 dsh-sdk 命令遥测。其版本随这项重要的隐私声明一同变更,使每个 profile 都确认当前文案。 diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index cb78dff700..fe9c9e40a6 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -3,6 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/README.md README.md: b9452d1f763be5be6953cb7973da8a2c909ed979 -README.zh.md: 1225101cff05a3f5655c4660d3d6d86be47d9a30 -README.md: 236531281c17ef982982e97caad99491584bd0b5 -README.zh.md: 73bc3e31c90a4f12c4c5f11e9fd0552601dcc7a6 +README.zh.md: 0dfb6e6b6d619f111e36d5bdf57d127ac1ca9ef5 From 62a437c082b5fd1740247e25eab783dc5abf4e10 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Wed, 12 Aug 2026 21:43:35 +0800 Subject: [PATCH 12/13] review: address ds-review-bot findings on session-stats - stateVersion starts at 1: the package is new, no persisted rows predate it - pendingCalls pairs by own key so a provider callId naming a prototype property cannot fold toolMs to NaN on an unmatched crash-recovery result - StatsLine folds the window fallback only when no sessionStats value is served, and gates the token group on actual token activity instead of steps, so failed-only sessions drop the zero-token group - correct the crash-step counting semantics in the README and Agent Note: recovery closes interrupted steps with a synthetic step/end on reload - reword the window-scoped alternative as a plain rejected option and name isTokenDelta's home beside the StreamChunk type --- ...12-full-session-turn-step-counts.i18n.yaml | 4 ++-- ...026-08-12-full-session-turn-step-counts.md | 6 +++--- ...-08-12-full-session-turn-step-counts.zh.md | 6 +++--- apps/web/tests/math-rendering.e2e.ts | 2 +- .../live-interactions/cancel.expected.md | 2 +- .../live-interactions/error-auth.expected.md | 2 +- .../markdown-cjk-strong/ui.expected.md | 2 +- .../snapshots/markdown-images/ui.expected.md | 2 +- .../markdown-inline-code-links/ui.expected.md | 2 +- .../snapshots/math-rendering/ui.expected.md | 2 +- .../queue-actions/preserved.expected.md | 2 +- .../stats-paged-history/ui.expected.md | 2 +- .../src/client/sessions/assistant-timing.ts | 2 +- .../src/client/chat/StatsLine.tsx | 13 +++++++----- .../tests/chat-stats.client.spec.tsx | 11 ++++++++++ .../session/session-stats/README.i18n.yaml | 4 ++-- packages/session/session-stats/README.md | 2 +- packages/session/session-stats/README.zh.md | 2 +- .../session/session-stats/src/projection.ts | 8 +++++-- .../session-stats/tests/projection.spec.ts | 21 +++++++++++++++++++ 20 files changed, 68 insertions(+), 29 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.i18n.yaml index f50df929a7..18ed791f68 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.md -2026-08-12-full-session-turn-step-counts.md: 7cea57e429d3ffc2da49c4a1359ee3489663ccc5 -2026-08-12-full-session-turn-step-counts.zh.md: 93e46cfb7ffa4f397b85ee80b26cca9f664d246a +2026-08-12-full-session-turn-step-counts.md: ecfa00dc3e24101953a9d5a724dba17682d839bd +2026-08-12-full-session-turn-step-counts.zh.md: 85e2a26b5af51296e20f29af49e909c6e182ea05 diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.md b/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.md index 7cea57e429..ecfa00dc3e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.md +++ b/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.md @@ -10,7 +10,7 @@ The web chat stats strip folded `StatsLine`'s loaded conversation window (`deriv ## Decision -A new function plugin `@deepseek-ai/dsh-session-stats` registers a `sessionStats` projection unit on `ctx.sessionProjections`, mounted as a web-app bundle row. The value carries the strip's whole non-token figure set — `{ turns, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens }`, field names mirroring the window fold so the two swap wholesale. `steps` counts `step/end` events and `turns` counts distinct turns carrying at least one (turn numbers are monotonic, so one `lastTurn` slot suffices); `llmMs` sums `step/start` → `assistant/message`; TTFT records the first non-empty delta chunk per step (surviving in-step `llm/retry`, the window `resetForRetry` parity); decode spans first token → assembled message on usage-reporting steps; `toolMs` pairs `tool/call` → `tool/result` by callId with unresolved calls dropped at `turn/end`. The first-token predicate `isTokenDelta` moved to `@deepseek-ai/dsh-llm/message` (the `StreamChunk` vocabulary owner) so the host fold and the client timing index share one implementation; client-runtime re-exports it. Delivery is entirely the existing projection seam — history tail-page block, `session/projection` push frames, list rows — with zero changes to apiproxy, wire schemas, or the client runtime. `StatsLine` reads `useProjection('sessionStats')` and falls back to the window fold when the key is undefined (an assembly without the unit). The client connection fixture mirrors the fold as `sessionStatsOf` under its existing every-composed-key discipline. +A new function plugin `@deepseek-ai/dsh-session-stats` registers a `sessionStats` projection unit on `ctx.sessionProjections`, mounted as a web-app bundle row. The value carries the strip's whole non-token figure set — `{ turns, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens }`, field names mirroring the window fold so the two swap wholesale. `steps` counts `step/end` events and `turns` counts distinct turns carrying at least one (turn numbers are monotonic, so one `lastTurn` slot suffices); `llmMs` sums `step/start` → `assistant/message`; TTFT records the first non-empty delta chunk per step (surviving in-step `llm/retry`, the window `resetForRetry` parity); decode spans first token → assembled message on usage-reporting steps; `toolMs` pairs `tool/call` → `tool/result` by callId with unresolved calls dropped at `turn/end`. The first-token predicate `isTokenDelta` moved to `@deepseek-ai/dsh-llm/message` (beside the `StreamChunk` type it discriminates) so the host fold and the client timing index share one implementation; client-runtime re-exports it. Delivery is entirely the existing projection seam — history tail-page block, `session/projection` push frames, list rows — with zero changes to apiproxy, wire schemas, or the client runtime. `StatsLine` reads `useProjection('sessionStats')` and falls back to the window fold when the key is undefined (an assembly without the unit). The client connection fixture mirrors the fold as `sessionStatsOf` under its existing every-composed-key discipline. `step/end` — not `assistant/message` — is the counted event, for two correctness reasons found while reviewing the obvious message-counting design: @@ -31,8 +31,8 @@ A new function plugin `@deepseek-ai/dsh-session-stats` registers a `sessionStats **Fold the full log client-side.** The client holds only the paged window by design; the projection RFC's no-client-folding rule exists exactly so figures survive paging, compaction, and cold reads. -**Keep wall times, TTFT, and throughput window-scoped.** The first shipped cut did, reading them as "what is on screen"; the same paging complaint immediately applied to the LLM duration, and a strip mixing whole-log counts with window-scoped times reads as one inconsistent figure set. The projection now carries the whole set, with the window fold demoted to the no-unit fallback. +**Keep wall times, TTFT, and throughput window-scoped, reading them as "what is on screen".** Rejected: the same paging complaint applies to the LLM duration, and a strip mixing whole-log counts with window-scoped times reads as one inconsistent figure set. The projection carries the whole set, with the window fold demoted to the no-unit fallback. ## Consequences -The strip shows whole-log figures from the first tail page; paging leaves every group fixed. Defined edge differences from the old window semantics are documented in the package README: a step that produced no visible output (failed before content) still counts, a step truncated by a crash between `step/start` and `step/end` does not, a cancelled step is counted but contributes no wall time (no message assembled), and a max-tokens usage-host message contributes model time the surface does not show. Every web tail page and list row carries one more small key, and the unit's internal state changes on step boundaries and first-token chunks, so the change feed emits a few value-identical frames per step; TUI and headless assemblies serve no `sessionStats` key and any consumer falls back to window folding. Two e2e probes that had parsed the strip as a loaded-window measure (`chat-scroll-contract`, `complex-history.perf`) now count mounted flow rows / turn-tail footers instead. The `stats-paged-history` web scenario seeds a 28-turn log cold and pins that the whole strip reads full totals on a partial tail page and does not move across Load earlier. +The strip shows whole-log figures from the first tail page; paging leaves every group fixed. Defined edge differences from the old window semantics are documented in the package README: a step that produced no visible output (failed before content) still counts, a step interrupted by a crash counts once recovery closes it with a synthetic `step/end` on reload (`interruptedTurnClosers`), a cancelled step is counted but contributes no wall time (no message assembled), and a max-tokens usage-host message contributes model time the surface does not show. Every web tail page and list row carries one more small key, and the unit's internal state changes on step boundaries and first-token chunks, so the change feed emits a few value-identical frames per step; TUI and headless assemblies serve no `sessionStats` key and any consumer falls back to window folding. Two e2e probes that had parsed the strip as a loaded-window measure (`chat-scroll-contract`, `complex-history.perf`) now count mounted flow rows / turn-tail footers instead. The `stats-paged-history` web scenario seeds a 28-turn log cold and pins that the whole strip reads full totals on a partial tail page and does not move across Load earlier. diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.zh.md b/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.zh.md index 93e46cfb7f..85e2a26b5a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.zh.md @@ -10,7 +10,7 @@ Web 聊天统计条的每个非 token 数字都折算自 `StatsLine` 已加载 ## 决定 -新的函数插件 `@deepseek-ai/dsh-session-stats` 在 `ctx.sessionProjections` 上注册 `sessionStats` 投影单元,作为 web-app bundle 行挂载。值携带统计条完整的非 token 数字集——`{ turns, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens }`,字段名与窗口折叠一一对应以便整体互换。`steps` 统计 `step/end` 事件,`turns` 统计含至少一条该事件的不同 turn(turn 号单调递增,一个 `lastTurn` 槽即可);`llmMs` 累加 `step/start` → `assistant/message`;TTFT 记录每步首个非空 delta chunk(在步内 `llm/retry` 后保留,与窗口 `resetForRetry` 对齐);解码时长覆盖首 token → 已组装消息、仅统计上报 usage 的步;`toolMs` 按 callId 配对 `tool/call` → `tool/result`,未解决的调用在 `turn/end` 时丢弃。首 token 谓词 `isTokenDelta` 移入 `@deepseek-ai/dsh-llm/message`(`StreamChunk` 词汇的属主),Host 折叠与客户端计时索引共用同一实现;client-runtime 转发导出。投递完全复用现有投影缝——history 尾页块、`session/projection` 推送帧、列表行——apiproxy、wire schema 与客户端运行时零改动。`StatsLine` 读取 `useProjection('sessionStats')`,键为 undefined(未组合该单元的装配)时整体回退到窗口折叠。客户端 connection fixture 按其「镜像每个已组合键」的既有纪律以 `sessionStatsOf` 平行实现该折叠。 +新的函数插件 `@deepseek-ai/dsh-session-stats` 在 `ctx.sessionProjections` 上注册 `sessionStats` 投影单元,作为 web-app bundle 行挂载。值携带统计条完整的非 token 数字集——`{ turns, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens }`,字段名与窗口折叠一一对应以便整体互换。`steps` 统计 `step/end` 事件,`turns` 统计含至少一条该事件的不同 turn(turn 号单调递增,一个 `lastTurn` 槽即可);`llmMs` 累加 `step/start` → `assistant/message`;TTFT 记录每步首个非空 delta chunk(在步内 `llm/retry` 后保留,与窗口 `resetForRetry` 对齐);解码时长覆盖首 token → 已组装消息、仅统计上报 usage 的步;`toolMs` 按 callId 配对 `tool/call` → `tool/result`,未解决的调用在 `turn/end` 时丢弃。首 token 谓词 `isTokenDelta` 移入 `@deepseek-ai/dsh-llm/message`(与其判别的 `StreamChunk` 类型同处),Host 折叠与客户端计时索引共用同一实现;client-runtime 转发导出。投递完全复用现有投影缝——history 尾页块、`session/projection` 推送帧、列表行——apiproxy、wire schema 与客户端运行时零改动。`StatsLine` 读取 `useProjection('sessionStats')`,键为 undefined(未组合该单元的装配)时整体回退到窗口折叠。客户端 connection fixture 按其「镜像每个已组合键」的既有纪律以 `sessionStatsOf` 平行实现该折叠。 计数事件选 `step/end` 而非 `assistant/message`,源于评审直觉方案(按消息计数)时发现的两个正确性问题: @@ -31,8 +31,8 @@ Web 聊天统计条的每个非 token 数字都折算自 `StatsLine` 已加载 **在客户端折叠全量日志。** 客户端按设计只持有分页窗口;投影 RFC 的「不在客户端折叠」规则正是为了让数字在分页、压缩与冷读之间存活。 -**墙钟时间、TTFT 与吞吐保持窗口口径。** 首个交付版本如此,将其解读为「屏幕上有什么」;同样的分页问题立刻落在 LLM 时长上,且全量计数与窗口时间混在一条统计条里读起来是一套自相矛盾的数字。投影现在携带完整集合,窗口折叠降级为无单元时的回退。 +**墙钟时间、TTFT 与吞吐保持窗口口径,解读为「屏幕上有什么」。** 否决:同样的分页问题一样落在 LLM 时长上,且全量计数与窗口时间混在一条统计条里读起来是一套自相矛盾的数字。投影携带完整集合,窗口折叠降级为无单元时的回退。 ## 后果 -统计条从第一个尾页起就显示全日志数字;翻页不再改变任何分组。与旧窗口语义的已定义边缘差异记录在包 README 中:未产生可见输出的步(在内容之前失败)仍计入;崩溃恰好截断在 `step/start` 与 `step/end` 之间的步不计;被取消的步计数但不计时(没有组装出消息);max-tokens 的 usage 宿主消息贡献 surface 上看不到的模型时间。每个 web 尾页与列表行多携带一个小键,且单元内部状态在步边界与首 token chunk 处变化,变更流每步会多发几帧值相同的推送;TUI 与 headless 装配不提供 `sessionStats` 键,其消费者回退窗口折叠。两个曾把统计条当作已加载窗口探针解析的 e2e(`chat-scroll-contract`、`complex-history.perf`)改为统计已挂载的消息流行/turn-tail 页脚。`stats-paged-history` web 场景冷种一份 28 轮日志,钉住整条统计条在不完整尾页上即读出全量数字、且「加载更早」前后不变。 +统计条从第一个尾页起就显示全日志数字;翻页不再改变任何分组。与旧窗口语义的已定义边缘差异记录在包 README 中:未产生可见输出的步(在内容之前失败)仍计入;被崩溃打断的步在重新加载、恢复为其补写合成 `step/end` 后计入(`interruptedTurnClosers`);被取消的步计数但不计时(没有组装出消息);max-tokens 的 usage 宿主消息贡献 surface 上看不到的模型时间。每个 web 尾页与列表行多携带一个小键,且单元内部状态在步边界与首 token chunk 处变化,变更流每步会多发几帧值相同的推送;TUI 与 headless 装配不提供 `sessionStats` 键,其消费者回退窗口折叠。两个曾把统计条当作已加载窗口探针解析的 e2e(`chat-scroll-contract`、`complex-history.perf`)改为统计已挂载的消息流行/turn-tail 页脚。`stats-paged-history` web 场景冷种一份 28 轮日志,钉住整条统计条在不完整尾页上即读出全量数字、且「加载更早」前后不变。 diff --git a/apps/web/tests/math-rendering.e2e.ts b/apps/web/tests/math-rendering.e2e.ts index de24c1ca76..67af32373c 100644 --- a/apps/web/tests/math-rendering.e2e.ts +++ b/apps/web/tests/math-rendering.e2e.ts @@ -120,7 +120,7 @@ describe('web e2e: settled Markdown math rendering', () => { await expect.poll(() => page.locator('.katex-display').count(), { timeout: 10_000 }).toBe(2) expect(await page.locator('.katex-error').count()).toBe(0) await expect.poll( - () => page.getByText('Input 0 tok · Output 0 tok', { exact: false }).count(), + () => page.getByText('1 turns · 1 steps', { exact: false }).count(), { timeout: 10_000 }, ).toBe(1) diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 4ea903679c..bafa739a58 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -31,4 +31,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 1 turns · 1 steps Input 0 tok · Output 0 tok +- text: 1 turns · 1 steps diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 975ebe3a0d..341ddf22db 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -27,4 +27,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 1 turns · 1 steps Input 0 tok · Output 0 tok +- text: 1 turns · 1 steps diff --git a/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md index dbaca89b8a..adae2f723e 100644 --- a/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md @@ -53,4 +53,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok +- text: 1 turns · 1 steps LLM {{duration}} diff --git a/apps/web/tests/snapshots/markdown-images/ui.expected.md b/apps/web/tests/snapshots/markdown-images/ui.expected.md index f90d10f616..85c537bb18 100644 --- a/apps/web/tests/snapshots/markdown-images/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-images/ui.expected.md @@ -32,4 +32,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok +- text: 1 turns · 1 steps LLM {{duration}} diff --git a/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md index f345357f1b..d940beabc9 100644 --- a/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md @@ -44,4 +44,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok +- text: 1 turns · 1 steps LLM {{duration}} diff --git a/apps/web/tests/snapshots/math-rendering/ui.expected.md b/apps/web/tests/snapshots/math-rendering/ui.expected.md index 0bb9a9b19b..5561c3574e 100644 --- a/apps/web/tests/snapshots/math-rendering/ui.expected.md +++ b/apps/web/tests/snapshots/math-rendering/ui.expected.md @@ -48,4 +48,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok +- text: 1 turns · 1 steps LLM {{duration}} diff --git a/apps/web/tests/snapshots/queue-actions/preserved.expected.md b/apps/web/tests/snapshots/queue-actions/preserved.expected.md index 7951af37e4..43c9665ac1 100644 --- a/apps/web/tests/snapshots/queue-actions/preserved.expected.md +++ b/apps/web/tests/snapshots/queue-actions/preserved.expected.md @@ -50,4 +50,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 1 turns · 1 steps Input 0 tok · Output 0 tok +- text: 1 turns · 1 steps diff --git a/apps/web/tests/snapshots/stats-paged-history/ui.expected.md b/apps/web/tests/snapshots/stats-paged-history/ui.expected.md index 8179078b8b..78d175af5d 100644 --- a/apps/web/tests/snapshots/stats-paged-history/ui.expected.md +++ b/apps/web/tests/snapshots/stats-paged-history/ui.expected.md @@ -354,4 +354,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 28 turns · 28 steps LLM {{duration}} Input 0 tok · Output 0 tok +- text: 28 turns · 28 steps LLM {{duration}} diff --git a/packages/client/runtime/src/client/sessions/assistant-timing.ts b/packages/client/runtime/src/client/sessions/assistant-timing.ts index 8f58f8daab..179f76281d 100644 --- a/packages/client/runtime/src/client/sessions/assistant-timing.ts +++ b/packages/client/runtime/src/client/sessions/assistant-timing.ts @@ -6,7 +6,7 @@ import { isTokenDelta } from '@deepseek-ai/dsh-llm/message' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { AssistantTiming } from './conversation.ts' -// The first-token predicate lives with the StreamChunk vocabulary in dsh-llm; +// The first-token predicate lives beside the StreamChunk type in dsh-llm; // re-exported here so Chat Definitions keep their client-runtime import. export { isTokenDelta } from '@deepseek-ai/dsh-llm/message' diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx index 177afdb05a..147d2b7c6c 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx @@ -162,12 +162,13 @@ export interface StatsLineProps { export const StatsLine = memo(function StatsLine({ useSession, useProjection, t }: StatsLineProps) { const settledNodes = useSession(s => s.chat.legacy.nodes) - const windowStats = useMemo(() => deriveStats(settledNodes), [settledNodes]) const usage = useProjection('tokenUsage') // Every figure rides the durable sessionStats projection, so paging and // compaction cannot change any of them; an assembly without the unit falls - // back to the window-scoped fold wholesale (same field names). - const stats = useProjection('sessionStats') ?? windowStats + // back to the window-scoped fold wholesale (same field names), paid only + // while no projection value is served. + const projected = useProjection('sessionStats') + const stats = useMemo(() => projected ?? deriveStats(settledNodes), [projected, settledNodes]) // Pipe-separated groups (figma stats strip); a group with no data drops out whole. const groups: string[] = [] if (stats.steps > 0) { @@ -190,9 +191,11 @@ export const StatsLine = memo(function StatsLine({ useSession, useProjection, t // Context occupancy deliberately lives on the composer's ContextMeter ring, // not here — one home per fact. // Billing rides the durable projection, so these survive paging and - // compaction. Suppress the empty projection on a brand-new session. + // compaction. Gated on actual token activity: a session whose steps all + // settled without billing (e.g. every request failed) shows its counts + // without a zero-token group. if (usage !== undefined - && (stats.steps > 0 || billedInputTokens(usage) > 0 || usage.outputTokens > 0)) { + && (billedInputTokens(usage) > 0 || usage.outputTokens > 0)) { const cacheHit = cacheHitPercent(usage) if (cacheHit !== null) groups.push(t('stats.cacheHit', { percent: cacheHit })) groups.push(t('stats.tokens', { diff --git a/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx b/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx index 6abcf611d2..4ace851a66 100644 --- a/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx @@ -314,6 +314,17 @@ describe('StatsLine', () => { expect(view.container.textContent).toBe('') }) + it('hides the zero-token group when steps closed without any billed activity', () => { + // A session whose only turn failed before billing (e.g. an auth error): + // the counts group renders alone, not an uninformative zero-token group. + const { source } = makeSource() + const view = render() + expect(view.container.textContent).toBe('1 turns · 1 steps') + }) + it('keeps the counts group over an empty visible window when the projection carries totals', () => { // Extends the durable-groups guarantee: full-session counts survive a // window that compaction (or paging) left without assistant nodes. diff --git a/packages/session/session-stats/README.i18n.yaml b/packages/session/session-stats/README.i18n.yaml index 8896173827..d12db01c69 100644 --- a/packages/session/session-stats/README.i18n.yaml +++ b/packages/session/session-stats/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-stats/README.md -README.md: a42d5b1b6efe8ff522a43431f1ccb68b71607876 -README.zh.md: 5929280a8a619fb37a6f6468fb839962702a68c3 +README.md: 81b0de17e335b67936afd6fb11f15411beee3b76 +README.zh.md: 606628ea09bc34203a5374e49db62c1646293846 diff --git a/packages/session/session-stats/README.md b/packages/session/session-stats/README.md index a42d5b1b6e..81b0de17e3 100644 --- a/packages/session/session-stats/README.md +++ b/packages/session/session-stats/README.md @@ -33,7 +33,7 @@ None; the plugin never assembles or sends provider requests. ## Known Limitations and Deferred Work -- **Steps count work attempted, not visible output** — a step that failed before producing any visible content still closed with `step/end` and counts; a step truncated by a crash between `step/start` and `step/end` does not. +- **Steps count work attempted, not visible output** — a step that failed before producing any visible content still closed with `step/end` and counts; a step interrupted by a crash counts after the session reloads, when crash recovery appends its synthetic `step/end` (`interruptedTurnClosers` in dsh-session). - **A cancelled step is counted but untimed** — no assistant message assembles, so its partial stream time enters no wall-time figure, matching the window fold's untimed interrupted node; a max-tokens usage-host message conversely contributes model time the surface does not show. - **Counts are log-scoped, not surface-scoped** — steps whose messages were later compacted away stay counted; the figures describe the whole session, not the current model-visible surface. - **Mounted only in the web-app bundle** — other assemblies serve no `sessionStats` key, and their consumers fall back to window-scoped counting (the web stats strip's fallback path). diff --git a/packages/session/session-stats/README.zh.md b/packages/session/session-stats/README.zh.md index 5929280a8a..606628ea09 100644 --- a/packages/session/session-stats/README.zh.md +++ b/packages/session/session-stats/README.zh.md @@ -33,7 +33,7 @@ ## 已知局限与延后工作 -- **步数统计的是已发生的工作,而非可见输出**——在产生任何可见内容前就失败的步仍以 `step/end` 关闭并计入;进程崩溃恰好截断在 `step/start` 与 `step/end` 之间的步不计。 +- **步数统计的是已发生的工作,而非可见输出**——在产生任何可见内容前就失败的步仍以 `step/end` 关闭并计入;被崩溃打断的步在会话重新加载后计入,届时崩溃恢复为其补写合成的 `step/end`(dsh-session 的 `interruptedTurnClosers`)。 - **被取消的步计数但不计时**——没有组装出 assistant 消息,其部分流式时间不进入任何墙钟数字,与窗口折叠的无计时 interrupted 节点一致;反之 max-tokens 的 usage 宿主消息贡献 surface 上看不到的模型时间。 - **计数是日志口径,不是 surface 口径**——消息后来被压缩掉的步仍然计入;数字描述整个会话,而非当前模型可见 surface。 - **仅挂载于 web-app bundle**——其他装配不提供 `sessionStats` 键,其消费者回退到窗口口径计数(Web 统计条的回退路径)。 diff --git a/packages/session/session-stats/src/projection.ts b/packages/session/session-stats/src/projection.ts index e155622920..a000300873 100644 --- a/packages/session/session-stats/src/projection.ts +++ b/packages/session/session-stats/src/projection.ts @@ -140,8 +140,12 @@ export const sessionStatsProjectionDefinition: ProjectionDefinition<'sessionStat case 'tool/call': return { ...state, pendingCalls: { ...state.pendingCalls, [event.data.callId]: event.time } } case 'tool/result': { + // Own-key check: callId is provider-minted (model/tool JSON boundary), + // so a prototype property name ('constructor', 'toString') on a result + // with no recorded call must read as unmatched, not as an inherited + // function that would poison toolMs with NaN. const callId = event.data.message.source.callId - const dispatched = state.pendingCalls[callId] + const dispatched = Object.hasOwn(state.pendingCalls, callId) ? state.pendingCalls[callId] : undefined if (dispatched === undefined) return state const pendingCalls = Object.fromEntries( Object.entries(state.pendingCalls).filter(([id]) => id !== callId), @@ -175,5 +179,5 @@ export const sessionStatsProjectionDefinition: ProjectionDefinition<'sessionStat decodeMs: state.decodeMs, decodeTokens: state.decodeTokens, }), - stateVersion: 2, + stateVersion: 1, } diff --git a/packages/session/session-stats/tests/projection.spec.ts b/packages/session/session-stats/tests/projection.spec.ts index fce09b4e9e..ebe728181b 100644 --- a/packages/session/session-stats/tests/projection.spec.ts +++ b/packages/session/session-stats/tests/projection.spec.ts @@ -237,6 +237,27 @@ describe('sessionStats wall-time fold (controlled timestamps)', () => { expect(pruned).toEqual(totals({ turns: 1, steps: 1 })) }) + it('pairs only own pendingCalls keys: a prototype-name callId without a recorded call stays unmatched', () => { + const result = (callId: string): unknown => + ({ turn: 1, step: 1, message: { source: { kind: 'tool', callId } } }) + // Crash recovery (TOOL_NOT_STARTED) emits results with no preceding + // tool/call; a provider-minted callId colliding with an Object prototype + // property must read as absent, not as an inherited function that would + // fold toolMs to NaN and fail the value schema. + expect(fold([ + at(1_000, 'step/start', { turn: 1, step: 1 }), + at(1_500, 'tool/result', result('toString')), + at(2_000, 'step/end', { turn: 1, step: 1 }), + ])).toEqual(totals({ turns: 1, steps: 1 })) + // The same name pairs normally once its call is recorded. + expect(fold([ + at(1_000, 'step/start', { turn: 1, step: 1 }), + at(1_100, 'tool/call', { turn: 1, step: 1, callId: 'constructor', name: 'read', arguments: '{}' }), + at(1_600, 'tool/result', result('constructor')), + at(2_000, 'step/end', { turn: 1, step: 1 }), + ])).toEqual(totals({ turns: 1, steps: 1, toolMs: 500 })) + }) + it('skips decode for an invalid usage report and ignores a duplicate assembled message', () => { const events = [ at(1_000, 'step/start', { turn: 1, step: 1 }), From cb31f5e5b58443e78bdeefb6099c4455dd34e4a3 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Wed, 12 Aug 2026 21:48:18 +0800 Subject: [PATCH 13/13] docs: regenerate the module graph for dsh-session-stats --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 9 ++++++++- docs/module-graph.zh.md | 9 ++++++++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 980d198c32..b528a4356d 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 371699d8a4aab83eb8603ce1623373fe56871dd3 -module-graph.zh.md: 381a387ff77ef36dda31655bb9c0e4d5b931e544 +module-graph.md: 5197a184f2be283e3bb57de91d4a4d3cb22e51b2 +module-graph.zh.md: 3b312b731d32ed71674d800c35a5868e0b5d94ee diff --git a/docs/module-graph.md b/docs/module-graph.md index 371699d8a4..5197a184f2 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -274,6 +274,7 @@ flowchart TD pkg_session_persistence_sqlite["session-persistence-sqlite"] pkg_session_projection["session-projection"] pkg_session_projection_cache["session-projection-cache"] + pkg_session_stats["session-stats"] pkg_session_telemetry["session-telemetry"] pkg_session_telemetry_otel["session-telemetry-otel"] pkg_session_title["session-title"] @@ -568,6 +569,10 @@ flowchart TD pkg_session_projection_cache --> pkg_session_persistence pkg_session_projection_cache --> pkg_session_projection pkg_session_projection_cache --> pkg_storage_domain + pkg_session_stats --> pkg_invariants + pkg_session_stats --> pkg_llm + pkg_session_stats --> pkg_session + pkg_session_stats --> pkg_session_projection pkg_session_telemetry --> pkg_agent pkg_session_telemetry --> pkg_invariants pkg_session_telemetry --> pkg_session @@ -1212,6 +1217,7 @@ flowchart TD pkg_client_ui_conversation --> pkg_compact pkg_client_ui_conversation --> pkg_invariants pkg_client_ui_conversation --> pkg_llm_retry + pkg_client_ui_conversation --> pkg_session_stats pkg_client_ui_conversation --> pkg_token_meter pkg_client_ui_conversation --> pkg_tools pkg_client_ui_directory_picker --> pkg_client_locale @@ -1461,6 +1467,7 @@ flowchart TD | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) | +| [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-title`](../packages/session/session-title) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | @@ -1565,7 +1572,7 @@ flowchart TD | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/boot/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-attachment`](../packages/client/ui-attachment), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-attachment`](../packages/client/ui-attachment), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | | [`client-ui-directory-picker`](../packages/client/ui-directory-picker) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 381a387ff7..3b312b731d 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -276,6 +276,7 @@ flowchart TD pkg_session_persistence_sqlite["session-persistence-sqlite"] pkg_session_projection["session-projection"] pkg_session_projection_cache["session-projection-cache"] + pkg_session_stats["session-stats"] pkg_session_telemetry["session-telemetry"] pkg_session_telemetry_otel["session-telemetry-otel"] pkg_session_title["session-title"] @@ -570,6 +571,10 @@ flowchart TD pkg_session_projection_cache --> pkg_session_persistence pkg_session_projection_cache --> pkg_session_projection pkg_session_projection_cache --> pkg_storage_domain + pkg_session_stats --> pkg_invariants + pkg_session_stats --> pkg_llm + pkg_session_stats --> pkg_session + pkg_session_stats --> pkg_session_projection pkg_session_telemetry --> pkg_agent pkg_session_telemetry --> pkg_invariants pkg_session_telemetry --> pkg_session @@ -1214,6 +1219,7 @@ flowchart TD pkg_client_ui_conversation --> pkg_compact pkg_client_ui_conversation --> pkg_invariants pkg_client_ui_conversation --> pkg_llm_retry + pkg_client_ui_conversation --> pkg_session_stats pkg_client_ui_conversation --> pkg_token_meter pkg_client_ui_conversation --> pkg_tools pkg_client_ui_directory_picker --> pkg_client_locale @@ -1463,6 +1469,7 @@ flowchart TD | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) | +| [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-title`](../packages/session/session-title) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | @@ -1567,7 +1574,7 @@ flowchart TD | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/boot/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-attachment`](../packages/client/ui-attachment), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-attachment`](../packages/client/ui-attachment), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | | [`client-ui-directory-picker`](../packages/client/ui-directory-picker) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) |