From 23527f0df2ee9039411929de97885711463a6aca Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 18 Aug 2026 17:26:04 +0800 Subject: [PATCH 01/49] test(typert-loader): poll async assertions instead of fixed 20ms sleeps 'contains steady-state registration failures' flaked on the CI coverage lane (mock called 0 times): the spec slept a fixed 20ms and asserted an async continuation had run, which contention can outlast. Convert the three positive-assertion sleeps to the file's existing vi.waitFor pattern; the absence-assertion sleeps stay, because polling cannot prove nothing happened. --- packages/typert/loader/tests/loader.spec.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index 2f4b4cae04..ab76f20024 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -214,17 +214,20 @@ describe('typert loader', () => { await ctx.loader.remove(id) await ctx.loader.await() - // The unmount reconciliation rides a queued microtask flush. - await new Promise(resolve => setTimeout(resolve, 20)) - expect(ctx.typert.get('@fixture/with-typert#Thing')).toBeUndefined() + // The unmount reconciliation rides a queued microtask flush; poll instead + // of assuming a fixed delay covers it under coverage-lane contention. + await vi.waitFor(() => { + expect(ctx.typert.get('@fixture/with-typert#Thing')).toBeUndefined() + }, { timeout: 10_000 }) await ctx.loader.remove(plainId) await ctx.loader.await() await new Promise(resolve => setTimeout(resolve, 20)) await ctx.loader.create({ name: '@fixture/with-typert' }) await ctx.loader.await() - await new Promise(resolve => setTimeout(resolve, 20)) - expect(ctx.typert.get('@fixture/with-typert#Thing')).toBeDefined() + await vi.waitFor(() => { + expect(ctx.typert.get('@fixture/with-typert#Thing')).toBeDefined() + }, { timeout: 10_000 }) }) it('follows entries mounted after activation', LOADER_TEST_TIMEOUT, async () => { From 2a23b73e7b60369df865a0b3b05ab49cda7da657 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 19 Aug 2026 09:30:02 +0800 Subject: [PATCH 02/49] test(typert-loader): state comment contracts without CI-incident citations --- packages/typert/loader/tests/loader.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index ab76f20024..b2604b0690 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -214,8 +214,8 @@ describe('typert loader', () => { await ctx.loader.remove(id) await ctx.loader.await() - // The unmount reconciliation rides a queued microtask flush; poll instead - // of assuming a fixed delay covers it under coverage-lane contention. + // The unmount reconciliation rides a queued microtask flush that settles + // after loader.await(); poll for the withdrawal. await vi.waitFor(() => { expect(ctx.typert.get('@fixture/with-typert#Thing')).toBeUndefined() }, { timeout: 10_000 }) From fc5da09dbc772adb9755275605fc3ad25d657633 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 24 Sep 2026 17:42:40 +0800 Subject: [PATCH 03/49] docs: show the producer-owned message source in current-state examples Session format v4 refuses the retired `{ kind: 'plugin', plugin: '' }` wrapper at message admission, and the v3-to-v4 migration maps released plugin names to producer-owned kinds. Five paired documents still show the wrapper as the current shape, so a plugin author copying them writes a source the session refuses. The examples now name the kind the producer declares through `MessageSourceMap`, and the tool cookbook states that declaration requirement; the wrapper appears only as the form the admission check rejects. The two package READMEs also disagreed with their own sources: time-context writes `{ kind: 'time-context', form: 'snapshot', sections }`, and repeat-tool-reminder writes `{ kind: 'repeat-tool-reminder' }` with no `form` or `summary`. --- docs/cookbook/adding-a-tool.i18n.yaml | 4 ++-- docs/cookbook/adding-a-tool.md | 2 +- docs/cookbook/adding-a-tool.zh.md | 2 +- docs/cookbook/extension-cookbook.i18n.yaml | 4 ++-- docs/cookbook/extension-cookbook.md | 2 +- docs/cookbook/extension-cookbook.zh.md | 2 +- packages/context/time-context/README.i18n.yaml | 4 ++-- packages/context/time-context/README.md | 2 +- packages/context/time-context/README.zh.md | 2 +- packages/core/agent/README.md | 2 +- packages/core/agent/README.zh.md | 2 +- packages/guard/repeat-tool-reminder/README.i18n.yaml | 4 ++-- packages/guard/repeat-tool-reminder/README.md | 2 +- packages/guard/repeat-tool-reminder/README.zh.md | 2 +- 14 files changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index 93b9c89422..7305f6b036 100644 --- a/docs/cookbook/adding-a-tool.i18n.yaml +++ b/docs/cookbook/adding-a-tool.i18n.yaml @@ -9,8 +9,8 @@ en: 2cc80a2a79e23005 zh: 87e43383055890ce /tool-authoring-reference/rules-of-the-execute-contract: - en: 110fa68298c53f96 - zh: f5762893eb615ddf + en: 652a65eb39f5a1cb + zh: cd65200637a08ed6 /tool-authoring-reference/long-running-work: en: d81167332dfd05b7 zh: 1c878b60f70728c2 diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 2deb608fb3..910fe381ff 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -46,7 +46,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool. S - **Throwing or returning an invalid value means `isError`.** The registry catches throws and contains schema, renderer, metadata-projector, and lossless-JSON failures before observers run. Throw for infrastructure failures. Represent a successful domain outcome in the canonical value even when its Native renderer explains a non-ideal state, such as a non-zero process exit. - **Honor `exec.signal`.** Cancel in-flight work when it fires. - **Project durable card data with `presentationMeta` (optional).** `output.presentationMeta(args, value)` derives replayable JSON from the same canonical value. The core persists it on `tool/result` and hands it to `presentResult`, so a card that needs result-time facts—such as `write`/`edit` applied hunks—survives replay without persisting the canonical value. The projector is skipped for nested Code dispatches because they have no cards. -- **Use `exec.agent` for async notifications.** `agent.inject({ content, source: { kind: 'plugin', plugin: '' } })` appends durable context the NEXT model request sees — it is not a wake-up (an idle agent stays idle). Guard against disposed agents (try/catch). +- **Use `exec.agent` for async notifications.** `agent.inject({ content, source: { kind: 'my-plugin' } })` appends durable context the NEXT model request sees — it is not a wake-up (an idle agent stays idle). Declare that `kind` through `MessageSourceMap` in your plugin: session format v4 rejects the retired `{ kind: 'plugin', plugin: '' }` wrapper at message admission. Guard against disposed agents (try/catch). ## Long-running work diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md index 5ad488c8b4..f2c54f21f6 100644 --- a/docs/cookbook/adding-a-tool.zh.md +++ b/docs/cookbook/adding-a-tool.zh.md @@ -46,7 +46,7 @@ export function apply(ctx: Context) { - **抛出异常或返回无效值意味着 `isError`。** 注册表会捕获异常,并在观察者运行前收敛 schema、渲染器、元数据投影器和无损 JSON 失败。基础设施故障请抛异常。成功的领域结果即使表示不理想的状态,也应写入规范值;其 Native 渲染器可以解释该状态,例如进程以非零状态退出。 - **遵守 `exec.signal`。** 信号触发时取消进行中的工作。 - **使用 `presentationMeta` 投影持久化的卡片数据(可选)。** `output.presentationMeta(args, value)` 从同一个规范值派生可回放的 JSON。核心将其持久化在 `tool/result` 上并传给 `presentResult`,因此需要结果期事实的卡片——例如 `write`/`edit` 的已应用 hunk——无需持久化规范值也能在回放中重现。嵌套 Code 分发没有卡片,因此会跳过该投影器。 -- **使用 `exec.agent` 发送异步通知。** `agent.inject({ content, source: { kind: 'plugin', plugin: '' } })` 追加持久化上下文,下一次模型请求会看到它——这不是唤醒(空闲的 agent(智能体)保持空闲)。请防范已 dispose 的 agent(try/catch)。 +- **使用 `exec.agent` 发送异步通知。** `agent.inject({ content, source: { kind: 'my-plugin' } })` 追加持久化上下文,下一次模型请求会看到它——这不是唤醒(空闲的 agent(智能体)保持空闲)。在插件里通过 `MessageSourceMap` 声明该 `kind`:session format v4 在消息准入处拒绝退役的 `{ kind: 'plugin', plugin: '' }` 包装。请防范已 dispose 的 agent(try/catch)。 ## 长时间运行的工作 diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index 497fc2fedc..aa6f92d928 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -21,5 +21,5 @@ en: f0a28d07c0bb4d1b zh: 840962f512cb4031 /cookbook-extension-plugin-shapes/the-feature-mechanism-map: - en: 2d4511fc000afc42 - zh: d4875bf1e3f2808f + en: ec452dcf9f7cf788 + zh: c9cfe78ae941d509 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index b54f0191b4..dd230ac9e6 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -124,7 +124,7 @@ Every product feature maps to a listener on a documented extension point — the | MCP | one plugin per server: discover tools → `ctx.tools.register()` | | Skills | section + tool registration; `inject()` skill content on invocation | | Memory | section provider + tool | -| Scheduled tasks (cron) | a plugin registers model-callable scheduling tools; timer fires → `followup(…, {source: {kind: 'plugin', plugin: 'schedule'}})` when idle / `inject()` notification when busy | +| Scheduled tasks (cron) | a plugin registers model-callable scheduling tools; timer fires → `followup(…, {source: {kind: 'schedule'}})` when idle / `inject()` notification when busy | | UI (GUI; CLI emits JSONL) | listen to `agent/assistant-stream` for live chunks and `session/event` for durable settlements, boundaries, and tool activity; input → `followup()` | | Web Client Chat business node | register a `ConversationNodeDefinition` and `conversation.chat.node` keyed renderer | | SessionTelemetryBackend / replayable trace | `session/event` → JSONL; replay = `sessions.create(id, { seed })` | diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index c03e92b21e..a172fc3a64 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -128,7 +128,7 @@ export function apply(ctx: Context) { | MCP | 每个服务器一个插件:发现工具 → `ctx.tools.register()` | | skill(技能) | section + 工具注册;调用时通过 `inject()` 注入 skill 内容 | | 记忆 | section 提供方 + 工具 | -| 定时任务(cron) | 插件注册面向模型的调度工具;定时器触发 → 空闲时 `followup(…, {source: {kind: 'plugin', plugin: 'schedule'}})`/忙碌时 `inject()` 通知 | +| 定时任务(cron) | 插件注册面向模型的调度工具;定时器触发 → 空闲时 `followup(…, {source: {kind: 'schedule'}})`/忙碌时 `inject()` 通知 | | UI(GUI;CLI(命令行界面)输出 JSONL) | 监听 `agent/assistant-stream` 的实时 chunk,并监听 `session/event` 的持久 settlement、边界与工具活动;输入 → `followup()` | | Web Client Chat 业务节点 | 注册 `ConversationNodeDefinition` 与 `conversation.chat.node` keyed renderer | | 遥测 / 可回放 trace | `session/event` → JSONL;回放 = `sessions.create(id, { seed })` | diff --git a/packages/context/time-context/README.i18n.yaml b/packages/context/time-context/README.i18n.yaml index c099dcb1bb..9b93e31557 100644 --- a/packages/context/time-context/README.i18n.yaml +++ b/packages/context/time-context/README.i18n.yaml @@ -30,8 +30,8 @@ en: 2d21fa2aad5a729d zh: cfbfada0eb647435 /deepseek-ai-dsh-time-context/understand-the-implementation/design-concept: - en: f2c19187501f7027 - zh: 48d31d0ea9bd8b16 + en: 8887d5d7453ef8cc + zh: a80b54028f75fbc5 /deepseek-ai-dsh-time-context/understand-the-implementation/source-map: en: 05a0a4627013d3d2 zh: 281429e6ef7f26ec diff --git a/packages/context/time-context/README.md b/packages/context/time-context/README.md index c52da10723..db07eba54c 100644 --- a/packages/context/time-context/README.md +++ b/packages/context/time-context/README.md @@ -64,7 +64,7 @@ This section explains the design of the plugin; the observable behavior is cover ### Design concept -The plugin prepends an `agent/pre-step` listener that delegates first and appends one sourced `UserMessage` when an injection is due and the downstream decision enters the step. Each reading uses the exact snapshot source `{ kind: 'plugin', plugin: 'time-context', form: 'snapshot', sections: [{ name: 'time-context', text }] }`, and the invariant companion validates that shape, re-derives the current-turn browser policy from the original `user-rpc` messages, and checks the timestamp zone and elapsed baseline. +The plugin prepends an `agent/pre-step` listener that delegates first and appends one sourced `UserMessage` when an injection is due and the downstream decision enters the step. Each reading uses the exact snapshot source `{ kind: 'time-context', form: 'snapshot', sections: [{ name: 'time-context', text }] }`, and the invariant companion validates that shape, re-derives the current-turn browser policy from the original `user-rpc` messages, and checks the timestamp zone and elapsed baseline. ### Source map diff --git a/packages/context/time-context/README.zh.md b/packages/context/time-context/README.zh.md index c8cda0b7f6..6c754ec266 100644 --- a/packages/context/time-context/README.zh.md +++ b/packages/context/time-context/README.zh.md @@ -64,7 +64,7 @@ kind: "package-reference" ### 设计理念 -插件前置注册一个 `agent/pre-step` 监听器,先委托下游,需要注入且下游决策进入步骤时追加一条带来源的 `UserMessage`。每个读数都使用确切的快照来源 `{ kind: 'plugin', plugin: 'time-context', form: 'snapshot', sections: [{ name: 'time-context', text }] }`,不变式配套模块会校验该形状,根据原始 `user-rpc` 消息重新派生当前轮次的浏览器策略,并检查时间戳时区与经过时长基线。 +插件前置注册一个 `agent/pre-step` 监听器,先委托下游,需要注入且下游决策进入步骤时追加一条带来源的 `UserMessage`。每个读数都使用确切的快照来源 `{ kind: 'time-context', form: 'snapshot', sections: [{ name: 'time-context', text }] }`,不变式配套模块会校验该形状,根据原始 `user-rpc` 消息重新派生当前轮次的浏览器策略,并检查时间戳时区与经过时长基线。 ### 源码地图 diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index be003d7b89..679634b236 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -53,7 +53,7 @@ handle.agent.followup({ }) handle.agent.steer({ content: [{ type: 'text', text: 'Focus on the tests.' }], - source: { kind: 'plugin', plugin: 'my-plugin' }, + source: { kind: 'my-plugin' }, }) await handle.agent.whenIdle() ``` diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index e360ba975d..f7f9bf2a0a 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -53,7 +53,7 @@ handle.agent.followup({ }) handle.agent.steer({ content: [{ type: 'text', text: 'Focus on the tests.' }], - source: { kind: 'plugin', plugin: 'my-plugin' }, + source: { kind: 'my-plugin' }, }) await handle.agent.whenIdle() ``` diff --git a/packages/guard/repeat-tool-reminder/README.i18n.yaml b/packages/guard/repeat-tool-reminder/README.i18n.yaml index be3de45e07..5c34c79e3f 100644 --- a/packages/guard/repeat-tool-reminder/README.i18n.yaml +++ b/packages/guard/repeat-tool-reminder/README.i18n.yaml @@ -36,8 +36,8 @@ en: b76a432aad14aa90 zh: b0cd916f7897fb42 /deepseek-ai-dsh-repeat-tool-reminder/understand-the-implementation/reminder-delivery: - en: ebd2a7d704e0a7be - zh: 438213ad0412a64e + en: a1535f5a6eae8422 + zh: e700f69f57950900 /deepseek-ai-dsh-repeat-tool-reminder/understand-the-implementation/source-map: en: 567a964105e1340b zh: 7a8b062b2d9b39d1 diff --git a/packages/guard/repeat-tool-reminder/README.md b/packages/guard/repeat-tool-reminder/README.md index 13035b68e8..c39eeefe89 100644 --- a/packages/guard/repeat-tool-reminder/README.md +++ b/packages/guard/repeat-tool-reminder/README.md @@ -88,7 +88,7 @@ Each agent's chain is keyed by `(tool name, canonical arguments)` — two calls ### Reminder delivery -Reminders ride the post-execute decision's `additionalContexts` (source `{kind: 'plugin', plugin: 'repeat-tool-reminder', form: 'notice', summary: ' × '}`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit. The loop buffers the context and appends it as an injected `user/message` after the step's tool results, which the session renders as a plain synthetic user message — model-visible, source-attributed, and reconstructable from the session log with no new session event. The guard always delegates via `next()` and prepends its reminder to the downstream decision's context array, so both decision variants (a blocked call included) still get the nudge while every entry retains its own source and metadata. +Reminders ride the post-execute decision's `additionalContexts` (source `{kind: 'repeat-tool-reminder'}`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit. The loop buffers the context and appends it as an injected `user/message` after the step's tool results, which the session renders as a plain synthetic user message — model-visible, source-attributed, and reconstructable from the session log with no new session event. The guard always delegates via `next()` and prepends its reminder to the downstream decision's context array, so both decision variants (a blocked call included) still get the nudge while every entry retains its own source and metadata. ### Source map diff --git a/packages/guard/repeat-tool-reminder/README.zh.md b/packages/guard/repeat-tool-reminder/README.zh.md index 27d142988f..b11d117bb1 100644 --- a/packages/guard/repeat-tool-reminder/README.zh.md +++ b/packages/guard/repeat-tool-reminder/README.zh.md @@ -88,7 +88,7 @@ guard 建立在四项承诺之上: ### 提醒传递 -提醒随 post-execute 决策的 `additionalContexts`(来源为 `{kind: 'plugin', plugin: 'repeat-tool-reminder', form: 'notice', summary: ' × '}`)传递,绝不替换 `content`:用于审计的 `tool/result` 事件仍保留工具自己的输出。循环会缓冲这段上下文,并在该步骤的工具结果之后作为注入的 `user/message` 追加,会话将其渲染为普通的合成用户消息——模型可见、带有来源归属,且无需新会话事件即可从会话日志重建。guard 始终通过 `next()` 委派,并把提醒放在下游决策的上下文数组之前,因此两种决策变体(包括被阻止的调用)都会收到提醒,同时每个条目保留自己的来源与元数据。 +提醒随 post-execute 决策的 `additionalContexts`(来源为 `{kind: 'repeat-tool-reminder'}`)传递,绝不替换 `content`:用于审计的 `tool/result` 事件仍保留工具自己的输出。循环会缓冲这段上下文,并在该步骤的工具结果之后作为注入的 `user/message` 追加,会话将其渲染为普通的合成用户消息——模型可见、带有来源归属,且无需新会话事件即可从会话日志重建。guard 始终通过 `next()` 委派,并把提醒放在下游决策的上下文数组之前,因此两种决策变体(包括被阻止的调用)都会收到提醒,同时每个条目保留自己的来源与元数据。 ### 源码地图 From 30024e37c8f04a3ad75581a8046648a14c5ecb71 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 24 Sep 2026 17:57:13 +0800 Subject: [PATCH 04/49] docs: keep the reminder and cron sources exact, and link MessageSourceMap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the previous commit found two inaccuracies it introduced. The repeat-tool-reminder producer still writes `form: 'notice'` and its ` × ` summary — `src/index.ts` spreads them onto `REMINDER_SOURCE` — so only the wrapper should have been dropped. The cron row also claimed an `inject()` path that `dsh-schedule` does not have: `runtime.ts` always delivers through `followup()` and flushes the session. Link `MessageSourceMap` to the page that owns it, and write `V4` as the corpus does for the format name. Both pairs are re-recorded. --- docs/cookbook/adding-a-tool.i18n.yaml | 4 ++-- docs/cookbook/adding-a-tool.md | 2 +- docs/cookbook/adding-a-tool.zh.md | 2 +- docs/cookbook/extension-cookbook.i18n.yaml | 4 ++-- docs/cookbook/extension-cookbook.md | 2 +- docs/cookbook/extension-cookbook.zh.md | 2 +- packages/guard/repeat-tool-reminder/README.i18n.yaml | 4 ++-- packages/guard/repeat-tool-reminder/README.md | 2 +- packages/guard/repeat-tool-reminder/README.zh.md | 2 +- 9 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index 7305f6b036..ccfb5a1d66 100644 --- a/docs/cookbook/adding-a-tool.i18n.yaml +++ b/docs/cookbook/adding-a-tool.i18n.yaml @@ -9,8 +9,8 @@ en: 2cc80a2a79e23005 zh: 87e43383055890ce /tool-authoring-reference/rules-of-the-execute-contract: - en: 652a65eb39f5a1cb - zh: cd65200637a08ed6 + en: 0b85f46f6a5bd8af + zh: 56eb8d05198333dc /tool-authoring-reference/long-running-work: en: d81167332dfd05b7 zh: 1c878b60f70728c2 diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 910fe381ff..a94fc497f5 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -46,7 +46,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool. S - **Throwing or returning an invalid value means `isError`.** The registry catches throws and contains schema, renderer, metadata-projector, and lossless-JSON failures before observers run. Throw for infrastructure failures. Represent a successful domain outcome in the canonical value even when its Native renderer explains a non-ideal state, such as a non-zero process exit. - **Honor `exec.signal`.** Cancel in-flight work when it fires. - **Project durable card data with `presentationMeta` (optional).** `output.presentationMeta(args, value)` derives replayable JSON from the same canonical value. The core persists it on `tool/result` and hands it to `presentResult`, so a card that needs result-time facts—such as `write`/`edit` applied hunks—survives replay without persisting the canonical value. The projector is skipped for nested Code dispatches because they have no cards. -- **Use `exec.agent` for async notifications.** `agent.inject({ content, source: { kind: 'my-plugin' } })` appends durable context the NEXT model request sees — it is not a wake-up (an idle agent stays idle). Declare that `kind` through `MessageSourceMap` in your plugin: session format v4 rejects the retired `{ kind: 'plugin', plugin: '' }` wrapper at message admission. Guard against disposed agents (try/catch). +- **Use `exec.agent` for async notifications.** `agent.inject({ content, source: { kind: 'my-plugin' } })` appends durable context the NEXT model request sees — it is not a wake-up (an idle agent stays idle). Declare that `kind` through [`MessageSourceMap`](../subsystems/llm-streaming.md#content-blocks-and-messages) in your plugin: session format V4 rejects the retired `{ kind: 'plugin', plugin: '' }` wrapper at message admission. Guard against disposed agents (try/catch). ## Long-running work diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md index f2c54f21f6..1050ccd6f3 100644 --- a/docs/cookbook/adding-a-tool.zh.md +++ b/docs/cookbook/adding-a-tool.zh.md @@ -46,7 +46,7 @@ export function apply(ctx: Context) { - **抛出异常或返回无效值意味着 `isError`。** 注册表会捕获异常,并在观察者运行前收敛 schema、渲染器、元数据投影器和无损 JSON 失败。基础设施故障请抛异常。成功的领域结果即使表示不理想的状态,也应写入规范值;其 Native 渲染器可以解释该状态,例如进程以非零状态退出。 - **遵守 `exec.signal`。** 信号触发时取消进行中的工作。 - **使用 `presentationMeta` 投影持久化的卡片数据(可选)。** `output.presentationMeta(args, value)` 从同一个规范值派生可回放的 JSON。核心将其持久化在 `tool/result` 上并传给 `presentResult`,因此需要结果期事实的卡片——例如 `write`/`edit` 的已应用 hunk——无需持久化规范值也能在回放中重现。嵌套 Code 分发没有卡片,因此会跳过该投影器。 -- **使用 `exec.agent` 发送异步通知。** `agent.inject({ content, source: { kind: 'my-plugin' } })` 追加持久化上下文,下一次模型请求会看到它——这不是唤醒(空闲的 agent(智能体)保持空闲)。在插件里通过 `MessageSourceMap` 声明该 `kind`:session format v4 在消息准入处拒绝退役的 `{ kind: 'plugin', plugin: '' }` 包装。请防范已 dispose 的 agent(try/catch)。 +- **使用 `exec.agent` 发送异步通知。** `agent.inject({ content, source: { kind: 'my-plugin' } })` 追加持久化上下文,下一次模型请求会看到它——这不是唤醒(空闲的 agent(智能体)保持空闲)。在插件里通过 [`MessageSourceMap`](../subsystems/llm-streaming.zh.md#content-blocks-and-messages) 声明该 `kind`:session format V4 在消息准入处拒绝退役的 `{ kind: 'plugin', plugin: '' }` 包装。请防范已 dispose 的 agent(try/catch)。 ## 长时间运行的工作 diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index aa6f92d928..f65dda80a5 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -21,5 +21,5 @@ en: f0a28d07c0bb4d1b zh: 840962f512cb4031 /cookbook-extension-plugin-shapes/the-feature-mechanism-map: - en: ec452dcf9f7cf788 - zh: c9cfe78ae941d509 + en: 0504676a42dd1445 + zh: b33fba88ec2ace9c diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index dd230ac9e6..736a18b791 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -124,7 +124,7 @@ Every product feature maps to a listener on a documented extension point — the | MCP | one plugin per server: discover tools → `ctx.tools.register()` | | Skills | section + tool registration; `inject()` skill content on invocation | | Memory | section provider + tool | -| Scheduled tasks (cron) | a plugin registers model-callable scheduling tools; timer fires → `followup(…, {source: {kind: 'schedule'}})` when idle / `inject()` notification when busy | +| Scheduled tasks (cron) | a plugin registers model-callable scheduling tools; a due timer delivers through `followup(…, {source: {kind: 'schedule'}})` | | UI (GUI; CLI emits JSONL) | listen to `agent/assistant-stream` for live chunks and `session/event` for durable settlements, boundaries, and tool activity; input → `followup()` | | Web Client Chat business node | register a `ConversationNodeDefinition` and `conversation.chat.node` keyed renderer | | SessionTelemetryBackend / replayable trace | `session/event` → JSONL; replay = `sessions.create(id, { seed })` | diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index a172fc3a64..77d10bfba6 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -128,7 +128,7 @@ export function apply(ctx: Context) { | MCP | 每个服务器一个插件:发现工具 → `ctx.tools.register()` | | skill(技能) | section + 工具注册;调用时通过 `inject()` 注入 skill 内容 | | 记忆 | section 提供方 + 工具 | -| 定时任务(cron) | 插件注册面向模型的调度工具;定时器触发 → 空闲时 `followup(…, {source: {kind: 'schedule'}})`/忙碌时 `inject()` 通知 | +| 定时任务(cron) | 插件注册面向模型的调度工具;定时器到期后通过 `followup(…, {source: {kind: 'schedule'}})` 投递 | | UI(GUI;CLI(命令行界面)输出 JSONL) | 监听 `agent/assistant-stream` 的实时 chunk,并监听 `session/event` 的持久 settlement、边界与工具活动;输入 → `followup()` | | Web Client Chat 业务节点 | 注册 `ConversationNodeDefinition` 与 `conversation.chat.node` keyed renderer | | 遥测 / 可回放 trace | `session/event` → JSONL;回放 = `sessions.create(id, { seed })` | diff --git a/packages/guard/repeat-tool-reminder/README.i18n.yaml b/packages/guard/repeat-tool-reminder/README.i18n.yaml index 5c34c79e3f..2b8fb8a331 100644 --- a/packages/guard/repeat-tool-reminder/README.i18n.yaml +++ b/packages/guard/repeat-tool-reminder/README.i18n.yaml @@ -36,8 +36,8 @@ en: b76a432aad14aa90 zh: b0cd916f7897fb42 /deepseek-ai-dsh-repeat-tool-reminder/understand-the-implementation/reminder-delivery: - en: a1535f5a6eae8422 - zh: e700f69f57950900 + en: a3edc5c0f98b0d1e + zh: 173f6166ce3af779 /deepseek-ai-dsh-repeat-tool-reminder/understand-the-implementation/source-map: en: 567a964105e1340b zh: 7a8b062b2d9b39d1 diff --git a/packages/guard/repeat-tool-reminder/README.md b/packages/guard/repeat-tool-reminder/README.md index c39eeefe89..9554ea16b8 100644 --- a/packages/guard/repeat-tool-reminder/README.md +++ b/packages/guard/repeat-tool-reminder/README.md @@ -88,7 +88,7 @@ Each agent's chain is keyed by `(tool name, canonical arguments)` — two calls ### Reminder delivery -Reminders ride the post-execute decision's `additionalContexts` (source `{kind: 'repeat-tool-reminder'}`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit. The loop buffers the context and appends it as an injected `user/message` after the step's tool results, which the session renders as a plain synthetic user message — model-visible, source-attributed, and reconstructable from the session log with no new session event. The guard always delegates via `next()` and prepends its reminder to the downstream decision's context array, so both decision variants (a blocked call included) still get the nudge while every entry retains its own source and metadata. +Reminders ride the post-execute decision's `additionalContexts` (source `{kind: 'repeat-tool-reminder', form: 'notice', summary: ' × '}`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit. The loop buffers the context and appends it as an injected `user/message` after the step's tool results, which the session renders as a plain synthetic user message — model-visible, source-attributed, and reconstructable from the session log with no new session event. The guard always delegates via `next()` and prepends its reminder to the downstream decision's context array, so both decision variants (a blocked call included) still get the nudge while every entry retains its own source and metadata. ### Source map diff --git a/packages/guard/repeat-tool-reminder/README.zh.md b/packages/guard/repeat-tool-reminder/README.zh.md index b11d117bb1..43326ab434 100644 --- a/packages/guard/repeat-tool-reminder/README.zh.md +++ b/packages/guard/repeat-tool-reminder/README.zh.md @@ -88,7 +88,7 @@ guard 建立在四项承诺之上: ### 提醒传递 -提醒随 post-execute 决策的 `additionalContexts`(来源为 `{kind: 'repeat-tool-reminder'}`)传递,绝不替换 `content`:用于审计的 `tool/result` 事件仍保留工具自己的输出。循环会缓冲这段上下文,并在该步骤的工具结果之后作为注入的 `user/message` 追加,会话将其渲染为普通的合成用户消息——模型可见、带有来源归属,且无需新会话事件即可从会话日志重建。guard 始终通过 `next()` 委派,并把提醒放在下游决策的上下文数组之前,因此两种决策变体(包括被阻止的调用)都会收到提醒,同时每个条目保留自己的来源与元数据。 +提醒随 post-execute 决策的 `additionalContexts`(来源为 `{kind: 'repeat-tool-reminder', form: 'notice', summary: ' × '}`)传递,绝不替换 `content`:用于审计的 `tool/result` 事件仍保留工具自己的输出。循环会缓冲这段上下文,并在该步骤的工具结果之后作为注入的 `user/message` 追加,会话将其渲染为普通的合成用户消息——模型可见、带有来源归属,且无需新会话事件即可从会话日志重建。guard 始终通过 `next()` 委派,并把提醒放在下游决策的上下文数组之前,因此两种决策变体(包括被阻止的调用)都会收到提醒,同时每个条目保留自己的来源与元数据。 ### 源码地图 From 7d7ca289d9e41b0491f920ad0b44bfb732ad12ee Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 28 Sep 2026 13:55:12 +0800 Subject: [PATCH 05/49] fix(ui-schedule): enlarge the Session-row clock mark by 2px --- packages/client/ui-schedule/src/client/SessionScheduleMark.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/ui-schedule/src/client/SessionScheduleMark.tsx b/packages/client/ui-schedule/src/client/SessionScheduleMark.tsx index b81400e701..e0c2c5e8c5 100644 --- a/packages/client/ui-schedule/src/client/SessionScheduleMark.tsx +++ b/packages/client/ui-schedule/src/client/SessionScheduleMark.tsx @@ -50,7 +50,7 @@ export function SessionScheduleMark({ // own click target; the row keeps every other cell as its activation area. onClick={(event) => { event.stopPropagation() }} > - + {/* The glyph is aria-hidden, so the count reaches assistive technology only through this label, as the row's status dots do. */} {t('mark.aria', { count: facts.records.length })} From ed50a72fc4b0b7262ab5a3a85defcb62a2b6a232 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:54:26 -0700 Subject: [PATCH 06/49] feat(web): connect plugin creation and usage entrypoints --- docs/subsystems/slots.i18n.yaml | 4 +- docs/subsystems/slots.md | 5 + docs/subsystems/slots.zh.md | 5 + .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 2 + packages/client/ui-agent-preset/README.zh.md | 2 + packages/client/ui-agent-preset/package.json | 1 + .../src/client/AgentPresetLabel.module.css | 26 ++- .../src/client/AgentPresetLabel.tsx | 39 +++- .../src/client/AgentPresetSeat.module.css | 33 ++++ .../src/client/AgentPresetSeat.tsx | 34 +++- .../client/CreatePluginMenuItem.module.css | 19 ++ .../src/client/CreatePluginMenuItem.tsx | 55 ++++++ .../src/client/PresetGuideDialog.tsx | 2 +- .../ui-agent-preset/src/client/index.ts | 15 ++ .../ui-agent-preset/src/client/locales.ts | 15 ++ .../tests/apply.client.spec.ts | 45 +++++ .../tests/components.client.spec.tsx | 143 +++++++++++--- .../create-plugin-menu-item.client.spec.tsx | 154 +++++++++++++++ packages/client/ui-agent-preset/tsconfig.json | 3 + .../client/ui-plugin-manager/README.i18n.yaml | 12 +- packages/client/ui-plugin-manager/README.md | 6 +- .../client/ui-plugin-manager/README.zh.md | 6 +- .../src/client/PluginManagerPage.module.css | 144 +++++++++++++- .../src/client/PluginManagerPage.tsx | 148 +++++++++++++-- .../ui-plugin-manager/src/client/index.ts | 5 +- .../ui-plugin-manager/src/client/locales.ts | 20 ++ .../src/client/manager-store.ts | 3 + .../src/client/slot-contract.ts | 16 ++ .../tests/browser-plugin.client.spec.tsx | 26 +++ .../tests/components.client.spec.tsx | 175 +++++++++++++++++- .../tests/manager-store.client.spec.ts | 6 + packages/client/ui-schedule/README.i18n.yaml | 4 +- packages/client/ui-schedule/README.md | 2 + packages/client/ui-schedule/README.zh.md | 2 + packages/client/ui-schedule/package.json | 1 + .../src/client/SchedulePluginUsage.module.css | 53 ++++++ .../src/client/SchedulePluginUsage.tsx | 76 ++++++++ .../client/ui-schedule/src/client/index.ts | 14 ++ .../src/client/task-manager-locales.ts | 18 ++ .../tests/browser-plugin.client.spec.ts | 42 ++++- .../schedule-plugin-usage.client.spec.tsx | 97 ++++++++++ packages/client/ui-schedule/tsconfig.json | 3 + .../src/client/slot-catalog.ts | 107 ++++++++++- pnpm-lock.yaml | 6 + 45 files changed, 1511 insertions(+), 87 deletions(-) create mode 100644 packages/client/ui-agent-preset/src/client/CreatePluginMenuItem.module.css create mode 100644 packages/client/ui-agent-preset/src/client/CreatePluginMenuItem.tsx create mode 100644 packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx create mode 100644 packages/client/ui-schedule/src/client/SchedulePluginUsage.module.css create mode 100644 packages/client/ui-schedule/src/client/SchedulePluginUsage.tsx create mode 100644 packages/client/ui-schedule/tests/schedule-plugin-usage.client.spec.tsx diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index a78b1185a1..1757552737 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/slots.md /web-client-slots: - en: bda026282132e290 - zh: 3e3faf96c72420d6 + en: 593f9ed34e925132 + zh: f82611667c858768 /web-client-slots/declaration-and-lifecycle: en: f2d2c0d3b8f14ec7 zh: 441ed13ff3f81d2a diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index 13b47b7460..bf4df4d478 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -8,6 +8,8 @@ This page documents slot ownership, component inputs, extension APIs, and the sh `plugins.bundle.config` supplies bundle detail configuration, keyed by npm package name. `plugins.bundle.activation` renders optional guidance after user-requested enablement, with owner callbacks to dismiss or open that bundle’s details. `conversation.input.activity` supplies one action between the model selector and Send, with toolbar expansion released on unmount. +The plugin catalog declares `plugins.add.actions`, a root-scoped list for `MenuItemButton` rows after installation in the Add plugin menu. Its owner supplies `onDismiss()` to close the menu before starting an action. It also declares `plugins.bundle.usage`, a root-scoped keyed slot for usage guidance before component details. The latter dispatches the npm package name and supplies `{ pkg: PluginPackageRef }`; its occupant decides which actions apply to the bundle's installed and enabled state. Both follow the owning Plugins page declaration lifetime. + ## Declaration and lifecycle `SlotMap` is the compile-time registry. A package declaration-merges the key, cardinality, scope, owner props, keyed props, and optional slot-level inject face. The runtime declaration is the matching `children` entry on the component that owns the render location. @@ -133,7 +135,10 @@ root │ ├─ settings.models.footer │ └─ settings.plugins.tab ├─ main +│ ├─ plugins.add.actions │ ├─ plugins.item +│ ├─ plugins.bundle.activation +│ ├─ plugins.bundle.usage │ ├─ plugins.bundle.config │ ├─ plugins.row.config │ ├─ plugins.detail.actions diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index 42bf724545..cfebe63101 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -8,6 +8,8 @@ Slots 是 Web Client 的类型化 React 组合系统。[`dsh-client-ui-slots`](. `plugins.bundle.config` 以 npm 包名为 key,提供 Bundle 详情配置。`plugins.bundle.activation` 在用户显式启用后渲染可选引导,由拥有者提供关闭引导和打开该 Bundle 详情的回调。`conversation.input.activity` 提供模型选择器与发送按钮之间的单个活动控件,可展开工具栏并在卸载时释放展开状态。 +插件目录声明 `plugins.add.actions`,它是 root 作用域的 list,在添加插件菜单的安装项之后提供 `MenuItemButton` 行。owner 提供 `onDismiss()`,用于开始动作前关闭菜单。同时声明 `plugins.bundle.usage`,它是 root 作用域的 keyed slot,在组件详情之前提供使用指引。后者以 npm 包名分派,并传入 `{ pkg: PluginPackageRef }`;占用者根据组合包的安装和启用状态决定适用动作。两者均遵循所属插件页面声明的生命周期。 + ## 声明与生命周期 `SlotMap` 是编译期注册表。包通过声明合并写入 key、cardinality(基数)、scope、owner props、keyed props 与可选的 slot 级 inject face。运行时声明则是拥有该渲染位置的组件在 `children` 中给出的对应条目。 @@ -133,7 +135,10 @@ root │ ├─ settings.models.footer │ └─ settings.plugins.tab ├─ main +│ ├─ plugins.add.actions │ ├─ plugins.item +│ ├─ plugins.bundle.activation +│ ├─ plugins.bundle.usage │ ├─ plugins.bundle.config │ ├─ plugins.row.config │ ├─ plugins.detail.actions diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 7f08d21282..66129e14b6 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: b3ad7867f23dad1c /deepseek-ai-dsh-client-ui-agent-preset/use-this-package: - en: cf381c4f00b42c3f - zh: 1b226687dcbd4875 + en: a44127eb01ad2ff6 + zh: d11935da1782d5a5 /deepseek-ai-dsh-client-ui-agent-preset/understand-the-implementation: en: 7813f0063c15e241 zh: 6eabc242edfc45ec diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index ca7bba393e..852db2158b 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -31,6 +31,8 @@ Coding Tools in General Settings decide whether a mode can be chosen at all: wit Known shipped presets offer mode details and usage examples in a read-only dialog. Its tabs preserve each page's scroll position; closing returns focus to the opening action. Help does not change the new-task default. The default badge replaces the card's group badge, and the preset id appears beside the title. Guide copy and examples belong to this package. +The new-task picker and session header also open the same read-only help. The Plugins page’s “Add plugin” menu offers “Let the agent create a plugin”. Choosing it closes the menu and opens the existing Creator task flow without submitting a message or replacing a draft. The item stays in place while the roster loads or refreshes, and explains why it is disabled when Creator mode is unavailable or Coding Tools are off. The optional slot contribution disappears with its owner. + ## Understand the implementation diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index d2a3471f45..baa8380f96 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -31,6 +31,8 @@ kind: "package-reference" 已知的内置预设提供只读的模式说明与使用示例对话框。各页签保留各自的滚动位置;关闭后焦点回到打开它的操作。帮助不会改变新任务默认值。默认徽标取代卡片的分组徽标,预设 id 显示在标题旁。指南文案与示例归本包所有。 +新任务选择器和任务标题也可打开同一份只读说明。插件页的「添加插件」菜单提供「让 Agent 创建插件」。选择后关闭菜单并进入现有创造模式任务流程,不发送消息,也不替换草稿。预设列表加载或刷新时菜单项保持原位;创造模式不可用或代码工作工具关闭时禁用,并解释原因。菜单项由可选插槽贡献,随所属插件卸载而移除。 + ## 理解实现 diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index a08b9d75ef..00a62f9a89 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -58,6 +58,7 @@ "@deepseek-ai/dsh-client-store": "workspace:*", "@deepseek-ai/dsh-client-test-runtime": "workspace:*", "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-session": "workspace:*", diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css index f828de9af1..dc08b0ce98 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css @@ -1,4 +1,4 @@ -/* Session-header agent-preset label: static chrome, never a control. */ +/* Session-header preset details never change the task's selected mode. */ .label { display: inline-flex; @@ -7,6 +7,7 @@ max-width: 180px; padding: 0 2px 0 0; height: 22px; + border: none; border-radius: var(--dsw-radius-xs); background: var(--dsw-alias-fill-tsp-secondary); font-size: 12px; @@ -17,14 +18,33 @@ text-overflow: ellipsis; } +.name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.details { + cursor: pointer; +} + +.details:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} + +.details:focus-visible { + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color); + outline-offset: 2px; +} + .icon { flex: none; opacity: 0.7; } /* First sacrifice when the title row (the enclosing inline-size container) - runs out of width: the preset is passive chrome, so it yields before any - interactive header action loses its label. */ + runs out of width: mode help yields before task actions lose their labels. */ @container (max-width: 540px) { .label { display: none; diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx index 26fdcfcbf6..968cc14ce4 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx @@ -3,20 +3,21 @@ * * Read-only by construction: a session's composition is fixed once its * conversation starts, and a header is only worth reading after that. Offering - * a control here would promise a switch the host refuses; naming what the - * session runs is the honest affordance, and the choice itself lives on the + * a selection control here would promise a switch the host refuses. Its + * read-only details explain the mode, and the choice itself lives on the * new-session screen ({@link AgentPresetSeat}). */ -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { IconAgentPresetOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconAgentPresetOutlineRegular, Modal } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the ui-conversation SlotMap merge (the header actions). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-agent-preset-registry/types' import type { AgentPresetSettingsState } from './settings-store.ts' -import { presetDisplayText } from './locales.ts' +import { isBuiltInPreset, presetDisplayText } from './locales.ts' +import { PresetGuideDialog, presetGuide } from './PresetGuideDialog.tsx' import css from './AgentPresetLabel.module.css' /** Registration-side business face for the header label. */ @@ -48,6 +49,10 @@ export function AgentPresetLabel({ return typeof value === 'string' ? value : undefined }) const options = useAgentPresets(state => state.options) + const [detailsOpen, setDetailsOpen] = useState(false) + const option = options.find(entry => entry.id === preset) + + useEffect(() => { setDetailsOpen(false) }, [sessionId, preset, option?.id]) useEffect(() => { // Deployments that compose no presets never label anything, so the roster @@ -57,12 +62,28 @@ export function AgentPresetLabel({ if (preset === undefined) return null - const option = options.find(entry => entry.id === preset) const text = option === undefined ? undefined : presetDisplayText(option, t) - return ( - + const label = text?.name ?? preset + const guide = option === undefined ? undefined : presetGuide(option.id, isBuiltInPreset(option) ? 'system' : 'user') + if (option === undefined) return ( + - {text?.name ?? preset} + {label} ) + return ( + <> + + {!detailsOpen ? null : guide === undefined ? ( + { setDetailsOpen(false) }} /> + ) : ( + { setDetailsOpen(false) }} /> + )} + + ) } diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css index 04f27d122d..eaa939d161 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css @@ -1,6 +1,39 @@ /* Agent-preset chip on the new-session screen, beside the workspace picker. Geometry mirrors HeroShell's .workspace so the two read as one row. */ +.controls { + display: inline-flex; + align-items: center; + gap: 2px; + min-width: 0; + max-width: 100%; +} + +.details { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + width: 24px; + height: 28px; + padding: 0; + border: none; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: var(--dsw-alias-label-caption); + cursor: pointer; +} + +.details:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} + +.details:focus-visible { + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color); + outline-offset: 2px; +} + /* Overflow-driven degradation (mirrors HeroShell's .workspace), no breakpoint: the label shrinks with an ellipsis down to nothing, so the preset icon and chevron always sit snug against whatever text remains — diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx index d32bc78f63..a039b5b841 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx @@ -16,12 +16,14 @@ import { useEffect, useRef, useState } from 'react' import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { - IconAgentPresetOutlineRegular, IconChevronDownOutlineRegular, IconWarningOutlineRegular, Menu, Toast, + IconAgentPresetOutlineRegular, IconChevronDownOutlineRegular, IconInfoOutlineRegular, IconWarningOutlineRegular, + Menu, Modal, Toast, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the ui-conversation SlotMap merge (the hero seat). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { AgentPresetSeatState } from './seat-store.ts' -import { presetDisplayText } from './locales.ts' +import { isBuiltInPreset, presetDisplayText } from './locales.ts' +import { PresetGuideDialog, presetGuide } from './PresetGuideDialog.tsx' import css from './AgentPresetSeat.module.css' /** Registration-side business face for the hero chip. */ @@ -84,6 +86,7 @@ export function AgentPresetSeat({ const main = useSessionRetainInfo(info => sessionId === undefined || (info?.retainedBy.mainView ?? 0) > 0) const [open, setOpen] = useState(false) + const [detailsOpen, setDetailsOpen] = useState(false) // The seq keys the banner, so picking the same broken preset twice replays // it rather than leaving the first one silently in place. const toastSeq = useRef(0) @@ -101,13 +104,17 @@ export function AgentPresetSeat({ useEffect(() => { if (developerTools) return setOpen(false) + setDetailsOpen(false) setToast(null) }, [developerTools]) const chosen = state.options.find(option => option.id === state.current) + useEffect(() => { setDetailsOpen(false) }, [state.current, chosen?.id]) + const chosenText = chosen === undefined ? undefined : presetDisplayText(chosen, t) const label = chosenText?.name ?? state.current const ready = state.options.length > 0 && state.current !== '' + const guide = chosen === undefined ? undefined : presetGuide(chosen.id, isBuiltInPreset(chosen) ? 'system' : 'user') // The introduce cue: the pick was staged from another screen (the settings // creator entry), so the chip announces it — the icon eases in and each @@ -154,7 +161,7 @@ export function AgentPresetSeat({ : label return ( - <> +
{ setOpen(false) }} @@ -208,6 +215,25 @@ export function AgentPresetSeat({ )} /> + {chosen === undefined ? null : ( + + + + )} + {!detailsOpen || chosen === undefined ? null : guide === undefined ? ( + { setDetailsOpen(false) }} /> + ) : ( + { setDetailsOpen(false) }} /> + )} {toast !== null && ( { setToast(null) }} /> )} - +
) } diff --git a/packages/client/ui-agent-preset/src/client/CreatePluginMenuItem.module.css b/packages/client/ui-agent-preset/src/client/CreatePluginMenuItem.module.css new file mode 100644 index 0000000000..e2540e0861 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/CreatePluginMenuItem.module.css @@ -0,0 +1,19 @@ +.copy { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + white-space: normal; +} + +.description { + display: -webkit-box; + max-height: 36px; + overflow: hidden; + overflow-wrap: anywhere; + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 18px; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} diff --git a/packages/client/ui-agent-preset/src/client/CreatePluginMenuItem.tsx b/packages/client/ui-agent-preset/src/client/CreatePluginMenuItem.tsx new file mode 100644 index 0000000000..a9464cbe5e --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/CreatePluginMenuItem.tsx @@ -0,0 +1,55 @@ +/** Create a plugin through the existing Creator flow from the Add plugin menu. */ +import { useEffect } from 'react' +import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' +import { IconAgentPresetOutlineRegular, MenuItemButton } from '@deepseek-ai/dsh-client-ui-primitives' +import type { AgentPresetSettingsState } from './settings-store.ts' +import css from './CreatePluginMenuItem.module.css' + +/** Roster, preference, and navigation supplied by the preset plugin. */ +export interface CreatePluginMenuItemInjected { + hooks: { + developerTools: ObservableSnapshot + agentPresets: SnapshotStore + } + load: () => Promise + startCreatorDraft: () => void +} + +type CreatePluginMenuItemProps = PropsRuntime<'plugins.add.actions'> + & PropsLocale<'settings.agentPreset'> & InjectFace + +/** + * Close the Add plugin menu before opening Creator without sending a message. + * @param props - locale, roster, preference, dismissal and navigation callbacks. + * @returns a stable menu item that explains why Creator is unavailable. + */ +export function CreatePluginMenuItem({ + t, useDeveloperTools, useAgentPresets, load, onDismiss, startCreatorDraft, +}: CreatePluginMenuItemProps) { + const developerTools = useDeveloperTools(value => value) + const roster = useAgentPresets(state => state) + const available = roster.status === 'ready' && roster.options.some(option => option.id === 'cordis') + const enabled = developerTools && available + useEffect(() => { void load() }, [load]) + + let description = t('createPluginDescription') + if (!developerTools) description = t('enableDevToolsToCreate') + else if (roster.status === 'idle' || roster.status === 'loading') description = t('createPluginChecking') + else if (roster.status === 'error' || roster.status === 'unavailable') description = t('createPluginUnavailable') + else if (!available) description = t('createPluginMissing') + + return ( + } disabled={!enabled} onSelect={() => { + if (!enabled) return + onDismiss() + startCreatorDraft() + }}> + + {t('createPlugin')} + {description} + + + ) +} diff --git a/packages/client/ui-agent-preset/src/client/PresetGuideDialog.tsx b/packages/client/ui-agent-preset/src/client/PresetGuideDialog.tsx index 2811594e13..a0c4d5fff3 100644 --- a/packages/client/ui-agent-preset/src/client/PresetGuideDialog.tsx +++ b/packages/client/ui-agent-preset/src/client/PresetGuideDialog.tsx @@ -1,4 +1,4 @@ -/** Read-only help stays local to Settings and never changes the selected preset. */ +/** Shared read-only help never changes the selected preset. */ import { useId, useLayoutEffect, useRef, useState } from 'react' import type { KeyboardEvent, ReactNode } from 'react' import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 155b324587..299d8f1d45 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -31,8 +31,10 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' // Type-only: pulls the Workspace UI navigation service merge (ctx.uiWorkspace). import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' +import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' import { AgentPresetLabel } from './AgentPresetLabel.tsx' +import { CreatePluginMenuItem } from './CreatePluginMenuItem.tsx' import type { AgentPresetLabelInjected } from './AgentPresetLabel.tsx' import { AgentPresetSeat } from './AgentPresetSeat.tsx' import type { AgentPresetSeatInjected } from './AgentPresetSeat.tsx' @@ -193,6 +195,19 @@ export function apply(ctx: ClientContext): void { label() } }, 'ui-agent-preset: new-session chip and header label') + + scope.slots.inject('plugins.add.actions', () => scope.slots.register({ + name: 'plugins.add.actions', + id: 'create-plugin', + locale: 'settings.agentPreset', + inject: () => ({ + hooks: { developerTools, agentPresets: controller.store }, + load: () => controller.load(), + startCreatorDraft: () => { + if (developerTools.getSnapshot()) creatorDraft?.() + }, + }), + }, CreatePluginMenuItem)) }) /** Capture the exact blank Session one Settings action may update. */ diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index 30b3e79eac..bf31a6911f 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -27,6 +27,11 @@ export type AgentPresetSettingsKey = | 'switchRefused' | 'close' | 'creatorDraft' + | 'createPlugin' + | 'createPluginDescription' + | 'createPluginChecking' + | 'createPluginUnavailable' + | 'createPluginMissing' | 'enableDevToolsToSetDefault' | 'enableDevToolsToCreate' @@ -66,6 +71,11 @@ export const en: Record = { close: 'Close', creatorDraft: 'Let the agent help me create a preset', + createPlugin: 'Let the agent create a plugin', + createPluginDescription: 'Enter Creator mode and make your own DSH plugin', + createPluginChecking: 'Checking whether Creator mode is available', + createPluginUnavailable: 'Temporarily unavailable. Reopen this menu to retry', + createPluginMissing: 'Creator mode is not included in this configuration', enableDevToolsToSetDefault: 'Turn on Coding Tools in General settings to choose a default', enableDevToolsToCreate: 'Turn on Coding Tools in General settings to start Creator mode', @@ -103,6 +113,11 @@ export const zh: Record = { close: '关闭', creatorDraft: '让 Agent 帮我创建预设模式', + createPlugin: '让 Agent 创建插件', + createPluginDescription: '进入创造模式,制作属于你的 DSH 插件', + createPluginChecking: '正在确认创造模式是否可用', + createPluginUnavailable: '暂时不可用,请重新打开菜单重试', + createPluginMissing: '当前配置未提供创造模式', enableDevToolsToSetDefault: '请先在通用设置中开启代码工作工具,再设置默认值', enableDevToolsToCreate: '请先在通用设置中开启代码工作工具,再启动创造模式', diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index 6e93b1e836..bb74d59461 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -22,6 +22,7 @@ import type { AgentPresetSectionInjected } from '../src/client/AgentPresetSectio import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx' import type { AgentPresetSeatInjected } from '../src/client/AgentPresetSeat.tsx' import { AgentPresetSeatController } from '../src/client/seat-store.ts' +import { CreatePluginMenuItem, type CreatePluginMenuItemInjected } from '../src/client/CreatePluginMenuItem.tsx' import { apply as hostApply } from '../src/index.ts' // These specs assert the shipped Chinese copy. The lane has no jsdom `window`, @@ -812,6 +813,50 @@ describe('ui-agent-preset apply', () => { expect(label.hooks.agentPresets.getSnapshot().options).toEqual([{ id: 'standard' }]) }) + it('binds the Add plugin menu item to the roster and Creator flow and removes it with the feature', async () => { + const { ctx, slots, setDeveloperTools } = await bench() + try { + slots.register({ + name: 'root', + children: { + 'settings.section': { kind: 'list', scope: 'root' }, + conversation: { kind: 'single', scope: 'root' }, + 'plugins.add.actions': { kind: 'list', scope: 'root' }, + }, + } as never, () => null) + declareConversation(slots) + ctx.provide('conversation', {} as never) + ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never) + const uiWorkspace = uiWorkspaceDouble() + ctx.provide('uiWorkspace', uiWorkspace as never) + const feature = ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }) + await feature.await() + + const entry = slots.entries('plugins.add.actions')[0]! + expect(entry.component).toBe(CreatePluginMenuItem) + const action: Partial | undefined = entry.inject?.() + if (action?.load === undefined || action.hooks === undefined || action.startCreatorDraft === undefined) { + throw new Error('expected the injected Create plugin actions') + } + await action.load() + expect(action.hooks.agentPresets.getSnapshot().options).toEqual([{ id: 'standard' }]) + + await setDeveloperTools(false) + action.startCreatorDraft() + expect(uiWorkspace.starts).toHaveLength(0) + await setDeveloperTools(true) + action.startCreatorDraft() + expect(uiWorkspace.starts).toHaveLength(1) + + await feature.dispose() + expect(slots.entries('plugins.add.actions')).toHaveLength(0) + action.startCreatorDraft() + expect(uiWorkspace.starts).toHaveLength(1) + } finally { + await ctx.fiber.dispose() + } + }) + it('stages the creator preset and starts a session from the section', async () => { const { ctx, slots } = await bench() declareRoot(slots) diff --git a/packages/client/ui-agent-preset/tests/components.client.spec.tsx b/packages/client/ui-agent-preset/tests/components.client.spec.tsx index 60dcd460f0..b7b24f8d2b 100644 --- a/packages/client/ui-agent-preset/tests/components.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/components.client.spec.tsx @@ -49,6 +49,12 @@ function translate(key: keyof typeof en, params?: Record): stri : template.replace(/\{(\w+)\}/g, (match, name: string) => name in params ? String(params[name]) : match) } +function seatTrigger(): HTMLElement { + const button = screen.getAllByRole('button').find(item => item.getAttribute('aria-haspopup') === 'menu') + if (button === undefined) throw new Error('No preset selection trigger') + return button +} + function renderSeat( state: Partial = {}, select: () => Promise = () => Promise.resolve(undefined), @@ -69,7 +75,7 @@ function renderSeat( t: translate, } as AgentPresetSeatProps render() - return { ...actions, developerTools } + return { ...actions, developerTools, store } } function renderLabel( @@ -89,7 +95,7 @@ function renderLabel( useAgentPresets: bindSnapshotSelector(store), t: (key: keyof typeof en) => en[key], } as unknown as AgentPresetLabelProps)} />) - return { load, view } + return { load, view, store } } describe('the new-session chip', () => { @@ -103,7 +109,7 @@ describe('the new-session chip', () => { renderSeat({}, undefined, { id: 's1', retainInfo: { referenceCount: 1, retainedBy: { mainView: 1 } }, }) - expect(screen.getByRole('button')).toBeTruthy() + expect(seatTrigger()).toBeTruthy() cleanup() renderSeat({}, undefined, { id: 's1', retainInfo: undefined }) @@ -114,14 +120,14 @@ describe('the new-session chip', () => { const actions = renderSeat() await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) }) - expect(screen.getByRole('button').textContent).toContain(en.presetStandardName) - expect(screen.getByRole('button').getAttribute('title')).toBe(en.seatHint) + expect(seatTrigger().textContent).toContain(en.presetStandardName) + expect(seatTrigger().getAttribute('title')).toBe(en.seatHint) }) it('offers each preset with what it is for', () => { renderSeat() - fireEvent.click(screen.getByRole('button')) + fireEvent.click(seatTrigger()) // The id alone never said what a preset does; the description is the // whole reason a preset can publish metadata at all. @@ -134,49 +140,49 @@ describe('the new-session chip', () => { it('closes the picker immediately when developer tools turn off without changing the staged preset', () => { const actions = renderSeat() - fireEvent.click(screen.getByRole('button')) + fireEvent.click(seatTrigger()) expect(screen.getByText(en.presetStandardDescription)).toBeTruthy() act(() => { actions.developerTools.set(false) }) expect(screen.queryByRole('button')).toBeNull() expect(screen.queryByText(en.presetStandardDescription)).toBeNull() expect(actions.select).not.toHaveBeenCalled() act(() => { actions.developerTools.set(true) }) - expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false') - expect(screen.getByRole('button').textContent).toContain(en.presetStandardName) + expect(seatTrigger().getAttribute('aria-expanded')).toBe('false') + expect(seatTrigger().textContent).toContain(en.presetStandardName) }) it('falls back to the id when the staged preset published no name', () => { renderSeat({ current: 'mine' }) - expect(screen.getByRole('button').textContent).toContain('mine') + expect(seatTrigger().textContent).toContain('mine') }) it('shows the staged id until a stale roster contains it', () => { renderSeat({ current: 'arriving' }) - expect(screen.getByRole('button').textContent).toContain('arriving') + expect(seatTrigger().textContent).toContain('arriving') }) it('stages the picked preset and closes the menu', () => { const actions = renderSeat() - fireEvent.click(screen.getByRole('button')) + fireEvent.click(seatTrigger()) fireEvent.click(screen.getByText('mine')) expect(actions.select).toHaveBeenCalledWith('mine') - expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false') + expect(seatTrigger().getAttribute('aria-expanded')).toBe('false') }) it('disables the trigger while a switch is in flight', () => { renderSeat({ busy: true }) - expect(screen.getByRole('button')).toHaveProperty('disabled', true) + expect(seatTrigger()).toHaveProperty('disabled', true) }) it('shows a refused switch on the trigger', () => { renderSeat({ error: 'session has already started' }) - expect(screen.getByRole('button').getAttribute('title')).toBe('session has already started') + expect(seatTrigger().getAttribute('title')).toBe('session has already started') }) it('renders nothing before the roster arrives or when there is none', () => { @@ -191,11 +197,66 @@ describe('the new-session chip', () => { it('closes on an outside dismissal', () => { renderSeat() - fireEvent.click(screen.getByRole('button')) + fireEvent.click(seatTrigger()) fireEvent.keyDown(document, { key: 'Escape' }) - expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false') + expect(seatTrigger().getAttribute('aria-expanded')).toBe('false') + }) + + it('opens mode help without selecting and returns keyboard focus after Escape', () => { + const actions = renderSeat() + const trigger = screen.getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` }) + trigger.focus() + fireEvent.click(trigger) + + expect(screen.getByRole('dialog', { name: en.presetStandardName })).toBeTruthy() + expect(document.activeElement).toBe(screen.getByRole('tab', { name: en.modeExplanation })) + fireEvent.click(screen.getByRole('tab', { name: en.howToUse })) + expect(screen.getByRole('tab', { name: en.howToUse }).getAttribute('aria-selected')).toBe('true') + fireEvent.keyDown(document.activeElement!, { key: 'Escape' }) + + expect(screen.queryByRole('dialog')).toBeNull() + expect(document.activeElement).toBe(trigger) + expect(actions.select).not.toHaveBeenCalled() + }) + + it('uses only a custom preset description even when it names a built-in id', () => { + const actions = renderSeat({ options: [{ id: 'standard', name: 'My workflow', description: 'Our review workflow' }] }) + fireEvent.click(screen.getByRole('button', { name: `${en.modeExplanation}: My workflow` })) + + expect(screen.getByRole('dialog', { name: 'My workflow' }).textContent).toContain('Our review workflow') + expect(screen.queryByRole('tab')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: en.close })) + expect(screen.queryByRole('dialog')).toBeNull() + expect(actions.select).not.toHaveBeenCalled() + }) + + it('forgets open help when developer tools turn off', () => { + const actions = renderSeat() + fireEvent.click(screen.getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` })) + act(() => { actions.developerTools.set(false) }) + expect(screen.queryByRole('dialog')).toBeNull() + act(() => { actions.developerTools.set(true) }) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it.each([ + { name: 'no presets', options: [] }, + { name: 'another preset', options: [{ id: 'mine' }] }, + ])('keeps help closed when the current preset returns after removal leaves $name', ({ options }) => { + const actions = renderSeat() + const before = actions.store.getSnapshot() + fireEvent.click(screen.getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` })) + expect(screen.getByRole('dialog', { name: en.presetStandardName })).toBeTruthy() + + act(() => { actions.store.set({ ...before, options }) }) + expect(screen.queryByRole('dialog')).toBeNull() + act(() => { actions.store.set(before) }) + + expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` })).toBeTruthy() + expect(actions.select).not.toHaveBeenCalled() }) }) @@ -208,7 +269,7 @@ describe('a refused switch', () => { const reason = 'failed to import loader entry live-on-mac (@deepseek-ai/dsh-also-gone)' renderSeat({}, () => Promise.resolve(reason)) - fireEvent.click(screen.getByRole('button')) + fireEvent.click(seatTrigger()) fireEvent.click(screen.getByRole('menuitem', { name: /mine/ })) // The host refuses a mount discovery reported healthy, so this banner is @@ -230,7 +291,7 @@ describe('a refused switch', () => { it('says nothing when the switch lands', async () => { const actions = renderSeat() - fireEvent.click(screen.getByRole('button')) + fireEvent.click(seatTrigger()) fireEvent.click(screen.getByRole('menuitem', { name: /mine/ })) await waitFor(() => { expect(actions.select).toHaveBeenCalledWith('mine') }) @@ -246,7 +307,7 @@ describe('the chip introduce cue', () => { /** Character spans carry inline animation delays; nothing else does. */ function delayedChars(): HTMLElement[] { - return Array.from(screen.getByRole('button').querySelectorAll('[style]')) + return Array.from(seatTrigger().querySelectorAll('[style]')) } it('reveals a long Latin name inside the shared window, then acknowledges', () => { @@ -334,11 +395,49 @@ describe('the session-header label', () => { }) await waitFor(() => { expect(load).toHaveBeenCalledTimes(1) }) - // A control here would promise a switch the host refuses outright. - expect(screen.queryByRole('button')).toBeNull() + // Reading details never offers a change to the task's preset. + expect(screen.getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` })).toBeTruthy() + expect(screen.queryByRole('menu')).toBeNull() expect(screen.getByTitle(en.presetStandardDescription).textContent).toBe(en.presetStandardName) }) + it('opens read-only mode help from a running task and restores focus on close', () => { + renderLabel({ blank: false, projectionValues: { agentPreset: 'standard' } }) + const trigger = screen.getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` }) + trigger.focus() + fireEvent.click(trigger) + + expect(screen.getByRole('dialog', { name: en.presetStandardName })).toBeTruthy() + expect(screen.queryByRole('menu')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: en.close })) + expect(screen.queryByRole('dialog')).toBeNull() + expect(document.activeElement).toBe(trigger) + }) + + it('displays the custom description without claiming built-in capabilities', () => { + renderLabel({ blank: false, projectionValues: { agentPreset: 'standard' } }, { + options: [{ id: 'standard', name: 'Review only', description: 'Describe changes for review' }], + }) + fireEvent.click(screen.getByRole('button', { name: `${en.modeExplanation}: Review only` })) + expect(screen.getByRole('dialog', { name: 'Review only' }).textContent).toContain('Describe changes for review') + expect(screen.queryByRole('tab')).toBeNull() + }) + + it('keeps help closed when the session preset returns after leaving the roster', () => { + const { store } = renderLabel({ blank: false, projectionValues: { agentPreset: 'standard' } }) + const before = store.getSnapshot() + fireEvent.click(screen.getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` })) + expect(screen.getByRole('dialog', { name: en.presetStandardName })).toBeTruthy() + + act(() => { store.set({ ...before, options: [{ id: 'mine' }] }) }) + expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.getByTitle(en.headerHint).textContent).toBe('standard') + act(() => { store.set(before) }) + + expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` })).toBeTruthy() + }) + it('falls back to the id, and to the generic hint, when metadata is absent', () => { renderLabel({ blank: true, projectionValues: { agentPreset: 'mine' } }) diff --git a/packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx b/packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx new file mode 100644 index 0000000000..5f57fb09e1 --- /dev/null +++ b/packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx @@ -0,0 +1,154 @@ +// @vitest-environment jsdom +import { useState, type ComponentProps } from 'react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { Menu, MenuItemButton } from '@deepseek-ai/dsh-client-ui-primitives' +import { CreatePluginMenuItem } from '../src/client/CreatePluginMenuItem.tsx' +import type { AgentPresetSettingsState } from '../src/client/settings-store.ts' +import { en } from '../src/client/locales.ts' + +afterEach(cleanup) +const translations: ReadonlyMap = new Map(Object.entries(en)) + +function unusedHook(): never { + throw new Error('Create plugin does not read this slot source') +} + +function view(enabled = true, roster: Partial = {}, inMenu = false) { + const developerTools = createSnapshotStore(enabled) + const agentPresets = createSnapshotStore({ + status: 'ready', error: null, options: [{ id: 'standard' }, { id: 'cordis' }], ...roster, + }) + const calls: string[] = [] + const startCreatorDraft = vi.fn(() => { calls.push('start') }) + const onDismiss = vi.fn(() => { calls.push('dismiss') }) + const load = vi.fn(async () => {}) + const props: ComponentProps = { + t: key => translations.get(key) ?? key, + useDeveloperTools: bindSnapshotSelector(developerTools), + useAgentPresets: bindSnapshotSelector(agentPresets), + usePanelInfo: unusedHook, useSessions: unusedHook, useSessionStatus: unusedHook, + useSessionRetainInfo: unusedHook, useWorkspaces: unusedHook, useResource: unusedHook, + load, startCreatorDraft, onDismiss, + } + function TestMenu() { + const [open, setOpen] = useState(true) + return { setOpen(false) }} + anchor={}> + {}}>Install a third-party plugin + { onDismiss(); setOpen(false) }} /> + + } + render(inMenu ? : ) + return { developerTools, agentPresets, load, startCreatorDraft, onDismiss, calls } +} + +function item(): HTMLElement { + return screen.getByRole('menuitem', { name: new RegExp(en.createPlugin) }) +} + +describe('Create plugin menu item', () => { + it('reads the roster and explains the direct Creator action without an introductory dialog', () => { + const actions = view() + expect(actions.load).toHaveBeenCalledOnce() + expect(item().textContent).toContain(en.createPluginDescription) + expect(screen.queryByRole('dialog')).toBeNull() + expect(actions.startCreatorDraft).not.toHaveBeenCalled() + }) + + it('dismisses the menu before starting Creator exactly once', () => { + const actions = view() + fireEvent.click(item()) + expect(actions.calls).toEqual(['dismiss', 'start']) + expect(actions.startCreatorDraft).toHaveBeenCalledOnce() + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('supports keyboard selection through the shared menu and closes before navigation', () => { + const actions = view(true, {}, true) + const trigger = item() + trigger.focus() + fireEvent.keyDown(trigger, { key: 'Tab' }) + expect(actions.calls).toEqual(['dismiss', 'start']) + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('explains disabled Developer tools and enables the same entry after the preference changes', () => { + const actions = view(false) + expect(item()).toHaveProperty('disabled', true) + expect(item().textContent).toContain(en.enableDevToolsToCreate) + fireEvent.click(item()) + expect(actions.calls).toEqual([]) + + act(() => { actions.developerTools.set(true) }) + expect(item()).toHaveProperty('disabled', false) + expect(item().textContent).toContain(en.createPluginDescription) + fireEvent.click(item()) + expect(actions.calls).toEqual(['dismiss', 'start']) + }) + + it('honors Developer tools turning off while the menu is open', () => { + const actions = view() + act(() => { actions.developerTools.set(false) }) + expect(item()).toHaveProperty('disabled', true) + fireEvent.click(item()) + expect(actions.calls).toEqual([]) + }) + + it.each(['idle', 'loading', 'error', 'unavailable'] as const)('keeps a disabled entry while the roster is %s', (status) => { + const actions = view(true, { status }) + expect(item()).toHaveProperty('disabled', true) + const description = status === 'idle' || status === 'loading' ? en.createPluginChecking : en.createPluginUnavailable + expect(item().textContent).toContain(description) + fireEvent.click(item()) + expect(actions.calls).toEqual([]) + }) + + it('explains why the entry is disabled when the deployment has no cordis preset', () => { + const actions = view(true, { options: [{ id: 'standard' }] }) + expect(item()).toHaveProperty('disabled', true) + expect(item().textContent).toContain(en.createPluginMissing) + fireEvent.click(item()) + expect(actions.calls).toEqual([]) + }) + + it('disables the same entry when Creator leaves the roster', () => { + const actions = view() + const original = item() + act(() => { actions.agentPresets.set({ status: 'ready', error: null, options: [{ id: 'standard' }] }) }) + expect(item()).toBe(original) + expect(item()).toHaveProperty('disabled', true) + expect(item().textContent).toContain(en.createPluginMissing) + fireEvent.click(item()) + expect(actions.calls).toEqual([]) + }) + + it('keeps the same second menu item through ready, loading, and ready states', () => { + const actions = view(true, {}, true) + const menu = screen.getByRole('menu') + const before = screen.getAllByRole('menuitem') + const original = item() + expect(before[1]).toBe(original) + expect(original).toHaveProperty('disabled', false) + + act(() => { actions.agentPresets.set({ ...actions.agentPresets.getSnapshot(), status: 'loading' }) }) + expect(screen.getByRole('menu')).toBe(menu) + expect(screen.getAllByRole('menuitem')).toEqual(before) + expect(item()).toBe(original) + expect(original).toHaveProperty('disabled', true) + expect(original.textContent).toContain(en.createPluginChecking) + fireEvent.click(original) + expect(actions.calls).toEqual([]) + + act(() => { actions.agentPresets.set({ ...actions.agentPresets.getSnapshot(), status: 'ready' }) }) + expect(screen.getByRole('menu')).toBe(menu) + expect(screen.getAllByRole('menuitem')).toEqual(before) + expect(item()).toBe(original) + expect(original).toHaveProperty('disabled', false) + expect(original.textContent).toContain(en.createPluginDescription) + fireEvent.click(original) + expect(actions.calls).toEqual(['dismiss', 'start']) + }) +}) diff --git a/packages/client/ui-agent-preset/tsconfig.json b/packages/client/ui-agent-preset/tsconfig.json index dee5fb035d..564a0b82d9 100644 --- a/packages/client/ui-agent-preset/tsconfig.json +++ b/packages/client/ui-agent-preset/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../ui-primitives" }, + { + "path": "../ui-plugin-manager/tsconfig.client.json" + }, { "path": "../ui-renderer" }, diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 29acda2d0f..29049aeef2 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -18,11 +18,11 @@ en: b7d2396da72ecac4 zh: 4ec8336478dd39c7 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle: - en: 20e7310ab6867389 - zh: eed2957381abc0bd + en: ea02185c3ec03a41 + zh: 0e1e5d641fa1c8f6 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-a-bundle: - en: abc90531efa889d5 - zh: 0562570f85e69c35 + en: a5f9da50f4fb3b23 + zh: c2160c918574616a /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-one-row-of-a-bundle: en: 620c6a3ee20848bd zh: a9429f047a12bb4c @@ -30,8 +30,8 @@ en: c32019330fd9586d zh: dd22110abd10159c /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/detail-page-extension-points: - en: 87f6d96467dc2383 - zh: e0fb91aa4c4d5a12 + en: a5bd63206fa48e20 + zh: 335b8dac564554df /deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation: en: 8ca48e1365f18adf zh: 91eadd683d2d8b5b diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index c075e53cf0..ea4afd5203 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -37,6 +37,8 @@ The **Refresh** button shows its tooltip after 500 ms of hovering or keyboard fo ### Installing a bundle +The **Add plugin** primary button directly opens the existing installation dialog. Its separate arrow button, **Choose how to add a plugin**, opens a menu: **Install a third-party plugin** opens that same dialog with focus in its package-name field; plugin-contributed creation actions follow it. The two buttons have independent click targets and accessible names. Arrow keys move between menu rows, Escape closes back to the arrow button, and the menu stays within the viewport. While an installation is tracked, **View installation** opens that task directly. + On first use, when pnpm uses the unconfigured official npm registry and npmmirror is offered, the Host probes both registries in parallel. The first successful HTTPS ping response selects the initial source. The dialog preserves remembered choices, manual selections, explicit manager configuration, and custom or unknown pnpm registries. An install clicked during initial probing waits for that bounded operation; a late result cannot overwrite a manual choice or reopen a closed dialog. The Host sends GET requests to `https://registry.npmjs.org/-/ping` and `https://registry.npmmirror.com/-/ping` through its normal fetch proxy. A 2xx response wins; redirects and failures do not. The remaining request is cancelled, both response bodies are discarded, and cleanup completes before the result returns. The default deadline is 1500 ms and results, including unavailable results, are cached for five minutes. Configure `registryProbeTimeoutMs` and `registryProbeCacheTtlMs` on `ui-plugin-manager`; `registryProbeEnabled: false` disables probing. If both requests fail or time out, the existing default remains selected. No IP-location service or Session content is involved. @@ -49,7 +51,7 @@ When the Host attributes a network failure or timeout to a GitHub address and of ### Switching a bundle -A bundle's page shows its full package name under the title, the spec that installs it elsewhere. A bundle's switch changes its layer selection. A profile with HMR recomposes before the operation completes; one without HMR, and a bundle a higher layer overrides, say so in a toast. A bundle the Host cannot read carries a problem tag and its reason on its page and cannot be switched on; one that provides the management components stays locked. The Host answers with error codes, which the page's dictionary words; pnpm's and the Loader's own diagnostics are shown as they are. The page excludes built-in profile bundles from cards and counts even when the profile holds them as dependencies or the Host reports an error. The Host inventory remains complete; the Settings Plugins section's Plugin list tab inspects their plugins. +A bundle's page shows its readable row names and descriptions under **What this plugin provides**, with duplicate descriptions grouped once and unavailable metadata omitted. Its scope is all sessions in this deployment; each session's mode still determines its tools. **Components and runtime status** folds package names, row identifiers, switches, and diagnostics by default and opens when a package, metadata, or row failure is reported. Declared changes to existing rows appear as a count under **Changes to existing capabilities**, with their ids inside the folded section. A bundle's switch changes its layer selection. A profile with HMR recomposes before the operation completes; one without HMR, and a bundle a higher layer overrides, say so in a toast. A bundle the Host cannot read carries a problem tag and its reason on its page and cannot be switched on; one that provides the management components stays locked. The Host answers with error codes, which the page's dictionary words; pnpm's and the Loader's own diagnostics are shown as they are. The page excludes built-in profile bundles from cards and counts even when the profile holds them as dependencies or the Host reports an error. The Host inventory remains complete; the Settings Plugins section's Plugin list tab inspects their plugins. ### Switching one row of a bundle @@ -71,6 +73,8 @@ The bundle's patch must declare the row under that id, and the registration exis ### Detail page extension points +`plugins.add.actions` adds root-scoped `MenuItemButton` rows after installation in the Add plugin menu. Its owner supplies `onDismiss()`, which a contribution calls before starting its action. `plugins.bundle.usage` is keyed by package name and receives `{ pkg: PluginPackageRef }`; it renders usage guidance and entry actions after the bundle description and before component details. Both follow the page declaration lifetime and accept contributions without importing feature components. + A plugin with something to say about a bundle, a row, or an official plugin it does not own contributes to that object's page through three list slots the page declares: `plugins.detail.actions` for a control at the head of the page, before the page's own switch and uninstall; `plugins.detail.badge` for a tag beside the title, after the version, experimental, and problem tags; and `plugins.detail.section` for a section under the page's own content — after the rows on a bundle's page, after the configuration on a row's or an official plugin's page. Every entry is rendered with the page's `subject`: `{ kind: 'bundle', pkg }`, `{ kind: 'row', pkg, row }`, or `{ kind: 'item', id }`, where `pkg` and `row` carry the name, version, installed and enabled facts, and the row list a contribution decides on. An entry renders null for a subject it has nothing for and draws its own section chrome; the page orders entries by `order`. ```tsx ignore-check diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 0d8e0f5afd..1900e8909d 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -37,6 +37,8 @@ kind: "package-reference" ### 安装一个组合包 +**添加插件**主按钮直接打开原安装对话框。旁边独立的箭头按钮**选择添加插件方式**打开菜单:**安装第三方插件**打开同一个对话框并聚焦包名输入框,插件贡献的创造动作接在其后。两个按钮有独立的点击区域和无障碍名称。方向键在菜单条目间移动,Escape 关闭菜单并返回箭头按钮,菜单位置受视口边界约束。已有安装任务时,**查看安装任务**直接打开该任务。 + 首次使用时,如果未显式配置安装源、pnpm 使用官方 npm 源,且列表提供 npmmirror,Host 会并发探测这两个源,选择最先成功响应 HTTPS ping 的源。已记住的选择、用户手选、管理器显式配置,以及自定义或未知的 pnpm 源均保留。在初次探测期间点击安装会等待这次有时限的操作;迟到结果不会覆盖手选或重新打开已关闭的对话框。 Host 通过普通 fetch 代理向 `https://registry.npmjs.org/-/ping` 和 `https://registry.npmmirror.com/-/ping` 发送 GET 请求,以最先返回 2xx 的源为结果;重定向和失败不参与选择。随后取消另一条请求、丢弃两个响应体,并在清理完成后返回。默认时限为 1500 毫秒,包括不可用结果在内的探测结果均缓存五分钟。在 `ui-plugin-manager` 上配置 `registryProbeTimeoutMs` 和 `registryProbeCacheTtlMs`;`registryProbeEnabled: false` 关闭探测。两条请求都失败或超时时保留现有默认源。不使用 IP 地区服务,也不发送 Session 内容。 @@ -49,7 +51,7 @@ Host 将网络失败或超时归因于 GitHub 地址,且提供 npmmirror 时 ### 切换一个组合包 -组合包页面在标题下方显示完整包名,也就是在别处安装它所需的 spec。组合包开关改变其层选择。启用了 HMR 的 profile 在操作完成前重组;没有 HMR 的 profile,以及被更高层覆盖的组合包,会以 toast 说明。Host 读不了的组合包带异常标签,其页面给出原因,且不能打开;提供管理组件的组合包保持锁定。Host 以错误码作答,由页面字典措辞;pnpm 与 Loader 自己的诊断原样显示。页面从卡片与数量中排除内置 profile 组合包,即使 profile 将它们列为依赖或 Host 报告了异常。Host 清单仍保留完整数据;设置中「插件」分区的「插件列表」标签页负责查看它们的插件。 +组合包页面在**这个插件提供**中展示组件的人类可读名称和说明,相同内容只列一次,缺少元信息时不补造。作用范围是此部署的所有会话,每个会话可用的工具仍由它的模式决定。**组件与运行状态**默认折叠包名、行标识、开关和诊断,出现包错误、元信息错误或组件运行异常时展开。声明的现有组件调整以数量显示在**调整现有能力**中,具体行 id 位于折叠区。组合包开关改变其层选择。启用了 HMR 的 profile 在操作完成前重组;没有 HMR 的 profile,以及被更高层覆盖的组合包,会以 toast 说明。Host 读不了的组合包带异常标签,其页面给出原因,且不能打开;提供管理组件的组合包保持锁定。Host 以错误码作答,由页面字典措辞;pnpm 与 Loader 自己的诊断原样显示。页面从卡片与数量中排除内置 profile 组合包,即使 profile 将它们列为依赖或 Host 报告了异常。Host 清单仍保留完整数据;设置中「插件」分区的「插件列表」标签页负责查看它们的插件。 ### 切换组合包里的一行 @@ -71,6 +73,8 @@ ctx.slots.inject('plugins.row.config', () => ctx.slots.register({ ### 详情页扩展点 +`plugins.add.actions` 在添加插件菜单的安装项之后提供 root 作用域的 `MenuItemButton` 行。owner 提供 `onDismiss()`,贡献者在开始动作前调用它关闭菜单。`plugins.bundle.usage` 以包名为键,接收 `{ pkg: PluginPackageRef }`,在组合包说明之后、组件详情之前渲染使用指引和入口动作。两者随页面声明的生命周期存在,贡献者无需导入其他功能组件。 + 对某个不属于自己的组合包、行或官方插件有话要说的插件,通过本页声明的三个 list slot 向该对象的页面贡献内容:`plugins.detail.actions` 在页头放一个控件,位于页面自己的开关和卸载之前;`plugins.detail.badge` 在标题旁放一个标签,位于版本、实验性和异常标签之后;`plugins.detail.section` 在页面自身内容之下放一个区块——组合包页在组件列表之后,行页和官方插件页在配置之后。每个条目都以页面的 `subject` 渲染:`{ kind: 'bundle', pkg }`、`{ kind: 'row', pkg, row }` 或 `{ kind: 'item', id }`,其中 `pkg` 与 `row` 携带包名、版本、是否已安装、是否启用以及行列表这些供贡献者判断的事实。条目对无话可说的 subject 返回 null,自绘区块外观;页面按 `order` 排列条目。 ```tsx ignore-check diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index f2818ba19a..0479c5c899 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -66,11 +66,49 @@ .toolbar { display: flex; + flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 16px; } +.addMenu { + width: 320px; + max-width: calc(100vw - 48px); +} + +.addMenu [role='menuitem'] { + height: 72px; + box-sizing: border-box; + align-items: center; + gap: 10px; + padding: 6px 12px; +} + +/* Keep both action icons centred beside the complete text block. */ +.addMenu [role='menuitem'] > span:first-child:has(> svg) { + width: 16px; + height: 16px; +} + +.addMenu [role='menuitem'] > span:first-child:has(> svg) > svg { + width: 16px; + height: 16px; +} + +.addMenuItem { + display: flex; + flex-direction: column; + gap: 2px; + white-space: normal; +} + +.addMenuDescription { + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-label-secondary); +} + .status, .failure p, .empty { @@ -368,7 +406,9 @@ } /* The toolbar's Add plugin action, one step up from the 28px sm capsule. */ -.addButton { +.addButton, +.addPrimary, +.addMore { height: 32px; font-size: 13px; line-height: 20px; @@ -376,6 +416,25 @@ border-radius: var(--dsw-radius-md); } +.addGroup { + display: inline-flex; + flex: none; + align-items: stretch; +} + +.addPrimary { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.addMore { + width: 32px; + padding: 0; + border-left: 0.5px solid color-mix(in srgb, var(--dsw-alias-label-primary-foreground) 30%, transparent); + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + .iconWrap { display: inline-flex; } @@ -1233,6 +1292,89 @@ label.registryOption { gap: 12px; } +.scope { + display: flex; + align-items: center; + gap: 4px; + margin-top: 8px; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-label-secondary); +} + +.capabilities, +.overrideIds { + display: grid; + gap: 12px; + margin: 0; + padding: 0; + list-style: none; +} + +.capability { + display: flex; + flex-direction: column; + gap: 4px; + padding-left: 12px; + border-left: 0.5px solid var(--dsw-alias-border-l2); +} + +.capabilityTitle { + font-size: 13px; + line-height: 20px; + font-weight: 500; +} + +.capabilityDescription { + margin: 0; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-secondary); + overflow-wrap: anywhere; +} + +.componentsToggle { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + padding: 0; + border: 0; + background: none; + color: var(--dsw-alias-label-secondary); + font: inherit; + font-size: 13px; + line-height: 20px; + text-align: left; + cursor: pointer; +} + +.componentsToggle[aria-expanded='false'] .detailsChevron { + transform: rotate(-90deg); +} + +.componentsToggle:focus-visible { + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: 4px; +} + +.componentsBody { + display: flex; + flex-direction: column; + gap: 20px; +} + +.componentsBody[hidden] { + display: none; +} + +.overrideIds { + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-label-secondary); + overflow-wrap: anywhere; +} + .sectionHead { display: flex; align-items: baseline; diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 92d06906f0..6bf54f812c 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -15,8 +15,8 @@ import type { PluginInstallFailureKind, Registry } from '@deepseek-ai/dsh-api-re import { Button, IconCheckCircleFillRegular, IconChevronDownOutlineRegular, IconChevronLeftOutlineMedium, IconChevronRightOutlineRegular, IconCloseOutlineMedium, - IconInfoOutlineRegular, IconPlusOutlineRegular, IconRefreshOutlineRegular, IconTrashOutlineRegular, - IconWarningOutlineRegular, Input, Modal, pointerModality, + IconDownloadOutlineRegular, IconInfoOutlineRegular, IconPlusOutlineRegular, IconRefreshOutlineRegular, IconTrashOutlineRegular, + IconWarningOutlineRegular, Input, Menu, MenuItemButton, Modal, pointerModality, PluginArtworkDefault, PluginArtworkLoop, PluginArtworkSearch, PluginArtworkSubagent, PluginArtworkTerminal, StateDot, Switch, Tag, TerminalBlock, Toast, Tooltip, useAnchoredPosition, useDismissOnOutsidePointer, type IconProps, type StateDotState, type TerminalBlockLabels, @@ -41,6 +41,7 @@ export type PluginManagerPageProps = & PropsLocale<'pluginManager'> & PropsRenderSlots< | 'plugins.item' | 'plugins.bundle.config' | 'plugins.row.config' | 'plugins.bundle.activation' + | 'plugins.add.actions' | 'plugins.bundle.usage' | 'plugins.detail.actions' | 'plugins.detail.badge' | 'plugins.detail.section' > & InjectFace @@ -52,6 +53,44 @@ type ResolveText = PluginManagerFace['resolveText'] type RowPhase = NonNullable +/** The primary action installs; the adjacent menu offers every add-plugin path. */ +function AddPluginMenu({ t, disabled, openInstall, renderSlot }: { + readonly t: Translate + readonly disabled: boolean + readonly openInstall: () => void + readonly renderSlot: RenderConfig +}): ReactNode { + const [open, setOpen] = useState(false) + const onDismiss = (): void => { setOpen(false) } + return ( + + + { setOpen(value => !value) }} + onKeyDown={(event) => { + if (!open && event.key === 'ArrowDown') { event.preventDefault(); setOpen(true) } + }}> + + + ) +} + /** How long the list marks a package an install just enabled. */ const HIGHLIGHT_MS = 2_400 @@ -207,7 +246,6 @@ function RowsSection({ rows, t, resolveText, toggle, configure }: {

{t('partsLabel')}

- {rows.length === 0 ? null : {partsSummary(rows, t)}}
{rows.length === 0 ?

{t('partsEmpty')}

: null} {rows.length > ROW_FILTER_THRESHOLD @@ -532,13 +570,44 @@ function RowDetail({ pkg, row, t, resolveText, onBack, renderSlot, form }: { ) } +/** Human-readable row metadata, without technical identifiers or runtime controls. */ +function CapabilitiesSummary({ rows, resolveText, t }: { + readonly rows: readonly PackageRow[] + readonly resolveText: ResolveText + readonly t: Translate +}): ReactNode { + const seen = new Set() + const capabilities = rows.flatMap((row) => { + const metadataTitle = row.meta?.title === undefined ? undefined : resolveText(row.meta.title).trim() || undefined + const title = metadataTitle === row.moduleName || metadataTitle === row.rowId ? undefined : metadataTitle + const description = row.meta?.description === undefined ? undefined : resolveText(row.meta.description).trim() || undefined + if (title === undefined && description === undefined) return [] + const identity = JSON.stringify([title, description]) + if (seen.has(identity)) return [] + seen.add(identity) + return [{ rowId: row.rowId, title, description }] + }) + if (capabilities.length === 0) return null + return ( +
+

{t('capabilitiesTitle')}

+
    + {capabilities.map(({ rowId, title, description }) => ( +
  • + {title === undefined ? null : {title}} + {description === undefined || description === title ? null : {description}} +
  • + ))} +
+
+ ) +} + /** * One package's page: the crumb back to the list; its icon with its switch * and, for a package the profile installed, uninstall; its title beside its - * version tag, its beta tag, and its problem tag; the package name the title - * stands for, which is what installs it elsewhere; its one-liner; the Host's - * problem when it reports one; the configuration the bundle registered for - * itself; and its rows with their switches and configure controls. + * version, beta, and problem tags; usage guidance, readable capabilities, + * configuration, and component diagnostics folded until requested or failed. */ function PackageDetail({ pkg, t, resolveText, busy, rowBusy, configured, configure, renderSlot, @@ -562,6 +631,11 @@ function PackageDetail({ const { title, description, beta } = packageText(pkg, resolveText) const status = packageStatus(pkg) const subject: PluginsSubject = { kind: 'bundle', pkg: packageRef(pkg) } + const hasProblem = pkg.error !== undefined || pkg.meta?.error !== undefined + || pkg.rows.some(row => row.phase === 'failed' || row.meta?.error !== undefined) + const [componentsOpen, setComponentsOpen] = useState(hasProblem) + const componentsId = useId() + useEffect(() => { if (hasProblem) setComponentsOpen(true) }, [hasProblem]) return (
{t('statusProblem')} : null} {renderSlot('plugins.detail.badge', { subject })}
-

{pkg.name}

{description === undefined ? null :

{description}

} +
+ {t('deploymentScope')} + + + +
{pkg.error === undefined ? null :

{t('reasonLabel')}: {managementText(pkg.error, t)}

} {pkg.readOnlyReason === undefined ? null :

{managementText({ code: pkg.readOnlyReason }, t)}

}
+ {renderSlot('plugins.bundle.usage', { pkg: subject.pkg }, { entryKey: pkg.name })} + + {pkg.overrides.length === 0 ? null : ( +
+

{t('overridesTitle')}

+

{t('overridesDescription', { count: String(pkg.overrides.length) })}

+
+ )} {configured ? (
@@ -613,13 +702,32 @@ function PackageDetail({
) : null} - busy || rowBusy(row), onSetEnabled: onSetRowEnabled } : undefined} - configure={configure} - /> +
+ + +
{renderSlot('plugins.detail.section', { subject })}
@@ -906,6 +1014,7 @@ function InstallDialog({ - + {state.install.requestId === undefined + ? + : } ) @@ -1400,6 +1511,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { {loaded && openPkg !== undefined && openRow === undefined ? ( face, children: { + 'plugins.add.actions': { kind: 'list', scope: 'root' }, 'plugins.item': { kind: 'list', scope: 'root' }, + 'plugins.bundle.usage': { kind: 'keyed', scope: 'root' }, 'plugins.bundle.activation': { kind: 'keyed', scope: 'root' }, 'plugins.bundle.config': { kind: 'keyed', scope: 'root' }, 'plugins.row.config': { kind: 'keyed', scope: 'root' }, diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts index 71678ea52b..43f8322c09 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -20,6 +20,9 @@ export const zh = { refreshError: '刷新失败,请重试', empty: '还没有安装任何插件。', addPlugin: '添加插件', + chooseAddMethod: '选择添加插件方式', + installExisting: '安装第三方插件', + installExistingDescription: '支持 npm 包、Git 仓库和本地目录', restartNotice: '更改将在下次启动生效', overriddenNotice: '{name} 已保存,但被更高优先级的配置覆盖,当前未生效', bundlesTitle: '已安装', @@ -29,6 +32,13 @@ export const zh = { reasonLabel: '原因', metadataError: '包元信息错误:{error}', versionTag: 'v{version}', + capabilitiesTitle: '这个插件提供', + deploymentScope: '作用范围:当前 DSH', + deploymentScopeDescription: '启用会为当前 DSH 添加能力;每个会话可用的工具仍由它的模式决定', + componentsDisclosure: '组件与运行状态', + overridesTitle: '调整现有能力', + overridesDescription: '此插件包含对 {count} 个现有组件的配置或启用状态调整', + overridesComponents: '调整的组件', partsLabel: '包含的组件', partsEmpty: '这个插件包不包含任何组件。', partsCountTotal: '共 {count} 个', @@ -217,6 +227,9 @@ export const en = { refreshError: 'Refresh failed. Please try again.', empty: 'No plugins are installed yet.', addPlugin: 'Add plugin', + chooseAddMethod: 'Choose how to add a plugin', + installExisting: 'Install a third-party plugin', + installExistingDescription: 'Supports npm packages, Git repositories, and local directories', restartNotice: 'The change takes effect at the next start', overriddenNotice: '{name} was saved, but a higher-priority configuration overrides it, so it is not in effect', bundlesTitle: 'Installed', @@ -226,6 +239,13 @@ export const en = { reasonLabel: 'Reason', metadataError: 'Package metadata error: {error}', versionTag: 'v{version}', + capabilitiesTitle: 'What this plugin provides', + deploymentScope: 'Scope: this DSH', + deploymentScopeDescription: 'Enabling adds capabilities to this DSH; each session’s mode still determines its available tools.', + componentsDisclosure: 'Components and runtime status', + overridesTitle: 'Changes to existing capabilities', + overridesDescription: 'This plugin includes configuration or enablement changes for {count} existing components.', + overridesComponents: 'Components changed', partsLabel: 'Components', partsEmpty: 'This plugin pack contains no components.', partsCountTotal: '{count} total', diff --git a/packages/client/ui-plugin-manager/src/client/manager-store.ts b/packages/client/ui-plugin-manager/src/client/manager-store.ts index 2f319d01ea..d946179e76 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -94,6 +94,8 @@ export interface PackageView { /** Why the Host cannot read the bundle, when it cannot. */ readonly error?: ManagementError readonly rows: readonly PackageRow[] + /** Existing row ids whose configuration or enablement the bundle's patch changes. */ + readonly overrides: readonly string[] } /** The typed spec as the Host read it, on the installing, installed, and failed screens. */ @@ -423,6 +425,7 @@ export function packageView(bundle: BundleInfo, plugins: readonly PluginInfo[]): optional: bundle.optional, enabled: bundle.enabled, rows, + overrides: bundle.overrides, ...bundle.version === undefined ? {} : { version: bundle.version }, ...bundle.description === undefined ? {} : { description: bundle.description }, ...bundle.meta === undefined ? {} : { meta: bundle.meta }, diff --git a/packages/client/ui-plugin-manager/src/client/slot-contract.ts b/packages/client/ui-plugin-manager/src/client/slot-contract.ts index 4c636813cb..2d3c6387f9 100644 --- a/packages/client/ui-plugin-manager/src/client/slot-contract.ts +++ b/packages/client/ui-plugin-manager/src/client/slot-contract.ts @@ -72,8 +72,24 @@ export interface PluginActivationOwnerProps { readonly onOpenDetails: () => void } +/** Add-plugin menu actions, independent of any selected bundle or Session. */ +export interface PluginAddActionsProps { + /** Close the add-plugin menu before starting the contributed action. */ + readonly onDismiss: () => void +} + +/** Bundle facts supplied to its usage guidance and entry actions. */ +export interface PluginBundleUsageProps { + /** The bundle whose detail page is open. */ + readonly pkg: PluginPackageRef +} + declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { + /** Additional MenuItemButton rows after installation in the add-plugin menu. */ + 'plugins.add.actions': { kind: 'list'; scope: 'root'; owner: PluginAddActionsProps } + /** Bundle-owned usage guidance below the description, keyed by npm package name. */ + 'plugins.bundle.usage': { kind: 'keyed'; scope: 'root'; owner: PluginBundleUsageProps } /** Optional guidance after the user enables a bundle from the list, keyed by npm package name. */ 'plugins.bundle.activation': { kind: 'keyed'; scope: 'root'; owner: PluginActivationOwnerProps } /** diff --git a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx index 155a15c69b..dcacad0820 100644 --- a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx @@ -62,6 +62,30 @@ function declare(slots: SlotRegistry): () => void { } describe('ui-plugin-manager browser plugin', () => { + it('reattaches add-plugin menu actions and bundle usage across page declaration lifetimes', async () => { + const b = await bench() + b.slots.inject('plugins.add.actions', () => b.slots.register({ + name: 'plugins.add.actions', id: 'create-plugin', + }, () => null)) + b.slots.inject('plugins.bundle.usage', () => b.slots.register({ + name: 'plugins.bundle.usage', key: '@acme/notes', + }, () => null)) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + const removeRoot = declare(b.slots) + expect(b.slots.entries('plugins.add.actions')).toHaveLength(1) + expect(b.slots.entries('plugins.bundle.usage')).toHaveLength(1) + removeRoot() + expect(b.slots.entries('plugins.add.actions')).toHaveLength(0) + expect(b.slots.entries('plugins.bundle.usage')).toHaveLength(0) + declare(b.slots) + expect(b.slots.entries('plugins.add.actions')).toHaveLength(1) + expect(b.slots.entries('plugins.bundle.usage')).toHaveLength(1) + await fiber.dispose() + expect(b.slots.entries('plugins.add.actions')).toHaveLength(0) + expect(b.slots.entries('plugins.bundle.usage')).toHaveLength(0) + }) + it('resets bundle selection when leaving Plugins and releases its panel observer with the registration', async () => { const b = await bench() const removeRoot = declare(b.slots) @@ -148,6 +172,8 @@ describe('ui-plugin-manager browser plugin', () => { expect(resolveSlotLabel(icon.options.label)).toBe('插件') // The page declares the slots a plugin's configuration arrives through, and binds their projection beside its state. expect(b.slots.spec('plugins.item')).toMatchObject({ kind: 'list', scope: 'root' }) + expect(b.slots.spec('plugins.add.actions')).toMatchObject({ kind: 'list', scope: 'root' }) + expect(b.slots.spec('plugins.bundle.usage')).toMatchObject({ kind: 'keyed', scope: 'root' }) expect(b.slots.spec('plugins.bundle.config')).toMatchObject({ kind: 'keyed', scope: 'root' }) expect(b.slots.spec('plugins.row.config')).toMatchObject({ kind: 'keyed', scope: 'root' }) for (const name of ['plugins.detail.actions', 'plugins.detail.badge', 'plugins.detail.section'] as const) { diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index 63fa70a35a..dc977431cc 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -8,6 +8,7 @@ import type { PluginEntryId, PluginInstallRequestId } from '@deepseek-ai/dsh-api import { bindSnapshotSelector, stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' import type { ConfigForm, ConfigFormSnapshot, SettingsMirrorSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { MenuItemButton } from '@deepseek-ai/dsh-client-ui-primitives' import { StrictMode, type ReactNode } from 'react' import { createNavigationStore } from '../src/client/navigation-store.ts' import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx' @@ -16,7 +17,7 @@ import type { PluginManagerPageProps } from '../src/client/index.ts' import type { ConfigLedger } from '../src/client/config-ledger.ts' import { rowKey, type InstallState, type PackageRow, type PackageView, type PluginManagerState } from '../src/client/manager-store.ts' import { en, zh, type PluginManagerLocaleKey } from '../src/client/locales.ts' -import type { PluginActivationOwnerProps, PluginDetailProps, PluginsSubject } from '../src/client/slot-contract.ts' +import type { PluginActivationOwnerProps, PluginAddActionsProps, PluginBundleUsageProps, PluginDetailProps, PluginsSubject } from '../src/client/slot-contract.ts' afterEach(cleanup) @@ -36,6 +37,7 @@ function pkg(overrides: Partial = {}): PackageView { optional: false, enabled: true, rows: [], + overrides: [], ...overrides, } } @@ -81,7 +83,7 @@ const READY: PluginManagerState = { * (a list slot's cell is empty), the view asked for — `detail` for a detail * contribution — and the owner props. */ -type SlotBodies = Record ReactNode> +type SlotBodies = Record ReactNode> /** The subject a detail contribution was rendered with. */ function subjectOf(owner: unknown): PluginsSubject | undefined { @@ -158,6 +160,8 @@ function renderTab( const body = bodies[`${name}:${opts?.only ?? opts?.entryKey ?? ''}`] if (body === undefined) return null if (name === 'plugins.bundle.activation') return body('activation', owner) + if (name === 'plugins.bundle.usage') return body('usage', owner) + if (name === 'plugins.add.actions') return body('add', owner) if (name.startsWith('plugins.detail.')) return body('detail', owner) if (!('view' in owner) || (owner.view !== 'summary' && owner.view !== 'page')) { throw new Error('Plugin configuration fixture requires a summary or page view') @@ -190,7 +194,156 @@ function renderTab( } } +/** Open the technical controls before exercising component-specific behavior. */ +function expandComponents(dict: typeof en = en): void { + const toggle = screen.getByRole('button', { name: new RegExp(dict.componentsDisclosure) }) + if (toggle.getAttribute('aria-expanded') === 'false') fireEvent.click(toggle) +} + describe('PluginManagerPage', () => { + it('keeps contributed creation inside Add plugin and usage before folded implementation details', () => { + const uses: PluginBundleUsageProps[] = [] + const createPlugin = vi.fn() + const { actions } = renderTab({ packages: [pkg({ meta: { description: 'Browse your project notes.' }, rows: [row({ + meta: { title: 'Project notes', description: 'Read Markdown notes in this workspace.' }, + })] })] }, {}, { + 'plugins.add.actions:': (_view, owner) => { + (owner as PluginAddActionsProps).onDismiss() + createPlugin() + }}>Create a plugin, + 'plugins.bundle.usage:dsh-better-sidebar': (_view, owner) => { + uses.push(owner as PluginBundleUsageProps) + return + }, + }) + expect(screen.queryByRole('menuitem', { name: 'Create a plugin' })).toBeNull() + const add = screen.getByRole('button', { name: en.chooseAddMethod }) + add.focus() + fireEvent.keyDown(add, { key: 'ArrowDown' }) + const install = screen.getByRole('menuitem', { name: new RegExp(en.installExisting) }) + const create = screen.getByRole('menuitem', { name: 'Create a plugin' }) + expect(screen.getAllByRole('menuitem')).toEqual([install, create]) + expect(document.activeElement).toBe(install) + fireEvent.keyDown(install, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(create) + fireEvent.keyDown(create, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + expect(document.activeElement).toBe(add) + fireEvent.click(add) + fireEvent.click(screen.getByRole('menuitem', { name: 'Create a plugin' })) + expect(createPlugin).toHaveBeenCalledOnce() + expect(actions.openInstall).not.toHaveBeenCalled() + expect(screen.queryByRole('menu')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) + expect(screen.queryByRole('button', { name: en.addPlugin })).toBeNull() + const usage = screen.getByRole('button', { name: 'Open project notes' }) + expect(uses.at(-1)?.pkg).toMatchObject({ name: 'dsh-better-sidebar', enabled: true }) + expect(screen.getByText(en.deploymentScope)).toBeTruthy() + expect(screen.getByRole('heading', { name: en.capabilitiesTitle })).toBeTruthy() + expect(screen.getByText('Project notes')).toBeTruthy() + expect(screen.getByText('Read Markdown notes in this workspace.')).toBeTruthy() + expect(document.querySelector('[data-plugin-name]')).toBeNull() + expect(document.querySelector('[data-plugin-row]')).toBeNull() + const toggle = screen.getByRole('button', { name: new RegExp(en.componentsDisclosure) }) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(usage.compareDocumentPosition(toggle) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect(document.querySelector('[data-plugin-name]')?.textContent).toBe('dsh-better-sidebar') + expect(screen.getByRole('switch', { name: en.partToggle.replace('{name}', 'Project notes') })).toBeTruthy() + }) + + it('opens the existing install dialog from the menu and keeps focus on its input', async () => { + const b = renderTab() + b.actions.openInstall.mockImplementation(() => { + b.set({ install: { ...IDLE_INSTALL, open: true } }) + }) + const add = screen.getByRole('button', { name: en.chooseAddMethod }) + add.focus() + fireEvent.click(add) + expect(b.actions.openInstall).not.toHaveBeenCalled() + await act(async () => { + fireEvent.click(screen.getByRole('menuitem', { name: new RegExp(en.installExisting) })) + }) + expect(b.actions.openInstall).toHaveBeenCalledOnce() + expect(screen.queryByRole('menu')).toBeNull() + expect(document.activeElement).toBe(screen.getByRole('textbox', { name: en.installSpecLabel })) + }) + + it('keeps primary installation and mouse-selected creation on separate buttons', () => { + const createPlugin = vi.fn() + const { actions } = renderTab({}, {}, { + 'plugins.add.actions:': (_view, owner) => { + (owner as PluginAddActionsProps).onDismiss() + createPlugin() + }}>Create a pluginDescribe it to the agent, + }) + const add = screen.getByRole('button', { name: en.addPlugin }) + const more = screen.getByRole('button', { name: en.chooseAddMethod }) + expect(add.getAttribute('aria-haspopup')).toBeNull() + expect(more.getAttribute('aria-haspopup')).toBe('menu') + fireEvent.pointerDown(add, { pointerType: 'mouse', button: 0 }) + fireEvent.pointerUp(add, { pointerType: 'mouse', button: 0 }) + fireEvent.click(add) + expect(actions.openInstall).toHaveBeenCalledOnce() + expect(screen.queryByRole('menu')).toBeNull() + actions.openInstall.mockClear() + for (const label of ['Create a plugin', 'Describe it to the agent']) { + fireEvent.pointerDown(more, { pointerType: 'mouse', button: 0 }) + fireEvent.pointerUp(more, { pointerType: 'mouse', button: 0 }) + fireEvent.click(more) + expect(more.getAttribute('aria-expanded')).toBe('true') + expect(actions.openInstall).not.toHaveBeenCalled() + const target = screen.getByText(label) + fireEvent.pointerDown(target, { pointerType: 'mouse', button: 0 }) + fireEvent.pointerUp(target, { pointerType: 'mouse', button: 0 }) + fireEvent.click(target) + expect(screen.queryByRole('menu')).toBeNull() + expect(more.getAttribute('aria-expanded')).toBe('false') + expect(actions.openInstall).not.toHaveBeenCalled() + } + expect(createPlugin).toHaveBeenCalledTimes(2) + fireEvent.click(more) + const install = screen.getByText(en.installExisting) + fireEvent.pointerDown(install, { pointerType: 'mouse', button: 0 }) + fireEvent.pointerUp(install, { pointerType: 'mouse', button: 0 }) + fireEvent.click(install) + expect(actions.openInstall).toHaveBeenCalledOnce() + expect(createPlugin).toHaveBeenCalledTimes(2) + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('deduplicates readable capabilities and reports declared overrides without exposing ids until expanded', () => { + const { setLanguage } = renderTab({ packages: [pkg({ + rows: [row({ meta: { title: { en: 'Notes', zh: '笔记' }, description: { en: 'Browse notes.', zh: '浏览笔记' } } }), + row({ rowId: 'duplicate', meta: { title: { en: 'Notes', zh: '笔记' }, description: { en: 'Browse notes.', zh: '浏览笔记' } } }), + row({ rowId: 'technical' })], + overrides: ['system-prompt', 'tools'], + })] }) + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) + const capabilities = document.querySelector('[data-plugin-capabilities]') as HTMLElement + expect(within(capabilities).getAllByRole('listitem')).toHaveLength(1) + expect(screen.getByText(en.overridesDescription.replace('{count}', '2'))).toBeTruthy() + expect(screen.queryByText('system-prompt')).toBeNull() + setLanguage(zh) + expect(within(capabilities).getByText('笔记')).toBeTruthy() + expect(within(capabilities).getByText('浏览笔记')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: new RegExp(zh.componentsDisclosure) })) + expect(screen.getByText('system-prompt')).toBeTruthy() + expect(screen.getByText('tools')).toBeTruthy() + }) + + it('opens component diagnostics when a running row fails and omits absent capability metadata', () => { + const { set } = renderTab({ packages: [pkg({ rows: [row()] })] }) + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) + expect(document.querySelector('[data-plugin-capabilities]')).toBeNull() + expect(document.querySelector('[data-plugin-overrides]')).toBeNull() + expect(document.querySelector('[data-plugin-row]')).toBeNull() + set({ packages: [pkg({ rows: [row({ phase: 'failed' })] })] }) + expect(screen.getByRole('button', { name: new RegExp(en.componentsDisclosure) }).getAttribute('aria-expanded')).toBe('true') + expect(document.querySelector('[data-plugin-row]')?.getAttribute('data-state')).toBe('failed') + }) + it('opens the requested bundle after its inventory arrives and falls back when it is absent', () => { const b = renderTab({ status: 'loading' }) act(() => { b.navigation.actions.setView({ kind: 'package', name: 'dsh-better-sidebar' }) }) @@ -221,6 +374,7 @@ describe('PluginManagerPage', () => { expect(loading.querySelector('button, input, [data-state="ongoing"]')).toBeNull() expect(document.querySelector('[data-plugin-panel]')?.getAttribute('aria-busy')).toBe('true') expect(screen.getByRole('button', { name: en.addPlugin })).toHaveProperty('disabled', true) + expect(screen.getByRole('button', { name: en.chooseAddMethod })).toHaveProperty('disabled', true) set({ status: 'unavailable' }) expect(screen.queryByRole('status', { name: en.loading })).toBeNull() expect(screen.getByRole('status').querySelector('[data-state="idle"]')).not.toBeNull() @@ -487,6 +641,7 @@ describe('PluginManagerPage', () => { fireEvent.click(screen.getByRole('switch', { name: zh.enableToggle.replace('{name}', title(zh)) })) expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith(name, false) fireEvent.click(screen.getByRole('button', { name: zh.openDetail.replace('{name}', title(zh)) })) + expandComponents(zh) for (const dict of [zh, en]) { setLanguage(dict) expect(screen.getByRole('heading', { level: 3 }).textContent).toBe(title(dict)) @@ -521,6 +676,7 @@ describe('PluginManagerPage', () => { set({ packages: [bundle] }) expect(image().getAttribute('src')).toBe(icon) fireEvent.click(screen.getByRole('button', { name: 'View dsh-better-sidebar' })) + expandComponents() expect(image().getAttribute('src')).toBe(icon) const rowImage = document.querySelector('[data-plugin-row] img')! expect(rowImage.getAttribute('src')).toBe(icon) @@ -548,6 +704,7 @@ describe('PluginManagerPage', () => { fireEvent.click(enable) expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith('dsh-better-sidebar', true) fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) + expandComponents(en) expect(screen.getByText(en.metadataError.replace('{error}', error))).toBeTruthy() const uninstall = screen.getByRole('button', { name: en.uninstallLabel.replace('{name}', 'dsh-better-sidebar') }) expect(uninstall).toHaveProperty('disabled', false) @@ -627,6 +784,7 @@ describe('PluginManagerPage', () => { { 'plugins.row.config:dsh-better-sidebar#theme': view => view === 'page' ?
: null }, ) fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'Bundle title') })) + expandComponents(en) expect(screen.getByRole('switch', { name: en.partToggle.replace('{name}', '@acme/dsh-sidebar/navigation') })).toBeTruthy() expect(screen.getByRole('switch', { name: en.partToggle.replace('{name}', '@acme/dsh-theme') })).toBeTruthy() expect(screen.queryByText('中文主题说明。')).toBeNull() @@ -637,7 +795,7 @@ describe('PluginManagerPage', () => { expect(within(navigation).queryByText('Bundle description.')).toBeNull() fireEvent.click(within(navigation).getByRole('switch', { name: zh.partToggle.replace('{name}', '@acme/dsh-sidebar/navigation') })) expect(actions.setRowEnabled).toHaveBeenCalledExactlyOnceWith('include:sidebar', false) - expect(screen.getByText('中文主题说明。')).toBeTruthy() + expect(within(document.querySelector('[data-plugin-rows]') as HTMLElement).getByText('中文主题说明。')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: zh.configureRow.replace('{name}', '主题插件') })) expect(document.querySelector('[data-plugin-row-detail]')?.getAttribute('data-plugin-row-detail')).toBe('dsh-better-sidebar#theme') expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('主题插件') @@ -658,6 +816,7 @@ describe('PluginManagerPage', () => { { 'plugins.row.config:dsh-better-sidebar#sidebar': view => view === 'page' ? : null }, ) fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) + expandComponents(en) const listed = document.querySelector('[data-plugin-row="include:sidebar"]') as HTMLElement expect(within(listed).getByText('Sidebar component')).toBeTruthy() expect(within(listed).getByText('sidebar', { selector: 'code' })).toBeTruthy() @@ -706,7 +865,7 @@ describe('PluginManagerPage', () => { } expect(screen.getByText('sidebar')).toBeTruthy() expect(screen.getByText('@acme/dsh-sidebar-widget')).toBeTruthy() - expect(screen.getByText('侧边导航')).toBeTruthy() + expect(within(document.querySelector('[data-plugin-rows]') as HTMLElement).getByText('侧边导航')).toBeTruthy() expect(screen.getByText(zh.metadataError.replace('{error}', error))).toBeTruthy() const toggle = screen.getByRole('switch', { name: zh.partToggle.replace('{name}', '导航组件') }) expect(toggle).toHaveProperty('disabled', false) @@ -788,6 +947,7 @@ describe('PluginManagerPage', () => { const theme = row({ rowId: 'theme', moduleName: 'dsh-better-sidebar/theme', entryId: 'include:theme' as PluginEntryId }) renderTab({ packages: [pkg({ rows: [row(), theme] })] }, { rows: new Set(['dsh-better-sidebar#sidebar']) }, bodies) fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) + expandComponents(en) expect(screen.queryByRole('button', { name: en.configureRow.replace('{name}', 'dsh-better-sidebar/theme') })).toBeNull() fireEvent.click(screen.getByRole('button', { name: en.configureRow.replace('{name}', 'dsh-better-sidebar') })) const page = document.querySelector('[data-plugin-row-detail="dsh-better-sidebar#sidebar"]') as HTMLElement @@ -830,6 +990,7 @@ describe('PluginManagerPage', () => { expect(screen.queryByRole('button', { name: /^act / })).toBeNull() fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) + expandComponents(en) const detail = document.querySelector('[data-plugin-detail]') as HTMLElement const act = within(detail).getByRole('button', { name: 'act bundle dsh-better-sidebar' }) expect(within(detail).getByText('badge bundle dsh-better-sidebar')).toBeTruthy() @@ -863,7 +1024,7 @@ describe('PluginManagerPage', () => { }) it('leaves the version out of a bundle the Host reports none for', () => { - const unversioned: PackageView = { name: 'dsh-better-sidebar', installed: true, optional: false, enabled: true, rows: [] } + const unversioned: PackageView = { name: 'dsh-better-sidebar', installed: true, optional: false, enabled: true, rows: [], overrides: [] } renderTab({ packages: [unversioned] }, {}, bodies) fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) expect(subjects.at(-1)).toEqual({ kind: 'bundle', pkg: { name: 'dsh-better-sidebar', installed: true, enabled: true, rows: [] } }) @@ -875,6 +1036,7 @@ describe('PluginManagerPage', () => { const { setLanguage } = renderTab({ packages: [pkg({ name, meta: { description: { en: 'Third-party description.' } } })] }) setLanguage(zh) fireEvent.click(screen.getByRole('button', { name: zh.openDetail.replace('{name}', name) })) + expandComponents(zh) expect(screen.getByRole('heading', { level: 3 }).textContent).toBe(name) expect(screen.getByText('Third-party description.')).toBeTruthy() expect(document.querySelector('[data-plugin-name]')?.textContent).toBe(name) @@ -933,6 +1095,7 @@ describe('PluginManagerPage', () => { })], }) fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) + expandComponents(en) const detail = document.querySelector('[data-plugin-detail="dsh-better-sidebar"]') as HTMLElement expect(within(detail).getByRole('heading', { level: 3 }).textContent).toBe('dsh-better-sidebar') // The version sits beside the name as a tag; the crumb only leads back. @@ -1784,6 +1947,8 @@ describe('PluginManagerPage', () => { ...IDLE_INSTALL, open: true, phase: 'unconfirmed', requestId: 'pending-install' as PluginInstallRequestId, failure: { reason: 'offline', uncertainty: 'cancellation' }, } }) + expect(screen.queryByRole('button', { name: en.chooseAddMethod })).toBeNull() + expect(screen.queryByRole('menu')).toBeNull() fireEvent.click(screen.getByRole('button', { name: en.installViewTask })) expect(actions.openInstall).toHaveBeenCalledOnce() expect(screen.getByRole('status').textContent).toBe(en.installUnconfirmedTitle) diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts index 895a4feccd..9dc32dd5cb 100644 --- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts @@ -113,10 +113,15 @@ it('hands a custom page the shared configuration form of its entry', () => { }) describe('packageView', () => { + it('retains the Host-reported rows adjusted by the bundle', () => { + expect(packageView({ ...BUNDLE, overrides: ['system-prompt', 'tools'] }, PLUGINS).overrides) + .toEqual(['system-prompt', 'tools']) + }) it('joins a bundle with the entries its rows run as', () => { expect(packageView(BUNDLE, PLUGINS)).toEqual({ name: 'dsh-better-sidebar', version: '0.16.0', description: 'A sidebar.', installed: true, optional: false, enabled: false, + overrides: [], rows: [ { rowId: 'sidebar', moduleName: 'dsh-better-sidebar', entryId: ROW_ENTRY, enabled: true, phase: 'active' }, { rowId: 'theme', moduleName: 'dsh-better-sidebar/theme', enabled: false, phase: null }, @@ -132,6 +137,7 @@ describe('packageView', () => { expect(packageView(protectedBundle, PLUGINS)).toEqual({ name: '@deepseek-ai/dsh-base', installed: false, optional: false, enabled: true, readOnlyReason: 'management-required', error: { code: 'operation-error', diagnostic: 'broken' }, + overrides: [], rows: [ { rowId: 'core', moduleName: '@deepseek-ai/dsh-base', entryId: 'include:core', enabled: true, phase: 'active', readOnlyReason: 'management-required' }, { rowId: 'gone', moduleName: 'x', entryId: 'include:gone', enabled: false, phase: null }, diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index 8866d6e127..162afe69f9 100644 --- a/packages/client/ui-schedule/README.i18n.yaml +++ b/packages/client/ui-schedule/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 405b30feaf48c087 /deepseek-ai-dsh-client-ui-schedule/use-this-package: - en: c129ae5d0a150a5d - zh: d81976f2840cb059 + en: b318072ec2191a71 + zh: adf9a59896141732 /deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source: en: 6bd6882dbbbefcb0 zh: 03aa15f1a65d13dc diff --git a/packages/client/ui-schedule/README.md b/packages/client/ui-schedule/README.md index ed9431a6b5..9fda3f37cf 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -25,6 +25,8 @@ Manage active and inactive tasks from the Automation tasks page and the right Si ## Use this package +The enabled Schedule bundle's detail page offers a usage card with its capability, sidebar location, and Open automation tasks action. Explicit enablement also opens dismissible guidance. Both reuse the Host catalog to confirm availability before enabling navigation, show read failures with Retry, and disappear when this plugin unloads. Opening the task page creates no Session or task. + The shipped Web composition carries no `ui-schedule` row; enable the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` from the Plugins page (Official group), or list it in a profile's `dsh.profile.bundles`, to insert and mount `ui-schedule` with the Host Schedule service and the clock context that supplies the model's current time. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Its task list supports text search and one All, Enabled, and Inactive status filter row; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state keeps the heading's New action beside the notice. Selecting an active or inactive task opens a second detail level with Rules and Delivery records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation. Selecting a task from the list opens Rules; refreshing that task preserves the selected tab. Rules edits an active task's name, instruction, and run time; the edits stay in a local draft until saved, and inactive tasks remain read-only. Unmodified Left/Right arrows cycle the tabs, Home/End select the first/last tab, and keyboard focus follows the selection. The tabs do not consume these keys with Alt, Ctrl, Meta, or Shift. The linked-Session control in the detail tab strip stays available on the Rules tab, and confirmed deletion is available from either view. diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index d03aefa658..dab4ae922b 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -25,6 +25,8 @@ kind: "package-reference" ## 使用本包 +已启用的 Schedule 组合包在详情页提供使用卡片,展示能力、侧栏入口和「打开自动化任务」操作;显式启用后还会显示可关闭的引导。两处均复用 Host 任务目录查询,在确认可用后开放跳转,读取失败时显示重试,并在本插件卸载后移除。打开任务页不会创建 Session 或任务。 + 随发行版交付的 Web 组合不含 `ui-schedule` 行;在插件管理页的“官方”分组中启用可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle`,或把它列进 profile 的 `dsh.profile.bundles`,即可插入 `ui-schedule` 并将它与 Host Schedule 服务、以及为模型提供当前时间的时钟上下文一起挂载。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示旁保留页头的“新建”操作。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。 从列表选择任务时默认打开“规则”;刷新同一任务会保留当前页签。活动任务的“规则”修改名称、指令和运行时间;这些修改保留在本地草稿中直到保存,未运行任务保持只读。不带修饰键的左右方向键循环切换页签,Home/End 选择第一个/最后一个页签,键盘焦点随选择移动。页签不会拦截这些键与 Alt、Ctrl、Meta 或 Shift 的组合。共用的会话入口位于详情页签条内,在“规则”页签可见;确认删除操作在两种页签下都可用。 diff --git a/packages/client/ui-schedule/package.json b/packages/client/ui-schedule/package.json index 665d1bca90..edb8398a79 100644 --- a/packages/client/ui-schedule/package.json +++ b/packages/client/ui-schedule/package.json @@ -62,6 +62,7 @@ "@deepseek-ai/dsh-client-test-runtime": "workspace:*", "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", "@deepseek-ai/dsh-client-ui-layout": "workspace:*", + "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-session": "workspace:*", diff --git a/packages/client/ui-schedule/src/client/SchedulePluginUsage.module.css b/packages/client/ui-schedule/src/client/SchedulePluginUsage.module.css new file mode 100644 index 0000000000..8f96f18b4f --- /dev/null +++ b/packages/client/ui-schedule/src/client/SchedulePluginUsage.module.css @@ -0,0 +1,53 @@ +.card { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 16px; + padding: 16px; + border: 0.5px solid var(--dsw-alias-border-l3); + border-radius: var(--dsw-radius-lg); + color: var(--dsw-alias-label-primary); + font-size: 13px; + line-height: 20px; +} + +.summary { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.card .summary { + flex: 1 1 240px; +} + +.status { + display: flex; + align-items: center; + gap: 8px; + font-weight: 500; +} + +.description { + margin: 0; + color: var(--dsw-alias-label-secondary); + font-size: 13px; + line-height: 20px; +} + +.location { + display: flex; + flex-wrap: wrap; + gap: 4px 12px; + color: var(--dsw-alias-label-secondary); + font-size: 13px; + line-height: 20px; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} diff --git a/packages/client/ui-schedule/src/client/SchedulePluginUsage.tsx b/packages/client/ui-schedule/src/client/SchedulePluginUsage.tsx new file mode 100644 index 0000000000..86d114e40a --- /dev/null +++ b/packages/client/ui-schedule/src/client/SchedulePluginUsage.tsx @@ -0,0 +1,76 @@ +/** Navigation from the enabled Schedule bundle to its live task page. */ +import { Button, Modal, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' +import type { ScheduleCatalogEntry } from '@deepseek-ai/dsh-schedule/client' +import type { CatalogInjected, CatalogSnapshot } from './catalog-source.ts' +import css from './SchedulePluginUsage.module.css' + +/** Shared catalog readiness and navigation supplied by the Schedule plugin. */ +export interface SchedulePluginUsageInjected extends Pick, 'hooks' | 'onRetry'> { + /** Open the global task page without selecting or creating a Session. */ + readonly onOpenAutomation: () => void +} + +/** Shares for the bundle detail's usage card. */ +export type SchedulePluginUsageProps = PropsRuntime<'plugins.bundle.usage'> + & PropsLocale<'schedule.manager'> & InjectFace + +/** Shares for the guidance shown after explicit bundle enablement. */ +export type SchedulePluginActivationProps = PropsRuntime<'plugins.bundle.activation'> + & PropsLocale<'schedule.manager'> & InjectFace + +type Status = CatalogSnapshot['status'] +type CopyProps = PropsLocale<'schedule.manager'> & { readonly status: Status } + +function statusKey(status: Status) { + return status === 'ready' ? 'plugin.ready' : status === 'loading' ? 'plugin.checking' : 'plugin.unavailable' +} + +function UsageSummary({ status, t }: CopyProps) { + return
+
+ + {t(statusKey(status))} +
+

{t('plugin.capability')}

+
+ {t('plugin.locationLabel')} + {t('plugin.location')} +
+
+} + +/** + * Show an enabled bundle's destination once the Host catalog answers. + * @param props - bundle enablement, catalog hook, navigation and localized copy. + * @returns a usage card, or nothing for a disabled bundle. + */ +export function SchedulePluginUsage({ pkg, useCatalog, onOpenAutomation, onRetry, t }: SchedulePluginUsageProps) { + const status = useCatalog(snapshot => snapshot.status) + if (!pkg.enabled) return null + return
+ +
+ {status === 'error' ? : null} + +
+
+} + +/** + * Guide an explicit activation to the same task page after the Host answers. + * @param props - catalog hook, dismiss and navigation callbacks, and localized copy. + * @returns dismissible guidance without creating a task or starting a model turn. + */ +export function SchedulePluginActivation({ useCatalog, onOpenAutomation, onRetry, onDismiss, t }: SchedulePluginActivationProps) { + const status = useCatalog(snapshot => snapshot.status) + return + + {status === 'error' ? : null} + + }> + + +} diff --git a/packages/client/ui-schedule/src/client/index.ts b/packages/client/ui-schedule/src/client/index.ts index 084d0edb8d..1d699fc417 100644 --- a/packages/client/ui-schedule/src/client/index.ts +++ b/packages/client/ui-schedule/src/client/index.ts @@ -57,6 +57,7 @@ import { TaskTabBindings } from './task-tab-bindings.ts' import { TaskManagerPage, type TaskManagerInjected } from './TaskManagerPage.tsx' import type { TaskDetailInjected } from './TaskDetail.tsx' import { TaskManagerIcon } from './TaskManagerIcon.tsx' +import { SchedulePluginActivation, SchedulePluginUsage, type SchedulePluginUsageInjected } from './SchedulePluginUsage.tsx' import { sessionLinkState } from './session-link.ts' import { en, NS, zh, type ScheduleCatalogKey } from './locales.ts' import { en as managerEn, zh as managerZh, type TaskManagerKey } from './task-manager-locales.ts' @@ -182,6 +183,19 @@ export function apply(ctx: ClientContext): void { locale: MANAGER_NS, label: () => t('panel'), }, TaskManagerIcon)) + ctx.inject(['layout'], (scope) => { + const usage = (): SchedulePluginUsageInjected => ({ + hooks: manager.hooks, + onRetry: manager.onRetry, + onOpenAutomation: () => { scope.layout.selectPanel(PANEL_ID) }, + }) + scope.slots.inject('plugins.bundle.usage', () => scope.slots.register({ + name: 'plugins.bundle.usage', key: '@deepseek-ai/dsh-experimental-schedule-bundle', locale: MANAGER_NS, inject: usage, + }, SchedulePluginUsage)) + scope.slots.inject('plugins.bundle.activation', () => scope.slots.register({ + name: 'plugins.bundle.activation', key: '@deepseek-ai/dsh-experimental-schedule-bundle', locale: MANAGER_NS, inject: usage, + }, SchedulePluginActivation)) + }) // The created task is a Turn-level element, not a Tool-group row: the Turn // Definition publishes the settled result and this tail list entry renders // the card beneath the closing prose. diff --git a/packages/client/ui-schedule/src/client/task-manager-locales.ts b/packages/client/ui-schedule/src/client/task-manager-locales.ts index 6906e54fff..83ce50f1c1 100644 --- a/packages/client/ui-schedule/src/client/task-manager-locales.ts +++ b/packages/client/ui-schedule/src/client/task-manager-locales.ts @@ -5,6 +5,15 @@ import { frequencyEn, frequencyZh } from './frequency-locales.ts' export const en = { 'panel': 'Automation tasks', 'title': 'Automation tasks', + 'plugin.usageLabel': 'Use automation tasks', + 'plugin.ready': 'Automation tasks are ready to use', + 'plugin.checking': 'Checking automation tasks…', + 'plugin.unavailable': 'Automation tasks are currently unavailable', + 'plugin.capability': 'Continue the original session at a scheduled time or on a repeating schedule.', + 'plugin.locationLabel': 'Location', + 'plugin.location': 'Sidebar · Automation tasks', + 'plugin.open': 'Open automation tasks', + 'plugin.close': 'Close', 'new.action': 'New', 'list.label': 'Task catalog', 'list.nextPrefix': 'Next scheduled time: ', @@ -148,6 +157,15 @@ export type TaskManagerKey = keyof typeof en export const zh: Record = { 'panel': '自动化任务', 'title': '自动化任务', + 'plugin.usageLabel': '使用自动化任务', + 'plugin.ready': '自动化任务已可使用', + 'plugin.checking': '正在确认自动化任务…', + 'plugin.unavailable': '自动化任务暂不可用', + 'plugin.capability': '按时间或周期继续原会话', + 'plugin.locationLabel': '入口位置', + 'plugin.location': '侧栏 · 自动化任务', + 'plugin.open': '打开自动化任务', + 'plugin.close': '关闭', 'new.action': '新建', 'list.label': '任务列表', 'list.nextPrefix': '下次计划时间:', diff --git a/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts b/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts index 4fa73699b9..b73dfb5c3c 100644 --- a/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts @@ -3,7 +3,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import { describe, expect, it, vi, type Mock } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { UiConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { resolveSlotLabel, type HostObservable, type StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' +import { resolveSlotLabel, type HostObservable, type PropsRenderSlots, type StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' import { stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '../src/client/index.ts' @@ -24,9 +24,11 @@ import { ScheduleCatalogAction } from '../src/client/ScheduleCatalogAction.tsx' import { SessionScheduleHover } from '../src/client/SessionScheduleHover.tsx' import { SessionScheduleMark } from '../src/client/SessionScheduleMark.tsx' import { ScheduleTurnCard, type ScheduleTurnCardInjected } from '../src/client/ScheduleTurnCard.tsx' +import { SchedulePluginActivation, SchedulePluginUsage, type SchedulePluginUsageInjected } from '../src/client/SchedulePluginUsage.tsx' import { en, NS, zh } from '../src/client/locales.ts' const Empty = () => null +const PluginSlots = (_props: PropsRenderSlots<'plugins.bundle.usage' | 'plugins.bundle.activation'>) => null /** Raw inject face of an ambient row seat: the shared Host catalog observable. */ interface SessionCatalogFace { @@ -137,6 +139,44 @@ function declareTranscriptSeats(ctx: Context): () => void { } describe('ui-schedule browser half', () => { + it('contributes plugin guidance only while loaded and opens the real task panel without creating a Session', async () => { + const catalog = vi.fn().mockResolvedValue({ ok: true, value: [] }) + const ctx = await baseContext({ schedule: { catalog }, $on: () => () => {} }) + const selectPanel = vi.fn() + const startSession = vi.spyOn(ctx.uiWorkspace, 'startSession') + const openSession = vi.spyOn(ctx.uiWorkspace, 'openSession') + ctx.provide('layout', { selectPanel } as never) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(ctx.slots.entries('plugins.bundle.usage')).toHaveLength(0) + const declaration = ctx.slots.register({ name: 'root', children: { + 'plugins.bundle.usage': { kind: 'keyed', scope: 'root' }, + 'plugins.bundle.activation': { kind: 'keyed', scope: 'root' }, + } }, PluginSlots) + const usage = ctx.slots.entries('plugins.bundle.usage')[0]! + const activation = ctx.slots.entries('plugins.bundle.activation')[0]! + expect(usage.options.key).toBe('@deepseek-ai/dsh-experimental-schedule-bundle') + expect(usage.component).toBe(SchedulePluginUsage) + expect(activation.component).toBe(SchedulePluginActivation) + const face = injectedFace(usage) as SchedulePluginUsageInjected + const guidance = injectedFace(activation) as SchedulePluginUsageInjected + expect(guidance.hooks.catalog).toBe(face.hooks.catalog) + const stop = face.hooks.catalog.subscribe(vi.fn()) + await face.onRetry() + expect(face.hooks.catalog.getSnapshot().status).toBe('ready') + face.onOpenAutomation() + guidance.onOpenAutomation() + expect(selectPanel.mock.calls).toEqual([['schedules'], ['schedules']]) + expect(startSession).not.toHaveBeenCalled() + expect(openSession).not.toHaveBeenCalled() + stop() + await fiber.dispose() + expect(ctx.slots.entries('plugins.bundle.usage')).toHaveLength(0) + expect(ctx.slots.entries('plugins.bundle.activation')).toHaveLength(0) + declaration() + await ctx.fiber.dispose() + }) + it('declares only the services used by registration', () => { expect(inject).toEqual([ 'slots', 'locale', 'remote', 'remote.schedule', 'conversation', 'uiConversation', 'uiWorkspace', 'sessions', diff --git a/packages/client/ui-schedule/tests/schedule-plugin-usage.client.spec.tsx b/packages/client/ui-schedule/tests/schedule-plugin-usage.client.spec.tsx new file mode 100644 index 0000000000..c06622bf0d --- /dev/null +++ b/packages/client/ui-schedule/tests/schedule-plugin-usage.client.spec.tsx @@ -0,0 +1,97 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { ScheduleCatalogEntry } from '@deepseek-ai/dsh-schedule/client' +import type { CatalogSnapshot } from '../src/client/catalog-source.ts' +import { + SchedulePluginActivation, SchedulePluginUsage, + type SchedulePluginActivationProps, type SchedulePluginUsageProps, +} from '../src/client/SchedulePluginUsage.tsx' +import { en, zh } from '../src/client/task-manager-locales.ts' + +afterEach(cleanup) + +function props(status: CatalogSnapshot['status'] = 'ready', enabled = true): SchedulePluginUsageProps { + const snapshot: CatalogSnapshot = { + status, records: [], deleting: [], settled: status === 'ready', readRequest: 1, + readSettled: status === 'ready' ? 1 : 0, + } + return { + pkg: { name: '@deepseek-ai/dsh-experimental-schedule-bundle', installed: false, enabled, rows: [] }, + useCatalog: selector => selector(snapshot), + onRetry: vi.fn().mockResolvedValue(undefined), + onOpenAutomation: vi.fn(), + t: makeTranslate(en), + } as SchedulePluginUsageProps +} + +function activation(status: CatalogSnapshot['status'] = 'ready'): SchedulePluginActivationProps { + return { + ...props(status), packageName: '@deepseek-ai/dsh-experimental-schedule-bundle', + onDismiss: vi.fn(), onOpenDetails: vi.fn(), + } +} + +describe('Schedule plugin usage', () => { + it('explains the live capability and opens the task page only on a click', () => { + const value = props() + render() + expect(screen.getByText('Automation tasks are ready to use')).toBeTruthy() + expect(screen.getByText('Sidebar · Automation tasks')).toBeTruthy() + expect(screen.getByText('Continue the original session at a scheduled time or on a repeating schedule.')).toBeTruthy() + expect(value.onOpenAutomation).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Open automation tasks' })) + expect(value.onOpenAutomation).toHaveBeenCalledOnce() + }) + + it('hides an explicitly disabled bundle and localizes a ready bundle', () => { + const value = props('ready', false) + const view = render() + expect(screen.queryByRole('region')).toBeNull() + view.rerender() + expect(screen.getByText('自动化任务已可使用')).toBeTruthy() + expect(screen.getByText('侧栏 · 自动化任务')).toBeTruthy() + expect(screen.getByRole('button', { name: '打开自动化任务' })).toBeTruthy() + }) + + it('does not announce readiness until a catalog query succeeds and retries failures', () => { + const loading = props('loading') + const view = render() + expect(screen.getByText('Checking automation tasks…')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Open automation tasks' })) + expect(loading.onOpenAutomation).not.toHaveBeenCalled() + const failed = props('error') + view.rerender() + expect(screen.getByText('Automation tasks are currently unavailable')).toBeTruthy() + expect(screen.queryByText('Automation tasks are ready to use')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(failed.onRetry).toHaveBeenCalledOnce() + fireEvent.click(screen.getByRole('button', { name: 'Open automation tasks' })) + expect(failed.onOpenAutomation).not.toHaveBeenCalled() + }) +}) + +describe('Schedule activation guidance', () => { + it('dismisses the guidance before opening the task page', () => { + const value = activation() + const calls: string[] = [] + render( { calls.push('close') }} onOpenAutomation={() => { calls.push('open') }} />) + expect(screen.getByRole('dialog', { name: 'Use automation tasks' })).toBeTruthy() + expect(calls).toEqual([]) + fireEvent.click(screen.getByRole('button', { name: 'Open automation tasks' })) + expect(calls).toEqual(['close', 'open']) + }) + + it('keeps failed activation guidance dismissible with a retry and no ready claim', () => { + const value = activation('error') + render() + expect(screen.getByText('Automation tasks are currently unavailable')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(value.onRetry).toHaveBeenCalledOnce() + fireEvent.click(screen.getByRole('button', { name: 'Open automation tasks' })) + expect(value.onOpenAutomation).not.toHaveBeenCalled() + fireEvent.click(screen.getAllByRole('button', { name: 'Close' })[0]!) + expect(value.onDismiss).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/client/ui-schedule/tsconfig.json b/packages/client/ui-schedule/tsconfig.json index 09401d4291..bed6d290b6 100644 --- a/packages/client/ui-schedule/tsconfig.json +++ b/packages/client/ui-schedule/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../ui-layout" }, + { + "path": "../ui-plugin-manager/tsconfig.client.json" + }, { "path": "../ui-sidebar" }, diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index ef586cd268..0784651381 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1800,6 +1800,56 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'main.conversation\', () => ctx.slots.register(\n { name: \'main.conversation\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', source: 'packages/client/ui-conversation/src/client/contract/slots.ts:131', }, + { + key: 'plugins.add.actions', + kind: 'list', + scope: 'root', + summary: 'Additional MenuItemButton rows after installation in the add-plugin menu.', + doc: 'Additional MenuItemButton rows after installation in the add-plugin menu.', + registerOptions: [ + { + name: 'id', + requirement: 'required', + type: 'string', + doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.', + }, + { + name: 'order', + requirement: 'optional', + type: 'number', + doc: 'Position among the entries, ascending (default 0).', + }, + { + name: 'label', + requirement: 'optional', + type: 'string | (() => string)', + doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.', + }, + ], + ownerProps: [ + '/** Add-plugin menu actions, independent of any selected bundle or Session. */\nexport interface PluginAddActionsProps {\n /** Close the add-plugin menu before starting the contributed action. */\n readonly onDismiss: () => void\n}', + ], + ownerPropsReferences: [], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'main\' (client-ui-plugin-manager), so it exists while that entry is mounted', + occupants: [ + 'client-ui-agent-preset CreatePluginMenuItem id \'create-plugin\'', + ], + replaceRisk: 'none', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.add.actions\', () => ctx.slots.register(\n { name: \'plugins.add.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:90', + }, { key: 'plugins.bundle.activation', kind: 'keyed', @@ -1827,16 +1877,17 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSessionRetainInfo: UseSessionRetainInfo', 'useWorkspaces: SnapshotSelectorHook', ], - keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: @deepseek-ai/dsh-experimental-voice-input-bundle', + keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: @deepseek-ai/dsh-experimental-schedule-bundle, @deepseek-ai/dsh-experimental-voice-input-bundle', hookContext: '', slotInject: '', declaredBy: 'an entry in \'main\' (client-ui-plugin-manager), so it exists while that entry is mounted', occupants: [ + 'client-ui-schedule SchedulePluginActivation key \'@deepseek-ai/dsh-experimental-schedule-bundle\'', 'experimental-client-ui-voice-input VoiceSetupPrompt key \'@deepseek-ai/dsh-experimental-voice-input-bundle\'', ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.bundle.activation\', () => ctx.slots.register(\n { name: \'plugins.bundle.activation\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:78', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:94', }, { key: 'plugins.bundle.config', @@ -1876,7 +1927,47 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.bundle.config\', () => ctx.slots.register(\n { name: \'plugins.bundle.config\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:94', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:110', + }, + { + key: 'plugins.bundle.usage', + kind: 'keyed', + scope: 'root', + summary: 'Bundle-owned usage guidance below the description, keyed by npm package name.', + doc: 'Bundle-owned usage guidance below the description, keyed by npm package name.', + registerOptions: [ + { + name: 'key', + requirement: 'required', + type: 'string', + doc: 'Your cell key: the entry renders where the owner dispatches this exact key. Registering an already-occupied key replaces that occupant.', + }, + ], + ownerProps: [ + '/** Bundle facts supplied to its usage guidance and entry actions. */\nexport interface PluginBundleUsageProps {\n /** The bundle whose detail page is open. */\n readonly pkg: PluginPackageRef\n}', + ], + ownerPropsReferences: [ + 'PluginPackageRef', + ], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + ], + keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: @deepseek-ai/dsh-experimental-schedule-bundle', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'main\' (client-ui-plugin-manager), so it exists while that entry is mounted', + occupants: [ + 'client-ui-schedule SchedulePluginUsage key \'@deepseek-ai/dsh-experimental-schedule-bundle\'', + ], + replaceRisk: 'shadows-shipped-ui', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.bundle.usage\', () => ctx.slots.register(\n { name: \'plugins.bundle.usage\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:92', }, { key: 'plugins.detail.actions', @@ -1926,7 +2017,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.detail.actions\', () => ctx.slots.register(\n { name: \'plugins.detail.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:108', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:124', }, { key: 'plugins.detail.badge', @@ -1976,7 +2067,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.detail.badge\', () => ctx.slots.register(\n { name: \'plugins.detail.badge\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:113', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:129', }, { key: 'plugins.detail.section', @@ -2026,7 +2117,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.detail.section\', () => ctx.slots.register(\n { name: \'plugins.detail.section\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:120', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:136', }, { key: 'plugins.item', @@ -2081,7 +2172,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.item\', () => ctx.slots.register(\n { name: \'plugins.item\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:88', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:104', }, { key: 'plugins.row.config', @@ -2119,7 +2210,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.row.config\', () => ctx.slots.register(\n { name: \'plugins.row.config\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:102', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:118', }, { key: 'rightbar', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b379a8c21..2d76978aee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3074,6 +3074,9 @@ importers: '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:* version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-plugin-manager': + specifier: workspace:* + version: link:../ui-plugin-manager '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:* version: link:../ui-primitives @@ -4563,6 +4566,9 @@ importers: '@deepseek-ai/dsh-client-ui-layout': specifier: workspace:* version: link:../ui-layout + '@deepseek-ai/dsh-client-ui-plugin-manager': + specifier: workspace:* + version: link:../ui-plugin-manager '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:* version: link:../ui-primitives From d70efffb63b38a6ce90aac98c14742a3858ba20d Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Mon, 28 Sep 2026 21:08:18 +0800 Subject: [PATCH 07/49] test(bench): measure Session list, search, and fork over large corpora Add a required benchmark over synthetic corpora whose per-Session length distribution is at least a measured local DSH corpus: list at 1,000 and 5,000 Sessions, cold and built content search and stratified forks at 1,000 Sessions, all through the real SessionController on built plain-Node workers. Raise the benchmark job timeout to 30 minutes for the added cases. --- ...09-28-session-corpus-performance.i18n.yaml | 31 ++ .../2026-09-28-session-corpus-performance.md | 72 +++ ...026-09-28-session-corpus-performance.zh.md | 72 +++ .github/workflows/ci.yml | 2 +- benchmarks/package.json | 10 +- benchmarks/session-corpus/README.i18n.yaml | 22 + benchmarks/session-corpus/README.md | 39 ++ benchmarks/session-corpus/README.zh.md | 39 ++ benchmarks/session-corpus/corpus-shape.ts | 76 ++++ .../session-corpus/session-corpus.bench.ts | 194 ++++++++ .../session-corpus/session-corpus.worker.ts | 340 ++++++++++++++ benchmarks/session-corpus/synthetic-corpus.ts | 416 ++++++++++++++++++ benchmarks/tsdown.config.ts | 7 + pnpm-lock.yaml | 24 + scripts/ci-workflow.spec.ts | 4 +- 15 files changed, 1344 insertions(+), 4 deletions(-) create mode 100644 .agents/notes/implemented/testing/2026-09-28-session-corpus-performance.i18n.yaml create mode 100644 .agents/notes/implemented/testing/2026-09-28-session-corpus-performance.md create mode 100644 .agents/notes/implemented/testing/2026-09-28-session-corpus-performance.zh.md create mode 100644 benchmarks/session-corpus/README.i18n.yaml create mode 100644 benchmarks/session-corpus/README.md create mode 100644 benchmarks/session-corpus/README.zh.md create mode 100644 benchmarks/session-corpus/corpus-shape.ts create mode 100644 benchmarks/session-corpus/session-corpus.bench.ts create mode 100644 benchmarks/session-corpus/session-corpus.worker.ts create mode 100644 benchmarks/session-corpus/synthetic-corpus.ts diff --git a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.i18n.yaml b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.i18n.yaml new file mode 100644 index 0000000000..d2100cbd3c --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.i18n.yaml @@ -0,0 +1,31 @@ +# Bilingual-pair consistency record for 2026-09-28-session-corpus-performance.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-28-session-corpus-performance.md +/agent-note-performance-baselines-for-large-session-corpora: + en: e84801a4e9d16b7a + zh: 2fb8bc57ed14ea0d +/agent-note-performance-baselines-for-large-session-corpora/problem: + en: d2a5a514f5c0aec3 + zh: becf99e4e8ae7b8f +/agent-note-performance-baselines-for-large-session-corpora/decision: + en: f8d67782422a1f40 + zh: 296d52c006d41661 +/agent-note-performance-baselines-for-large-session-corpora/decision/workload: + en: ae4682fdb06d3192 + zh: 6a48d412f15bedfa +/agent-note-performance-baselines-for-large-session-corpora/decision/corpus-construction: + en: b10d48e91e1ec022 + zh: e47854e79e3fb630 +/agent-note-performance-baselines-for-large-session-corpora/decision/measured-endpoints: + en: e2854f9bdf00a586 + zh: b2cc87dd1d50444e +/agent-note-performance-baselines-for-large-session-corpora/calibration-evidence: + en: d18a054dd2d204da + zh: 054c48ee9fe17c8b +/agent-note-performance-baselines-for-large-session-corpora/alternatives-considered: + en: a17b4375a6350360 + zh: 4d2b9b9e691940a9 +/agent-note-performance-baselines-for-large-session-corpora/consequences: + en: b82f7c00d2271186 + zh: 70a0da23fe292a27 diff --git a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.md b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.md new file mode 100644 index 0000000000..a69a845270 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.md @@ -0,0 +1,72 @@ +# Agent Note: Performance baselines for large Session corpora + +Status: implemented + +English | [中文](2026-09-28-session-corpus-performance.zh.md) + +## Problem + +Existing gates measure one long Session: [opening it](2026-09-04-session-open-performance-gate.md) or [continuing it](2026-09-06-backend-continuation-performance.md). Listing, content search, and fork scale with the whole corpus or with the source Session's length, and no gate covered a user with many long Sessions. A corpus of uniform small Sessions would understate the work, because real corpora are heavy-tailed. + +## Decision + +The [session-corpus benchmark](../../../../benchmarks/session-corpus/session-corpus.bench.ts) measures three Web Host endpoints over synthetic current-generation Zstandard corpora. No product implementation changes. + +### Workload + +On 2026-09-28, one developer's local DSH home held 1,650 Sessions in 88 project directories. Aggregate counts were extracted from the newest generation of each Session; no content, identity, or path left that machine. [corpus-shape.ts](../../../../benchmarks/session-corpus/corpus-shape.ts) records 22 upper-quantile anchors for three dimensions: logical events (compact delta rows expanded), decompressed bytes, and turns. + +| Quantile | Events | Logical bytes | Turns | +|---|---:|---:|---:| +| p50 | 53 | 86,195 | 1 | +| p90 | 709 | 1,849,503 | 3 | +| p99 | 7,229 | 8,479,554 | 18 | +| max | 84,467 | 48,139,877 | 2,181 | + +Rank `r` of an `n`-Session corpus takes the first anchor at or above quantile `(r + 1) / n`, so every quantile of the synthetic corpus is at least the measured one. All dimensions share the rank: a long Session is long in every dimension. At 1,000 Sessions this upper-step sampling adds 16% events and 14% bytes over the measured quantile function; coarser anchors added up to 40%. + +Other measured aggregates also set the fixture: 19 Sessions per project directory, one subagent child per six Sessions (measured 288 of 1,650), a 20,000-character system prompt (the measured median largest event is 21.9 KB), 3.9 characters per streamed delta, and 1.6 events per Zstandard frame. Turns contain tool steps (assistant message, tool call, tool result) until the event target is reached; a secant search over payload characters then reaches the byte target from below. Text comes from a Zipf-distributed synthetic vocabulary. Its stored compression ratio is 2.8, below the measured 3.4, so the fixture stores more compressed bytes than the source corpus. + +### Corpus construction + +The seed worker authors one event body per anchor, compresses it once, and writes each Session as its own header frame followed by the anchor's body. Sessions sharing an anchor differ only in header identity, creation time, project directory, and parent link. Creation time is a fixed permutation of rank, so list order does not follow length. Events carry the fields that `Session.append` records, compact streams come from the production `AssistantStreamAccumulator`, and rows are encoded with the persistence package's `eventLines`. A verification worker replays each distinct body through `sessionQuery.readSession`. + +The projection cache is part of the list endpoint: each cold row carries cached projection values. The seed folds each anchor once through the public `sessionProjectionCache.coldSnapshot` write-back. It then writes per-record documents for every Session with `serializeRecord`, rebinding identity to each header. Writing through the domain API costs one fsync per record, which took 89 s for 10,000 Sessions. The list worker requires every returned row to carry projections, so a storage layout change fails the benchmark instead of silently removing that work. + +### Measured endpoints + +Workers are compiled plain-Node processes, each started fresh against a seeded root. They compose the production Session store, projection registry, JSONL persistence, SQLite Session query with `openAt: first-search` on an in-memory index, JSON storage and projection cache, the Web projection set, AgentLoop, and the real `SessionController`. Transport lookups, upload registration, default model selection, and the Workspace registry are inert providers, because no measured path depends on them. + +| Case | Corpus | Samples | Endpoint | +|---|---|---:|---| +| List | 1,000 and 5,000 | 5 | Host boot; first `session.list`; repeated `session.list` in the same process | +| Content search | 1,000 | 1 | First `session.search`, including the index build; a second query with a different term | +| Fork | 1,000 | 3 | `session.fork` return for rank midpoints of ten equal length strata, rank 990, and rank 999 | + +Budgets enforce medians: list boot, first, and repeat; search first and repeat; fork median over strata, rank 990, and rank 999. One search sample is enforced because a cold index build takes about a minute on the reference machine. Reports include peak RSS and heap after the endpoint, but no memory budget applies. + +## Calibration evidence + +Reference expectations come from Apple M5 Pro, macOS arm64, Node 26.5, measured in isolated worker runs. They are scaled through the shared CI factor and headroom. + +| Endpoint | Observed | Expectation | +|---|---|---:| +| List 1,000: boot / first / repeat | 237–271 / 311–358 / 254–293 ms | 300 / 400 / 300 ms | +| List 5,000: boot / first / repeat | 1,182–1,261 / 1,364–1,603 / 1,287–1,588 ms | 1,300 / 1,700 / 1,600 ms | +| Search 1,000: first / repeat | 57,006 / 1,046 ms | 60,000 / 1,100 ms | +| Fork: strata median / p99 / longest | 29–32 / 608–672 / 6,000–6,365 ms | 40 / 700 / 6,500 ms | + +A 10,000-Session list took 3.2–5.4 s for the first call with 1.9 GB peak RSS; the extreme list case uses 5,000 Sessions to bound seeding time and disk use. + +## Alternatives considered + +- **Sanitized copies of real Sessions.** Rejected: benchmarks never use recorded user material. +- **Authoring every Session through `Session.append`.** Rejected: per-append validation and freezing, repeated per Session, made seeding take minutes. +- **Omitting the projection cache.** Rejected: rows would lack the values the Web list shows, which understates list work. +- **A separate CI job.** Rejected: benchmarks share one idle standard runner; the job timeout rises to 30 minutes instead. + +## Consequences + +The gate covers corpus-scale list, search, and fork costs that single-Session gates cannot see. It already shows that content search spends about a minute building its index on first use, that list cost grows linearly to about 1.5 s at 5,000 Sessions, and that forking the longest Session takes about 6 s. Seeding writes about 1.9 GB and adds roughly half a minute locally. + +Exclusions: the corpus is current-generation only, although most real Sessions are stored in older generations, which list revision hashing and migration-on-read would add. Only 22 distinct bodies exist, so search indexes repeated text. Fork runs without Agent presets or Workspace attachment. Calls are in-process, without Typert transport or a browser. Filesystem caches are not evicted, so cold means a fresh process, not cold storage. diff --git a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.zh.md b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.zh.md new file mode 100644 index 0000000000..79c7fc2792 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.zh.md @@ -0,0 +1,72 @@ +# Agent Note: 大型 Session 语料的性能基线 + +Status: implemented + +[English](2026-09-28-session-corpus-performance.md) | 中文 + +## 问题 + +现有门禁只测量单个长 Session:[打开](2026-09-04-session-open-performance-gate.zh.md)或[续聊](2026-09-06-backend-continuation-performance.zh.md)。列表、内容搜索和 fork 的成本随整个语料或源 Session 长度增长,此前没有门禁覆盖拥有大量长 Session 的用户。由均匀小 Session 组成的语料会低估工作量,因为真实语料呈重尾分布。 + +## 决定 + +[session-corpus 基准](../../../../benchmarks/session-corpus/session-corpus.bench.ts)在合成的当前代 Zstandard 语料上测量三个 Web Host 端点。产品实现不做任何改动。 + +### 工作负载 + +2026-09-28,一名开发者的本地 DSH home 在 88 个项目目录中保存了 1,650 个 Session。统计只从每个 Session 的最新代中提取聚合计数;内容、身份和路径都没有离开那台机器。[corpus-shape.ts](../../../../benchmarks/session-corpus/corpus-shape.ts) 为三个维度记录 22 个上分位锚点:逻辑事件数(展开紧凑 delta 行)、解压字节数和轮次数。 + +| 分位 | 事件数 | 逻辑字节数 | 轮次数 | +|---|---:|---:|---:| +| p50 | 53 | 86,195 | 1 | +| p90 | 709 | 1,849,503 | 3 | +| p99 | 7,229 | 8,479,554 | 18 | +| max | 84,467 | 48,139,877 | 2,181 | + +`n` 个 Session 的语料中,排名 `r` 取不低于分位 `(r + 1) / n` 的第一个锚点,因此合成语料的每个分位都不低于实测分位。所有维度共享同一排名:长 Session 在每个维度上都长。在 1,000 个 Session 时,这种上阶梯采样比实测分位函数多出 16% 的事件和 14% 的字节;更粗的锚点最多多出 40%。 + +其他实测聚合值也用于确定夹具:每个项目目录 19 个 Session,每六个 Session 中一个 subagent 子会话(实测为 1,650 个中的 288 个),20,000 字符的 system prompt(实测最大事件的中位数为 21.9 KB),每个流式 delta 3.9 个字符,每个 Zstandard 帧 1.6 个事件。轮次中持续加入工具步骤(assistant 消息、工具调用、工具结果)直到达到事件目标;随后对负载字符数做割线搜索,从下方达到字节目标。文本来自 Zipf 分布的合成词表。其存储压缩比为 2.8,低于实测的 3.4,因此夹具存储的压缩字节多于源语料。 + +### 语料构建 + +播种 worker 为每个锚点生成一份事件主体,只压缩一次,然后把每个 Session 写成独立的 header 帧加上该锚点的主体。共享锚点的 Session 只在 header 身份、创建时间、项目目录和父链接上不同。创建时间是排名的固定置换,因此列表顺序不跟随长度。事件携带 `Session.append` 记录的字段,紧凑流来自生产 `AssistantStreamAccumulator`,行通过持久化包的 `eventLines` 编码。验证 worker 通过 `sessionQuery.readSession` 回放每份不同的主体。 + +投影缓存属于列表端点:每个冷行都携带缓存的投影值。播种过程通过公开的 `sessionProjectionCache.coldSnapshot` 回写,为每个锚点折叠一次。之后用 `serializeRecord` 为每个 Session 写入逐记录文档,并把身份重新绑定到各自的 header。通过 domain API 写入时每条记录一次 fsync,10,000 个 Session 耗时 89 s。列表 worker 要求返回的每一行都携带投影,因此存储布局变化会使基准失败,而不是静默去掉这部分工作。 + +### 测量端点 + +worker 是编译后的纯 Node 进程,每次都针对已播种根目录全新启动。它们组合生产 Session store、投影注册表、JSONL 持久化、使用 `openAt: first-search` 和内存索引的 SQLite Session query、JSON 存储和投影缓存、Web 投影集合、AgentLoop 以及真实的 `SessionController`。传输查找、上传注册、默认模型选择和 Workspace 注册表使用惰性提供者,因为没有被测路径依赖它们。 + +| 用例 | 语料 | 样本数 | 终点 | +|---|---|---:|---| +| 列表 | 1,000 和 5,000 | 5 | Host 启动;首次 `session.list`;同一进程中重复的 `session.list` | +| 内容搜索 | 1,000 | 1 | 首次 `session.search`,包括索引构建;使用不同词项的第二次查询 | +| Fork | 1,000 | 3 | 十个等长度分层的中点排名、排名 990 和排名 999 的 `session.fork` 返回 | + +预算约束中位数:列表的启动、首次和重复;搜索的首次和重复;fork 的分层中位数、排名 990 和排名 999。搜索只约束一个样本,因为在参考机器上一次冷索引构建约需一分钟。报告包含端点之后的峰值 RSS 和堆,但不设内存预算。 + +## 校准证据 + +参考期望值来自 Apple M5 Pro、macOS arm64、Node 26.5 上隔离的 worker 运行,并通过共享 CI 系数和余量缩放。 + +| 端点 | 观测值 | 期望值 | +|---|---|---:| +| 列表 1,000:启动 / 首次 / 重复 | 237–271 / 311–358 / 254–293 ms | 300 / 400 / 300 ms | +| 列表 5,000:启动 / 首次 / 重复 | 1,182–1,261 / 1,364–1,603 / 1,287–1,588 ms | 1,300 / 1,700 / 1,600 ms | +| 搜索 1,000:首次 / 重复 | 57,006 / 1,046 ms | 60,000 / 1,100 ms | +| Fork:分层中位数 / p99 / 最长 | 29–32 / 608–672 / 6,000–6,365 ms | 40 / 700 / 6,500 ms | + +10,000 个 Session 的首次列表耗时 3.2–5.4 s,峰值 RSS 为 1.9 GB;极端列表用例使用 5,000 个 Session,以限制播种时间和磁盘占用。 + +## 考虑过的替代方案 + +- **脱敏复制真实 Session。** 已否决:基准从不使用录制的用户材料。 +- **通过 `Session.append` 生成每个 Session。** 已否决:逐次追加的验证和冻结在每个 Session 上重复,使播种耗时数分钟。 +- **省略投影缓存。** 已否决:行会缺少 Web 列表显示的值,从而低估列表工作量。 +- **单独的 CI 任务。** 已否决:基准共享一台空闲的标准运行器;改为把任务超时提高到 30 分钟。 + +## 后果 + +该门禁覆盖单 Session 门禁看不到的语料规模列表、搜索和 fork 成本。它已经显示:内容搜索在首次使用时约花一分钟构建索引,列表成本线性增长,在 5,000 个 Session 时约 1.5 s,fork 最长 Session 约需 6 s。播种写入约 1.9 GB,本地约增加半分钟。 + +排除项:语料只包含当前代,而大多数真实 Session 以旧代存储,这会增加列表的修订哈希和读取时迁移。只有 22 份不同主体,因此搜索索引的是重复文本。Fork 不挂载 Agent preset,也不附加 Workspace。调用在进程内完成,不经过 Typert 传输或浏览器。不驱逐文件系统缓存,因此“冷”指全新进程,而不是冷存储。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c8d801864..f913bb496f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -196,7 +196,7 @@ jobs: name: node 24 / benchmarks # Wall-clock budgets need an otherwise idle runner, so this job runs the # benchmark lane alone instead of joining a concurrent gate aggregate. - timeout-minutes: 15 + timeout-minutes: 30 steps: - uses: actions/checkout@v6 with: diff --git a/benchmarks/package.json b/benchmarks/package.json index 975ea4a44a..e04ce199a8 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -34,7 +34,15 @@ "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", - "@deepseek-ai/dsh-util-values": "workspace:*" + "@deepseek-ai/dsh-util-values": "workspace:*", + "@deepseek-ai/dsh-session-projection-cache": "workspace:*", + "@deepseek-ai/dsh-session-query-sqlite": "workspace:*", + "@deepseek-ai/dsh-storage": "workspace:*", + "@deepseek-ai/dsh-storage-domain": "workspace:*", + "@deepseek-ai/dsh-storage-json": "workspace:*", + "@deepseek-ai/dsh-session-format": "workspace:*", + "@deepseek-ai/dsh-session-format-catalog": "workspace:*", + "@deepseek-ai/dsh-session-format-v3-to-v4": "workspace:*" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~" diff --git a/benchmarks/session-corpus/README.i18n.yaml b/benchmarks/session-corpus/README.i18n.yaml new file mode 100644 index 0000000000..b3cd789f8b --- /dev/null +++ b/benchmarks/session-corpus/README.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write benchmarks/session-corpus/README.md +/session-corpus-benchmarks: + en: 28ebbfcfa3ea913a + zh: 3ebad56e2c97c7c8 +/session-corpus-benchmarks/summary: + en: 84da6860b2ff8c6e + zh: 671ceb53d53b1650 +/session-corpus-benchmarks/table-of-contents: + en: 66a769ce60d49ff3 + zh: 7e0bfa7c598157fa +/session-corpus-benchmarks/run: + en: e2436a8eeb9a3b7b + zh: 7137a8761e202b68 +/session-corpus-benchmarks/measurements: + en: 648ed1dcdc9c67a4 + zh: 039a91d845b5ea53 +/session-corpus-benchmarks/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/benchmarks/session-corpus/README.md b/benchmarks/session-corpus/README.md new file mode 100644 index 0000000000..731a68b79f --- /dev/null +++ b/benchmarks/session-corpus/README.md @@ -0,0 +1,39 @@ +# Session corpus benchmarks + +English | [中文](README.zh.md) + +## Summary + +Measure the Web Host's Session list, content search, and fork over synthetic corpora in which every Session is at least as long as the same quantile of a measured local DSH corpus. The list runs over 1,000 and 5,000 Sessions; search and fork run over 1,000. No case uses network services, recorded Sessions, or a browser. + +## Table of Contents + +- [Run](#run) +- [Measurements](#measurements) +- [Dev Note](#dev-note) + + + +## Run + +From the repository root, build the libraries and workers with `pnpm run build:bench`, then run `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/session-corpus/session-corpus.bench.ts`. Do not overlap timing runs with builds or other benchmarks. Seeding writes about 1.9 GB of Zstandard logs under a private temporary root, which the test removes after success or failure. + +The test reports every fresh-process sample, the CPU model, available parallelism, platform, and Node version, and enforces median budgets. A failed worker reports its exit, signal, timeout, and stderr tail. The required benchmark lane discovers this file automatically. + + + +## Measurements + +[corpus-shape.ts](corpus-shape.ts) holds the measured quantile anchors. [synthetic-corpus.ts](synthetic-corpus.ts) authors one event body per anchor and stores each Session as its own header frame followed by that body. [The Agent Note](../../.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.md) owns the workload derivation, timing endpoints, calibration, and exclusions. + +| Case | Corpus | Timed endpoints | +|---|---|---| +| List | 1,000 and 5,000 Sessions | Host boot; first and repeated `session.list`, each returning every Session with cached projections | +| Content search | 1,000 Sessions | First `session.search`, which builds the in-memory index; a second query over the built index | +| Fork | 1,000 Sessions | `session.fork` return for ten length strata, the p99 Session, and the longest Session | + + + +## Dev Note + +None. diff --git a/benchmarks/session-corpus/README.zh.md b/benchmarks/session-corpus/README.zh.md new file mode 100644 index 0000000000..70e347b2e9 --- /dev/null +++ b/benchmarks/session-corpus/README.zh.md @@ -0,0 +1,39 @@ +# Session 语料基准 + +[English](README.md) | 中文 + +## 概述 + +在合成语料上测量 Web Host 的 Session 列表、内容搜索和 fork;语料中每个 Session 的长度都不短于一份实测本地 DSH 语料在同一分位上的长度。列表用例运行 1,000 和 5,000 个 Session;搜索和 fork 用例运行 1,000 个 Session。所有用例均不使用网络服务、录制的 Session 或浏览器。 + +## 目录 + +- [运行](#run) +- [测量](#measurements) +- [开发备注](#dev-note) + + + +## 运行 + +在仓库根目录使用 `pnpm run build:bench` 构建库和 worker,然后运行 `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/session-corpus/session-corpus.bench.ts`。不要让计时运行与构建或其他基准重叠。播种过程会在私有临时根目录下写入约 1.9 GB 的 Zstandard 日志,测试成功或失败后都会删除该目录。 + +测试报告每个新进程样本、CPU 型号、可用并行度、平台和 Node 版本,并约束中位数预算。worker 失败时报告退出状态、信号、超时和 stderr 末尾。必需基准通道自动发现此文件。 + + + +## 测量 + +[corpus-shape.ts](corpus-shape.ts) 保存实测分位锚点。[synthetic-corpus.ts](synthetic-corpus.ts) 为每个锚点生成一份事件主体,并把每个 Session 存储为独立的 header 帧加上该主体。[Agent Note](../../.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.zh.md) 负责说明工作负载推导、计时终点、校准和排除项。 + +| 用例 | 语料 | 计时终点 | +|---|---|---| +| 列表 | 1,000 和 5,000 个 Session | Host 启动;首次和重复的 `session.list`,每次都返回全部 Session 及其缓存投影 | +| 内容搜索 | 1,000 个 Session | 构建内存索引的首次 `session.search`;在已构建索引上的第二次查询 | +| Fork | 1,000 个 Session | 十个长度分层、p99 Session 和最长 Session 的 `session.fork` 返回 | + + + +## 开发备注 + +无。 diff --git a/benchmarks/session-corpus/corpus-shape.ts b/benchmarks/session-corpus/corpus-shape.ts new file mode 100644 index 0000000000..1eb365d0e1 --- /dev/null +++ b/benchmarks/session-corpus/corpus-shape.ts @@ -0,0 +1,76 @@ +/** Per-Session length targets for the synthetic Session corpus. */ + +/** + * Upper-quantile anchors, in percent, of the measured corpus distribution. + * Each anchor pairs with one entry of every dimension table below. + */ +const QUANTILES = [10, 20, 30, 40, 50, 60, 70, 75, 80, 85, 90, 92, 94, 96, 97, 98, 99, 99.3, 99.6, 99.8, 99.9, 100] as const + +/** + * Aggregate quantiles of 1,650 local DSH Sessions measured on 2026-09-28 + * (newest generation per Session; top-level and subagent Sessions together). + * Only counts and byte totals were extracted; no content, identity, or path. + */ +const MEASURED = { + /** Logical events, expanding compact delta rows to their event count. */ + events: [22, 22, 27, 32, 53, 63, 84, 154, 251, 387, 709, 975, 1398, 2398, 2652, 3492, 7229, 11620, 21995, 26580, 60699, 84467], + /** Decompressed JSONL bytes including the header line. */ + logicalBytes: [ + 37_233, 46_642, 54_432, 67_432, 86_195, 95_561, 184_414, 572_320, 882_968, 1_250_060, 1_849_503, + 2_341_019, 2_849_779, 3_902_295, 4_874_426, 6_691_370, 8_479_554, 10_570_582, 13_024_700, 17_646_397, + 21_225_976, 48_139_877, + ], + /** Started turns. */ + turns: [1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 3, 4, 5, 7, 9, 11, 18, 20, 23, 39, 118, 2181], +} as const + +/** Minimum length of one synthetic Session. */ +export interface SessionShape { + /** Index of the measured anchor; Sessions with one anchor share one shape. */ + readonly anchor: number + /** Logical events the Session must contain at least. */ + readonly events: number + /** Decompressed log bytes the Session must contain at least. */ + readonly logicalBytes: number + /** Completed turns. */ + readonly turns: number +} + +/** Number of measured anchors, and therefore of distinct Session shapes. */ +export const ANCHOR_COUNT = QUANTILES.length + +/** + * Read the length targets of one measured anchor. + * @param anchor - zero-based anchor index. + * @returns the anchor's minimum event, byte, and turn counts. + */ +export function anchorShape(anchor: number): SessionShape { + if (!Number.isInteger(anchor) || anchor < 0 || anchor >= ANCHOR_COUNT) { + throw new Error(`corpus anchor ${String(anchor)} does not exist`) + } + return { + anchor, + events: MEASURED.events[anchor] as number, + logicalBytes: MEASURED.logicalBytes[anchor] as number, + turns: MEASURED.turns[anchor] as number, + } +} + +/** + * Select the length targets for one rank of an ascending corpus. + * + * Every dimension uses the next measured anchor at or above the rank's + * quantile, so each quantile of the generated corpus is at least the measured + * one. Dimensions share the rank, so a long Session is long in every dimension. + * @param rank - zero-based ascending length rank. + * @param count - corpus size. + * @returns the rank's minimum event, byte, and turn counts. + */ +export function sessionShape(rank: number, count: number): SessionShape { + const quantile = (100 * (rank + 1)) / count + const anchor = QUANTILES.findIndex(value => value >= quantile) + if (!Number.isInteger(rank) || rank < 0 || anchor === -1) { + throw new Error(`corpus rank ${String(rank)} is outside a ${String(count)}-Session corpus`) + } + return anchorShape(anchor) +} diff --git a/benchmarks/session-corpus/session-corpus.bench.ts b/benchmarks/session-corpus/session-corpus.bench.ts new file mode 100644 index 0000000000..c55ae78855 --- /dev/null +++ b/benchmarks/session-corpus/session-corpus.bench.ts @@ -0,0 +1,194 @@ +/** Required budgets for listing, searching, and forking Sessions in corpora with the measured local length distribution. */ + +import { mkdtemp, rm } from 'node:fs/promises' +import { availableParallelism, cpus, tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { runBuiltBenchmarkWorker } from '../support/built-worker.ts' +import { ciTimeBudget } from '../support/calibration.ts' +import { anchorShape, ANCHOR_COUNT, sessionShape } from './corpus-shape.ts' +import type { + ForkReport, + ListReport, + SearchReport, + SeedReport, + SessionCorpusReport, + VerifyReport, +} from './session-corpus.worker.ts' + +/** Corpus used by search and fork, and the extreme corpus used only by the list. */ +const CORPUS = { standard: 1_000, extreme: 5_000 } as const +/** Fresh processes per list and fork scenario; the median enforces each budget. */ +const ATTEMPTS = { list: 5, fork: 3 } as const +/** One cold content-search sample: the index build takes about a minute on the reference machine. */ +const SEARCH_ATTEMPTS = 1 +/** Midpoints of ten equal length strata of the standard corpus, then its p99 and longest Session. */ +const FORK_STRATA = [50, 150, 250, 350, 450, 550, 650, 750, 850, 950] as const +const FORK_P99_RANK = 990 +const FORK_LONGEST_RANK = 999 +const SEED_TIMEOUT_MS = 600_000 +const WORKER_TIMEOUT_MS = 300_000 +const WORKER = join(import.meta.dirname, '..', '.dsh-build', 'session-corpus', 'session-corpus.worker.js') + +/** Apple M5 Pro / Node 26.5 expectations, before shared CI scaling and variance headroom. */ +const EXPECTED_MS = { + listBoot: { standard: 300, extreme: 1_300 }, + listFirst: { standard: 400, extreme: 1_700 }, + listRepeat: { standard: 300, extreme: 1_600 }, + searchFirst: 60_000, + searchRepeat: 1_100, + forkStratumMedian: 40, + forkP99: 700, + forkLongest: 6_500, +} as const + +type CorpusSize = keyof typeof CORPUS + +async function run(args: readonly string[], timeoutMs = WORKER_TIMEOUT_MS): Promise { + const outcome = await runBuiltBenchmarkWorker({ worker: WORKER, args, timeoutMs, exposeGc: true }) + if (outcome.report === undefined) { + const stderr = outcome.stderr.trim().split('\n').slice(-20).join('\n') + throw new Error(`session-corpus worker ${args[1] ?? ''} failed: exit=${String(outcome.exitCode)}, ` + + `signal=${String(outcome.signal)}, timedOut=${String(outcome.timedOut)}\n${stderr}`) + } + return outcome.report +} + +function median(values: readonly number[]): number { + return [...values].sort((left, right) => left - right)[Math.floor(values.length / 2)] as number +} + +function rounded(values: readonly number[]): number[] { + return values.map(value => Math.round(value * 10) / 10) +} + +function expectWithinBudget(value: number, budget: number): void { + expect(value).toBeLessThanOrEqual(budget) +} + +function environment() { + return { + cpu: cpus()[0]?.model, + availableParallelism: availableParallelism(), + platform: `${process.platform}-${process.arch}`, + node: process.version, + } +} + +describe('Session corpus workload', () => { + it('is at least the measured distribution at every rank of both corpora', () => { + for (const count of Object.values(CORPUS)) { + const shapes = Array.from({ length: count }, (_, rank) => sessionShape(rank, count)) + expect(new Set(shapes.map(shape => shape.anchor)).size).toBe(ANCHOR_COUNT) + expect(shapes[Math.floor(count / 2) - 1]).toEqual(anchorShape(4)) + expect(shapes.at(-1)).toEqual({ anchor: ANCHOR_COUNT - 1, events: 84_467, logicalBytes: 48_139_877, turns: 2_181 }) + for (let rank = 1; rank < count; rank++) { + const [previous, current] = [shapes[rank - 1], shapes[rank]] as const + expect(current?.events).toBeGreaterThanOrEqual(previous?.events ?? 0) + expect(current?.logicalBytes).toBeGreaterThanOrEqual(previous?.logicalBytes ?? 0) + } + } + expect(() => sessionShape(CORPUS.standard, CORPUS.standard)).toThrow('outside') + }) + + it('rejects endpoint medians more than a quarter above their CI expectation', () => { + const expectations = [ + EXPECTED_MS.listFirst.extreme, EXPECTED_MS.searchFirst, EXPECTED_MS.forkLongest, EXPECTED_MS.forkStratumMedian, + ] + for (const expected of expectations) { + const budget = ciTimeBudget(expected) + expectWithinBudget(expected * 2, budget) + expect(() => expectWithinBudget(Math.ceil(expected * 2 * 1.26), budget)).toThrow() + } + }) +}) + +describe('Session corpus operations', () => { + let scratch = '' + const root = (size: CorpusSize): string => join(scratch, `corpus-${String(CORPUS[size])}`) + + beforeAll(async () => { + scratch = await mkdtemp(join(tmpdir(), 'dsh-session-corpus-bench-')) + const seeded = await run([scratch, 'seed', String(CORPUS.standard), String(CORPUS.extreme)], SEED_TIMEOUT_MS) + console.log(JSON.stringify({ benchmark: 'session-corpus/seed', ...seeded, environment: environment() })) + }, SEED_TIMEOUT_MS) + + afterAll(async () => { + if (scratch !== '') await rm(scratch, { recursive: true, force: true }) + }) + + it('stores replay-valid Sessions for every measured anchor', async () => { + const report = await run([root('standard'), 'verify', String(CORPUS.standard)]) + expect(report.anchors).toHaveLength(ANCHOR_COUNT) + for (const { anchor, events } of report.anchors) { + expect(events).toBeGreaterThanOrEqual(anchorShape(anchor).events) + } + }) + + for (const size of ['standard', 'extreme'] as const) { + const budgets = { + bootMs: ciTimeBudget(EXPECTED_MS.listBoot[size]), + firstMs: ciTimeBudget(EXPECTED_MS.listFirst[size]), + repeatMs: ciTimeBudget(EXPECTED_MS.listRepeat[size]), + } + + it(`lists ${String(CORPUS[size])} Sessions after a cold Host boot`, async () => { + const reports: ListReport[] = [] + for (let attempt = 0; attempt < ATTEMPTS.list; attempt++) { + reports.push(await run([root(size), 'list', String(CORPUS[size])])) + } + const samples = { + bootMs: rounded(reports.map(report => report.bootMs)), + firstMs: rounded(reports.map(report => report.firstMs)), + repeatMs: rounded(reports.map(report => report.repeatMs)), + peakRssMb: reports.map(report => report.memory.peakRssMb), + heapUsedMb: reports.map(report => report.memory.heapUsedMb), + } + console.log(JSON.stringify({ benchmark: `session-corpus/list-${String(CORPUS[size])}`, samples, budgets, environment: environment() })) + for (const report of reports) expect(report.itemsWithProjections).toBe(CORPUS[size]) + expectWithinBudget(median(samples.bootMs), budgets.bootMs) + expectWithinBudget(median(samples.firstMs), budgets.firstMs) + expectWithinBudget(median(samples.repeatMs), budgets.repeatMs) + }) + } + + it(`searches ${String(CORPUS.standard)} Sessions with a cold and then a built content index`, async () => { + const budgets = { firstMs: ciTimeBudget(EXPECTED_MS.searchFirst), repeatMs: ciTimeBudget(EXPECTED_MS.searchRepeat) } + const reports: SearchReport[] = [] + for (let attempt = 0; attempt < SEARCH_ATTEMPTS; attempt++) { + reports.push(await run([root('standard'), 'search'])) + } + console.log(JSON.stringify({ benchmark: 'session-corpus/search', reports, budgets, environment: environment() })) + expectWithinBudget(median(reports.map(report => report.firstMs)), budgets.firstMs) + expectWithinBudget(median(reports.map(report => report.repeatMs)), budgets.repeatMs) + }, SEED_TIMEOUT_MS) + + it(`forks Sessions across the length distribution of ${String(CORPUS.standard)} Sessions`, async () => { + const budgets = { + stratumMedianMs: ciTimeBudget(EXPECTED_MS.forkStratumMedian), + p99Ms: ciTimeBudget(EXPECTED_MS.forkP99), + longestMs: ciTimeBudget(EXPECTED_MS.forkLongest), + } + const ranks = [...FORK_STRATA, FORK_P99_RANK, FORK_LONGEST_RANK].map(String) + const reports: ForkReport[] = [] + for (let attempt = 0; attempt < ATTEMPTS.fork; attempt++) { + reports.push(await run([root('standard'), 'fork', ...ranks])) + } + const forkMs = (report: ForkReport, rank: number): number => { + const fork = report.forks.find(entry => entry.rank === rank) + if (fork === undefined) throw new Error(`fork report omits rank ${String(rank)}`) + return fork.forkMs + } + const samples = { + stratumMedianMs: rounded(reports.map(report => median(FORK_STRATA.map(rank => forkMs(report, rank))))), + p99Ms: rounded(reports.map(report => forkMs(report, FORK_P99_RANK))), + longestMs: rounded(reports.map(report => forkMs(report, FORK_LONGEST_RANK))), + perRank: reports.map(report => report.forks.map(({ rank, sourceEvents, forkMs }) => ({ rank, sourceEvents, forkMs: Math.round(forkMs) }))), + peakRssMb: reports.map(report => report.memory.peakRssMb), + } + console.log(JSON.stringify({ benchmark: 'session-corpus/fork', samples, budgets, environment: environment() })) + expectWithinBudget(median(samples.stratumMedianMs), budgets.stratumMedianMs) + expectWithinBudget(median(samples.p99Ms), budgets.p99Ms) + expectWithinBudget(median(samples.longestMs), budgets.longestMs) + }, SEED_TIMEOUT_MS) +}) diff --git a/benchmarks/session-corpus/session-corpus.worker.ts b/benchmarks/session-corpus/session-corpus.worker.ts new file mode 100644 index 0000000000..2855b6570c --- /dev/null +++ b/benchmarks/session-corpus/session-corpus.worker.ts @@ -0,0 +1,340 @@ +/** Isolated worker that seeds the synthetic corpus or measures one Session-controller operation over it. */ + +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { scheduler } from 'node:timers/promises' +import { Context } from '@deepseek-ai/cordis' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' +import SessionController from '@deepseek-ai/dsh-api-session-controller' +import { SessionLogOffset } from '@deepseek-ai/dsh-session' +import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import SessionProjectionCache, { projectionCacheDomainSpec } from '@deepseek-ai/dsh-session-projection-cache' +import type { CheckpointRecord } from '@deepseek-ai/dsh-session-projection-cache' +import SqliteSessionQueryEngine from '@deepseek-ai/dsh-session-query-sqlite' +import * as SessionStatsPlugin from '@deepseek-ai/dsh-session-stats' +import SessionTitleService from '@deepseek-ai/dsh-session-title' +import * as SessionTurnOutlinePlugin from '@deepseek-ai/dsh-session-turn-outline' +import Storage from '@deepseek-ai/dsh-storage' +import * as StorageDomain from '@deepseek-ai/dsh-storage-domain' +import type { KvTable } from '@deepseek-ai/dsh-storage-domain' +import * as StorageJson from '@deepseek-ai/dsh-storage-json' +import TokenMeter from '@deepseek-ai/dsh-token-meter' +import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts' +import { serializeRecord } from '../../packages/storage/storage-json/src/format.ts' +import { sessionShape } from './corpus-shape.ts' +import { + corpusHeader, + corpusSessionId, + forEachConcurrently, + SEARCH_QUERIES, + SyntheticCorpusWriter, + type SyntheticCorpusFacts, + type WrittenSession, +} from './synthetic-corpus.ts' + +/** Process memory after the measured endpoint, with its result still reachable. */ +export interface CorpusMemory { + readonly heapUsedMb: number + readonly rssMb: number + readonly peakRssMb: number +} + +/** Seeding report: facts of every corpus and the time spent building them. */ +export interface SeedReport { + readonly mode: 'seed' + readonly corpora: readonly SyntheticCorpusFacts[] + readonly seedMs: number +} + +/** List report: Host boot, then a cold and a repeated Session list. */ +export interface ListReport { + readonly mode: 'list' + readonly bootMs: number + readonly firstMs: number + readonly repeatMs: number + readonly items: number + readonly itemsWithProjections: number + readonly memory: CorpusMemory +} + +/** Search report: the first search builds the in-memory index; the second reuses it. */ +export interface SearchReport { + readonly mode: 'search' + readonly bootMs: number + readonly firstMs: number + readonly repeatMs: number + readonly firstItems: number + readonly repeatItems: number + readonly memory: CorpusMemory +} + +/** Fork report: one fork of each requested source, in request order. */ +export interface ForkReport { + readonly mode: 'fork' + readonly bootMs: number + readonly forks: readonly { + readonly rank: number + readonly sourceEvents: number + readonly forkMs: number + }[] + readonly memory: CorpusMemory +} + +/** Verification report: replay-validated event counts of every distinct body. */ +export interface VerifyReport { + readonly mode: 'verify' + readonly anchors: readonly { readonly anchor: number; readonly rank: number; readonly events: number }[] +} + +/** Any worker report. */ +export type SessionCorpusReport = SeedReport | ListReport | SearchReport | ForkReport | VerifyReport + +const BENCH_MODEL = { provider: 'bench', model: 'bench' } as const +/** Bound on waiting for fire-and-forget projection-cache write-backs after seeding. */ +const SEED_DRAIN_TIMEOUT_MS = 120_000 + +function megabytes(bytes: number): number { + return Math.round(bytes / 104_857.6) / 10 +} + +async function memory(): Promise { + const gc = (globalThis as typeof globalThis & { gc?: () => void }).gc + if (gc === undefined) throw new Error('Session corpus benchmark requires --expose-gc') + gc() + await scheduler.yield() + gc() + const usage = process.memoryUsage() + return { + heapUsedMb: megabytes(usage.heapUsed), + rssMb: megabytes(usage.rss), + peakRssMb: Math.round(process.resourceUsage().maxRSS / 102.4) / 10, + } +} + +/** Mount the shipped Web Host's Session list, search, and fork dependencies. */ +async function mountHost(root: string): Promise<{ readonly ctx: Context; readonly controller: SessionController }> { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(JsonlSessionPersistence, { root: join(root, 'sessions'), compression: 'zstd' }) + await ctx.plugin(Storage) + await ctx.plugin(StorageJson, { root: join(root, 'storages') }) + await ctx.plugin(StorageDomain, { backend: 'json' }) + await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 200, writeIntervalMs: 5_000 }) + // Content search is opt-in; enabled deployments defer the in-memory index to the first search. + await ctx.plugin(SqliteSessionQueryEngine, { path: ':memory:', openAt: 'first-search' }) + ctx.sessionProjections.register(agentPresetProjectionDefinition) + await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 80 }) + await ctx.plugin(SessionStatsPlugin) + await ctx.plugin(SessionTurnOutlinePlugin) + await ctx.plugin(TokenMeter) + await ctx.plugin(AgentLoop, { agents: [] }) + // Transport, upload, model-default, and Workspace services are outside every measured path. + const dispose = (): void => {} + ctx.provide('typert', { lookups: { configure: () => dispose }, contexts: { configureHost: () => dispose } } as never) + ctx.provide('fileUploads', { registerAgentResolver: () => dispose } as never) + ctx.provide('agentDefaultModel', { currentSelection: () => BENCH_MODEL } as never) + ctx.provide('workspaceRegistry', { list: () => [], archivedSessionIds: [] } as never) + const controller = new SessionController(ctx, { nativeOpen: false }, { canOpenPath: () => false }) + return { ctx, controller } +} + +/** Run one operation against the projection-cache table stored under `root`. */ +async function withCacheTable( + root: string, + operation: (table: KvTable) => Promise, +): Promise { + const ctx = new Context() + try { + await ctx.plugin(Storage) + await ctx.plugin(StorageJson, { root: join(root, 'storages') }) + await ctx.plugin(StorageDomain, { backend: 'json' }) + const domain = await ctx.storageDomain.open(projectionCacheDomainSpec) + try { + return await operation(domain.table('sessions')) + } finally { + await domain.close() + } + } finally { + await ctx.fiber.dispose() + } +} + +async function seed(root: string, counts: readonly number[]): Promise { + const started = performance.now() + const writer = new SyntheticCorpusWriter() + const folded = new Map() + for (const count of counts) { + for (let rank = 0; rank < count; rank++) { + const { anchor } = sessionShape(rank, count) + if (!folded.has(anchor)) folded.set(anchor, corpusHeader(rank, count)) + } + } + const foldRoot = join(root, 'fold') + const host = await mountHost(foldRoot) + try { + for (const [anchor, header] of folded) { + // The production cold-read write-back folds each shared body once. + host.ctx.sessionProjectionCache.coldSnapshot(header, SessionLogOffset(0), await writer.author(anchor)) + } + // Write-backs are fire-and-forget; the cache serves a record only after it is durable. + const deadline = performance.now() + SEED_DRAIN_TIMEOUT_MS + while ([...folded.values()].some(header => host.ctx.sessionProjectionCache.cachedSnapshot(header) === undefined)) { + if (performance.now() > deadline) throw new Error('projection-cache write-back did not become durable') + await scheduler.wait(20) + } + } finally { + await host.ctx.fiber.dispose() + } + const records = await withCacheTable(foldRoot, table => Promise.resolve(new Map( + [...folded].map(([anchor, header]) => [anchor, table.get(header.id) as CheckpointRecord]), + ))) + const corpora: SyntheticCorpusFacts[] = [] + for (const count of counts) { + const corpusRoot = join(root, `corpus-${String(count)}`) + const written = await writer.writeCorpus(join(corpusRoot, 'sessions'), count) + // Sessions of one anchor share events, so their records differ only in the header-bound identity. + // Per-record documents are written directly: the domain's one-fsync-per-put chain would dominate seeding, + // and the list endpoint rejects a corpus whose rows the cache does not serve. + const table = join(corpusRoot, 'storages', projectionCacheDomainSpec.name, 'sessions') + await mkdir(table, { recursive: true }) + await forEachConcurrently(written.sessions.length, async (rank) => { + const { header, anchor } = written.sessions[rank] as WrittenSession + const source = records.get(anchor) as CheckpointRecord + await writeFile(join(table, `${header.id}.json`), serializeRecord(projectionCacheDomainSpec.version, { + identity: { ...source.identity, createdAt: header.createdAt, cwd: header.cwd }, + rows: source.rows, + })) + }) + corpora.push(written.facts) + } + return { mode: 'seed', corpora, seedMs: performance.now() - started } +} + +async function measureList(root: string, expected: number): Promise { + const started = performance.now() + const { ctx, controller } = await mountHost(root) + try { + const booted = performance.now() + const signal = new AbortController().signal + const first = await controller.list({}, signal) + const firstDone = performance.now() + const repeated = await controller.list({}, signal) + const done = performance.now() + if (first.items.length !== expected || repeated.items.length !== expected) { + throw new Error(`Session list returned ${String(first.items.length)} of ${String(expected)} Sessions`) + } + return { + mode: 'list', + bootMs: booted - started, + firstMs: firstDone - booted, + repeatMs: done - firstDone, + items: first.items.length, + itemsWithProjections: first.items.filter(item => item.projections !== undefined).length, + memory: await memory(), + } + } finally { + await ctx.fiber.dispose() + } +} + +async function measureSearch(root: string): Promise { + const started = performance.now() + const { ctx, controller } = await mountHost(root) + try { + const booted = performance.now() + const signal = new AbortController().signal + const first = await controller.search({ query: SEARCH_QUERIES[0] }, signal) + const firstDone = performance.now() + const repeated = await controller.search({ query: SEARCH_QUERIES[1] }, signal) + const done = performance.now() + if (first.items.length === 0 || repeated.items.length === 0) { + throw new Error('Session search matched no synthetic Session') + } + return { + mode: 'search', + bootMs: booted - started, + firstMs: firstDone - booted, + repeatMs: done - firstDone, + firstItems: first.items.length, + repeatItems: repeated.items.length, + memory: await memory(), + } + } finally { + await ctx.fiber.dispose() + } +} + +async function measureFork(root: string, ranks: readonly number[]): Promise { + const started = performance.now() + const { ctx, controller } = await mountHost(root) + try { + const booted = performance.now() + const forks: ForkReport['forks'][number][] = [] + for (const rank of ranks) { + const forkStarted = performance.now() + const { sessionId } = await controller.fork({ sessionId: corpusSessionId(rank) }) + const forkMs = performance.now() - forkStarted + const child = ctx.sessions.get(sessionId) + if (child === undefined) throw new Error(`fork of rank ${String(rank)} did not publish its child`) + forks.push({ rank, sourceEvents: child.inheritedEventCount, forkMs }) + } + return { mode: 'fork', bootMs: booted - started, forks, memory: await memory() } + } finally { + await ctx.fiber.dispose() + } +} + +async function verify(root: string, count: number): Promise { + const { ctx } = await mountHost(root) + try { + const anchors: VerifyReport['anchors'][number][] = [] + for (let rank = 0; rank < count; rank++) { + const { anchor } = sessionShape(rank, count) + if (anchors.some(entry => entry.anchor === anchor)) continue + // Exact reads replay the log through Session validation. + const { events } = await ctx.sessionQuery.readSession(corpusSessionId(rank)) + anchors.push({ anchor, rank, events: events.length }) + } + return { mode: 'verify', anchors } + } finally { + await ctx.fiber.dispose() + } +} + +assertBuiltBenchmarkRuntime(import.meta.url, Object.fromEntries([ + '@deepseek-ai/dsh-api-session-controller', + '@deepseek-ai/dsh-session-persistence-jsonl', + '@deepseek-ai/dsh-session-query-sqlite', + '@deepseek-ai/dsh-session-projection-cache', +].map(name => [name, import.meta.resolve(name)]))) + +const [root, mode, ...rest] = process.argv.slice(2) +const numbers = rest.map(Number) +if (root === undefined || numbers.some(value => !Number.isSafeInteger(value) || value < 0)) { + throw new Error('usage: session-corpus.worker.js ') +} +let report: SessionCorpusReport +switch (mode) { + case 'seed': + report = await seed(root, numbers) + break + case 'list': + report = await measureList(root, numbers[0] ?? 0) + break + case 'search': + report = await measureSearch(root) + break + case 'fork': + report = await measureFork(root, numbers) + break + case 'verify': + report = await verify(root, numbers[0] ?? 0) + break + default: + throw new Error(`unknown Session corpus mode ${String(mode)}`) +} +process.stdout.write(JSON.stringify(report) + '\n') diff --git a/benchmarks/session-corpus/synthetic-corpus.ts b/benchmarks/session-corpus/synthetic-corpus.ts new file mode 100644 index 0000000000..de7519a28a --- /dev/null +++ b/benchmarks/session-corpus/synthetic-corpus.ts @@ -0,0 +1,416 @@ +/** Deterministic current-generation Session corpus whose length distribution follows `corpus-shape.ts`. */ + +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm/assistant-stream' +import { MessageId, ToolCallId } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' +import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionEventMap, SessionEventType, SessionHeader } from '@deepseek-ai/dsh-session' +import { + eventLines, + generationLogPath, + toHeaderLine, +} from '../../packages/session/session-persistence-jsonl/src/format.ts' +import { compressZstdFrame } from '../../packages/session/session-persistence-jsonl/src/zstd.ts' +import { anchorShape, sessionShape, type SessionShape } from './corpus-shape.ts' + +/** Time of every body's first event; Sessions are created in the preceding minutes. */ +const TIME_ZERO = 1_700_000_000_000 +/** Prime stride that permutes creation order independently of length rank. */ +const CREATION_STRIDE = 7_919 +/** Concurrent Session file writes while materializing a corpus. */ +const WRITE_CONCURRENCY = 8 +/** Measured local Sessions per project directory: 1,650 Sessions in 88 directories. */ +const SESSIONS_PER_PROJECT = 19 +/** Every sixth Session is a subagent child; the measured share is 288 of 1,650. */ +const CHILD_EVERY = 6 +/** Turn-one system prompt size; the measured median largest event is 21.9 KB. */ +const SYSTEM_PROMPT_CHARS = 20_000 +/** Characters per streamed text or reasoning delta; the measured mean is 3.9. */ +const DELTA_CHARS = 4 +/** Share of remaining payload characters by message slot. */ +const PAYLOAD_SHARE = { user: 0.1, reasoning: 0.2, text: 0.25, toolResult: 0.45 } as const +/** Distinct words in the synthetic vocabulary and total words in the shared text pool. */ +const VOCABULARY = { words: 8_192, poolWords: 2_000_000 } as const +/** Word ranks used by the measured searches: one frequent and one uncommon token. */ +export const SEARCH_QUERIES = [vocabularyWord(40), vocabularyWord(1_500)] as const + +/** Physical and logical facts of one written corpus. */ +export interface SyntheticCorpusFacts { + /** Logical Sessions. */ + readonly sessions: number + /** Distinct event bodies; Sessions of one anchor share one body. */ + readonly distinctBodies: number + /** Project directories. */ + readonly projects: number + /** Logical events across all Sessions. */ + readonly events: number + /** Decompressed JSONL bytes across all Sessions. */ + readonly logicalBytes: number + /** Stored Zstandard bytes across all Sessions. */ + readonly compressedBytes: number + /** Zstandard frames across all Sessions. */ + readonly frames: number +} + +/** One written Session. */ +export interface WrittenSession { + /** Stored header. */ + readonly header: SessionHeader + /** Measured anchor whose body the Session stores. */ + readonly anchor: number +} + +/** + * Identify one Session by length rank. + * @param rank - zero-based ascending length rank. + * @returns stable Session id. + */ +export function corpusSessionId(rank: number): SessionId { + return SessionId('bench-' + String(rank).padStart(5, '0')) +} + +function vocabularyWord(rank: number): string { + let value = rank + VOCABULARY.words + let word = '' + const letters = 'etaoinsrhdlucmfywgpbvkxqjz' + while (value > 0) { + word += letters[value % letters.length] + value = Math.floor(value / letters.length) + } + return word +} + +/** Mulberry32: a small deterministic generator for fixture construction only. */ +function random(seed: number): () => number { + let state = seed >>> 0 + return () => { + state = (state + 0x6D2B79F5) >>> 0 + let value = state + value = Math.imul(value ^ (value >>> 15), value | 1) + value ^= value + Math.imul(value ^ (value >>> 7), value | 61) + return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296 + } +} + +/** Zipf-distributed prose from which every message body takes a deterministic slice. */ +class TextPool { + private readonly text: string + + constructor() { + const next = random(1) + const cumulative: number[] = [] + let total = 0 + for (let rank = 1; rank <= VOCABULARY.words; rank++) { + total += 1 / rank + cumulative.push(total) + } + const words = Array.from({ length: VOCABULARY.words }, (_, rank) => vocabularyWord(rank)) + const parts: string[] = [] + for (let index = 0; index < VOCABULARY.poolWords; index++) { + const target = next() * total + let low = 0 + let high = cumulative.length - 1 + while (low < high) { + const middle = (low + high) >>> 1 + if ((cumulative[middle] as number) < target) low = middle + 1 + else high = middle + } + parts.push(words[low] as string, index % 13 === 12 ? '.\n' : ' ') + } + this.text = parts.join('') + } + + slice(next: () => number, length: number): string { + if (length <= 0) return '' + const span = Math.min(length, this.text.length) + const start = Math.floor(next() * (this.text.length - span)) + return this.text.slice(start, start + span) + } +} + +interface PayloadPlan { + readonly userChars: number + readonly reasoningChars: number + readonly textChars: number + readonly resultChars: number +} + +function planPayload(shape: SessionShape, toolSteps: number, payloadChars: number): PayloadPlan { + const replies = toolSteps + shape.turns + const textShare = toolSteps === 0 ? PAYLOAD_SHARE.text + PAYLOAD_SHARE.toolResult : PAYLOAD_SHARE.text + return { + userChars: Math.ceil((payloadChars * PAYLOAD_SHARE.user) / shape.turns), + reasoningChars: Math.ceil((payloadChars * PAYLOAD_SHARE.reasoning) / replies), + textChars: Math.ceil((payloadChars * textShare) / replies), + resultChars: toolSteps === 0 ? 0 : Math.ceil((payloadChars * PAYLOAD_SHARE.toolResult) / toolSteps), + } +} + +function streamChunks(content: readonly ContentBlock[], finish: 'stop' | 'tool-calls'): StreamChunk[] { + const chunks: StreamChunk[] = [] + content.forEach((block, index) => { + chunks.push({ type: 'block-start', index, blockType: block.type }) + if (block.type === 'text' || block.type === 'reasoning') { + const type = block.type === 'text' ? 'text-delta' : 'reasoning-delta' + for (let offset = 0; offset < block.text.length; offset += DELTA_CHARS) { + chunks.push({ type, index, text: block.text.slice(offset, offset + DELTA_CHARS) }) + } + } else if (block.type === 'tool-call') { + chunks.push({ type: 'tool-call-delta', index, id: block.id, name: block.name, argumentsDelta: block.arguments }) + } + chunks.push({ type: 'block-end', index, block }) + }) + chunks.push({ type: 'usage', usage: { inputTokens: 20_000, outputTokens: 500 } }) + chunks.push({ type: 'finish', reason: { kind: finish } }) + return chunks +} + +/** Raw log builder emitting the fields `Session.append` records, without its per-append validation. */ +class RawLog { + readonly events: SessionEvent[] = [] + readonly frameStarts = new Set() + + constructor(private readonly createdAt: number) {} + + get seq(): number { + return this.events.length + } + + /** Start a durable flush batch at the next event. */ + flush(): void { + this.frameStarts.add(this.events.length) + } + + append( + type: T, + data: SessionEventMap[T], + surface: { readonly surfaceOp?: 'append'; readonly sourceEventSeqs?: readonly number[] } = {}, + ): number { + const seq = this.events.length + this.events.push({ type, seq, time: this.createdAt + seq * 1_000, data, ...surface } as SessionEvent) + return seq + } +} + +/** Authors one Session with production stream compaction; replay validation belongs to the fixture test. */ +function authorEvents( + shape: SessionShape, + payloadChars: number, + pool: TextPool, + seed: number, +): { readonly events: SessionEvent[]; readonly frameStarts: ReadonlySet } { + const next = random(seed) + const toolSteps = Math.max(0, Math.ceil((shape.events - 6 * shape.turns - 1) / 5)) + const plan = planPayload(shape, toolSteps, payloadChars) + const log = new RawLog(TIME_ZERO) + for (let turn = 1; turn <= shape.turns; turn++) { + const turnSteps = Math.floor(toolSteps / shape.turns) + (turn <= toolSteps % shape.turns ? 1 : 0) + log.flush() + log.append('turn/start', { turn }) + log.append('step/start', { turn, step: 1 }) + if (turn === 1) log.append('system/message', { + turn, step: 1, + message: { + id: MessageId('system-head'), role: 'system', + content: [{ type: 'text', text: pool.slice(next, SYSTEM_PROMPT_CHARS) }], + source: { kind: 'system-prompt' }, + }, + }, { surfaceOp: 'append' }) + log.append('user/message', { + id: MessageId('prompt-' + String(turn)), role: 'user', + content: [{ type: 'text', text: pool.slice(next, plan.userChars) }], source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + for (let step = 1; step <= turnSteps + 1; step++) { + if (step > 1) log.append('step/start', { turn, step }) + const callId = step <= turnSteps ? ToolCallId(`call-${String(turn)}-${String(step)}`) : undefined + const content: ContentBlock[] = [ + { type: 'reasoning', text: pool.slice(next, plan.reasoningChars) }, + { type: 'text', text: pool.slice(next, plan.textChars) }, + ...callId === undefined ? [] : [{ + type: 'tool-call' as const, id: callId, name: 'read_file', + arguments: JSON.stringify({ path: `src/module-${String(turn)}-${String(step)}.ts` }), + }], + ] + const stream = new AssistantStreamAccumulator() + const time = TIME_ZERO + log.seq * 1_000 + for (const [index, chunk] of streamChunks(content, callId === undefined ? 'stop' : 'tool-calls').entries()) { + stream.push({ time: time + index, chunk }) + } + log.flush() + log.append('assistant/message', { + turn, step, + message: { + id: MessageId(`reply-${String(turn)}-${String(step)}`), role: 'assistant', content, + source: { kind: 'model', provider: 'bench', model: 'bench' }, + }, + stream: [...stream.snapshot()], + }, { surfaceOp: 'append' }) + if (callId !== undefined) { + log.flush() + const callSeq = log.append('tool/call', { + turn, step, callId, name: 'read_file', arguments: JSON.stringify({ path: `src/module-${String(turn)}-${String(step)}.ts` }), + }) + log.flush() + log.append('tool/result', { + turn, step, + message: { + id: MessageId('result-' + callId), role: 'tool', toolCallId: callId, isError: false, + source: { kind: 'tool', callId }, + content: [{ type: 'text', text: pool.slice(next, plan.resultChars) }], + }, + }, { surfaceOp: 'append', sourceEventSeqs: [callSeq] }) + } + log.append('step/end', { turn, step }) + } + log.append('turn/end', { turn, reason: { kind: 'completed' } }) + } + return { events: log.events, frameStarts: log.frameStarts } +} + +function frameTexts(events: readonly SessionEvent[], frameStarts: ReadonlySet): string[] { + const frames: string[] = [] + let start = 0 + for (let index = 1; index <= events.length; index++) { + if (index === events.length || frameStarts.has(index)) { + frames.push(eventLines(events.slice(start, index)) + '\n') + start = index + } + } + return frames +} + +function headerLine(header: SessionHeader): string { + return JSON.stringify(toHeaderLine(header)) + '\n' +} + +/** One compressed event body shared by every Session of one measured anchor. */ +interface AnchorBody { + readonly frames: Buffer + readonly frameCount: number + readonly events: number + readonly logicalBytes: number +} + +/** One corpus written from authored anchor bodies. */ +export interface WrittenCorpus { + /** Physical and logical totals. */ + readonly facts: SyntheticCorpusFacts + /** Every stored Session in rank order. */ + readonly sessions: readonly WrittenSession[] +} + +/** + * Build the stored header of one corpus rank. + * @param rank - zero-based ascending length rank. + * @param count - corpus size. + * @returns the rank's header; every sixth rank is a subagent child of the preceding rank. + */ +export function corpusHeader(rank: number, count: number): SessionHeader { + if (count % CREATION_STRIDE === 0) throw new Error('corpus size must be coprime with the creation stride') + const child = rank % CHILD_EVERY === CHILD_EVERY - 1 + // Creation order is a fixed permutation of length rank, so list order does not track length. + const createdAt = TIME_ZERO - (((rank * CREATION_STRIDE) % count) + 1) * 60_000 + return { + version: SESSION_FORMAT_VERSION, + id: corpusSessionId(rank), + createdAt, + cwd: `/bench/project-${String(rank % Math.ceil(count / SESSIONS_PER_PROJECT))}`, + isSeeded: false, + ...child + ? { origin: 'subagent' as const, parentSession: corpusSessionId(rank - 1), delegationDepth: 1 } + : { delegationDepth: 0 }, + } +} + +/** + * Run one asynchronous file operation per index with bounded concurrency. + * @param count - number of indexes, starting at zero. + * @param operation - operation for one index. + */ +export async function forEachConcurrently(count: number, operation: (index: number) => Promise): Promise { + let next = 0 + const worker = async (): Promise => { + for (let index = next++; index < count; index = next++) await operation(index) + } + await Promise.all(Array.from({ length: WRITE_CONCURRENCY }, () => worker())) +} + +/** Authors each anchor body once and writes corpora that share those bodies. */ +export class SyntheticCorpusWriter { + private readonly pool = new TextPool() + private readonly bodies = new Map() + /** Stored bytes per payload character, measured by the previous anchor's final secant step. */ + private bytesPerChar = 4 + + /** + * Author one anchor's body; its body bytes alone reach the anchor's logical-byte target. + * @param anchor - measured anchor index. + * @returns the authored events, which the writer does not retain. + */ + async author(anchor: number): Promise { + const shape = anchorShape(anchor) + // Secant steps from below: stored bytes grow linearly with payload characters. + let payloadChars = 0 + let previous: { readonly payloadChars: number; readonly bytes: number } | undefined + for (let attempt = 0; attempt < 8; attempt++) { + const authored = authorEvents(shape, payloadChars, this.pool, anchor + 1) + if (authored.events.length < shape.events) { + throw new Error(`corpus anchor ${String(anchor)} authored ${String(authored.events.length)} of ${String(shape.events)} events`) + } + const frames = frameTexts(authored.events, authored.frameStarts) + const bytes = frames.reduce((sum, text) => sum + Buffer.byteLength(text), 0) + if (bytes < shape.logicalBytes) { + if (previous !== undefined) this.bytesPerChar = (bytes - previous.bytes) / (payloadChars - previous.payloadChars) + previous = { payloadChars, bytes } + const bytesPerChar = this.bytesPerChar + // Every message rounds its share up, so each step adds at least one character per event. + payloadChars += Math.ceil((1.01 * (shape.logicalBytes - bytes)) / bytesPerChar) + shape.events + continue + } + const compressed = await Promise.all(frames.map(text => compressZstdFrame(text))) + this.bodies.set(anchor, { + frames: Buffer.concat(compressed), frameCount: frames.length, events: authored.events.length, logicalBytes: bytes, + }) + return authored.events + } + throw new Error(`corpus anchor ${String(anchor)} did not reach ${String(shape.logicalBytes)} logical bytes`) + } + + /** + * Write every rank of one corpus from authored bodies. + * @param root - JSONL persistence root. + * @param count - corpus size; the length distribution is sampled at this many ranks. + * @returns totals and the stored headers. + */ + async writeCorpus(root: string, count: number): Promise { + const totals = { events: 0, logicalBytes: 0, compressedBytes: 0, frames: 0 } + const sessions: WrittenSession[] = [] + await forEachConcurrently(count, async (rank) => { + const { anchor } = sessionShape(rank, count) + const body = this.bodies.get(anchor) + if (body === undefined) throw new Error(`corpus anchor ${String(anchor)} was not authored`) + const header = corpusHeader(rank, count) + const line = headerLine(header) + const physical = Buffer.concat([await compressZstdFrame(line), body.frames]) + const path = generationLogPath(root, header.cwd, header.id, SESSION_FORMAT_VERSION, 'zstd') + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, physical) + sessions[rank] = { header, anchor } + totals.events += body.events + totals.logicalBytes += Buffer.byteLength(line) + body.logicalBytes + totals.compressedBytes += physical.byteLength + totals.frames += body.frameCount + 1 + }) + return { + facts: { + sessions: count, + projects: Math.ceil(count / SESSIONS_PER_PROJECT), + distinctBodies: new Set(sessions.map(session => session.anchor)).size, + ...totals, + }, + sessions, + } + } +} diff --git a/benchmarks/tsdown.config.ts b/benchmarks/tsdown.config.ts index 18e2928c0e..4d21a78117 100644 --- a/benchmarks/tsdown.config.ts +++ b/benchmarks/tsdown.config.ts @@ -47,6 +47,13 @@ export default defineConfig([ clean: true, tsconfig: 'tsconfig.host.json', }, + { + ...shared, + entry: { 'session-corpus.worker': 'session-corpus/session-corpus.worker.ts' }, + outDir: '.dsh-build/session-corpus', + clean: true, + tsconfig: 'tsconfig.host.json', + }, { ...shared, entry: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 14a4b54546..ebe0751833 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -891,6 +891,15 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../packages/core/session + '@deepseek-ai/dsh-session-format': + specifier: workspace:* + version: link:../packages/session/session-format + '@deepseek-ai/dsh-session-format-catalog': + specifier: workspace:* + version: link:../packages/session/session-format-catalog + '@deepseek-ai/dsh-session-format-v3-to-v4': + specifier: workspace:* + version: link:../packages/session/session-format-v3-to-v4 '@deepseek-ai/dsh-session-persistence': specifier: workspace:* version: link:../packages/session/session-persistence @@ -900,9 +909,15 @@ importers: '@deepseek-ai/dsh-session-projection': specifier: workspace:* version: link:../packages/session/session-projection + '@deepseek-ai/dsh-session-projection-cache': + specifier: workspace:* + version: link:../packages/session/session-projection-cache '@deepseek-ai/dsh-session-query': specifier: workspace:* version: link:../packages/session-query/session-query + '@deepseek-ai/dsh-session-query-sqlite': + specifier: workspace:* + version: link:../packages/session-query/session-query-sqlite '@deepseek-ai/dsh-session-stats': specifier: workspace:* version: link:../packages/session/session-stats @@ -912,6 +927,15 @@ importers: '@deepseek-ai/dsh-session-turn-outline': specifier: workspace:* version: link:../packages/session/session-turn-outline + '@deepseek-ai/dsh-storage': + specifier: workspace:* + version: link:../packages/storage/storage + '@deepseek-ai/dsh-storage-domain': + specifier: workspace:* + version: link:../packages/storage/storage-domain + '@deepseek-ai/dsh-storage-json': + specifier: workspace:* + version: link:../packages/storage/storage-json '@deepseek-ai/dsh-subagent': specifier: workspace:* version: link:../packages/subagent/subagent diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index db3315e074..b4a9ca194f 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -464,10 +464,10 @@ describe('CI workflow', () => { }) }) - it('bounds the complete benchmark job to fifteen minutes', () => { + it('bounds the complete benchmark job to thirty minutes', () => { const benchmark = workflowJob(loadWorkflow('.github/workflows/ci.yml'), 'node-24-bench') - expect(benchmark['timeout-minutes']).toBe(15) + expect(benchmark['timeout-minutes']).toBe(30) expect(benchmark.steps).toContainEqual({ name: 'Run performance benchmarks', env: { DSH_GATE_VERBOSE: '1' }, From 6200ffe8c78112b747547efcf0a56f83e7cc6f08 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 28 Sep 2026 06:11:53 -0700 Subject: [PATCH 08/49] fix(web): allow one-task Creator entry with Coding Tools off --- apps/web/tests/agent-preset-selection.e2e.ts | 10 +-- .../agent-preset-selection/header.expected.md | 2 +- .../agent-preset-selection/hero.expected.md | 1 + .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 4 +- packages/client/ui-agent-preset/README.zh.md | 4 +- .../src/client/AgentPresetSeat.module.css | 6 +- .../src/client/AgentPresetSeat.tsx | 15 ++-- .../src/client/CreatePluginMenuItem.tsx | 19 ++--- .../ui-agent-preset/src/client/index.ts | 16 ++-- .../ui-agent-preset/src/client/seat-store.ts | 19 ++++- .../tests/apply.client.spec.ts | 51 +++++++++--- .../tests/components.client.spec.tsx | 72 +++++++++++++++++ .../create-plugin-menu-item.client.spec.tsx | 36 ++------- .../tests/settings-store.client.spec.ts | 79 +++++++++++++++++++ .../src/client/PluginManagerPage.tsx | 2 +- .../tests/components.client.spec.tsx | 3 + snapshots/web/minimal-preset/ui.expected.md | 2 +- 18 files changed, 262 insertions(+), 83 deletions(-) diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 48f1722d7c..500bdd82c3 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -269,7 +269,7 @@ describe('web e2e: agent-preset selection', () => { it('names every preset and what it is for', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-menu')) - await page.getByRole('button', { name: 'Standard mode' }).click() + await page.getByRole('button', { name: 'Standard mode', exact: true }).click() const menu = page.getByRole('menu') await menu.waitFor({ timeout: 10_000 }) @@ -285,7 +285,7 @@ describe('web e2e: agent-preset selection', () => { it('applies the staged pick to the blank session, and the host honors it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-stage')) - await page.getByRole('button', { name: 'Standard mode' }).click() + await page.getByRole('button', { name: 'Standard mode', exact: true }).click() await page.getByRole('menuitem', { name: /Minimal mode/ }).click() // The chip stages; the blank session the workspace connect produced is @@ -296,7 +296,7 @@ describe('web e2e: agent-preset selection', () => { }) it('omits eagerly failed presets from selection and retains their diagnostics', async () => { - await page.getByRole('button', { name: 'Minimal mode' }).click() + await page.getByRole('button', { name: 'Minimal mode', exact: true }).click() await page.getByRole('menu').waitFor() expect(await page.getByRole('menuitem', { name: /Refusing mode/ }).count()).toBe(0) await page.keyboard.press('Escape') @@ -329,7 +329,7 @@ describe('web e2e: agent-preset selection', () => { // against its list row, so a row that never reprojected the first switch // answers "already standard" and sends nothing — and restores the catalog // instead of leaving the session reading the narrower composition. - await page.getByRole('button', { name: 'Minimal mode' }).click() + await page.getByRole('button', { name: 'Minimal mode', exact: true }).click() await page.getByRole('menuitem', { name: /^Standard mode/ }).first().click() await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('standard') @@ -373,7 +373,7 @@ describe('web e2e: agent-preset selection', () => { await reopened.getByRole('button', { name: 'New task default: Minimal mode' }).waitFor({ timeout: 10_000 }) await reopened.getByRole('button', { name: 'Close' }).last().click() await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal') - await page.getByRole('button', { name: 'Minimal mode' }).waitFor({ timeout: 10_000 }) + await page.getByRole('button', { name: 'Minimal mode', exact: true }).waitFor({ timeout: 10_000 }) }) it('labels a resumed session with the preset it was created under', async () => { diff --git a/apps/web/tests/expected/agent-preset-selection/header.expected.md b/apps/web/tests/expected/agent-preset-selection/header.expected.md index 81cc3c1175..5880f8c97d 100644 --- a/apps/web/tests/expected/agent-preset-selection/header.expected.md +++ b/apps/web/tests/expected/agent-preset-selection/header.expected.md @@ -1,5 +1,5 @@ - navigation "Session hierarchy": Seeded turn - button "1 subagent" -- text: Minimal mode +- 'button "Mode details: Minimal mode"': Minimal mode - button "More actions" - button "Open right sidebar" diff --git a/apps/web/tests/expected/agent-preset-selection/hero.expected.md b/apps/web/tests/expected/agent-preset-selection/hero.expected.md index 2fe7ad2c8f..58b292e0d8 100644 --- a/apps/web/tests/expected/agent-preset-selection/hero.expected.md +++ b/apps/web/tests/expected/agent-preset-selection/hero.expected.md @@ -1,2 +1,3 @@ - button "Choose workspace": workspace - button "Standard mode" +- 'button "Mode details: Standard mode"' diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 66129e14b6..11cb1aeee2 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: b3ad7867f23dad1c /deepseek-ai-dsh-client-ui-agent-preset/use-this-package: - en: a44127eb01ad2ff6 - zh: d11935da1782d5a5 + en: d778cf8ed737f5f8 + zh: 3cf1a1d9f8c14d38 /deepseek-ai-dsh-client-ui-agent-preset/understand-the-implementation: en: 7813f0063c15e241 zh: 6eabc242edfc45ec diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 852db2158b..957944bf3e 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -27,11 +27,11 @@ Choose Agent presets and the new-task default in Web, read what each mode does a Settings shows the built-in and custom card groups with default highlighting and card-body selection; a group without presets is omitted, except the custom group, which keeps its Creator entry on screen. Every card offers “View configuration”, which opens the preset's declared plugin list as read-only YAML in the Loader's own dialect (`!!js` conditions included); a failed preset stays readable because its diagnostic points into that YAML. Escape closes only the viewer and returns focus to its card; leaving Settings clears the viewer, and a late read does not reopen it. The page edits nothing: the Creator entry starts a Creator-mode task that authors or overrides a preset as a bundle, offered while the `cordis` preset is on the roster and a conversation flow exists. -Coding Tools in General Settings decide whether a mode can be chosen at all: with them off the new-session picker disappears and the cards refuse selection, while the saved default keeps composing new tasks. Choosing a healthy default also synchronizes the blank session on the current new-task surface. Creator starts a new task using the `cordis` preset. +Coding Tools in General Settings control ordinary preset selection: with them off the new-session picker disappears and the cards refuse selection, while the saved default keeps composing new tasks. Choosing a healthy default also synchronizes the blank session on the current new-task surface. Creator starts a new-task flow using the `cordis` preset. Known shipped presets offer mode details and usage examples in a read-only dialog. Its tabs preserve each page's scroll position; closing returns focus to the opening action. Help does not change the new-task default. The default badge replaces the card's group badge, and the preset id appears beside the title. Guide copy and examples belong to this package. -The new-task picker and session header also open the same read-only help. The Plugins page’s “Add plugin” menu offers “Let the agent create a plugin”. Choosing it closes the menu and opens the existing Creator task flow without submitting a message or replacing a draft. The item stays in place while the roster loads or refreshes, and explains why it is disabled when Creator mode is unavailable or Coding Tools are off. The optional slot contribution disappears with its owner. +The new-task picker and session header also open the same read-only help. The Plugins page’s “Add plugin” menu offers “Let the agent create a plugin”, including when Coding Tools are off. Choosing it closes the menu and stages Creator for one task without submitting a message, replacing a draft, or changing global preferences. With Coding Tools off, the new-task page shows only a read-only Creator label and help; it offers no preset switching. The next ordinary task uses the saved default. The menu item stays in place while the roster loads or refreshes, and explains why it is disabled when Creator mode is unavailable. The optional slot contribution disappears with its owner. ## Understand the implementation diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index baa8380f96..f4d8474c41 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -27,11 +27,11 @@ kind: "package-reference" 设置页显示内置与自定义卡片分组、默认项高亮和点击卡片选择;没有 preset 的分组不显示,但自定义分组会保留其创造入口。每张卡片都提供「查看配置」,以只读 YAML 打开该 preset 声明的插件列表,使用 Loader 自己的方言(含 `!!js` 条件);加载失败的 preset 同样可读,因为其诊断信息正指向这份 YAML。Escape 只关闭查看器并将焦点还给卡片;离开设置页会清空查看器,迟到的读取结果不会重新打开它。本页不编辑任何内容:创造入口启动一个创造模式任务,以 bundle 形式创建或覆盖 preset;当 `cordis` preset 在列表中且存在会话流程时提供。 -通用设置中的代码工作工具决定能否选择模式:关闭后新会话选择器消失、卡片拒绝选择,而已保存的默认值继续用于新任务。选择健康定义作为默认值也会同步当前新任务页面的空白会话。Creator 入口开启一个使用 `cordis` preset 的新任务。 +通用设置中的代码工作工具控制普通预设选择:关闭后新会话选择器消失、卡片拒绝选择,而已保存的默认值继续用于新任务。选择健康定义作为默认值也会同步当前新任务页面的空白会话。Creator 入口进入使用 `cordis` preset 的新任务流程。 已知的内置预设提供只读的模式说明与使用示例对话框。各页签保留各自的滚动位置;关闭后焦点回到打开它的操作。帮助不会改变新任务默认值。默认徽标取代卡片的分组徽标,预设 id 显示在标题旁。指南文案与示例归本包所有。 -新任务选择器和任务标题也可打开同一份只读说明。插件页的「添加插件」菜单提供「让 Agent 创建插件」。选择后关闭菜单并进入现有创造模式任务流程,不发送消息,也不替换草稿。预设列表加载或刷新时菜单项保持原位;创造模式不可用或代码工作工具关闭时禁用,并解释原因。菜单项由可选插槽贡献,随所属插件卸载而移除。 +新任务选择器和任务标题也可打开同一份只读说明。插件页的「添加插件」菜单提供「让 Agent 创建插件」,代码工作工具关闭时也可使用。选择后关闭菜单,为一次任务暂存创造模式,不发送消息、不替换草稿,也不修改全局偏好。代码工作工具关闭时,新任务页仅显示只读的创造模式标签和说明,不提供预设切换;之后的普通任务继续使用已保存的默认值。预设列表加载或刷新时菜单项保持原位;创造模式不可用时禁用,并解释原因。菜单项由可选插槽贡献,随所属插件卸载而移除。 ## 理解实现 diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css index eaa939d161..da8988db87 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css @@ -67,7 +67,7 @@ cursor: pointer; } -.seat:not(:disabled):hover, +button.seat:not(:disabled):hover, .seat[aria-expanded='true'] { background: var(--dsw-alias-interactive-bg-hover); } @@ -77,6 +77,10 @@ color: var(--dsw-alias-label-quaternary); } +.readOnly { + cursor: default; +} + .seatIcon { flex: none; color: var(--dsw-alias-label-primary); diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx index a039b5b841..29bf6ec0da 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx @@ -75,8 +75,8 @@ export type AgentPresetSeatProps = /** * Render the new-session agent-preset chip. * @param props - composed slot props. - * @returns The chip, or null outside the main view, with Developer tools off, - * or before the roster provides a preset choice. + * @returns the picker, read-only Creator details with Coding Tools off, or null + * outside the main view and before a preset choice is available. */ export function AgentPresetSeat({ sessionId, useSessionRetainInfo, load, select, introduced, useAgentPresetSeat, useDeveloperTools, t, @@ -138,7 +138,7 @@ export function AgentPresetSeat({ // Nothing to choose between: the deployment composes no presets and every // session shares the host composition. - if (!main || !developerTools || !ready) return null + if (!main || !ready || (!developerTools && state.current !== 'cordis')) return null // One wrapper span: the chip is a flex row with a gap, so loose character // spans would each pick up the gap between them. @@ -162,7 +162,7 @@ export function AgentPresetSeat({ return (
- { setOpen(false) }} items={state.options.map((option) => { @@ -214,7 +214,12 @@ export function AgentPresetSeat({ )} - /> + /> : ( + + + {shownLabel} + + )} {chosen === undefined ? null : ( } render(inMenu ? : ) - return { developerTools, agentPresets, load, startCreatorDraft, onDismiss, calls } + return { agentPresets, load, startCreatorDraft, onDismiss, calls } } function item(): HTMLElement { @@ -67,7 +65,7 @@ describe('Create plugin menu item', () => { }) it('supports keyboard selection through the shared menu and closes before navigation', () => { - const actions = view(true, {}, true) + const actions = view({}, true) const trigger = item() trigger.focus() fireEvent.keyDown(trigger, { key: 'Tab' }) @@ -75,30 +73,8 @@ describe('Create plugin menu item', () => { expect(screen.queryByRole('menu')).toBeNull() }) - it('explains disabled Developer tools and enables the same entry after the preference changes', () => { - const actions = view(false) - expect(item()).toHaveProperty('disabled', true) - expect(item().textContent).toContain(en.enableDevToolsToCreate) - fireEvent.click(item()) - expect(actions.calls).toEqual([]) - - act(() => { actions.developerTools.set(true) }) - expect(item()).toHaveProperty('disabled', false) - expect(item().textContent).toContain(en.createPluginDescription) - fireEvent.click(item()) - expect(actions.calls).toEqual(['dismiss', 'start']) - }) - - it('honors Developer tools turning off while the menu is open', () => { - const actions = view() - act(() => { actions.developerTools.set(false) }) - expect(item()).toHaveProperty('disabled', true) - fireEvent.click(item()) - expect(actions.calls).toEqual([]) - }) - it.each(['idle', 'loading', 'error', 'unavailable'] as const)('keeps a disabled entry while the roster is %s', (status) => { - const actions = view(true, { status }) + const actions = view({ status }) expect(item()).toHaveProperty('disabled', true) const description = status === 'idle' || status === 'loading' ? en.createPluginChecking : en.createPluginUnavailable expect(item().textContent).toContain(description) @@ -107,7 +83,7 @@ describe('Create plugin menu item', () => { }) it('explains why the entry is disabled when the deployment has no cordis preset', () => { - const actions = view(true, { options: [{ id: 'standard' }] }) + const actions = view({ options: [{ id: 'standard' }] }) expect(item()).toHaveProperty('disabled', true) expect(item().textContent).toContain(en.createPluginMissing) fireEvent.click(item()) @@ -126,7 +102,7 @@ describe('Create plugin menu item', () => { }) it('keeps the same second menu item through ready, loading, and ready states', () => { - const actions = view(true, {}, true) + const actions = view({}, true) const menu = screen.getByRole('menu') const before = screen.getAllByRole('menuitem') const original = item() diff --git a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts index 30e6d41c91..2c703e7cb6 100644 --- a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts @@ -509,6 +509,85 @@ describe('the new-session chip controller', () => { }) }) + it('keeps a Creator stage without a workspace, consumes it once, and leaves later tasks on the default', async () => { + const writes: Recorded[] = [] + const developerTools = createSnapshotStore(false) + let current: SeatSession | undefined + const controller = chip([...ROSTER, { id: 'cordis', isDefault: false }], () => current, { writes, developerTools }) + controller.stageCreator() + controller.introduced() + await controller.load() + await controller.apply() + expect(controller.store.getSnapshot()).toMatchObject({ current: 'cordis', introduce: false }) + expect(writes).toEqual([]) + + current = { id: 'creator' as SessionId, blank: true, projectionValues: { agentPreset: 'standard' } } + await controller.apply() + expect(writes).toEqual([{ ns: 'select', ops: 'cordis' }]) + expect(controller.store.getSnapshot().current).toBe('cordis') + + current = { id: 'ordinary' as SessionId, blank: true, projectionValues: { agentPreset: 'standard' } } + await controller.load() + await controller.apply() + expect(writes).toEqual([{ ns: 'select', ops: 'cordis' }]) + expect(controller.store.getSnapshot().current).toBe('standard') + expect(developerTools.getSnapshot()).toBe(false) + }) + + it('does not change a started session or carry its rejected Creator stage into another task', async () => { + const writes: Recorded[] = [] + let current: SeatSession = { id: 'running' as SessionId, blank: false, projectionValues: { agentPreset: 'standard' } } + const controller = chip(ROSTER, () => current, { writes, developerTools: createSnapshotStore(false) }) + await controller.load() + controller.stageCreator() + await controller.apply() + expect(writes).toEqual([]) + + current = { id: 'ordinary' as SessionId, blank: true, projectionValues: { agentPreset: 'standard' } } + await controller.apply() + expect(writes).toEqual([]) + expect(controller.store.getSnapshot().current).toBe('standard') + }) + + it('does not let an ordinary pick inherit a pending Creator stage exemption', async () => { + const writes: Recorded[] = [] + let current: SeatSession | undefined = undefined + const controller = chip(ROSTER, () => current, { writes, developerTools: createSnapshotStore(false) }) + await controller.load() + controller.stageCreator() + await controller.select('cordis') + expect(controller.store.getSnapshot().current).toBe('standard') + + current = { id: 'blank' as SessionId, blank: true, projectionValues: { agentPreset: 'standard' } } + await controller.apply() + expect(writes).toEqual([]) + }) + + it('keeps a Settings default synchronization gated after an explicit Creator stage', async () => { + const writes: Recorded[] = [] + const current: SeatSession = { id: 'blank' as SessionId, blank: true, projectionValues: { agentPreset: 'standard' } } + const controller = chip(ROSTER, current, { writes, developerTools: createSnapshotStore(false) }) + await controller.load() + controller.stageCreator() + await controller.syncBlankSession(current.id, 'minimal') + await controller.apply() + expect(writes).toEqual([]) + expect(controller.store.getSnapshot().current).toBe('standard') + }) + + it('rechecks a Creator stage made while its roster request was pending', async () => { + const reply = Promise.withResolvers>() + const developerTools = createSnapshotStore(false) + const controller = chip(ROSTER, undefined, { developerTools, list: () => reply.promise }) + const loaded = controller.load() + controller.stageCreator() + controller.introduced() + reply.resolve({ ok: true, value: { presets: [...ROSTER, { id: 'cordis', isDefault: false }] } }) + await loaded + expect(controller.store.getSnapshot()).toMatchObject({ current: 'cordis', introduce: false }) + expect(developerTools.getSnapshot()).toBe(false) + }) + it('leaves the introduction cue to the chip while Developer tools stay on', async () => { const developerTools = createSnapshotStore(true) const session = { diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 6bf54f812c..ad7c017045 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -578,7 +578,7 @@ function CapabilitiesSummary({ rows, resolveText, t }: { }): ReactNode { const seen = new Set() const capabilities = rows.flatMap((row) => { - const metadataTitle = row.meta?.title === undefined ? undefined : resolveText(row.meta.title).trim() || undefined + const metadataTitle = row.meta?.title === undefined ? undefined : resolveText(row.meta.title).trim() const title = metadataTitle === row.moduleName || metadataTitle === row.rowId ? undefined : metadataTitle const description = row.meta?.description === undefined ? undefined : resolveText(row.meta.description).trim() || undefined if (title === undefined && description === undefined) return [] diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index dc977431cc..ec9b88a8c8 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -219,6 +219,9 @@ describe('PluginManagerPage', () => { expect(screen.queryByRole('menuitem', { name: 'Create a plugin' })).toBeNull() const add = screen.getByRole('button', { name: en.chooseAddMethod }) add.focus() + expect(fireEvent.keyDown(add, { key: 'Tab' })).toBe(true) + expect(screen.queryByRole('menu')).toBeNull() + expect(actions.openInstall).not.toHaveBeenCalled() fireEvent.keyDown(add, { key: 'ArrowDown' }) const install = screen.getByRole('menuitem', { name: new RegExp(en.installExisting) }) const create = screen.getByRole('menuitem', { name: 'Create a plugin' }) diff --git a/snapshots/web/minimal-preset/ui.expected.md b/snapshots/web/minimal-preset/ui.expected.md index e489e916e6..f29261d70e 100644 --- a/snapshots/web/minimal-preset/ui.expected.md +++ b/snapshots/web/minimal-preset/ui.expected.md @@ -1,6 +1,6 @@ - banner: - navigation "Session hierarchy": Use the bash tool to - - text: Minimal mode + - 'button "Mode details: Minimal mode"': Minimal mode - button "More actions" - button "Open right sidebar" - tablist: From 7d88fc9b2b79fc6fffeafc7eeb02fe682ae5c3db Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 28 Sep 2026 06:22:01 -0700 Subject: [PATCH 09/49] refactor(web): limit plugin UX changes to add and create flows --- apps/web/tests/agent-preset-selection.e2e.ts | 10 +- .../agent-preset-selection/header.expected.md | 2 +- .../agent-preset-selection/hero.expected.md | 1 - docs/subsystems/slots.i18n.yaml | 4 +- docs/subsystems/slots.md | 4 +- docs/subsystems/slots.zh.md | 4 +- .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 2 +- packages/client/ui-agent-preset/README.zh.md | 2 +- .../src/client/AgentPresetLabel.module.css | 26 +- .../src/client/AgentPresetLabel.tsx | 39 +-- .../src/client/AgentPresetSeat.module.css | 39 +-- .../src/client/AgentPresetSeat.tsx | 33 +-- .../tests/components.client.spec.tsx | 247 ++++++------------ .../client/ui-plugin-manager/README.i18n.yaml | 12 +- packages/client/ui-plugin-manager/README.md | 6 +- .../client/ui-plugin-manager/README.zh.md | 6 +- .../src/client/PluginManagerPage.module.css | 83 ------ .../src/client/PluginManagerPage.tsx | 98 +------ .../ui-plugin-manager/src/client/index.ts | 3 +- .../ui-plugin-manager/src/client/locales.ts | 14 - .../src/client/manager-store.ts | 3 - .../src/client/slot-contract.ts | 8 - .../tests/browser-plugin.client.spec.tsx | 27 +- .../tests/components.client.spec.tsx | 99 +------ .../tests/manager-store.client.spec.ts | 6 - packages/client/ui-schedule/README.i18n.yaml | 4 +- packages/client/ui-schedule/README.md | 2 - packages/client/ui-schedule/README.zh.md | 2 - packages/client/ui-schedule/package.json | 1 - .../src/client/SchedulePluginUsage.module.css | 53 ---- .../src/client/SchedulePluginUsage.tsx | 76 ------ .../client/ui-schedule/src/client/index.ts | 14 - .../src/client/task-manager-locales.ts | 18 -- .../tests/browser-plugin.client.spec.ts | 42 +-- .../schedule-plugin-usage.client.spec.tsx | 97 ------- packages/client/ui-schedule/tsconfig.json | 3 - .../src/client/slot-catalog.ts | 59 +---- pnpm-lock.yaml | 3 - snapshots/web/minimal-preset/ui.expected.md | 2 +- 40 files changed, 163 insertions(+), 995 deletions(-) delete mode 100644 packages/client/ui-schedule/src/client/SchedulePluginUsage.module.css delete mode 100644 packages/client/ui-schedule/src/client/SchedulePluginUsage.tsx delete mode 100644 packages/client/ui-schedule/tests/schedule-plugin-usage.client.spec.tsx diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 500bdd82c3..48f1722d7c 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -269,7 +269,7 @@ describe('web e2e: agent-preset selection', () => { it('names every preset and what it is for', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-menu')) - await page.getByRole('button', { name: 'Standard mode', exact: true }).click() + await page.getByRole('button', { name: 'Standard mode' }).click() const menu = page.getByRole('menu') await menu.waitFor({ timeout: 10_000 }) @@ -285,7 +285,7 @@ describe('web e2e: agent-preset selection', () => { it('applies the staged pick to the blank session, and the host honors it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-stage')) - await page.getByRole('button', { name: 'Standard mode', exact: true }).click() + await page.getByRole('button', { name: 'Standard mode' }).click() await page.getByRole('menuitem', { name: /Minimal mode/ }).click() // The chip stages; the blank session the workspace connect produced is @@ -296,7 +296,7 @@ describe('web e2e: agent-preset selection', () => { }) it('omits eagerly failed presets from selection and retains their diagnostics', async () => { - await page.getByRole('button', { name: 'Minimal mode', exact: true }).click() + await page.getByRole('button', { name: 'Minimal mode' }).click() await page.getByRole('menu').waitFor() expect(await page.getByRole('menuitem', { name: /Refusing mode/ }).count()).toBe(0) await page.keyboard.press('Escape') @@ -329,7 +329,7 @@ describe('web e2e: agent-preset selection', () => { // against its list row, so a row that never reprojected the first switch // answers "already standard" and sends nothing — and restores the catalog // instead of leaving the session reading the narrower composition. - await page.getByRole('button', { name: 'Minimal mode', exact: true }).click() + await page.getByRole('button', { name: 'Minimal mode' }).click() await page.getByRole('menuitem', { name: /^Standard mode/ }).first().click() await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('standard') @@ -373,7 +373,7 @@ describe('web e2e: agent-preset selection', () => { await reopened.getByRole('button', { name: 'New task default: Minimal mode' }).waitFor({ timeout: 10_000 }) await reopened.getByRole('button', { name: 'Close' }).last().click() await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal') - await page.getByRole('button', { name: 'Minimal mode', exact: true }).waitFor({ timeout: 10_000 }) + await page.getByRole('button', { name: 'Minimal mode' }).waitFor({ timeout: 10_000 }) }) it('labels a resumed session with the preset it was created under', async () => { diff --git a/apps/web/tests/expected/agent-preset-selection/header.expected.md b/apps/web/tests/expected/agent-preset-selection/header.expected.md index 5880f8c97d..81cc3c1175 100644 --- a/apps/web/tests/expected/agent-preset-selection/header.expected.md +++ b/apps/web/tests/expected/agent-preset-selection/header.expected.md @@ -1,5 +1,5 @@ - navigation "Session hierarchy": Seeded turn - button "1 subagent" -- 'button "Mode details: Minimal mode"': Minimal mode +- text: Minimal mode - button "More actions" - button "Open right sidebar" diff --git a/apps/web/tests/expected/agent-preset-selection/hero.expected.md b/apps/web/tests/expected/agent-preset-selection/hero.expected.md index 58b292e0d8..2fe7ad2c8f 100644 --- a/apps/web/tests/expected/agent-preset-selection/hero.expected.md +++ b/apps/web/tests/expected/agent-preset-selection/hero.expected.md @@ -1,3 +1,2 @@ - button "Choose workspace": workspace - button "Standard mode" -- 'button "Mode details: Standard mode"' diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index 1757552737..aec206af8c 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/slots.md /web-client-slots: - en: 593f9ed34e925132 - zh: f82611667c858768 + en: 8d33b164230344fa + zh: 13a930ab96280744 /web-client-slots/declaration-and-lifecycle: en: f2d2c0d3b8f14ec7 zh: 441ed13ff3f81d2a diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index bf4df4d478..42da307ddd 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -8,7 +8,7 @@ This page documents slot ownership, component inputs, extension APIs, and the sh `plugins.bundle.config` supplies bundle detail configuration, keyed by npm package name. `plugins.bundle.activation` renders optional guidance after user-requested enablement, with owner callbacks to dismiss or open that bundle’s details. `conversation.input.activity` supplies one action between the model selector and Send, with toolbar expansion released on unmount. -The plugin catalog declares `plugins.add.actions`, a root-scoped list for `MenuItemButton` rows after installation in the Add plugin menu. Its owner supplies `onDismiss()` to close the menu before starting an action. It also declares `plugins.bundle.usage`, a root-scoped keyed slot for usage guidance before component details. The latter dispatches the npm package name and supplies `{ pkg: PluginPackageRef }`; its occupant decides which actions apply to the bundle's installed and enabled state. Both follow the owning Plugins page declaration lifetime. +The plugin catalog declares `plugins.add.actions`, a root-scoped list for `MenuItemButton` rows after installation in the Add plugin menu. Its owner supplies `onDismiss()` to close the menu before starting an action. The slot follows the owning Plugins page declaration lifetime. ## Declaration and lifecycle @@ -137,8 +137,6 @@ root ├─ main │ ├─ plugins.add.actions │ ├─ plugins.item -│ ├─ plugins.bundle.activation -│ ├─ plugins.bundle.usage │ ├─ plugins.bundle.config │ ├─ plugins.row.config │ ├─ plugins.detail.actions diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index cfebe63101..10e6338d0f 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -8,7 +8,7 @@ Slots 是 Web Client 的类型化 React 组合系统。[`dsh-client-ui-slots`](. `plugins.bundle.config` 以 npm 包名为 key,提供 Bundle 详情配置。`plugins.bundle.activation` 在用户显式启用后渲染可选引导,由拥有者提供关闭引导和打开该 Bundle 详情的回调。`conversation.input.activity` 提供模型选择器与发送按钮之间的单个活动控件,可展开工具栏并在卸载时释放展开状态。 -插件目录声明 `plugins.add.actions`,它是 root 作用域的 list,在添加插件菜单的安装项之后提供 `MenuItemButton` 行。owner 提供 `onDismiss()`,用于开始动作前关闭菜单。同时声明 `plugins.bundle.usage`,它是 root 作用域的 keyed slot,在组件详情之前提供使用指引。后者以 npm 包名分派,并传入 `{ pkg: PluginPackageRef }`;占用者根据组合包的安装和启用状态决定适用动作。两者均遵循所属插件页面声明的生命周期。 +插件目录声明 `plugins.add.actions`,它是 root 作用域的 list,在添加插件菜单的安装项之后提供 `MenuItemButton` 行。owner 提供 `onDismiss()`,用于开始动作前关闭菜单。该 slot 遵循所属插件页面声明的生命周期。 ## 声明与生命周期 @@ -137,8 +137,6 @@ root ├─ main │ ├─ plugins.add.actions │ ├─ plugins.item -│ ├─ plugins.bundle.activation -│ ├─ plugins.bundle.usage │ ├─ plugins.bundle.config │ ├─ plugins.row.config │ ├─ plugins.detail.actions diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 11cb1aeee2..5f556eca51 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: b3ad7867f23dad1c /deepseek-ai-dsh-client-ui-agent-preset/use-this-package: - en: d778cf8ed737f5f8 - zh: 3cf1a1d9f8c14d38 + en: 3f1c4b7b23f9b379 + zh: b87af50275888cb1 /deepseek-ai-dsh-client-ui-agent-preset/understand-the-implementation: en: 7813f0063c15e241 zh: 6eabc242edfc45ec diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 957944bf3e..2d34e810c3 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -31,7 +31,7 @@ Coding Tools in General Settings control ordinary preset selection: with them of Known shipped presets offer mode details and usage examples in a read-only dialog. Its tabs preserve each page's scroll position; closing returns focus to the opening action. Help does not change the new-task default. The default badge replaces the card's group badge, and the preset id appears beside the title. Guide copy and examples belong to this package. -The new-task picker and session header also open the same read-only help. The Plugins page’s “Add plugin” menu offers “Let the agent create a plugin”, including when Coding Tools are off. Choosing it closes the menu and stages Creator for one task without submitting a message, replacing a draft, or changing global preferences. With Coding Tools off, the new-task page shows only a read-only Creator label and help; it offers no preset switching. The next ordinary task uses the saved default. The menu item stays in place while the roster loads or refreshes, and explains why it is disabled when Creator mode is unavailable. The optional slot contribution disappears with its owner. +The Plugins page’s “Add plugin” menu offers “Let the agent create a plugin”, including when Coding Tools are off. Choosing it closes the menu and stages Creator for one task without submitting a message, replacing a draft, or changing global preferences. With Coding Tools off, the new-task page shows a read-only Creator label that opens its help; it offers no preset switching. The next ordinary task uses the saved default. The menu item stays in place while the roster loads or refreshes, and explains why it is disabled when Creator mode is unavailable. The optional slot contribution disappears with its owner. ## Understand the implementation diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index f4d8474c41..7be610ad2a 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -31,7 +31,7 @@ kind: "package-reference" 已知的内置预设提供只读的模式说明与使用示例对话框。各页签保留各自的滚动位置;关闭后焦点回到打开它的操作。帮助不会改变新任务默认值。默认徽标取代卡片的分组徽标,预设 id 显示在标题旁。指南文案与示例归本包所有。 -新任务选择器和任务标题也可打开同一份只读说明。插件页的「添加插件」菜单提供「让 Agent 创建插件」,代码工作工具关闭时也可使用。选择后关闭菜单,为一次任务暂存创造模式,不发送消息、不替换草稿,也不修改全局偏好。代码工作工具关闭时,新任务页仅显示只读的创造模式标签和说明,不提供预设切换;之后的普通任务继续使用已保存的默认值。预设列表加载或刷新时菜单项保持原位;创造模式不可用时禁用,并解释原因。菜单项由可选插槽贡献,随所属插件卸载而移除。 +插件页的「添加插件」菜单提供「让 Agent 创建插件」,代码工作工具关闭时也可使用。选择后关闭菜单,为一次任务暂存创造模式,不发送消息、不替换草稿,也不修改全局偏好。代码工作工具关闭时,新任务页显示只读的创造模式标签,点击可打开说明,不提供预设切换;之后的普通任务继续使用已保存的默认值。预设列表加载或刷新时菜单项保持原位;创造模式不可用时禁用,并解释原因。菜单项由可选插槽贡献,随所属插件卸载而移除。 ## 理解实现 diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css index dc08b0ce98..f828de9af1 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css @@ -1,4 +1,4 @@ -/* Session-header preset details never change the task's selected mode. */ +/* Session-header agent-preset label: static chrome, never a control. */ .label { display: inline-flex; @@ -7,7 +7,6 @@ max-width: 180px; padding: 0 2px 0 0; height: 22px; - border: none; border-radius: var(--dsw-radius-xs); background: var(--dsw-alias-fill-tsp-secondary); font-size: 12px; @@ -18,33 +17,14 @@ text-overflow: ellipsis; } -.name { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; -} - -.details { - cursor: pointer; -} - -.details:hover { - background: var(--dsw-alias-interactive-bg-hover); - color: var(--dsw-alias-label-primary); -} - -.details:focus-visible { - outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color); - outline-offset: 2px; -} - .icon { flex: none; opacity: 0.7; } /* First sacrifice when the title row (the enclosing inline-size container) - runs out of width: mode help yields before task actions lose their labels. */ + runs out of width: the preset is passive chrome, so it yields before any + interactive header action loses its label. */ @container (max-width: 540px) { .label { display: none; diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx index 968cc14ce4..26fdcfcbf6 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx @@ -3,21 +3,20 @@ * * Read-only by construction: a session's composition is fixed once its * conversation starts, and a header is only worth reading after that. Offering - * a selection control here would promise a switch the host refuses. Its - * read-only details explain the mode, and the choice itself lives on the + * a control here would promise a switch the host refuses; naming what the + * session runs is the honest affordance, and the choice itself lives on the * new-session screen ({@link AgentPresetSeat}). */ -import { useEffect, useState } from 'react' +import { useEffect } from 'react' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { IconAgentPresetOutlineRegular, Modal } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconAgentPresetOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the ui-conversation SlotMap merge (the header actions). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-agent-preset-registry/types' import type { AgentPresetSettingsState } from './settings-store.ts' -import { isBuiltInPreset, presetDisplayText } from './locales.ts' -import { PresetGuideDialog, presetGuide } from './PresetGuideDialog.tsx' +import { presetDisplayText } from './locales.ts' import css from './AgentPresetLabel.module.css' /** Registration-side business face for the header label. */ @@ -49,10 +48,6 @@ export function AgentPresetLabel({ return typeof value === 'string' ? value : undefined }) const options = useAgentPresets(state => state.options) - const [detailsOpen, setDetailsOpen] = useState(false) - const option = options.find(entry => entry.id === preset) - - useEffect(() => { setDetailsOpen(false) }, [sessionId, preset, option?.id]) useEffect(() => { // Deployments that compose no presets never label anything, so the roster @@ -62,28 +57,12 @@ export function AgentPresetLabel({ if (preset === undefined) return null + const option = options.find(entry => entry.id === preset) const text = option === undefined ? undefined : presetDisplayText(option, t) - const label = text?.name ?? preset - const guide = option === undefined ? undefined : presetGuide(option.id, isBuiltInPreset(option) ? 'system' : 'user') - if (option === undefined) return ( - + return ( + - {label} + {text?.name ?? preset} ) - return ( - <> - - {!detailsOpen ? null : guide === undefined ? ( - { setDetailsOpen(false) }} /> - ) : ( - { setDetailsOpen(false) }} /> - )} - - ) } diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css index da8988db87..04f27d122d 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css @@ -1,39 +1,6 @@ /* Agent-preset chip on the new-session screen, beside the workspace picker. Geometry mirrors HeroShell's .workspace so the two read as one row. */ -.controls { - display: inline-flex; - align-items: center; - gap: 2px; - min-width: 0; - max-width: 100%; -} - -.details { - display: inline-flex; - align-items: center; - justify-content: center; - flex: none; - width: 24px; - height: 28px; - padding: 0; - border: none; - border-radius: var(--dsw-radius-sm); - background: transparent; - color: var(--dsw-alias-label-caption); - cursor: pointer; -} - -.details:hover { - background: var(--dsw-alias-interactive-bg-hover); - color: var(--dsw-alias-label-primary); -} - -.details:focus-visible { - outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color); - outline-offset: 2px; -} - /* Overflow-driven degradation (mirrors HeroShell's .workspace), no breakpoint: the label shrinks with an ellipsis down to nothing, so the preset icon and chevron always sit snug against whatever text remains — @@ -67,7 +34,7 @@ cursor: pointer; } -button.seat:not(:disabled):hover, +.seat:not(:disabled):hover, .seat[aria-expanded='true'] { background: var(--dsw-alias-interactive-bg-hover); } @@ -77,10 +44,6 @@ button.seat:not(:disabled):hover, color: var(--dsw-alias-label-quaternary); } -.readOnly { - cursor: default; -} - .seatIcon { flex: none; color: var(--dsw-alias-label-primary); diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx index 29bf6ec0da..8f2a5c4d7c 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx @@ -16,8 +16,7 @@ import { useEffect, useRef, useState } from 'react' import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { - IconAgentPresetOutlineRegular, IconChevronDownOutlineRegular, IconInfoOutlineRegular, IconWarningOutlineRegular, - Menu, Modal, Toast, Tooltip, + IconAgentPresetOutlineRegular, IconChevronDownOutlineRegular, IconWarningOutlineRegular, Menu, Modal, Toast, } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the ui-conversation SlotMap merge (the hero seat). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -29,7 +28,7 @@ import css from './AgentPresetSeat.module.css' /** Registration-side business face for the hero chip. */ export interface AgentPresetSeatInjected { hooks: { - /** Shared Developer tools preference; off hides the chip. */ + /** Shared Developer tools preference; off hides ordinary preset selection. */ developerTools: ObservableSnapshot /** Seat snapshot bound by the renderer as useAgentPresetSeat. */ agentPresetSeat: SnapshotStore @@ -104,12 +103,11 @@ export function AgentPresetSeat({ useEffect(() => { if (developerTools) return setOpen(false) - setDetailsOpen(false) setToast(null) }, [developerTools]) const chosen = state.options.find(option => option.id === state.current) - useEffect(() => { setDetailsOpen(false) }, [state.current, chosen?.id]) + useEffect(() => { setDetailsOpen(false) }, [developerTools, state.current, chosen?.id]) const chosenText = chosen === undefined ? undefined : presetDisplayText(chosen, t) const label = chosenText?.name ?? state.current @@ -161,7 +159,7 @@ export function AgentPresetSeat({ : label return ( -
+ <> {developerTools ? { setOpen(false) }} @@ -215,25 +213,14 @@ export function AgentPresetSeat({ )} /> : ( - + )} - {chosen === undefined ? null : ( - - - - )} - {!detailsOpen || chosen === undefined ? null : guide === undefined ? ( + {!detailsOpen || developerTools || chosen === undefined ? null : guide === undefined ? ( { setDetailsOpen(false) }} /> ) : ( @@ -253,6 +240,6 @@ export function AgentPresetSeat({ onDone={() => { setToast(null) }} /> )} -
+ ) } diff --git a/packages/client/ui-agent-preset/tests/components.client.spec.tsx b/packages/client/ui-agent-preset/tests/components.client.spec.tsx index 1c71c0b866..97914c2b3a 100644 --- a/packages/client/ui-agent-preset/tests/components.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/components.client.spec.tsx @@ -49,12 +49,6 @@ function translate(key: keyof typeof en, params?: Record): stri : template.replace(/\{(\w+)\}/g, (match, name: string) => name in params ? String(params[name]) : match) } -function seatTrigger(): HTMLElement { - const button = screen.getAllByRole('button').find(item => item.getAttribute('aria-haspopup') === 'menu') - if (button === undefined) throw new Error('No preset selection trigger') - return button -} - function renderSeat( state: Partial = {}, select: () => Promise = () => Promise.resolve(undefined), @@ -95,7 +89,7 @@ function renderLabel( useAgentPresets: bindSnapshotSelector(store), t: (key: keyof typeof en) => en[key], } as unknown as AgentPresetLabelProps)} />) - return { load, view, store } + return { load, view } } describe('the new-session chip', () => { @@ -105,44 +99,11 @@ describe('the new-session chip', () => { expect(screen.queryByRole('button')).toBeNull() }) - it('offers only read-only Creator help while Developer tools are off', () => { - const actions = renderSeat({ current: 'cordis', options: [{ id: 'cordis' }, { id: 'standard' }] }, - undefined, undefined, false) - expect(screen.getByText(en.presetCordisName)).toBeTruthy() - expect(screen.queryByRole('menu')).toBeNull() - expect(document.querySelector('[aria-haspopup="menu"]')).toBeNull() - const trigger = screen.getByRole('button', { name: `${en.modeExplanation}: ${en.presetCordisName}` }) - trigger.focus() - fireEvent.click(trigger) - expect(screen.getByRole('dialog', { name: en.presetCordisName })).toBeTruthy() - fireEvent.keyDown(document.activeElement!, { key: 'Escape' }) - expect(screen.queryByRole('dialog')).toBeNull() - expect(document.activeElement).toBe(trigger) - expect(actions.select).not.toHaveBeenCalled() - expect(actions.developerTools.getSnapshot()).toBe(false) - - act(() => { actions.store.set({ ...actions.store.getSnapshot(), current: 'standard' }) }) - expect(screen.queryByRole('button')).toBeNull() - expect(screen.queryByText(en.presetCordisName)).toBeNull() - }) - - it('removes the Creator picker when Coding Tools turn off but keeps read-only help', () => { - const actions = renderSeat({ current: 'cordis', options: [{ id: 'cordis' }, { id: 'standard' }] }) - fireEvent.click(seatTrigger()) - expect(screen.getByRole('menu')).toBeTruthy() - act(() => { actions.developerTools.set(false) }) - expect(screen.queryByRole('menu')).toBeNull() - expect(document.querySelector('[aria-haspopup="menu"]')).toBeNull() - expect(screen.getByText(en.presetCordisName)).toBeTruthy() - expect(screen.getByRole('button', { name: `${en.modeExplanation}: ${en.presetCordisName}` })).toBeTruthy() - expect(actions.select).not.toHaveBeenCalled() - }) - it('renders only for a Session retained by the main view', () => { renderSeat({}, undefined, { id: 's1', retainInfo: { referenceCount: 1, retainedBy: { mainView: 1 } }, }) - expect(seatTrigger()).toBeTruthy() + expect(screen.getByRole('button')).toBeTruthy() cleanup() renderSeat({}, undefined, { id: 's1', retainInfo: undefined }) @@ -153,14 +114,14 @@ describe('the new-session chip', () => { const actions = renderSeat() await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) }) - expect(seatTrigger().textContent).toContain(en.presetStandardName) - expect(seatTrigger().getAttribute('title')).toBe(en.seatHint) + expect(screen.getByRole('button').textContent).toContain(en.presetStandardName) + expect(screen.getByRole('button').getAttribute('title')).toBe(en.seatHint) }) it('offers each preset with what it is for', () => { renderSeat() - fireEvent.click(seatTrigger()) + fireEvent.click(screen.getByRole('button')) // The id alone never said what a preset does; the description is the // whole reason a preset can publish metadata at all. @@ -173,49 +134,49 @@ describe('the new-session chip', () => { it('closes the picker immediately when developer tools turn off without changing the staged preset', () => { const actions = renderSeat() - fireEvent.click(seatTrigger()) + fireEvent.click(screen.getByRole('button')) expect(screen.getByText(en.presetStandardDescription)).toBeTruthy() act(() => { actions.developerTools.set(false) }) expect(screen.queryByRole('button')).toBeNull() expect(screen.queryByText(en.presetStandardDescription)).toBeNull() expect(actions.select).not.toHaveBeenCalled() act(() => { actions.developerTools.set(true) }) - expect(seatTrigger().getAttribute('aria-expanded')).toBe('false') - expect(seatTrigger().textContent).toContain(en.presetStandardName) + expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false') + expect(screen.getByRole('button').textContent).toContain(en.presetStandardName) }) it('falls back to the id when the staged preset published no name', () => { renderSeat({ current: 'mine' }) - expect(seatTrigger().textContent).toContain('mine') + expect(screen.getByRole('button').textContent).toContain('mine') }) it('shows the staged id until a stale roster contains it', () => { renderSeat({ current: 'arriving' }) - expect(seatTrigger().textContent).toContain('arriving') + expect(screen.getByRole('button').textContent).toContain('arriving') }) it('stages the picked preset and closes the menu', () => { const actions = renderSeat() - fireEvent.click(seatTrigger()) + fireEvent.click(screen.getByRole('button')) fireEvent.click(screen.getByText('mine')) expect(actions.select).toHaveBeenCalledWith('mine') - expect(seatTrigger().getAttribute('aria-expanded')).toBe('false') + expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false') }) it('disables the trigger while a switch is in flight', () => { renderSeat({ busy: true }) - expect(seatTrigger()).toHaveProperty('disabled', true) + expect(screen.getByRole('button')).toHaveProperty('disabled', true) }) it('shows a refused switch on the trigger', () => { renderSeat({ error: 'session has already started' }) - expect(seatTrigger().getAttribute('title')).toBe('session has already started') + expect(screen.getByRole('button').getAttribute('title')).toBe('session has already started') }) it('renders nothing before the roster arrives or when there is none', () => { @@ -230,76 +191,73 @@ describe('the new-session chip', () => { it('closes on an outside dismissal', () => { renderSeat() - fireEvent.click(seatTrigger()) + fireEvent.click(screen.getByRole('button')) fireEvent.keyDown(document, { key: 'Escape' }) - expect(seatTrigger().getAttribute('aria-expanded')).toBe('false') + expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false') }) +}) - it('opens mode help without selecting and returns keyboard focus after Escape', () => { - const actions = renderSeat() - const trigger = screen.getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` }) - trigger.focus() - fireEvent.click(trigger) - - expect(screen.getByRole('dialog', { name: en.presetStandardName })).toBeTruthy() - expect(document.activeElement).toBe(screen.getByRole('tab', { name: en.modeExplanation })) - fireEvent.click(screen.getByRole('tab', { name: en.howToUse })) - expect(screen.getByRole('tab', { name: en.howToUse }).getAttribute('aria-selected')).toBe('true') - fireEvent.keyDown(document.activeElement!, { key: 'Escape' }) - +describe('Creator with Coding Tools off', () => { + it('opens read-only help from the label without offering a preset switch or changing preferences', () => { + const actions = renderSeat({ current: 'cordis', options: [{ id: 'cordis' }] }, undefined, undefined, false) + const label = screen.getByRole('button', { name: `${en.modeExplanation}: ${en.presetCordisName}` }) + expect(label.getAttribute('aria-haspopup')).toBe('dialog') + expect(document.querySelector('[aria-haspopup="menu"]')).toBeNull() + label.focus() + fireEvent.click(label) + expect(screen.getByRole('dialog', { name: en.presetCordisName })).toBeTruthy() + fireEvent.keyDown(screen.getByRole('tab', { name: en.modeExplanation }), { key: 'Escape' }) expect(screen.queryByRole('dialog')).toBeNull() - expect(document.activeElement).toBe(trigger) + expect(document.activeElement).toBe(label) expect(actions.select).not.toHaveBeenCalled() + expect(actions.developerTools.getSnapshot()).toBe(false) + + act(() => { actions.store.set({ ...SEAT_READY }) }) + expect(screen.queryByRole('button')).toBeNull() }) - it('uses only a custom preset description even when it names a built-in id', () => { - const actions = renderSeat({ options: [{ id: 'standard', name: 'My workflow', description: 'Our review workflow' }] }) - fireEvent.click(screen.getByRole('button', { name: `${en.modeExplanation}: My workflow` })) - - expect(screen.getByRole('dialog', { name: 'My workflow' }).textContent).toContain('Our review workflow') - expect(screen.queryByRole('tab')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: en.close })) - expect(screen.queryByRole('dialog')).toBeNull() - expect(actions.select).not.toHaveBeenCalled() - }) - - it('explains a custom preset with no description without offering built-in guidance', () => { - const actions = renderSeat({ current: 'mine' }) - fireEvent.click(screen.getByRole('button', { name: `${en.modeExplanation}: mine` })) - expect(screen.getByRole('dialog', { name: 'mine' }).textContent).toContain(en.noDescription) - expect(screen.queryByRole('tab')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: en.close })) - expect(screen.queryByRole('dialog')).toBeNull() - expect(actions.select).not.toHaveBeenCalled() - }) - - it('forgets open help when developer tools turn off', () => { - const actions = renderSeat() - fireEvent.click(screen.getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` })) + it('replaces an open picker with the read-only label and clears help when Coding Tools return', () => { + const actions = renderSeat({ current: 'cordis', options: [{ id: 'cordis' }] }) + fireEvent.click(screen.getByRole('button')) + expect(screen.getByRole('menu')).toBeTruthy() act(() => { actions.developerTools.set(false) }) - expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.queryByRole('menu')).toBeNull() + fireEvent.click(screen.getByRole('button')) + expect(screen.getByRole('dialog')).toBeTruthy() act(() => { actions.developerTools.set(true) }) expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.getByRole('button').getAttribute('aria-haspopup')).toBe('menu') + act(() => { actions.developerTools.set(false) }) + expect(screen.queryByRole('dialog')).toBeNull() + expect(actions.select).not.toHaveBeenCalled() }) - it.each([ - { name: 'no presets', options: [] }, - { name: 'another preset', options: [{ id: 'mine' }] }, - ])('keeps help closed when the current preset returns after removal leaves $name', ({ options }) => { - const actions = renderSeat() - const before = actions.store.getSnapshot() - fireEvent.click(screen.getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` })) - expect(screen.getByRole('dialog', { name: en.presetStandardName })).toBeTruthy() - - act(() => { actions.store.set({ ...before, options }) }) + it.each<{ description?: string; expected: string }>([ + { description: 'My own plugin authoring setup', expected: 'My own plugin authoring setup' }, + { expected: en.noDescription }, + ])('shows only declared help for a custom Creator preset: $expected', (copy) => { + const option = { id: 'cordis', name: 'My creator', ...(copy.description === undefined ? {} : { description: copy.description }) } + renderSeat({ current: 'cordis', options: [option] }, undefined, undefined, false) + fireEvent.click(screen.getByRole('button')) + expect(screen.getByRole('dialog', { name: 'My creator' }).textContent).toContain(copy.expected) + expect(screen.queryByRole('tab')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: en.close })) expect(screen.queryByRole('dialog')).toBeNull() - act(() => { actions.store.set(before) }) + }) + it('closes help when the preset disappears and does not reopen it when the roster recovers', () => { + const actions = renderSeat({ current: 'cordis', options: [{ id: 'cordis' }] }, undefined, undefined, false) + fireEvent.click(screen.getByRole('button')) + expect(screen.getByRole('dialog')).toBeTruthy() + act(() => { actions.store.set({ ...actions.store.getSnapshot(), options: [{ id: 'standard' }], error: 'Unavailable' }) }) expect(screen.queryByRole('dialog')).toBeNull() - expect(screen.getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` })).toBeTruthy() - expect(actions.select).not.toHaveBeenCalled() + expect(screen.getByRole('button')).toHaveProperty('disabled', true) + expect(screen.getByRole('button').getAttribute('title')).toBe('Unavailable') + act(() => { actions.store.set({ ...actions.store.getSnapshot(), options: [{ id: 'cordis' }], error: null }) }) + expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.getByRole('button')).toHaveProperty('disabled', false) }) }) @@ -312,7 +270,7 @@ describe('a refused switch', () => { const reason = 'failed to import loader entry live-on-mac (@deepseek-ai/dsh-also-gone)' renderSeat({}, () => Promise.resolve(reason)) - fireEvent.click(seatTrigger()) + fireEvent.click(screen.getByRole('button')) fireEvent.click(screen.getByRole('menuitem', { name: /mine/ })) // The host refuses a mount discovery reported healthy, so this banner is @@ -334,7 +292,7 @@ describe('a refused switch', () => { it('says nothing when the switch lands', async () => { const actions = renderSeat() - fireEvent.click(seatTrigger()) + fireEvent.click(screen.getByRole('button')) fireEvent.click(screen.getByRole('menuitem', { name: /mine/ })) await waitFor(() => { expect(actions.select).toHaveBeenCalledWith('mine') }) @@ -350,22 +308,19 @@ describe('the chip introduce cue', () => { /** Character spans carry inline animation delays; nothing else does. */ function delayedChars(): HTMLElement[] { - return Array.from(seatTrigger().querySelectorAll('[style]')) + return Array.from(screen.getByRole('button').querySelectorAll('[style]')) } - it('announces a read-only Creator entry without exposing a picker or changing Coding Tools', () => { + it('announces Creator on its read-only label while Coding Tools remain off', () => { vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false }))) vi.useFakeTimers() - const actions = renderSeat({ current: 'cordis', options: [{ id: 'cordis' }], introduce: true }, - undefined, undefined, false) - const label = screen.getByTitle(en.presetCordisDescription) - expect(label.textContent).toBe(en.presetCordisName) - expect(label.querySelectorAll('[style]').length).toBeGreaterThan(0) - expect(document.querySelector('[aria-haspopup="menu"]')).toBeNull() - + const actions = renderSeat({ current: 'cordis', options: [{ id: 'cordis' }], introduce: true }, undefined, undefined, false) + expect(delayedChars().map(span => span.textContent).join('')).toBe(en.presetCordisName) + expect(screen.getByRole('button').getAttribute('aria-haspopup')).toBe('dialog') + expect(actions.introduced).not.toHaveBeenCalled() act(() => { vi.advanceTimersByTime(750) }) - expect(actions.introduced).toHaveBeenCalledOnce() - expect(label.querySelectorAll('[style]')).toHaveLength(0) + expect(actions.introduced).toHaveBeenCalledTimes(1) + expect(delayedChars()).toHaveLength(0) expect(actions.developerTools.getSnapshot()).toBe(false) expect(actions.select).not.toHaveBeenCalled() }) @@ -455,61 +410,11 @@ describe('the session-header label', () => { }) await waitFor(() => { expect(load).toHaveBeenCalledTimes(1) }) - // Reading details never offers a change to the task's preset. - expect(screen.getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` })).toBeTruthy() - expect(screen.queryByRole('menu')).toBeNull() + // A control here would promise a switch the host refuses outright. + expect(screen.queryByRole('button')).toBeNull() expect(screen.getByTitle(en.presetStandardDescription).textContent).toBe(en.presetStandardName) }) - it('opens read-only mode help from a running task and restores focus on close', () => { - renderLabel({ blank: false, projectionValues: { agentPreset: 'standard' } }) - const trigger = screen.getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` }) - trigger.focus() - fireEvent.click(trigger) - - expect(screen.getByRole('dialog', { name: en.presetStandardName })).toBeTruthy() - expect(screen.queryByRole('menu')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: en.close })) - expect(screen.queryByRole('dialog')).toBeNull() - expect(document.activeElement).toBe(trigger) - }) - - it('displays the custom description without claiming built-in capabilities', () => { - renderLabel({ blank: false, projectionValues: { agentPreset: 'standard' } }, { - options: [{ id: 'standard', name: 'Review only', description: 'Describe changes for review' }], - }) - fireEvent.click(screen.getByRole('button', { name: `${en.modeExplanation}: Review only` })) - expect(screen.getByRole('dialog', { name: 'Review only' }).textContent).toContain('Describe changes for review') - expect(screen.queryByRole('tab')).toBeNull() - }) - - it('opens and closes help for a custom session preset that has no description', () => { - renderLabel({ blank: false, projectionValues: { agentPreset: 'mine' } }) - const trigger = screen.getByRole('button', { name: `${en.modeExplanation}: mine` }) - trigger.focus() - fireEvent.click(trigger) - expect(screen.getByRole('dialog', { name: 'mine' }).textContent).toContain(en.noDescription) - expect(screen.queryByRole('tab')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: en.close })) - expect(screen.queryByRole('dialog')).toBeNull() - expect(document.activeElement).toBe(trigger) - }) - - it('keeps help closed when the session preset returns after leaving the roster', () => { - const { store } = renderLabel({ blank: false, projectionValues: { agentPreset: 'standard' } }) - const before = store.getSnapshot() - fireEvent.click(screen.getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` })) - expect(screen.getByRole('dialog', { name: en.presetStandardName })).toBeTruthy() - - act(() => { store.set({ ...before, options: [{ id: 'mine' }] }) }) - expect(screen.queryByRole('dialog')).toBeNull() - expect(screen.getByTitle(en.headerHint).textContent).toBe('standard') - act(() => { store.set(before) }) - - expect(screen.queryByRole('dialog')).toBeNull() - expect(screen.getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` })).toBeTruthy() - }) - it('falls back to the id, and to the generic hint, when metadata is absent', () => { renderLabel({ blank: true, projectionValues: { agentPreset: 'mine' } }) diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 29049aeef2..732e4f9659 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -18,11 +18,11 @@ en: b7d2396da72ecac4 zh: 4ec8336478dd39c7 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle: - en: ea02185c3ec03a41 - zh: 0e1e5d641fa1c8f6 + en: 7fe665944e8b8377 + zh: faf09d639eca62dd /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-a-bundle: - en: a5f9da50f4fb3b23 - zh: c2160c918574616a + en: abc90531efa889d5 + zh: 0562570f85e69c35 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-one-row-of-a-bundle: en: 620c6a3ee20848bd zh: a9429f047a12bb4c @@ -30,8 +30,8 @@ en: c32019330fd9586d zh: dd22110abd10159c /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/detail-page-extension-points: - en: a5bd63206fa48e20 - zh: 335b8dac564554df + en: 87f6d96467dc2383 + zh: e0fb91aa4c4d5a12 /deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation: en: 8ca48e1365f18adf zh: 91eadd683d2d8b5b diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index ea4afd5203..110db393b4 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -39,6 +39,8 @@ The **Refresh** button shows its tooltip after 500 ms of hovering or keyboard fo The **Add plugin** primary button directly opens the existing installation dialog. Its separate arrow button, **Choose how to add a plugin**, opens a menu: **Install a third-party plugin** opens that same dialog with focus in its package-name field; plugin-contributed creation actions follow it. The two buttons have independent click targets and accessible names. Arrow keys move between menu rows, Escape closes back to the arrow button, and the menu stays within the viewport. While an installation is tracked, **View installation** opens that task directly. +`plugins.add.actions` adds root-scoped `MenuItemButton` rows after installation in this menu. Its owner supplies `onDismiss()`, which a contribution calls before starting its action. + On first use, when pnpm uses the unconfigured official npm registry and npmmirror is offered, the Host probes both registries in parallel. The first successful HTTPS ping response selects the initial source. The dialog preserves remembered choices, manual selections, explicit manager configuration, and custom or unknown pnpm registries. An install clicked during initial probing waits for that bounded operation; a late result cannot overwrite a manual choice or reopen a closed dialog. The Host sends GET requests to `https://registry.npmjs.org/-/ping` and `https://registry.npmmirror.com/-/ping` through its normal fetch proxy. A 2xx response wins; redirects and failures do not. The remaining request is cancelled, both response bodies are discarded, and cleanup completes before the result returns. The default deadline is 1500 ms and results, including unavailable results, are cached for five minutes. Configure `registryProbeTimeoutMs` and `registryProbeCacheTtlMs` on `ui-plugin-manager`; `registryProbeEnabled: false` disables probing. If both requests fail or time out, the existing default remains selected. No IP-location service or Session content is involved. @@ -51,7 +53,7 @@ When the Host attributes a network failure or timeout to a GitHub address and of ### Switching a bundle -A bundle's page shows its readable row names and descriptions under **What this plugin provides**, with duplicate descriptions grouped once and unavailable metadata omitted. Its scope is all sessions in this deployment; each session's mode still determines its tools. **Components and runtime status** folds package names, row identifiers, switches, and diagnostics by default and opens when a package, metadata, or row failure is reported. Declared changes to existing rows appear as a count under **Changes to existing capabilities**, with their ids inside the folded section. A bundle's switch changes its layer selection. A profile with HMR recomposes before the operation completes; one without HMR, and a bundle a higher layer overrides, say so in a toast. A bundle the Host cannot read carries a problem tag and its reason on its page and cannot be switched on; one that provides the management components stays locked. The Host answers with error codes, which the page's dictionary words; pnpm's and the Loader's own diagnostics are shown as they are. The page excludes built-in profile bundles from cards and counts even when the profile holds them as dependencies or the Host reports an error. The Host inventory remains complete; the Settings Plugins section's Plugin list tab inspects their plugins. +A bundle's page shows its full package name under the title, the spec that installs it elsewhere. A bundle's switch changes its layer selection. A profile with HMR recomposes before the operation completes; one without HMR, and a bundle a higher layer overrides, say so in a toast. A bundle the Host cannot read carries a problem tag and its reason on its page and cannot be switched on; one that provides the management components stays locked. The Host answers with error codes, which the page's dictionary words; pnpm's and the Loader's own diagnostics are shown as they are. The page excludes built-in profile bundles from cards and counts even when the profile holds them as dependencies or the Host reports an error. The Host inventory remains complete; the Settings Plugins section's Plugin list tab inspects their plugins. ### Switching one row of a bundle @@ -73,8 +75,6 @@ The bundle's patch must declare the row under that id, and the registration exis ### Detail page extension points -`plugins.add.actions` adds root-scoped `MenuItemButton` rows after installation in the Add plugin menu. Its owner supplies `onDismiss()`, which a contribution calls before starting its action. `plugins.bundle.usage` is keyed by package name and receives `{ pkg: PluginPackageRef }`; it renders usage guidance and entry actions after the bundle description and before component details. Both follow the page declaration lifetime and accept contributions without importing feature components. - A plugin with something to say about a bundle, a row, or an official plugin it does not own contributes to that object's page through three list slots the page declares: `plugins.detail.actions` for a control at the head of the page, before the page's own switch and uninstall; `plugins.detail.badge` for a tag beside the title, after the version, experimental, and problem tags; and `plugins.detail.section` for a section under the page's own content — after the rows on a bundle's page, after the configuration on a row's or an official plugin's page. Every entry is rendered with the page's `subject`: `{ kind: 'bundle', pkg }`, `{ kind: 'row', pkg, row }`, or `{ kind: 'item', id }`, where `pkg` and `row` carry the name, version, installed and enabled facts, and the row list a contribution decides on. An entry renders null for a subject it has nothing for and draws its own section chrome; the page orders entries by `order`. ```tsx ignore-check diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 1900e8909d..8d61561eb6 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -39,6 +39,8 @@ kind: "package-reference" **添加插件**主按钮直接打开原安装对话框。旁边独立的箭头按钮**选择添加插件方式**打开菜单:**安装第三方插件**打开同一个对话框并聚焦包名输入框,插件贡献的创造动作接在其后。两个按钮有独立的点击区域和无障碍名称。方向键在菜单条目间移动,Escape 关闭菜单并返回箭头按钮,菜单位置受视口边界约束。已有安装任务时,**查看安装任务**直接打开该任务。 +`plugins.add.actions` 在此菜单的安装项之后提供 root 作用域的 `MenuItemButton` 行。owner 提供 `onDismiss()`,贡献者在开始动作前调用它关闭菜单。 + 首次使用时,如果未显式配置安装源、pnpm 使用官方 npm 源,且列表提供 npmmirror,Host 会并发探测这两个源,选择最先成功响应 HTTPS ping 的源。已记住的选择、用户手选、管理器显式配置,以及自定义或未知的 pnpm 源均保留。在初次探测期间点击安装会等待这次有时限的操作;迟到结果不会覆盖手选或重新打开已关闭的对话框。 Host 通过普通 fetch 代理向 `https://registry.npmjs.org/-/ping` 和 `https://registry.npmmirror.com/-/ping` 发送 GET 请求,以最先返回 2xx 的源为结果;重定向和失败不参与选择。随后取消另一条请求、丢弃两个响应体,并在清理完成后返回。默认时限为 1500 毫秒,包括不可用结果在内的探测结果均缓存五分钟。在 `ui-plugin-manager` 上配置 `registryProbeTimeoutMs` 和 `registryProbeCacheTtlMs`;`registryProbeEnabled: false` 关闭探测。两条请求都失败或超时时保留现有默认源。不使用 IP 地区服务,也不发送 Session 内容。 @@ -51,7 +53,7 @@ Host 将网络失败或超时归因于 GitHub 地址,且提供 npmmirror 时 ### 切换一个组合包 -组合包页面在**这个插件提供**中展示组件的人类可读名称和说明,相同内容只列一次,缺少元信息时不补造。作用范围是此部署的所有会话,每个会话可用的工具仍由它的模式决定。**组件与运行状态**默认折叠包名、行标识、开关和诊断,出现包错误、元信息错误或组件运行异常时展开。声明的现有组件调整以数量显示在**调整现有能力**中,具体行 id 位于折叠区。组合包开关改变其层选择。启用了 HMR 的 profile 在操作完成前重组;没有 HMR 的 profile,以及被更高层覆盖的组合包,会以 toast 说明。Host 读不了的组合包带异常标签,其页面给出原因,且不能打开;提供管理组件的组合包保持锁定。Host 以错误码作答,由页面字典措辞;pnpm 与 Loader 自己的诊断原样显示。页面从卡片与数量中排除内置 profile 组合包,即使 profile 将它们列为依赖或 Host 报告了异常。Host 清单仍保留完整数据;设置中「插件」分区的「插件列表」标签页负责查看它们的插件。 +组合包页面在标题下方显示完整包名,也就是在别处安装它所需的 spec。组合包开关改变其层选择。启用了 HMR 的 profile 在操作完成前重组;没有 HMR 的 profile,以及被更高层覆盖的组合包,会以 toast 说明。Host 读不了的组合包带异常标签,其页面给出原因,且不能打开;提供管理组件的组合包保持锁定。Host 以错误码作答,由页面字典措辞;pnpm 与 Loader 自己的诊断原样显示。页面从卡片与数量中排除内置 profile 组合包,即使 profile 将它们列为依赖或 Host 报告了异常。Host 清单仍保留完整数据;设置中「插件」分区的「插件列表」标签页负责查看它们的插件。 ### 切换组合包里的一行 @@ -73,8 +75,6 @@ ctx.slots.inject('plugins.row.config', () => ctx.slots.register({ ### 详情页扩展点 -`plugins.add.actions` 在添加插件菜单的安装项之后提供 root 作用域的 `MenuItemButton` 行。owner 提供 `onDismiss()`,贡献者在开始动作前调用它关闭菜单。`plugins.bundle.usage` 以包名为键,接收 `{ pkg: PluginPackageRef }`,在组合包说明之后、组件详情之前渲染使用指引和入口动作。两者随页面声明的生命周期存在,贡献者无需导入其他功能组件。 - 对某个不属于自己的组合包、行或官方插件有话要说的插件,通过本页声明的三个 list slot 向该对象的页面贡献内容:`plugins.detail.actions` 在页头放一个控件,位于页面自己的开关和卸载之前;`plugins.detail.badge` 在标题旁放一个标签,位于版本、实验性和异常标签之后;`plugins.detail.section` 在页面自身内容之下放一个区块——组合包页在组件列表之后,行页和官方插件页在配置之后。每个条目都以页面的 `subject` 渲染:`{ kind: 'bundle', pkg }`、`{ kind: 'row', pkg, row }` 或 `{ kind: 'item', id }`,其中 `pkg` 与 `row` 携带包名、版本、是否已安装、是否启用以及行列表这些供贡献者判断的事实。条目对无话可说的 subject 返回 null,自绘区块外观;页面按 `order` 排列条目。 ```tsx ignore-check diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index 0479c5c899..f003556b49 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -1292,89 +1292,6 @@ label.registryOption { gap: 12px; } -.scope { - display: flex; - align-items: center; - gap: 4px; - margin-top: 8px; - font-size: 12px; - line-height: 18px; - color: var(--dsw-alias-label-secondary); -} - -.capabilities, -.overrideIds { - display: grid; - gap: 12px; - margin: 0; - padding: 0; - list-style: none; -} - -.capability { - display: flex; - flex-direction: column; - gap: 4px; - padding-left: 12px; - border-left: 0.5px solid var(--dsw-alias-border-l2); -} - -.capabilityTitle { - font-size: 13px; - line-height: 20px; - font-weight: 500; -} - -.capabilityDescription { - margin: 0; - font-size: 13px; - line-height: 20px; - color: var(--dsw-alias-label-secondary); - overflow-wrap: anywhere; -} - -.componentsToggle { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 8px; - padding: 0; - border: 0; - background: none; - color: var(--dsw-alias-label-secondary); - font: inherit; - font-size: 13px; - line-height: 20px; - text-align: left; - cursor: pointer; -} - -.componentsToggle[aria-expanded='false'] .detailsChevron { - transform: rotate(-90deg); -} - -.componentsToggle:focus-visible { - outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); - outline-offset: 4px; -} - -.componentsBody { - display: flex; - flex-direction: column; - gap: 20px; -} - -.componentsBody[hidden] { - display: none; -} - -.overrideIds { - font-size: 12px; - line-height: 18px; - color: var(--dsw-alias-label-secondary); - overflow-wrap: anywhere; -} - .sectionHead { display: flex; align-items: baseline; diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index ad7c017045..baf14a04ca 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -41,7 +41,7 @@ export type PluginManagerPageProps = & PropsLocale<'pluginManager'> & PropsRenderSlots< | 'plugins.item' | 'plugins.bundle.config' | 'plugins.row.config' | 'plugins.bundle.activation' - | 'plugins.add.actions' | 'plugins.bundle.usage' + | 'plugins.add.actions' | 'plugins.detail.actions' | 'plugins.detail.badge' | 'plugins.detail.section' > & InjectFace @@ -246,6 +246,7 @@ function RowsSection({ rows, t, resolveText, toggle, configure }: {

{t('partsLabel')}

+ {rows.length === 0 ? null : {partsSummary(rows, t)}}
{rows.length === 0 ?

{t('partsEmpty')}

: null} {rows.length > ROW_FILTER_THRESHOLD @@ -570,44 +571,13 @@ function RowDetail({ pkg, row, t, resolveText, onBack, renderSlot, form }: { ) } -/** Human-readable row metadata, without technical identifiers or runtime controls. */ -function CapabilitiesSummary({ rows, resolveText, t }: { - readonly rows: readonly PackageRow[] - readonly resolveText: ResolveText - readonly t: Translate -}): ReactNode { - const seen = new Set() - const capabilities = rows.flatMap((row) => { - const metadataTitle = row.meta?.title === undefined ? undefined : resolveText(row.meta.title).trim() - const title = metadataTitle === row.moduleName || metadataTitle === row.rowId ? undefined : metadataTitle - const description = row.meta?.description === undefined ? undefined : resolveText(row.meta.description).trim() || undefined - if (title === undefined && description === undefined) return [] - const identity = JSON.stringify([title, description]) - if (seen.has(identity)) return [] - seen.add(identity) - return [{ rowId: row.rowId, title, description }] - }) - if (capabilities.length === 0) return null - return ( -
-

{t('capabilitiesTitle')}

-
    - {capabilities.map(({ rowId, title, description }) => ( -
  • - {title === undefined ? null : {title}} - {description === undefined || description === title ? null : {description}} -
  • - ))} -
-
- ) -} - /** * One package's page: the crumb back to the list; its icon with its switch * and, for a package the profile installed, uninstall; its title beside its - * version, beta, and problem tags; usage guidance, readable capabilities, - * configuration, and component diagnostics folded until requested or failed. + * version tag, its beta tag, and its problem tag; the package name the title + * stands for, which is what installs it elsewhere; its one-liner; the Host's + * problem when it reports one; the configuration the bundle registered for + * itself; and its rows with their switches and configure controls. */ function PackageDetail({ pkg, t, resolveText, busy, rowBusy, configured, configure, renderSlot, @@ -631,11 +601,6 @@ function PackageDetail({ const { title, description, beta } = packageText(pkg, resolveText) const status = packageStatus(pkg) const subject: PluginsSubject = { kind: 'bundle', pkg: packageRef(pkg) } - const hasProblem = pkg.error !== undefined || pkg.meta?.error !== undefined - || pkg.rows.some(row => row.phase === 'failed' || row.meta?.error !== undefined) - const [componentsOpen, setComponentsOpen] = useState(hasProblem) - const componentsId = useId() - useEffect(() => { if (hasProblem) setComponentsOpen(true) }, [hasProblem]) return (
{t('statusProblem')} : null} {renderSlot('plugins.detail.badge', { subject })}
+

{pkg.name}

{description === undefined ? null :

{description}

} -
- {t('deploymentScope')} - - - -
{pkg.error === undefined ? null :

{t('reasonLabel')}: {managementText(pkg.error, t)}

} {pkg.readOnlyReason === undefined ? null :

{managementText({ code: pkg.readOnlyReason }, t)}

}
- {renderSlot('plugins.bundle.usage', { pkg: subject.pkg }, { entryKey: pkg.name })} - - {pkg.overrides.length === 0 ? null : ( -
-

{t('overridesTitle')}

-

{t('overridesDescription', { count: String(pkg.overrides.length) })}

-
- )} {configured ? (
@@ -702,32 +652,13 @@ function PackageDetail({
) : null} -
- - -
+ busy || rowBusy(row), onSetEnabled: onSetRowEnabled } : undefined} + configure={configure} + /> {renderSlot('plugins.detail.section', { subject })}
@@ -1511,7 +1442,6 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { {loaded && openPkg !== undefined && openRow === undefined ? ( void } -/** Bundle facts supplied to its usage guidance and entry actions. */ -export interface PluginBundleUsageProps { - /** The bundle whose detail page is open. */ - readonly pkg: PluginPackageRef -} - declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { /** Additional MenuItemButton rows after installation in the add-plugin menu. */ 'plugins.add.actions': { kind: 'list'; scope: 'root'; owner: PluginAddActionsProps } - /** Bundle-owned usage guidance below the description, keyed by npm package name. */ - 'plugins.bundle.usage': { kind: 'keyed'; scope: 'root'; owner: PluginBundleUsageProps } /** Optional guidance after the user enables a bundle from the list, keyed by npm package name. */ 'plugins.bundle.activation': { kind: 'keyed'; scope: 'root'; owner: PluginActivationOwnerProps } /** diff --git a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx index dcacad0820..8d5be40efa 100644 --- a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx @@ -62,30 +62,6 @@ function declare(slots: SlotRegistry): () => void { } describe('ui-plugin-manager browser plugin', () => { - it('reattaches add-plugin menu actions and bundle usage across page declaration lifetimes', async () => { - const b = await bench() - b.slots.inject('plugins.add.actions', () => b.slots.register({ - name: 'plugins.add.actions', id: 'create-plugin', - }, () => null)) - b.slots.inject('plugins.bundle.usage', () => b.slots.register({ - name: 'plugins.bundle.usage', key: '@acme/notes', - }, () => null)) - const fiber = b.ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - const removeRoot = declare(b.slots) - expect(b.slots.entries('plugins.add.actions')).toHaveLength(1) - expect(b.slots.entries('plugins.bundle.usage')).toHaveLength(1) - removeRoot() - expect(b.slots.entries('plugins.add.actions')).toHaveLength(0) - expect(b.slots.entries('plugins.bundle.usage')).toHaveLength(0) - declare(b.slots) - expect(b.slots.entries('plugins.add.actions')).toHaveLength(1) - expect(b.slots.entries('plugins.bundle.usage')).toHaveLength(1) - await fiber.dispose() - expect(b.slots.entries('plugins.add.actions')).toHaveLength(0) - expect(b.slots.entries('plugins.bundle.usage')).toHaveLength(0) - }) - it('resets bundle selection when leaving Plugins and releases its panel observer with the registration', async () => { const b = await bench() const removeRoot = declare(b.slots) @@ -171,9 +147,8 @@ describe('ui-plugin-manager browser plugin', () => { expect(icon.locale).toBe(NS) expect(resolveSlotLabel(icon.options.label)).toBe('插件') // The page declares the slots a plugin's configuration arrives through, and binds their projection beside its state. - expect(b.slots.spec('plugins.item')).toMatchObject({ kind: 'list', scope: 'root' }) expect(b.slots.spec('plugins.add.actions')).toMatchObject({ kind: 'list', scope: 'root' }) - expect(b.slots.spec('plugins.bundle.usage')).toMatchObject({ kind: 'keyed', scope: 'root' }) + expect(b.slots.spec('plugins.item')).toMatchObject({ kind: 'list', scope: 'root' }) expect(b.slots.spec('plugins.bundle.config')).toMatchObject({ kind: 'keyed', scope: 'root' }) expect(b.slots.spec('plugins.row.config')).toMatchObject({ kind: 'keyed', scope: 'root' }) for (const name of ['plugins.detail.actions', 'plugins.detail.badge', 'plugins.detail.section'] as const) { diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index ec9b88a8c8..b9ebfef8eb 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -17,7 +17,7 @@ import type { PluginManagerPageProps } from '../src/client/index.ts' import type { ConfigLedger } from '../src/client/config-ledger.ts' import { rowKey, type InstallState, type PackageRow, type PackageView, type PluginManagerState } from '../src/client/manager-store.ts' import { en, zh, type PluginManagerLocaleKey } from '../src/client/locales.ts' -import type { PluginActivationOwnerProps, PluginAddActionsProps, PluginBundleUsageProps, PluginDetailProps, PluginsSubject } from '../src/client/slot-contract.ts' +import type { PluginActivationOwnerProps, PluginAddActionsProps, PluginDetailProps, PluginsSubject } from '../src/client/slot-contract.ts' afterEach(cleanup) @@ -37,7 +37,6 @@ function pkg(overrides: Partial = {}): PackageView { optional: false, enabled: true, rows: [], - overrides: [], ...overrides, } } @@ -83,7 +82,7 @@ const READY: PluginManagerState = { * (a list slot's cell is empty), the view asked for — `detail` for a detail * contribution — and the owner props. */ -type SlotBodies = Record ReactNode> +type SlotBodies = Record ReactNode> /** The subject a detail contribution was rendered with. */ function subjectOf(owner: unknown): PluginsSubject | undefined { @@ -160,7 +159,6 @@ function renderTab( const body = bodies[`${name}:${opts?.only ?? opts?.entryKey ?? ''}`] if (body === undefined) return null if (name === 'plugins.bundle.activation') return body('activation', owner) - if (name === 'plugins.bundle.usage') return body('usage', owner) if (name === 'plugins.add.actions') return body('add', owner) if (name.startsWith('plugins.detail.')) return body('detail', owner) if (!('view' in owner) || (owner.view !== 'summary' && owner.view !== 'page')) { @@ -194,34 +192,16 @@ function renderTab( } } -/** Open the technical controls before exercising component-specific behavior. */ -function expandComponents(dict: typeof en = en): void { - const toggle = screen.getByRole('button', { name: new RegExp(dict.componentsDisclosure) }) - if (toggle.getAttribute('aria-expanded') === 'false') fireEvent.click(toggle) -} - describe('PluginManagerPage', () => { - it('keeps contributed creation inside Add plugin and usage before folded implementation details', () => { - const uses: PluginBundleUsageProps[] = [] - const createPlugin = vi.fn() - const { actions } = renderTab({ packages: [pkg({ meta: { description: 'Browse your project notes.' }, rows: [row({ - meta: { title: 'Project notes', description: 'Read Markdown notes in this workspace.' }, - })] })] }, {}, { - 'plugins.add.actions:': (_view, owner) => { - (owner as PluginAddActionsProps).onDismiss() - createPlugin() - }}>Create a plugin, - 'plugins.bundle.usage:dsh-better-sidebar': (_view, owner) => { - uses.push(owner as PluginBundleUsageProps) - return - }, + it('opens the add-plugin menu with ArrowDown and returns focus to its trigger on Escape', () => { + const { actions } = renderTab({}, {}, { + 'plugins.add.actions:': (_view, owner) => { (owner as PluginAddActionsProps).onDismiss() }}>Create a plugin, }) - expect(screen.queryByRole('menuitem', { name: 'Create a plugin' })).toBeNull() const add = screen.getByRole('button', { name: en.chooseAddMethod }) add.focus() expect(fireEvent.keyDown(add, { key: 'Tab' })).toBe(true) expect(screen.queryByRole('menu')).toBeNull() - expect(actions.openInstall).not.toHaveBeenCalled() fireEvent.keyDown(add, { key: 'ArrowDown' }) const install = screen.getByRole('menuitem', { name: new RegExp(en.installExisting) }) const create = screen.getByRole('menuitem', { name: 'Create a plugin' }) @@ -232,28 +212,7 @@ describe('PluginManagerPage', () => { fireEvent.keyDown(create, { key: 'Escape' }) expect(screen.queryByRole('menu')).toBeNull() expect(document.activeElement).toBe(add) - fireEvent.click(add) - fireEvent.click(screen.getByRole('menuitem', { name: 'Create a plugin' })) - expect(createPlugin).toHaveBeenCalledOnce() expect(actions.openInstall).not.toHaveBeenCalled() - expect(screen.queryByRole('menu')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) - expect(screen.queryByRole('button', { name: en.addPlugin })).toBeNull() - const usage = screen.getByRole('button', { name: 'Open project notes' }) - expect(uses.at(-1)?.pkg).toMatchObject({ name: 'dsh-better-sidebar', enabled: true }) - expect(screen.getByText(en.deploymentScope)).toBeTruthy() - expect(screen.getByRole('heading', { name: en.capabilitiesTitle })).toBeTruthy() - expect(screen.getByText('Project notes')).toBeTruthy() - expect(screen.getByText('Read Markdown notes in this workspace.')).toBeTruthy() - expect(document.querySelector('[data-plugin-name]')).toBeNull() - expect(document.querySelector('[data-plugin-row]')).toBeNull() - const toggle = screen.getByRole('button', { name: new RegExp(en.componentsDisclosure) }) - expect(toggle.getAttribute('aria-expanded')).toBe('false') - expect(usage.compareDocumentPosition(toggle) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() - fireEvent.click(toggle) - expect(toggle.getAttribute('aria-expanded')).toBe('true') - expect(document.querySelector('[data-plugin-name]')?.textContent).toBe('dsh-better-sidebar') - expect(screen.getByRole('switch', { name: en.partToggle.replace('{name}', 'Project notes') })).toBeTruthy() }) it('opens the existing install dialog from the menu and keeps focus on its input', async () => { @@ -316,37 +275,6 @@ describe('PluginManagerPage', () => { expect(screen.queryByRole('menu')).toBeNull() }) - it('deduplicates readable capabilities and reports declared overrides without exposing ids until expanded', () => { - const { setLanguage } = renderTab({ packages: [pkg({ - rows: [row({ meta: { title: { en: 'Notes', zh: '笔记' }, description: { en: 'Browse notes.', zh: '浏览笔记' } } }), - row({ rowId: 'duplicate', meta: { title: { en: 'Notes', zh: '笔记' }, description: { en: 'Browse notes.', zh: '浏览笔记' } } }), - row({ rowId: 'technical' })], - overrides: ['system-prompt', 'tools'], - })] }) - fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) - const capabilities = document.querySelector('[data-plugin-capabilities]') as HTMLElement - expect(within(capabilities).getAllByRole('listitem')).toHaveLength(1) - expect(screen.getByText(en.overridesDescription.replace('{count}', '2'))).toBeTruthy() - expect(screen.queryByText('system-prompt')).toBeNull() - setLanguage(zh) - expect(within(capabilities).getByText('笔记')).toBeTruthy() - expect(within(capabilities).getByText('浏览笔记')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: new RegExp(zh.componentsDisclosure) })) - expect(screen.getByText('system-prompt')).toBeTruthy() - expect(screen.getByText('tools')).toBeTruthy() - }) - - it('opens component diagnostics when a running row fails and omits absent capability metadata', () => { - const { set } = renderTab({ packages: [pkg({ rows: [row()] })] }) - fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) - expect(document.querySelector('[data-plugin-capabilities]')).toBeNull() - expect(document.querySelector('[data-plugin-overrides]')).toBeNull() - expect(document.querySelector('[data-plugin-row]')).toBeNull() - set({ packages: [pkg({ rows: [row({ phase: 'failed' })] })] }) - expect(screen.getByRole('button', { name: new RegExp(en.componentsDisclosure) }).getAttribute('aria-expanded')).toBe('true') - expect(document.querySelector('[data-plugin-row]')?.getAttribute('data-state')).toBe('failed') - }) - it('opens the requested bundle after its inventory arrives and falls back when it is absent', () => { const b = renderTab({ status: 'loading' }) act(() => { b.navigation.actions.setView({ kind: 'package', name: 'dsh-better-sidebar' }) }) @@ -644,7 +572,6 @@ describe('PluginManagerPage', () => { fireEvent.click(screen.getByRole('switch', { name: zh.enableToggle.replace('{name}', title(zh)) })) expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith(name, false) fireEvent.click(screen.getByRole('button', { name: zh.openDetail.replace('{name}', title(zh)) })) - expandComponents(zh) for (const dict of [zh, en]) { setLanguage(dict) expect(screen.getByRole('heading', { level: 3 }).textContent).toBe(title(dict)) @@ -679,7 +606,6 @@ describe('PluginManagerPage', () => { set({ packages: [bundle] }) expect(image().getAttribute('src')).toBe(icon) fireEvent.click(screen.getByRole('button', { name: 'View dsh-better-sidebar' })) - expandComponents() expect(image().getAttribute('src')).toBe(icon) const rowImage = document.querySelector('[data-plugin-row] img')! expect(rowImage.getAttribute('src')).toBe(icon) @@ -707,7 +633,6 @@ describe('PluginManagerPage', () => { fireEvent.click(enable) expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith('dsh-better-sidebar', true) fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) - expandComponents(en) expect(screen.getByText(en.metadataError.replace('{error}', error))).toBeTruthy() const uninstall = screen.getByRole('button', { name: en.uninstallLabel.replace('{name}', 'dsh-better-sidebar') }) expect(uninstall).toHaveProperty('disabled', false) @@ -787,7 +712,6 @@ describe('PluginManagerPage', () => { { 'plugins.row.config:dsh-better-sidebar#theme': view => view === 'page' ? : null }, ) fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'Bundle title') })) - expandComponents(en) expect(screen.getByRole('switch', { name: en.partToggle.replace('{name}', '@acme/dsh-sidebar/navigation') })).toBeTruthy() expect(screen.getByRole('switch', { name: en.partToggle.replace('{name}', '@acme/dsh-theme') })).toBeTruthy() expect(screen.queryByText('中文主题说明。')).toBeNull() @@ -798,7 +722,7 @@ describe('PluginManagerPage', () => { expect(within(navigation).queryByText('Bundle description.')).toBeNull() fireEvent.click(within(navigation).getByRole('switch', { name: zh.partToggle.replace('{name}', '@acme/dsh-sidebar/navigation') })) expect(actions.setRowEnabled).toHaveBeenCalledExactlyOnceWith('include:sidebar', false) - expect(within(document.querySelector('[data-plugin-rows]') as HTMLElement).getByText('中文主题说明。')).toBeTruthy() + expect(screen.getByText('中文主题说明。')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: zh.configureRow.replace('{name}', '主题插件') })) expect(document.querySelector('[data-plugin-row-detail]')?.getAttribute('data-plugin-row-detail')).toBe('dsh-better-sidebar#theme') expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('主题插件') @@ -819,7 +743,6 @@ describe('PluginManagerPage', () => { { 'plugins.row.config:dsh-better-sidebar#sidebar': view => view === 'page' ? : null }, ) fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) - expandComponents(en) const listed = document.querySelector('[data-plugin-row="include:sidebar"]') as HTMLElement expect(within(listed).getByText('Sidebar component')).toBeTruthy() expect(within(listed).getByText('sidebar', { selector: 'code' })).toBeTruthy() @@ -868,7 +791,7 @@ describe('PluginManagerPage', () => { } expect(screen.getByText('sidebar')).toBeTruthy() expect(screen.getByText('@acme/dsh-sidebar-widget')).toBeTruthy() - expect(within(document.querySelector('[data-plugin-rows]') as HTMLElement).getByText('侧边导航')).toBeTruthy() + expect(screen.getByText('侧边导航')).toBeTruthy() expect(screen.getByText(zh.metadataError.replace('{error}', error))).toBeTruthy() const toggle = screen.getByRole('switch', { name: zh.partToggle.replace('{name}', '导航组件') }) expect(toggle).toHaveProperty('disabled', false) @@ -950,7 +873,6 @@ describe('PluginManagerPage', () => { const theme = row({ rowId: 'theme', moduleName: 'dsh-better-sidebar/theme', entryId: 'include:theme' as PluginEntryId }) renderTab({ packages: [pkg({ rows: [row(), theme] })] }, { rows: new Set(['dsh-better-sidebar#sidebar']) }, bodies) fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) - expandComponents(en) expect(screen.queryByRole('button', { name: en.configureRow.replace('{name}', 'dsh-better-sidebar/theme') })).toBeNull() fireEvent.click(screen.getByRole('button', { name: en.configureRow.replace('{name}', 'dsh-better-sidebar') })) const page = document.querySelector('[data-plugin-row-detail="dsh-better-sidebar#sidebar"]') as HTMLElement @@ -993,7 +915,6 @@ describe('PluginManagerPage', () => { expect(screen.queryByRole('button', { name: /^act / })).toBeNull() fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) - expandComponents(en) const detail = document.querySelector('[data-plugin-detail]') as HTMLElement const act = within(detail).getByRole('button', { name: 'act bundle dsh-better-sidebar' }) expect(within(detail).getByText('badge bundle dsh-better-sidebar')).toBeTruthy() @@ -1027,7 +948,7 @@ describe('PluginManagerPage', () => { }) it('leaves the version out of a bundle the Host reports none for', () => { - const unversioned: PackageView = { name: 'dsh-better-sidebar', installed: true, optional: false, enabled: true, rows: [], overrides: [] } + const unversioned: PackageView = { name: 'dsh-better-sidebar', installed: true, optional: false, enabled: true, rows: [] } renderTab({ packages: [unversioned] }, {}, bodies) fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) expect(subjects.at(-1)).toEqual({ kind: 'bundle', pkg: { name: 'dsh-better-sidebar', installed: true, enabled: true, rows: [] } }) @@ -1039,7 +960,6 @@ describe('PluginManagerPage', () => { const { setLanguage } = renderTab({ packages: [pkg({ name, meta: { description: { en: 'Third-party description.' } } })] }) setLanguage(zh) fireEvent.click(screen.getByRole('button', { name: zh.openDetail.replace('{name}', name) })) - expandComponents(zh) expect(screen.getByRole('heading', { level: 3 }).textContent).toBe(name) expect(screen.getByText('Third-party description.')).toBeTruthy() expect(document.querySelector('[data-plugin-name]')?.textContent).toBe(name) @@ -1098,7 +1018,6 @@ describe('PluginManagerPage', () => { })], }) fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) - expandComponents(en) const detail = document.querySelector('[data-plugin-detail="dsh-better-sidebar"]') as HTMLElement expect(within(detail).getByRole('heading', { level: 3 }).textContent).toBe('dsh-better-sidebar') // The version sits beside the name as a tag; the crumb only leads back. diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts index 9dc32dd5cb..895a4feccd 100644 --- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts @@ -113,15 +113,10 @@ it('hands a custom page the shared configuration form of its entry', () => { }) describe('packageView', () => { - it('retains the Host-reported rows adjusted by the bundle', () => { - expect(packageView({ ...BUNDLE, overrides: ['system-prompt', 'tools'] }, PLUGINS).overrides) - .toEqual(['system-prompt', 'tools']) - }) it('joins a bundle with the entries its rows run as', () => { expect(packageView(BUNDLE, PLUGINS)).toEqual({ name: 'dsh-better-sidebar', version: '0.16.0', description: 'A sidebar.', installed: true, optional: false, enabled: false, - overrides: [], rows: [ { rowId: 'sidebar', moduleName: 'dsh-better-sidebar', entryId: ROW_ENTRY, enabled: true, phase: 'active' }, { rowId: 'theme', moduleName: 'dsh-better-sidebar/theme', enabled: false, phase: null }, @@ -137,7 +132,6 @@ describe('packageView', () => { expect(packageView(protectedBundle, PLUGINS)).toEqual({ name: '@deepseek-ai/dsh-base', installed: false, optional: false, enabled: true, readOnlyReason: 'management-required', error: { code: 'operation-error', diagnostic: 'broken' }, - overrides: [], rows: [ { rowId: 'core', moduleName: '@deepseek-ai/dsh-base', entryId: 'include:core', enabled: true, phase: 'active', readOnlyReason: 'management-required' }, { rowId: 'gone', moduleName: 'x', entryId: 'include:gone', enabled: false, phase: null }, diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index 162afe69f9..8866d6e127 100644 --- a/packages/client/ui-schedule/README.i18n.yaml +++ b/packages/client/ui-schedule/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 405b30feaf48c087 /deepseek-ai-dsh-client-ui-schedule/use-this-package: - en: b318072ec2191a71 - zh: adf9a59896141732 + en: c129ae5d0a150a5d + zh: d81976f2840cb059 /deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source: en: 6bd6882dbbbefcb0 zh: 03aa15f1a65d13dc diff --git a/packages/client/ui-schedule/README.md b/packages/client/ui-schedule/README.md index 9fda3f37cf..ed9431a6b5 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -25,8 +25,6 @@ Manage active and inactive tasks from the Automation tasks page and the right Si ## Use this package -The enabled Schedule bundle's detail page offers a usage card with its capability, sidebar location, and Open automation tasks action. Explicit enablement also opens dismissible guidance. Both reuse the Host catalog to confirm availability before enabling navigation, show read failures with Retry, and disappear when this plugin unloads. Opening the task page creates no Session or task. - The shipped Web composition carries no `ui-schedule` row; enable the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` from the Plugins page (Official group), or list it in a profile's `dsh.profile.bundles`, to insert and mount `ui-schedule` with the Host Schedule service and the clock context that supplies the model's current time. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Its task list supports text search and one All, Enabled, and Inactive status filter row; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state keeps the heading's New action beside the notice. Selecting an active or inactive task opens a second detail level with Rules and Delivery records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation. Selecting a task from the list opens Rules; refreshing that task preserves the selected tab. Rules edits an active task's name, instruction, and run time; the edits stay in a local draft until saved, and inactive tasks remain read-only. Unmodified Left/Right arrows cycle the tabs, Home/End select the first/last tab, and keyboard focus follows the selection. The tabs do not consume these keys with Alt, Ctrl, Meta, or Shift. The linked-Session control in the detail tab strip stays available on the Rules tab, and confirmed deletion is available from either view. diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index dab4ae922b..d03aefa658 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -25,8 +25,6 @@ kind: "package-reference" ## 使用本包 -已启用的 Schedule 组合包在详情页提供使用卡片,展示能力、侧栏入口和「打开自动化任务」操作;显式启用后还会显示可关闭的引导。两处均复用 Host 任务目录查询,在确认可用后开放跳转,读取失败时显示重试,并在本插件卸载后移除。打开任务页不会创建 Session 或任务。 - 随发行版交付的 Web 组合不含 `ui-schedule` 行;在插件管理页的“官方”分组中启用可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle`,或把它列进 profile 的 `dsh.profile.bundles`,即可插入 `ui-schedule` 并将它与 Host Schedule 服务、以及为模型提供当前时间的时钟上下文一起挂载。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示旁保留页头的“新建”操作。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。 从列表选择任务时默认打开“规则”;刷新同一任务会保留当前页签。活动任务的“规则”修改名称、指令和运行时间;这些修改保留在本地草稿中直到保存,未运行任务保持只读。不带修饰键的左右方向键循环切换页签,Home/End 选择第一个/最后一个页签,键盘焦点随选择移动。页签不会拦截这些键与 Alt、Ctrl、Meta 或 Shift 的组合。共用的会话入口位于详情页签条内,在“规则”页签可见;确认删除操作在两种页签下都可用。 diff --git a/packages/client/ui-schedule/package.json b/packages/client/ui-schedule/package.json index edb8398a79..665d1bca90 100644 --- a/packages/client/ui-schedule/package.json +++ b/packages/client/ui-schedule/package.json @@ -62,7 +62,6 @@ "@deepseek-ai/dsh-client-test-runtime": "workspace:*", "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", "@deepseek-ai/dsh-client-ui-layout": "workspace:*", - "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-session": "workspace:*", diff --git a/packages/client/ui-schedule/src/client/SchedulePluginUsage.module.css b/packages/client/ui-schedule/src/client/SchedulePluginUsage.module.css deleted file mode 100644 index 8f96f18b4f..0000000000 --- a/packages/client/ui-schedule/src/client/SchedulePluginUsage.module.css +++ /dev/null @@ -1,53 +0,0 @@ -.card { - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: 16px; - padding: 16px; - border: 0.5px solid var(--dsw-alias-border-l3); - border-radius: var(--dsw-radius-lg); - color: var(--dsw-alias-label-primary); - font-size: 13px; - line-height: 20px; -} - -.summary { - display: flex; - flex-direction: column; - gap: 8px; - min-width: 0; -} - -.card .summary { - flex: 1 1 240px; -} - -.status { - display: flex; - align-items: center; - gap: 8px; - font-weight: 500; -} - -.description { - margin: 0; - color: var(--dsw-alias-label-secondary); - font-size: 13px; - line-height: 20px; -} - -.location { - display: flex; - flex-wrap: wrap; - gap: 4px 12px; - color: var(--dsw-alias-label-secondary); - font-size: 13px; - line-height: 20px; -} - -.actions { - display: flex; - flex-wrap: wrap; - gap: 8px; -} diff --git a/packages/client/ui-schedule/src/client/SchedulePluginUsage.tsx b/packages/client/ui-schedule/src/client/SchedulePluginUsage.tsx deleted file mode 100644 index 86d114e40a..0000000000 --- a/packages/client/ui-schedule/src/client/SchedulePluginUsage.tsx +++ /dev/null @@ -1,76 +0,0 @@ -/** Navigation from the enabled Schedule bundle to its live task page. */ -import { Button, Modal, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' -import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' -import type { ScheduleCatalogEntry } from '@deepseek-ai/dsh-schedule/client' -import type { CatalogInjected, CatalogSnapshot } from './catalog-source.ts' -import css from './SchedulePluginUsage.module.css' - -/** Shared catalog readiness and navigation supplied by the Schedule plugin. */ -export interface SchedulePluginUsageInjected extends Pick, 'hooks' | 'onRetry'> { - /** Open the global task page without selecting or creating a Session. */ - readonly onOpenAutomation: () => void -} - -/** Shares for the bundle detail's usage card. */ -export type SchedulePluginUsageProps = PropsRuntime<'plugins.bundle.usage'> - & PropsLocale<'schedule.manager'> & InjectFace - -/** Shares for the guidance shown after explicit bundle enablement. */ -export type SchedulePluginActivationProps = PropsRuntime<'plugins.bundle.activation'> - & PropsLocale<'schedule.manager'> & InjectFace - -type Status = CatalogSnapshot['status'] -type CopyProps = PropsLocale<'schedule.manager'> & { readonly status: Status } - -function statusKey(status: Status) { - return status === 'ready' ? 'plugin.ready' : status === 'loading' ? 'plugin.checking' : 'plugin.unavailable' -} - -function UsageSummary({ status, t }: CopyProps) { - return
-
- - {t(statusKey(status))} -
-

{t('plugin.capability')}

-
- {t('plugin.locationLabel')} - {t('plugin.location')} -
-
-} - -/** - * Show an enabled bundle's destination once the Host catalog answers. - * @param props - bundle enablement, catalog hook, navigation and localized copy. - * @returns a usage card, or nothing for a disabled bundle. - */ -export function SchedulePluginUsage({ pkg, useCatalog, onOpenAutomation, onRetry, t }: SchedulePluginUsageProps) { - const status = useCatalog(snapshot => snapshot.status) - if (!pkg.enabled) return null - return
- -
- {status === 'error' ? : null} - -
-
-} - -/** - * Guide an explicit activation to the same task page after the Host answers. - * @param props - catalog hook, dismiss and navigation callbacks, and localized copy. - * @returns dismissible guidance without creating a task or starting a model turn. - */ -export function SchedulePluginActivation({ useCatalog, onOpenAutomation, onRetry, onDismiss, t }: SchedulePluginActivationProps) { - const status = useCatalog(snapshot => snapshot.status) - return - - {status === 'error' ? : null} - - }> - - -} diff --git a/packages/client/ui-schedule/src/client/index.ts b/packages/client/ui-schedule/src/client/index.ts index 1d699fc417..084d0edb8d 100644 --- a/packages/client/ui-schedule/src/client/index.ts +++ b/packages/client/ui-schedule/src/client/index.ts @@ -57,7 +57,6 @@ import { TaskTabBindings } from './task-tab-bindings.ts' import { TaskManagerPage, type TaskManagerInjected } from './TaskManagerPage.tsx' import type { TaskDetailInjected } from './TaskDetail.tsx' import { TaskManagerIcon } from './TaskManagerIcon.tsx' -import { SchedulePluginActivation, SchedulePluginUsage, type SchedulePluginUsageInjected } from './SchedulePluginUsage.tsx' import { sessionLinkState } from './session-link.ts' import { en, NS, zh, type ScheduleCatalogKey } from './locales.ts' import { en as managerEn, zh as managerZh, type TaskManagerKey } from './task-manager-locales.ts' @@ -183,19 +182,6 @@ export function apply(ctx: ClientContext): void { locale: MANAGER_NS, label: () => t('panel'), }, TaskManagerIcon)) - ctx.inject(['layout'], (scope) => { - const usage = (): SchedulePluginUsageInjected => ({ - hooks: manager.hooks, - onRetry: manager.onRetry, - onOpenAutomation: () => { scope.layout.selectPanel(PANEL_ID) }, - }) - scope.slots.inject('plugins.bundle.usage', () => scope.slots.register({ - name: 'plugins.bundle.usage', key: '@deepseek-ai/dsh-experimental-schedule-bundle', locale: MANAGER_NS, inject: usage, - }, SchedulePluginUsage)) - scope.slots.inject('plugins.bundle.activation', () => scope.slots.register({ - name: 'plugins.bundle.activation', key: '@deepseek-ai/dsh-experimental-schedule-bundle', locale: MANAGER_NS, inject: usage, - }, SchedulePluginActivation)) - }) // The created task is a Turn-level element, not a Tool-group row: the Turn // Definition publishes the settled result and this tail list entry renders // the card beneath the closing prose. diff --git a/packages/client/ui-schedule/src/client/task-manager-locales.ts b/packages/client/ui-schedule/src/client/task-manager-locales.ts index 83ce50f1c1..6906e54fff 100644 --- a/packages/client/ui-schedule/src/client/task-manager-locales.ts +++ b/packages/client/ui-schedule/src/client/task-manager-locales.ts @@ -5,15 +5,6 @@ import { frequencyEn, frequencyZh } from './frequency-locales.ts' export const en = { 'panel': 'Automation tasks', 'title': 'Automation tasks', - 'plugin.usageLabel': 'Use automation tasks', - 'plugin.ready': 'Automation tasks are ready to use', - 'plugin.checking': 'Checking automation tasks…', - 'plugin.unavailable': 'Automation tasks are currently unavailable', - 'plugin.capability': 'Continue the original session at a scheduled time or on a repeating schedule.', - 'plugin.locationLabel': 'Location', - 'plugin.location': 'Sidebar · Automation tasks', - 'plugin.open': 'Open automation tasks', - 'plugin.close': 'Close', 'new.action': 'New', 'list.label': 'Task catalog', 'list.nextPrefix': 'Next scheduled time: ', @@ -157,15 +148,6 @@ export type TaskManagerKey = keyof typeof en export const zh: Record = { 'panel': '自动化任务', 'title': '自动化任务', - 'plugin.usageLabel': '使用自动化任务', - 'plugin.ready': '自动化任务已可使用', - 'plugin.checking': '正在确认自动化任务…', - 'plugin.unavailable': '自动化任务暂不可用', - 'plugin.capability': '按时间或周期继续原会话', - 'plugin.locationLabel': '入口位置', - 'plugin.location': '侧栏 · 自动化任务', - 'plugin.open': '打开自动化任务', - 'plugin.close': '关闭', 'new.action': '新建', 'list.label': '任务列表', 'list.nextPrefix': '下次计划时间:', diff --git a/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts b/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts index b73dfb5c3c..4fa73699b9 100644 --- a/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts @@ -3,7 +3,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import { describe, expect, it, vi, type Mock } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { UiConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { resolveSlotLabel, type HostObservable, type PropsRenderSlots, type StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' +import { resolveSlotLabel, type HostObservable, type StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' import { stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '../src/client/index.ts' @@ -24,11 +24,9 @@ import { ScheduleCatalogAction } from '../src/client/ScheduleCatalogAction.tsx' import { SessionScheduleHover } from '../src/client/SessionScheduleHover.tsx' import { SessionScheduleMark } from '../src/client/SessionScheduleMark.tsx' import { ScheduleTurnCard, type ScheduleTurnCardInjected } from '../src/client/ScheduleTurnCard.tsx' -import { SchedulePluginActivation, SchedulePluginUsage, type SchedulePluginUsageInjected } from '../src/client/SchedulePluginUsage.tsx' import { en, NS, zh } from '../src/client/locales.ts' const Empty = () => null -const PluginSlots = (_props: PropsRenderSlots<'plugins.bundle.usage' | 'plugins.bundle.activation'>) => null /** Raw inject face of an ambient row seat: the shared Host catalog observable. */ interface SessionCatalogFace { @@ -139,44 +137,6 @@ function declareTranscriptSeats(ctx: Context): () => void { } describe('ui-schedule browser half', () => { - it('contributes plugin guidance only while loaded and opens the real task panel without creating a Session', async () => { - const catalog = vi.fn().mockResolvedValue({ ok: true, value: [] }) - const ctx = await baseContext({ schedule: { catalog }, $on: () => () => {} }) - const selectPanel = vi.fn() - const startSession = vi.spyOn(ctx.uiWorkspace, 'startSession') - const openSession = vi.spyOn(ctx.uiWorkspace, 'openSession') - ctx.provide('layout', { selectPanel } as never) - const fiber = ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - expect(ctx.slots.entries('plugins.bundle.usage')).toHaveLength(0) - const declaration = ctx.slots.register({ name: 'root', children: { - 'plugins.bundle.usage': { kind: 'keyed', scope: 'root' }, - 'plugins.bundle.activation': { kind: 'keyed', scope: 'root' }, - } }, PluginSlots) - const usage = ctx.slots.entries('plugins.bundle.usage')[0]! - const activation = ctx.slots.entries('plugins.bundle.activation')[0]! - expect(usage.options.key).toBe('@deepseek-ai/dsh-experimental-schedule-bundle') - expect(usage.component).toBe(SchedulePluginUsage) - expect(activation.component).toBe(SchedulePluginActivation) - const face = injectedFace(usage) as SchedulePluginUsageInjected - const guidance = injectedFace(activation) as SchedulePluginUsageInjected - expect(guidance.hooks.catalog).toBe(face.hooks.catalog) - const stop = face.hooks.catalog.subscribe(vi.fn()) - await face.onRetry() - expect(face.hooks.catalog.getSnapshot().status).toBe('ready') - face.onOpenAutomation() - guidance.onOpenAutomation() - expect(selectPanel.mock.calls).toEqual([['schedules'], ['schedules']]) - expect(startSession).not.toHaveBeenCalled() - expect(openSession).not.toHaveBeenCalled() - stop() - await fiber.dispose() - expect(ctx.slots.entries('plugins.bundle.usage')).toHaveLength(0) - expect(ctx.slots.entries('plugins.bundle.activation')).toHaveLength(0) - declaration() - await ctx.fiber.dispose() - }) - it('declares only the services used by registration', () => { expect(inject).toEqual([ 'slots', 'locale', 'remote', 'remote.schedule', 'conversation', 'uiConversation', 'uiWorkspace', 'sessions', diff --git a/packages/client/ui-schedule/tests/schedule-plugin-usage.client.spec.tsx b/packages/client/ui-schedule/tests/schedule-plugin-usage.client.spec.tsx deleted file mode 100644 index c06622bf0d..0000000000 --- a/packages/client/ui-schedule/tests/schedule-plugin-usage.client.spec.tsx +++ /dev/null @@ -1,97 +0,0 @@ -// @vitest-environment jsdom -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' -import type { ScheduleCatalogEntry } from '@deepseek-ai/dsh-schedule/client' -import type { CatalogSnapshot } from '../src/client/catalog-source.ts' -import { - SchedulePluginActivation, SchedulePluginUsage, - type SchedulePluginActivationProps, type SchedulePluginUsageProps, -} from '../src/client/SchedulePluginUsage.tsx' -import { en, zh } from '../src/client/task-manager-locales.ts' - -afterEach(cleanup) - -function props(status: CatalogSnapshot['status'] = 'ready', enabled = true): SchedulePluginUsageProps { - const snapshot: CatalogSnapshot = { - status, records: [], deleting: [], settled: status === 'ready', readRequest: 1, - readSettled: status === 'ready' ? 1 : 0, - } - return { - pkg: { name: '@deepseek-ai/dsh-experimental-schedule-bundle', installed: false, enabled, rows: [] }, - useCatalog: selector => selector(snapshot), - onRetry: vi.fn().mockResolvedValue(undefined), - onOpenAutomation: vi.fn(), - t: makeTranslate(en), - } as SchedulePluginUsageProps -} - -function activation(status: CatalogSnapshot['status'] = 'ready'): SchedulePluginActivationProps { - return { - ...props(status), packageName: '@deepseek-ai/dsh-experimental-schedule-bundle', - onDismiss: vi.fn(), onOpenDetails: vi.fn(), - } -} - -describe('Schedule plugin usage', () => { - it('explains the live capability and opens the task page only on a click', () => { - const value = props() - render() - expect(screen.getByText('Automation tasks are ready to use')).toBeTruthy() - expect(screen.getByText('Sidebar · Automation tasks')).toBeTruthy() - expect(screen.getByText('Continue the original session at a scheduled time or on a repeating schedule.')).toBeTruthy() - expect(value.onOpenAutomation).not.toHaveBeenCalled() - fireEvent.click(screen.getByRole('button', { name: 'Open automation tasks' })) - expect(value.onOpenAutomation).toHaveBeenCalledOnce() - }) - - it('hides an explicitly disabled bundle and localizes a ready bundle', () => { - const value = props('ready', false) - const view = render() - expect(screen.queryByRole('region')).toBeNull() - view.rerender() - expect(screen.getByText('自动化任务已可使用')).toBeTruthy() - expect(screen.getByText('侧栏 · 自动化任务')).toBeTruthy() - expect(screen.getByRole('button', { name: '打开自动化任务' })).toBeTruthy() - }) - - it('does not announce readiness until a catalog query succeeds and retries failures', () => { - const loading = props('loading') - const view = render() - expect(screen.getByText('Checking automation tasks…')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Open automation tasks' })) - expect(loading.onOpenAutomation).not.toHaveBeenCalled() - const failed = props('error') - view.rerender() - expect(screen.getByText('Automation tasks are currently unavailable')).toBeTruthy() - expect(screen.queryByText('Automation tasks are ready to use')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: 'Retry' })) - expect(failed.onRetry).toHaveBeenCalledOnce() - fireEvent.click(screen.getByRole('button', { name: 'Open automation tasks' })) - expect(failed.onOpenAutomation).not.toHaveBeenCalled() - }) -}) - -describe('Schedule activation guidance', () => { - it('dismisses the guidance before opening the task page', () => { - const value = activation() - const calls: string[] = [] - render( { calls.push('close') }} onOpenAutomation={() => { calls.push('open') }} />) - expect(screen.getByRole('dialog', { name: 'Use automation tasks' })).toBeTruthy() - expect(calls).toEqual([]) - fireEvent.click(screen.getByRole('button', { name: 'Open automation tasks' })) - expect(calls).toEqual(['close', 'open']) - }) - - it('keeps failed activation guidance dismissible with a retry and no ready claim', () => { - const value = activation('error') - render() - expect(screen.getByText('Automation tasks are currently unavailable')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Retry' })) - expect(value.onRetry).toHaveBeenCalledOnce() - fireEvent.click(screen.getByRole('button', { name: 'Open automation tasks' })) - expect(value.onOpenAutomation).not.toHaveBeenCalled() - fireEvent.click(screen.getAllByRole('button', { name: 'Close' })[0]!) - expect(value.onDismiss).toHaveBeenCalledOnce() - }) -}) diff --git a/packages/client/ui-schedule/tsconfig.json b/packages/client/ui-schedule/tsconfig.json index bed6d290b6..09401d4291 100644 --- a/packages/client/ui-schedule/tsconfig.json +++ b/packages/client/ui-schedule/tsconfig.json @@ -29,9 +29,6 @@ { "path": "../ui-layout" }, - { - "path": "../ui-plugin-manager/tsconfig.client.json" - }, { "path": "../ui-sidebar" }, diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 0784651381..be8dfdb1d5 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1848,7 +1848,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.add.actions\', () => ctx.slots.register(\n { name: \'plugins.add.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:90', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:84', }, { key: 'plugins.bundle.activation', @@ -1877,17 +1877,16 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSessionRetainInfo: UseSessionRetainInfo', 'useWorkspaces: SnapshotSelectorHook', ], - keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: @deepseek-ai/dsh-experimental-schedule-bundle, @deepseek-ai/dsh-experimental-voice-input-bundle', + keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: @deepseek-ai/dsh-experimental-voice-input-bundle', hookContext: '', slotInject: '', declaredBy: 'an entry in \'main\' (client-ui-plugin-manager), so it exists while that entry is mounted', occupants: [ - 'client-ui-schedule SchedulePluginActivation key \'@deepseek-ai/dsh-experimental-schedule-bundle\'', 'experimental-client-ui-voice-input VoiceSetupPrompt key \'@deepseek-ai/dsh-experimental-voice-input-bundle\'', ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.bundle.activation\', () => ctx.slots.register(\n { name: \'plugins.bundle.activation\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:94', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:86', }, { key: 'plugins.bundle.config', @@ -1927,47 +1926,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.bundle.config\', () => ctx.slots.register(\n { name: \'plugins.bundle.config\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:110', - }, - { - key: 'plugins.bundle.usage', - kind: 'keyed', - scope: 'root', - summary: 'Bundle-owned usage guidance below the description, keyed by npm package name.', - doc: 'Bundle-owned usage guidance below the description, keyed by npm package name.', - registerOptions: [ - { - name: 'key', - requirement: 'required', - type: 'string', - doc: 'Your cell key: the entry renders where the owner dispatches this exact key. Registering an already-occupied key replaces that occupant.', - }, - ], - ownerProps: [ - '/** Bundle facts supplied to its usage guidance and entry actions. */\nexport interface PluginBundleUsageProps {\n /** The bundle whose detail page is open. */\n readonly pkg: PluginPackageRef\n}', - ], - ownerPropsReferences: [ - 'PluginPackageRef', - ], - standardProps: [ - 'useResource: UseResource', - 'useWorkspaces: SnapshotSelectorHook', - 'usePanelInfo: UsePanelInfo', - 'useSessions: UseSessions', - 'useSessionStatus: UseSessionStatus', - 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', - ], - keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: @deepseek-ai/dsh-experimental-schedule-bundle', - hookContext: '', - slotInject: '', - declaredBy: 'an entry in \'main\' (client-ui-plugin-manager), so it exists while that entry is mounted', - occupants: [ - 'client-ui-schedule SchedulePluginUsage key \'@deepseek-ai/dsh-experimental-schedule-bundle\'', - ], - replaceRisk: 'shadows-shipped-ui', - example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.bundle.usage\', () => ctx.slots.register(\n { name: \'plugins.bundle.usage\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:92', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:102', }, { key: 'plugins.detail.actions', @@ -2017,7 +1976,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.detail.actions\', () => ctx.slots.register(\n { name: \'plugins.detail.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:124', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:116', }, { key: 'plugins.detail.badge', @@ -2067,7 +2026,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.detail.badge\', () => ctx.slots.register(\n { name: \'plugins.detail.badge\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:129', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:121', }, { key: 'plugins.detail.section', @@ -2117,7 +2076,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.detail.section\', () => ctx.slots.register(\n { name: \'plugins.detail.section\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:136', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:128', }, { key: 'plugins.item', @@ -2172,7 +2131,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.item\', () => ctx.slots.register(\n { name: \'plugins.item\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:104', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:96', }, { key: 'plugins.row.config', @@ -2210,7 +2169,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.row.config\', () => ctx.slots.register(\n { name: \'plugins.row.config\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:118', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:110', }, { key: 'rightbar', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d76978aee..1a2f5d5ad7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4566,9 +4566,6 @@ importers: '@deepseek-ai/dsh-client-ui-layout': specifier: workspace:* version: link:../ui-layout - '@deepseek-ai/dsh-client-ui-plugin-manager': - specifier: workspace:* - version: link:../ui-plugin-manager '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:* version: link:../ui-primitives diff --git a/snapshots/web/minimal-preset/ui.expected.md b/snapshots/web/minimal-preset/ui.expected.md index f29261d70e..e489e916e6 100644 --- a/snapshots/web/minimal-preset/ui.expected.md +++ b/snapshots/web/minimal-preset/ui.expected.md @@ -1,6 +1,6 @@ - banner: - navigation "Session hierarchy": Use the bash tool to - - 'button "Mode details: Minimal mode"': Minimal mode + - text: Minimal mode - button "More actions" - button "Open right sidebar" - tablist: From 93a7d221edf807ff8e3826308d7e2a3e667ceca3 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 28 Sep 2026 06:55:46 -0700 Subject: [PATCH 10/49] test(web): align plugin menu snapshots and verify Creator flow --- apps/web/tests/agent-preset-selection.e2e.ts | 74 +++++++++++++++++++ .../plugin-config/official.expected.md | 4 +- .../plugin-manager/live-enabled.expected.md | 4 +- .../plugin-manager/loading.expected.md | 4 +- .../plugin-manager/manager.expected.md | 4 +- apps/web/tests/plugin-manager.e2e.ts | 2 +- 6 files changed, 87 insertions(+), 5 deletions(-) diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 48f1722d7c..1c99be13a3 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -401,3 +401,77 @@ describe('web e2e: agent-preset selection', () => { expect(tripwire.warnings).toEqual([]) }) }) + +it('starts one real Creator composition from Plugins with Coding Tools off and keeps the next task on Standard', async () => { + const scaffold = await launchWebScaffold({ developerTools: false, agentPresets: { default: 'standard' } }) + let browser: Browser | undefined + try { + browser = await chromium.launch() + const page = await newEnglishPage(browser) + const tripwire = watchConsole(page) + onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-creator')) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + const creator = scaffold.ctx.agents.list()[0] + if (creator === undefined) throw new Error('Connecting the workspace did not create a Session') + expect(scaffold.ctx.agentPresets.composedPreset(creator.ctx)).toBe('standard') + expect(await page.getByRole('button', { name: 'Standard mode', exact: true }).count()).toBe(0) + + await page.getByRole('navigation', { name: 'Global panels' }).getByRole('button', { name: 'Plugins', exact: true }).click() + await page.getByRole('button', { name: 'Choose how to add a plugin', exact: true }).click() + await page.getByRole('menuitem', { name: /^Let the agent create a plugin/ }).click() + const label = page.getByRole('button', { name: 'Mode details: Creator mode', exact: true }) + await label.waitFor() + expect(await label.getAttribute('aria-haspopup')).toBe('dialog') + expect(await page.getByRole('dialog', { name: 'Add plugin', exact: true }).count()).toBe(0) + await expect.poll(() => scaffold.ctx.sessionProjections.stateOf(creator.session, 'agentPreset')).toBe('cordis') + expect(scaffold.ctx.agentPresets.composedPreset(creator.ctx)).toBe('cordis') + expect(scaffold.ctx.tools.schemas(creator).map(tool => tool.name)) + .toEqual(expect.arrayContaining(['cordis_inspect_list', 'cordis_inspect_query', 'plugin_manager'])) + expect(creator.session.snapshotEvents().some(event => event.type === 'agent-preset/selected' + && event.data.agentPreset === 'cordis')).toBe(true) + expect(creator.session.snapshotEvents().some(event => event.type === 'user/message' || event.type === 'turn/start')).toBe(false) + expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-settings')?.value).toMatchObject({ enabled: false }) + expect(scaffold.ctx.agentPresets.defaultId).toBe('standard') + + await label.click() + await page.getByRole('dialog', { name: 'Creator mode', exact: true }).waitFor() + await page.keyboard.press('Escape') + const newSession = page.getByRole('button', { name: 'New session', exact: true }).filter({ hasText: 'New Session' }) + await Promise.all([page.waitForResponse('**/api/session/create'), newSession.click()]) + await label.waitFor() + // New Session deliberately reuses the unstarted draft; it is not proof of a new default. + expect(scaffold.ctx.agents.list().map(agent => agent.id)).toEqual([creator.id]) + expect(scaffold.ctx.agentPresets.composedPreset(creator.ctx)).toBe('cordis') + + // Close a fixture turn on the real Creator Session without requesting a model. + creator.session.append('turn/start', { turn: 1 }) + const user = creator.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Creator fixture completed without a model call.' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + creator.session.append('session/title', { title: 'Creator fixture', messageSeqs: [user.seq], source: { kind: 'fallback' } }) + creator.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await scaffold.ctx.sessions.flush(creator.session) + await page.getByText('Creator fixture completed without a model call.', { exact: true }).waitFor() + await newSession.click() + await expect.poll(() => scaffold.ctx.agents.list().find(agent => agent.id !== creator.id)).toBeDefined() + const next = scaffold.ctx.agents.list().find(agent => agent.id !== creator.id)! + expect(next.id).not.toBe(creator.id) + expect(next.session.header.agentPreset).toBe('standard') + expect(scaffold.ctx.sessionProjections.stateOf(next.session, 'agentPreset')).toBe('standard') + expect(scaffold.ctx.agentPresets.composedPreset(next.ctx)).toBe('standard') + expect(scaffold.ctx.sessionProjections.stateOf(creator.session, 'agentPreset')).toBe('cordis') + await expect.poll(() => label.count()).toBe(0) + await openSettings(page, 'en') + const settings = page.getByRole('dialog', { name: 'Settings' }) + await settings.getByRole('button', { name: 'General', exact: true }).click() + expect(await settings.getByRole('switch', { name: 'Coding Tools' }).getAttribute('aria-checked')).toBe('false') + expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-settings')?.value).toMatchObject({ enabled: false }) + expect(scaffold.ctx.agentPresets.defaultId).toBe('standard') + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } finally { + await browser?.close() + await scaffold.close() + } +}) diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md index 3906b58624..f4b2293eb0 100644 --- a/apps/web/tests/expected/plugin-config/official.expected.md +++ b/apps/web/tests/expected/plugin-config/official.expected.md @@ -2,7 +2,9 @@ - text: 安装、启用和配置插件 - button "插件说明" - button "刷新" -- button "添加插件" +- group "添加插件": + - button "添加插件" + - button "选择添加插件方式" - heading "官方" [level=3] - text: "8" - list: diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md index 23d74b2077..fa9fd12631 100644 --- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md +++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md @@ -2,7 +2,9 @@ - text: 安装、启用和配置插件 - button "插件说明" - button "刷新" -- button "添加插件" +- group "添加插件": + - button "添加插件" + - button "选择添加插件方式" - heading "官方" [level=3] - text: "8" - list: diff --git a/apps/web/tests/expected/plugin-manager/loading.expected.md b/apps/web/tests/expected/plugin-manager/loading.expected.md index 9c6fea0d43..4cf8d9ea23 100644 --- a/apps/web/tests/expected/plugin-manager/loading.expected.md +++ b/apps/web/tests/expected/plugin-manager/loading.expected.md @@ -2,7 +2,9 @@ - text: 安装、启用和配置插件 - button "插件说明" - button "刷新" [disabled] -- button "添加插件" [disabled] +- group "添加插件": + - button "添加插件" [disabled] + - button "选择添加插件方式" [disabled] - status "正在读取插件…" 1680px: 4 rows; page/group headers, rows, icons, text lines and blank action spaces align within 0.1px diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md index 7e126cd61b..89ca0d1416 100644 --- a/apps/web/tests/expected/plugin-manager/manager.expected.md +++ b/apps/web/tests/expected/plugin-manager/manager.expected.md @@ -2,7 +2,9 @@ - text: 安装、启用和配置插件 - button "插件说明" - button "刷新" -- button "添加插件" +- group "添加插件": + - button "添加插件" + - button "选择添加插件方式" - heading "官方" [level=3] - text: "8" - list: diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index b89279d173..8f55363b81 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -124,7 +124,7 @@ describe('web e2e: plugin manager', () => { expect(await skeleton.getAttribute('aria-label')).toBe('正在读取插件…') expect(await panel.getAttribute('aria-busy')).toBe('true') const actions = panel.locator(':scope > header > div:last-child button') - expect(await actions.count()).toBe(2) + expect(await actions.count()).toBe(3) for (const action of await actions.all()) expect(await action.isDisabled()).toBe(true) expect(await panel.getByRole('button', { name: '插件说明' }).isEnabled()).toBe(true) const loadingAria = await captureStableAria(probe, '[data-plugin-panel]', scaffold.workspaceCwd) From e75c2ff098c6124f7b069af2c74dd5120aaea0fd Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 28 Sep 2026 06:58:13 -0700 Subject: [PATCH 11/49] test(web): wait for Creator backend requests before assertions --- apps/web/tests/agent-preset-selection.e2e.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 1c99be13a3..f110147dfe 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -419,12 +419,15 @@ it('starts one real Creator composition from Plugins with Coding Tools off and k await page.getByRole('navigation', { name: 'Global panels' }).getByRole('button', { name: 'Plugins', exact: true }).click() await page.getByRole('button', { name: 'Choose how to add a plugin', exact: true }).click() - await page.getByRole('menuitem', { name: /^Let the agent create a plugin/ }).click() + await Promise.all([ + page.waitForResponse('**/api/agentPresets/select'), + page.getByRole('menuitem', { name: /^Let the agent create a plugin/ }).click(), + ]) const label = page.getByRole('button', { name: 'Mode details: Creator mode', exact: true }) await label.waitFor() expect(await label.getAttribute('aria-haspopup')).toBe('dialog') expect(await page.getByRole('dialog', { name: 'Add plugin', exact: true }).count()).toBe(0) - await expect.poll(() => scaffold.ctx.sessionProjections.stateOf(creator.session, 'agentPreset')).toBe('cordis') + expect(scaffold.ctx.sessionProjections.stateOf(creator.session, 'agentPreset')).toBe('cordis') expect(scaffold.ctx.agentPresets.composedPreset(creator.ctx)).toBe('cordis') expect(scaffold.ctx.tools.schemas(creator).map(tool => tool.name)) .toEqual(expect.arrayContaining(['cordis_inspect_list', 'cordis_inspect_query', 'plugin_manager'])) @@ -453,15 +456,15 @@ it('starts one real Creator composition from Plugins with Coding Tools off and k creator.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await scaffold.ctx.sessions.flush(creator.session) await page.getByText('Creator fixture completed without a model call.', { exact: true }).waitFor() - await newSession.click() - await expect.poll(() => scaffold.ctx.agents.list().find(agent => agent.id !== creator.id)).toBeDefined() + await Promise.all([page.waitForResponse('**/api/session/create'), newSession.click()]) + expect(scaffold.ctx.agents.list().find(agent => agent.id !== creator.id)).toBeDefined() const next = scaffold.ctx.agents.list().find(agent => agent.id !== creator.id)! expect(next.id).not.toBe(creator.id) expect(next.session.header.agentPreset).toBe('standard') expect(scaffold.ctx.sessionProjections.stateOf(next.session, 'agentPreset')).toBe('standard') expect(scaffold.ctx.agentPresets.composedPreset(next.ctx)).toBe('standard') expect(scaffold.ctx.sessionProjections.stateOf(creator.session, 'agentPreset')).toBe('cordis') - await expect.poll(() => label.count()).toBe(0) + await label.waitFor({ state: 'hidden' }) await openSettings(page, 'en') const settings = page.getByRole('dialog', { name: 'Settings' }) await settings.getByRole('button', { name: 'General', exact: true }).click() From 7deaab676f68e6fcab17984f30449f3c4e19f0e8 Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Mon, 28 Sep 2026 22:00:40 +0800 Subject: [PATCH 12/49] test(bench): bound corpus frame compression concurrency Starting every frame compression at once kept one native Zstandard context per pending frame alive and peaked the seed worker at 7.7 GB RSS, which exceeds the standard hosted runner's memory. Bound compression like file writes; the seed now peaks at 2.2 GB and writes identical bytes. --- benchmarks/session-corpus/synthetic-corpus.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/benchmarks/session-corpus/synthetic-corpus.ts b/benchmarks/session-corpus/synthetic-corpus.ts index de7519a28a..81ae00f642 100644 --- a/benchmarks/session-corpus/synthetic-corpus.ts +++ b/benchmarks/session-corpus/synthetic-corpus.ts @@ -19,8 +19,8 @@ import { anchorShape, sessionShape, type SessionShape } from './corpus-shape.ts' const TIME_ZERO = 1_700_000_000_000 /** Prime stride that permutes creation order independently of length rank. */ const CREATION_STRIDE = 7_919 -/** Concurrent Session file writes while materializing a corpus. */ -const WRITE_CONCURRENCY = 8 +/** Concurrent frame compressions and file writes while materializing a corpus. */ +const IO_CONCURRENCY = 8 /** Measured local Sessions per project directory: 1,650 Sessions in 88 directories. */ const SESSIONS_PER_PROJECT = 19 /** Every sixth Session is a subagent child; the measured share is 288 of 1,650. */ @@ -325,7 +325,7 @@ export function corpusHeader(rank: number, count: number): SessionHeader { } /** - * Run one asynchronous file operation per index with bounded concurrency. + * Run one asynchronous compression or file operation per index with bounded concurrency. * @param count - number of indexes, starting at zero. * @param operation - operation for one index. */ @@ -334,7 +334,7 @@ export async function forEachConcurrently(count: number, operation: (index: numb const worker = async (): Promise => { for (let index = next++; index < count; index = next++) await operation(index) } - await Promise.all(Array.from({ length: WRITE_CONCURRENCY }, () => worker())) + await Promise.all(Array.from({ length: IO_CONCURRENCY }, () => worker())) } /** Authors each anchor body once and writes corpora that share those bodies. */ @@ -369,7 +369,11 @@ export class SyntheticCorpusWriter { payloadChars += Math.ceil((1.01 * (shape.logicalBytes - bytes)) / bytesPerChar) + shape.events continue } - const compressed = await Promise.all(frames.map(text => compressZstdFrame(text))) + // Each pending compression owns a native Zstandard context, so compression is bounded like writes. + const compressed: Buffer[] = [] + await forEachConcurrently(frames.length, async (index) => { + compressed[index] = await compressZstdFrame(frames[index] as string) + }) this.bodies.set(anchor, { frames: Buffer.concat(compressed), frameCount: frames.length, events: authored.events.length, logicalBytes: bytes, }) From e806d9cb97545eb327bf9aaba80d8a52a5d08e58 Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Mon, 28 Sep 2026 22:18:58 +0800 Subject: [PATCH 13/49] test(bench): calibrate Session corpus budgets on hosted CI Standard two-CPU hosted runs are 2.4-3.4 times the reference machine, above the shared reference scale, so the budgets use rounded hosted medians with the shared 1.25 headroom. The benchmark job took 14.4 minutes with these cases; its timeout becomes 25 minutes. --- ...09-28-session-corpus-performance.i18n.yaml | 20 +++--- .../2026-09-28-session-corpus-performance.md | 24 +++---- ...026-09-28-session-corpus-performance.zh.md | 24 +++---- .github/workflows/ci.yml | 2 +- .../session-corpus/session-corpus.bench.ts | 70 +++++++++++-------- scripts/ci-workflow.spec.ts | 4 +- 6 files changed, 78 insertions(+), 66 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.i18n.yaml b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.i18n.yaml index d2100cbd3c..caa20915db 100644 --- a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.i18n.yaml @@ -15,17 +15,17 @@ en: ae4682fdb06d3192 zh: 6a48d412f15bedfa /agent-note-performance-baselines-for-large-session-corpora/decision/corpus-construction: - en: b10d48e91e1ec022 - zh: e47854e79e3fb630 + en: d350a1fbedc2e3a7 + zh: 9f4024144beeea10 /agent-note-performance-baselines-for-large-session-corpora/decision/measured-endpoints: - en: e2854f9bdf00a586 - zh: b2cc87dd1d50444e + en: c25cc40af5d6bca5 + zh: ad4ad41323816ea6 /agent-note-performance-baselines-for-large-session-corpora/calibration-evidence: - en: d18a054dd2d204da - zh: 054c48ee9fe17c8b + en: 92d9f4b25d028690 + zh: 08ec11c2d9cba4d7 /agent-note-performance-baselines-for-large-session-corpora/alternatives-considered: - en: a17b4375a6350360 - zh: 4d2b9b9e691940a9 + en: 6cb37cadd5533349 + zh: fa4415f7c407de89 /agent-note-performance-baselines-for-large-session-corpora/consequences: - en: b82f7c00d2271186 - zh: 70a0da23fe292a27 + en: 1918124e4bae0f76 + zh: abd8d168700b4c08 diff --git a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.md b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.md index a69a845270..e97f5faea3 100644 --- a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.md +++ b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.md @@ -29,7 +29,7 @@ Other measured aggregates also set the fixture: 19 Sessions per project director ### Corpus construction -The seed worker authors one event body per anchor, compresses it once, and writes each Session as its own header frame followed by the anchor's body. Sessions sharing an anchor differ only in header identity, creation time, project directory, and parent link. Creation time is a fixed permutation of rank, so list order does not follow length. Events carry the fields that `Session.append` records, compact streams come from the production `AssistantStreamAccumulator`, and rows are encoded with the persistence package's `eventLines`. A verification worker replays each distinct body through `sessionQuery.readSession`. +The seed worker authors one event body per anchor, compresses it once, and writes each Session as its own header frame followed by the anchor's body. Frame compression and file writes run eight at a time: each pending compression holds a native Zstandard context, and unbounded compression peaked at 7.7 GB RSS, more than a standard hosted runner provides. Sessions sharing an anchor differ only in header identity, creation time, project directory, and parent link. Creation time is a fixed permutation of rank, so list order does not follow length. Events carry the fields that `Session.append` records, compact streams come from the production `AssistantStreamAccumulator`, and rows are encoded with the persistence package's `eventLines`. A verification worker replays each distinct body through `sessionQuery.readSession`. The projection cache is part of the list endpoint: each cold row carries cached projection values. The seed folds each anchor once through the public `sessionProjectionCache.coldSnapshot` write-back. It then writes per-record documents for every Session with `serializeRecord`, rebinding identity to each header. Writing through the domain API costs one fsync per record, which took 89 s for 10,000 Sessions. The list worker requires every returned row to carry projections, so a storage layout change fails the benchmark instead of silently removing that work. @@ -43,30 +43,30 @@ Workers are compiled plain-Node processes, each started fresh against a seeded r | Content search | 1,000 | 1 | First `session.search`, including the index build; a second query with a different term | | Fork | 1,000 | 3 | `session.fork` return for rank midpoints of ten equal length strata, rank 990, and rank 999 | -Budgets enforce medians: list boot, first, and repeat; search first and repeat; fork median over strata, rank 990, and rank 999. One search sample is enforced because a cold index build takes about a minute on the reference machine. Reports include peak RSS and heap after the endpoint, but no memory budget applies. +Budgets enforce medians: list boot, first, and repeat; search first and repeat; fork median over strata, rank 990, and rank 999. One search sample is enforced because a cold index build takes over two minutes on standard hosted CI. Reports include peak RSS and heap after the endpoint, but no memory budget applies. ## Calibration evidence -Reference expectations come from Apple M5 Pro, macOS arm64, Node 26.5, measured in isolated worker runs. They are scaled through the shared CI factor and headroom. +Budgets use standard two-CPU hosted CI expectations (AMD EPYC 7763, Linux x64, Node 24.21, PR #5403), rounded above the recorded medians and multiplied by the shared 1.25 headroom. Hosted samples varied by less than 4% within each case. The hosted-to-reference ratio is 2.4 to 3.4, above the shared reference scale of 2, so reference-machine scaling would reject ordinary hosted runs. -| Endpoint | Observed | Expectation | -|---|---|---:| -| List 1,000: boot / first / repeat | 237–271 / 311–358 / 254–293 ms | 300 / 400 / 300 ms | -| List 5,000: boot / first / repeat | 1,182–1,261 / 1,364–1,603 / 1,287–1,588 ms | 1,300 / 1,700 / 1,600 ms | -| Search 1,000: first / repeat | 57,006 / 1,046 ms | 60,000 / 1,100 ms | -| Fork: strata median / p99 / longest | 29–32 / 608–672 / 6,000–6,365 ms | 40 / 700 / 6,500 ms | +| Endpoint | Hosted medians | Expectation | Budget | +|---|---|---:|---:| +| List 1,000: boot / first / repeat | 830.3 / 1,155.3 / 876.7 ms | 850 / 1,200 / 900 ms | 1,063 / 1,500 / 1,125 ms | +| List 5,000: boot / first / repeat | 3,569.5 / 5,143.5 / 4,290.5 ms | 3,600 / 5,200 / 4,300 ms | 4,500 / 6,500 / 5,375 ms | +| Search 1,000: first / repeat (one sample) | 139,470 / 3,369 ms | 140,000 / 3,400 ms | 175,000 / 4,250 ms | +| Fork: strata median / p99 / longest | 89.6 / 1,485.7 / 17,538.1 ms | 90 / 1,500 / 17,600 ms | 113 / 1,875 / 22,000 ms | -A 10,000-Session list took 3.2–5.4 s for the first call with 1.9 GB peak RSS; the extreme list case uses 5,000 Sessions to bound seeding time and disk use. +On Apple M5 Pro with Node 26.5, isolated runs measured list 1,000 at 311–358 ms first, list 5,000 at 1,364–1,603 ms first, search at 57 s first, and fork of the longest Session at 6.0–6.4 s. A 10,000-Session list took 3.2–5.4 s for the first call with 1.9 GB peak RSS; the extreme list case uses 5,000 Sessions to bound seeding time and disk use. ## Alternatives considered - **Sanitized copies of real Sessions.** Rejected: benchmarks never use recorded user material. - **Authoring every Session through `Session.append`.** Rejected: per-append validation and freezing, repeated per Session, made seeding take minutes. - **Omitting the projection cache.** Rejected: rows would lack the values the Web list shows, which understates list work. -- **A separate CI job.** Rejected: benchmarks share one idle standard runner; the job timeout rises to 30 minutes instead. +- **A separate CI job.** Rejected: benchmarks share one idle standard runner. The file adds 6.6 minutes to that job, including 68 s of seeding, and the job timeout rises from 15 to 25 minutes. ## Consequences -The gate covers corpus-scale list, search, and fork costs that single-Session gates cannot see. It already shows that content search spends about a minute building its index on first use, that list cost grows linearly to about 1.5 s at 5,000 Sessions, and that forking the longest Session takes about 6 s. Seeding writes about 1.9 GB and adds roughly half a minute locally. +The gate covers corpus-scale list, search, and fork costs that single-Session gates cannot see. On standard hosted CI it already shows that content search spends 139 s building its index on first use, that the first list takes 5.1 s at 5,000 Sessions, and that forking the longest Session takes 17.5 s. Seeding writes about 1.9 GB. Exclusions: the corpus is current-generation only, although most real Sessions are stored in older generations, which list revision hashing and migration-on-read would add. Only 22 distinct bodies exist, so search indexes repeated text. Fork runs without Agent presets or Workspace attachment. Calls are in-process, without Typert transport or a browser. Filesystem caches are not evicted, so cold means a fresh process, not cold storage. diff --git a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.zh.md b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.zh.md index 79c7fc2792..d2549c8f16 100644 --- a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.zh.md +++ b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.zh.md @@ -29,7 +29,7 @@ Status: implemented ### 语料构建 -播种 worker 为每个锚点生成一份事件主体,只压缩一次,然后把每个 Session 写成独立的 header 帧加上该锚点的主体。共享锚点的 Session 只在 header 身份、创建时间、项目目录和父链接上不同。创建时间是排名的固定置换,因此列表顺序不跟随长度。事件携带 `Session.append` 记录的字段,紧凑流来自生产 `AssistantStreamAccumulator`,行通过持久化包的 `eventLines` 编码。验证 worker 通过 `sessionQuery.readSession` 回放每份不同的主体。 +播种 worker 为每个锚点生成一份事件主体,只压缩一次,然后把每个 Session 写成独立的 header 帧加上该锚点的主体。帧压缩和文件写入每次并发八个:每个待完成的压缩都持有一个原生 Zstandard 上下文,不加限制的压缩峰值 RSS 达到 7.7 GB,超过标准托管运行器的内存。共享锚点的 Session 只在 header 身份、创建时间、项目目录和父链接上不同。创建时间是排名的固定置换,因此列表顺序不跟随长度。事件携带 `Session.append` 记录的字段,紧凑流来自生产 `AssistantStreamAccumulator`,行通过持久化包的 `eventLines` 编码。验证 worker 通过 `sessionQuery.readSession` 回放每份不同的主体。 投影缓存属于列表端点:每个冷行都携带缓存的投影值。播种过程通过公开的 `sessionProjectionCache.coldSnapshot` 回写,为每个锚点折叠一次。之后用 `serializeRecord` 为每个 Session 写入逐记录文档,并把身份重新绑定到各自的 header。通过 domain API 写入时每条记录一次 fsync,10,000 个 Session 耗时 89 s。列表 worker 要求返回的每一行都携带投影,因此存储布局变化会使基准失败,而不是静默去掉这部分工作。 @@ -43,30 +43,30 @@ worker 是编译后的纯 Node 进程,每次都针对已播种根目录全新 | 内容搜索 | 1,000 | 1 | 首次 `session.search`,包括索引构建;使用不同词项的第二次查询 | | Fork | 1,000 | 3 | 十个等长度分层的中点排名、排名 990 和排名 999 的 `session.fork` 返回 | -预算约束中位数:列表的启动、首次和重复;搜索的首次和重复;fork 的分层中位数、排名 990 和排名 999。搜索只约束一个样本,因为在参考机器上一次冷索引构建约需一分钟。报告包含端点之后的峰值 RSS 和堆,但不设内存预算。 +预算约束中位数:列表的启动、首次和重复;搜索的首次和重复;fork 的分层中位数、排名 990 和排名 999。搜索只约束一个样本,因为在标准托管 CI 上一次冷索引构建超过两分钟。报告包含端点之后的峰值 RSS 和堆,但不设内存预算。 ## 校准证据 -参考期望值来自 Apple M5 Pro、macOS arm64、Node 26.5 上隔离的 worker 运行,并通过共享 CI 系数和余量缩放。 +预算使用标准双 CPU 托管 CI 的期望值(AMD EPYC 7763、Linux x64、Node 24.21、PR #5403),取高于记录中位数的整数,再乘以共享的 1.25 余量。每个用例内的托管样本差异小于 4%。托管与参考机器的比值为 2.4 到 3.4,高于共享的参考缩放系数 2,因此按参考机器缩放会拒绝正常的托管运行。 -| 端点 | 观测值 | 期望值 | -|---|---|---:| -| 列表 1,000:启动 / 首次 / 重复 | 237–271 / 311–358 / 254–293 ms | 300 / 400 / 300 ms | -| 列表 5,000:启动 / 首次 / 重复 | 1,182–1,261 / 1,364–1,603 / 1,287–1,588 ms | 1,300 / 1,700 / 1,600 ms | -| 搜索 1,000:首次 / 重复 | 57,006 / 1,046 ms | 60,000 / 1,100 ms | -| Fork:分层中位数 / p99 / 最长 | 29–32 / 608–672 / 6,000–6,365 ms | 40 / 700 / 6,500 ms | +| 端点 | 托管中位数 | 期望值 | 预算 | +|---|---|---:|---:| +| 列表 1,000:启动 / 首次 / 重复 | 830.3 / 1,155.3 / 876.7 ms | 850 / 1,200 / 900 ms | 1,063 / 1,500 / 1,125 ms | +| 列表 5,000:启动 / 首次 / 重复 | 3,569.5 / 5,143.5 / 4,290.5 ms | 3,600 / 5,200 / 4,300 ms | 4,500 / 6,500 / 5,375 ms | +| 搜索 1,000:首次 / 重复(一个样本) | 139,470 / 3,369 ms | 140,000 / 3,400 ms | 175,000 / 4,250 ms | +| Fork:分层中位数 / p99 / 最长 | 89.6 / 1,485.7 / 17,538.1 ms | 90 / 1,500 / 17,600 ms | 113 / 1,875 / 22,000 ms | -10,000 个 Session 的首次列表耗时 3.2–5.4 s,峰值 RSS 为 1.9 GB;极端列表用例使用 5,000 个 Session,以限制播种时间和磁盘占用。 +在 Apple M5 Pro 和 Node 26.5 上的隔离运行中,1,000 个 Session 的首次列表为 311–358 ms,5,000 个 Session 的首次列表为 1,364–1,603 ms,首次搜索为 57 s,fork 最长 Session 为 6.0–6.4 s。10,000 个 Session 的首次列表耗时 3.2–5.4 s,峰值 RSS 为 1.9 GB;极端列表用例使用 5,000 个 Session,以限制播种时间和磁盘占用。 ## 考虑过的替代方案 - **脱敏复制真实 Session。** 已否决:基准从不使用录制的用户材料。 - **通过 `Session.append` 生成每个 Session。** 已否决:逐次追加的验证和冻结在每个 Session 上重复,使播种耗时数分钟。 - **省略投影缓存。** 已否决:行会缺少 Web 列表显示的值,从而低估列表工作量。 -- **单独的 CI 任务。** 已否决:基准共享一台空闲的标准运行器;改为把任务超时提高到 30 分钟。 +- **单独的 CI 任务。** 已否决:基准共享一台空闲的标准运行器。此文件让该任务增加 6.6 分钟,其中播种 68 s,任务超时从 15 分钟提高到 25 分钟。 ## 后果 -该门禁覆盖单 Session 门禁看不到的语料规模列表、搜索和 fork 成本。它已经显示:内容搜索在首次使用时约花一分钟构建索引,列表成本线性增长,在 5,000 个 Session 时约 1.5 s,fork 最长 Session 约需 6 s。播种写入约 1.9 GB,本地约增加半分钟。 +该门禁覆盖单 Session 门禁看不到的语料规模列表、搜索和 fork 成本。在标准托管 CI 上,它已经显示:内容搜索在首次使用时花 139 s 构建索引,5,000 个 Session 的首次列表耗时 5.1 s,fork 最长 Session 耗时 17.5 s。播种写入约 1.9 GB。 排除项:语料只包含当前代,而大多数真实 Session 以旧代存储,这会增加列表的修订哈希和读取时迁移。只有 22 份不同主体,因此搜索索引的是重复文本。Fork 不挂载 Agent preset,也不附加 Workspace。调用在进程内完成,不经过 Typert 传输或浏览器。不驱逐文件系统缓存,因此“冷”指全新进程,而不是冷存储。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f913bb496f..a8ea782318 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -196,7 +196,7 @@ jobs: name: node 24 / benchmarks # Wall-clock budgets need an otherwise idle runner, so this job runs the # benchmark lane alone instead of joining a concurrent gate aggregate. - timeout-minutes: 30 + timeout-minutes: 25 steps: - uses: actions/checkout@v6 with: diff --git a/benchmarks/session-corpus/session-corpus.bench.ts b/benchmarks/session-corpus/session-corpus.bench.ts index c55ae78855..b47ff7f54b 100644 --- a/benchmarks/session-corpus/session-corpus.bench.ts +++ b/benchmarks/session-corpus/session-corpus.bench.ts @@ -5,7 +5,7 @@ import { availableParallelism, cpus, tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { runBuiltBenchmarkWorker } from '../support/built-worker.ts' -import { ciTimeBudget } from '../support/calibration.ts' +import { PERFORMANCE_BUDGET_HEADROOM } from '../support/calibration.ts' import { anchorShape, ANCHOR_COUNT, sessionShape } from './corpus-shape.ts' import type { ForkReport, @@ -20,7 +20,7 @@ import type { const CORPUS = { standard: 1_000, extreme: 5_000 } as const /** Fresh processes per list and fork scenario; the median enforces each budget. */ const ATTEMPTS = { list: 5, fork: 3 } as const -/** One cold content-search sample: the index build takes about a minute on the reference machine. */ +/** One cold content-search sample: the index build takes over two minutes on standard hosted CI. */ const SEARCH_ATTEMPTS = 1 /** Midpoints of ten equal length strata of the standard corpus, then its p99 and longest Session. */ const FORK_STRATA = [50, 150, 250, 350, 450, 550, 650, 750, 850, 950] as const @@ -30,18 +30,26 @@ const SEED_TIMEOUT_MS = 600_000 const WORKER_TIMEOUT_MS = 300_000 const WORKER = join(import.meta.dirname, '..', '.dsh-build', 'session-corpus', 'session-corpus.worker.js') -/** Apple M5 Pro / Node 26.5 expectations, before shared CI scaling and variance headroom. */ -const EXPECTED_MS = { - listBoot: { standard: 300, extreme: 1_300 }, - listFirst: { standard: 400, extreme: 1_700 }, - listRepeat: { standard: 300, extreme: 1_600 }, - searchFirst: 60_000, - searchRepeat: 1_100, - forkStratumMedian: 40, - forkP99: 700, - forkLongest: 6_500, +/** + * Standard two-CPU hosted CI expectations, rounded above the recorded medians before variance headroom. + * Recorded medians: list 1,000 830.3 / 1,155.3 / 876.7 ms; list 5,000 3,569.5 / 5,143.5 / 4,290.5 ms; + * search 139,470 / 3,369 ms; fork 89.6 / 1,485.7 / 17,538.1 ms. + */ +const EXPECTED_CI_MS = { + listBoot: { standard: 850, extreme: 3_600 }, + listFirst: { standard: 1_200, extreme: 5_200 }, + listRepeat: { standard: 900, extreme: 4_300 }, + searchFirst: 140_000, + searchRepeat: 3_400, + forkStratumMedian: 90, + forkP99: 1_500, + forkLongest: 17_600, } as const +function budget(expectedCiMs: number): number { + return Math.ceil(expectedCiMs * PERFORMANCE_BUDGET_HEADROOM) +} + type CorpusSize = keyof typeof CORPUS async function run(args: readonly string[], timeoutMs = WORKER_TIMEOUT_MS): Promise { @@ -62,8 +70,8 @@ function rounded(values: readonly number[]): number[] { return values.map(value => Math.round(value * 10) / 10) } -function expectWithinBudget(value: number, budget: number): void { - expect(value).toBeLessThanOrEqual(budget) +function expectWithinBudget(value: number, limit: number): void { + expect(value).toBeLessThanOrEqual(limit) } function environment() { @@ -91,15 +99,19 @@ describe('Session corpus workload', () => { expect(() => sessionShape(CORPUS.standard, CORPUS.standard)).toThrow('outside') }) - it('rejects endpoint medians more than a quarter above their CI expectation', () => { - const expectations = [ - EXPECTED_MS.listFirst.extreme, EXPECTED_MS.searchFirst, EXPECTED_MS.forkLongest, EXPECTED_MS.forkStratumMedian, - ] - for (const expected of expectations) { - const budget = ciTimeBudget(expected) - expectWithinBudget(expected * 2, budget) - expect(() => expectWithinBudget(Math.ceil(expected * 2 * 1.26), budget)).toThrow() + it('accepts recorded hosted medians and rejects medians a quarter above their expectation', () => { + const recorded = [ + [median([3_569.4, 3_634.7, 3_569.5, 3_536, 3_624.8]), EXPECTED_CI_MS.listBoot.extreme], + [median([5_206.2, 5_043, 5_143.5, 5_167.9, 5_047.4]), EXPECTED_CI_MS.listFirst.extreme], + [139_470, EXPECTED_CI_MS.searchFirst], + [median([17_573.2, 17_532.9, 17_538.1]), EXPECTED_CI_MS.forkLongest], + [median([88.2, 89.6, 91.8]), EXPECTED_CI_MS.forkStratumMedian], + ] as const + for (const [value, expected] of recorded) { + expectWithinBudget(value, budget(expected)) + expect(() => expectWithinBudget(Math.ceil(expected * 1.26), budget(expected))).toThrow() } + expect(budget(EXPECTED_CI_MS.listFirst.extreme)).toBe(6_500) }) }) @@ -127,9 +139,9 @@ describe('Session corpus operations', () => { for (const size of ['standard', 'extreme'] as const) { const budgets = { - bootMs: ciTimeBudget(EXPECTED_MS.listBoot[size]), - firstMs: ciTimeBudget(EXPECTED_MS.listFirst[size]), - repeatMs: ciTimeBudget(EXPECTED_MS.listRepeat[size]), + bootMs: budget(EXPECTED_CI_MS.listBoot[size]), + firstMs: budget(EXPECTED_CI_MS.listFirst[size]), + repeatMs: budget(EXPECTED_CI_MS.listRepeat[size]), } it(`lists ${String(CORPUS[size])} Sessions after a cold Host boot`, async () => { @@ -153,7 +165,7 @@ describe('Session corpus operations', () => { } it(`searches ${String(CORPUS.standard)} Sessions with a cold and then a built content index`, async () => { - const budgets = { firstMs: ciTimeBudget(EXPECTED_MS.searchFirst), repeatMs: ciTimeBudget(EXPECTED_MS.searchRepeat) } + const budgets = { firstMs: budget(EXPECTED_CI_MS.searchFirst), repeatMs: budget(EXPECTED_CI_MS.searchRepeat) } const reports: SearchReport[] = [] for (let attempt = 0; attempt < SEARCH_ATTEMPTS; attempt++) { reports.push(await run([root('standard'), 'search'])) @@ -165,9 +177,9 @@ describe('Session corpus operations', () => { it(`forks Sessions across the length distribution of ${String(CORPUS.standard)} Sessions`, async () => { const budgets = { - stratumMedianMs: ciTimeBudget(EXPECTED_MS.forkStratumMedian), - p99Ms: ciTimeBudget(EXPECTED_MS.forkP99), - longestMs: ciTimeBudget(EXPECTED_MS.forkLongest), + stratumMedianMs: budget(EXPECTED_CI_MS.forkStratumMedian), + p99Ms: budget(EXPECTED_CI_MS.forkP99), + longestMs: budget(EXPECTED_CI_MS.forkLongest), } const ranks = [...FORK_STRATA, FORK_P99_RANK, FORK_LONGEST_RANK].map(String) const reports: ForkReport[] = [] diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index b4a9ca194f..b70255cc3a 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -464,10 +464,10 @@ describe('CI workflow', () => { }) }) - it('bounds the complete benchmark job to thirty minutes', () => { + it('bounds the complete benchmark job to twenty-five minutes', () => { const benchmark = workflowJob(loadWorkflow('.github/workflows/ci.yml'), 'node-24-bench') - expect(benchmark['timeout-minutes']).toBe(30) + expect(benchmark['timeout-minutes']).toBe(25) expect(benchmark.steps).toContainEqual({ name: 'Run performance benchmarks', env: { DSH_GATE_VERBOSE: '1' }, From 479de510cafe40d7fdc7e8980f074c0955c1c096 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 28 Sep 2026 07:20:26 -0700 Subject: [PATCH 14/49] test(subagent): dispose continuable fixtures before removing storage --- .../tool-subagent/tests/tool-subagent.spec.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index e916089552..ba36e88d34 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -1176,17 +1176,20 @@ describe('dsh-tool-subagent background mode', () => { }) describe('dsh-tool-subagent continuable background mode', () => { - const roots: string[] = [] - afterEach(() => { - for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) + const fixtures: { ctx: Context; root: string }[] = [] + afterEach(async () => { + for (const { ctx, root } of fixtures.splice(0)) { + await ctx.fiber.dispose() + rmSync(root, { recursive: true, force: true }) + } }) /** Boot the real continuable stack without any model-facing follow-up adapter. */ async function continuableSetup() { const ctx = new Context() - await mountAgentLoopTestDependencies(ctx) const root = mkdtempSync(path.join(tmpdir(), 'dsh-tool-subagent-continuable-')) - roots.push(root) + fixtures.push({ ctx, root }) + await mountAgentLoopTestDependencies(ctx) await ctx.plugin(JsonlSessionPersistence, { root }) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentRuntime) From bfcd447963f954ef40c7e2914e472c625027948e Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Mon, 28 Sep 2026 23:08:19 +0800 Subject: [PATCH 15/49] test(bench): fit Session corpus benchmark within five minutes Keep each case at its hardest setting that fits five minutes of hosted CI: search and fork stay at 1,000 Sessions with the longest fork sampled once, the list runs 3 samples over 3,000 Sessions, and seeding validates authored bodies in memory instead of a separate verification pass. The file's last case asserts the limit; the benchmark job timeout becomes 20 minutes. --- ...09-28-session-corpus-performance.i18n.yaml | 20 +-- .../2026-09-28-session-corpus-performance.md | 16 +- ...026-09-28-session-corpus-performance.zh.md | 16 +- .github/workflows/ci.yml | 2 +- benchmarks/session-corpus/README.i18n.yaml | 12 +- benchmarks/session-corpus/README.md | 8 +- benchmarks/session-corpus/README.zh.md | 8 +- .../session-corpus/session-corpus.bench.ts | 140 +++++++++--------- .../session-corpus/session-corpus.worker.ts | 50 ++----- benchmarks/session-corpus/synthetic-corpus.ts | 19 ++- scripts/ci-workflow.spec.ts | 4 +- 11 files changed, 143 insertions(+), 152 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.i18n.yaml b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.i18n.yaml index caa20915db..5b09882e62 100644 --- a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.i18n.yaml @@ -15,17 +15,17 @@ en: ae4682fdb06d3192 zh: 6a48d412f15bedfa /agent-note-performance-baselines-for-large-session-corpora/decision/corpus-construction: - en: d350a1fbedc2e3a7 - zh: 9f4024144beeea10 + en: 6f1022f35e1e27e9 + zh: c8628ac26c642439 /agent-note-performance-baselines-for-large-session-corpora/decision/measured-endpoints: - en: c25cc40af5d6bca5 - zh: ad4ad41323816ea6 + en: d9406e015ec714f3 + zh: f9cf3fbf2f0f2f7c /agent-note-performance-baselines-for-large-session-corpora/calibration-evidence: - en: 92d9f4b25d028690 - zh: 08ec11c2d9cba4d7 + en: d140f581c2c4d379 + zh: 1bc8b660d3ae9bfe /agent-note-performance-baselines-for-large-session-corpora/alternatives-considered: - en: 6cb37cadd5533349 - zh: fa4415f7c407de89 + en: 4eaa4666262a1aee + zh: 7e2eb44b94bad05d /agent-note-performance-baselines-for-large-session-corpora/consequences: - en: 1918124e4bae0f76 - zh: abd8d168700b4c08 + en: 29df18ac019a65b2 + zh: 72ff0cdbc46907c6 diff --git a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.md b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.md index e97f5faea3..0d7225acb2 100644 --- a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.md +++ b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.md @@ -29,7 +29,7 @@ Other measured aggregates also set the fixture: 19 Sessions per project director ### Corpus construction -The seed worker authors one event body per anchor, compresses it once, and writes each Session as its own header frame followed by the anchor's body. Frame compression and file writes run eight at a time: each pending compression holds a native Zstandard context, and unbounded compression peaked at 7.7 GB RSS, more than a standard hosted runner provides. Sessions sharing an anchor differ only in header identity, creation time, project directory, and parent link. Creation time is a fixed permutation of rank, so list order does not follow length. Events carry the fields that `Session.append` records, compact streams come from the production `AssistantStreamAccumulator`, and rows are encoded with the persistence package's `eventLines`. A verification worker replays each distinct body through `sessionQuery.readSession`. +The seed worker authors one event body per anchor, compresses it once, and writes each Session as its own header frame followed by the anchor's body. Frame compression and file writes run eight at a time: each pending compression holds a native Zstandard context, and unbounded compression peaked at 7.7 GB RSS, more than a standard hosted runner provides. Sessions sharing an anchor differ only in header identity, creation time, project directory, and parent link. Creation time is a fixed permutation of rank, so list order does not follow length. Events carry the fields that `Session.append` records, compact streams come from the production `AssistantStreamAccumulator`, and rows are encoded with the persistence package's `eventLines`. Before writing, the seed replays each authored body through `Session.create`, as `sessionQuery.readSession` does. The projection cache is part of the list endpoint: each cold row carries cached projection values. The seed folds each anchor once through the public `sessionProjectionCache.coldSnapshot` write-back. It then writes per-record documents for every Session with `serializeRecord`, rebinding identity to each header. Writing through the domain API costs one fsync per record, which took 89 s for 10,000 Sessions. The list worker requires every returned row to carry projections, so a storage layout change fails the benchmark instead of silently removing that work. @@ -39,11 +39,11 @@ Workers are compiled plain-Node processes, each started fresh against a seeded r | Case | Corpus | Samples | Endpoint | |---|---|---:|---| -| List | 1,000 and 5,000 | 5 | Host boot; first `session.list`; repeated `session.list` in the same process | +| List | 3,000 | 3 | Host boot; first `session.list`; repeated `session.list` in the same process | | Content search | 1,000 | 1 | First `session.search`, including the index build; a second query with a different term | -| Fork | 1,000 | 3 | `session.fork` return for rank midpoints of ten equal length strata, rank 990, and rank 999 | +| Fork | 1,000 | 3, then 1 | `session.fork` return for rank midpoints of ten equal length strata and rank 990; then rank 999 once | -Budgets enforce medians: list boot, first, and repeat; search first and repeat; fork median over strata, rank 990, and rank 999. One search sample is enforced because a cold index build takes over two minutes on standard hosted CI. Reports include peak RSS and heap after the endpoint, but no memory budget applies. +The file must finish within five minutes on standard hosted CI, including seeding, and its last case asserts that limit. Search over 1,000 Sessions and one fork of the longest Session take about 170 s of it, so each has one sample. The list corpus is the largest that fits the remainder. Budgets enforce medians of list boot, first, and repeat, and of the fork median over strata and rank 990; search and the longest fork enforce their single samples. Reports include peak RSS and heap after the endpoint, but no memory budget applies. ## Calibration evidence @@ -51,8 +51,7 @@ Budgets use standard two-CPU hosted CI expectations (AMD EPYC 7763, Linux x64, N | Endpoint | Hosted medians | Expectation | Budget | |---|---|---:|---:| -| List 1,000: boot / first / repeat | 830.3 / 1,155.3 / 876.7 ms | 850 / 1,200 / 900 ms | 1,063 / 1,500 / 1,125 ms | -| List 5,000: boot / first / repeat | 3,569.5 / 5,143.5 / 4,290.5 ms | 3,600 / 5,200 / 4,300 ms | 4,500 / 6,500 / 5,375 ms | +| List 3,000: boot / first / repeat | interpolated between 1,000 (830.3 / 1,155.3 / 876.7 ms) and 5,000 (3,569.5 / 5,143.5 / 4,290.5 ms) | 2,200 / 3,200 / 2,600 ms | 2,750 / 4,000 / 3,250 ms | | Search 1,000: first / repeat (one sample) | 139,470 / 3,369 ms | 140,000 / 3,400 ms | 175,000 / 4,250 ms | | Fork: strata median / p99 / longest | 89.6 / 1,485.7 / 17,538.1 ms | 90 / 1,500 / 17,600 ms | 113 / 1,875 / 22,000 ms | @@ -63,10 +62,11 @@ On Apple M5 Pro with Node 26.5, isolated runs measured list 1,000 at 311–358 m - **Sanitized copies of real Sessions.** Rejected: benchmarks never use recorded user material. - **Authoring every Session through `Session.append`.** Rejected: per-append validation and freezing, repeated per Session, made seeding take minutes. - **Omitting the projection cache.** Rejected: rows would lack the values the Web list shows, which understates list work. -- **A separate CI job.** Rejected: benchmarks share one idle standard runner. The file adds 6.6 minutes to that job, including 68 s of seeding, and the job timeout rises from 15 to 25 minutes. +- **A separate CI job.** Rejected: benchmarks share one idle standard runner. This file is limited to five minutes, and the job timeout rises from 15 to 20 minutes. +- **Listing 5,000 Sessions with five samples, and two list sizes.** Rejected: with the other cases the file took 6.6 minutes on standard hosted CI. ## Consequences -The gate covers corpus-scale list, search, and fork costs that single-Session gates cannot see. On standard hosted CI it already shows that content search spends 139 s building its index on first use, that the first list takes 5.1 s at 5,000 Sessions, and that forking the longest Session takes 17.5 s. Seeding writes about 1.9 GB. +The gate covers corpus-scale list, search, and fork costs that single-Session gates cannot see. On standard hosted CI it already shows that content search spends 139 s building its index on first use, that the first list took 5.1 s at 5,000 Sessions in calibration, and that forking the longest Session takes 17.5 s. Seeding writes about 1.2 GB. Exclusions: the corpus is current-generation only, although most real Sessions are stored in older generations, which list revision hashing and migration-on-read would add. Only 22 distinct bodies exist, so search indexes repeated text. Fork runs without Agent presets or Workspace attachment. Calls are in-process, without Typert transport or a browser. Filesystem caches are not evicted, so cold means a fresh process, not cold storage. diff --git a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.zh.md b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.zh.md index d2549c8f16..1e50f88b44 100644 --- a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.zh.md +++ b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.zh.md @@ -29,7 +29,7 @@ Status: implemented ### 语料构建 -播种 worker 为每个锚点生成一份事件主体,只压缩一次,然后把每个 Session 写成独立的 header 帧加上该锚点的主体。帧压缩和文件写入每次并发八个:每个待完成的压缩都持有一个原生 Zstandard 上下文,不加限制的压缩峰值 RSS 达到 7.7 GB,超过标准托管运行器的内存。共享锚点的 Session 只在 header 身份、创建时间、项目目录和父链接上不同。创建时间是排名的固定置换,因此列表顺序不跟随长度。事件携带 `Session.append` 记录的字段,紧凑流来自生产 `AssistantStreamAccumulator`,行通过持久化包的 `eventLines` 编码。验证 worker 通过 `sessionQuery.readSession` 回放每份不同的主体。 +播种 worker 为每个锚点生成一份事件主体,只压缩一次,然后把每个 Session 写成独立的 header 帧加上该锚点的主体。帧压缩和文件写入每次并发八个:每个待完成的压缩都持有一个原生 Zstandard 上下文,不加限制的压缩峰值 RSS 达到 7.7 GB,超过标准托管运行器的内存。共享锚点的 Session 只在 header 身份、创建时间、项目目录和父链接上不同。创建时间是排名的固定置换,因此列表顺序不跟随长度。事件携带 `Session.append` 记录的字段,紧凑流来自生产 `AssistantStreamAccumulator`,行通过持久化包的 `eventLines` 编码。写入之前,播种过程按 `sessionQuery.readSession` 的方式,通过 `Session.create` 回放每份生成的主体。 投影缓存属于列表端点:每个冷行都携带缓存的投影值。播种过程通过公开的 `sessionProjectionCache.coldSnapshot` 回写,为每个锚点折叠一次。之后用 `serializeRecord` 为每个 Session 写入逐记录文档,并把身份重新绑定到各自的 header。通过 domain API 写入时每条记录一次 fsync,10,000 个 Session 耗时 89 s。列表 worker 要求返回的每一行都携带投影,因此存储布局变化会使基准失败,而不是静默去掉这部分工作。 @@ -39,11 +39,11 @@ worker 是编译后的纯 Node 进程,每次都针对已播种根目录全新 | 用例 | 语料 | 样本数 | 终点 | |---|---|---:|---| -| 列表 | 1,000 和 5,000 | 5 | Host 启动;首次 `session.list`;同一进程中重复的 `session.list` | +| 列表 | 3,000 | 3 | Host 启动;首次 `session.list`;同一进程中重复的 `session.list` | | 内容搜索 | 1,000 | 1 | 首次 `session.search`,包括索引构建;使用不同词项的第二次查询 | -| Fork | 1,000 | 3 | 十个等长度分层的中点排名、排名 990 和排名 999 的 `session.fork` 返回 | +| Fork | 1,000 | 3,然后 1 | 十个等长度分层的中点排名和排名 990 的 `session.fork` 返回;然后排名 999 一次 | -预算约束中位数:列表的启动、首次和重复;搜索的首次和重复;fork 的分层中位数、排名 990 和排名 999。搜索只约束一个样本,因为在标准托管 CI 上一次冷索引构建超过两分钟。报告包含端点之后的峰值 RSS 和堆,但不设内存预算。 +此文件(包括播种)必须在标准托管 CI 上五分钟内完成,其最后一个用例断言该上限。1,000 个 Session 的搜索和最长 Session 的一次 fork 约占其中 170 s,因此两者各取一个样本。列表语料是剩余时间内能容纳的最大规模。预算约束列表启动、首次和重复的中位数,以及 fork 分层中位数和排名 990 的中位数;搜索和最长 fork 约束各自的单个样本。报告包含端点之后的峰值 RSS 和堆,但不设内存预算。 ## 校准证据 @@ -51,8 +51,7 @@ worker 是编译后的纯 Node 进程,每次都针对已播种根目录全新 | 端点 | 托管中位数 | 期望值 | 预算 | |---|---|---:|---:| -| 列表 1,000:启动 / 首次 / 重复 | 830.3 / 1,155.3 / 876.7 ms | 850 / 1,200 / 900 ms | 1,063 / 1,500 / 1,125 ms | -| 列表 5,000:启动 / 首次 / 重复 | 3,569.5 / 5,143.5 / 4,290.5 ms | 3,600 / 5,200 / 4,300 ms | 4,500 / 6,500 / 5,375 ms | +| 列表 3,000:启动 / 首次 / 重复 | 在 1,000(830.3 / 1,155.3 / 876.7 ms)与 5,000(3,569.5 / 5,143.5 / 4,290.5 ms)之间插值 | 2,200 / 3,200 / 2,600 ms | 2,750 / 4,000 / 3,250 ms | | 搜索 1,000:首次 / 重复(一个样本) | 139,470 / 3,369 ms | 140,000 / 3,400 ms | 175,000 / 4,250 ms | | Fork:分层中位数 / p99 / 最长 | 89.6 / 1,485.7 / 17,538.1 ms | 90 / 1,500 / 17,600 ms | 113 / 1,875 / 22,000 ms | @@ -63,10 +62,11 @@ worker 是编译后的纯 Node 进程,每次都针对已播种根目录全新 - **脱敏复制真实 Session。** 已否决:基准从不使用录制的用户材料。 - **通过 `Session.append` 生成每个 Session。** 已否决:逐次追加的验证和冻结在每个 Session 上重复,使播种耗时数分钟。 - **省略投影缓存。** 已否决:行会缺少 Web 列表显示的值,从而低估列表工作量。 -- **单独的 CI 任务。** 已否决:基准共享一台空闲的标准运行器。此文件让该任务增加 6.6 分钟,其中播种 68 s,任务超时从 15 分钟提高到 25 分钟。 +- **单独的 CI 任务。** 已否决:基准共享一台空闲的标准运行器。此文件限制在五分钟内,任务超时从 15 分钟提高到 20 分钟。 +- **以五个样本列出 5,000 个 Session,并测两种列表规模。** 已否决:加上其他用例后,此文件在标准托管 CI 上耗时 6.6 分钟。 ## 后果 -该门禁覆盖单 Session 门禁看不到的语料规模列表、搜索和 fork 成本。在标准托管 CI 上,它已经显示:内容搜索在首次使用时花 139 s 构建索引,5,000 个 Session 的首次列表耗时 5.1 s,fork 最长 Session 耗时 17.5 s。播种写入约 1.9 GB。 +该门禁覆盖单 Session 门禁看不到的语料规模列表、搜索和 fork 成本。在标准托管 CI 上,它已经显示:内容搜索在首次使用时花 139 s 构建索引,校准时 5,000 个 Session 的首次列表耗时 5.1 s,fork 最长 Session 耗时 17.5 s。播种写入约 1.2 GB。 排除项:语料只包含当前代,而大多数真实 Session 以旧代存储,这会增加列表的修订哈希和读取时迁移。只有 22 份不同主体,因此搜索索引的是重复文本。Fork 不挂载 Agent preset,也不附加 Workspace。调用在进程内完成,不经过 Typert 传输或浏览器。不驱逐文件系统缓存,因此“冷”指全新进程,而不是冷存储。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8ea782318..112e9c18a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -196,7 +196,7 @@ jobs: name: node 24 / benchmarks # Wall-clock budgets need an otherwise idle runner, so this job runs the # benchmark lane alone instead of joining a concurrent gate aggregate. - timeout-minutes: 25 + timeout-minutes: 20 steps: - uses: actions/checkout@v6 with: diff --git a/benchmarks/session-corpus/README.i18n.yaml b/benchmarks/session-corpus/README.i18n.yaml index b3cd789f8b..7564705339 100644 --- a/benchmarks/session-corpus/README.i18n.yaml +++ b/benchmarks/session-corpus/README.i18n.yaml @@ -6,17 +6,17 @@ en: 28ebbfcfa3ea913a zh: 3ebad56e2c97c7c8 /session-corpus-benchmarks/summary: - en: 84da6860b2ff8c6e - zh: 671ceb53d53b1650 + en: 4bc34acd1959a4b5 + zh: 6f7932de565a5a8f /session-corpus-benchmarks/table-of-contents: en: 66a769ce60d49ff3 zh: 7e0bfa7c598157fa /session-corpus-benchmarks/run: - en: e2436a8eeb9a3b7b - zh: 7137a8761e202b68 + en: 491f186c7f0bd9f0 + zh: eff8ca4ff9537e0d /session-corpus-benchmarks/measurements: - en: 648ed1dcdc9c67a4 - zh: 039a91d845b5ea53 + en: c357c231c3bfb588 + zh: 3d901a2caccf2587 /session-corpus-benchmarks/dev-note: en: b75c8458928b72b9 zh: 003ba4e17b4b5269 diff --git a/benchmarks/session-corpus/README.md b/benchmarks/session-corpus/README.md index 731a68b79f..21943daa67 100644 --- a/benchmarks/session-corpus/README.md +++ b/benchmarks/session-corpus/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) ## Summary -Measure the Web Host's Session list, content search, and fork over synthetic corpora in which every Session is at least as long as the same quantile of a measured local DSH corpus. The list runs over 1,000 and 5,000 Sessions; search and fork run over 1,000. No case uses network services, recorded Sessions, or a browser. +Measure the Web Host's Session list, content search, and fork over synthetic corpora in which every Session is at least as long as the same quantile of a measured local DSH corpus. Search and fork run over 1,000 Sessions; the list runs over 3,000, the largest count that keeps this file within five minutes on standard hosted CI. No case uses network services, recorded Sessions, or a browser. ## Table of Contents @@ -16,9 +16,9 @@ Measure the Web Host's Session list, content search, and fork over synthetic cor ## Run -From the repository root, build the libraries and workers with `pnpm run build:bench`, then run `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/session-corpus/session-corpus.bench.ts`. Do not overlap timing runs with builds or other benchmarks. Seeding writes about 1.9 GB of Zstandard logs under a private temporary root, which the test removes after success or failure. +From the repository root, build the libraries and workers with `pnpm run build:bench`, then run `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/session-corpus/session-corpus.bench.ts`. Do not overlap timing runs with builds or other benchmarks. Seeding writes about 1.2 GB of Zstandard logs under a private temporary root, which the test removes after success or failure. -The test reports every fresh-process sample, the CPU model, available parallelism, platform, and Node version, and enforces median budgets. A failed worker reports its exit, signal, timeout, and stderr tail. The required benchmark lane discovers this file automatically. +The test reports every fresh-process sample, the CPU model, available parallelism, platform, and Node version, and enforces median budgets. Its last case fails when the file, including seeding, exceeds five minutes. A failed worker reports its exit, signal, timeout, and stderr tail. The required benchmark lane discovers this file automatically. @@ -28,7 +28,7 @@ The test reports every fresh-process sample, the CPU model, available parallelis | Case | Corpus | Timed endpoints | |---|---|---| -| List | 1,000 and 5,000 Sessions | Host boot; first and repeated `session.list`, each returning every Session with cached projections | +| List | 3,000 Sessions | Host boot; first and repeated `session.list`, each returning every Session with cached projections | | Content search | 1,000 Sessions | First `session.search`, which builds the in-memory index; a second query over the built index | | Fork | 1,000 Sessions | `session.fork` return for ten length strata, the p99 Session, and the longest Session | diff --git a/benchmarks/session-corpus/README.zh.md b/benchmarks/session-corpus/README.zh.md index 70e347b2e9..2ad1e361e2 100644 --- a/benchmarks/session-corpus/README.zh.md +++ b/benchmarks/session-corpus/README.zh.md @@ -4,7 +4,7 @@ ## 概述 -在合成语料上测量 Web Host 的 Session 列表、内容搜索和 fork;语料中每个 Session 的长度都不短于一份实测本地 DSH 语料在同一分位上的长度。列表用例运行 1,000 和 5,000 个 Session;搜索和 fork 用例运行 1,000 个 Session。所有用例均不使用网络服务、录制的 Session 或浏览器。 +在合成语料上测量 Web Host 的 Session 列表、内容搜索和 fork;语料中每个 Session 的长度都不短于一份实测本地 DSH 语料在同一分位上的长度。搜索和 fork 用例运行 1,000 个 Session;列表用例运行 3,000 个 Session,这是在标准托管 CI 上让此文件五分钟内完成的最大数量。所有用例均不使用网络服务、录制的 Session 或浏览器。 ## 目录 @@ -16,9 +16,9 @@ ## 运行 -在仓库根目录使用 `pnpm run build:bench` 构建库和 worker,然后运行 `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/session-corpus/session-corpus.bench.ts`。不要让计时运行与构建或其他基准重叠。播种过程会在私有临时根目录下写入约 1.9 GB 的 Zstandard 日志,测试成功或失败后都会删除该目录。 +在仓库根目录使用 `pnpm run build:bench` 构建库和 worker,然后运行 `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/session-corpus/session-corpus.bench.ts`。不要让计时运行与构建或其他基准重叠。播种过程会在私有临时根目录下写入约 1.2 GB 的 Zstandard 日志,测试成功或失败后都会删除该目录。 -测试报告每个新进程样本、CPU 型号、可用并行度、平台和 Node 版本,并约束中位数预算。worker 失败时报告退出状态、信号、超时和 stderr 末尾。必需基准通道自动发现此文件。 +测试报告每个新进程样本、CPU 型号、可用并行度、平台和 Node 版本,并约束中位数预算。当整个文件(包括播种)超过五分钟时,最后一个用例失败。worker 失败时报告退出状态、信号、超时和 stderr 末尾。必需基准通道自动发现此文件。 @@ -28,7 +28,7 @@ | 用例 | 语料 | 计时终点 | |---|---|---| -| 列表 | 1,000 和 5,000 个 Session | Host 启动;首次和重复的 `session.list`,每次都返回全部 Session 及其缓存投影 | +| 列表 | 3,000 个 Session | Host 启动;首次和重复的 `session.list`,每次都返回全部 Session 及其缓存投影 | | 内容搜索 | 1,000 个 Session | 构建内存索引的首次 `session.search`;在已构建索引上的第二次查询 | | Fork | 1,000 个 Session | 十个长度分层、p99 Session 和最长 Session 的 `session.fork` 返回 | diff --git a/benchmarks/session-corpus/session-corpus.bench.ts b/benchmarks/session-corpus/session-corpus.bench.ts index b47ff7f54b..a22b49b5a7 100644 --- a/benchmarks/session-corpus/session-corpus.bench.ts +++ b/benchmarks/session-corpus/session-corpus.bench.ts @@ -3,6 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { availableParallelism, cpus, tmpdir } from 'node:os' import { join } from 'node:path' +import { performance } from 'node:perf_hooks' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { runBuiltBenchmarkWorker } from '../support/built-worker.ts' import { PERFORMANCE_BUDGET_HEADROOM } from '../support/calibration.ts' @@ -13,32 +14,31 @@ import type { SearchReport, SeedReport, SessionCorpusReport, - VerifyReport, } from './session-corpus.worker.ts' -/** Corpus used by search and fork, and the extreme corpus used only by the list. */ -const CORPUS = { standard: 1_000, extreme: 5_000 } as const -/** Fresh processes per list and fork scenario; the median enforces each budget. */ -const ATTEMPTS = { list: 5, fork: 3 } as const -/** One cold content-search sample: the index build takes over two minutes on standard hosted CI. */ -const SEARCH_ATTEMPTS = 1 -/** Midpoints of ten equal length strata of the standard corpus, then its p99 and longest Session. */ +/** Session counts: search and fork share the query corpus; the list corpus is the largest that fits the time limit. */ +const CORPUS = { query: 1_000, list: 3_000 } as const +/** Upper bound on this file's hosted CI wall time, from seeding through the last case. */ +const FILE_LIMIT_MS = 300_000 +/** Fresh processes per list and fork-strata scenario; the median enforces each budget. */ +const ATTEMPTS = { list: 3, fork: 3 } as const +/** Midpoints of ten equal length strata of the query corpus, then its p99 Session. */ const FORK_STRATA = [50, 150, 250, 350, 450, 550, 650, 750, 850, 950] as const const FORK_P99_RANK = 990 +/** The longest Session; one sample, because one fork takes about 18 s on standard hosted CI. */ const FORK_LONGEST_RANK = 999 -const SEED_TIMEOUT_MS = 600_000 -const WORKER_TIMEOUT_MS = 300_000 +const WORKER_TIMEOUT_MS = 240_000 const WORKER = join(import.meta.dirname, '..', '.dsh-build', 'session-corpus', 'session-corpus.worker.js') /** * Standard two-CPU hosted CI expectations, rounded above the recorded medians before variance headroom. - * Recorded medians: list 1,000 830.3 / 1,155.3 / 876.7 ms; list 5,000 3,569.5 / 5,143.5 / 4,290.5 ms; - * search 139,470 / 3,369 ms; fork 89.6 / 1,485.7 / 17,538.1 ms. + * Recorded medians: list 1,000 830.3 / 1,155.3 / 876.7 ms and list 5,000 3,569.5 / 5,143.5 / 4,290.5 ms, + * interpolated to the list corpus; search 139,470 / 3,369 ms; fork 89.6 / 1,485.7 / 17,538.1 ms. */ const EXPECTED_CI_MS = { - listBoot: { standard: 850, extreme: 3_600 }, - listFirst: { standard: 1_200, extreme: 5_200 }, - listRepeat: { standard: 900, extreme: 4_300 }, + listBoot: 2_200, + listFirst: 3_200, + listRepeat: 2_600, searchFirst: 140_000, searchRepeat: 3_400, forkStratumMedian: 90, @@ -50,7 +50,7 @@ function budget(expectedCiMs: number): number { return Math.ceil(expectedCiMs * PERFORMANCE_BUDGET_HEADROOM) } -type CorpusSize = keyof typeof CORPUS +type CorpusName = keyof typeof CORPUS async function run(args: readonly string[], timeoutMs = WORKER_TIMEOUT_MS): Promise { const outcome = await runBuiltBenchmarkWorker({ worker: WORKER, args, timeoutMs, exposeGc: true }) @@ -96,13 +96,11 @@ describe('Session corpus workload', () => { expect(current?.logicalBytes).toBeGreaterThanOrEqual(previous?.logicalBytes ?? 0) } } - expect(() => sessionShape(CORPUS.standard, CORPUS.standard)).toThrow('outside') + expect(() => sessionShape(CORPUS.query, CORPUS.query)).toThrow('outside') }) it('accepts recorded hosted medians and rejects medians a quarter above their expectation', () => { const recorded = [ - [median([3_569.4, 3_634.7, 3_569.5, 3_536, 3_624.8]), EXPECTED_CI_MS.listBoot.extreme], - [median([5_206.2, 5_043, 5_143.5, 5_167.9, 5_047.4]), EXPECTED_CI_MS.listFirst.extreme], [139_470, EXPECTED_CI_MS.searchFirst], [median([17_573.2, 17_532.9, 17_538.1]), EXPECTED_CI_MS.forkLongest], [median([88.2, 89.6, 91.8]), EXPECTED_CI_MS.forkStratumMedian], @@ -111,81 +109,81 @@ describe('Session corpus workload', () => { expectWithinBudget(value, budget(expected)) expect(() => expectWithinBudget(Math.ceil(expected * 1.26), budget(expected))).toThrow() } - expect(budget(EXPECTED_CI_MS.listFirst.extreme)).toBe(6_500) + expect(budget(EXPECTED_CI_MS.forkLongest)).toBe(22_000) }) }) describe('Session corpus operations', () => { let scratch = '' - const root = (size: CorpusSize): string => join(scratch, `corpus-${String(CORPUS[size])}`) + let started = 0 + let seeded: SeedReport | undefined + const root = (name: CorpusName): string => join(scratch, `corpus-${String(CORPUS[name])}`) beforeAll(async () => { + started = performance.now() scratch = await mkdtemp(join(tmpdir(), 'dsh-session-corpus-bench-')) - const seeded = await run([scratch, 'seed', String(CORPUS.standard), String(CORPUS.extreme)], SEED_TIMEOUT_MS) + seeded = await run([scratch, 'seed', String(CORPUS.query), String(CORPUS.list)]) console.log(JSON.stringify({ benchmark: 'session-corpus/seed', ...seeded, environment: environment() })) - }, SEED_TIMEOUT_MS) + }, WORKER_TIMEOUT_MS) afterAll(async () => { if (scratch !== '') await rm(scratch, { recursive: true, force: true }) }) - it('stores replay-valid Sessions for every measured anchor', async () => { - const report = await run([root('standard'), 'verify', String(CORPUS.standard)]) - expect(report.anchors).toHaveLength(ANCHOR_COUNT) - for (const { anchor, events } of report.anchors) { - expect(events).toBeGreaterThanOrEqual(anchorShape(anchor).events) + it('writes corpora at least as long as the measured distribution', () => { + for (const count of [CORPUS.query, CORPUS.list]) { + const facts = seeded?.corpora.find(corpus => corpus.sessions === count) + const shapes = Array.from({ length: count }, (_, rank) => sessionShape(rank, count)) + expect(facts?.distinctBodies).toBe(ANCHOR_COUNT) + expect(facts?.events).toBeGreaterThanOrEqual(shapes.reduce((sum, shape) => sum + shape.events, 0)) + expect(facts?.logicalBytes).toBeGreaterThanOrEqual(shapes.reduce((sum, shape) => sum + shape.logicalBytes, 0)) } }) - for (const size of ['standard', 'extreme'] as const) { + it(`lists ${String(CORPUS.list)} Sessions after a cold Host boot`, async () => { const budgets = { - bootMs: budget(EXPECTED_CI_MS.listBoot[size]), - firstMs: budget(EXPECTED_CI_MS.listFirst[size]), - repeatMs: budget(EXPECTED_CI_MS.listRepeat[size]), + bootMs: budget(EXPECTED_CI_MS.listBoot), + firstMs: budget(EXPECTED_CI_MS.listFirst), + repeatMs: budget(EXPECTED_CI_MS.listRepeat), } + const reports: ListReport[] = [] + for (let attempt = 0; attempt < ATTEMPTS.list; attempt++) { + reports.push(await run([root('list'), 'list', String(CORPUS.list)])) + } + const samples = { + bootMs: rounded(reports.map(report => report.bootMs)), + firstMs: rounded(reports.map(report => report.firstMs)), + repeatMs: rounded(reports.map(report => report.repeatMs)), + peakRssMb: reports.map(report => report.memory.peakRssMb), + heapUsedMb: reports.map(report => report.memory.heapUsedMb), + } + console.log(JSON.stringify({ benchmark: `session-corpus/list-${String(CORPUS.list)}`, samples, budgets, environment: environment() })) + for (const report of reports) expect(report.itemsWithProjections).toBe(CORPUS.list) + expectWithinBudget(median(samples.bootMs), budgets.bootMs) + expectWithinBudget(median(samples.firstMs), budgets.firstMs) + expectWithinBudget(median(samples.repeatMs), budgets.repeatMs) + }) - it(`lists ${String(CORPUS[size])} Sessions after a cold Host boot`, async () => { - const reports: ListReport[] = [] - for (let attempt = 0; attempt < ATTEMPTS.list; attempt++) { - reports.push(await run([root(size), 'list', String(CORPUS[size])])) - } - const samples = { - bootMs: rounded(reports.map(report => report.bootMs)), - firstMs: rounded(reports.map(report => report.firstMs)), - repeatMs: rounded(reports.map(report => report.repeatMs)), - peakRssMb: reports.map(report => report.memory.peakRssMb), - heapUsedMb: reports.map(report => report.memory.heapUsedMb), - } - console.log(JSON.stringify({ benchmark: `session-corpus/list-${String(CORPUS[size])}`, samples, budgets, environment: environment() })) - for (const report of reports) expect(report.itemsWithProjections).toBe(CORPUS[size]) - expectWithinBudget(median(samples.bootMs), budgets.bootMs) - expectWithinBudget(median(samples.firstMs), budgets.firstMs) - expectWithinBudget(median(samples.repeatMs), budgets.repeatMs) - }) - } - - it(`searches ${String(CORPUS.standard)} Sessions with a cold and then a built content index`, async () => { + it(`searches ${String(CORPUS.query)} Sessions with a cold and then a built content index`, async () => { const budgets = { firstMs: budget(EXPECTED_CI_MS.searchFirst), repeatMs: budget(EXPECTED_CI_MS.searchRepeat) } - const reports: SearchReport[] = [] - for (let attempt = 0; attempt < SEARCH_ATTEMPTS; attempt++) { - reports.push(await run([root('standard'), 'search'])) - } - console.log(JSON.stringify({ benchmark: 'session-corpus/search', reports, budgets, environment: environment() })) - expectWithinBudget(median(reports.map(report => report.firstMs)), budgets.firstMs) - expectWithinBudget(median(reports.map(report => report.repeatMs)), budgets.repeatMs) - }, SEED_TIMEOUT_MS) + // One sample: the cold index build dominates this file's time. + const report = await run([root('query'), 'search']) + console.log(JSON.stringify({ benchmark: 'session-corpus/search', report, budgets, environment: environment() })) + expectWithinBudget(report.firstMs, budgets.firstMs) + expectWithinBudget(report.repeatMs, budgets.repeatMs) + }, WORKER_TIMEOUT_MS) - it(`forks Sessions across the length distribution of ${String(CORPUS.standard)} Sessions`, async () => { + it(`forks Sessions across the length distribution of ${String(CORPUS.query)} Sessions`, async () => { const budgets = { stratumMedianMs: budget(EXPECTED_CI_MS.forkStratumMedian), p99Ms: budget(EXPECTED_CI_MS.forkP99), longestMs: budget(EXPECTED_CI_MS.forkLongest), } - const ranks = [...FORK_STRATA, FORK_P99_RANK, FORK_LONGEST_RANK].map(String) const reports: ForkReport[] = [] for (let attempt = 0; attempt < ATTEMPTS.fork; attempt++) { - reports.push(await run([root('standard'), 'fork', ...ranks])) + reports.push(await run([root('query'), 'fork', ...[...FORK_STRATA, FORK_P99_RANK].map(String)])) } + const longest = await run([root('query'), 'fork', String(FORK_LONGEST_RANK)]) const forkMs = (report: ForkReport, rank: number): number => { const fork = report.forks.find(entry => entry.rank === rank) if (fork === undefined) throw new Error(`fork report omits rank ${String(rank)}`) @@ -194,13 +192,19 @@ describe('Session corpus operations', () => { const samples = { stratumMedianMs: rounded(reports.map(report => median(FORK_STRATA.map(rank => forkMs(report, rank))))), p99Ms: rounded(reports.map(report => forkMs(report, FORK_P99_RANK))), - longestMs: rounded(reports.map(report => forkMs(report, FORK_LONGEST_RANK))), + longestMs: Math.round(forkMs(longest, FORK_LONGEST_RANK) * 10) / 10, perRank: reports.map(report => report.forks.map(({ rank, sourceEvents, forkMs }) => ({ rank, sourceEvents, forkMs: Math.round(forkMs) }))), - peakRssMb: reports.map(report => report.memory.peakRssMb), + peakRssMb: [...reports, longest].map(report => report.memory.peakRssMb), } console.log(JSON.stringify({ benchmark: 'session-corpus/fork', samples, budgets, environment: environment() })) expectWithinBudget(median(samples.stratumMedianMs), budgets.stratumMedianMs) expectWithinBudget(median(samples.p99Ms), budgets.p99Ms) - expectWithinBudget(median(samples.longestMs), budgets.longestMs) - }, SEED_TIMEOUT_MS) + expectWithinBudget(samples.longestMs, budgets.longestMs) + }, WORKER_TIMEOUT_MS) + + it(`completes within ${String(FILE_LIMIT_MS / 60_000)} minutes`, () => { + const elapsedMs = performance.now() - started + console.log(JSON.stringify({ benchmark: 'session-corpus/total', elapsedMs: Math.round(elapsedMs), limitMs: FILE_LIMIT_MS })) + expectWithinBudget(elapsedMs, FILE_LIMIT_MS) + }) }) diff --git a/benchmarks/session-corpus/session-corpus.worker.ts b/benchmarks/session-corpus/session-corpus.worker.ts index 2855b6570c..a7d80bfdb4 100644 --- a/benchmarks/session-corpus/session-corpus.worker.ts +++ b/benchmarks/session-corpus/session-corpus.worker.ts @@ -9,7 +9,8 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' import SessionController from '@deepseek-ai/dsh-api-session-controller' -import { SessionLogOffset } from '@deepseek-ai/dsh-session' +import { currentSessionMessageProjections } from '@deepseek-ai/dsh-session-format-catalog/message-projections' +import { Session, SessionLogOffset } from '@deepseek-ai/dsh-session' import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionProjectionCache, { projectionCacheDomainSpec } from '@deepseek-ai/dsh-session-projection-cache' @@ -46,7 +47,9 @@ export interface CorpusMemory { /** Seeding report: facts of every corpus and the time spent building them. */ export interface SeedReport { readonly mode: 'seed' - readonly corpora: readonly SyntheticCorpusFacts[] + readonly corpora: readonly (SyntheticCorpusFacts & { readonly writeMs: number })[] + /** Authoring, replay validation, and projection folding of every distinct body. */ + readonly authorMs: number readonly seedMs: number } @@ -84,14 +87,8 @@ export interface ForkReport { readonly memory: CorpusMemory } -/** Verification report: replay-validated event counts of every distinct body. */ -export interface VerifyReport { - readonly mode: 'verify' - readonly anchors: readonly { readonly anchor: number; readonly rank: number; readonly events: number }[] -} - /** Any worker report. */ -export type SessionCorpusReport = SeedReport | ListReport | SearchReport | ForkReport | VerifyReport +export type SessionCorpusReport = SeedReport | ListReport | SearchReport | ForkReport const BENCH_MODEL = { provider: 'bench', model: 'bench' } as const /** Bound on waiting for fire-and-forget projection-cache write-backs after seeding. */ @@ -178,7 +175,10 @@ async function seed(root: string, counts: readonly number[]): Promise Promise.resolve(new Map( [...folded].map(([anchor, header]) => [anchor, table.get(header.id) as CheckpointRecord]), ))) - const corpora: SyntheticCorpusFacts[] = [] + const corpora: SeedReport['corpora'][number][] = [] for (const count of counts) { const corpusRoot = join(root, `corpus-${String(count)}`) + const writeStarted = performance.now() const written = await writer.writeCorpus(join(corpusRoot, 'sessions'), count) // Sessions of one anchor share events, so their records differ only in the header-bound identity. // Per-record documents are written directly: the domain's one-fsync-per-put chain would dominate seeding, @@ -209,9 +211,9 @@ async function seed(root: string, counts: readonly number[]): Promise { @@ -288,23 +290,6 @@ async function measureFork(root: string, ranks: readonly number[]): Promise { - const { ctx } = await mountHost(root) - try { - const anchors: VerifyReport['anchors'][number][] = [] - for (let rank = 0; rank < count; rank++) { - const { anchor } = sessionShape(rank, count) - if (anchors.some(entry => entry.anchor === anchor)) continue - // Exact reads replay the log through Session validation. - const { events } = await ctx.sessionQuery.readSession(corpusSessionId(rank)) - anchors.push({ anchor, rank, events: events.length }) - } - return { mode: 'verify', anchors } - } finally { - await ctx.fiber.dispose() - } -} - assertBuiltBenchmarkRuntime(import.meta.url, Object.fromEntries([ '@deepseek-ai/dsh-api-session-controller', '@deepseek-ai/dsh-session-persistence-jsonl', @@ -315,7 +300,7 @@ assertBuiltBenchmarkRuntime(import.meta.url, Object.fromEntries([ const [root, mode, ...rest] = process.argv.slice(2) const numbers = rest.map(Number) if (root === undefined || numbers.some(value => !Number.isSafeInteger(value) || value < 0)) { - throw new Error('usage: session-corpus.worker.js ') + throw new Error('usage: session-corpus.worker.js ') } let report: SessionCorpusReport switch (mode) { @@ -331,9 +316,6 @@ switch (mode) { case 'fork': report = await measureFork(root, numbers) break - case 'verify': - report = await verify(root, numbers[0] ?? 0) - break default: throw new Error(`unknown Session corpus mode ${String(mode)}`) } diff --git a/benchmarks/session-corpus/synthetic-corpus.ts b/benchmarks/session-corpus/synthetic-corpus.ts index 81ae00f642..9f2a2b1d48 100644 --- a/benchmarks/session-corpus/synthetic-corpus.ts +++ b/benchmarks/session-corpus/synthetic-corpus.ts @@ -341,8 +341,8 @@ export async function forEachConcurrently(count: number, operation: (index: numb export class SyntheticCorpusWriter { private readonly pool = new TextPool() private readonly bodies = new Map() - /** Stored bytes per payload character, measured by the previous anchor's final secant step. */ - private bytesPerChar = 4 + /** Stored-byte model from the previous anchor: fixed system prompt, bytes per event, and bytes per payload character. */ + private model = { bytesPerEvent: 0, bytesPerChar: 4 } /** * Author one anchor's body; its body bytes alone reach the anchor's logical-byte target. @@ -351,8 +351,11 @@ export class SyntheticCorpusWriter { */ async author(anchor: number): Promise { const shape = anchorShape(anchor) - // Secant steps from below: stored bytes grow linearly with payload characters. - let payloadChars = 0 + // Start from the previous anchor's model, then take secant steps from below. + const { bytesPerEvent, bytesPerChar } = this.model + let payloadChars = Math.max(0, Math.ceil( + (1.01 * (shape.logicalBytes - SYSTEM_PROMPT_CHARS - bytesPerEvent * shape.events)) / bytesPerChar, + )) let previous: { readonly payloadChars: number; readonly bytes: number } | undefined for (let attempt = 0; attempt < 8; attempt++) { const authored = authorEvents(shape, payloadChars, this.pool, anchor + 1) @@ -361,12 +364,14 @@ export class SyntheticCorpusWriter { } const frames = frameTexts(authored.events, authored.frameStarts) const bytes = frames.reduce((sum, text) => sum + Buffer.byteLength(text), 0) + if (previous !== undefined && payloadChars > previous.payloadChars) { + this.model.bytesPerChar = (bytes - previous.bytes) / (payloadChars - previous.payloadChars) + } + this.model.bytesPerEvent = (bytes - SYSTEM_PROMPT_CHARS - this.model.bytesPerChar * payloadChars) / authored.events.length if (bytes < shape.logicalBytes) { - if (previous !== undefined) this.bytesPerChar = (bytes - previous.bytes) / (payloadChars - previous.payloadChars) previous = { payloadChars, bytes } - const bytesPerChar = this.bytesPerChar // Every message rounds its share up, so each step adds at least one character per event. - payloadChars += Math.ceil((1.01 * (shape.logicalBytes - bytes)) / bytesPerChar) + shape.events + payloadChars += Math.ceil((1.01 * (shape.logicalBytes - bytes)) / this.model.bytesPerChar) + shape.events continue } // Each pending compression owns a native Zstandard context, so compression is bounded like writes. diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index b70255cc3a..2c2f50be89 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -464,10 +464,10 @@ describe('CI workflow', () => { }) }) - it('bounds the complete benchmark job to twenty-five minutes', () => { + it('bounds the complete benchmark job to twenty minutes', () => { const benchmark = workflowJob(loadWorkflow('.github/workflows/ci.yml'), 'node-24-bench') - expect(benchmark['timeout-minutes']).toBe(25) + expect(benchmark['timeout-minutes']).toBe(20) expect(benchmark.steps).toContainEqual({ name: 'Run performance benchmarks', env: { DSH_GATE_VERBOSE: '1' }, From 3c26fdd204e541313fa6f952593b619c328c5ebb Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:05:57 -0700 Subject: [PATCH 16/49] feat(web): keep standard and creator presets available without coding tools --- apps/web/tests/agent-preset-selection.e2e.ts | 10 ++- .../web/tests/developer-tools-settings.e2e.ts | 76 +++++++++++++++---- .../desktop-locale/automatic.expected.md | 2 +- .../desktop-locale/selected.expected.md | 2 +- .../settings-chrome/dialog-en.expected.md | 2 +- .../dialog-no-browser.expected.md | 2 +- .../settings-chrome/dialog.expected.md | 2 +- .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 2 +- packages/client/ui-agent-preset/README.zh.md | 2 +- .../src/client/AgentPresetSeat.tsx | 29 ++++--- .../src/client/AgentPresetSection.tsx | 15 ++-- .../ui-agent-preset/src/client/index.ts | 16 +--- .../ui-agent-preset/src/client/seat-store.ts | 17 +---- .../tests/apply.client.spec.ts | 51 +++---------- .../tests/components.client.spec.tsx | 23 +++++- .../tests/section.client.spec.tsx | 27 ++++--- .../tests/settings-store.client.spec.ts | 30 +++----- .../ui-settings-general/src/client/locales.ts | 4 +- 19 files changed, 152 insertions(+), 164 deletions(-) diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 48f1722d7c..197757d32b 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -343,7 +343,7 @@ describe('web e2e: agent-preset selection', () => { await writeComposerDraft(page, composer, '') }, 90_000) - it('keeps the saved default composing sessions while Developer tools only gate the choice', async () => { + it('keeps the saved default composing sessions while Coding Tools hide PTC and Minimal choices', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-disabled')) await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('standard') @@ -359,8 +359,12 @@ describe('web e2e: agent-preset selection', () => { await expect.poll(() => developerTools.getAttribute('aria-checked')).toBe('false') await dialog.getByRole('button', { name: 'Close' }).last().click() - // The gate hides the choice; the blank task keeps its saved composition. - await expect.poll(() => page.getByRole('button', { name: / mode$/ }).count()).toBe(0) + // A selected hidden option keeps its label and composition until another choice is made. + await page.getByRole('button', { name: 'Minimal mode', exact: true }).click() + await page.getByRole('menu').waitFor() + expect(await page.getByRole('menuitem', { name: /^PTC mode|^Minimal mode/ }).count()).toBe(0) + expect(await page.getByRole('menuitem', { name: /^Standard mode|^Creator mode/ }).count()).toBe(2) + await page.keyboard.press('Escape') await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal') await openSettings(page, 'en') diff --git a/apps/web/tests/developer-tools-settings.e2e.ts b/apps/web/tests/developer-tools-settings.e2e.ts index 40481128d2..8e5272d5f2 100644 --- a/apps/web/tests/developer-tools-settings.e2e.ts +++ b/apps/web/tests/developer-tools-settings.e2e.ts @@ -1,17 +1,23 @@ -/** Developer tools default on for a fresh Host and survive browser reload through the ordinary settings UI. */ +/** Coding Tools persist while Standard, Creator, and custom presets remain selectable when disabled. */ import { readFile } from 'node:fs/promises' import { join } from 'node:path' +import type {} from '@deepseek-ai/dsh-api-session-controller' import { chromium } from 'playwright' import { expect, it, onTestFinished } from 'vitest' -import { launchWebScaffold } from './scaffold.ts' -import { openSettings, newEnglishPage } from './support.ts' +import { launchWebScaffold, watchConsole } from './scaffold.ts' +import { connectFreshWorkspace, openSettings, newEnglishPage } from './support.ts' -it('persists developer tools in the Host settings document and restores the accepted choice', async () => { - const scaffold = await launchWebScaffold() +it('persists Coding Tools and limits only the built-in PTC and Minimal session choices', async () => { + const scaffold = await launchWebScaffold({ + agentPresets: { default: 'standard', definitions: [{ id: 'custom', name: 'Custom mode', plugins: [] }] }, + }) onTestFinished(() => scaffold.close()) const browser = await chromium.launch() onTestFinished(() => browser.close()) const page = await newEnglishPage(browser) + const tripwire = watchConsole(page) + const livePresets = async () => (await scaffold.ctx.sessionController.list({}, new AbortController().signal)) + .items.map(item => item.projections?.values.agentPreset) await page.goto(scaffold.authenticatedUrl) await openSettings(page, 'en') const toggle = page.getByRole('switch', { name: 'Coding Tools' }) @@ -26,17 +32,55 @@ it('persists developer tools in the Host settings document and restores the acce await page.getByRole('button', { name: 'Agent presets', exact: true }).click() await expect.poll(() => page.getByRole('heading', { name: 'Agent presets', exact: true }).count()).toBe(1) - // The page owns no selection switch: only the General one gates the choice. - const section = page.locator('section') - .filter({ has: page.getByRole('heading', { name: 'Agent presets', exact: true }) }) - const refused = section.getByRole('button', { name: /^Turn on Coding Tools in General settings to choose a default: / }) - expect(await section.getByRole('switch').count()).toBe(0) - await expect.poll(() => refused.count()).toBeGreaterThan(0) + const settings = page.getByRole('dialog', { name: 'Settings', exact: true }) + expect(await settings.getByRole('switch').count()).toBe(0) + await expect.poll(() => settings.locator('[data-agent-preset-id]').count()).toBe(5) + await settings.getByRole('button', { name: 'Set as new task default: Minimal mode', exact: true }).click() + await settings.getByRole('button', { name: 'New task default: Minimal mode', exact: true }).waitFor() + await settings.getByRole('button', { name: 'Set as new task default: Standard mode', exact: true }).click() + await settings.getByRole('button', { name: 'New task default: Standard mode', exact: true }).waitFor() + expect(await settings.getByRole('button', { name: 'Let the agent help me create a preset', exact: true }).isEnabled()).toBe(true) + await settings.getByRole('button', { name: 'Close', exact: true }).last().click() + await connectFreshWorkspace(page, scaffold.workspaceCwd) + await expect.poll(livePresets).toEqual(['standard']) + + const menu = page.getByRole('menu') + await page.getByRole('button', { name: 'Standard mode', exact: true }).click() + await expect.poll(() => menu.getByRole('menuitem').count()).toBe(3) + expect(await menu.getByRole('menuitem', { name: /^PTC mode|^Minimal mode/ }).count()).toBe(0) + expect(await menu.getByRole('menuitem', { name: /^Custom mode/ }).count()).toBe(1) + await menu.getByRole('menuitem', { name: /^Creator mode/ }).click() + await expect.poll(livePresets, { timeout: 15_000 }).toEqual(['cordis']) + + await page.getByRole('button', { name: 'Creator mode', exact: true }).click() + await menu.waitFor() await scaffold.ctx.settings.update('ui-settings', { enabled: true }) - await expect.poll(() => section.getByRole('button', { name: 'Set as new task default: Minimal mode' }).count()).toBe(1) - await expect.poll(() => refused.count()).toBe(0) - expect(await section.getByRole('switch').count()).toBe(0) + await menu.waitFor({ state: 'detached' }) + await page.getByRole('button', { name: 'Creator mode', exact: true }).click() + await expect.poll(() => menu.getByRole('menuitem').count()).toBe(5) + expect(await menu.getByRole('menuitem', { name: /^PTC mode/ }).count()).toBe(1) + expect(await menu.getByRole('menuitem', { name: /^Minimal mode/ }).count()).toBe(1) + expect(await livePresets()).toEqual(['cordis']) await scaffold.ctx.settings.update('ui-settings', { enabled: false }) - await expect.poll(() => refused.count()).toBeGreaterThan(0) - expect(await section.getByRole('switch').count()).toBe(0) + await menu.waitFor({ state: 'detached' }) + expect(await livePresets()).toEqual(['cordis']) + await page.getByRole('button', { name: 'Creator mode', exact: true }).click() + await expect.poll(() => menu.getByRole('menuitem').count()).toBe(3) + await menu.getByRole('menuitem', { name: /^Standard mode/ }).click() + await expect.poll(livePresets, { timeout: 15_000 }).toEqual(['standard']) + + await page.reload() + await page.getByRole('button', { name: 'Standard mode', exact: true }).click() + await expect.poll(() => menu.getByRole('menuitem').count()).toBe(3) + await page.keyboard.press('Escape') + await openSettings(page, 'en') + await settings.getByRole('button', { name: 'General', exact: true }).click() + await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false') + await settings.getByRole('button', { name: 'Agent presets', exact: true }).click() + await settings.getByRole('button', { name: 'New task default: Standard mode', exact: true }).waitFor() + await settings.getByRole('button', { name: 'Let the agent help me create a preset', exact: true }).click() + await settings.waitFor({ state: 'detached' }) + await expect.poll(livePresets, { timeout: 15_000 }).toContain('cordis') + expect((await scaffold.ctx.agentPresets.remoteExportList()).presets.find(preset => preset.isDefault)?.id).toBe('standard') + expect(tripwire.pageErrors).toEqual([]) }) diff --git a/apps/web/tests/expected/desktop-locale/automatic.expected.md b/apps/web/tests/expected/desktop-locale/automatic.expected.md index 77b4593b90..0f2b533897 100644 --- a/apps/web/tests/expected/desktop-locale/automatic.expected.md +++ b/apps/web/tests/expected/desktop-locale/automatic.expected.md @@ -20,7 +20,7 @@ - button "减小字号" - text: px 工作步骤展示 选择希望看到多少工具调用细节 - button "标准" - - text: 代码工作工具 开启后显示轨迹、本轮代码差异,新对话中的 Agent 预设切换 + - text: 代码工作工具 开启后显示轨迹、本轮代码差异,并在新对话中提供 PTC 和极简模式 - switch "代码工作工具" [checked] - text: 快捷键 - paragraph: 查看和编辑当前可用的快捷键和输入操作 diff --git a/apps/web/tests/expected/desktop-locale/selected.expected.md b/apps/web/tests/expected/desktop-locale/selected.expected.md index c4fd5e5eff..53cdd35b32 100644 --- a/apps/web/tests/expected/desktop-locale/selected.expected.md +++ b/apps/web/tests/expected/desktop-locale/selected.expected.md @@ -20,7 +20,7 @@ - button "Decrease font size" - text: px Work details Choose how much detail to show for tool calls - button "Standard" - - text: Coding Tools Shows trajectory, code diffs, and Agent preset switching in new chats + - text: Coding Tools Shows trajectory and code diffs, and adds PTC and Minimal modes to new chats - switch "Coding Tools" [checked] - text: Keyboard shortcuts - paragraph: View and edit available shortcuts and input actions diff --git a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md index ad3348ea5a..953038a5dd 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md @@ -20,7 +20,7 @@ - button "Decrease font size" - text: px Work details Choose how much detail to show for tool calls - button "Detailed" - - text: Coding Tools Shows trajectory, code diffs, and Agent preset switching in new chats + - text: Coding Tools Shows trajectory and code diffs, and adds PTC and Minimal modes to new chats - switch "Coding Tools" - text: Keyboard shortcuts - paragraph: View and edit available shortcuts and input actions diff --git a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md index ad3348ea5a..953038a5dd 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md @@ -20,7 +20,7 @@ - button "Decrease font size" - text: px Work details Choose how much detail to show for tool calls - button "Detailed" - - text: Coding Tools Shows trajectory, code diffs, and Agent preset switching in new chats + - text: Coding Tools Shows trajectory and code diffs, and adds PTC and Minimal modes to new chats - switch "Coding Tools" - text: Keyboard shortcuts - paragraph: View and edit available shortcuts and input actions diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md index 0f17fa643f..92a8c17a75 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -20,7 +20,7 @@ - button "减小字号" - text: px 工作步骤展示 选择希望看到多少工具调用细节 - button "详细" - - text: 代码工作工具 开启后显示轨迹、本轮代码差异,新对话中的 Agent 预设切换 + - text: 代码工作工具 开启后显示轨迹、本轮代码差异,并在新对话中提供 PTC 和极简模式 - switch "代码工作工具" - text: 快捷键 - paragraph: 查看和编辑当前可用的快捷键和输入操作 diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 7f08d21282..59f91f142c 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: b3ad7867f23dad1c /deepseek-ai-dsh-client-ui-agent-preset/use-this-package: - en: cf381c4f00b42c3f - zh: 1b226687dcbd4875 + en: 584999ed508d4146 + zh: 1426a8b908bd178a /deepseek-ai-dsh-client-ui-agent-preset/understand-the-implementation: en: 7813f0063c15e241 zh: 6eabc242edfc45ec diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index ca7bba393e..30ba8e2389 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -27,7 +27,7 @@ Choose Agent presets and the new-task default in Web, read what each mode does a Settings shows the built-in and custom card groups with default highlighting and card-body selection; a group without presets is omitted, except the custom group, which keeps its Creator entry on screen. Every card offers “View configuration”, which opens the preset's declared plugin list as read-only YAML in the Loader's own dialect (`!!js` conditions included); a failed preset stays readable because its diagnostic points into that YAML. Escape closes only the viewer and returns focus to its card; leaving Settings clears the viewer, and a late read does not reopen it. The page edits nothing: the Creator entry starts a Creator-mode task that authors or overrides a preset as a bundle, offered while the `cordis` preset is on the roster and a conversation flow exists. -Coding Tools in General Settings decide whether a mode can be chosen at all: with them off the new-session picker disappears and the cards refuse selection, while the saved default keeps composing new tasks. Choosing a healthy default also synchronizes the blank session on the current new-task surface. Creator starts a new task using the `cordis` preset. +With Coding Tools off in General Settings, the new-session picker offers Standard, Creator, and custom presets; turning them on also offers PTC and Minimal. The preference changes only these menu choices: it preserves the staged choice, saved default, and current session preset. Settings keeps the full roster, default selection, and Creator entry available in both states. Choosing a healthy default also synchronizes the blank session on the current new-task surface. Creator starts a new task using the `cordis` preset. Known shipped presets offer mode details and usage examples in a read-only dialog. Its tabs preserve each page's scroll position; closing returns focus to the opening action. Help does not change the new-task default. The default badge replaces the card's group badge, and the preset id appears beside the title. Guide copy and examples belong to this package. diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index d2a3471f45..73dcf4b0f7 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" 设置页显示内置与自定义卡片分组、默认项高亮和点击卡片选择;没有 preset 的分组不显示,但自定义分组会保留其创造入口。每张卡片都提供「查看配置」,以只读 YAML 打开该 preset 声明的插件列表,使用 Loader 自己的方言(含 `!!js` 条件);加载失败的 preset 同样可读,因为其诊断信息正指向这份 YAML。Escape 只关闭查看器并将焦点还给卡片;离开设置页会清空查看器,迟到的读取结果不会重新打开它。本页不编辑任何内容:创造入口启动一个创造模式任务,以 bundle 形式创建或覆盖 preset;当 `cordis` preset 在列表中且存在会话流程时提供。 -通用设置中的代码工作工具决定能否选择模式:关闭后新会话选择器消失、卡片拒绝选择,而已保存的默认值继续用于新任务。选择健康定义作为默认值也会同步当前新任务页面的空白会话。Creator 入口开启一个使用 `cordis` preset 的新任务。 +关闭通用设置中的代码工作工具后,新会话选择器提供标准、创造和自定义预设;开启后还提供 PTC 和极简模式。此偏好只改变这些菜单选项,保留暂存的选择、已保存的默认值和当前会话预设。两种状态下,设置页均保留完整列表、默认值选择和创造入口。选择健康定义作为默认值也会同步当前新任务页面的空白会话。Creator 入口开启一个使用 `cordis` preset 的新任务。 已知的内置预设提供只读的模式说明与使用示例对话框。各页签保留各自的滚动位置;关闭后焦点回到打开它的操作。帮助不会改变新任务默认值。默认徽标取代卡片的分组徽标,预设 id 显示在标题旁。指南文案与示例归本包所有。 diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx index d32bc78f63..0dd51b06c1 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx @@ -21,13 +21,13 @@ import { // Type-only: pulls the ui-conversation SlotMap merge (the hero seat). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { AgentPresetSeatState } from './seat-store.ts' -import { presetDisplayText } from './locales.ts' +import { isBuiltInPreset, presetDisplayText } from './locales.ts' import css from './AgentPresetSeat.module.css' /** Registration-side business face for the hero chip. */ export interface AgentPresetSeatInjected { hooks: { - /** Shared Developer tools preference; off hides the chip. */ + /** Shared Developer tools preference; off hides the PTC and Minimal menu choices. */ developerTools: ObservableSnapshot /** Seat snapshot bound by the renderer as useAgentPresetSeat. */ agentPresetSeat: SnapshotStore @@ -73,8 +73,7 @@ export type AgentPresetSeatProps = /** * Render the new-session agent-preset chip. * @param props - composed slot props. - * @returns The chip, or null outside the main view, with Developer tools off, - * or before the roster provides a preset choice. + * @returns The chip, or null outside the main view or before the roster provides a preset choice. */ export function AgentPresetSeat({ sessionId, useSessionRetainInfo, load, select, introduced, useAgentPresetSeat, useDeveloperTools, t, @@ -88,22 +87,20 @@ export function AgentPresetSeat({ // it rather than leaving the first one silently in place. const toastSeq = useRef(0) const [toast, setToast] = useState<{ seq: number; text: string } | null>(null) - const pickerVisible = useRef(developerTools) - pickerVisible.current = developerTools useEffect(() => { void load() }, [load]) - // The component stays registered while hidden, so clear local disclosure - // state explicitly; otherwise an external off/on edit can revive an old - // menu or refusal banner. + // Close a menu whose available choices just changed. useEffect(() => { - if (developerTools) return setOpen(false) - setToast(null) }, [developerTools]) + const options = state.options.filter(option => developerTools + || !isBuiltInPreset(option) + || (option.id !== 'ptc' && option.id !== 'minimal')) + const chosen = state.options.find(option => option.id === state.current) const chosenText = chosen === undefined ? undefined : presetDisplayText(chosen, t) const label = chosenText?.name ?? state.current @@ -131,7 +128,7 @@ export function AgentPresetSeat({ // Nothing to choose between: the deployment composes no presets and every // session shares the host composition. - if (!main || !developerTools || !ready) return null + if (!main || !ready) return null // One wrapper span: the chip is a flex row with a gap, so loose character // spans would each pick up the gap between them. @@ -158,7 +155,7 @@ export function AgentPresetSeat({ { setOpen(false) }} - items={state.options.map((option) => { + items={options.map((option) => { const text = presetDisplayText(option, t) return { id: option.id, @@ -175,16 +172,16 @@ export function AgentPresetSeat({ selectedId={state.current} onSelect={(id) => { setOpen(false) - const picked = state.options.find(option => option.id === id) + const picked = options.find(option => option.id === id) // The fallback is for the row shape `find` cannot promise; the menu's - // items ARE `state.options`, so an emitted id is always one of them. + // items ARE `options`, so an emitted id is always one of them. /* v8 ignore next */ const name = picked === undefined ? id : presetDisplayText(picked, t).name void select(id).then((refusal) => { // Announced only for a pick a person just made: `apply()` also runs // when a session becomes current, and a banner over that would // report a refusal nobody asked for. - if (refusal === undefined || !pickerVisible.current) return + if (refusal === undefined) return toastSeq.current += 1 setToast({ seq: toastSeq.current, text: t('switchRefused', { name, reason: refusal }) }) }) diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index 9d193d94ff..25c59f944a 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -4,7 +4,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { Button, IconBrowseOutlineRegular, IconPlusOutlineRegular, Modal, Tag, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { AgentPresetSectionState } from './section-store.ts' import { isBuiltInPreset, presetDisplayText } from './locales.ts' @@ -15,8 +15,6 @@ import css from './AgentPresetSection.module.css' export interface AgentPresetSectionInjected { hooks: { agentPresetSection: SnapshotStore - /** Shared Developer tools preference; off hides every selection action. */ - developerTools: ObservableSnapshot } /** Stage the `cordis` preset and start a Creator-mode task; absent without a conversation flow. */ startCreatorDraft?: () => void @@ -62,10 +60,9 @@ function CardDescription({ text }: { text: string }): ReactNode { */ export function AgentPresetSection({ useAgentPresetSection, load, view, closeView, makeDefault, startCreatorDraft, - close: closeSettings, useDeveloperTools, t, + close: closeSettings, t, }: AgentPresetSectionProps) { const state = useAgentPresetSection(value => value) - const developerTools = useDeveloperTools(enabled => enabled) const [guide, setGuide] = useState<{ content: NonNullable> page: PresetGuidePage @@ -90,8 +87,7 @@ export function AgentPresetSection({ )} - /> + />} {toast !== null && ( } holdMs={REFUSAL_HOLD_MS} // The composer card, which is the content column this chip sits @@ -215,7 +211,7 @@ export function AgentPresetSeat({ // Absent, the banner centers on the window, which is off-center // whenever the sidebar is open. anchor={document.querySelector('[data-composer-card]')} - onDone={() => { setToast(null) }} + onDone={() => { dismissRefusal(toast.error) }} /> )} diff --git a/packages/client/ui-agent-preset/src/client/CreatePluginMenuItem.tsx b/packages/client/ui-agent-preset/src/client/CreatePluginMenuItem.tsx index 654a1849be..e4bbf21d02 100644 --- a/packages/client/ui-agent-preset/src/client/CreatePluginMenuItem.tsx +++ b/packages/client/ui-agent-preset/src/client/CreatePluginMenuItem.tsx @@ -37,7 +37,7 @@ export function CreatePluginMenuItem({ else if (!enabled) description = t('createPluginMissing') return ( - } disabled={!enabled} onSelect={() => { + } disabled={!enabled} onSelect={() => { onDismiss() startCreatorDraft() }}> diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 1ceef47c20..ec70d64a88 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -191,6 +191,7 @@ export function apply(ctx: ClientContext): void { hooks: { agentPresetSeat: seat.store, developerTools: ctx.configForms.developerTools.enabled }, load: () => seat.load(), select: (id: string) => seat.select(id), + dismissRefusal: (error) => { seat.dismissRefusal(error) }, introduced: () => { seat.introduced() }, } } @@ -200,13 +201,15 @@ export function apply(ctx: ClientContext): void { load: () => controller.load(), }) + const startCreatorDraft = () => { + const seat = mainBlankSeat() ?? unboundSeat + seat.stage('cordis', true) + scope.uiWorkspace.startSession() + void seat.apply() + } + scope.effect(() => { - creatorDraft = () => { - const seat = mainBlankSeat() ?? unboundSeat - seat.stage('cordis', true) - scope.uiWorkspace.startSession() - void seat.apply() - } + creatorDraft = startCreatorDraft const chip = scope.slots.register({ name: 'conversation.hero.agentPreset', locale: 'settings.agentPreset', @@ -234,9 +237,7 @@ export function apply(ctx: ClientContext): void { inject: () => ({ hooks: { agentPresets: controller.store }, load: () => controller.load(), - startCreatorDraft: () => { - creatorDraft?.() - }, + startCreatorDraft, }), }, CreatePluginMenuItem)) }) diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts index 186f340398..34177385e7 100644 --- a/packages/client/ui-agent-preset/src/client/seat-store.ts +++ b/packages/client/ui-agent-preset/src/client/seat-store.ts @@ -25,8 +25,8 @@ export interface AgentPresetSeatState { options: readonly AgentPresetOption[] /** The staged choice, empty until the roster loads. */ current: string - /** A rejected apply's message, cleared by the next attempt. */ - error: string | null + /** An error message; explicit selection failures also carry the preset for a Toast. */ + error: string | { readonly preset: AgentPresetOption; readonly reason: string } | null busy: boolean /** * One-shot cue that the chip should introduce itself (the creator-draft @@ -101,7 +101,10 @@ export class AgentPresetSeatController { } this.acceptedSelection = { sessionId, current: preset } this.codingToolsFailure = undefined - if (this.currentSession()?.id === sessionId) this.set({ current: this.staged.id ?? preset, error: null }) + if (this.currentSession()?.id === sessionId) { + const error = this.store.getSnapshot().error + this.set({ current: this.staged.id ?? preset, error: typeof error === 'object' ? error : null }) + } } /** Forget selection receipts when reconnect establishes a new Host generation. */ @@ -127,8 +130,10 @@ export class AgentPresetSeatController { const generation = ++this.loadGeneration const roster = await readRoster(this.ctx) if (generation !== this.loadGeneration) return + // A roster refresh can finish after a selection; leave its announcement for the chip. + const error = this.store.getSnapshot().error if (!roster.ok) { - this.set({ error: roster.error }) + if (typeof error !== 'object' || error === null) this.set({ error: roster.error }) return } const { presets } = roster.value @@ -144,7 +149,7 @@ export class AgentPresetSeatController { // once the flow's session is current, so the reply can arrive after // apply() already composed it. current: this.staged.id ?? (session === undefined ? this.fallback : this.currentPreset(session) ?? ''), - error: null, + error: typeof error === 'object' ? error : null, introduce: this.staged.introduce, }) await this.apply() @@ -154,11 +159,8 @@ export class AgentPresetSeatController { * Stage one preset for the next session, applying it immediately when a * blank session is already current. * - * The refusal is returned as well as stored, because the two readers need - * different things from it: the chip's own label carries the standing state, - * while the caller that made this pick is the one that has to say why the - * label came back — and only it knows the pick was a person's, not the - * applier catching up with a session that just became current. + * The refusal is stored for the chip's announcement and returned to callers + * such as Settings that also report the result of their own write. * @param id - the preset to stage. * @returns the refusal text, or undefined once the pick settled. */ @@ -186,6 +188,15 @@ export class AgentPresetSeatController { this.set({ current: id, error: null, introduce }) } + /** Acknowledge a displayed refusal without dismissing a newer attempt. + * @param refusal - the selection error whose Toast finished. + */ + dismissRefusal(refusal: AgentPresetSeatState['error']): void { + if (refusal !== null && typeof refusal === 'object' && this.store.getSnapshot().error === refusal) { + this.set({ error: refusal.reason }) + } + } + /** * Capture the exact blank Session a Settings action may bring along. * @returns its id, or undefined outside a blank Session. @@ -302,36 +313,32 @@ export class AgentPresetSeatController { const forgetRefused = () => { this.unconfirmedSelections = this.unconfirmedSelections.filter(selection => selection !== awaitingOwner) } + const refuse = (error: string) => { + forgetRefused() + this.set({ + error: correction === undefined + ? { reason: error, preset: this.store.getSnapshot().options.find(option => option.id === staged) ?? { id: staged } } + : error, + current: this.staged.id ?? this.currentPreset(session) ?? current, + }) + if (correction !== undefined) this.codingToolsFailure = { sessionId: session.id, preset: correction, error } + return error + } try { this.set({ busy: true, error: null }) const result = await this.ctx.remote.agentPresets.select(session.id, staged) if (!result.ok) { - forgetRefused() const { error } = result + // Prefer the bare cause; the Toast already names the rejected preset. const refusal = 'reason' in error.details && typeof error.details.reason === 'string' ? error.details.reason : error.message - this.set({ - // A refusal carries its cause twice: `message` wraps it in the - // roster's own frame, which names the preset the surface reporting - // this already names, and a `reason` detail holds the same cause - // without it. Read by the detail rather than by the code, because - // every refusal that has a cause to give names it the same way. - error: refusal, - current: this.staged.id ?? this.currentPreset(session) ?? current, - }) - if (correction !== undefined) this.codingToolsFailure = { sessionId: session.id, preset: correction, error: refusal } - return refusal + return refuse(refusal) } if (this.acceptedSelection === accepted) this.acceptedSelection = { sessionId: session.id, current: result.value } this.set({ current: this.staged.id ?? this.currentPreset(session) ?? '' }) } catch (error) { - forgetRefused() - if (correction === undefined) throw error - const refusal = error instanceof Error ? error.message : String(error) - this.codingToolsFailure = { sessionId: session.id, preset: correction, error: refusal } - this.set({ current: this.staged.id ?? this.currentPreset(session) ?? current, error: refusal }) - return refusal + return refuse(error instanceof Error ? error.message : String(error)) } finally { this.pendingSelection = undefined this.set({ busy: false }) diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index 73fa20fa99..3e9ecca6c2 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -412,8 +412,6 @@ describe('ui-agent-preset apply', () => { await feature.dispose() expect(slots.entries('plugins.add.actions')).toHaveLength(0) - action.startCreatorDraft() - expect(uiWorkspace.starts).toHaveLength(1) } finally { await ctx.fiber.dispose() } @@ -1105,8 +1103,9 @@ describe('ui-agent-preset apply', () => { conversation() }) - it('keeps the applied composition when the roster load lands late', async () => { - const { ctx, slots, calls } = await bench() + it.each([false, true])('keeps the bound Creator outcome when the roster load lands late (refused: %s)', async (refused) => { + const selection = Promise.withResolvers() + const { ctx, slots, calls } = await bench({ selectGate: selection.promise }) declareRoot(slots) const conversation = declareConversation(slots) ctx.provide('conversation', {} as never) @@ -1125,6 +1124,7 @@ describe('ui-agent-preset apply', () => { const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)() const injectSeat = slots.entries('conversation.hero.agentPreset')[0]! .inject as unknown as (sessionId?: SessionId) => AgentPresetSeatInjected + const unboundSeat = injectSeat() await section.load() section.startCreatorDraft?.() @@ -1132,18 +1132,28 @@ describe('ui-agent-preset apply', () => { state.byId['s1'] = { id: 's1', blank: true } sessions.notify() const boundSeat = injectSeat(SessionId('s1')) - await boundSeat.load() + const loading = boundSeat.load() await vi.waitFor(() => { expect(calls).toContain('select:cordis') }) + if (refused) selection.reject(new Error('Creator failed to mount')) + else selection.resolve(undefined) + await loading + await vi.waitFor(() => { expect(boundSeat.hooks.agentPresetSeat.getSnapshot().busy).toBe(false) }) // The chip mounts with the flow's session, so its roster load can land // AFTER the stage was consumed; the session's own composition is what // the display must keep — not the deployment default. state.byId['s1'] = { - id: 's1', blank: true, projectionValues: { agentPreset: 'cordis' }, + id: 's1', blank: true, projectionValues: { agentPreset: refused ? null : 'cordis' }, } await boundSeat.load() - expect(boundSeat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis') + expect(boundSeat.hooks.agentPresetSeat.getSnapshot()).toMatchObject({ + current: refused ? '' : 'cordis', + error: refused ? { preset: { id: 'cordis' }, reason: 'Creator failed to mount' } : null, + }) + expect(unboundSeat.hooks.agentPresetSeat.getSnapshot().error).toBeNull() + boundSeat.dismissRefusal(boundSeat.hooks.agentPresetSeat.getSnapshot().error) + expect(boundSeat.hooks.agentPresetSeat.getSnapshot().error).toBe(refused ? 'Creator failed to mount' : null) conversation() }) @@ -1192,6 +1202,25 @@ describe('AgentPresetSeatController reconciliation', () => { expect(controller.store.getSnapshot().current).toBe('cordis') }) + it('keeps a refused choice visible when an earlier successful selection event arrives late', async () => { + const select = vi.fn((_id: SessionId, id: string) => Promise.resolve({ ok: true as const, value: id })) + const session = { id: SessionId('blank'), blank: true, projectionValues: { agentPreset: 'standard' } } + const controller = new AgentPresetSeatController({ remote: { agentPresets: { select } } } as never, () => session) + await controller.select('minimal') + select.mockRejectedValueOnce(new Error('Creator failed to mount')) + await controller.select('cordis') + const refusal = controller.store.getSnapshot().error + expect(refusal).toEqual({ preset: { id: 'cordis' }, reason: 'Creator failed to mount' }) + + controller.observeSelection(session.id, 'minimal') + + expect(controller.store.getSnapshot().current).toBe('minimal') + expect(controller.store.getSnapshot().error).toBe(refusal) + controller.dismissRefusal(refusal) + controller.observeSelection(session.id, 'standard') + expect(controller.store.getSnapshot().error).toBeNull() + }) + it.each(['external', 'reset'] as const)('does not overwrite a newer %s observation with an older RPC reply', async (change) => { const reply = Promise.withResolvers<{ ok: true; value: string }>() const select = vi.fn(() => reply.promise) @@ -1349,7 +1378,7 @@ describe('AgentPresetSeatController reconciliation', () => { expect(controller.store.getSnapshot()).toMatchObject({ current: '', error: 'Disconnected', busy: false }) }) - it('propagates an explicit selection transport failure and releases it before an automatic fallback', async () => { + it('reports an explicit selection transport failure and releases it before an automatic fallback', async () => { const failure = new Error('Disconnected') const select = vi.fn((_id: SessionId, id: string) => Promise.resolve({ ok: true as const, value: id })) .mockRejectedValueOnce(failure) @@ -1363,8 +1392,10 @@ describe('AgentPresetSeatController reconciliation', () => { id: SessionId('blank'), blank: true, projectionValues: { agentPreset: 'minimal' }, }), undefined, () => disabled) await controller.load() - await expect(controller.select('cordis')).rejects.toBe(failure) - expect(controller.store.getSnapshot().busy).toBe(false) + await expect(controller.select('cordis')).resolves.toBe(failure.message) + expect(controller.store.getSnapshot()).toMatchObject({ + busy: false, error: { preset: { id: 'cordis' }, reason: failure.message }, + }) disabled = true await controller.reconcileCodingTools() expect(select.mock.calls.map(call => call[1])).toEqual(['cordis', 'standard']) @@ -1404,7 +1435,7 @@ describe('AgentPresetSeatController reconciliation', () => { await expect(settings).resolves.toBe(refuseLatest ? 'selection refused' : undefined) expect(controller.store.getSnapshot()).toMatchObject({ current: refuseLatest ? (refuseFirst ? 'standard' : 'minimal') : 'cordis', busy: false, - error: refuseLatest ? 'selection refused' : null, + error: refuseLatest ? { preset: { id: 'cordis' }, reason: 'selection refused' } : null, }) }) @@ -1529,7 +1560,7 @@ describe('AgentPresetSeatController reconciliation', () => { await controller.select('minimal') expect(controller.store.getSnapshot()).toMatchObject({ - busy: false, current: '', error: 'selection rejected', + busy: false, current: '', error: { preset: { id: 'minimal' }, reason: 'selection rejected' }, }) }) @@ -1539,6 +1570,7 @@ describe('AgentPresetSeatController reconciliation', () => { ...developerTools(), remote: { agentPresets: { + list: () => Promise.resolve({ ok: false as const, error: new RemoteError('gateway/internal', 'roster unavailable', {}) }), select: () => Promise.resolve({ ok: false as const, error: new RemoteError( @@ -1554,6 +1586,17 @@ describe('AgentPresetSeatController reconciliation', () => { // The surface reporting this names the preset itself, so carrying the // roster's own "preset X failed to mount" frame would say it twice. expect(await controller.select('broken')).toBe(reason) + const first = controller.store.getSnapshot().error + expect(first).toEqual({ preset: { id: 'broken' }, reason }) + await controller.select('broken') + const second = controller.store.getSnapshot().error + expect(second).toEqual(first) + expect(second).not.toBe(first) + await controller.load() + expect(controller.store.getSnapshot().error).toBe(second) + controller.dismissRefusal(first) + expect(controller.store.getSnapshot().error).toBe(second) + controller.dismissRefusal(second) expect(controller.store.getSnapshot().error).toBe(reason) }) }) diff --git a/packages/client/ui-agent-preset/tests/components.client.spec.tsx b/packages/client/ui-agent-preset/tests/components.client.spec.tsx index 432c7e5392..1b99210d1b 100644 --- a/packages/client/ui-agent-preset/tests/components.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/components.client.spec.tsx @@ -57,7 +57,14 @@ function renderSeat( ) { const store = createSnapshotStore({ ...SEAT_READY, ...state }) const developerTools = createSnapshotStore(enabled) - const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(select), introduced: vi.fn() } + const actions = { + load: vi.fn(() => Promise.resolve()), select: vi.fn(select), introduced: vi.fn(), + dismissRefusal: vi.fn((error: AgentPresetSeatState['error']) => { + if (store.getSnapshot().error === error && error !== null && typeof error === 'object') { + store.set({ ...store.getSnapshot(), error: error.reason }) + } + }), + } const props = { ...actions, sessionId: session === undefined ? undefined : SessionId(session.id), @@ -69,7 +76,7 @@ function renderSeat( t: translate, } as AgentPresetSeatProps render() - return { ...actions, developerTools } + return { ...actions, developerTools, store } } function renderLabel( @@ -215,28 +222,34 @@ describe('the new-session chip', () => { }) describe('a refused switch', () => { - it('announces the reason instead of letting the label snap back in silence', async () => { + it('announces delayed and repeated refusals even without a composed preset or Coding Tools', () => { // The banner's own timer has to be a fake one from the start, or the // lifetime assertion below would wait out its real nine seconds. vi.useFakeTimers({ shouldAdvanceTime: true }) try { const reason = 'failed to import loader entry live-on-mac (@deepseek-ai/dsh-also-gone)' - renderSeat({}, () => Promise.resolve(reason)) - - fireEvent.click(screen.getByRole('button')) - fireEvent.click(screen.getByRole('menuitem', { name: /mine/ })) + const actions = renderSeat({ current: '' }, undefined, undefined, false) + const refusal = { preset: { id: 'cordis' }, reason } + act(() => { actions.store.set({ ...actions.store.getSnapshot(), error: refusal }) }) // The host refuses a mount discovery reported healthy, so this banner is // the only place the cause appears — the chip has already reverted and // the settings row shows the preset as fine. - const banner = await screen.findByRole('alert') + const banner = screen.getByRole('alert') expect(banner.textContent).toContain(reason) - expect(banner.textContent).toContain('mine') + expect(banner.textContent).toContain(en.presetCordisName) + expect(screen.queryByRole('button')).toBeNull() + + act(() => { vi.advanceTimersByTime(7000) }) + act(() => { actions.store.set({ ...actions.store.getSnapshot(), error: { ...refusal } }) }) + act(() => { vi.advanceTimersByTime(2001) }) + expect(screen.getByRole('alert').textContent).toContain(reason) // Transient by design: it holds long enough to read a cause that names // packages, then leaves rather than sitting over the screen. act(() => { vi.advanceTimersByTime(9001) }) expect(screen.queryByRole('alert')).toBeNull() + expect(actions.dismissRefusal).toHaveBeenCalledOnce() } finally { vi.useRealTimers() } diff --git a/packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx b/packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx index b71bae9db1..d1ecedaa66 100644 --- a/packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx @@ -48,19 +48,15 @@ function item(): HTMLElement { } describe('Create plugin menu item', () => { - it('reads the roster and explains the direct Creator action without an introductory dialog', () => { - const actions = view() + it('explains Creator and dismisses the menu before starting it exactly once', () => { + const actions = view({}, true) expect(actions.load).toHaveBeenCalledOnce() expect(item().textContent).toContain(en.createPluginDescription) expect(screen.queryByRole('dialog')).toBeNull() expect(actions.startCreatorDraft).not.toHaveBeenCalled() - }) - - it('dismisses the menu before starting Creator exactly once', () => { - const actions = view() fireEvent.click(item()) expect(actions.calls).toEqual(['dismiss', 'start']) - expect(actions.startCreatorDraft).toHaveBeenCalledOnce() + expect(screen.queryByRole('menu')).toBeNull() expect(screen.queryByRole('dialog')).toBeNull() }) @@ -82,14 +78,6 @@ describe('Create plugin menu item', () => { expect(actions.calls).toEqual([]) }) - it('explains why the entry is disabled when the deployment has no cordis preset', () => { - const actions = view({ options: [{ id: 'standard' }] }) - expect(item()).toHaveProperty('disabled', true) - expect(item().textContent).toContain(en.createPluginMissing) - fireEvent.click(item()) - expect(actions.calls).toEqual([]) - }) - it('disables the same entry when Creator leaves the roster', () => { const actions = view() const original = item() diff --git a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts index 172318a088..6dcbf614fb 100644 --- a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts @@ -441,7 +441,9 @@ describe('the new-session chip controller', () => { // Showing `minimal` after a refusal would claim a composition the session // never got. - expect(controller.store.getSnapshot()).toMatchObject({ current: 'standard', error: 'already started' }) + expect(controller.store.getSnapshot()).toMatchObject({ + current: 'standard', error: { preset: { id: 'minimal' }, reason: 'already started' }, + }) }) it('ignores a pick while a switch is in flight', async () => { diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 732e4f9659..ff23543a2e 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -18,8 +18,8 @@ en: b7d2396da72ecac4 zh: 4ec8336478dd39c7 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle: - en: 7fe665944e8b8377 - zh: faf09d639eca62dd + en: aa90cbad07ea99b0 + zh: 8e1060e5ec331abb /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-a-bundle: en: abc90531efa889d5 zh: 0562570f85e69c35 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 110db393b4..f5f1d3b31a 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -37,9 +37,9 @@ The **Refresh** button shows its tooltip after 500 ms of hovering or keyboard fo ### Installing a bundle -The **Add plugin** primary button directly opens the existing installation dialog. Its separate arrow button, **Choose how to add a plugin**, opens a menu: **Install a third-party plugin** opens that same dialog with focus in its package-name field; plugin-contributed creation actions follow it. The two buttons have independent click targets and accessible names. Arrow keys move between menu rows, Escape closes back to the arrow button, and the menu stays within the viewport. While an installation is tracked, **View installation** opens that task directly. +**Add plugin** and the arrow menu's **Install a third-party plugin** open the installation dialog with focus in its package field. The menu also offers plugin-contributed actions. -`plugins.add.actions` adds root-scoped `MenuItemButton` rows after installation in this menu. Its owner supplies `onDismiss()`, which a contribution calls before starting its action. +`plugins.add.actions` accepts root-scoped `MenuItemButton` contributions after the install action. Contributions call the supplied `onDismiss()` before starting their action. On first use, when pnpm uses the unconfigured official npm registry and npmmirror is offered, the Host probes both registries in parallel. The first successful HTTPS ping response selects the initial source. The dialog preserves remembered choices, manual selections, explicit manager configuration, and custom or unknown pnpm registries. An install clicked during initial probing waits for that bounded operation; a late result cannot overwrite a manual choice or reopen a closed dialog. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 8d61561eb6..24b01e57e2 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -37,9 +37,9 @@ kind: "package-reference" ### 安装一个组合包 -**添加插件**主按钮直接打开原安装对话框。旁边独立的箭头按钮**选择添加插件方式**打开菜单:**安装第三方插件**打开同一个对话框并聚焦包名输入框,插件贡献的创造动作接在其后。两个按钮有独立的点击区域和无障碍名称。方向键在菜单条目间移动,Escape 关闭菜单并返回箭头按钮,菜单位置受视口边界约束。已有安装任务时,**查看安装任务**直接打开该任务。 +**添加插件**主按钮和箭头菜单中的**安装第三方插件**均打开安装对话框并聚焦包名输入框。菜单还提供插件贡献的动作。 -`plugins.add.actions` 在此菜单的安装项之后提供 root 作用域的 `MenuItemButton` 行。owner 提供 `onDismiss()`,贡献者在开始动作前调用它关闭菜单。 +`plugins.add.actions` 接受 root 作用域的 `MenuItemButton` 贡献,放在安装项之后。贡献者在开始动作前调用传入的 `onDismiss()` 关闭菜单。 首次使用时,如果未显式配置安装源、pnpm 使用官方 npm 源,且列表提供 npmmirror,Host 会并发探测这两个源,选择最先成功响应 HTTPS ping 的源。已记住的选择、用户手选、管理器显式配置,以及自定义或未知的 pnpm 源均保留。在初次探测期间点击安装会等待这次有时限的操作;迟到结果不会覆盖手选或重新打开已关闭的对话框。 diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index 9a991ef152..630915eed9 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -84,13 +84,6 @@ padding: 6px 12px; } -/* Keep both action icons centred beside the complete text block. */ -.addMenu [role='menuitem'] > span:first-child:has(> svg), -.addMenu [role='menuitem'] > span:first-child:has(> svg) > svg { - width: 16px; - height: 16px; -} - .addMenuItem { display: flex; flex-direction: column; @@ -99,9 +92,15 @@ } .addMenuDescription { + display: -webkit-box; + max-height: 36px; + overflow: hidden; + overflow-wrap: anywhere; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; } .status, diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index baf14a04ca..9a8f97a9f9 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -79,7 +79,7 @@ function AddPluginMenu({ t, disabled, openInstall, renderSlot }: {