mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
Merge remote-tracking branch 'origin/master' into worktree/web-mention-ux-polish-1bf698
This commit is contained in:
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md
|
||||
2026-08-18-session-history-and-event-transport.md: 808565ff7df60b8aa6aa3f18820c1139b8bf5362
|
||||
2026-08-18-session-history-and-event-transport.zh.md: 8bd00def4531afa9cdf77ae7f689f2e77908545e
|
||||
2026-08-18-session-history-and-event-transport.md: 5f4aba19d147eae3f49fcefc9a8006d0f557dc6c
|
||||
2026-08-18-session-history-and-event-transport.zh.md: dcf7e7ffc5ad325756b1fb37dd5ad60d2f0b3147
|
||||
|
||||
+8
-2
@@ -62,6 +62,8 @@ API Proxy owns neither the Session or Workspace Remote namespace nor the Host do
|
||||
|
||||
The browser's Client Remote plugin starts `RemoteStreamMuxClient` idempotently on activation and connects to `/api/remote.mux` immediately. The physical WebSocket remains resident even when there is no business logical stream.
|
||||
|
||||
The Host sends one RFC 6455 Ping control frame to every open mux socket at the configured `websocketHeartbeatIntervalMs` interval (30 seconds by default). The browser replies with Pong at the protocol layer; neither control frame enters the Remote stream JSON union or changes Connection generation state. The Host imposes no Pong deadline, so half-open detection remains with TCP and network intermediaries.
|
||||
|
||||
After an initial connection failure or the loss of a connected socket, the mux rebuilds the physical connection with capped jittered backoff. Logical streams not yet opened share that reconnect loop; streams already open end their current physical generation with `RemoteStreamCarrierError`.
|
||||
|
||||
In-process `connection.rpc.open` uses the same logical endpoint semantics while bypassing the browser WebSocket mux.
|
||||
@@ -76,7 +78,7 @@ Unexpected normal completion of `$events`, a Host error, a malformed opening fra
|
||||
|
||||
Gateway stream generation, Connection generation, and a Session business open epoch are three independent counters: the first identifies physical replacement of one logical stream, the second identifies a Host-availability handshake, and the last prevents an obsolete Session open from writing into current state.
|
||||
|
||||
Plugin disposal stops backoff, cancels candidate and active sockets, ends logical streams, and awaits quiescence of background loops and consumers.
|
||||
Host plugin disposal stops the heartbeat timer, terminates mux sockets, and waits for active iterators. Client plugin disposal stops backoff, cancels candidate and active sockets, ends logical streams, and awaits quiescence of background loops and consumers.
|
||||
|
||||
### General Remote stream model
|
||||
|
||||
@@ -320,13 +322,15 @@ API Proxy carries only independent business APIs it owns. Session, Workspace, Re
|
||||
|
||||
**Use an independent physical WebSocket or duplex stream for Remote Event.** Gateway mux already provides authenticated upgrade, multiplexing, cancellation, error mapping, and reconnect. Downlink `$events` plus HTTP `$events/result` expresses request/response without a third connection.
|
||||
|
||||
**Send application-level JSON heartbeat frames.** This would expand the strict Remote stream message union and require browser handling for traffic with no business meaning. WebSocket Ping/Pong provides carrier activity without changing logical-stream semantics.
|
||||
|
||||
**Retain API Proxy's Host mux.** This keeps the handwritten union, schema, response envelope, and second stream lifecycle, and prevents Session and Workspace Controllers from owning their data protocols independently.
|
||||
|
||||
**Update Session list time from aggregate `session/event`.** List correctness would depend on which Sessions a browser consumes and would mistake arbitrary plugin events for user activity. The durable `lastPromptAt` projection expresses the ordering fact directly.
|
||||
|
||||
## Verification
|
||||
|
||||
Gateway mux tests pin connection without logical streams, idle residency, initial-failure and disconnect recovery, active-stream carrier failure, cancellation, and no reconnect after disposal.
|
||||
Gateway mux tests pin connection without logical streams, idle residency, configurable Ping/Pong without application messages, initial-failure and disconnect recovery, active-stream carrier failure, cancellation, and no reconnect after disposal.
|
||||
|
||||
Connection tests pin missing, duplicate, and withdrawn generation sources; the race between `$events` ready and `host.describe`; and description withdrawal and rebuilding after generation failure.
|
||||
|
||||
@@ -364,6 +368,8 @@ Durable logs repair a missing suffix by sequence number and page; Session contro
|
||||
|
||||
Gateway owns only transport, generation, pending waterfalls, and strict wire validation, not Session or Workspace business fields. A domain Controller supplies only openers, cursor rules, baseline reducers, and error presentation.
|
||||
|
||||
Each resident browser connection adds one empty Ping/Pong exchange per configured interval. Deployments can shorten the interval for stricter idle timeouts without changing the Remote stream protocol or browser code.
|
||||
|
||||
Session and Workspace Host APIs, stream adapters, and Client data models each have an explicit owner. API Proxy is no longer their intermediary.
|
||||
|
||||
The general stream objects add three explicit layers while deleting the retry, cancellation, generation, baseline, and gap-repair shells previously duplicated by each Controller.
|
||||
|
||||
+8
-2
@@ -62,6 +62,8 @@ API Proxy 不拥有 Session 或 Workspace Remote namespace,也不拥有 Host
|
||||
|
||||
浏览器的 Client Remote 插件激活时幂等启动 `RemoteStreamMuxClient`,并立即连接 `/api/remote.mux`。没有业务 logical stream 时物理 WebSocket 仍保持常驻。
|
||||
|
||||
Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 30 秒)向每条已打开的 mux socket 发送一个 RFC 6455 Ping 控制帧;浏览器在协议层回复 Pong。两种控制帧都不进入 Remote stream JSON union,也不改变 Connection generation 状态。Host 不设置 Pong deadline,因此半开检测仍由 TCP 与网络中间层承担。
|
||||
|
||||
首次建连失败或已连接 socket 丢失后,mux 使用有上限的抖动退避重建物理连接。尚未打开的 logical stream 共享该重连循环;已经打开的 stream 以 `RemoteStreamCarrierError` 结束当前物理 generation。
|
||||
|
||||
进程内 `connection.rpc.open` 使用同一 logical endpoint 语义,但绕过浏览器 WebSocket mux。
|
||||
@@ -76,7 +78,7 @@ Host event source 在返回首帧前同步安装增量 listener。Gateway 随后
|
||||
|
||||
Gateway stream、Connection generation 与 Session 业务 open epoch 是三个独立计数:前者表示某条 logical stream 的物理替换,第二个表示 Host 可用性握手,最后一个防止已淘汰的 Session open 写回当前状态。
|
||||
|
||||
插件销毁会停止退避,取消候选与活动 socket,终止 logical stream,并等待后台循环和 consumer 静默退出。
|
||||
Host 插件销毁会停止心跳定时器、终止 mux socket,并等待活跃 iterator 完成。Client 插件销毁会停止退避,取消候选与活动 socket,终止 logical stream,并等待后台循环和 consumer 完全停稳。
|
||||
|
||||
### 通用 Remote stream 模型
|
||||
|
||||
@@ -320,13 +322,15 @@ API Proxy 只承接自身拥有的独立业务 API,不是 Session、Workspace
|
||||
|
||||
**给 Remote Event 使用独立物理 WebSocket 或 duplex stream。** Gateway mux 已提供认证升级、复用、取消、错误映射和重连;下行 `$events` 加上 HTTP `$events/result` 足以表达 request/response,不需要第三条连接。
|
||||
|
||||
**发送应用层 JSON 心跳帧。** 这会扩展严格的 Remote stream message union,并要求浏览器处理没有业务含义的流量。WebSocket Ping/Pong 无需改变 logical stream 语义即可保持 carrier 活跃。
|
||||
|
||||
**继续保留 API Proxy 的 Host mux。** 这会保留手写 union、schema、响应 envelope 和第二套 stream 生命周期,并使 Session 与 Workspace Controller 不能独立拥有自己的数据协议。
|
||||
|
||||
**从聚合 `session/event` 更新 Session 列表时间。** 列表正确性会依赖浏览器正在消费哪些 Session,并把任意插件事件误判为用户活跃;持久 `lastPromptAt` 投影直接表达排序事实。
|
||||
|
||||
## 验证
|
||||
|
||||
Gateway mux 测试固定无 logical stream 时建连、空闲常驻、初始失败与断线重连、活动 stream carrier failure、取消和 dispose 后不再重连。
|
||||
Gateway mux 测试固定无 logical stream 时建连、空闲常驻、可配置且不产生应用消息的 Ping/Pong、初始失败与断线重连、活动 stream carrier failure、取消和 dispose 后不再重连。
|
||||
|
||||
Connection 测试固定 generation source 缺失、重复注册、撤回、`$events` ready 与 `host.describe` 的竞争,以及 generation 失败后的 description 撤回和重建。
|
||||
|
||||
@@ -364,6 +368,8 @@ Remote Event Client 测试固定实例私有 key、Cordis 注册顺序、Agent C
|
||||
|
||||
Gateway 只拥有 transport、generation、pending waterfall 和严格 wire 校验,不拥有 Session 或 Workspace 业务字段。领域 Controller 只提供 opener、cursor 规则、baseline reducer 和错误呈现。
|
||||
|
||||
每条常驻浏览器连接会按配置间隔增加一次空载荷 Ping/Pong 交换。面对更严格的空闲超时,部署方可缩短间隔,而无需改变 Remote stream 协议或浏览器代码。
|
||||
|
||||
Session 与 Workspace 的 Host API、stream adapter 和 Client 数据模型各有明确 owner;API Proxy 不再是它们之间的中介。
|
||||
|
||||
通用 stream 对象增加了三个明确层级,但删除了每个 Controller 各自复制的 retry、cancel、generation、baseline 和 gap-repair 外壳。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md
|
||||
2026-07-29-pnpm-setup-runner-isolation.md: c7c076f34dcd4b905a6bb54411538d6cf61bc1d0
|
||||
2026-07-29-pnpm-setup-runner-isolation.zh.md: 2dd866404a5a799fe33e8b9c70c17787dfed0c2a
|
||||
2026-07-29-pnpm-setup-runner-isolation.md: d5cd02cceba920368f0dfe6535e4bd03ee075417
|
||||
2026-07-29-pnpm-setup-runner-isolation.zh.md: 5266112224b940c06ea2567247532eb15ce7fce8
|
||||
|
||||
@@ -10,9 +10,9 @@ English | [中文](2026-07-29-pnpm-setup-runner-isolation.zh.md)
|
||||
|
||||
## Decision
|
||||
|
||||
Every `pnpm/action-setup` step in [the primary CI workflow](../../../../.github/workflows/ci.yml) and [the master workflow](../../../../.github/workflows/ci-master.yml) sets `dest: ${{ runner.temp }}/setup-pnpm`. Each runner service owns its temporary directory, so one setup cannot replace another runner's install directory. Persistent store reuse remains separate through `PNPM_CONFIG_STORE_DIR`, as established by the [pnpm provisioning decision](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md).
|
||||
Every non-Windows `pnpm/action-setup` step in [the primary CI workflow](../../../../.github/workflows/ci.yml) and [the master workflow](../../../../.github/workflows/ci-master.yml) sets `dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}`. Each runner service owns its temporary directory, so one setup cannot replace another runner's install directory, and the run/attempt suffix also protects sequential jobs on the same runner from a stale locked `pnpm.exe`. The Windows native jobs and the [python SDK exe build](../../../../.github/workflows/build-exe-for-python-sdk.yml) use a separate pnpm executable under `setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}` (not `standalone: true`; the destination keeps that executable apart): the run/attempt/job suffix gives every job a fresh directory even when sequential jobs land on the same self-hosted runner and a previous job leaves a locked @reflink native module. Persistent store reuse remains separate through `PNPM_CONFIG_STORE_DIR`, as established by the [pnpm provisioning decision](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md).
|
||||
|
||||
[The workflow regression test](../../../../scripts/ci-workflow.spec.ts) discovers every `pnpm/action-setup` step in `ci.yml` and `ci-master.yml` and rejects one without the runner-private destination. This keeps newly added jobs inside the same isolation boundary.
|
||||
[The workflow regression test](../../../../scripts/ci-workflow.spec.ts) discovers every `pnpm/action-setup` step in `ci.yml`, `ci-master.yml`, and `build-exe-for-python-sdk.yml` and rejects one without the runner-private destination. This keeps newly added jobs inside the same isolation boundary.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
[主 CI 工作流](../../../../.github/workflows/ci.yml)与 [CI master 工作流](../../../../.github/workflows/ci-master.yml)中的每个 `pnpm/action-setup` 步骤都设置 `dest: ${{ runner.temp }}/setup-pnpm`。每个 runner 服务独占自己的临时目录,因此一个设置过程无法替换另一个 runner 的安装目录。持久 store 的复用仍由 `PNPM_CONFIG_STORE_DIR` 独立处理,遵循 [pnpm 配置决策](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md)。
|
||||
[主 CI 工作流](../../../../.github/workflows/ci.yml)与 [CI master 工作流](../../../../.github/workflows/ci-master.yml)中的每个**非 Windows** `pnpm/action-setup` 步骤都设置 `dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}`。每个 runner 服务独占自己的临时目录,因此一个设置过程无法替换另一个 runner 的安装目录;run/attempt 后缀还能防止同一 runner 上顺序作业因残留的锁定 `pnpm.exe` 而失败。Windows 原生作业与 [python SDK exe 构建](../../../../.github/workflows/build-exe-for-python-sdk.yml)在 `setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}` 下使用独立的 pnpm 可执行文件(非 `standalone: true`,目录本身起分离作用):run/attempt/job 后缀让每次作业都使用全新目录,即使顺序作业落到同一自托管 runner、且前一作业留下被锁定的 @reflink 原生模块。持久 store 的复用仍由 `PNPM_CONFIG_STORE_DIR` 独立处理,遵循 [pnpm 配置决策](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md)。
|
||||
|
||||
[工作流回归测试](../../../../scripts/ci-workflow.spec.ts)会找出 `ci.yml` 与 `ci-master.yml` 中的每个 `pnpm/action-setup` 步骤,并拒绝缺少 runner 专属目标目录的步骤。这可确保后续新增的作业也处于同一隔离边界内。
|
||||
[工作流回归测试](../../../../scripts/ci-workflow.spec.ts)会找出 `ci.yml`、`ci-master.yml` 与 `build-exe-for-python-sdk.yml` 中的每个 `pnpm/action-setup` 步骤,并拒绝缺少 runner 专属目标目录的步骤。这可确保后续新增的作业也处于同一隔离边界内。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md
|
||||
2026-07-31-web-telemetry-default-mount.md: a492356eccba9f272ee216777eb518750c7b6b62
|
||||
2026-07-31-web-telemetry-default-mount.zh.md: 3d852229c069ae32f328c8dae38cfdf1744c7293
|
||||
2026-07-31-web-telemetry-default-mount.md: aea90ef928afaa7669df8046fa98f16c575618f0
|
||||
2026-07-31-web-telemetry-default-mount.zh.md: 122b7ad593c18cd547ade835827e8903d34da441
|
||||
|
||||
@@ -10,12 +10,12 @@ The telemetry seam and OTel backend ([revival Note](2026-07-23-session-telemetry
|
||||
|
||||
## Decision
|
||||
|
||||
The shared dsh base bundle (`packages/bundle/base/cordis.patch.yml`) mounts the `session-telemetry-otel` row with a baked-in production endpoint, so every base-backed profile has one consistent telemetry capability. The standalone [`sdk-minimal` profile](../architecture/2026-08-24-standalone-sdk-minimal-profile.md) deliberately omits that row. The [default-off decision](2026-08-10-telemetry-default-off.md) keeps the mounted row in `DISABLED` mode unless a deployment explicitly selects `FULL` or `FEEDBACK_ONLY`; the endpoint alone does not authorize reporting. Web and headless use the [bounded, escalating process-shutdown controller](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) on SIGINT/SIGTERM, giving an enabled backend's three-second shutdown deadline time to drain before the five-second launcher bound.
|
||||
The shared dsh base bundle (`packages/bundle/base/cordis.patch.yml`) mounts the `session-telemetry-otel` row with a baked-in production endpoint, so every base-backed profile has one consistent telemetry capability. The standalone [`sdk-minimal` profile](../architecture/2026-08-24-standalone-sdk-minimal-profile.md) deliberately omits that row. The [default-off decision](2026-08-10-telemetry-default-off.md) originally kept the mounted row in `DISABLED` mode; the [feedback-gated default](2026-08-25-feedback-gated-telemetry-default.md) now resolves an unset mode to `FEEDBACK_ONLY`, uploading only when the user records `/feedback`. The endpoint alone still does not authorize reporting. Web and headless use the [bounded, escalating process-shutdown controller](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) on SIGINT/SIGTERM, giving an enabled backend's three-second shutdown deadline time to drain before the five-second launcher bound.
|
||||
|
||||
| Ruling | Value | Rationale |
|
||||
|---|---|---|
|
||||
| Mount surface | `packages/bundle/base/cordis.patch.yml` | One capability row for every profile that loads the shared base |
|
||||
| Sharing mode | `DSH_TELEMETRY_MODE`, default `DISABLED`; explicit `FULL` or `FEEDBACK_ONLY` opts in | A fresh profile makes no telemetry network request, while internal deployments retain both upload policies |
|
||||
| Sharing mode | `DSH_TELEMETRY_MODE`, default `FEEDBACK_ONLY` ([feedback-gated default](2026-08-25-feedback-gated-telemetry-default.md)); explicit `FULL` or `DISABLED` overrides | A fresh profile uploads only when the user records `/feedback`, while internal deployments retain both explicit policies |
|
||||
| Endpoint | `DSH_TELEMETRY_OTLP_URL`, default `https://harness-telemetry.deepseeksvc.com/v1/logs` | Internal collector; the env override serves local/dev runs |
|
||||
| Hard opt-out | any non-empty `DSH_TELEMETRY_DISABLED` (including `0`/`false`) disables the row | The launcher patch takes effect before load-time transport validation and overrides every configured mode |
|
||||
| Cadence | `processor.scheduledDelayMillis: 10000` (10s/batch) in uploading modes | Streaming while the session runs, never exit-time-only; a crash loses at most the last unexported interval |
|
||||
@@ -23,7 +23,7 @@ The shared dsh base bundle (`packages/bundle/base/cordis.patch.yml`) mounts the
|
||||
| Compression | `compression: gzip` | Event bodies carry full content; cross-datacenter bandwidth |
|
||||
| CI isolation | top-level `env: DSH_TELEMETRY_DISABLED: '1'` in GitHub workflows | Defense in depth keeps test sessions local even when a job explicitly selects an uploading mode |
|
||||
|
||||
The base bundle test pins the shipped `DISABLED` mode expression, the backend suite pins that omitted mode constructs no transport, and the real Loader composition suite explicitly selects each uploading mode when it verifies OTLP delivery.
|
||||
The base bundle test pins the shipped `FEEDBACK_ONLY` mode expression, the backend suite pins that omitted mode constructs no transport, and the real Loader composition suite explicitly selects each uploading mode when it verifies OTLP delivery.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -35,6 +35,6 @@ The base bundle test pins the shipped `DISABLED` mode expression, the backend su
|
||||
|
||||
## Consequences
|
||||
|
||||
- A developer running `dsh web` without telemetry configuration makes no telemetry network request. An internal deployment sets `DSH_TELEMETRY_MODE` and may point `DSH_TELEMETRY_OTLP_URL` at another collector.
|
||||
- A developer running `dsh web` without telemetry configuration makes no telemetry network request until they record `/feedback`. An internal deployment sets `DSH_TELEMETRY_MODE` and may point `DSH_TELEMETRY_OTLP_URL` at another collector.
|
||||
- **No redaction rule is mounted**: explicitly enabled exports are the raw captured copy (full user/assistant message text, tool arguments and results, the system prompt, the local `session.cwd` path). Crossing a trust boundary requires `session-telemetry/record` rules first — the redaction rule, remaining identity Resource attributes, and usage metrics remain separate deployment work. The anonymous user id ships through the [anonymous-user-id Note](2026-07-31-telemetry-anonymous-user-id.md).
|
||||
- Test rigs remain local by default; explicit uploading-mode tests provide their own collector and mode.
|
||||
|
||||
@@ -10,12 +10,12 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
共享 dsh 基础组合包(`packages/bundle/base/cordis.patch.yml`)挂载带有内置生产 endpoint 的 `session-telemetry-otel` 配置行,使每个基于 base 的 profile 都具有一致的遥测能力。独立的 [`sdk-minimal` profile](../architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md)刻意省略该配置项。[默认关闭决策](2026-08-10-telemetry-default-off.zh.md)让已挂载配置项保持 `DISABLED` 模式,除非部署方显式选择 `FULL` 或 `FEEDBACK_ONLY`;仅配置 endpoint 不构成上报授权。Web 与 headless 在 SIGINT/SIGTERM 时使用[有界、可升级的进程关闭控制器](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md),在启动器 5 秒上限到期前,先给已启用的后端 3 秒关闭截止时间完成排空。
|
||||
共享 dsh 基础组合包(`packages/bundle/base/cordis.patch.yml`)挂载带有内置生产 endpoint 的 `session-telemetry-otel` 配置行,使每个基于 base 的 profile 都具有一致的遥测能力。独立的 [`sdk-minimal` profile](../architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md)刻意省略该配置项。[默认关闭决策](2026-08-10-telemetry-default-off.zh.md)最初让已挂载配置项保持 `DISABLED` 模式;[反馈门控默认值决定](2026-08-25-feedback-gated-telemetry-default.zh.md)现在把未设置的模式解析为 `FEEDBACK_ONLY`,只在用户记录 `/feedback` 时上传。仅配置 endpoint 仍不构成上报授权。Web 与 headless 在 SIGINT/SIGTERM 时使用[有界、可升级的进程关闭控制器](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md),在启动器 5 秒上限到期前,先给已启用的后端 3 秒关闭截止时间完成排空。
|
||||
|
||||
| 决策项 | 取值 | 理由 |
|
||||
|---|---|---|
|
||||
| 挂载面 | `packages/bundle/base/cordis.patch.yml` | 每个加载共享基础组合包的 profile 都使用同一个能力配置行 |
|
||||
| 共享模式 | `DSH_TELEMETRY_MODE`,默认 `DISABLED`;显式设置 `FULL` 或 `FEEDBACK_ONLY` 即启用 | 新 profile 不发出遥测网络请求,内部部署仍可使用两种上传策略 |
|
||||
| 共享模式 | `DSH_TELEMETRY_MODE`,默认 `FEEDBACK_ONLY`([反馈门控默认值决定](2026-08-25-feedback-gated-telemetry-default.zh.md));显式设置 `FULL` 或 `DISABLED` 即覆盖 | 新 profile 只在用户记录 `/feedback` 时上传,内部部署仍可使用两种显式策略 |
|
||||
| endpoint | `DSH_TELEMETRY_OTLP_URL`,缺省 `https://harness-telemetry.deepseeksvc.com/v1/logs` | 内部 collector;env 覆盖供本地/联调 |
|
||||
| 硬性退出 | `DSH_TELEMETRY_DISABLED` 非空(含 `0`/`false`)即禁用该配置行 | 启动器 patch 在加载期传输校验之前生效,并覆盖所有已配置模式 |
|
||||
| 上报节奏 | 上传模式中为 `processor.scheduledDelayMillis: 10000`(10s/批) | 在会话运行期间流式上报,而非仅在退出时上报;崩溃至多丢失最后一个尚未导出间隔内的数据 |
|
||||
@@ -24,7 +24,7 @@ Status: implemented
|
||||
| CI 隔离 | GitHub 工作流顶层 `env: DSH_TELEMETRY_DISABLED: '1'` | 即使 CI 任务显式选择上传模式,纵深防御也会让测试会话留在本地 |
|
||||
|
||||
|
||||
基础组合包测试固定交付的 `DISABLED` 模式表达式,后端测试套件固定省略模式时不构造传输,真实 Loader 组合测试则在验证 OTLP 投递时显式选择每种上传模式。
|
||||
基础组合包测试固定交付的 `FEEDBACK_ONLY` 模式表达式,后端测试套件固定省略模式时不构造传输,真实 Loader 组合测试则在验证 OTLP 投递时显式选择每种上传模式。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -36,6 +36,6 @@ Status: implemented
|
||||
|
||||
## 后果
|
||||
|
||||
- 开发者运行没有遥测配置的 `dsh web` 时,不会发出遥测网络请求。内部部署需设置 `DSH_TELEMETRY_MODE`,并可让 `DSH_TELEMETRY_OTLP_URL` 指向其他 collector。
|
||||
- 开发者运行没有遥测配置的 `dsh web` 时,在记录 `/feedback` 之前不会发出遥测网络请求。内部部署需设置 `DSH_TELEMETRY_MODE`,并可让 `DSH_TELEMETRY_OTLP_URL` 指向其他 collector。
|
||||
- **没有挂载任何脱敏规则**:显式启用的导出即原始捕获副本(用户/助手消息全文、工具参数与工具结果、系统提示词、`session.cwd` 本地路径)。跨信任边界前必须先挂载 `session-telemetry/record` 规则;脱敏规则、其余身份 Resource 属性和使用情况指标仍是独立的部署工作。匿名 user id 由[匿名 user id Note](2026-07-31-telemetry-anonymous-user-id.zh.md)交付。
|
||||
- 测试载具默认将数据留在本地;显式启用上传模式的测试提供自己的 collector 和模式。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md
|
||||
2026-08-10-telemetry-default-off.md: 3f56817c9c23ec55f2173b66fa915ab05646b2a7
|
||||
2026-08-10-telemetry-default-off.zh.md: ea89ea94dfea3106886e555e172ad8a6ab39305b
|
||||
2026-08-10-telemetry-default-off.md: 1bc9719f7036509f1fff8c28f0607669505280ad
|
||||
2026-08-10-telemetry-default-off.zh.md: 8f8bbb57810125059c177dc1c94f7631040a1d58
|
||||
|
||||
@@ -10,11 +10,11 @@ DeepSeek Harness has two outbound telemetry feeds. During internal testing, the
|
||||
|
||||
## Decision
|
||||
|
||||
Both feeds use `DSH_TELEMETRY_MODE` as their positive consent setting. Unset and empty values resolve to `DISABLED`. `@deepseek-ai/dsh-session-telemetry-otel` also resolves an omitted `mode` to `DISABLED`, which constructs no OTel provider, processor, or exporter and leaves feedback in the local session log. The shared dsh base keeps the backend row mounted so disabled feedback can still explain that nothing was shared. A deployment opts into Session Log sharing through `FULL` or `FEEDBACK_ONLY`; only `FULL` also permits dsh-sdk launcher reporting. Any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative pre-load hard opt-out. The [default-mount decision](2026-07-31-web-telemetry-default-mount.md) continues to own the endpoint, batching cadence, and exit-drain settings.
|
||||
Both feeds use `DSH_TELEMETRY_MODE` as their positive consent setting. Unset and empty values resolve to `DISABLED`. `@deepseek-ai/dsh-session-telemetry-otel` also resolves an omitted `mode` to `DISABLED`, which constructs no OTel provider, processor, or exporter and leaves feedback in the local session log. The shared dsh base keeps the backend row mounted so disabled feedback can still explain that nothing was shared. A deployment opts into Session Log sharing through `FULL` or `FEEDBACK_ONLY`; only `FULL` also permits dsh-sdk launcher reporting. The shared base's session-backend default was later superseded by the [feedback-gated default](2026-08-25-feedback-gated-telemetry-default.md), which resolves an unset `DSH_TELEMETRY_MODE` to `FEEDBACK_ONLY`; the hard opt-out and the launcher rule below remain current. Any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative pre-load hard opt-out. The [default-mount decision](2026-07-31-web-telemetry-default-mount.md) continues to own the endpoint, batching cadence, and exit-drain settings.
|
||||
|
||||
The dsh-sdk launcher reads the same variable without parsing `cordis.yml` or booting Cordis. `FULL` permits reporting; `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny it. Consent is frozen from the launching environment before the command runs, because `dsh-sdk start` loads a project `.env` and project code can mutate `process.env`: resolving afterwards would let a project grant reporting of its own configuration, which the [configuration source ownership decision](../architecture/2026-08-04-configuration-source-ownership.md) denies for the whole `DSH_*` namespace. An unsupported mode denies rather than throwing at that boundary, since telemetry may never change a command's result. This rule superseded the default-on launcher consent before the launcher and its proposal were deleted by the [SDK project toolchain removal](../simplification/2026-08-11-remove-sdk-project-toolchain.md).
|
||||
|
||||
The [CLI reference README](../../../../apps/cli/reference/README.md) documents the deployment stance: Session Log upload is off by default, `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` and `DSH_TELEMETRY_MODE=FULL` are the two opt-in choices, and explicitly enabled exports can contain complete session content. The restored [testing-stage onboarding notice](2026-08-13-shared-modal-product-onboarding.md) contains no telemetry copy, so the product still presents no prompt about enabling upload.
|
||||
The [CLI reference README](../../../../apps/cli/reference/README.md) documents the current deployment stance: the shared base defaults to feedback-gated sharing ([feedback-gated default](2026-08-25-feedback-gated-telemetry-default.md)), `DSH_TELEMETRY_MODE=FULL` and `DSH_TELEMETRY_MODE=DISABLED` are the explicit overrides, and enabled exports can contain complete session content. The restored [testing-stage onboarding notice](2026-08-13-shared-modal-product-onboarding.md) contains no telemetry copy, so the product still presents no prompt about enabling upload.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -28,4 +28,4 @@ The [CLI reference README](../../../../apps/cli/reference/README.md) documents t
|
||||
|
||||
## Consequences
|
||||
|
||||
Fresh profiles and projects make no telemetry network request. Internal deployments select one mode for both feeds: `FEEDBACK_ONLY` permits only feedback-triggered Session Log sharing, while `FULL` also enables launcher reporting. The existing hard opt-out remains effective, and uploading modes retain their endpoint validation, redaction responsibility, batching, and shutdown behavior.
|
||||
Fresh profiles and projects make no telemetry network request until the user records `/feedback` ([feedback-gated default](2026-08-25-feedback-gated-telemetry-default.md)). `FULL` still requires an explicit setting; the launcher feed it once also enabled was deleted with the SDK project toolchain. The existing hard opt-out remains effective, and uploading modes retain their endpoint validation, redaction responsibility, batching, and shutdown behavior.
|
||||
|
||||
@@ -10,11 +10,11 @@ DeepSeek Harness 有两路出站遥测数据流。在内测阶段,共享基础
|
||||
|
||||
## 决策
|
||||
|
||||
两路数据流都使用 `DSH_TELEMETRY_MODE` 作为正向授权配置。未设置和空值都解析为 `DISABLED`。`@deepseek-ai/dsh-session-telemetry-otel` 也将省略的 `mode` 解析为 `DISABLED`;该模式不构造 OTel 提供方、处理器或导出器,并将反馈留在本地会话日志中。dsh 共享基础配置继续挂载后端配置行,使禁用模式仍可在记录反馈时说明没有共享任何内容。部署方通过 `FULL` 或 `FEEDBACK_ONLY` 显式启用 Session Log 共享;只有 `FULL` 还允许 dsh-sdk 启动器上报。任何非空 `DSH_TELEMETRY_DISABLED` 仍是具有最高优先级的加载前硬性退出开关。[默认挂载决策](2026-07-31-web-telemetry-default-mount.zh.md)继续负责 endpoint、批处理节奏和退出排空设置。
|
||||
两路数据流都使用 `DSH_TELEMETRY_MODE` 作为正向授权配置。未设置和空值都解析为 `DISABLED`。`@deepseek-ai/dsh-session-telemetry-otel` 也将省略的 `mode` 解析为 `DISABLED`;该模式不构造 OTel 提供方、处理器或导出器,并将反馈留在本地会话日志中。dsh 共享基础配置继续挂载后端配置行,使禁用模式仍可在记录反馈时说明没有共享任何内容。部署方通过 `FULL` 或 `FEEDBACK_ONLY` 显式启用 Session Log 共享;只有 `FULL` 还允许 dsh-sdk 启动器上报。共享基础配置中会话后端的默认值后来被[反馈门控默认值决定](2026-08-25-feedback-gated-telemetry-default.zh.md)取代,未设置的 `DSH_TELEMETRY_MODE` 解析为 `FEEDBACK_ONLY`;硬性退出开关和下文的启动器规则仍然有效。任何非空 `DSH_TELEMETRY_DISABLED` 仍是具有最高优先级的加载前硬性退出开关。[默认挂载决策](2026-07-31-web-telemetry-default-mount.zh.md)继续负责 endpoint、批处理节奏和退出排空设置。
|
||||
|
||||
dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cordis。`FULL` 允许上报;`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝。授权在命令执行前从启动环境冻结:`dsh-sdk start` 会加载项目 `.env`,项目代码也能修改 `process.env`,若在执行后解析,项目便能自行授权上报其自身配置,而[配置来源所有权决策](../architecture/2026-08-04-configuration-source-ownership.zh.md)对整个 `DSH_*` 命名空间禁止这种行为。在该边界上,不受支持的模式按拒绝处理而非抛出,因为遥测不得改变命令结果。此规则在启动器及其提案被[SDK 项目工具链移除决策](../simplification/2026-08-11-remove-sdk-project-toolchain.zh.md)删除之前,仅取代了启动器默认允许上报的规则。
|
||||
|
||||
[CLI reference README](../../../../apps/cli/reference/README.zh.md) 记录了这一部署口径:会话日志上传默认关闭,`DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 和 `DSH_TELEMETRY_MODE=FULL` 是两种显式启用选项,显式开启后的导出可能包含完整会话内容。恢复后的[测试阶段引导声明](2026-08-13-shared-modal-product-onboarding.zh.md)不包含遥测文案,因此产品仍不提供任何关于开启上传的提示。
|
||||
[CLI reference README](../../../../apps/cli/reference/README.zh.md) 记录了当前的部署口径:共享基础配置默认按反馈门控共享([反馈门控默认值决定](2026-08-25-feedback-gated-telemetry-default.zh.md)),`DSH_TELEMETRY_MODE=FULL` 和 `DSH_TELEMETRY_MODE=DISABLED` 是显式覆盖值,开启后的导出可能包含完整会话内容。恢复后的[测试阶段引导声明](2026-08-13-shared-modal-product-onboarding.zh.md)不包含遥测文案,因此产品仍不提供任何关于开启上传的提示。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -28,4 +28,4 @@ dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cord
|
||||
|
||||
## 后果
|
||||
|
||||
全新 profile 和项目不发出任何遥测网络请求。内部部署为两路数据流选择一个模式:`FEEDBACK_ONLY` 只允许由反馈触发的 Session Log 共享,`FULL` 还会启用启动器上报。现有硬性退出继续生效,上传模式也保留 endpoint 校验、脱敏责任、批处理和关闭行为。
|
||||
全新 profile 和项目在用户记录 `/feedback` 之前不发出任何遥测网络请求([反馈门控默认值决定](2026-08-25-feedback-gated-telemetry-default.zh.md))。`FULL` 仍需显式设置;它曾一并启用的启动器数据流已随 SDK 项目工具链删除。现有硬性退出继续生效,上传模式也保留 endpoint 校验、脱敏责任、批处理和关闭行为。
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md
|
||||
2026-08-18-model-selected-subagent-routes.md: bf4788b141370933197d9ec1a1ad3c8e76a6740c
|
||||
2026-08-18-model-selected-subagent-routes.zh.md: 1d83e2e91ffe87fff7f8e9d1988320cb2bb8f2f7
|
||||
2026-08-18-model-selected-subagent-routes.md: 9cdbdbec3b79a93043fa6ae95a6a6dedf6072086
|
||||
2026-08-18-model-selected-subagent-routes.zh.md: 3b81f5417339325dd60884049a853d1094a9f1eb
|
||||
|
||||
@@ -12,15 +12,15 @@ The model also needs a bounded way to discover live providers and model-owned ef
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-tool-subagent` exposes optional `provider`, `model`, and `reasoning_effort` fields only when its instance enables `enableModelSelection`, or its Agent-scoped `modelSelectionSettings` instance resolves an enabled Session decision, and the bound subagent provider advertises `SubagentCapabilities.agentOptions`. No route allowlist is required. Registered LLM provider routes are available for child selection; this tool does not add a second authorization policy over the deployment's LLM registry. Disabled instances omit and reject model-facing selection, while configured `Config.agentOptions` remain deployment-owned defaults. Either selection mode against a provider without the capability fails the plugin mount.
|
||||
`dsh-tool-subagent` exposes optional `provider`, `model`, and `reasoning_effort` fields only when its Agent-scoped `modelSelectionSettings` instance resolves a Session policy and the bound subagent provider advertises `SubagentCapabilities.agentOptions`. The policy uses the exact user authorization owned by [user-authorized subagent model routes](2026-08-24-user-authorized-subagent-model-routes.md); there is no unrestricted static mode. Disabled instances omit and reject model-facing selection, while configured `Config.agentOptions` remain deployment-owned defaults. A settings-enabled instance against a provider without the capability fails the plugin mount.
|
||||
|
||||
Provider and model form one route and must be supplied together. An effort may be supplied alone when configured, parent, or provider-owned route defaults provide the effective route. Static `provider.agentRouteDefaults`, when present, establish the provider/model baseline; `Config.agentOptions` and model arguments overlay it before route-aware effort clearing. Providers without static defaults use compatible fields from the parent Agent's latest logged request selection, with creation options supplying the fallback before its first request and retaining the configured output-token limit. Reasoning-effort identifiers remain adapter-owned. An unchanged route inherits an omitted effort only from the selected baseline; changing provider or model without naming an effort clears the lower layer's route-owned value so the selected model resolves its own default. `AgentOptions` carries the resulting effort into the child loop, whose request header logs the effective value. A continuable descriptor records it with the resolved provider and model so a child that has not logged its first request can cold-resume with the same selection.
|
||||
|
||||
An explicit or configured provider, model, or effort resolves through `ctx.llm.resolveCallConfig()` after the provider baseline and request precedence are complete. Providers with static route defaults suppress parent-effort inheritance when the request omits effort, preserving the selected model's default. The LLM lookup owns provider registration, exact-model metadata, reasoning-effort validation, and adapter defaults. After the asynchronous lookup, the tool checks cancellation and confirms the same provider instance remains registered before creating a child or background job, so HMR cannot combine one provider's defaults with another provider's process. Calls with no model-facing selection and no configured route fields preserve the existing provider path without requiring the optional LLM service.
|
||||
|
||||
An enabled definition registers `list_subagent_models`. With no arguments the tool lists registered providers; with `provider` it calls that adapter's advisory model catalog; with `provider` and `model` it resolves the exact model and returns its reasoning efforts and default. At most one instance in a tool scope enables selection because the discovery name is global. Shipped product compositions put `modelSelectionSettings: true` on the primary Agent-scoped `subagent` instance and register the Host-owned `subagent-model-selection` settings namespace with `enabled: false`. A new top-level Session samples that preference during composition and logs an enabled decision as `subagent/model-selection-enabled` before any model request. A child Session inherits the live parent's decision, and a resumed Session uses its existing marker instead of the current preference. Therefore a settings edit affects only subsequently composed top-level Sessions. The fixed discovery definition remains available without the optional LLM service, while discovery and selected-route calls fail until that service is present. An unlisted model remains selectable when the adapter accepts its id.
|
||||
An enabled definition registers `list_subagent_models`. With no arguments the tool lists authorized registered providers; with `provider` it calls that adapter's advisory model catalog only after authorization; with `provider` and `model` it authorizes the exact route before resolving its reasoning efforts and default. At most one instance in a tool scope enables selection because the discovery name is global. Shipped product compositions put `modelSelectionSettings: true` on the primary Agent-scoped `subagent` instance and register the default-off Host-owned `subagent-model-selection` setting with an explicit `enabled` switch and `allowedModels` list. The Plugins settings page stores both fields atomically. A new top-level Session snapshots the route policy as `subagent/model-selection-policy` when the setting is enabled, before any model request. A child Session inherits the live parent's policy, and a resumed Session uses its recorded event instead of current settings. Therefore a settings edit affects only subsequently composed top-level Sessions. The fixed discovery definition remains available without the optional LLM service, while discovery and selected-route calls fail until that service is present. Discovery lists the intersection of the live catalog and recorded policy, and the executor rejects explicit routes outside it.
|
||||
|
||||
Shipped `subagent_fork` instances leave `enableModelSelection` disabled even though the in-process fork provider supports `agentOptions`. A fork inherits the parent's effective provider and model so its copied conversation prefix remains eligible for provider-side KV Cache reuse. Changing either route component requires the new route to prefill that inherited history again, and that recomputation can dominate the delegated task's cost. This restriction is independent of the discovery tool's global name: separating discovery ownership would permit the configuration but would not preserve reuse. Fork route selection remains unavailable until a route change can retain prefix reuse or the caller can explicitly bound and accept the recomputation cost.
|
||||
Shipped `subagent_fork` instances do not read model-selection settings even though the in-process fork provider supports `agentOptions`. A fork inherits the parent's effective provider and model so its copied conversation prefix remains eligible for provider-side KV Cache reuse. Changing either route component requires the new route to prefill that inherited history again, and that recomputation can dominate the delegated task's cost. This restriction is independent of the discovery tool's global name: separating discovery ownership would permit the configuration but would not preserve reuse. Fork route selection remains unavailable until a route change can retain prefix reuse or the caller can explicitly bound and accept the recomputation cost.
|
||||
|
||||
The delegation definition is static across adapter registration and catalog changes, so live topology neither expands every parent request nor invalidates its cache prefix. The discovery result enters the transcript only when called. A custom inheritance-capable instance that enables selection warns that changing provider or model can prevent provider-side reuse of the inherited conversation prefix.
|
||||
|
||||
@@ -28,7 +28,7 @@ The delegation definition is static across adapter registration and catalog chan
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep a deployment-configured route allowlist.** Rejected because it duplicates the live LLM registry, requires configuration before the model can use an already registered route, and creates a second policy surface for clients to edit. Deployments that must restrict LLM access should control which provider routes they register.
|
||||
**Require a deployment-configured route allowlist for static enablement.** Rejected because it duplicates the live LLM registry and requires configuration before a custom composition can use an already registered route. The shipped user-owned preference is a distinct authorization decision and is documented by [user-authorized subagent model routes](2026-08-24-user-authorized-subagent-model-routes.md).
|
||||
|
||||
**Render the live adapter catalog in every delegation description.** Rejected because one provider can advertise hundreds of models, inflating every request, and catalog changes would rewrite an early cache-prefix definition. The on-demand directory keeps mutable data out of the fixed schema.
|
||||
|
||||
@@ -48,8 +48,8 @@ The delegation definition is static across adapter registration and catalog chan
|
||||
|
||||
## Consequences
|
||||
|
||||
- An enabled delegation tool can select any live child LLM route without deployment selector configuration; disabled instances omit and reject model-facing route fields.
|
||||
- The primary delegation-tool instance defaults selection off, exposes a Models-page opt-in for new Sessions, and registers `list_subagent_models` only in Sessions whose durable decision is enabled; its catalog rows do not restrict delegation.
|
||||
- A settings-enabled Session can select only its recorded exact child LLM routes; disabled Sessions omit and reject model-facing route fields.
|
||||
- The primary delegation-tool instance defaults selection off, exposes a Plugins-page exact-route opt-in for new Sessions, and registers `list_subagent_models` only in Sessions whose durable policy exists; discovery and explicit selection are constrained to that policy.
|
||||
- Shipped fork tools inherit the parent's provider and model and omit model-facing route fields so the inherited conversation prefix remains eligible for KV Cache reuse.
|
||||
- Omission retains configured defaults plus static provider route defaults or compatible parent inheritance; a route change without an explicit effort uses the selected model's default.
|
||||
- Adapter catalog and topology changes leave the delegation definition and its prompt-cache prefix unchanged.
|
||||
|
||||
@@ -12,15 +12,15 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
只有实例启用 `enableModelSelection`,或其 Agent 作用域的 `modelSelectionSettings` 实例解析出已启用的 Session 决定,且绑定的 subagent 提供方声明 `SubagentCapabilities.agentOptions` 时,`dsh-tool-subagent` 才公开可选的 `provider`、`model` 与 `reasoning_effort` 字段,不要求配置路由允许列表。已注册的 LLM 提供方路由都可供子级选择;本工具不会在部署的 LLM 注册表之上增加第二套授权策略。禁用的实例会省略并拒绝面向模型的选择,而配置的 `Config.agentOptions` 仍是部署方所有的默认值。如果提供方缺少该能力,任一种选择模式都会使插件挂载失败。
|
||||
只有 Agent 作用域的 `modelSelectionSettings` 实例解析出 Session 策略,且绑定的 subagent 提供方声明 `SubagentCapabilities.agentOptions` 时,`dsh-tool-subagent` 才公开可选的 `provider`、`model` 与 `reasoning_effort` 字段。该策略使用[用户授权的 subagent 模型路由](2026-08-24-user-authorized-subagent-model-routes.zh.md)所拥有的精确用户授权;不存在无限制静态模式。禁用的实例会省略并拒绝面向模型的选择,而配置的 `Config.agentOptions` 仍是部署方所有的默认值。如果 settings 已启用的实例缺少该提供方能力,插件挂载会失败。
|
||||
|
||||
提供方与模型共同组成一条路由,必须一起提供。如果配置值、父级值或提供方持有的路由默认值能够提供生效路由,则可以只提供推理强度。静态的 `provider.agentRouteDefaults` 在存在时构成 provider/model 基线;`Config.agentOptions` 与模型参数会在路由相关强度清除之前覆盖它。没有静态默认值的提供方会使用父 Agent 最新记录请求中的兼容字段,首个请求之前由创建选项提供回退,并保留其中配置的输出 token 上限。推理强度 ID 仍由 adapter 所有。只有所选基线的路由不变时才会继承省略的强度;更换提供方或模型但没有指定强度时,会清除下层路由自有的值,使所选模型解析自己的默认值。`AgentOptions` 把结果强度传入子级循环,其请求 header 会记录生效值。可继续描述符会把它与解析后的提供方和模型一同记录,使尚未写入首个请求的子级能以相同选择冷恢复。
|
||||
|
||||
显式或配置的提供方、模型或强度会在提供方基线与请求优先级完成后,通过 `ctx.llm.resolveCallConfig()` 解析。具有静态路由默认值的提供方会在请求省略强度时禁止继承父级强度,从而保留所选模型的默认值。LLM 查询负责提供方注册、精确模型元数据、推理强度校验和 adapter 默认值。异步查询完成后、创建子级或后台 job 之前,工具会再次检查取消状态,并确认同一个提供方实例仍处于注册状态,因此 HMR 不会把一个提供方的默认值与另一个提供方的进程组合。既没有面向模型的选择、也没有配置路由字段的调用会保留原有提供方路径,不要求可选 LLM 服务存在。
|
||||
|
||||
启用的定义会注册 `list_subagent_models`。无参数调用列出已注册提供方;提供 `provider` 时调用该适配器的建议性模型目录;同时提供 `provider` 与 `model` 时解析精确模型,并返回其推理强度和默认值。因为发现工具使用全局名称,一个工具作用域最多由一个实例启用选择。随附产品组合在 Agent 作用域的主 `subagent` 实例上设置 `modelSelectionSettings: true`,并注册默认 `enabled: false` 的 Host 自有 `subagent-model-selection` settings namespace。新的顶层 Session 会在组合期间读取该偏好,并在任何模型请求之前把启用决定记录为 `subagent/model-selection-enabled`。子 Session 继承在线父级的决定;恢复的 Session 使用已有标记,而不是当前偏好。因此,设置修改只影响之后组合的顶层 Session。即使缺少可选 LLM 服务,固定发现定义仍保持可用;发现调用和所选路由调用会在该服务出现前失败。只要适配器接受某个未列出的模型 ID,仍可选择该模型。
|
||||
启用的定义会注册 `list_subagent_models`。无参数调用列出已授权且已注册的提供方;提供 `provider` 时先授权,再调用该适配器的建议性模型目录;同时提供 `provider` 与 `model` 时会先授权精确路由,再解析其推理强度和默认值。因为发现工具使用全局名称,一个工具作用域最多由一个实例启用选择。随附产品组合在 Agent 作用域的主 `subagent` 实例上设置 `modelSelectionSettings: true`,并注册 Host 自有的 `subagent-model-selection` 设置,其中包含默认关闭的显式 `enabled` 开关与 `allowedModels` 列表。Plugins 设置页会原子保存两个字段。设置启用时,新的顶层 Session 会在任何模型请求之前,把路由策略快照记录为 `subagent/model-selection-policy`。子 Session 继承在线父级的策略;恢复的 Session 使用已记录事件,而不是当前设置。因此,设置修改只影响之后组合的顶层 Session。即使缺少可选 LLM 服务,固定发现定义仍保持可用;发现调用和所选路由调用会在该服务出现前失败。发现会列出实时目录与已记录策略的交集,执行器会拒绝策略之外的显式路由。
|
||||
|
||||
随附的 `subagent_fork` 实例不会启用 `enableModelSelection`,即使进程内 fork 提供方支持 `agentOptions` 也是如此。fork 会继承父级生效的提供方与模型,使复制的对话前缀仍可供提供方侧 KV Cache 复用。更改任一路由组件都会要求新路由重新预填充继承的历史,而这项重算成本可能超过委派任务本身。该限制与发现工具的全局名称无关:分离发现工具的持有权可以让配置生效,却无法保留复用。只有在路由变化仍能保留前缀复用,或调用方可以显式限制并接受重算成本时,才重新考虑 fork 路由选择。
|
||||
随附的 `subagent_fork` 实例不会读取模型选择设置,即使进程内 fork 提供方支持 `agentOptions` 也是如此。fork 会继承父级生效的提供方与模型,使复制的对话前缀仍可供提供方侧 KV Cache 复用。更改任一路由组件都会要求新路由重新预填充继承的历史,而这项重算成本可能超过委派任务本身。该限制与发现工具的全局名称无关:分离发现工具的持有权可以让配置生效,却无法保留复用。只有在路由变化仍能保留前缀复用,或调用方可以显式限制并接受重算成本时,才重新考虑 fork 路由选择。
|
||||
|
||||
委派定义不会随 adapter 注册和目录变化而改变,因此实时拓扑既不会扩大每个父级请求,也不会使缓存前缀失效。只有调用发现工具时,目录结果才进入 transcript。自定义的上下文继承实例如果启用选择,其描述会警告,更改提供方或模型可能阻止提供方复用继承的对话前缀。
|
||||
|
||||
@@ -28,7 +28,7 @@ Status: implemented
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**保留部署配置的路由允许列表。** 不采用,因为它重复实时 LLM 注册表,要求先配置才能让模型使用已经注册的路由,并为客户端增加第二套策略编辑界面。需要限制 LLM 访问的部署应控制所注册的提供方路由。
|
||||
**要求静态启用配置部署路由允许列表。** 不采用,因为它会重复实时 LLM 注册表,并要求自定义组合先配置才能使用已经注册的路由。随附的用户自有偏好属于另一项授权决定,由[用户授权的 subagent 模型路由](2026-08-24-user-authorized-subagent-model-routes.zh.md)记录。
|
||||
|
||||
**在每一份委派描述中渲染实时 adapter 目录。** 不采用,因为一个提供方可能公布数百个模型,从而扩大每次请求,而且目录变化会改写缓存前缀中的早期定义。按需目录让可变数据留在固定 schema 之外。
|
||||
|
||||
@@ -48,8 +48,8 @@ Status: implemented
|
||||
|
||||
## 结果
|
||||
|
||||
- 启用的委派工具无需部署选择器配置,即可选择任意实时子级 LLM 路由;禁用的实例会省略并拒绝面向模型的路由字段。
|
||||
- 主委派工具实例默认关闭选择,为新 Session 提供 Models 页面 opt-in,并且只在持久决定已启用的 Session 中注册 `list_subagent_models`;其目录条目不会限制委派。
|
||||
- settings 已启用的 Session 只能选择其记录的精确子级 LLM 路由;禁用的 Session 会省略并拒绝面向模型的路由字段。
|
||||
- 主委派工具实例默认关闭选择,为新 Session 提供 Plugins 页面精确路由 opt-in,并且只在持久策略存在的 Session 中注册 `list_subagent_models`;发现与显式选择都受该策略限制。
|
||||
- 随附 fork 工具会继承父级的提供方与模型,并省略面向模型的路由字段,使继承的对话前缀仍可供 KV Cache 复用。
|
||||
- 省略选择时保留配置默认值,并使用静态提供方路由默认值或来自父级最新记录请求的兼容继承;改变路由但不显式指定强度时,使用所选模型的默认值。
|
||||
- adapter 目录和拓扑变化不会改变委派定义及其 prompt 缓存前缀。
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md
|
||||
2026-08-24-user-authorized-subagent-model-routes.md: 0f9816ae89562545c267d87713c13faecd9efc66
|
||||
2026-08-24-user-authorized-subagent-model-routes.zh.md: ca63215a5ec7ae56ae2f077a7fb19fc1e204527c
|
||||
@@ -0,0 +1,43 @@
|
||||
# Agent Note: User-authorized subagent model routes
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-24-user-authorized-subagent-model-routes.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Registering an LLM adapter makes its routes reachable, but does not authorize an Agent to choose every reachable model for a child. A single enabled preference over the live adapter registry expands silently when another provider or model appears. The product needs an explicit, stable authorization decision without rendering a potentially large model directory into every parent request.
|
||||
|
||||
## Decision
|
||||
|
||||
The Host-owned `subagent-model-selection` settings section stores an explicit `enabled` switch and `allowedModels`, an array of exact `{ provider, model }` routes. Enabling requires at least one route; disabling may retain the selected routes for later reuse. The Plugins settings card reads the live adapter directory through `llm.models`, lets the user stage the switch and routes, and saves both fields in one revision-fenced settings mutation. It stores no adapter-owned display names, descriptions, or reasoning-effort metadata. A stored or staged route absent from the current directory remains visible as unavailable and removable; a provider-local catalog failure does not block other providers or erase saved authorization or an unsaved selection. A connection reset discards the draft because namespace revisions are comparable only within one Host process.
|
||||
|
||||
A newly composed top-level Session snapshots the route list in `subagent/model-selection-policy` when the setting is enabled, before its model-selectable definitions can reach a request. Event presence means selection was enabled; the event does not store the global switch. Child Sessions inherit that exact list from their live parent, and resumed Sessions use the recorded event instead of current settings. Settings changes therefore affect only subsequently composed top-level Sessions, while a non-empty legacy Session without the event remains disabled.
|
||||
|
||||
The fixed `list_subagent_models` schema does not enumerate the policy. At call time, provider and model listings are the intersection of the Session route list and the adapter's live advertised directory. An exact provider/model lookup first requires authorization, then resolves the adapter-owned model metadata and all advertised reasoning efforts. The delegation executor independently rejects any explicit provider, model, or effort selection whose effective provider/model route is outside the Session list before `resolveCallConfig()` validates adapter availability and effort support. A call that supplies no selection field retains configured or inherited routing because the model made no route choice.
|
||||
|
||||
Model selection has no unrestricted static mode. The default-off Host setting is the only authority, and an enabled Session always carries an exact allowlist. The primary spawn tool reads that setting; the shipped fork tool still exposes no route selection so inherited conversation prefixes remain eligible for provider-side KV Cache reuse.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Render the allowed routes in the delegation description.** Rejected because a large or changing list would enlarge every request and invalidate an early prompt prefix. On-demand discovery keeps the fixed schema prefix-stable and logs directory content only when requested.
|
||||
|
||||
**Filter only the settings UI or discovery result.** Rejected because a model can guess a route or retain one from an earlier transcript. Authorization is enforced in the executor that starts the child.
|
||||
|
||||
**Infer enablement from a non-empty `allowedModels` array.** Rejected because disabling would have to discard a useful selection or preserve a non-empty array whose meaning depends on write history. The explicit switch is authoritative, and the settings scope submits both fields in one Host-validated mutation so no intermediate state is persisted.
|
||||
|
||||
**Store per-route reasoning-effort allowlists.** Rejected because the user decision concerns child models, while effort ids and compatibility belong to the exact adapter route. Every adapter-supported effort remains available after the route is authorized.
|
||||
|
||||
**Read current settings on every discovery or delegation call.** Rejected because a settings edit would silently change a running Session's model-visible capabilities and execution authority. The durable Session snapshot keeps resume and child inheritance deterministic.
|
||||
|
||||
## Consequences
|
||||
|
||||
- New adapter registrations and newly advertised models do not expand user authorization.
|
||||
- Adapter removals or catalog failures can reduce what discovery currently lists without deleting the saved route decision; an exact authorized route remains usable when its adapter accepts it even if the advisory catalog omits it.
|
||||
- The allowlist itself consumes no parent-request tokens. Only a `list_subagent_models` result enters the transcript.
|
||||
- The policy event is log-only and is appended while an Agent is composed, before either SDK begins its run subscription. Shipped SDK profiles do not enable this Web-owned preference, so the event changes neither SDK's expected notifications or persisted-session output; package restore tests own its durable projection instead of fabricating an SDK composition solely to emit it.
|
||||
- Unit coverage pins settings validation, malformed durable values, Session sampling and inheritance, discovery intersection, executor denial, live UI catalog invalidation, staged-route retention, connection-generation invalidation, staged whole-array writes, stale-revision rejection, and retry after scoped installation failure. The assembled Web scenario pins the real settings document and Plugins card flow.
|
||||
|
||||
## Related decisions
|
||||
|
||||
The route arguments, adapter preflight, discovery tool, and fork cache restriction remain owned by [model-selected subagent routes](2026-08-18-model-selected-subagent-routes.md).
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Agent Note: 用户授权的 subagent 模型路由
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-24-user-authorized-subagent-model-routes.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
注册 LLM 适配器会使其路由可达,但不代表授权 Agent 为子级选择每一个可达模型。针对实时适配器注册表的单一启用偏好,会在另一个提供方或模型出现时静默扩大范围。产品需要一项显式且稳定的授权决定,同时避免把可能很大的模型目录渲染进父 Agent 的每次请求。
|
||||
|
||||
## Decision
|
||||
|
||||
Host 自有的 `subagent-model-selection` 设置 section 保存显式 `enabled` 开关与 `allowedModels`,后者是由精确 `{ provider, model }` 路由组成的数组。启用时必须至少有一条路由;关闭时可以保留已选路由,供以后重新启用。Plugins 设置卡通过 `llm.models` 读取实时适配器目录,让用户暂存开关与路由,再在一次带 revision 限制的设置 mutation 中保存两个字段。它不保存适配器自有的显示名称、描述或推理强度元数据。当前目录中缺失的已存或暂存路由仍显示为不可用并允许移除;某个提供方的目录失败不会阻塞其他提供方,也不会清除已存授权或未保存选择。连接重置会丢弃草稿,因为 namespace revision 只能在同一个 Host 进程内比较。
|
||||
|
||||
设置启用时,新组合的顶层 Session 会在模型可选定义进入请求之前,把路由列表快照记录为 `subagent/model-selection-policy`。事件存在就表示模型选择已启用;事件不保存全局开关。子 Session 从在线父级继承同一份精确列表,恢复的 Session 使用已记录事件而不是当前设置。因此,设置修改只影响之后组合的顶层 Session,而已有非空日志但没有该事件的 Session 仍保持禁用。
|
||||
|
||||
固定的 `list_subagent_models` schema 不会枚举该策略。调用时,提供方和模型列表是 Session 路由列表与适配器实时公布目录的交集。精确 provider/model 查询先要求授权,再解析适配器自有的模型元数据和全部已公布推理强度。委派执行器还会独立拒绝任何生效 provider/model 路由不在 Session 列表内的显式提供方、模型或强度选择,然后才由 `resolveCallConfig()` 校验适配器可用性与强度支持。完全没有选择字段的调用保留配置或继承路由,因为模型没有作出路由选择。
|
||||
|
||||
模型选择不再有无限制的静态模式。默认关闭的 Host 设置是唯一授权来源,启用的 Session 始终携带精确允许列表。主 spawn 工具读取该设置;随附 fork 工具仍不公开路由选择,使继承的对话前缀继续符合提供方侧 KV Cache 复用条件。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**在委派描述中渲染允许路由。** 不采用,因为很大或变化的列表会扩大每次请求,并使较早的提示词前缀失效。按需发现会保持固定 schema 的前缀稳定,且只在请求目录时记录其内容。
|
||||
|
||||
**只过滤设置 UI 或发现结果。** 不采用,因为模型可以猜测路由,或从较早的 transcript 中保留路由。授权由启动子级的执行器强制执行。
|
||||
|
||||
**从非空 `allowedModels` 数组推断是否启用。** 不采用,因为关闭功能时要么必须丢弃仍有用的选择,要么要保留一个含义取决于写入历史的非空数组。显式开关是权威依据,设置 scope 会在一次由 Host 校验的 mutation 中提交两个字段,因此不会持久化中间状态。
|
||||
|
||||
**保存每条路由的推理强度允许列表。** 不采用,因为用户决定针对子级模型,而强度 id 与兼容性属于精确适配器路由。路由获准后,仍可使用适配器支持的每种强度。
|
||||
|
||||
**每次发现或委派调用都读取当前设置。** 不采用,因为设置编辑会静默改变运行中 Session 的模型可见能力和执行权限。持久 Session 快照会让恢复与子级继承保持确定。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 新适配器注册和新公布模型不会扩大用户授权。
|
||||
- 适配器移除或目录失败可以减少发现当前列出的内容,但不会删除已存路由决定;即使建议性目录省略某条精确已授权路由,只要适配器接受它,该路由仍然可用。
|
||||
- 允许列表本身不消耗父级请求 token。只有 `list_subagent_models` 结果进入 transcript。
|
||||
- 策略事件仅存在于日志,并在 Agent 组合期间、两套 SDK 开始订阅运行前追加。随附 SDK profile 不启用这项 Web 自有偏好,因此该事件不会改变任一 SDK 的预期通知或持久 Session 输出;其持久投影由包级恢复测试负责,不会为了发出该事件而虚构 SDK 组合。
|
||||
- 单元覆盖固定设置校验、异常持久值、Session 取样与继承、发现交集、执行器拒绝、UI 实时目录失效、暂存路由保留、连接换代失效、暂存后的整数组写入、陈旧 revision 拒绝,以及作用域安装失败后的重试。组装 Web 场景固定真实设置文档与 Plugins 设置卡流程。
|
||||
|
||||
## Related decisions
|
||||
|
||||
路由参数、适配器预检、发现工具与 fork 缓存限制仍由[模型选择的 subagent 路由](2026-08-18-model-selected-subagent-routes.zh.md)负责。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md
|
||||
2026-08-25-feedback-gated-telemetry-default.md: 772d134da53386292083790148dce738a98f2c0f
|
||||
2026-08-25-feedback-gated-telemetry-default.zh.md: ea05d4d687bc2270f907983a71f592b880d7449c
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Feedback-gated session-telemetry default
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-25-feedback-gated-telemetry-default.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Diagnosing a `/feedback` report needs the session data the report describes. With the shared base resolving an unset `DSH_TELEMETRY_MODE` to `DISABLED`, a default installation's feedback reached its receiver with no session data at all, and the reporter had no way to grant access at the moment they asked for help; only deployments that had exported `DSH_TELEMETRY_MODE` beforehand ever delivered a diagnosable report.
|
||||
|
||||
## Decision
|
||||
|
||||
The shared dsh base resolves an unset or empty `DSH_TELEMETRY_MODE` to `FEEDBACK_ONLY` instead of `DISABLED`. Nothing is uploaded before the user records `/feedback`; each recorded feedback uploads the not-yet-shared session-log records — from the last handoff through that exact event — to the configured OTLP endpoint, a resumed session shares only its current lifecycle, and the acknowledgement's sharing disclosure states that recording feedback uploads the records not yet shared. `FULL` and `DISABLED` remain explicit `DSH_TELEMETRY_MODE` overrides, any non-empty `DSH_TELEMETRY_DISABLED` remains the authoritative pre-load hard opt-out, and the plugin's own omitted-`mode` default stays `DISABLED`: the default changes only in the shared base's config expression, where deployments already override it.
|
||||
|
||||
This supersedes the session-backend default of the [default-off decision](2026-08-10-telemetry-default-off.md), accepting the user's explicit feedback action as the release authorization that note required a deployment setting for. That note's hard opt-out and its launcher-feed history remain current, and the [default-mount decision](2026-07-31-web-telemetry-default-mount.md) continues to own the endpoint, batching cadence, and exit-drain settings.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep `DISABLED` and instruct reporters to re-run with `DSH_TELEMETRY_MODE=FEEDBACK_ONLY`.** Rejected: the session that exhibited the problem is the one worth uploading, and re-running loses it.
|
||||
|
||||
**Default to `FULL`.** Rejected: continuous export without any user action is exactly what the default-off decision forbids, and nothing in a fresh installation authorizes it.
|
||||
|
||||
**Gate the official DeepSeek `dsh_session_log` request contribution on feedback instead of reviving the OTel default.** Not taken here: that contribution uploads through subsequent LLM requests rather than at the feedback boundary, so a session's final feedback would never be delivered; a feedback-triggered flush on that path is a larger design than a default flip.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A fresh installation uploads the not-yet-shared session-log records to the production collector when — and only when — the user records `/feedback`; no other trigger uploads.
|
||||
- Released exports remain the raw captured copy: the shipped base mounts no `session-telemetry/record` redaction rule, so they can contain message text, tool arguments and results, and workspace paths.
|
||||
- The sharing disclosure is part of the `/feedback` acknowledgement, so the user reads it after the release has been triggered. A deployment that requires prior informed consent must override the default to `DISABLED` or add a pre-upload confirmation before this default is defensible there.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: 反馈门控的会话遥测默认值
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-25-feedback-gated-telemetry-default.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
诊断一条 `/feedback` 报告需要报告所描述的会话数据。共享基础配置把未设置的 `DSH_TELEMETRY_MODE` 解析为 `DISABLED`,因此默认安装发出的反馈到达接收方时不带任何会话数据,报告者在求助的那一刻也没有授权共享的途径;只有事先导出了 `DSH_TELEMETRY_MODE` 的部署才能交付可诊断的报告。
|
||||
|
||||
## 决定
|
||||
|
||||
共享 dsh 基础配置把未设置或为空的 `DSH_TELEMETRY_MODE` 解析为 `FEEDBACK_ONLY` 而不是 `DISABLED`。用户记录 `/feedback` 之前不上传任何数据;每条已记录的反馈把尚未共享的会话日志记录——自上次交接至该事件为止——上传到已配置的 OTLP 端点,恢复的会话只共享当前生命周期,确认信息中的共享声明会说明记录反馈将上传尚未共享的记录。`FULL` 和 `DISABLED` 仍是显式的 `DSH_TELEMETRY_MODE` 覆盖值,任何非空的 `DSH_TELEMETRY_DISABLED` 仍是加载前的强制关闭开关,插件自身省略 `mode` 的默认值仍是 `DISABLED`:默认值只在共享基础配置的配置表达式中改变,部署本来就在那里覆盖它。
|
||||
|
||||
本决定取代[默认关闭决定](2026-08-10-telemetry-default-off.zh.md)中会话后端的默认值,把用户显式的反馈动作接受为该决定原本要求由部署设置提供的释放授权。该决定的强制关闭开关和 launcher 上报历史仍然有效,端点、批处理节奏和退出排空设置仍由[默认挂载决定](2026-07-31-web-telemetry-default-mount.zh.md)持有。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**保持 `DISABLED`,让报告者带着 `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 重跑。** 否决:值得上传的正是出现问题的那个会话,重跑会丢掉它。
|
||||
|
||||
**默认 `FULL`。** 否决:没有任何用户动作的持续导出正是默认关闭决定所禁止的,全新安装中没有任何东西授权它。
|
||||
|
||||
**改为在反馈时门控官方 DeepSeek `dsh_session_log` 请求贡献,而不是恢复 OTel 默认值。** 此处未采用:该贡献通过后续 LLM 请求上传,而不是在反馈边界上传,会话的最后一条反馈永远不会被交付;在那条路径上做反馈触发的冲刷是比翻转默认值更大的设计。
|
||||
|
||||
## 后果
|
||||
|
||||
- 全新安装只在用户记录 `/feedback` 时把尚未共享的会话日志记录上传到生产 collector;没有其他触发上传的途径。
|
||||
- 释放的导出仍是未加工的原始副本:随附基础配置没有挂载 `session-telemetry/record` 脱敏规则,导出可能包含消息文本、工具参数和结果,以及 workspace 路径。
|
||||
- 共享声明是 `/feedback` 确认信息的一部分,用户读到它时释放已被触发。要求事先知情同意的部署必须把默认值覆盖为 `DISABLED`,或在上传前增加确认步骤,此默认值在那类部署中才站得住。
|
||||
@@ -160,7 +160,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm-js
|
||||
dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
|
||||
|
||||
- name: Enable Windows Developer Mode (symlink support)
|
||||
if: runner.os == 'Windows'
|
||||
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm
|
||||
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm
|
||||
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -176,7 +176,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm
|
||||
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -264,7 +264,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm
|
||||
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
# The benchmark's Windows lanes deliberately skip the store cache like
|
||||
# the independent native Windows job; an empty input disables caching.
|
||||
@@ -355,7 +355,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm
|
||||
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
# Unlike the larger-runner suite, both platforms cache the store here:
|
||||
# the consolidated topology measures cache mechanics as workload.
|
||||
|
||||
+11
-30
@@ -55,7 +55,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm
|
||||
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm
|
||||
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -173,7 +173,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm
|
||||
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -261,7 +261,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm
|
||||
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -329,7 +329,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm
|
||||
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -421,16 +421,9 @@ jobs:
|
||||
run: >-
|
||||
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"
|
||||
/t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1"
|
||||
# Best-effort: a torn-down job on this self-hosted pool can leave a
|
||||
# locked @reflink native module under the action's install destination,
|
||||
# and pnpm/action-setup's self-installer then fails its unlink with
|
||||
# EPERM. Clearing the destination gives every attempt fresh state.
|
||||
- name: Clear stale pnpm setup state
|
||||
shell: pwsh
|
||||
run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue }
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm-js
|
||||
dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.PRIMARY_NODE_VERSION }}
|
||||
@@ -453,7 +446,7 @@ jobs:
|
||||
env:
|
||||
DSH_COVERAGE_MAX_WORKERS: '6'
|
||||
DSH_COVERAGE_PARTITIONS: '4'
|
||||
DSH_COVERAGE_TEST_TIMEOUT_MS: '30000'
|
||||
DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'
|
||||
DSH_GATE_CONCURRENCY: '3'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -464,13 +457,9 @@ jobs:
|
||||
run: >-
|
||||
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"
|
||||
/t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1"
|
||||
# Best-effort stale pnpm-destination cleanup; rationale on windows-build's copy.
|
||||
- name: Clear stale pnpm setup state
|
||||
shell: pwsh
|
||||
run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue }
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm-js
|
||||
dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.PRIMARY_NODE_VERSION }}
|
||||
@@ -502,13 +491,9 @@ jobs:
|
||||
run: >-
|
||||
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"
|
||||
/t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1"
|
||||
# Best-effort stale pnpm-destination cleanup; rationale on windows-build's copy.
|
||||
- name: Clear stale pnpm setup state
|
||||
shell: pwsh
|
||||
run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue }
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm-js
|
||||
dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.PRIMARY_NODE_VERSION }}
|
||||
@@ -520,7 +505,7 @@ jobs:
|
||||
run: >-
|
||||
pnpm exec vitest run
|
||||
--no-file-parallelism
|
||||
--testTimeout 30000
|
||||
--testTimeout 90000
|
||||
packages/shell/tool-pwsh/tests/loader.spec.ts
|
||||
packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts
|
||||
packages/workflow/tool-ralph/tests/integration.spec.ts
|
||||
@@ -548,13 +533,9 @@ jobs:
|
||||
run: >-
|
||||
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"
|
||||
/t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1"
|
||||
# Best-effort stale pnpm-destination cleanup; rationale on windows-build's copy.
|
||||
- name: Clear stale pnpm setup state
|
||||
shell: pwsh
|
||||
run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue }
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm-js
|
||||
dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.PRIMARY_NODE_VERSION }}
|
||||
|
||||
@@ -165,7 +165,7 @@ External packages **directly declared** only by repository tooling, test infrast
|
||||
| [`cytoscape`](https://github.com/cytoscape/cytoscape.js) | MIT |
|
||||
| [`cytoscape-cose-bilkent`](https://github.com/cytoscape/cytoscape.js-cose-bilkent) | MIT |
|
||||
| [`dayjs`](https://github.com/iamkun/dayjs) | MIT |
|
||||
| [`debug`](https://github.com/visionmedia/debug) | MIT |
|
||||
| [`debug`](https://github.com/debug-js/debug) | MIT |
|
||||
| [`esbuild`](https://github.com/evanw/esbuild) | MIT |
|
||||
| [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only |
|
||||
| [`execa`](https://github.com/sindresorhus/execa) | MIT |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/cli/reference/README.md
|
||||
README.md: 0391cb18d89263d6b53319d8b125731178ac5132
|
||||
README.zh.md: 13a4873bbd19401991d2a62f50602e133330aa35
|
||||
README.md: dcb5f7fd36ba2f5db3e4ce65abc23f266872c4d3
|
||||
README.zh.md: bc87059883f641d850e18516b86f4bb089d8158e
|
||||
|
||||
@@ -89,9 +89,9 @@ New sessions in base-backed profiles default to the `workspace-write` permission
|
||||
|
||||
## Shared deployment behavior
|
||||
|
||||
The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, the public-only HTTP fetch provider, and disabled session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`. The Web app's `cordis`, `code`, and `standard` agent presets expose `web_fetch` in every sandbox and approval mode without per-call confirmation; the provider still rejects non-public destinations before connecting.
|
||||
The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, the public-only HTTP fetch provider, and feedback-gated session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`. The Web app's `cordis`, `code`, and `standard` agent presets expose `web_fetch` in every sandbox and approval mode without per-call confirmation; the provider still rejects non-public destinations before connecting.
|
||||
|
||||
Session telemetry stays local by default. `DSH_TELEMETRY_MODE=FULL` streams every projected session event as OTLP/HTTP logs, while `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` uploads a session-log suffix only when feedback is recorded. `DSH_TELEMETRY_OTLP_URL` selects another collector, and any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative hard opt-out. The shipped base has no telemetry redaction rule, so explicitly enabled exports can contain message text, tool arguments and results, and workspace paths; the [default-off Agent Note](../../../.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md) owns that deployment decision.
|
||||
Session telemetry defaults to feedback-gated sharing: nothing is uploaded until the user records `/feedback`, and each recorded feedback uploads the session records not yet shared, through that event; a resumed session shares only its current lifecycle. `DSH_TELEMETRY_MODE=FULL` instead streams every projected session event as OTLP/HTTP logs, `DSH_TELEMETRY_MODE=DISABLED` keeps everything local, and any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative hard opt-out. `DSH_TELEMETRY_OTLP_URL` selects another collector. The shipped base has no telemetry redaction rule, so released exports can contain message text, tool arguments and results, and workspace paths; the [feedback-gated-default Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md) owns that deployment decision.
|
||||
|
||||
Install external plugin bundles through `dsh plugin --profile <name> add <package-or-git-spec>`. The installed package owns its dependencies and contributes its declared `cordis.patch.yml` layer. The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for patch layers, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox.
|
||||
|
||||
|
||||
@@ -89,9 +89,9 @@ dsh web --help
|
||||
|
||||
## 共享部署行为
|
||||
|
||||
基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`、仅限公网的 HTTP fetch 提供方,以及已禁用的会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`。Web app 的 `cordis`、`code` 与 `standard` agent preset 会在所有 sandbox 和审批模式下暴露 `web_fetch`,无需逐次确认;提供方仍会在连接前拒绝非公开目的地址。
|
||||
基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`、仅限公网的 HTTP fetch 提供方,以及按反馈门控的会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`。Web app 的 `cordis`、`code` 与 `standard` agent preset 会在所有 sandbox 和审批模式下暴露 `web_fetch`,无需逐次确认;提供方仍会在连接前拒绝非公开目的地址。
|
||||
|
||||
会话遥测默认留在本地。`DSH_TELEMETRY_MODE=FULL` 将每条已投影会话事件作为 OTLP/HTTP 日志流式发送,`DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 则仅在记录反馈时上传会话日志后缀。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。任何非空的 `DSH_TELEMETRY_DISABLED` 都是具有最终效力的遥测强制关闭开关。随附基础配置没有遥测脱敏规则,因此显式启用的导出可能包含消息文本、工具参数和结果,以及 workspace 路径;相关部署决策见[默认关闭 Agent Note](../../../.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md)。
|
||||
会话遥测默认按反馈门控共享:在用户记录 `/feedback` 之前不上传任何数据,每条已记录的反馈通过该事件上传尚未共享的会话记录;恢复的会话只共享当前生命周期。`DSH_TELEMETRY_MODE=FULL` 改为将每条已投影会话事件作为 OTLP/HTTP 日志流式发送,`DSH_TELEMETRY_MODE=DISABLED` 让全部数据留在本地,任何非空的 `DSH_TELEMETRY_DISABLED` 仍是具有最终效力的遥测强制关闭开关。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。随附基础配置没有遥测脱敏规则,因此释放的导出可能包含消息文本、工具参数和结果,以及 workspace 路径;相关部署决策见[反馈门控默认值 Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md)。
|
||||
|
||||
通过 `dsh plugin --profile <name> add <package-or-git-spec>` 安装外部插件组合包。安装的包拥有其依赖,并贡献其声明的 `cordis.patch.yml` 层。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为供 patch 层使用的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent(智能体)沙箱之外的受信任可执行代码。
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
/** Published-entry acceptance for argument errors, profile lifecycle, and boot-free config dumps. */
|
||||
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
// The dsh built bin cold-starts slowly on the contended self-hosted Windows pool; the
|
||||
// execa deadline, its error text, the outer vitest case budget, and waitForFile all
|
||||
// share this value so a widening cannot leave a stale 25s diagnostic behind.
|
||||
const SPAWN_TIMEOUT_MS = 60_000
|
||||
// The release version, including a prerelease such as 0.0.1-rc.1: `--version`
|
||||
// prints what this manifest carries, so no test may pin it to a literal.
|
||||
const cliVersion = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version
|
||||
@@ -36,7 +40,7 @@ async function runBuiltBin(
|
||||
)
|
||||
const result = await execa(process.execPath, [dshBin, ...args], {
|
||||
input: '',
|
||||
timeout: 25_000,
|
||||
timeout: SPAWN_TIMEOUT_MS,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
env: childEnv,
|
||||
@@ -44,13 +48,13 @@ async function runBuiltBin(
|
||||
...cwd === undefined ? {} : { cwd },
|
||||
})
|
||||
if (result.timedOut) {
|
||||
throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
throw new Error(`dsh built bin did not exit within ${SPAWN_TIMEOUT_MS / 1_000}s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
}
|
||||
return { stdout: result.stdout, code: result.exitCode ?? -1, stderr: result.stderr }
|
||||
}
|
||||
|
||||
async function waitForFile(file: string): Promise<void> {
|
||||
const deadline = Date.now() + 20_000
|
||||
const deadline = Date.now() + SPAWN_TIMEOUT_MS
|
||||
while (!existsSync(file)) {
|
||||
if (Date.now() >= deadline) throw new Error(`dsh profile lifecycle marker did not appear: ${file}`)
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
@@ -146,6 +150,8 @@ function startProfileLifecycle(fixture: ProfileLifecycleFixture, args: readonly
|
||||
return execa(process.execPath, [dshBin, '--profile', 'lifecycle', ...args], {
|
||||
cwd: fixture.home,
|
||||
input: '',
|
||||
timeout: SPAWN_TIMEOUT_MS,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
env: {
|
||||
DSH_HOME: fixture.home,
|
||||
@@ -310,7 +316,7 @@ function startStartupProfile(fixture: StartupFixture, args: readonly string[]) {
|
||||
cwd: fixture.home,
|
||||
input: '',
|
||||
reject: false,
|
||||
timeout: 25_000,
|
||||
timeout: SPAWN_TIMEOUT_MS,
|
||||
killSignal: 'SIGKILL',
|
||||
env: {
|
||||
DSH_HOME: fixture.home,
|
||||
@@ -335,7 +341,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
const result = await runBuiltBin(removed)
|
||||
expect(result.code).toBe(1)
|
||||
}
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS * 3 + 30_000)
|
||||
|
||||
it('routes help and usage errors without activating startup-dependent rows', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-app-help-'))
|
||||
@@ -392,7 +398,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}, process.platform === 'win32' ? 60_000 : 30_000)
|
||||
}, SPAWN_TIMEOUT_MS * 3 + 30_000)
|
||||
|
||||
it('reports SDK startup failure when stdin reaches EOF first', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-startup-failure-'))
|
||||
@@ -416,14 +422,14 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it('serves the SDK protocol through the sdk profile and exits after shutdown', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-'))
|
||||
const child = execa(process.execPath, [dshBin, '--profile', 'sdk'], {
|
||||
cwd: home,
|
||||
reject: false,
|
||||
timeout: 25_000,
|
||||
timeout: SPAWN_TIMEOUT_MS,
|
||||
killSignal: 'SIGKILL',
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -471,7 +477,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
await child
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it('runs a mock-backed ACP turn through the acp profile and exits on disconnect', async () => {
|
||||
const apiKey = 'built-acp-profile-key'
|
||||
@@ -484,7 +490,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
const child = execa(process.execPath, [dshBin, '--profile', 'acp'], {
|
||||
cwd: home,
|
||||
reject: false,
|
||||
timeout: 25_000,
|
||||
timeout: SPAWN_TIMEOUT_MS,
|
||||
killSignal: 'SIGKILL',
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -555,7 +561,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
await server.close()
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it('runs the headless profile through its app-owned task positional', async () => {
|
||||
const apiKey = 'built-dsh-headless-key'
|
||||
@@ -583,7 +589,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
await server.close()
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it('does not load a project environment for --version', async () => {
|
||||
const project = mkdtempSync(join(tmpdir(), 'dsh-version-project-'))
|
||||
@@ -606,7 +612,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it('uses the launching endpoint and managed credential through the published entry', async () => {
|
||||
const apiKey = 'built-home-layer-key'
|
||||
@@ -646,7 +652,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
rmSync(project, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it('reports a patch-overlay boot failure without hanging', async () => {
|
||||
// The HMR main watcher's initial scan once refreshed the include
|
||||
@@ -666,7 +672,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it('lets a profile without a parser ignore app arguments and dispose on a startup-time signal', async () => {
|
||||
const fixture = createProfileLifecycleFixture()
|
||||
@@ -682,7 +688,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
child.kill('SIGKILL')
|
||||
rmSync(fixture.home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it('fully settles a custom profile, hot-reloads its patch layer with removal reverting, and disposes on a signal', async () => {
|
||||
const fixture = createProfileLifecycleFixture()
|
||||
@@ -734,7 +740,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
child.kill('SIGKILL')
|
||||
rmSync(fixture.home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it('hands the app arguments to the profile, which applies them before its rows start', async () => {
|
||||
const fixture = createStartupFixture()
|
||||
@@ -750,7 +756,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
child.kill('SIGKILL')
|
||||
rmSync(fixture.home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it('starts a consumer on its composed value when the invocation carries no app arguments', async () => {
|
||||
const fixture = createStartupFixture()
|
||||
@@ -764,7 +770,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
child.kill('SIGKILL')
|
||||
rmSync(fixture.home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it('keeps the app arguments across a user patch reload', async () => {
|
||||
// A live edit recomposes every row while the provider service remains
|
||||
@@ -798,7 +804,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
child.kill('SIGKILL')
|
||||
rmSync(fixture.home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it("prints the app's own help, starts none of its rows, and exits", async () => {
|
||||
const fixture = createStartupFixture()
|
||||
@@ -811,7 +817,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
} finally {
|
||||
rmSync(fixture.home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it('anchors a relative add spec to the invoking directory, not the profile', async () => {
|
||||
// `dsh plugin --profile x add .` from a plugin checkout must install THAT
|
||||
@@ -829,7 +835,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
const result = await execa(process.execPath, [dshBin, 'plugin', '--profile', 'anchor', 'add', '.'], {
|
||||
cwd: checkout,
|
||||
input: '',
|
||||
timeout: 60_000,
|
||||
timeout: SPAWN_TIMEOUT_MS,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
env: { DSH_HOME: home },
|
||||
@@ -860,7 +866,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
rmSync(checkout, { recursive: true, force: true })
|
||||
}
|
||||
}, 90_000)
|
||||
}, SPAWN_TIMEOUT_MS * 2 + 30_000)
|
||||
|
||||
it('activates a dependency that gained dsh.bundle in a later update', async () => {
|
||||
// Reconcile runs against the INSTALLED state on every successful pnpm
|
||||
@@ -897,7 +903,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS * 2 + 30_000)
|
||||
|
||||
describe('config dump', () => {
|
||||
let home: string
|
||||
@@ -913,7 +919,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
expect(stdout).toContain('# == @deepseek-ai/dsh-base')
|
||||
expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'")
|
||||
expect(existsSync(join(home, 'profiles', 'node_modules'))).toBe(false)
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it('prints the headless profile without Host or browser layers', async () => {
|
||||
const { stdout, code, stderr } = await runBuiltBin(
|
||||
@@ -926,7 +932,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-host-/)
|
||||
expect(stdout).not.toContain("name: '@deepseek-ai/dsh-web-app'")
|
||||
expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-client-/)
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it('prints the exact standalone sdk-minimal tree without dsh-base', async () => {
|
||||
const { stdout, code, stderr } = await runBuiltBin(
|
||||
@@ -959,7 +965,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
expect(stdout).toContain('# == @deepseek-ai/dsh-sdk-minimal')
|
||||
expect(stdout).not.toContain('@deepseek-ai/dsh-base')
|
||||
expect(stdout).not.toContain('@deepseek-ai/dsh-web-app')
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS * 2 + 30_000)
|
||||
|
||||
it('composes the profile user layer and a --patch overlay in order', async () => {
|
||||
// Auto-init the web profile first, then write its user layer.
|
||||
@@ -998,6 +1004,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
// Both layers patched the row; the comment lists them in application order.
|
||||
expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`)
|
||||
expect(stderr).toContain('patch: entry "absent-row" not found')
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS * 2 + 30_000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -124,7 +124,6 @@
|
||||
config:
|
||||
provider: spawn
|
||||
toolName: subagent
|
||||
enableModelSelection: true
|
||||
backgroundMode: continuable
|
||||
maxDepth: 1
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ describe('Python SDK dsh profile keyless smoke', () => {
|
||||
const tools = modelRequests[0]?.tools as { function?: { name?: string } }[]
|
||||
expect(modelRequests[0]?.reasoning_effort).toBe('max')
|
||||
expect(modelRequests[0]?.max_tokens).toBe(1234)
|
||||
expect(tools.map(tool => tool.function?.name)).toContain('list_subagent_models')
|
||||
expect(tools.map(tool => tool.function?.name)).not.toContain('list_subagent_models')
|
||||
|
||||
child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`)
|
||||
const shutdown = await waitForLine(lines, value => value.id === 3, () => stderr)
|
||||
|
||||
@@ -241,13 +241,19 @@ describe('the shipped Web composition', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('applies the default-off subagent model-selection preference only to new sessions', async () => {
|
||||
await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false })
|
||||
it('applies the default-off subagent model allowlist only to new sessions', async () => {
|
||||
await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, {
|
||||
enabled: false,
|
||||
allowedModels: [],
|
||||
})
|
||||
const disabled = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-model-selection-disabled'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
||||
})
|
||||
await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true })
|
||||
await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, {
|
||||
enabled: true,
|
||||
allowedModels: [{ provider: 'deepseek-official', model: 'deepseek-v4-flash' }],
|
||||
})
|
||||
const enabled = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-model-selection-enabled'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
||||
|
||||
@@ -19,10 +19,6 @@
|
||||
- text: 关闭
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- region "Subagent 自选模型":
|
||||
- heading "Subagent 自选模型" [level=3]
|
||||
- paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。
|
||||
- switch "允许 subagent 自选模型"
|
||||
- status: 已保存 minimax-cn。
|
||||
- list:
|
||||
- listitem:
|
||||
|
||||
@@ -19,10 +19,6 @@
|
||||
- text: 关闭
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- region "Subagent 自选模型":
|
||||
- heading "Subagent 自选模型" [level=3]
|
||||
- paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。
|
||||
- switch "允许 subagent 自选模型"
|
||||
- list:
|
||||
- listitem:
|
||||
- text: minimax-cn
|
||||
|
||||
@@ -19,10 +19,6 @@
|
||||
- text: 关闭
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- region "Subagent 自选模型":
|
||||
- heading "Subagent 自选模型" [level=3]
|
||||
- paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。
|
||||
- switch "允许 subagent 自选模型"
|
||||
- list:
|
||||
- listitem:
|
||||
- text: minimax-cn
|
||||
|
||||
@@ -19,10 +19,6 @@
|
||||
- text: 关闭
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- region "Subagent 自选模型":
|
||||
- heading "Subagent 自选模型" [level=3]
|
||||
- paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。
|
||||
- switch "允许 subagent 自选模型"
|
||||
- list
|
||||
- text: 提供方
|
||||
- combobox "提供方":
|
||||
|
||||
@@ -19,10 +19,6 @@
|
||||
- text: 关闭
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- region "Subagent 自选模型":
|
||||
- heading "Subagent 自选模型" [level=3]
|
||||
- paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。
|
||||
- switch "允许 subagent 自选模型"
|
||||
- list:
|
||||
- listitem:
|
||||
- text: DeepSeek
|
||||
|
||||
@@ -19,10 +19,6 @@
|
||||
- text: 关闭
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- region "Subagent 自选模型":
|
||||
- heading "Subagent 自选模型" [level=3]
|
||||
- paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。
|
||||
- switch "允许 subagent 自选模型"
|
||||
- list:
|
||||
- listitem:
|
||||
- text: DeepSeek
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
- 'button "展开设置: Agent 循环"':
|
||||
- text: Agent 循环 Agent 如何派发工具调用。
|
||||
- img
|
||||
- listitem:
|
||||
- 'button "展开设置: Subagent"':
|
||||
- text: Subagent 控制 Agent 为 Subagent 选择模型的权限。
|
||||
- img
|
||||
- listitem:
|
||||
- 'button "展开设置: 网页搜索"':
|
||||
- text: 网页搜索 DeepSeek 搜索提供方。
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// Keyless assembled-browser coverage for the shipped FEEDBACK_ONLY default
|
||||
// over the Web bundles and the real host wire. The scaffold mounts the
|
||||
// shipped telemetry row in FEEDBACK_ONLY mode against this suite's own
|
||||
// loopback mock collector, so the default release path is real: /feedback
|
||||
// releases the session records through that event (exactly one OTLP request,
|
||||
// carrying the drive prompt and the feedback text), the acknowledgement pins
|
||||
// the feedback-gated disclosure sentence, and a second feedback releases only
|
||||
// the records since the first handoff — the earlier prompt does not repeat.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { createServer, type Server } from 'node:http'
|
||||
import { once } from 'node:events'
|
||||
import { gunzipSync } from 'node:zlib'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/feedback-release', import.meta.url))
|
||||
// The release path needs only a settled ordinary turn, so this lane replays
|
||||
// the feedback-command scenario's recorded session (declared as this
|
||||
// manifest's `session.source`) instead of recording a duplicate.
|
||||
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/feedback-command/session.jsonl', import.meta.url))
|
||||
const ACK_EXPECTED = join(SNAPSHOT_DIR, 'ack.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
|
||||
|
||||
describe('web e2e: feedback-gated release under the shipped default mode', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let collector: Server
|
||||
const uploads: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
collector = createServer((request, response) => {
|
||||
const chunks: Buffer[] = []
|
||||
request.on('data', chunk => chunks.push(chunk as Buffer))
|
||||
request.on('end', () => {
|
||||
const raw = Buffer.concat(chunks)
|
||||
uploads.push((request.headers['content-encoding'] === 'gzip' ? gunzipSync(raw) : raw).toString())
|
||||
response.writeHead(200, { 'content-type': 'application/json' }).end('{}')
|
||||
})
|
||||
})
|
||||
collector.listen(0, '127.0.0.1')
|
||||
await once(collector, 'listening')
|
||||
const address = collector.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('collector has no port')
|
||||
scaffold = await launchWebScaffold({
|
||||
telemetryUrl: `http://127.0.0.1:${address.port}/v1/logs`,
|
||||
telemetryMode: 'FEEDBACK_ONLY',
|
||||
// The replayed session.jsonl belongs to the feedback-command scenario;
|
||||
// comparing (or refreshing) the persisted session here would rewrite
|
||||
// that shared source with this lane's feedback events. The release
|
||||
// evidence lives in this lane's golden and collector assertions.
|
||||
compareReplaySession: false,
|
||||
...(MODE === 'record' ? {} : { replayFixture: FIXTURE }),
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
collector?.close()
|
||||
collector?.closeAllConnections()
|
||||
})
|
||||
|
||||
it('drives the recorded prompt to a settled turn (all modes)', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-release-drive'))
|
||||
if (MODE !== 'record') {
|
||||
// Drift guard: the shared fixture must carry exactly the drive prompt.
|
||||
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
|
||||
}
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
await input.waitFor({ timeout: 10_000 })
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
const sessionId = await settled
|
||||
if (MODE === 'record') {
|
||||
// Re-records the SHARED feedback-command session this lane replays.
|
||||
await recordFixture(scaffold, sessionId, FIXTURE)
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('releases the session records through the feedback and pins the disclosure', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-release'))
|
||||
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
|
||||
expect(uploads).toEqual([])
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
await input.fill('/feedback the diff view is unreadable')
|
||||
await input.press('Enter')
|
||||
|
||||
await page.getByText(/Feedback recorded for session/).waitFor({ timeout: 10_000 })
|
||||
expect(await page.getByText(/recording feedback uploads the session records not yet shared/).count()).toBe(1)
|
||||
|
||||
// FEEDBACK_ONLY releases through the committed feedback event: exactly
|
||||
// one request reaches the collector, carrying the whole unshared range.
|
||||
await expect.poll(() => uploads.length, { timeout: 15_000 }).toBe(1)
|
||||
expect(uploads[0]).toContain('the diff view is unreadable')
|
||||
expect(uploads[0]).toContain(PROMPT)
|
||||
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('releases only the records since the last handoff on a second feedback', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-release-suffix'))
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
await input.fill('/feedback the second remark')
|
||||
await input.press('Enter')
|
||||
await expect.poll(() => uploads.length, { timeout: 15_000 }).toBe(2)
|
||||
// Suffix semantics: the second release starts after the first feedback's
|
||||
// handoff, so the drive prompt already shared must not repeat.
|
||||
expect(uploads[1]).toContain('the second remark')
|
||||
expect(uploads[1]).not.toContain(PROMPT)
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ack.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -76,7 +76,9 @@ describe('web e2e: plugin configuration section', () => {
|
||||
const dialog = await openPlugins()
|
||||
|
||||
// Every card the shipped web composition exposes: the shell executor, the
|
||||
// agent loop, and the DeepSeek search provider.
|
||||
// agent loop, subagent selection, and the DeepSeek search provider.
|
||||
await dialog.getByText('Subagent', { exact: true }).waitFor({ timeout: 10_000 })
|
||||
expect(await dialog.getByRole('button', { name: '展开设置: Subagent' }).count()).toBe(1)
|
||||
await dialog.getByText('终端', { exact: true }).waitFor({ timeout: 10_000 })
|
||||
expect(await dialog.getByText('Agent 循环', { exact: true }).count()).toBe(1)
|
||||
expect(await dialog.getByText('网页搜索', { exact: true }).count()).toBe(1)
|
||||
@@ -88,6 +90,45 @@ describe('web e2e: plugin configuration section', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('persists selected adapter routes as the subagent model allowlist', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-subagent-model-selection'))
|
||||
const dialog = await openPlugins()
|
||||
await dialog.getByText('Subagent', { exact: true }).click()
|
||||
const toggle = dialog.getByRole('switch', { name: '允许 Agent 为 Subagent 选择模型' })
|
||||
|
||||
await toggle.click()
|
||||
const models = dialog.getByRole('group', { name: 'Agent 可选择的模型' })
|
||||
await models.waitFor({ timeout: 10_000 })
|
||||
const firstModel = models.getByRole('checkbox').first()
|
||||
await firstModel.check()
|
||||
await dialog.getByRole('button', { name: '保存', exact: true }).click()
|
||||
|
||||
const expandSubagent = dialog.getByRole('button', { name: '展开设置: Subagent' })
|
||||
await expandSubagent.waitFor({ timeout: 5_000 })
|
||||
await expect.poll(async () => (await settingsDocument()).includes('subagent-model-selection:'), { timeout: 10_000 })
|
||||
.toBe(true)
|
||||
expect(await settingsDocument()).toContain('enabled: true')
|
||||
expect(await settingsDocument()).toContain('allowedModels:')
|
||||
expect(await settingsDocument()).toContain('provider:')
|
||||
expect(await settingsDocument()).toContain('model:')
|
||||
await expandSubagent.click()
|
||||
await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('true')
|
||||
await expect.poll(() => dialog.getByRole('button', { name: '保存', exact: true }).isDisabled()).toBe(true)
|
||||
expect(await dialog.getByText('未保存', { exact: true }).count()).toBe(0)
|
||||
|
||||
await toggle.click()
|
||||
await dialog.getByRole('button', { name: '保存', exact: true }).click()
|
||||
await expandSubagent.waitFor({ timeout: 5_000 })
|
||||
await expect.poll(async () => (await settingsDocument()).includes('enabled: false'), { timeout: 10_000 })
|
||||
.toBe(true)
|
||||
expect(await settingsDocument()).toContain('allowedModels:')
|
||||
expect(await settingsDocument()).toContain('provider:')
|
||||
expect(await settingsDocument()).toContain('model:')
|
||||
await expandSubagent.click()
|
||||
await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('false')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('stages an edit and writes it only when saved', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-write'))
|
||||
const dialog = await openPlugins()
|
||||
@@ -109,6 +150,9 @@ describe('web e2e: plugin configuration section', () => {
|
||||
|
||||
await expect.poll(async () => (await settingsDocument()).includes('timeoutMs: 12000'), { timeout: 10_000 })
|
||||
.toBe(true)
|
||||
const expandTerminal = dialog.getByRole('button', { name: '展开设置: 终端' })
|
||||
await expandTerminal.waitFor({ timeout: 5_000 })
|
||||
await expandTerminal.click()
|
||||
// Presence in the user layer is what the badge reports, and the reset is
|
||||
// offered only for a field that has one.
|
||||
await expect.poll(() => dialog.getByText('已覆盖').count(), { timeout: 5_000 }).toBe(1)
|
||||
@@ -167,6 +211,9 @@ describe('web e2e: plugin configuration section', () => {
|
||||
|
||||
await expect.poll(async () => (await settingsDocument()).includes('timeoutMs'), { timeout: 10_000 })
|
||||
.toBe(false)
|
||||
const expandTerminal = dialog.getByRole('button', { name: '展开设置: 终端' })
|
||||
await expandTerminal.waitFor({ timeout: 5_000 })
|
||||
await expandTerminal.click()
|
||||
expect(await timeout.inputValue()).toBe('60000')
|
||||
expect(await dialog.getByText('已覆盖').count()).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
|
||||
@@ -329,12 +329,14 @@ export interface LaunchOptions {
|
||||
default: string
|
||||
}
|
||||
/**
|
||||
* Mount the shipped telemetry row in FULL mode against this exporter URL
|
||||
* instead of disabling it. Used to pin a real backend disclosure in
|
||||
* assembled coverage; point the URL at a local dead endpoint so no record
|
||||
* leaves the process.
|
||||
* Mount the shipped telemetry row against this exporter URL instead of
|
||||
* disabling it. Used to pin a real backend disclosure in assembled
|
||||
* coverage; point the URL at a local endpoint (a dead port, or a scenario's
|
||||
* own mock collector) so no record leaves the machine.
|
||||
*/
|
||||
telemetryUrl?: string
|
||||
/** Uploading mode for the mounted telemetry row. Defaults to `FULL`. */
|
||||
telemetryMode?: 'FULL' | 'FEEDBACK_ONLY'
|
||||
/**
|
||||
* Browse through a trusted non-loopback hostname that the browser resolves
|
||||
* to loopback (for example `*.localhost`). The test server stays bound to
|
||||
@@ -497,7 +499,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
: {
|
||||
id: 'session-telemetry-otel',
|
||||
config: {
|
||||
mode: 'FULL',
|
||||
mode: options.telemetryMode ?? 'FULL',
|
||||
exporter: { url: options.telemetryUrl },
|
||||
shutdownTimeoutMillis: 1_000,
|
||||
},
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
"tests/shipped-composition.e2e.ts",
|
||||
"tests/schedule-after.e2e.ts",
|
||||
"tests/feedback-command.e2e.ts",
|
||||
"tests/feedback-release.e2e.ts",
|
||||
"tests/agent-team-panel.e2e.ts",
|
||||
"tests/startup-auto-selection.e2e.ts",
|
||||
"tests/produced-files.e2e.ts",
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/config-catalog.md
|
||||
config-catalog.md: 7f85b870bd9604ed983b0d8a251a3ee4511a52b7
|
||||
config-catalog.zh.md: 1ab6838e4cea77a7d98a2227aca6e8ac47d84fcd
|
||||
config-catalog.md: ab16221ff6c13768c9b0fb6a8189e30565dcc289
|
||||
config-catalog.zh.md: 8c9d956ad3ad5f672f73e5b4dd02aaed938c8667
|
||||
|
||||
+18
-6
@@ -274,6 +274,22 @@ Depends on: [`ToolPresentationMode`](subsystems/tools.md)
|
||||
|
||||
Source: [`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-api-gateway"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-api-gateway`
|
||||
|
||||
Requires: `typert`
|
||||
|
||||
```ts config-catalog
|
||||
/** Gateway transport configuration. */
|
||||
export interface Config {
|
||||
/** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 30000 */
|
||||
readonly websocketHeartbeatIntervalMs?: number
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/api/gateway/src/index.ts:114`](../packages/api/gateway/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-api-session-controller"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-api-session-controller`
|
||||
@@ -2863,12 +2879,9 @@ export interface Config {
|
||||
* a distinct name.
|
||||
*/
|
||||
toolName?: string
|
||||
/** Let the model discover and select the child LLM route (default false). */
|
||||
enableModelSelection?: boolean
|
||||
/**
|
||||
* Sample the Host `subagent-model-selection` user setting for each new
|
||||
* top-level session and inherit that decision in its child sessions. Mutually
|
||||
* exclusive with `enableModelSelection`.
|
||||
* top-level session and inherit that decision in its child sessions.
|
||||
*/
|
||||
modelSelectionSettings?: boolean
|
||||
/**
|
||||
@@ -2918,7 +2931,7 @@ export interface Config {
|
||||
|
||||
Depends on: [`AgentOptions`](subsystems/core.md)
|
||||
|
||||
Source: [`packages/subagent/tool-subagent/src/index.ts:48`](../packages/subagent/tool-subagent/src/index.ts)
|
||||
Source: [`packages/subagent/tool-subagent/src/index.ts:49`](../packages/subagent/tool-subagent/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-tool-subagent-report"></a>
|
||||
|
||||
@@ -3317,7 +3330,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
|
||||
- `@deepseek-ai/dsh-acp-app` — requires `cmdlineArgs` ([`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts))
|
||||
- `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts))
|
||||
- `@deepseek-ai/dsh-api-gateway` — requires `typert` ([`packages/api/gateway/src/index.ts`](../packages/api/gateway/src/index.ts))
|
||||
- `@deepseek-ai/dsh-api-remotes` — requires `typertGateway` ([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts))
|
||||
- `@deepseek-ai/dsh-api-settings-controller` ([`packages/api/settings-controller/src/index.ts`](../packages/api/settings-controller/src/index.ts))
|
||||
- `@deepseek-ai/dsh-api-workspace-controller` — requires `typert` · `workspaceRegistry` ([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts))
|
||||
|
||||
@@ -276,6 +276,22 @@ export interface Config {
|
||||
|
||||
来源:[`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-api-gateway"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-api-gateway`
|
||||
|
||||
需要:`typert`
|
||||
|
||||
```ts config-catalog
|
||||
/** Gateway transport configuration. */
|
||||
export interface Config {
|
||||
/** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 30000 */
|
||||
readonly websocketHeartbeatIntervalMs?: number
|
||||
}
|
||||
```
|
||||
|
||||
来源:[`packages/api/gateway/src/index.ts:114`](../packages/api/gateway/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-api-session-controller"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-api-session-controller`
|
||||
@@ -2865,12 +2881,9 @@ export interface Config {
|
||||
* a distinct name.
|
||||
*/
|
||||
toolName?: string
|
||||
/** Let the model discover and select the child LLM route (default false). */
|
||||
enableModelSelection?: boolean
|
||||
/**
|
||||
* Sample the Host `subagent-model-selection` user setting for each new
|
||||
* top-level session and inherit that decision in its child sessions. Mutually
|
||||
* exclusive with `enableModelSelection`.
|
||||
* top-level session and inherit that decision in its child sessions.
|
||||
*/
|
||||
modelSelectionSettings?: boolean
|
||||
/**
|
||||
@@ -3319,7 +3332,6 @@ export interface Config {
|
||||
|
||||
- `@deepseek-ai/dsh-acp-app` — 需要 `cmdlineArgs`([`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts))
|
||||
- `@deepseek-ai/dsh-agent`([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts))
|
||||
- `@deepseek-ai/dsh-api-gateway` — 需要 `typert`([`packages/api/gateway/src/index.ts`](../packages/api/gateway/src/index.ts))
|
||||
- `@deepseek-ai/dsh-api-remotes` — 需要 `typertGateway`([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts))
|
||||
- `@deepseek-ai/dsh-api-settings-controller`([`packages/api/settings-controller/src/index.ts`](../packages/api/settings-controller/src/index.ts))
|
||||
- `@deepseek-ai/dsh-api-workspace-controller` — 需要 `typert` · `workspaceRegistry`([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts))
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/module-graph.md
|
||||
module-graph.md: 41b05c95f7e45c2fc044ce7bf62dfc8c7d31c45e
|
||||
module-graph.zh.md: 1fb86d8745ce909d97ada49565bf200c0eb868b2
|
||||
module-graph.md: e6013b2915121444f9f1e5ccc172190b8fccc650
|
||||
module-graph.zh.md: 6b491805dbbf7733cbda881552a2b8319d81ecc4
|
||||
|
||||
@@ -1346,6 +1346,7 @@ flowchart TD
|
||||
pkg_client_ui_settings_plugin_inventory --> pkg_client_ui_settings
|
||||
pkg_client_ui_settings_plugin_inventory --> pkg_invariants
|
||||
pkg_client_ui_settings_plugins --> pkg_api_remotes
|
||||
pkg_client_ui_settings_plugins --> pkg_client_connection
|
||||
pkg_client_ui_settings_plugins --> pkg_client_locale
|
||||
pkg_client_ui_settings_plugins --> pkg_client_ui_renderer
|
||||
pkg_client_ui_settings_plugins --> pkg_client_ui_settings
|
||||
@@ -1906,7 +1907,7 @@ flowchart TD
|
||||
| [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
|
||||
| [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
|
||||
| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
|
||||
@@ -1348,6 +1348,7 @@ flowchart TD
|
||||
pkg_client_ui_settings_plugin_inventory --> pkg_client_ui_settings
|
||||
pkg_client_ui_settings_plugin_inventory --> pkg_invariants
|
||||
pkg_client_ui_settings_plugins --> pkg_api_remotes
|
||||
pkg_client_ui_settings_plugins --> pkg_client_connection
|
||||
pkg_client_ui_settings_plugins --> pkg_client_locale
|
||||
pkg_client_ui_settings_plugins --> pkg_client_ui_renderer
|
||||
pkg_client_ui_settings_plugins --> pkg_client_ui_settings
|
||||
@@ -1908,7 +1909,7 @@ flowchart TD
|
||||
| [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
|
||||
| [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
|
||||
| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/persistence-catalog.md
|
||||
persistence-catalog.md: b9839833308afbd0d561bc227a95b236a10238a1
|
||||
persistence-catalog.zh.md: 7b5fa938b23ea9112e370133bf7575c8d689806d
|
||||
persistence-catalog.md: 6a48b9c674375c6b5fa8b296afb7658a9d508168
|
||||
persistence-catalog.zh.md: 367b1c1a11324cea057ff03d0c456d531edc0183
|
||||
|
||||
@@ -738,9 +738,9 @@ Source: [`packages/core/session/src/types.ts:239`](../packages/core/session/src/
|
||||
|
||||
Source: [`packages/subagent/subagent/src/descriptor.ts:38`](../packages/subagent/subagent/src/descriptor.ts)
|
||||
|
||||
<a id="subagentmodel-selection-enabled--log-only"></a>
|
||||
<a id="subagentmodel-selection-policy--log-only"></a>
|
||||
|
||||
#### `subagent/model-selection-enabled` — log-only
|
||||
#### `subagent/model-selection-policy` — log-only
|
||||
|
||||
```ts persistence-catalog
|
||||
/**
|
||||
@@ -749,10 +749,13 @@ Source: [`packages/subagent/subagent/src/descriptor.ts:38`](../packages/subagent
|
||||
* request; absence means the fixed-route definition. Log-only: it carries
|
||||
* no `surfaceOp` and never enters model history.
|
||||
*/
|
||||
'subagent/model-selection-enabled': Record<string, never>
|
||||
'subagent/model-selection-policy': {
|
||||
/** Exact routes this Session may select explicitly for a child. */
|
||||
allowedModels: AllowedModelRoute[]
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/tool-subagent/src/model-selection-state.ts:13`](../packages/subagent/tool-subagent/src/model-selection-state.ts)
|
||||
Source: [`packages/subagent/tool-subagent/src/model-selection-state.ts:14`](../packages/subagent/tool-subagent/src/model-selection-state.ts)
|
||||
|
||||
### `team/*`
|
||||
|
||||
|
||||
@@ -740,9 +740,9 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
|
||||
|
||||
来源:[`packages/subagent/subagent/src/descriptor.ts:38`](../packages/subagent/subagent/src/descriptor.ts)
|
||||
|
||||
<a id="subagentmodel-selection-enabled--log-only"></a>
|
||||
<a id="subagentmodel-selection-policy--log-only"></a>
|
||||
|
||||
#### `subagent/model-selection-enabled` — log-only
|
||||
#### `subagent/model-selection-policy` — 仅日志
|
||||
|
||||
```ts persistence-catalog
|
||||
/**
|
||||
@@ -751,10 +751,13 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
|
||||
* request; absence means the fixed-route definition. Log-only: it carries
|
||||
* no `surfaceOp` and never enters model history.
|
||||
*/
|
||||
'subagent/model-selection-enabled': Record<string, never>
|
||||
'subagent/model-selection-policy': {
|
||||
/** Exact routes this Session may select explicitly for a child. */
|
||||
allowedModels: AllowedModelRoute[]
|
||||
}
|
||||
```
|
||||
|
||||
来源:[`packages/subagent/tool-subagent/src/model-selection-state.ts:13`](../packages/subagent/tool-subagent/src/model-selection-state.ts)
|
||||
来源:[`packages/subagent/tool-subagent/src/model-selection-state.ts:14`](../packages/subagent/tool-subagent/src/model-selection-state.ts)
|
||||
|
||||
### `team/*`
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/subagent.md
|
||||
subagent.md: f854711f1161ba1c533fdbc43d7d6c35681f2f7f
|
||||
subagent.zh.md: 960d9b099d915fcf5b1e321ab74374664bb8e468
|
||||
subagent.md: 9206672b89ac31e30bee176f536b3057eb37f3ee
|
||||
subagent.zh.md: 30e4e09a145a4b3d51044d85c602ce81fd44c3b3
|
||||
|
||||
@@ -505,10 +505,10 @@ Singleton settings owner read by delegation tools when an Agent is published.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Read the preference for the next eligible Agent publication.
|
||||
* @returns whether that Agent should receive model-selectable delegation.
|
||||
* Read a detached selection preference for the next eligible Agent publication.
|
||||
* @returns the enabled state and exact allowed routes.
|
||||
*/
|
||||
currentEnabled(): boolean
|
||||
current(): SubagentModelSelectionSettings
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/tool-subagent/src/model-selection-settings.ts`](../../packages/subagent/tool-subagent/src/model-selection-settings.ts)
|
||||
|
||||
@@ -509,10 +509,10 @@ Singleton settings owner read by delegation tools when an Agent is published.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Read the preference for the next eligible Agent publication.
|
||||
* @returns whether that Agent should receive model-selectable delegation.
|
||||
* Read a detached selection preference for the next eligible Agent publication.
|
||||
* @returns the enabled state and exact allowed routes.
|
||||
*/
|
||||
currentEnabled(): boolean
|
||||
current(): SubagentModelSelectionSettings
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/tool-subagent/src/model-selection-settings.ts`](../../packages/subagent/tool-subagent/src/model-selection-settings.ts)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/tool-catalog.md
|
||||
tool-catalog.md: 7b166243fc3f5ef2c1bacdddaf5ee44c5b155622
|
||||
tool-catalog.zh.md: b44a0de4968dbcd760db546037f35e844608c819
|
||||
tool-catalog.md: 16142c2f7d98cf1037b034d2836c742b62f26594
|
||||
tool-catalog.zh.md: 533caacc7a923b5ea36f527292f420b610f1c488
|
||||
|
||||
+3
-15
@@ -33,7 +33,7 @@ This table connects model-visible tool names to the plugin package and service s
|
||||
| `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflowEngine`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. |
|
||||
| `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - |
|
||||
| `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. |
|
||||
| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Models preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. |
|
||||
| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered delegation name is the load-time `toolName` config (default `subagent`); the default schema above has model selection off, while the discovery schema is shown as the fixed companion available in an enabled Session. Web presets sample the Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Each instance independently controls whether it reads model-selection settings and its background behavior through `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. |
|
||||
| `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`, `list_agents`, `send_message` | `ctx.tools`, `ctx.subagents`, `ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` and `interrupt_agent` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows use the sessionProjections and live Agent registries). |
|
||||
| `@deepseek-ai/dsh-tool-subagent-report` | `report` | `ctx.subagents`, `ctx.systemPrompt`, `a live continuable in-process child Agent` | `tool/call`, `tool/result`, `a user-role message in the direct parent session` | - | Registered per continuable in-process child rather than globally, so this schema is visible only inside such a child and survives its global `toolFilter`. The same contribution installs the child-scoped `tool:report` prompt section, which this catalog does not render. The parent-facing `send_message` tool is installed independently. |
|
||||
| `@deepseek-ai/dsh-tool-jobs` | `job_kill`, `job_list`, `job_output` | `ctx.tools`, `ctx.jobs`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-job controller: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. |
|
||||
@@ -1561,7 +1561,7 @@ Source: [`packages/subagent/tool-subagent/src/list-models.ts`](../packages/subag
|
||||
|
||||
### `subagent`
|
||||
|
||||
Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.
|
||||
Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -1575,18 +1575,6 @@ Delegate a self-contained task to a subagent (a separate agent that works in its
|
||||
"type": "string",
|
||||
"description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."
|
||||
},
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route."
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route."
|
||||
},
|
||||
"reasoning_effort": {
|
||||
"type": "string",
|
||||
"description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default."
|
||||
},
|
||||
"run_in_background": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill."
|
||||
@@ -1601,7 +1589,7 @@ Delegate a self-contained task to a subagent (a separate agent that works in its
|
||||
|
||||
Source: [`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts)
|
||||
|
||||
The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Models preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`.
|
||||
The registered delegation name is the load-time `toolName` config (default `subagent`); the default schema above has model selection off, while the discovery schema is shown as the fixed companion available in an enabled Session. Web presets sample the Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Each instance independently controls whether it reads model-selection settings and its background behavior through `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`.
|
||||
|
||||
<a id="deepseek-aidsh-tool-subagent-control"></a>
|
||||
|
||||
|
||||
+3
-15
@@ -37,7 +37,7 @@
|
||||
| `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`、`ctx.workflowEngine`、`ctx.subagents`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents every fresh round)` | `tool/call`、`tool/result`、`workflow and child session events during execution` | - | 固定的前台工作流会在每个 Round 启动一个全新的结构化子级;模型只能选择不可变目标和可选的 Round 上限。 |
|
||||
| `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`、`ctx.agents`、`ctx.skills` | `tool/call`、`tool/result`、`user/message replacement catalogs via agent.inject()` | - | - |
|
||||
| `@deepseek-ai/dsh-tool-session-query` | `session_event_read`、`session_event_search`、`session_event_trace`、`session_search`、`session_trace` | `ctx.tools`、`ctx.systemPrompt`、`ctx.sessionQuery`、`a calling Agent for workspace authority` | `tool/call`、`tool/result` | - | 这 5 个只读工具会隐藏提供方游标,并根据不可变的调用 agent 会话为每个结果授权。该包需要选择启用;需要强制截止时间或限制行内输出的组合还会挂载通用超时或 spill 策略。 |
|
||||
| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 以静态启用模型选择作为参考。模型选择默认为关闭。Web preset 会在每个新顶层 Session 创建时读取 Models 页中默认关闭的偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。显式组合也可以改用静态 `enableModelSelection`。每个实例通过 `enableModelSelection`、`modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制模型选择、发现工具持有权和后台行为。 |
|
||||
| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述默认 schema 关闭模型选择,而发现 schema 则展示为已启用 Session 中可用的固定配套工具。Web preset 会在每个新顶层 Session 创建时读取插件页偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。每个实例通过 `modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制是否读取模型选择设置及其后台行为。 |
|
||||
| `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`、`list_agents`、`send_message` | `ctx.tools`、`ctx.subagents`、`ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`、`tool/result`、`child session events through ctx.subagents` | - | 这些是控制可继续后台 subagent 的全局命名工具:绑定提供方的 `tool-subagent` 实例注册不同的委派工具;本包注册一次 `send_message` 和 `interrupt_agent`,另由 `list_agents` 通过单独加载的 `/list-agents` 插件提供,其目录行使用 sessionProjections 和实时 Agent 注册表。 |
|
||||
| `@deepseek-ai/dsh-tool-subagent-report` | `report` | `ctx.subagents`、`ctx.systemPrompt`、`a live continuable in-process child Agent` | `tool/call`、`tool/result`、`a user-role message in the direct parent session` | - | 按可继续的进程内子级注册,而非全局注册,因此该 schema 仅在这种子级内部可见,并且不受其全局 `toolFilter` 影响。同一份贡献还会安装子级作用域的 `tool:report` 系统提示词 section,本目录不渲染该 section。面向父级的 `send_message` 工具单独安装。 |
|
||||
| `@deepseek-ai/dsh-tool-jobs` | `job_kill`、`job_list`、`job_output` | `ctx.tools`、`ctx.jobs`、`ctx.systemPrompt` | `tool/call`、`tool/result`、`user/message via agent.inject() for background completion notices` | - | 与任务种类无关的后台任务控制器:后台 bash 命令、PTY 发送和 subagent 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 |
|
||||
@@ -1567,7 +1567,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
|
||||
|
||||
### `subagent`
|
||||
|
||||
将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。请提供完整、独立的提示词,因为它看不到当前对话。此调用默认等待结果。设置 `run_in_background: true` 可返回 job id;使用 `job_output` 收集结果,使用 `job_kill` 停止任务。子级 LLM 选择是可选的。省略 `provider`、`model` 与 `reasoning_effort` 会使用配置的子级默认值,并从父 Agent 继承兼容的缺失值。先用 `list_subagent_models` 检查公布的路由和强度,再一起提供 `provider` 与 `model`。改变生效路由但不指定强度时,会使用所选模型的默认强度。
|
||||
将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。请提供完整、独立的提示词,因为它看不到当前对话。此调用默认等待结果。设置 `run_in_background: true` 可返回 job id;使用 `job_output` 收集结果,使用 `job_kill` 停止任务。
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -1581,18 +1581,6 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
|
||||
"type": "string",
|
||||
"description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."
|
||||
},
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route."
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route."
|
||||
},
|
||||
"reasoning_effort": {
|
||||
"type": "string",
|
||||
"description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default."
|
||||
},
|
||||
"run_in_background": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill."
|
||||
@@ -1607,7 +1595,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
|
||||
|
||||
来源:[`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts)
|
||||
|
||||
注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 以静态启用模型选择作为参考。模型选择默认为关闭。Web preset 会在每个新顶层 Session 创建时读取 Models 页中默认关闭的偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。显式组合也可以改用静态 `enableModelSelection`。每个实例通过 `enableModelSelection`、`modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制模型选择、发现工具持有权和后台行为。
|
||||
注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述默认 schema 关闭模型选择,而发现 schema 则展示为已启用 Session 中可用的固定配套工具。Web preset 会在每个新顶层 Session 创建时读取插件页偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。每个实例通过 `modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制是否读取模型选择设置及其后台行为。
|
||||
|
||||
<a id="deepseek-aidsh-tool-subagent-control"></a>
|
||||
|
||||
|
||||
@@ -696,8 +696,7 @@
|
||||
"@deepseek-ai/dsh-llm-deepseek",
|
||||
"@deepseek-ai/dsh-session-checkpoint-policy",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl",
|
||||
"@deepseek-ai/dsh-skill-filesystem",
|
||||
"@deepseek-ai/dsh-tool-subagent"
|
||||
"@deepseek-ai/dsh-skill-filesystem"
|
||||
]
|
||||
},
|
||||
"packages/shell/tool-pwsh": {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/api/gateway/README.md
|
||||
README.md: 2e0cb32e4db6c1bee8576a5addd5492fb7ef53ac
|
||||
README.zh.md: f67e13f6b1f789da02796397a121e59a55427cca
|
||||
README.md: 504ff95494d8c374426c18d0a77fb238457561e3
|
||||
README.zh.md: e556b4981ce5789e6fe9f74bb7d4dc9c5217ae4c
|
||||
|
||||
@@ -32,7 +32,7 @@ The Host entry registers a trusted-host interceptor on Connection's shared `/api
|
||||
|
||||
A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type.
|
||||
|
||||
A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then validates each yielded item with the generated result codec. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates, keeps it connected while idle, and retries physical connection failures with capped backoff. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it.
|
||||
A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then validates each yielded item with the generated result codec. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates, keeps it connected while idle, and retries physical connection failures with capped backoff. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (30 seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it.
|
||||
|
||||
Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, and per-Client queues. Its source factory attaches incremental listeners synchronously; Gateway then yields `{ type: 'ready' }` before iterating the source, so the Client starts baseline reads only after incremental delivery is ready.
|
||||
|
||||
@@ -68,6 +68,7 @@ No direct effect; invoked business Services own any model-visible result.
|
||||
- `$stream()` supervises carrier replacement but does not infer replay semantics; each domain owns its resume cursor or replacement-baseline validation and normal-end classification. Connection generations reopen the internal `$events` stream; one-way notifications are not replayed, while pending scoped waterfalls retain their event id across replay.
|
||||
- Lookup resolvers are configured per key; an individual Remote parameter or endpoint cannot currently select a live-only policy under the same `agent`/`session` key.
|
||||
- Forwarded events reach `$on` without business-payload projection or redaction. Ordinary notifications are not replayed after reconnect; Agent-scoped waterfalls project only the top-level Agent identity needed to select the Client Context and carry their own pending lifetime.
|
||||
- WebSocket heartbeats keep idle intermediaries active but do not require a timely Pong or terminate an unresponsive peer. Half-open carriers remain subject to TCP or intermediary failure detection before the Client reconnects.
|
||||
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -32,7 +32,7 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle
|
||||
|
||||
支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。
|
||||
|
||||
流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再用生成的 result codec 校验每个产出项。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,使其在空闲时保持连接,并以有上限的退避重试物理连接失败。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。
|
||||
流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再用生成的 result codec 校验每个产出项。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,使其在空闲时保持连接,并以有上限的退避重试物理连接失败。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 30 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote stream frame。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。
|
||||
|
||||
Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的 stream。事件名单、参数校验和每 Client 队列由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener;Gateway 随后先产出 `{ type: 'ready' }`,再迭代 source,让 Client 只在增量投递就绪后开始 baseline 读取。
|
||||
|
||||
@@ -68,6 +68,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source
|
||||
- `$stream()` 监督载体替换,但不推断回放语义;各领域自行拥有恢复 cursor 或替换 baseline 的校验,以及正常结束的分类。Connection generation 会重开内部 `$events`;单向通知不会重放,仍处于 pending 的 scoped waterfall 则沿用同一个 event id 重放。
|
||||
- lookup resolver 按 key 配置;当前无法让单个 Remote 参数或 endpoint 在同一 `agent`/`session` key 下选择 live-only 策略。
|
||||
- 被转发的事件到达 `$on` 时不做业务载荷投影或脱敏。普通通知在重连后不重放;Agent-scoped waterfall 只投影选择 Client Context 所需的顶层 Agent 身份,并自行携带 pending 生命周期。
|
||||
- WebSocket 心跳用于保持空闲中间层活跃,但不会要求及时收到 Pong,也不会主动终止无响应对端。半开 carrier 仍需等待 TCP 或中间层检测失败后,Client 才会重连。
|
||||
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -56,7 +56,9 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/schemastery": "workspace:^",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -9,6 +9,8 @@ import { randomUUID } from 'node:crypto'
|
||||
import { Context, Service, symbols } from '@deepseek-ai/cordis'
|
||||
import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection'
|
||||
import type { WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import {
|
||||
remoteMethods,
|
||||
TypertLookupFailure,
|
||||
@@ -106,6 +108,17 @@ interface PendingRemoteEvent {
|
||||
type ConnectionRpcResult = Awaited<ReturnType<ConnectionRpcHandler>>
|
||||
type ConnectionRpcError = Extract<ConnectionRpcResult, { readonly ok: false }>['error']
|
||||
const NEVER_ABORTED_SIGNAL = new AbortController().signal
|
||||
const DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS = 30_000
|
||||
|
||||
/** Gateway transport configuration. */
|
||||
export interface Config {
|
||||
/** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 30000 */
|
||||
readonly websocketHeartbeatIntervalMs?: number
|
||||
}
|
||||
|
||||
interface ResolvedConfig extends Config {
|
||||
readonly websocketHeartbeatIntervalMs: number
|
||||
}
|
||||
|
||||
/** Dispatch failure produced outside the invoked business method. */
|
||||
export class TypertGatewayError extends Error {
|
||||
@@ -156,6 +169,10 @@ class RemoteInvocationCancelled extends Error {
|
||||
*/
|
||||
export class TypertGatewayService extends Service implements TypertGateway {
|
||||
static inject = ['typert']
|
||||
static Config: z<Config> = z.object({
|
||||
websocketHeartbeatIntervalMs: z.number().step(1).min(1).max(MAX_TIMER_DELAY_MS)
|
||||
.default(DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS),
|
||||
})
|
||||
|
||||
/** Carrier adapter shared by the WebSocket mux and local Host transports. */
|
||||
readonly wireStream: TypertGatewayWireStream = {
|
||||
@@ -171,9 +188,11 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
/**
|
||||
* Register the Gateway against the active Typert registry.
|
||||
* @param ctx - owning Host Context with Typert registry access.
|
||||
* @param config - validated Gateway transport configuration.
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, 'typertGateway')
|
||||
const resolved = config as ResolvedConfig
|
||||
ctx.on('internal/service', () => {
|
||||
this.srcClaims = undefined
|
||||
})
|
||||
@@ -188,6 +207,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
const mux = new RemoteStreamMuxServer(
|
||||
(endpoint, payload, signal) => this.openWireStream(endpoint, payload, signal),
|
||||
this.wireStream.failure,
|
||||
resolved.websocketHeartbeatIntervalMs,
|
||||
)
|
||||
webCtx.effect(() => {
|
||||
const route: WebUpgradeRoute = {
|
||||
|
||||
@@ -23,14 +23,17 @@ export type RemoteStreamFailureMapper = (error: unknown) => RemoteStreamFailure
|
||||
export class RemoteStreamMuxServer {
|
||||
private readonly server = new WebSocketServer({ noServer: true })
|
||||
private readonly connections = new Set<Promise<void>>()
|
||||
private heartbeatTimer: NodeJS.Timeout | undefined
|
||||
|
||||
/**
|
||||
* @param open - Gateway stream dispatcher.
|
||||
* @param failure - Gateway error-to-wire mapper.
|
||||
* @param heartbeatIntervalMs - interval between WebSocket Ping control frames.
|
||||
*/
|
||||
constructor(
|
||||
private readonly open: RemoteStreamOpener,
|
||||
private readonly failure: RemoteStreamFailureMapper,
|
||||
private readonly heartbeatIntervalMs: number,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -41,6 +44,7 @@ export class RemoteStreamMuxServer {
|
||||
*/
|
||||
handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void {
|
||||
this.server.handleUpgrade(req, socket, head, (websocket) => {
|
||||
this.startHeartbeat()
|
||||
const connection = new RemoteStreamMuxConnection(websocket, this.open, this.failure)
|
||||
const done = connection.run()
|
||||
this.connections.add(done)
|
||||
@@ -50,6 +54,8 @@ export class RemoteStreamMuxServer {
|
||||
|
||||
/** Terminate all sockets and wait until every iterator has returned. */
|
||||
async close(): Promise<void> {
|
||||
clearInterval(this.heartbeatTimer)
|
||||
this.heartbeatTimer = undefined
|
||||
for (const socket of this.server.clients) socket.terminate()
|
||||
const closed = Promise.withResolvers<void>()
|
||||
this.server.close((error) => {
|
||||
@@ -59,6 +65,17 @@ export class RemoteStreamMuxServer {
|
||||
await closed.promise
|
||||
await Promise.all(this.connections)
|
||||
}
|
||||
|
||||
/** Start one `unref()` timer after the first upgrade; it spans empty-client periods until close(). */
|
||||
private startHeartbeat(): void {
|
||||
if (this.heartbeatTimer !== undefined) return
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
for (const socket of this.server.clients) {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.ping()
|
||||
}
|
||||
}, this.heartbeatIntervalMs)
|
||||
this.heartbeatTimer.unref()
|
||||
}
|
||||
}
|
||||
|
||||
interface ActiveStream {
|
||||
|
||||
@@ -5,6 +5,7 @@ import WebSocket, { type RawData } from 'ws'
|
||||
import { Context, Service, symbols } from '@deepseek-ai/cordis'
|
||||
import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection'
|
||||
import WebServer from '@deepseek-ai/dsh-host-webserver'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import {
|
||||
bindTypertRemote,
|
||||
Remote,
|
||||
@@ -17,6 +18,7 @@ import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import { provideBrowserCredentials } from './browser-credentials.ts'
|
||||
import TypertGatewayService, {
|
||||
TypertGatewayError,
|
||||
type Config as GatewayConfig,
|
||||
type TypertRemoteEventDispatch,
|
||||
type TypertRemoteEventInvocation,
|
||||
type TypertRemoteEventOutcome,
|
||||
@@ -211,6 +213,15 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('Typert Remote streams', () => {
|
||||
it('validates the WebSocket heartbeat timer range', () => {
|
||||
expect(TypertGatewayService.Config({})).toEqual({ websocketHeartbeatIntervalMs: 30_000 })
|
||||
expect(TypertGatewayService.Config({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS }))
|
||||
.toEqual({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS })
|
||||
for (const websocketHeartbeatIntervalMs of [0, 1.5, MAX_TIMER_DELAY_MS + 1]) {
|
||||
expect(() => TypertGatewayService.Config({ websocketHeartbeatIntervalMs })).toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('opens decoded carrier payloads through the in-process wire adapter', async () => {
|
||||
const { ctx } = await setup(false)
|
||||
const source = await ctx.typertGateway.wireStream.open(
|
||||
@@ -280,6 +291,19 @@ describe('Typert Remote streams', () => {
|
||||
})).rejects.toMatchObject({ code: 'signature-invalid' } satisfies Partial<TypertGatewayError>)
|
||||
})
|
||||
|
||||
it('uses the configured WebSocket heartbeat interval', { timeout: 1_000 }, async () => {
|
||||
const { ctx } = await setup(true, { websocketHeartbeatIntervalMs: 20 })
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, {
|
||||
headers: { cookie: browserCookie(ctx) },
|
||||
})
|
||||
const ping = once(socket, 'ping')
|
||||
await once(socket, 'open')
|
||||
expect((await ping)[0]).toEqual(Buffer.alloc(0))
|
||||
|
||||
socket.close()
|
||||
await once(socket, 'close')
|
||||
})
|
||||
|
||||
it('multiplexes independent streams over one WebSocket and propagates cancellation', async () => {
|
||||
const { ctx, service } = await setup(true)
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, {
|
||||
@@ -964,7 +988,10 @@ describe('Typert Remote streams', () => {
|
||||
})
|
||||
})
|
||||
|
||||
async function setup(transport: boolean): Promise<{ readonly ctx: Context; readonly service: FeedService }> {
|
||||
async function setup(
|
||||
transport: boolean,
|
||||
gatewayConfig: GatewayConfig = {},
|
||||
): Promise<{ readonly ctx: Context; readonly service: FeedService }> {
|
||||
const ctx = new Context()
|
||||
roots.push(ctx)
|
||||
if (transport) {
|
||||
@@ -972,7 +999,7 @@ async function setup(transport: boolean): Promise<{ readonly ctx: Context; reado
|
||||
provideBrowserCredentials(ctx)
|
||||
}
|
||||
await ctx.plugin(TypertRegistry)
|
||||
await ctx.plugin(TypertGatewayService)
|
||||
await ctx.plugin(TypertGatewayService, gatewayConfig)
|
||||
if (transport) {
|
||||
await ctx.plugin({ inject: [...connectionInject], apply: applyConnection })
|
||||
}
|
||||
|
||||
@@ -25,6 +25,31 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('Remote stream mux server carrier lifecycle', () => {
|
||||
it('sends WebSocket Ping control frames without application messages', async () => {
|
||||
const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal), 20)
|
||||
const client = await connect(entry.url)
|
||||
const serverSocket = acceptedSocket(entry.mux)
|
||||
const messages = vi.fn()
|
||||
client.on('message', messages)
|
||||
|
||||
const ping = once(client, 'ping')
|
||||
const pong = once(serverSocket, 'pong')
|
||||
expect((await ping)[0]).toEqual(Buffer.alloc(0))
|
||||
expect((await pong)[0]).toEqual(Buffer.alloc(0))
|
||||
expect(messages).not.toHaveBeenCalled()
|
||||
|
||||
const closingPing = vi.spyOn(serverSocket, 'ping')
|
||||
client.pause()
|
||||
serverSocket.close()
|
||||
expect(serverSocket.readyState).toBe(WebSocket.CLOSING)
|
||||
await new Promise<void>((resolve) => { setTimeout(resolve, 25) })
|
||||
expect(closingPing).not.toHaveBeenCalled()
|
||||
|
||||
const closed = once(client, 'close')
|
||||
client.resume()
|
||||
await closed
|
||||
})
|
||||
|
||||
it('rejects binary, malformed, and duplicate logical-stream messages', async () => {
|
||||
const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal))
|
||||
|
||||
@@ -168,8 +193,8 @@ const mapFailure: RemoteStreamFailureMapper = error => ({
|
||||
details: {},
|
||||
})
|
||||
|
||||
async function startMux(open: RemoteStreamOpener): Promise<RunningMux> {
|
||||
const mux = new RemoteStreamMuxServer(open, mapFailure)
|
||||
async function startMux(open: RemoteStreamOpener, heartbeatIntervalMs = 30_000): Promise<RunningMux> {
|
||||
const mux = new RemoteStreamMuxServer(open, mapFailure, heartbeatIntervalMs)
|
||||
const http = createServer()
|
||||
http.on('upgrade', (request, socket, head) => { mux.handleUpgrade(request, socket, head) })
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
},
|
||||
@@ -30,6 +33,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../typert/protocol"
|
||||
},
|
||||
{
|
||||
"path": "../../util/timeout"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -165,11 +165,14 @@
|
||||
writeEveryEvents: 200
|
||||
writeIntervalMs: 5000
|
||||
|
||||
# Session telemetry is mounted but disabled by default. DSH_TELEMETRY_MODE
|
||||
# explicitly opts into FULL or FEEDBACK_ONLY reporting; uploading mirrors
|
||||
# session-log records onto OTLP/HTTP logs with no session-telemetry/record redaction
|
||||
# rule, so exports are the raw captured copy. The deployment stance, env
|
||||
# seams, and follow-ups are pinned in the default-off Agent Note.
|
||||
# Session telemetry defaults to feedback-gated sharing: FEEDBACK_ONLY
|
||||
# uploads only when the user records /feedback, releasing the session
|
||||
# records since the last handoff through that event (a resumed session
|
||||
# shares only its current lifecycle). DSH_TELEMETRY_MODE overrides to
|
||||
# FULL or DISABLED; uploading mirrors session-log records onto OTLP/HTTP
|
||||
# logs with no session-telemetry/record redaction rule, so exports are
|
||||
# the raw captured copy. The deployment stance, env seams, and follow-ups
|
||||
# are pinned in the feedback-gated-default Agent Note.
|
||||
# DSH_TELEMETRY_OTLP_URL overrides the production endpoint. A non-empty
|
||||
# DSH_TELEMETRY_DISABLED — any value, including '0'/'false' — opts the
|
||||
# process out (the launchers patch the row disabled; config cannot disable
|
||||
@@ -187,7 +190,7 @@
|
||||
- id: session-telemetry-otel
|
||||
name: '@deepseek-ai/dsh-session-telemetry-otel'
|
||||
config:
|
||||
mode: !!js process.env.DSH_TELEMETRY_MODE || 'DISABLED'
|
||||
mode: !!js process.env.DSH_TELEMETRY_MODE || 'FEEDBACK_ONLY'
|
||||
shutdownTimeoutMillis: 3000
|
||||
exporter:
|
||||
url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://harness-telemetry.deepseeksvc.com/v1/logs'
|
||||
@@ -354,7 +357,6 @@
|
||||
config:
|
||||
provider: spawn
|
||||
toolName: subagent
|
||||
enableModelSelection: true
|
||||
backgroundMode: continuable
|
||||
|
||||
# Fork omits model selection so provider/model stay equal to the parent and
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('dsh-base bundle', () => {
|
||||
expect(rows.length).toBeGreaterThan(50)
|
||||
expect(rows.some(row => row.id === 'agent-loop')).toBe(true)
|
||||
expect(rows.find(row => row.id === 'session-telemetry-otel')?.config?.['mode']).toEqual({
|
||||
__jsExpr: "process.env.DSH_TELEMETRY_MODE || 'DISABLED'",
|
||||
__jsExpr: "process.env.DSH_TELEMETRY_MODE || 'FEEDBACK_ONLY'",
|
||||
})
|
||||
expect(rows.find(row => row.id === 'hmr')).toMatchObject({
|
||||
disabled: true,
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
# window.__DSH_BOOT__; the modules row is simultaneously a host row.
|
||||
- insert:
|
||||
# Host-owned opt-in sampled when a new Web session receives its preset
|
||||
# delegation tools. The Models page edits this settings namespace.
|
||||
# delegation tools. The Plugins page edits this settings namespace.
|
||||
- id: subagent-model-selection-settings
|
||||
name: '@deepseek-ai/dsh-tool-subagent/model-selection-settings'
|
||||
|
||||
|
||||
@@ -498,7 +498,8 @@ export function ChatView({
|
||||
loadOlder()
|
||||
}
|
||||
|
||||
const navigateToTurn = (item: TurnNavigationItem): void => {
|
||||
// Identity feeds the memoized rail; a fresh closure per render would defeat it.
|
||||
const navigateToTurn = useCallback((item: TurnNavigationItem): void => {
|
||||
const local = listRef.current
|
||||
if (local === null) return
|
||||
const row = anchorElement(local, item.anchorKey)
|
||||
@@ -519,7 +520,7 @@ export function ChatView({
|
||||
const position = isAtBottom ? null : scrollPosition(local, el)
|
||||
if (isAtBottom) chatScroll.save(null)
|
||||
else if (position !== null) chatScroll.save(position)
|
||||
}
|
||||
}, [loadingOlder, chatScroll])
|
||||
|
||||
return (
|
||||
<div className={css.root}>
|
||||
|
||||
@@ -292,4 +292,3 @@
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
useId, useState, type CSSProperties, type MouseEvent, type PointerEvent,
|
||||
memo, useId, useState, type CSSProperties, type MouseEvent, type PointerEvent,
|
||||
} from 'react'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import type { TurnNavigationItem } from '../contract/snapshot.ts'
|
||||
@@ -53,8 +53,7 @@ function itemAtPointer(
|
||||
return items[Math.round(ratio * (items.length - 1))]
|
||||
}
|
||||
|
||||
/** Compact rail of the currently loaded Turns with hover and focus previews. */
|
||||
export function TurnNavigator({ items, activeTurn, onNavigate, t }: TurnNavigatorProps) {
|
||||
function TurnNavigatorRail({ items, activeTurn, onNavigate, t }: TurnNavigatorProps) {
|
||||
const [previewTurn, setPreviewTurn] = useState<number | null>(null)
|
||||
const previewId = useId()
|
||||
if (items.length < 2) return null
|
||||
@@ -116,3 +115,14 @@ export function TurnNavigator({ items, activeTurn, onNavigate, t }: TurnNavigato
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact rail of the currently loaded Turns with hover and focus previews.
|
||||
*
|
||||
* Memoized because it renders two host elements per loaded Turn while the
|
||||
* enclosing view re-renders on every streaming delta: without the guard a long
|
||||
* session rebuilds hundreds of marks per commit for a rail that only changes
|
||||
* when a Turn is added, removed, or becomes active. Its props must therefore
|
||||
* stay referentially stable across those commits.
|
||||
*/
|
||||
export const TurnNavigator = memo(TurnNavigatorRail)
|
||||
|
||||
@@ -412,6 +412,34 @@ describe('Chat node rendering', () => {
|
||||
})
|
||||
|
||||
describe('ChatView', () => {
|
||||
it('leaves the turn rail unrendered when an unrelated Chat update commits', () => {
|
||||
const snapshot = chatSnapshotFixture({
|
||||
nodes: [
|
||||
userInTurn(1, 'first prompt', 1),
|
||||
assistant(2, 'first response', 1),
|
||||
userInTurn(4, 'second prompt', 2),
|
||||
assistant(5, 'second response', 2),
|
||||
],
|
||||
turnEnds: new Map([[1, 3], [2, 6]]),
|
||||
})
|
||||
const h = makeHarness({}, {}, snapshot)
|
||||
// The rail asks for its own accessible name once per render, so counting
|
||||
// that key counts renders without reaching into the component.
|
||||
let railRenders = 0
|
||||
const translate = h.props.t
|
||||
const counting = ((key: string, vars?: Record<string, unknown>) => {
|
||||
if (key === 'chat.turnNavigation.label') railRenders += 1
|
||||
return (translate as (k: string, v?: Record<string, unknown>) => string)(key, vars)
|
||||
}) as ChatViewSlotProps['t']
|
||||
render(<h.ChatView {...h.props} t={counting} />)
|
||||
const afterMount = railRenders
|
||||
expect(afterMount).toBeGreaterThan(0)
|
||||
|
||||
act(() => { h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }) })
|
||||
|
||||
expect(railRenders).toBe(afterMount)
|
||||
})
|
||||
|
||||
it('projects loaded turns into prompt and response navigation previews', () => {
|
||||
const snapshot = chatSnapshotFixture({
|
||||
nodes: [
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md
|
||||
README.md: 6ed2bde147f7c9d3853196fa023d7461f977b2da
|
||||
README.zh.md: 3806244d4deec80a31d9c4bafc6a11aaddbb528f
|
||||
README.md: 658b357992a1926f1f21e109c19b7c0975da58ea
|
||||
README.zh.md: dcc19f80a15e6e7e81c3dea128a999b83e8311ba
|
||||
|
||||
@@ -35,10 +35,6 @@ The primary field on an editor card is a single **API key** input — the page n
|
||||
|
||||
The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately not among the editable fields: it is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`/`maxTokens`; existing fields outside that curated set survive edits.
|
||||
|
||||
### Subagent model selection
|
||||
|
||||
When the Host advertises the `subagent-model-selection` settings namespace, Models shows a localized switch above the provider rows. The switch defaults off and writes only `{ enabled }` through `settings.update` with the namespace revision. The Host samples the value when it composes a new top-level Session, so changing it does not reconfigure running Sessions. Child Sessions inherit their parent's recorded decision.
|
||||
|
||||
### Adding and deleting providers
|
||||
|
||||
The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. **Fetch available models** asks `llm.discoverModels` about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a picker rather than being written, and nothing is written until **Add selected**. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider.
|
||||
|
||||
@@ -35,10 +35,6 @@ kind: "package-reference"
|
||||
|
||||
收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意不在可编辑字段之列:它是按模型的能力,提供方级的控件只可能被设成某些模型会拒绝的值。每个 DeepSeek 行编辑 `id`、可选显示 `name` 与可选 `contextWindow`/`maxTokens`;该精选集之外的现有字段在编辑后仍会保留。
|
||||
|
||||
### 子代理模型选择
|
||||
|
||||
当宿主提供 `subagent-model-selection` 设置 namespace 时,Models 会在提供方行上方显示一个本地化开关。该开关默认关闭,并通过 `settings.update` 携带 namespace revision、只写入 `{ enabled }`。宿主在组合新的顶层 Session 时读取此值,因此更改它不会重新配置正在运行的 Session。子 Session 会继承其父级已记录的决定。
|
||||
|
||||
### 新增与删除提供方
|
||||
|
||||
「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。**获取可用模型**就表单显示的端点询问 `llm.discoverModels`,因此新增提供方一次即可完成,而非先保存再返回;回复打开的是选择器而非直接写入,只有点击**添加所选**才会写入。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。
|
||||
|
||||
@@ -40,87 +40,6 @@
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.preferenceCard {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px 16px;
|
||||
margin-top: 4px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.preferenceCopy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.preferenceTitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.preferenceDescription {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.switch {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
padding: 2px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: var(--dsw-alias-border-l3);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switchOn {
|
||||
background: var(--dsw-alias-brand-primary);
|
||||
}
|
||||
|
||||
.switch:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.switch:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
.switchThumb {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-label-primary-foreground);
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.switchOn .switchThumb {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.preferenceStatus,
|
||||
.preferenceCard > .error {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.preferenceStatus {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.rows {
|
||||
list-style: none;
|
||||
/* Extra air between the title/intro block and the first provider card. */
|
||||
|
||||
@@ -23,7 +23,6 @@ import { deriveKeyRef, messageOf, protocolChoices, providerUsable } from './stor
|
||||
import type { ModelsSettingsStore, ModelsWire, ProviderRow } from './store.ts'
|
||||
import type { SettingsSchemaOperations } from './schema-operations.ts'
|
||||
import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx'
|
||||
import { SubagentModelSelectionCard } from './SubagentModelSelectionCard.tsx'
|
||||
import type { en } from './locales.ts'
|
||||
import styles from './ModelsSection.module.css'
|
||||
|
||||
@@ -309,24 +308,12 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
|
||||
// one whose schema names the protocols one may speak; without it mounted
|
||||
// there is nothing to declare and the entry point stays disabled.
|
||||
const protocols = protocolChoices(state.namespaces.get('llm-pi-ai'), schema)
|
||||
const subagentModelSelection = state.namespaces.get('subagent-model-selection')
|
||||
|
||||
return (
|
||||
<div className={styles['section']}>
|
||||
<h2 className={styles['title']}>{t('title')}</h2>
|
||||
<p className={styles['intro']}>{t('intro')}</p>
|
||||
{!state.writable && state.status === 'ready' ? <p className={styles['notice']}>{t('readOnly')}</p> : null}
|
||||
{subagentModelSelection === undefined
|
||||
? null
|
||||
: (
|
||||
<SubagentModelSelectionCard
|
||||
namespace={subagentModelSelection}
|
||||
writable={state.writable}
|
||||
api={api}
|
||||
controller={controller}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{savedIdentity === undefined
|
||||
? null
|
||||
: (
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
/** User control for model-selectable subagent delegation in new sessions. */
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { ModelsSettingsStore } from './store.ts'
|
||||
import type { en } from './locales.ts'
|
||||
import { messageOf } from './store.ts'
|
||||
import styles from './ModelsSection.module.css'
|
||||
|
||||
/** Props for the Host-owned subagent model-selection preference. */
|
||||
export interface SubagentModelSelectionCardProps {
|
||||
/** Current redacted namespace view. */
|
||||
namespace: SettingsNamespaceView
|
||||
/** Whether the settings provider accepts writes. */
|
||||
writable: boolean
|
||||
/** Settings wire face. */
|
||||
api: SettingsWireFace
|
||||
/** Models page controller to refresh after a commit. */
|
||||
controller: ModelsSettingsStore
|
||||
/** Localized Models copy. */
|
||||
t: (key: keyof typeof en) => string
|
||||
}
|
||||
|
||||
/** Read the schema-validated resolved boolean from a namespace view. */
|
||||
function enabledOf(namespace: SettingsNamespaceView): boolean {
|
||||
if (typeof namespace.value !== 'object' || namespace.value === null) return false
|
||||
return (namespace.value as { enabled?: unknown }).enabled === true
|
||||
}
|
||||
|
||||
/** Render and persist the default-off new-session preference. */
|
||||
export function SubagentModelSelectionCard({
|
||||
namespace,
|
||||
writable,
|
||||
api,
|
||||
controller,
|
||||
t,
|
||||
}: SubagentModelSelectionCardProps): ReactNode {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [error, setError] = useState<string | undefined>(undefined)
|
||||
const enabled = enabledOf(namespace)
|
||||
|
||||
const toggle = (): void => {
|
||||
setSaving(true)
|
||||
setSaved(false)
|
||||
setError(undefined)
|
||||
void api.settings.update(
|
||||
namespace.ns,
|
||||
{ enabled: !enabled },
|
||||
namespace.revision,
|
||||
).then(async (response) => {
|
||||
if (!response.ok) throw new Error(response.error.message)
|
||||
controller.acceptNamespace(response.value)
|
||||
await controller.load()
|
||||
setSaved(true)
|
||||
}).catch((reason: unknown) => {
|
||||
setError(messageOf(reason))
|
||||
}).finally(() => { setSaving(false) })
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles['preferenceCard']} aria-labelledby="subagent-model-selection-title">
|
||||
<div className={styles['preferenceCopy']}>
|
||||
<h3 id="subagent-model-selection-title" className={styles['preferenceTitle']}>
|
||||
{t('subagentModelSelectionTitle')}
|
||||
</h3>
|
||||
<p className={styles['preferenceDescription']}>{t('subagentModelSelectionDescription')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
aria-label={t('subagentModelSelectionToggle')}
|
||||
className={`${styles['switch']} ${enabled ? styles['switchOn'] : ''}`}
|
||||
disabled={!writable || saving}
|
||||
onClick={toggle}
|
||||
>
|
||||
<span className={styles['switchThumb']} />
|
||||
</button>
|
||||
{saved
|
||||
? <p className={styles['preferenceStatus']} role="status">{t('subagentModelSelectionSaved')}</p>
|
||||
: null}
|
||||
{error === undefined ? null : <p className={styles['error']} role="alert">{error}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -5,10 +5,6 @@ export const en = {
|
||||
nav: 'Models',
|
||||
title: 'Models',
|
||||
intro: 'Enter your API keys to use models from the following providers.',
|
||||
subagentModelSelectionTitle: 'Subagent model selection',
|
||||
subagentModelSelectionDescription: 'Allow new sessions to choose a provider, model, and reasoning effort for subagents. Running sessions do not change.',
|
||||
subagentModelSelectionToggle: 'Allow subagents to choose models',
|
||||
subagentModelSelectionSaved: 'Saved. New sessions use this setting.',
|
||||
edit: 'Edit',
|
||||
editProvider: 'Edit {provider}',
|
||||
remove: 'Delete',
|
||||
@@ -113,10 +109,6 @@ export const zh: { [Key in keyof typeof en]: string } = {
|
||||
nav: '模型',
|
||||
title: '模型',
|
||||
intro: '填入各提供方的 API 密钥即可使用其模型。',
|
||||
subagentModelSelectionTitle: 'Subagent 自选模型',
|
||||
subagentModelSelectionDescription: '允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。',
|
||||
subagentModelSelectionToggle: '允许 subagent 自选模型',
|
||||
subagentModelSelectionSaved: '已保存,新会话将使用此设置。',
|
||||
edit: '编辑',
|
||||
editProvider: '编辑 {provider}',
|
||||
remove: '删除',
|
||||
|
||||
@@ -137,24 +137,15 @@ export class ModelsSettingsStore {
|
||||
private generation = 0
|
||||
|
||||
/**
|
||||
* @param api - the page's wire faces (credentials Remote, llm reads, settings writes).
|
||||
* @param api - the page's credentials Remote and LLM wire faces.
|
||||
* @param describeFace - the shared mirror's describe face (namespace views and writability).
|
||||
*/
|
||||
constructor(
|
||||
private readonly api: ModelsWire,
|
||||
private readonly api: Pick<ModelsWire, 'credentials' | 'llm'>,
|
||||
private readonly schema: SettingsSchemaOperations,
|
||||
private readonly describeFace: SettingsDescribeFace,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Fold one successful settings write into the shared mirror before rejoining
|
||||
* this page's rows.
|
||||
* @param view - namespace view returned by the settings wire method.
|
||||
*/
|
||||
acceptNamespace(view: SettingsNamespaceView): void {
|
||||
this.describeFace.acceptView(view)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the whole page snapshot: the provider directory and the mirror's
|
||||
* settings answer in parallel, then one batched credential describe over
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { JsonValue, RpcResponse, SettingsNamespaceView } from '@deepseek-ai
|
||||
import {
|
||||
ModelsSection, needsSetup, providerCopy, providerTargetLabel, removeProviderProfile,
|
||||
} from '../src/client/ModelsSection.tsx'
|
||||
import { SubagentModelSelectionCard } from '../src/client/SubagentModelSelectionCard.tsx'
|
||||
import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx'
|
||||
import { pathOps } from '../src/client/ProviderEditor.tsx'
|
||||
import {
|
||||
@@ -328,72 +327,6 @@ describe('ModelsSection', () => {
|
||||
expect(screen.getByLabelText(en.keyInput)).toBeTruthy()
|
||||
expect(cardSeatCalls(renderSlot).some(([provider]) => provider === 'anthropic')).toBe(false)
|
||||
})
|
||||
|
||||
it('persists the default-off subagent model-selection switch for new sessions', async () => {
|
||||
const enabledNamespace: SettingsNamespaceView = {
|
||||
...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!,
|
||||
value: { enabled: true },
|
||||
user: { enabled: true },
|
||||
revision: 5,
|
||||
}
|
||||
const update = vi.fn(() => Promise.resolve(remoteOk(enabledNamespace)))
|
||||
await mountSection({ update })
|
||||
|
||||
const toggle = screen.getByRole('switch', { name: en.subagentModelSelectionToggle })
|
||||
expect(toggle.getAttribute('aria-checked')).toBe('false')
|
||||
fireEvent.click(toggle)
|
||||
|
||||
await waitFor(() => { expect(toggle.getAttribute('aria-checked')).toBe('true') })
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
'subagent-model-selection',
|
||||
{ enabled: true },
|
||||
4,
|
||||
)
|
||||
expect(screen.getByRole('status').textContent).toBe(en.subagentModelSelectionSaved)
|
||||
})
|
||||
|
||||
it('reports rejected subagent model-selection updates and permits a retry', async () => {
|
||||
const update = vi.fn()
|
||||
.mockResolvedValueOnce(remoteFail('revision changed', 'settings-rejected'))
|
||||
.mockResolvedValueOnce(remoteOk({
|
||||
...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!,
|
||||
value: { enabled: true },
|
||||
revision: 5,
|
||||
}))
|
||||
await mountSection({ update })
|
||||
|
||||
const toggle = screen.getByRole('switch', { name: en.subagentModelSelectionToggle })
|
||||
fireEvent.click(toggle)
|
||||
expect((await screen.findByRole('alert')).textContent).toBe('revision changed')
|
||||
|
||||
fireEvent.click(toggle)
|
||||
await waitFor(() => { expect(toggle.getAttribute('aria-checked')).toBe('true') })
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps malformed and read-only subagent preferences off', () => {
|
||||
const namespace = {
|
||||
...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!,
|
||||
value: null,
|
||||
} as unknown as SettingsNamespaceView
|
||||
const mutate = vi.fn()
|
||||
render(
|
||||
<SubagentModelSelectionCard
|
||||
namespace={namespace}
|
||||
writable={false}
|
||||
api={{ settings: { mutate } } as never}
|
||||
controller={{ acceptNamespace: vi.fn(), load: vi.fn() } as never}
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
|
||||
const toggle = screen.getByRole('switch', { name: en.subagentModelSelectionToggle })
|
||||
expect(toggle.getAttribute('aria-checked')).toBe('false')
|
||||
expect((toggle as HTMLButtonElement).disabled).toBe(true)
|
||||
fireEvent.click(toggle)
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders the unkeyed whole-section provider as an open setup card in the first-run posture', async () => {
|
||||
await mountFirstRun()
|
||||
// Nothing is reachable yet, and DeepSeek has no configured credential and
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-settings-plugins/README.md
|
||||
README.md: 17ef6fda253ead749eba297519ad4b0647fc482e
|
||||
README.zh.md: 9f48f673c6d145e0ff1e182d5a47a710a531ba72
|
||||
README.md: 8453bd4cb8928bbd88217716f9d505cafaf46b4f
|
||||
README.zh.md: 6a9b716b843af804b1534f0fd0fa0fc8fb5686c1
|
||||
|
||||
@@ -25,7 +25,7 @@ English | [中文](README.zh.md)
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Open the Plugins section in Settings and select the **Plugin configuration** tab to edit the host-plane plugins this deployment composes. The cards this package ships cover the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), and the DeepSeek search provider (`web-search-deepseek`).
|
||||
Open the Plugins section in Settings and select the **Plugin configuration** tab to edit the host-plane plugins this deployment composes. The cards appear in this order: the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), subagent model selection (`subagent-model-selection`), and the DeepSeek search provider (`web-search-deepseek`).
|
||||
|
||||
### What appears here
|
||||
|
||||
@@ -33,7 +33,9 @@ The tab reads which settings namespaces the Host serves and dispatches one slot
|
||||
|
||||
### Editing and saving
|
||||
|
||||
A card stages what the user types and writes it only when they save. Each control renders staged text, so what is on screen is exactly what a save would store; **Discard** drops the drafts, and a card holding unsaved edits says so on its header even while collapsed. A reset stages the composed default rather than writing immediately, and a draft the field does not accept blocks the save instead of being dropped. The Host is the only authority on whether a value was accepted — the card reads the section back afterwards and reports a save that did not land, keeping those drafts for the user to correct.
|
||||
A card stages what the user types and writes it only when they save. Each control renders staged text, so what is on screen is exactly what a save would store; **Discard** drops the drafts, and a card holding unsaved edits says so on its header even while collapsed. A successful save collapses the card after the read-back confirms the writes; a failed save keeps the card open, reports the failure, and retains the drafts for correction. A reset stages the composed default rather than writing immediately, and a draft the field does not accept blocks the save instead of being dropped. The Host is the only authority on whether a value was accepted.
|
||||
|
||||
The Subagent card stages its permission switch and exact model checkboxes together. Enabling requires at least one selected adapter route. Saving submits `enabled` and `allowedModels` in one mutation fenced by the revision where that draft began; a newer Host revision marks the draft failed instead of restoring a revoked route. Disabling retains the selected routes for later reuse. Available models are grouped by provider, while saved routes absent from the current catalog appear last and remain removable. Adapter names and model descriptions remain live directory metadata and are not stored, and the card refreshes them after adapter changes, settings commits, and reconnects.
|
||||
|
||||
### Secret-role fields
|
||||
|
||||
@@ -55,7 +57,7 @@ The section declares `settings.plugins.tab`, a root list slot whose labels becom
|
||||
|
||||
### The write path
|
||||
|
||||
Saving writes each staged field through the client settings scope, which fences every write with the namespace revision it read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Secret-role fields never ride a response; the card re-reads on the forwarded `credentials/reference-updated` event for the reference it watches.
|
||||
Saving writes staged fields through the client settings scope, which fences each write or ordered mutation with the namespace revision the draft read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Secret-role fields never ride a response; the card re-reads on the forwarded `credentials/reference-updated` event for the reference it watches.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
打开设置中的「插件」分区并选择**插件配置**标签页,即可编辑本部署所组装的宿主平面插件。本包自带的卡片覆盖 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。
|
||||
打开设置中的「插件」分区并选择**插件配置**标签页,即可编辑本部署所组装的宿主平面插件。卡片依次为 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)、subagent 模型选择(`subagent-model-selection`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。
|
||||
|
||||
### 这里会出现什么
|
||||
|
||||
@@ -33,7 +33,9 @@ kind: "package-reference"
|
||||
|
||||
### 编辑与保存
|
||||
|
||||
卡片暂存用户输入,只有用户保存时才写入。每个控件渲染的都是暂存文本,因此屏幕上所见即保存后所存;**放弃修改**丢弃这些草稿,持有未保存修改的卡片即使收起也会在标题上标明。重置暂存的是组装默认值而非立即写入;字段不接受的草稿会阻塞保存,而不是被丢弃。某个值是否被接受只有 Host 说了算——卡片在写入后回读分节,报告没有落盘的保存,并保留这些草稿供用户修改。
|
||||
卡片暂存用户输入,只有用户保存时才写入。每个控件渲染的都是暂存文本,因此屏幕上所见即保存后所存;**放弃修改**丢弃这些草稿,持有未保存修改的卡片即使收起也会在标题上标明。保存成功后,卡片会在回读确认写入后收起;保存失败时,卡片保持展开、报告失败并保留草稿供用户修改。重置暂存的是组装默认值而非立即写入;字段不接受的草稿会阻塞保存,而不是被丢弃。某个值是否被接受只有 Host 说了算。
|
||||
|
||||
Subagent 卡会同时暂存其权限开关与精确模型复选框。启用时必须至少选择一条适配器路由。保存会在一次 mutation 中提交 `enabled` 与 `allowedModels`,并以草稿开始时的 revision 设栅;Host revision 更新后,草稿会标记为失败,而不会恢复已撤销的路由。关闭时会保留已选路由供以后重新使用。可用模型按提供方分组;当前目录中缺失的已存路由排在末尾,且仍可移除。适配器名称与模型描述仍属于实时目录元数据,不会存储;适配器变化、设置提交和重连后,卡片会刷新这些元数据。
|
||||
|
||||
### secret 角色字段
|
||||
|
||||
@@ -55,7 +57,7 @@ kind: "package-reference"
|
||||
|
||||
### 写入路径
|
||||
|
||||
保存时,每个暂存字段都通过客户端 settings scope 写入,该 scope 用读取时的命名空间 revision 为每次写入设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应;卡片会在转发来的 `credentials/reference-updated` 事件报告它所关注的引用时重读。
|
||||
保存时,暂存字段通过客户端 settings scope 写入;每次单字段写入或有序 mutation 都以草稿读取时的命名空间 revision 设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应;卡片会在转发来的 `credentials/reference-updated` 事件报告它所关注的引用时重读。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-api-remotes"
|
||||
@@ -47,6 +48,7 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
@@ -55,6 +57,7 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* disabled card the user cannot act on.
|
||||
*/
|
||||
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { CardShell } from './card-form.ts'
|
||||
@@ -46,7 +46,19 @@ export interface PluginCardProps {
|
||||
*/
|
||||
export function PluginCard(props: PluginCardProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const saveStarted = useRef(false)
|
||||
const { state } = props
|
||||
// Collapse only after Host-confirmed settlement; a rejected write keeps its
|
||||
// diagnostics and retained drafts visible for correction.
|
||||
useEffect(() => {
|
||||
if (state.saving) {
|
||||
saveStarted.current = true
|
||||
return
|
||||
}
|
||||
if (!saveStarted.current) return
|
||||
saveStarted.current = false
|
||||
if (!state.dirty && !state.failed) setOpen(false)
|
||||
}, [state.dirty, state.failed, state.saving])
|
||||
if (!state.available) return null
|
||||
const title = props.t(props.titleKey)
|
||||
const blocked = !state.dirty || state.invalid || state.saving
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
.permission {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.toggleRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.toggleLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.switch {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
padding: 2px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: var(--dsw-alias-border-l3);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switchOn {
|
||||
background: var(--dsw-alias-brand-primary);
|
||||
}
|
||||
|
||||
.switch:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.switch:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-label-primary-foreground);
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.switchOn .thumb {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.selection {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.hint,
|
||||
.notice,
|
||||
.invalid,
|
||||
.conflict {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hint,
|
||||
.notice {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.invalid,
|
||||
.conflict {
|
||||
color: var(--dsw-alias-label-error);
|
||||
}
|
||||
|
||||
.catalogError {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-label-error);
|
||||
}
|
||||
|
||||
.catalogError button {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-brand-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.models {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
max-height: 280px;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.models legend {
|
||||
padding: 0 4px;
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.modelGroup {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.modelGroup + .modelGroup {
|
||||
margin-top: 4px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
.providerName {
|
||||
padding: 0 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.model {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.model:hover {
|
||||
background: var(--dsw-alias-bg-layer-4);
|
||||
}
|
||||
|
||||
.modelName,
|
||||
.route {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.modelName {
|
||||
font-size: 13px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.route {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.unavailable {
|
||||
font-size: 11px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/** User control for model-selectable subagent delegation in new sessions. */
|
||||
|
||||
import clsx from 'clsx'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {
|
||||
SubagentModelCandidate,
|
||||
SubagentModelSelectionCardFace,
|
||||
} from './subagent-model-selection-card-controller.ts'
|
||||
import type {} from './slot-contract.ts'
|
||||
import { PluginCard } from './PluginCard.tsx'
|
||||
import css from './SubagentModelSelectionCard.module.css'
|
||||
|
||||
/** Props the renderer binds for the subagent model-selection card. */
|
||||
export type SubagentModelSelectionCardProps =
|
||||
PropsRuntime<'settings.plugin.item'>
|
||||
& PropsLocale<'settings.plugins'>
|
||||
& InjectFace<SubagentModelSelectionCardFace>
|
||||
|
||||
/**
|
||||
* Render the default-off preference and its exact adapter-route choices.
|
||||
* @param props - locale copy, the card snapshot, and its toggle action.
|
||||
* @returns the preference card, or nothing when the namespace is unavailable.
|
||||
*/
|
||||
export function SubagentModelSelectionCard(props: SubagentModelSelectionCardProps) {
|
||||
const { t } = props
|
||||
const state = props.useSubagentModelSelectionCard(snapshot => snapshot)
|
||||
const availableGroups = new Map<string, {
|
||||
providerName: string
|
||||
candidates: SubagentModelCandidate[]
|
||||
}>()
|
||||
const unavailable: SubagentModelCandidate[] = []
|
||||
for (const candidate of state.candidates) {
|
||||
if (!candidate.available) {
|
||||
unavailable.push(candidate)
|
||||
continue
|
||||
}
|
||||
const group = availableGroups.get(candidate.provider)
|
||||
if (group === undefined) {
|
||||
availableGroups.set(candidate.provider, {
|
||||
providerName: candidate.providerName,
|
||||
candidates: [candidate],
|
||||
})
|
||||
} else {
|
||||
group.candidates.push(candidate)
|
||||
}
|
||||
}
|
||||
const renderCandidate = (candidate: SubagentModelCandidate) => (
|
||||
<label key={candidate.key} className={css.model}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={candidate.selected}
|
||||
disabled={!state.writable || state.saving}
|
||||
onChange={() => { props.toggleModel(candidate.key) }}
|
||||
/>
|
||||
<span>
|
||||
<span className={css.modelName}>{candidate.modelName}</span>
|
||||
<span className={css.route}>{`${candidate.providerName} · ${candidate.provider}/${candidate.model}`}</span>
|
||||
</span>
|
||||
{!candidate.available
|
||||
? <span className={css.unavailable}>{t('subagentModelSelectionUnavailable')}</span>
|
||||
: null}
|
||||
</label>
|
||||
)
|
||||
return (
|
||||
<PluginCard
|
||||
t={t}
|
||||
titleKey="subagentModelSelectionTitle"
|
||||
descriptionKey="subagentModelSelectionDescription"
|
||||
state={state}
|
||||
onSave={props.save}
|
||||
onDiscard={props.discard}
|
||||
>
|
||||
<div className={css.permission}>
|
||||
<div className={css.toggleRow}>
|
||||
<span className={css.toggleLabel}>{t('subagentModelSelectionToggle')}</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={state.enabled}
|
||||
aria-label={t('subagentModelSelectionToggle')}
|
||||
className={clsx(css.switch, state.enabled && css.switchOn)}
|
||||
disabled={!state.writable || state.saving}
|
||||
onClick={props.toggleEnabled}
|
||||
>
|
||||
<span className={css.thumb} />
|
||||
</button>
|
||||
</div>
|
||||
<p className={css.hint}>
|
||||
{t(state.enabled ? 'subagentModelSelectionChoose' : 'subagentModelSelectionOff')}
|
||||
</p>
|
||||
</div>
|
||||
{state.enabled
|
||||
? (
|
||||
<div className={css.selection}>
|
||||
{state.catalogStatus === 'loading'
|
||||
? <p className={css.notice} role="status">{t('subagentModelSelectionLoading')}</p>
|
||||
: null}
|
||||
{state.catalogStatus === 'error'
|
||||
? (
|
||||
<div className={css.catalogError} role="alert">
|
||||
<span>{t('subagentModelSelectionLoadFailed')}</span>
|
||||
<button type="button" disabled={state.saving} onClick={props.retryCatalog}>
|
||||
{t('subagentModelSelectionRetry')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
{state.catalogPartial
|
||||
? <p className={css.notice}>{t('subagentModelSelectionPartial')}</p>
|
||||
: null}
|
||||
{state.candidates.length > 0
|
||||
? (
|
||||
<fieldset className={css.models}>
|
||||
<legend>{t('subagentModelSelectionAllowed')}</legend>
|
||||
{[...availableGroups].map(([provider, group]) => (
|
||||
<div key={provider} className={css.modelGroup}>
|
||||
<div className={css.providerName}>{group.providerName}</div>
|
||||
{group.candidates.map(renderCandidate)}
|
||||
</div>
|
||||
))}
|
||||
{unavailable.length > 0
|
||||
? (
|
||||
<div className={css.modelGroup}>
|
||||
<div className={css.providerName}>{t('subagentModelSelectionUnavailableGroup')}</div>
|
||||
{unavailable.map(renderCandidate)}
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
</fieldset>
|
||||
)
|
||||
: state.catalogStatus === 'ready'
|
||||
? <p className={css.notice}>{t('subagentModelSelectionEmpty')}</p>
|
||||
: null}
|
||||
{state.invalid ? <p className={css.invalid}>{t('subagentModelSelectionRequired')}</p> : null}
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
{state.conflicted
|
||||
? <p className={css.conflict} role="status">{t('subagentModelSelectionConflict')}</p>
|
||||
: null}
|
||||
</PluginCard>
|
||||
)
|
||||
}
|
||||
@@ -4,11 +4,12 @@
|
||||
*
|
||||
* The section declares `settings.plugins.tab`; its own `configurable` tab then
|
||||
* declares `settings.plugin.item` and renders whatever cards were registered
|
||||
* into it. The three cards this package ships are the host-plane sections the
|
||||
* into it. The cards this package ships are the host-plane sections the
|
||||
* deployment already exposes; each binds its namespace through the client
|
||||
* settings scope, which keeps them unaware of one another and of other tabs.
|
||||
*/
|
||||
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
// Type-only: the settings shell's SlotMap merge (the 'settings.section' entry)
|
||||
@@ -25,10 +26,14 @@ import { BashCard } from './BashCard.tsx'
|
||||
import { ConfigurablePluginsTab } from './ConfigurablePluginsTab.tsx'
|
||||
import { PluginsSettingsSection } from './PluginsSettingsSection.tsx'
|
||||
import type { PluginsSettingsSectionInjected, PluginsSettingsTabEntry } from './PluginsSettingsSection.tsx'
|
||||
import { SubagentModelSelectionCard } from './SubagentModelSelectionCard.tsx'
|
||||
import { WebSearchCard } from './WebSearchCard.tsx'
|
||||
import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-card-controller.ts'
|
||||
import { SHELL_NS, BashCardController } from './bash-card-controller.ts'
|
||||
import { ConfigurablePluginsTabController } from './tab-store.ts'
|
||||
import {
|
||||
SUBAGENT_MODEL_SELECTION_NS, SubagentModelSelectionCardController,
|
||||
} from './subagent-model-selection-card-controller.ts'
|
||||
import { WEB_SEARCH_NS, WebSearchCardController } from './web-search-card-controller.ts'
|
||||
import { en, zh } from './locales.ts'
|
||||
|
||||
@@ -49,13 +54,14 @@ export type { WebSearchCardFace, WebSearchCardState } from './web-search-card-co
|
||||
const NS = 'settings.plugins'
|
||||
|
||||
/** Required services (cordis fiber inject). */
|
||||
export const inject = ['slots', 'locale', 'remote', 'remote.credentials', 'settingsScope']
|
||||
export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.credentials', 'settingsScope']
|
||||
|
||||
/**
|
||||
* Mount the plugin configuration section and the cards this package ships.
|
||||
* @param ctx - the browser plugin context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const { api } = ctx.get('connection') as ConnectionHandle
|
||||
const t = ctx.locale.bind(NS)
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-plugins: section dictionaries')
|
||||
|
||||
@@ -63,6 +69,10 @@ export function apply(ctx: ClientContext): void {
|
||||
const agentLoop = new AgentLoopCardController(ctx.settingsScope.bind({ namespace: AGENT_LOOP_NS }))
|
||||
const webSearch = new WebSearchCardController(
|
||||
ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), ctx.remote.credentials)
|
||||
const subagentModelSelection = new SubagentModelSelectionCardController(
|
||||
ctx.settingsScope.bind({ namespace: SUBAGENT_MODEL_SELECTION_NS }),
|
||||
api,
|
||||
)
|
||||
|
||||
// The credential a card reports is not part of any settings section, so its
|
||||
// scope publishes nothing when one is written. This is the only signal that
|
||||
@@ -71,6 +81,19 @@ export function apply(ctx: ClientContext): void {
|
||||
() => ctx.remote.$on('credentials/reference-updated', (ref) => { webSearch.refreshCredential(ref) }),
|
||||
'ui-settings-plugins: credential invalidations',
|
||||
)
|
||||
ctx.effect(
|
||||
() => ctx.remote.$on('llm/adapters-updated', () => { subagentModelSelection.refreshCatalog() }),
|
||||
'ui-settings-plugins: subagent adapter invalidations',
|
||||
)
|
||||
ctx.effect(
|
||||
() => ctx.remote.$on('settings/document-updated', () => { subagentModelSelection.refreshCatalog() }),
|
||||
'ui-settings-plugins: subagent settings invalidations',
|
||||
)
|
||||
ctx.effect(
|
||||
() => ctx.on('connection/reset', () => { subagentModelSelection.resetConnection() }),
|
||||
'ui-settings-plugins: subagent connection generation',
|
||||
)
|
||||
ctx.effect(() => () => { subagentModelSelection.dispose() }, 'ui-settings-plugins: subagent preference')
|
||||
|
||||
// The shared SettingsScope mirror updates after document commits and reconnects.
|
||||
const configurable = new ConfigurablePluginsTabController(
|
||||
@@ -130,7 +153,7 @@ export function apply(ctx: ClientContext): void {
|
||||
}, PluginsSettingsSection))
|
||||
|
||||
// The existing configuration page is one ordinary tab. It keeps ownership
|
||||
// of the card slot and the three shipped card contributions below.
|
||||
// of the card slot and the shipped card contributions below.
|
||||
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
|
||||
name: 'settings.plugins.tab',
|
||||
id: 'configurable',
|
||||
@@ -154,6 +177,12 @@ export function apply(ctx: ClientContext): void {
|
||||
locale: NS,
|
||||
inject: () => agentLoop.inject(),
|
||||
}, AgentLoopCard)
|
||||
yield ctx.slots.register({
|
||||
name: 'settings.plugin.item',
|
||||
key: SUBAGENT_MODEL_SELECTION_NS,
|
||||
locale: NS,
|
||||
inject: () => subagentModelSelection.inject(),
|
||||
}, SubagentModelSelectionCard)
|
||||
yield ctx.slots.register({
|
||||
name: 'settings.plugin.item',
|
||||
key: WEB_SEARCH_NS,
|
||||
|
||||
@@ -11,6 +11,12 @@ export type PluginsSettingsLocaleKey =
|
||||
| 'webSearchTitle' | 'webSearchDescription'
|
||||
| 'webSearchApiKey' | 'webSearchApiKeyHint' | 'webSearchApiKeySet' | 'webSearchApiKeyUnset'
|
||||
| 'webSearchBaseUrl' | 'webSearchBaseUrlHint' | 'webSearchMaxUses' | 'webSearchMaxUsesHint'
|
||||
| 'subagentModelSelectionTitle' | 'subagentModelSelectionDescription'
|
||||
| 'subagentModelSelectionToggle' | 'subagentModelSelectionChoose' | 'subagentModelSelectionAllowed'
|
||||
| 'subagentModelSelectionLoading' | 'subagentModelSelectionLoadFailed' | 'subagentModelSelectionRetry'
|
||||
| 'subagentModelSelectionPartial' | 'subagentModelSelectionUnavailable'
|
||||
| 'subagentModelSelectionUnavailableGroup' | 'subagentModelSelectionEmpty'
|
||||
| 'subagentModelSelectionRequired' | 'subagentModelSelectionConflict' | 'subagentModelSelectionOff'
|
||||
|
||||
/** English copy. */
|
||||
export const en: Record<PluginsSettingsLocaleKey, string> = {
|
||||
@@ -51,6 +57,21 @@ export const en: Record<PluginsSettingsLocaleKey, string> = {
|
||||
webSearchBaseUrlHint: 'Leave blank to use the provider default.',
|
||||
webSearchMaxUses: 'Max searches per request',
|
||||
webSearchMaxUsesHint: 'How many times one request may search before it must answer.',
|
||||
subagentModelSelectionTitle: 'Subagent',
|
||||
subagentModelSelectionDescription: 'Control which models agents may choose for subagents.',
|
||||
subagentModelSelectionToggle: 'Allow agents to choose models for subagents',
|
||||
subagentModelSelectionChoose: 'When enabled, agents can choose a provider, model, and reasoning effort for each subagent from the authorized models below. Applies only to new sessions.',
|
||||
subagentModelSelectionAllowed: 'Models agents may choose',
|
||||
subagentModelSelectionLoading: 'Loading models…',
|
||||
subagentModelSelectionLoadFailed: 'Models could not be loaded.',
|
||||
subagentModelSelectionRetry: 'Retry',
|
||||
subagentModelSelectionPartial: 'Some model providers could not be loaded; saved choices remain removable.',
|
||||
subagentModelSelectionUnavailable: 'Currently unavailable',
|
||||
subagentModelSelectionUnavailableGroup: 'Saved but currently unavailable',
|
||||
subagentModelSelectionEmpty: 'No model provider currently advertises a model.',
|
||||
subagentModelSelectionRequired: 'Select at least one model before saving.',
|
||||
subagentModelSelectionConflict: 'Settings changed elsewhere. Discard your draft and try again.',
|
||||
subagentModelSelectionOff: 'Subagents use configured defaults or inherit the parent agent\'s model. Saved model choices are retained.',
|
||||
}
|
||||
|
||||
/** Simplified Chinese copy. */
|
||||
@@ -92,4 +113,19 @@ export const zh: Record<PluginsSettingsLocaleKey, string> = {
|
||||
webSearchBaseUrlHint: '留空则使用提供方默认地址。',
|
||||
webSearchMaxUses: '单次请求最多搜索次数',
|
||||
webSearchMaxUsesHint: '一次请求在必须作答前最多可以搜索多少次。',
|
||||
subagentModelSelectionTitle: 'Subagent',
|
||||
subagentModelSelectionDescription: '控制 Agent 为 Subagent 选择模型的权限。',
|
||||
subagentModelSelectionToggle: '允许 Agent 为 Subagent 选择模型',
|
||||
subagentModelSelectionChoose: '开启后,Agent 可以从下方授权模型中,为每个 Subagent 选择提供方、模型和推理强度。仅影响新会话。',
|
||||
subagentModelSelectionAllowed: 'Agent 可选择的模型',
|
||||
subagentModelSelectionLoading: '正在加载模型…',
|
||||
subagentModelSelectionLoadFailed: '无法加载模型。',
|
||||
subagentModelSelectionRetry: '重试',
|
||||
subagentModelSelectionPartial: '部分模型提供方暂时无法加载;已保存的选择仍可移除。',
|
||||
subagentModelSelectionUnavailable: '当前不可用',
|
||||
subagentModelSelectionUnavailableGroup: '已保存但当前不可用',
|
||||
subagentModelSelectionEmpty: '当前没有模型提供方公布模型。',
|
||||
subagentModelSelectionRequired: '保存前请至少选择一个模型。',
|
||||
subagentModelSelectionConflict: '设置已在其他位置更新。请放弃修改后重试。',
|
||||
subagentModelSelectionOff: '关闭后,Subagent 使用配置的默认模型或继承父 Agent 的模型;已选模型会保留。',
|
||||
}
|
||||
|
||||
+360
@@ -0,0 +1,360 @@
|
||||
/** Staged editor for the Host-owned subagent model allowlist. */
|
||||
|
||||
import type {
|
||||
IApiClient,
|
||||
ModelProviderGroup,
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { CardShell } from './card-form.ts'
|
||||
|
||||
/** Namespace of the Host-owned subagent model-selection preference. */
|
||||
export const SUBAGENT_MODEL_SELECTION_NS = 'subagent-model-selection'
|
||||
|
||||
/** One exact provider/model route stored as user authorization. */
|
||||
export interface AllowedSubagentModel {
|
||||
provider: string
|
||||
model: string
|
||||
}
|
||||
|
||||
/** Settings fields stored for subagent model selection. */
|
||||
export interface SubagentModelSelectionSettings {
|
||||
/** Whether model-facing child route selection applies to new Sessions. */
|
||||
enabled: boolean
|
||||
/** Exact child routes offered to newly composed top-level Sessions. */
|
||||
allowedModels: AllowedSubagentModel[]
|
||||
}
|
||||
|
||||
/** One catalog row joined with a stored route that may no longer be advertised. */
|
||||
export interface SubagentModelCandidate extends AllowedSubagentModel {
|
||||
/** Stable opaque identity used only for lookup. */
|
||||
key: string
|
||||
/** Adapter-owned provider display name. */
|
||||
providerName: string
|
||||
/** Adapter-owned model display name. */
|
||||
modelName: string
|
||||
/** Whether the current adapter catalog advertises this exact route. */
|
||||
available: boolean
|
||||
/** Whether the current draft authorizes this route. */
|
||||
selected: boolean
|
||||
}
|
||||
|
||||
/** State rendered by the staged allowlist card. */
|
||||
export interface SubagentModelSelectionCardState extends CardShell {
|
||||
/** Whether the draft enables model-facing child route selection. */
|
||||
enabled: boolean
|
||||
/** Live catalog joined with stored routes. */
|
||||
candidates: readonly SubagentModelCandidate[]
|
||||
/** Adapter-directory request state. */
|
||||
catalogStatus: 'idle' | 'loading' | 'ready' | 'error'
|
||||
/** Whether any provider-local catalog request failed. */
|
||||
catalogPartial: boolean
|
||||
/** Whether a newer Host revision invalidated the current draft. */
|
||||
conflicted: boolean
|
||||
}
|
||||
|
||||
/** Registration-side face for the subagent model-selection card. */
|
||||
export interface SubagentModelSelectionCardFace {
|
||||
hooks: {
|
||||
/** Card snapshot bound by the renderer as useSubagentModelSelectionCard. */
|
||||
subagentModelSelectionCard: SnapshotStore<SubagentModelSelectionCardState>
|
||||
}
|
||||
/** Stage the enabled state; enabling also loads the adapter directory. */
|
||||
toggleEnabled: () => void
|
||||
/** Stage one exact route as allowed or denied. */
|
||||
toggleModel: (key: string) => void
|
||||
/** Retry the adapter directory. */
|
||||
retryCatalog: () => void
|
||||
/** Persist the switch and exact routes as one revision-fenced mutation. */
|
||||
save: () => void
|
||||
/** Drop the staged enabled state and route choices. */
|
||||
discard: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable identity for one exact route; callers resolve it by lookup and never parse it.
|
||||
* @param route - Provider/model route to identify.
|
||||
* @returns Opaque key for lookup within the card.
|
||||
*/
|
||||
export function subagentModelKey(route: AllowedSubagentModel): string {
|
||||
return `${route.provider}\0${route.model}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Join live adapter metadata with stored routes that remain removable after disappearance.
|
||||
* @param groups - Current model directory grouped by provider.
|
||||
* @param stored - Routes in the effective settings value.
|
||||
* @param selected - Opaque route keys selected in the current draft.
|
||||
* @returns Candidate rows for the card.
|
||||
*/
|
||||
export function subagentModelCandidates(
|
||||
groups: readonly ModelProviderGroup[],
|
||||
stored: readonly AllowedSubagentModel[],
|
||||
selected: ReadonlySet<string>,
|
||||
): SubagentModelCandidate[] {
|
||||
const storedByKey = new Map(stored.map(route => [subagentModelKey(route), route]))
|
||||
const candidates = groups.flatMap(group => group.models.map((model): SubagentModelCandidate => {
|
||||
const route = { provider: group.id, model: model.id }
|
||||
const key = subagentModelKey(route)
|
||||
storedByKey.delete(key)
|
||||
return {
|
||||
...route,
|
||||
key,
|
||||
providerName: group.name,
|
||||
modelName: model.name,
|
||||
available: true,
|
||||
selected: selected.has(key),
|
||||
}
|
||||
}))
|
||||
for (const route of storedByKey.values()) {
|
||||
const key = subagentModelKey(route)
|
||||
candidates.push({
|
||||
...route,
|
||||
key,
|
||||
providerName: route.provider,
|
||||
modelName: route.model,
|
||||
available: false,
|
||||
selected: selected.has(key),
|
||||
})
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
function sameRoutes(left: readonly AllowedSubagentModel[], right: readonly AllowedSubagentModel[]): boolean {
|
||||
if (left.length !== right.length) return false
|
||||
const rightKeys = new Set(right.map(subagentModelKey))
|
||||
return left.every(route => rightKeys.has(subagentModelKey(route)))
|
||||
}
|
||||
|
||||
/** Bridges one settings scope and the live adapter directory onto a staged card. */
|
||||
export class SubagentModelSelectionCardController {
|
||||
private catalogGroups: readonly ModelProviderGroup[] = []
|
||||
private catalogPartial = false
|
||||
private catalogStatus: SubagentModelSelectionCardState['catalogStatus'] = 'idle'
|
||||
private draftEnabled: boolean | undefined
|
||||
private draftRoutes: Map<string, AllowedSubagentModel> | undefined
|
||||
private draftRevision: number | undefined
|
||||
private saving = false
|
||||
private failed = false
|
||||
private conflicted = false
|
||||
private disposed = false
|
||||
private saveGeneration = 0
|
||||
private catalogGeneration = 0
|
||||
private readonly store: SnapshotStore<SubagentModelSelectionCardState>
|
||||
private readonly unsubscribe: () => void
|
||||
|
||||
/**
|
||||
* @param scope - bound `subagent-model-selection` settings scope.
|
||||
* @param api - Host LLM directory face.
|
||||
*/
|
||||
constructor(
|
||||
private readonly scope: SettingsScope<SubagentModelSelectionSettings>,
|
||||
private readonly api: Pick<IApiClient, 'llm'>,
|
||||
) {
|
||||
this.store = createSnapshotStore(this.projection())
|
||||
this.unsubscribe = scope.subscribe(() => {
|
||||
if (!this.saving && this.draftRoutes !== undefined
|
||||
&& this.scope.getSnapshot().revision !== this.draftRevision) {
|
||||
if (this.currentEnabled() === this.enabled()
|
||||
&& sameRoutes(this.currentRoutes(), this.desiredRoutes())) this.clearDraft()
|
||||
else this.conflicted = true
|
||||
}
|
||||
if (this.enabled() && this.catalogStatus === 'idle') void this.loadCatalog()
|
||||
this.publish()
|
||||
})
|
||||
if (this.enabled() && this.catalogStatus === 'idle') void this.loadCatalog()
|
||||
}
|
||||
|
||||
/** Stop observing settings and suppress late directory/write settlements. */
|
||||
dispose(): void {
|
||||
this.disposed = true
|
||||
this.saveGeneration += 1
|
||||
this.catalogGeneration += 1
|
||||
this.unsubscribe()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the renderer face for this card.
|
||||
* @returns The snapshot and staged card actions injected into the renderer.
|
||||
*/
|
||||
inject(): SubagentModelSelectionCardFace {
|
||||
return {
|
||||
hooks: { subagentModelSelectionCard: this.store },
|
||||
toggleEnabled: () => { this.toggleEnabled() },
|
||||
toggleModel: (key) => { this.toggleModel(key) },
|
||||
retryCatalog: () => { void this.loadCatalog() },
|
||||
save: () => { void this.save() },
|
||||
discard: () => { this.discard() },
|
||||
}
|
||||
}
|
||||
|
||||
private currentRoutes(): AllowedSubagentModel[] {
|
||||
return this.scope.getSnapshot().value?.allowedModels.map(route => ({ ...route })) ?? []
|
||||
}
|
||||
|
||||
private currentEnabled(): boolean {
|
||||
return this.scope.getSnapshot().value?.enabled ?? false
|
||||
}
|
||||
|
||||
private selected(): Set<string> {
|
||||
return new Set(this.draftRoutes?.keys() ?? this.currentRoutes().map(subagentModelKey))
|
||||
}
|
||||
|
||||
private enabled(): boolean {
|
||||
return this.draftEnabled ?? this.currentEnabled()
|
||||
}
|
||||
|
||||
private beginDraft(): Map<string, AllowedSubagentModel> {
|
||||
if (this.draftRoutes === undefined) {
|
||||
const snapshot = this.scope.getSnapshot()
|
||||
this.draftEnabled = snapshot.value?.enabled ?? false
|
||||
this.draftRoutes = new Map(
|
||||
snapshot.value?.allowedModels.map(route => [subagentModelKey(route), { ...route }]) ?? [],
|
||||
)
|
||||
this.draftRevision = snapshot.revision
|
||||
}
|
||||
return this.draftRoutes
|
||||
}
|
||||
|
||||
private toggleEnabled(): void {
|
||||
const snapshot = this.scope.getSnapshot()
|
||||
if (this.disposed || snapshot.status !== 'ready' || !snapshot.writable || this.saving) return
|
||||
this.beginDraft()
|
||||
this.draftEnabled = !this.draftEnabled
|
||||
this.failed = false
|
||||
if (this.draftEnabled && this.catalogStatus === 'idle') void this.loadCatalog()
|
||||
this.publish()
|
||||
}
|
||||
|
||||
private toggleModel(key: string): void {
|
||||
if (!this.enabled() || this.saving || !this.scope.getSnapshot().writable) return
|
||||
const candidate = this.candidates().find(candidate => candidate.key === key)
|
||||
if (candidate === undefined) return
|
||||
const routes = this.beginDraft()
|
||||
if (routes.has(key)) routes.delete(key)
|
||||
else routes.set(key, { provider: candidate.provider, model: candidate.model })
|
||||
this.failed = false
|
||||
this.publish()
|
||||
}
|
||||
|
||||
private clearDraft(): void {
|
||||
this.draftEnabled = undefined
|
||||
this.draftRoutes = undefined
|
||||
this.draftRevision = undefined
|
||||
this.failed = false
|
||||
this.conflicted = false
|
||||
}
|
||||
|
||||
private discard(): void {
|
||||
if (this.saving) return
|
||||
this.clearDraft()
|
||||
this.publish()
|
||||
}
|
||||
|
||||
private candidates(): SubagentModelCandidate[] {
|
||||
const retained = new Map(this.currentRoutes().map(route => [subagentModelKey(route), route]))
|
||||
for (const [key, route] of this.draftRoutes ?? []) retained.set(key, route)
|
||||
return subagentModelCandidates(this.catalogGroups, [...retained.values()], this.selected())
|
||||
}
|
||||
|
||||
private desiredRoutes(): AllowedSubagentModel[] {
|
||||
return [...this.draftRoutes?.values() ?? this.currentRoutes()].map(route => ({ ...route }))
|
||||
}
|
||||
|
||||
private async save(): Promise<void> {
|
||||
const snapshot = this.scope.getSnapshot()
|
||||
const desiredEnabled = this.enabled()
|
||||
const desired = this.desiredRoutes()
|
||||
if (this.disposed || snapshot.status !== 'ready' || !snapshot.writable || this.saving
|
||||
|| (this.currentEnabled() === desiredEnabled && sameRoutes(this.currentRoutes(), desired))
|
||||
|| (desiredEnabled && desired.length === 0)) return
|
||||
if (this.draftRoutes !== undefined && snapshot.revision !== this.draftRevision) {
|
||||
this.conflicted = true
|
||||
this.publish()
|
||||
return
|
||||
}
|
||||
const generation = this.saveGeneration
|
||||
this.saving = true
|
||||
this.failed = false
|
||||
this.conflicted = false
|
||||
this.publish()
|
||||
await this.scope.mutate([
|
||||
{ op: 'set', path: ['enabled'], value: desiredEnabled },
|
||||
{
|
||||
op: 'set',
|
||||
path: ['allowedModels'],
|
||||
value: desired.map(route => ({ provider: route.provider, model: route.model })),
|
||||
},
|
||||
], this.draftRevision)
|
||||
if (generation !== this.saveGeneration) return
|
||||
const landed = this.currentEnabled() === desiredEnabled && sameRoutes(this.currentRoutes(), desired)
|
||||
this.saving = false
|
||||
this.failed = !landed
|
||||
if (landed) this.clearDraft()
|
||||
this.publish()
|
||||
}
|
||||
|
||||
/** Invalidate and reload model candidates after a Host model input changes. */
|
||||
refreshCatalog(): void {
|
||||
if (this.disposed) return
|
||||
this.catalogGeneration += 1
|
||||
this.catalogStatus = 'idle'
|
||||
this.catalogPartial = false
|
||||
if (this.enabled()) void this.loadCatalog()
|
||||
else this.publish()
|
||||
}
|
||||
|
||||
/** Drop Host-specific candidates and drafts, then reload after reconnecting. */
|
||||
resetConnection(): void {
|
||||
if (this.disposed) return
|
||||
this.saveGeneration += 1
|
||||
this.saving = false
|
||||
this.clearDraft()
|
||||
this.catalogGroups = []
|
||||
this.refreshCatalog()
|
||||
}
|
||||
|
||||
private async loadCatalog(): Promise<void> {
|
||||
if (this.disposed || this.catalogStatus === 'loading') return
|
||||
const generation = this.catalogGeneration
|
||||
this.catalogStatus = 'loading'
|
||||
this.catalogPartial = false
|
||||
this.publish()
|
||||
try {
|
||||
const response = await this.api.llm.models({})
|
||||
if (generation !== this.catalogGeneration) return
|
||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
||||
this.catalogGroups = response.result.value.groups
|
||||
this.catalogPartial = response.result.value.failures.length > 0
|
||||
this.catalogStatus = 'ready'
|
||||
} catch {
|
||||
if (generation !== this.catalogGeneration) return
|
||||
this.catalogStatus = 'error'
|
||||
}
|
||||
this.publish()
|
||||
}
|
||||
|
||||
private projection(): SubagentModelSelectionCardState {
|
||||
const snapshot = this.scope.getSnapshot()
|
||||
const current = this.currentRoutes()
|
||||
const desired = this.desiredRoutes()
|
||||
const enabled = this.enabled()
|
||||
return {
|
||||
available: snapshot.status === 'ready',
|
||||
writable: snapshot.writable,
|
||||
dirty: this.currentEnabled() !== enabled || !sameRoutes(current, desired),
|
||||
invalid: enabled && desired.length === 0,
|
||||
saving: this.saving,
|
||||
failed: this.failed,
|
||||
enabled,
|
||||
candidates: this.candidates(),
|
||||
catalogStatus: this.catalogStatus,
|
||||
catalogPartial: this.catalogPartial,
|
||||
conflicted: this.conflicted,
|
||||
}
|
||||
}
|
||||
|
||||
private publish(): void {
|
||||
this.store.set(this.projection())
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-plugins/clien
|
||||
import type {
|
||||
ConfigurablePluginsTabFace, PluginsSettingsSectionInjected,
|
||||
} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
|
||||
import { SubagentModelSelectionCardController } from '../src/client/subagent-model-selection-card-controller.ts'
|
||||
|
||||
// These specs assert the shipped Chinese copy. The lane has no jsdom `window`,
|
||||
// so browser-language detection never runs and a fresh LocaleRuntime opens on
|
||||
@@ -27,6 +28,9 @@ async function bench(served?: string[]) {
|
||||
locale.setLocale('zh')
|
||||
ctx.provide('locale', locale)
|
||||
const describeCredentials = vi.fn(() => Promise.resolve({ ok: false, error: { code: 'internal', message: 'no provider', details: {} } }))
|
||||
const models = vi.fn(() => Promise.resolve({
|
||||
rpcId: 'm', result: { ok: true, value: { groups: [], failures: [] } },
|
||||
}))
|
||||
const describeSettings = vi.fn(() => Promise.resolve(served === undefined
|
||||
? { ok: false, error: { code: 'internal', message: 'no provider', details: {} } }
|
||||
: {
|
||||
@@ -43,9 +47,14 @@ async function bench(served?: string[]) {
|
||||
credentials: { describe: describeCredentials, set: vi.fn() },
|
||||
settings: { describe: describeSettings },
|
||||
})
|
||||
ctx.provide('connection', { isLoopback: true, api: {} } as never)
|
||||
ctx.provide('connection', {
|
||||
isLoopback: true,
|
||||
api: { llm: { models } },
|
||||
} as never)
|
||||
await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
|
||||
return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, remote }
|
||||
return {
|
||||
ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, models, remote,
|
||||
}
|
||||
}
|
||||
|
||||
function declareRoot(slots: SlotRegistry): () => void {
|
||||
@@ -57,7 +66,7 @@ function declareRoot(slots: SlotRegistry): () => void {
|
||||
|
||||
describe('ui-settings-plugins apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.credentials', 'settingsScope'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.credentials', 'settingsScope'])
|
||||
})
|
||||
|
||||
it('registers one Plugins section and declares the tab and card slots', async () => {
|
||||
@@ -117,7 +126,7 @@ describe('ui-settings-plugins apply', () => {
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
expect(slots.entries('settings.plugin.item').map(entry => entry.options.key))
|
||||
.toEqual(['shell', 'agent-loop', 'web-search-deepseek'])
|
||||
.toEqual(['shell', 'agent-loop', 'subagent-model-selection', 'web-search-deepseek'])
|
||||
})
|
||||
|
||||
it('dispatches the served namespaces its cards claim, and no others', async () => {
|
||||
@@ -176,6 +185,23 @@ describe('ui-settings-plugins apply', () => {
|
||||
await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) })
|
||||
})
|
||||
|
||||
it('refreshes the subagent catalog after model inputs change or the connection resets', async () => {
|
||||
const refresh = vi.spyOn(SubagentModelSelectionCardController.prototype, 'refreshCatalog')
|
||||
const reset = vi.spyOn(SubagentModelSelectionCardController.prototype, 'resetConnection')
|
||||
const { ctx, slots, remote } = await bench(['subagent-model-selection'])
|
||||
declareRoot(slots)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
refresh.mockClear()
|
||||
reset.mockClear()
|
||||
|
||||
remote.emit('llm/adapters-updated', [])
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
remote.emit('settings/document-updated', ['llm-deepseek', 1])
|
||||
expect(refresh).toHaveBeenCalledTimes(2)
|
||||
ctx.emit('connection/reset')
|
||||
expect(reset).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ignores a credential change for a reference no card watches', async () => {
|
||||
const { ctx, slots, describeCredentials, remote } = await bench()
|
||||
declareRoot(slots)
|
||||
@@ -203,7 +229,7 @@ describe('ui-settings-plugins apply', () => {
|
||||
declareRoot(slots)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(slots.entries('settings.plugin.item')).toHaveLength(3)
|
||||
expect(slots.entries('settings.plugin.item')).toHaveLength(4)
|
||||
|
||||
await fiber.dispose()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/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'
|
||||
@@ -12,6 +12,8 @@ import { ConfigurablePluginsTab } from '../src/client/ConfigurablePluginsTab.tsx
|
||||
import type { ConfigurablePluginsTabProps } from '../src/client/ConfigurablePluginsTab.tsx'
|
||||
import { PluginsSettingsSection } from '../src/client/PluginsSettingsSection.tsx'
|
||||
import type { PluginsSettingsSectionProps, PluginsSettingsTabEntry } from '../src/client/PluginsSettingsSection.tsx'
|
||||
import { SubagentModelSelectionCard } from '../src/client/SubagentModelSelectionCard.tsx'
|
||||
import type { SubagentModelSelectionCardProps } from '../src/client/SubagentModelSelectionCard.tsx'
|
||||
import { WebSearchCard } from '../src/client/WebSearchCard.tsx'
|
||||
import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx'
|
||||
import type { AgentLoopCardState } from '../src/client/agent-loop-card-controller.ts'
|
||||
@@ -19,6 +21,7 @@ import type { BashCardState } from '../src/client/bash-card-controller.ts'
|
||||
import type { CardFieldState, CardShell } from '../src/client/card-form.ts'
|
||||
import type { ConfigurablePluginsTabState } from '../src/client/tab-store.ts'
|
||||
import type { WebSearchCardState } from '../src/client/web-search-card-controller.ts'
|
||||
import type { SubagentModelSelectionCardState } from '../src/client/subagent-model-selection-card-controller.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -66,7 +69,7 @@ function renderConfigurable(namespaces: string[], cards: Record<string, string>
|
||||
render(<ConfigurablePluginsTab {...props} />)
|
||||
}
|
||||
|
||||
function renderBash(state: Partial<BashCardState> = {}) {
|
||||
function renderBashCard(state: Partial<BashCardState> = {}) {
|
||||
const store = createSnapshotStore<BashCardState>({
|
||||
...settled,
|
||||
timeoutMs: field('60000'),
|
||||
@@ -76,6 +79,36 @@ function renderBash(state: Partial<BashCardState> = {}) {
|
||||
const actions = cardActions()
|
||||
const props = { ...actions, t, useBashCard: bindSnapshotSelector(store) } as unknown as BashCardProps
|
||||
render(<BashCard {...props} />)
|
||||
return { actions, store }
|
||||
}
|
||||
|
||||
function renderBash(state: Partial<BashCardState> = {}) {
|
||||
return renderBashCard(state).actions
|
||||
}
|
||||
|
||||
function renderSubagentModelSelection(state: Partial<SubagentModelSelectionCardState> = {}) {
|
||||
const store = createSnapshotStore<SubagentModelSelectionCardState>({
|
||||
...settled,
|
||||
enabled: false,
|
||||
candidates: [],
|
||||
catalogStatus: 'idle',
|
||||
catalogPartial: false,
|
||||
conflicted: false,
|
||||
...state,
|
||||
})
|
||||
const actions = {
|
||||
toggleEnabled: vi.fn(),
|
||||
toggleModel: vi.fn(),
|
||||
retryCatalog: vi.fn(),
|
||||
save: vi.fn(),
|
||||
discard: vi.fn(),
|
||||
}
|
||||
const props = {
|
||||
...actions,
|
||||
t,
|
||||
useSubagentModelSelectionCard: bindSnapshotSelector(store),
|
||||
} as unknown as SubagentModelSelectionCardProps
|
||||
render(<SubagentModelSelectionCard {...props} />)
|
||||
return actions
|
||||
}
|
||||
|
||||
@@ -292,6 +325,137 @@ describe('BashCard', () => {
|
||||
|
||||
expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull()
|
||||
})
|
||||
|
||||
it('collapses after a successful save settles', () => {
|
||||
const { actions, store } = renderBashCard({ dirty: true })
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
fireEvent.click(screen.getByRole('button', { name: en.save }))
|
||||
expect(actions.save).toHaveBeenCalledOnce()
|
||||
|
||||
act(() => { store.set({ ...store.getSnapshot(), saving: true }) })
|
||||
act(() => { store.set({ ...store.getSnapshot(), dirty: false, saving: false }) })
|
||||
|
||||
expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a failed save open', () => {
|
||||
const { store } = renderBashCard({ dirty: true })
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
fireEvent.click(screen.getByRole('button', { name: en.save }))
|
||||
|
||||
act(() => { store.set({ ...store.getSnapshot(), saving: true }) })
|
||||
act(() => { store.set({ ...store.getSnapshot(), failed: true, saving: false }) })
|
||||
|
||||
expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy()
|
||||
expect(screen.getByText(en.saveFailed)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubagentModelSelectionCard', () => {
|
||||
it('renders the default-off preference in its staged plugin card', () => {
|
||||
const actions = renderSubagentModelSelection()
|
||||
fireEvent.click(screen.getByText(en.subagentModelSelectionTitle))
|
||||
|
||||
const control = screen.getByRole('switch', { name: en.subagentModelSelectionToggle })
|
||||
expect(control.getAttribute('aria-checked')).toBe('false')
|
||||
fireEvent.click(control)
|
||||
|
||||
expect(actions.toggleEnabled).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('groups available adapter candidates by provider', () => {
|
||||
const actions = renderSubagentModelSelection({
|
||||
enabled: true,
|
||||
candidates: [
|
||||
{
|
||||
key: 'alpha\0fast',
|
||||
provider: 'alpha',
|
||||
model: 'fast',
|
||||
providerName: 'Alpha API',
|
||||
modelName: 'Fast',
|
||||
available: true,
|
||||
selected: true,
|
||||
},
|
||||
{
|
||||
key: 'alpha\0deep',
|
||||
provider: 'alpha',
|
||||
model: 'deep',
|
||||
providerName: 'Alpha API',
|
||||
modelName: 'Deep',
|
||||
available: true,
|
||||
selected: false,
|
||||
},
|
||||
],
|
||||
catalogStatus: 'ready',
|
||||
})
|
||||
fireEvent.click(screen.getByText(en.subagentModelSelectionTitle))
|
||||
|
||||
expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('true')
|
||||
expect(screen.getByText('Alpha API', { exact: true })).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /Fast/ }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /Deep/ }))
|
||||
expect(actions.toggleModel).toHaveBeenCalledWith('alpha\0fast')
|
||||
expect(actions.toggleModel).toHaveBeenCalledWith('alpha\0deep')
|
||||
})
|
||||
|
||||
it('renders directory progress, failures, unavailable routes, and validation', () => {
|
||||
renderSubagentModelSelection({ enabled: true, catalogStatus: 'loading', invalid: true })
|
||||
fireEvent.click(screen.getByText(en.subagentModelSelectionTitle))
|
||||
expect(screen.getByText(en.subagentModelSelectionLoading)).toBeTruthy()
|
||||
expect(screen.getByText(en.subagentModelSelectionRequired)).toBeTruthy()
|
||||
|
||||
cleanup()
|
||||
const errorActions = renderSubagentModelSelection({ enabled: true, catalogStatus: 'error' })
|
||||
fireEvent.click(screen.getByText(en.subagentModelSelectionTitle))
|
||||
fireEvent.click(screen.getByRole('button', { name: en.subagentModelSelectionRetry }))
|
||||
expect(errorActions.retryCatalog).toHaveBeenCalledOnce()
|
||||
|
||||
cleanup()
|
||||
renderSubagentModelSelection({
|
||||
enabled: true,
|
||||
catalogStatus: 'ready',
|
||||
catalogPartial: true,
|
||||
candidates: [{
|
||||
key: 'legacy\0old',
|
||||
provider: 'legacy',
|
||||
model: 'old',
|
||||
providerName: 'legacy',
|
||||
modelName: 'old',
|
||||
available: false,
|
||||
selected: true,
|
||||
}],
|
||||
})
|
||||
fireEvent.click(screen.getByText(en.subagentModelSelectionTitle))
|
||||
expect(screen.getByText(en.subagentModelSelectionPartial)).toBeTruthy()
|
||||
expect(screen.getByText(en.subagentModelSelectionUnavailable)).toBeTruthy()
|
||||
expect(screen.getByText(en.subagentModelSelectionUnavailableGroup)).toBeTruthy()
|
||||
|
||||
cleanup()
|
||||
renderSubagentModelSelection({ enabled: true, catalogStatus: 'ready' })
|
||||
fireEvent.click(screen.getByText(en.subagentModelSelectionTitle))
|
||||
expect(screen.getByText(en.subagentModelSelectionEmpty)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('distinguishes a stale draft from a rejected save', () => {
|
||||
renderSubagentModelSelection({ dirty: true, conflicted: true })
|
||||
fireEvent.click(screen.getByText(en.subagentModelSelectionTitle))
|
||||
|
||||
expect(screen.getByText(en.subagentModelSelectionConflict)).toBeTruthy()
|
||||
expect(screen.queryByText(en.saveFailed)).toBeNull()
|
||||
})
|
||||
|
||||
it('stays hidden when unavailable and disables writes when read-only', () => {
|
||||
renderSubagentModelSelection({ available: false })
|
||||
expect(screen.queryByText(en.subagentModelSelectionTitle)).toBeNull()
|
||||
|
||||
cleanup()
|
||||
const actions = renderSubagentModelSelection({ writable: false })
|
||||
fireEvent.click(screen.getByText(en.subagentModelSelectionTitle))
|
||||
const control = screen.getByRole('switch') as HTMLButtonElement
|
||||
expect(control.disabled).toBe(true)
|
||||
fireEvent.click(control)
|
||||
expect(actions.toggleEnabled).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentLoopCard', () => {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { CardForm, numberField, textField } from '../src/client/card-form.ts'
|
||||
import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-card-controller.ts'
|
||||
@@ -12,6 +13,11 @@ import {
|
||||
SettingsDescribeMirror, type SettingsMirrorSnapshot,
|
||||
} from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
|
||||
import { ConfigurablePluginsTabController } from '../src/client/tab-store.ts'
|
||||
import {
|
||||
SubagentModelSelectionCardController,
|
||||
subagentModelCandidates,
|
||||
type SubagentModelSelectionSettings,
|
||||
} from '../src/client/subagent-model-selection-card-controller.ts'
|
||||
import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-card-controller.ts'
|
||||
|
||||
/** Make the stub behave like a Host that accepts every write. */
|
||||
@@ -21,6 +27,18 @@ function acceptWrites<T>(host: StubSettingsScope<T>): void {
|
||||
host.set.mockImplementation((field: string, value: unknown) => {
|
||||
host.publish({ value: { ...section(), [field]: value } as T, user: { ...layer(), [field]: value } })
|
||||
})
|
||||
host.mutate.mockImplementation((ops: readonly SettingsPathOpView[]) => {
|
||||
const value = { ...section() }
|
||||
const user = { ...layer() }
|
||||
for (const op of ops) {
|
||||
const field = op.path[0]!
|
||||
if (op.op === 'set') {
|
||||
value[field] = op.value
|
||||
user[field] = op.value
|
||||
}
|
||||
}
|
||||
host.publish({ value: value as T, user })
|
||||
})
|
||||
host.unset.mockImplementation((field: string) => {
|
||||
const user = Object.fromEntries(Object.entries(layer()).filter(([key]) => key !== field))
|
||||
const base = host.scope.getSnapshot().base as Record<string, unknown> | undefined
|
||||
@@ -37,6 +55,34 @@ function credentialsApi(configured: boolean) {
|
||||
return { api: { describe, set } as never, describe, set }
|
||||
}
|
||||
|
||||
function modelsApi(options: {
|
||||
groups?: readonly {
|
||||
id: string
|
||||
name: string
|
||||
models: readonly { id: string; name: string }[]
|
||||
}[]
|
||||
failures?: readonly { id: string; name: string; message: string }[]
|
||||
error?: string
|
||||
} = {}) {
|
||||
const models = vi.fn(() => Promise.resolve({
|
||||
rpcId: 'm-1' as never,
|
||||
result: options.error === undefined
|
||||
? { ok: true as const, value: { groups: options.groups ?? [], failures: options.failures ?? [] } }
|
||||
: { ok: false as const, error: { code: 'internal_error' as never, message: options.error } },
|
||||
}))
|
||||
return { api: { llm: { models } } as never, models }
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: unknown) => void
|
||||
const promise = new Promise<T>((accept, fail) => {
|
||||
resolve = accept
|
||||
reject = fail
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
describe('CardForm', () => {
|
||||
function form() {
|
||||
const host = stubSettingsScope<Record<string, unknown>>()
|
||||
@@ -383,6 +429,431 @@ describe('AgentLoopCardController', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubagentModelSelectionCardController', () => {
|
||||
it('joins stored routes with the live catalog without dropping unavailable choices', () => {
|
||||
const candidates = subagentModelCandidates(
|
||||
[{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
|
||||
[{ provider: 'legacy', model: 'old' }],
|
||||
new Set(['legacy\0old']),
|
||||
)
|
||||
|
||||
expect(candidates).toEqual([
|
||||
{
|
||||
key: 'alpha\0fast', provider: 'alpha', model: 'fast', providerName: 'Alpha API',
|
||||
modelName: 'Fast', available: true, selected: false,
|
||||
},
|
||||
{
|
||||
key: 'legacy\0old', provider: 'legacy', model: 'old', providerName: 'legacy',
|
||||
modelName: 'old', available: false, selected: true,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('loads adapter models and saves the switch and routes atomically', async () => {
|
||||
const host = stubSettingsScope<SubagentModelSelectionSettings>()
|
||||
acceptWrites(host)
|
||||
const models = modelsApi({
|
||||
groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
|
||||
})
|
||||
const controller = new SubagentModelSelectionCardController(host.scope, models.api)
|
||||
host.publish({
|
||||
status: 'ready', writable: true, revision: 3,
|
||||
value: { enabled: false, allowedModels: [] }, user: {},
|
||||
})
|
||||
const face = controller.inject()
|
||||
|
||||
expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false)
|
||||
face.toggleEnabled()
|
||||
await vi.waitFor(() => {
|
||||
expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1)
|
||||
})
|
||||
face.toggleModel('alpha\0fast')
|
||||
face.save()
|
||||
await vi.waitFor(() => {
|
||||
expect(host.mutate).toHaveBeenCalledWith([
|
||||
{ op: 'set', path: ['enabled'], value: true },
|
||||
{ op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] },
|
||||
], 3)
|
||||
})
|
||||
|
||||
expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
|
||||
enabled: true,
|
||||
dirty: false,
|
||||
saving: false,
|
||||
failed: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('starts an empty draft when a ready test scope has no decoded value', () => {
|
||||
const host = stubSettingsScope<SubagentModelSelectionSettings>()
|
||||
const controller = new SubagentModelSelectionCardController(host.scope, modelsApi().api)
|
||||
host.publish({ status: 'ready', writable: true, revision: 0, value: undefined })
|
||||
const face = controller.inject()
|
||||
|
||||
face.toggleEnabled()
|
||||
|
||||
expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
|
||||
enabled: true, dirty: true, invalid: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the Host value and reports a rejected write', async () => {
|
||||
const host = stubSettingsScope<SubagentModelSelectionSettings>()
|
||||
const models = modelsApi({
|
||||
groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
|
||||
})
|
||||
const controller = new SubagentModelSelectionCardController(host.scope, models.api)
|
||||
host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} })
|
||||
const face = controller.inject()
|
||||
|
||||
face.toggleEnabled()
|
||||
await vi.waitFor(() => {
|
||||
expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1)
|
||||
})
|
||||
face.toggleModel('alpha\0fast')
|
||||
face.save()
|
||||
await vi.waitFor(() => {
|
||||
expect(face.hooks.subagentModelSelectionCard.getSnapshot().failed).toBe(true)
|
||||
})
|
||||
|
||||
expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
|
||||
enabled: true,
|
||||
dirty: true,
|
||||
saving: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('loads stored routes, stages removal and disablement, and discards both', async () => {
|
||||
const host = stubSettingsScope<SubagentModelSelectionSettings>()
|
||||
const models = modelsApi({
|
||||
groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
|
||||
failures: [{ id: 'beta', name: 'Beta', message: 'offline' }],
|
||||
})
|
||||
const controller = new SubagentModelSelectionCardController(host.scope, models.api)
|
||||
host.publish({
|
||||
status: 'ready', writable: true, revision: 5,
|
||||
value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {},
|
||||
})
|
||||
const face = controller.inject()
|
||||
const state = () => face.hooks.subagentModelSelectionCard.getSnapshot()
|
||||
await vi.waitFor(() => { expect(state().catalogStatus).toBe('ready') })
|
||||
expect(state().catalogPartial).toBe(true)
|
||||
|
||||
face.toggleModel('missing')
|
||||
expect(state().dirty).toBe(false)
|
||||
face.toggleModel('alpha\0fast')
|
||||
expect(state()).toMatchObject({ dirty: true, invalid: true })
|
||||
face.discard()
|
||||
expect(state()).toMatchObject({ dirty: false, invalid: false, enabled: true })
|
||||
|
||||
face.toggleEnabled()
|
||||
expect(state()).toMatchObject({ dirty: true, enabled: false })
|
||||
face.toggleEnabled()
|
||||
expect(state()).toMatchObject({ dirty: false, enabled: true })
|
||||
})
|
||||
|
||||
it('retains selected routes when disabling and loads an already-ready enabled card', async () => {
|
||||
const host = stubSettingsScope<SubagentModelSelectionSettings>()
|
||||
acceptWrites(host)
|
||||
host.publish({
|
||||
status: 'ready', writable: true, revision: 5,
|
||||
value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {},
|
||||
})
|
||||
const models = modelsApi({
|
||||
groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
|
||||
})
|
||||
const controller = new SubagentModelSelectionCardController(host.scope, models.api)
|
||||
const face = controller.inject()
|
||||
await vi.waitFor(() => { expect(models.models).toHaveBeenCalledOnce() })
|
||||
|
||||
face.toggleEnabled()
|
||||
face.save()
|
||||
await vi.waitFor(() => {
|
||||
expect(host.mutate).toHaveBeenCalledWith([
|
||||
{ op: 'set', path: ['enabled'], value: false },
|
||||
{ op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] },
|
||||
], 5)
|
||||
})
|
||||
expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
|
||||
enabled: false, dirty: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a directory error and retries it', async () => {
|
||||
const host = stubSettingsScope<SubagentModelSelectionSettings>()
|
||||
const models = modelsApi({ error: 'offline' })
|
||||
const controller = new SubagentModelSelectionCardController(host.scope, models.api)
|
||||
host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} })
|
||||
const face = controller.inject()
|
||||
const state = () => face.hooks.subagentModelSelectionCard.getSnapshot()
|
||||
|
||||
face.toggleEnabled()
|
||||
await vi.waitFor(() => { expect(state().catalogStatus).toBe('error') })
|
||||
face.retryCatalog()
|
||||
await vi.waitFor(() => { expect(models.models).toHaveBeenCalledTimes(2) })
|
||||
})
|
||||
|
||||
it('rejects a draft after the Host revision changes', async () => {
|
||||
const host = stubSettingsScope<SubagentModelSelectionSettings>()
|
||||
const models = modelsApi({
|
||||
groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
|
||||
})
|
||||
const controller = new SubagentModelSelectionCardController(host.scope, models.api)
|
||||
host.publish({
|
||||
status: 'ready', writable: true, revision: 4,
|
||||
value: { enabled: false, allowedModels: [] }, user: {},
|
||||
})
|
||||
const face = controller.inject()
|
||||
face.toggleEnabled()
|
||||
await vi.waitFor(() => {
|
||||
expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1)
|
||||
})
|
||||
face.toggleModel('alpha\0fast')
|
||||
|
||||
host.publish({
|
||||
revision: 5,
|
||||
value: { enabled: true, allowedModels: [{ provider: 'other', model: 'new' }] },
|
||||
})
|
||||
expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
|
||||
conflicted: true, failed: false, dirty: true,
|
||||
})
|
||||
face.save()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(host.mutate).not.toHaveBeenCalled()
|
||||
face.discard()
|
||||
expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
|
||||
conflicted: false, failed: false, dirty: false, enabled: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('settles a draft when a newer Host revision already contains it', async () => {
|
||||
const host = stubSettingsScope<SubagentModelSelectionSettings>()
|
||||
const models = modelsApi({
|
||||
groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }],
|
||||
})
|
||||
const controller = new SubagentModelSelectionCardController(host.scope, models.api)
|
||||
host.publish({
|
||||
status: 'ready', writable: true, revision: 4,
|
||||
value: { enabled: false, allowedModels: [] }, user: {},
|
||||
})
|
||||
const face = controller.inject()
|
||||
face.toggleEnabled()
|
||||
await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) })
|
||||
face.toggleModel('alpha\0fast')
|
||||
|
||||
host.publish({
|
||||
revision: 5,
|
||||
value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] },
|
||||
})
|
||||
|
||||
expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
|
||||
conflicted: false, dirty: false, enabled: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('retains unsaved routes across a catalog refresh', async () => {
|
||||
const host = stubSettingsScope<SubagentModelSelectionSettings>()
|
||||
acceptWrites(host)
|
||||
host.publish({
|
||||
status: 'ready', writable: true, revision: 2,
|
||||
value: { enabled: false, allowedModels: [] }, user: {},
|
||||
})
|
||||
const refreshed = deferred<never>()
|
||||
const models = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
rpcId: 'catalog-1',
|
||||
result: { ok: true, value: {
|
||||
groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }],
|
||||
failures: [],
|
||||
} },
|
||||
})
|
||||
.mockImplementationOnce(() => refreshed.promise)
|
||||
const controller = new SubagentModelSelectionCardController(
|
||||
host.scope, { llm: { models } } as never,
|
||||
)
|
||||
const face = controller.inject()
|
||||
const state = () => face.hooks.subagentModelSelectionCard.getSnapshot()
|
||||
face.toggleEnabled()
|
||||
await vi.waitFor(() => { expect(state().candidates).toHaveLength(1) })
|
||||
face.toggleModel('alpha\0fast')
|
||||
|
||||
controller.refreshCatalog()
|
||||
expect(state()).toMatchObject({
|
||||
catalogStatus: 'loading',
|
||||
candidates: [expect.objectContaining({ key: 'alpha\0fast', selected: true })],
|
||||
})
|
||||
refreshed.resolve({
|
||||
rpcId: 'catalog-2',
|
||||
result: { ok: true, value: { groups: [], failures: [] } },
|
||||
} as never)
|
||||
await vi.waitFor(() => { expect(state().catalogStatus).toBe('ready') })
|
||||
expect(state().candidates).toEqual([
|
||||
expect.objectContaining({ key: 'alpha\0fast', available: false, selected: true }),
|
||||
])
|
||||
|
||||
face.save()
|
||||
await vi.waitFor(() => {
|
||||
expect(host.mutate).toHaveBeenCalledWith([
|
||||
{ op: 'set', path: ['enabled'], value: true },
|
||||
{ op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] },
|
||||
], 2)
|
||||
})
|
||||
})
|
||||
|
||||
it('drops a draft when the connection generation changes', async () => {
|
||||
const host = stubSettingsScope<SubagentModelSelectionSettings>()
|
||||
const models = modelsApi({
|
||||
groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }],
|
||||
})
|
||||
host.publish({
|
||||
status: 'ready', writable: true, revision: 4,
|
||||
value: { enabled: false, allowedModels: [] }, user: {},
|
||||
})
|
||||
const controller = new SubagentModelSelectionCardController(host.scope, models.api)
|
||||
const face = controller.inject()
|
||||
face.toggleEnabled()
|
||||
await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) })
|
||||
face.toggleModel('alpha\0fast')
|
||||
|
||||
controller.resetConnection()
|
||||
host.publish({
|
||||
revision: 4,
|
||||
value: { enabled: true, allowedModels: [{ provider: 'other', model: 'new' }] },
|
||||
})
|
||||
|
||||
expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
|
||||
conflicted: false, dirty: false, enabled: true,
|
||||
})
|
||||
face.save()
|
||||
await Promise.resolve()
|
||||
expect(host.mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reloads the model catalog after invalidation', async () => {
|
||||
const host = stubSettingsScope<SubagentModelSelectionSettings>()
|
||||
host.publish({
|
||||
status: 'ready', writable: true, revision: 1,
|
||||
value: { enabled: true, allowedModels: [] }, user: {},
|
||||
})
|
||||
const models = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
rpcId: 'catalog-1',
|
||||
result: { ok: true, value: {
|
||||
groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }],
|
||||
failures: [],
|
||||
} },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
rpcId: 'catalog-2',
|
||||
result: { ok: true, value: {
|
||||
groups: [{ id: 'beta', name: 'Beta', models: [{ id: 'new', name: 'New' }] }],
|
||||
failures: [],
|
||||
} },
|
||||
})
|
||||
const controller = new SubagentModelSelectionCardController(
|
||||
host.scope, { llm: { models } } as never,
|
||||
)
|
||||
const state = () => controller.inject().hooks.subagentModelSelectionCard.getSnapshot()
|
||||
await vi.waitFor(() => { expect(state().candidates[0]?.provider).toBe('alpha') })
|
||||
|
||||
controller.refreshCatalog()
|
||||
|
||||
await vi.waitFor(() => { expect(state().candidates[0]?.provider).toBe('beta') })
|
||||
expect(models).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('suppresses duplicate actions and late save settlements', async () => {
|
||||
const host = stubSettingsScope<SubagentModelSelectionSettings>()
|
||||
const catalog = modelsApi({
|
||||
groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
|
||||
})
|
||||
const write = deferred<undefined>()
|
||||
const mutate = vi.fn(async (ops: readonly SettingsPathOpView[]) => {
|
||||
await write.promise
|
||||
const enabled = ops.find(op => op.path[0] === 'enabled')
|
||||
const allowedModels = ops.find(op => op.path[0] === 'allowedModels')
|
||||
host.publish({ value: {
|
||||
enabled: enabled?.op === 'set' ? enabled.value as boolean : false,
|
||||
allowedModels: allowedModels?.op === 'set' ? allowedModels.value as never[] : [],
|
||||
} })
|
||||
})
|
||||
const controller = new SubagentModelSelectionCardController({ ...host.scope, mutate }, catalog.api)
|
||||
const face = controller.inject()
|
||||
|
||||
face.save()
|
||||
face.toggleModel('alpha\0fast')
|
||||
host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} })
|
||||
face.save()
|
||||
face.toggleEnabled()
|
||||
await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().catalogStatus).toBe('ready') })
|
||||
face.save()
|
||||
face.toggleModel('alpha\0fast')
|
||||
face.save()
|
||||
expect(face.hooks.subagentModelSelectionCard.getSnapshot().saving).toBe(true)
|
||||
face.toggleEnabled()
|
||||
face.toggleModel('alpha\0fast')
|
||||
face.save()
|
||||
face.discard()
|
||||
controller.dispose()
|
||||
write.resolve(undefined)
|
||||
await write.promise
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('suppresses duplicate directory loads and late resolve or reject settlements', async () => {
|
||||
const host = stubSettingsScope<SubagentModelSelectionSettings>()
|
||||
host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} })
|
||||
|
||||
const pending = deferred<never>()
|
||||
const models = vi.fn(() => pending.promise)
|
||||
const controller = new SubagentModelSelectionCardController(host.scope, { llm: { models } } as never)
|
||||
const face = controller.inject()
|
||||
face.toggleEnabled()
|
||||
face.retryCatalog()
|
||||
expect(models).toHaveBeenCalledOnce()
|
||||
controller.dispose()
|
||||
pending.reject(new Error('late failure'))
|
||||
await pending.promise.catch(() => undefined)
|
||||
|
||||
const pendingResolve = deferred<never>()
|
||||
const resolving = new SubagentModelSelectionCardController(
|
||||
host.scope,
|
||||
{ llm: { models: () => pendingResolve.promise } } as never,
|
||||
)
|
||||
const resolvingFace = resolving.inject()
|
||||
resolvingFace.toggleEnabled()
|
||||
resolving.dispose()
|
||||
pendingResolve.resolve({
|
||||
rpcId: 'late' as never,
|
||||
result: { ok: true, value: { groups: [], failures: [] } },
|
||||
} as never)
|
||||
await pendingResolve.promise
|
||||
})
|
||||
|
||||
it('ignores writes while read-only and scope notifications after disposal', () => {
|
||||
const host = stubSettingsScope<SubagentModelSelectionSettings>()
|
||||
const controller = new SubagentModelSelectionCardController(host.scope, modelsApi().api)
|
||||
host.publish({ status: 'ready', writable: false, value: { enabled: false, allowedModels: [] }, user: {} })
|
||||
const face = controller.inject()
|
||||
|
||||
face.toggleEnabled()
|
||||
face.toggleModel('alpha\0fast')
|
||||
face.save()
|
||||
expect(host.mutate).not.toHaveBeenCalled()
|
||||
|
||||
controller.dispose()
|
||||
controller.refreshCatalog()
|
||||
controller.resetConnection()
|
||||
face.toggleEnabled()
|
||||
face.retryCatalog()
|
||||
face.save()
|
||||
host.publish({ value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] } })
|
||||
expect(host.mutate).not.toHaveBeenCalled()
|
||||
expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebSearchCardController', () => {
|
||||
it('reads the credential state for the reference the tab names', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-settings/README.md
|
||||
README.md: a9a45d90eaa46502829ee6d2c1793b7dadb1f0a5
|
||||
README.zh.md: bd615cbb5a3236370b7bbc9fc735deeab6efd8ce
|
||||
README.md: 3e4970bff9784a80716a073bf6d7f9f3e62889e5
|
||||
README.zh.md: a527dfe21a5c756183ffb4022ea0a8f3290f9dc5
|
||||
|
||||
@@ -29,7 +29,7 @@ Feature plugins use this package to store and edit their preferences without re-
|
||||
|
||||
### Binding a namespace
|
||||
|
||||
A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: one field path fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten.
|
||||
A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: `set` and `unset` submit one operation, while `mutate` submits several ordered operations atomically. Each write is fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten. A staged editor can supply the revision where its draft began as a fixed fence; otherwise the scope uses the latest queued or mirrored revision.
|
||||
|
||||
### Filling the settings slots
|
||||
|
||||
@@ -55,7 +55,7 @@ The plugin injects `connection` and `remote` and owns the one `settings.describe
|
||||
|
||||
### Scope derivation
|
||||
|
||||
`ctx.settingsScope.bind(spec)` returns a per-namespace scope derived from the mirror on the caller's context: the scope's disposer belongs to the calling fiber, binding adds no wire read, and a row's activation never blocks on the settings transport. Writes stay per-scope with the namespace revision as `expectedRevision`; a committed write folds its answer in, a rejected or failed latest write triggers one recovery read, and a superseded one leaves recovery to its successor. The cold-boot read count is pinned by `../../../apps/web/tests/startup-rpc-budget.e2e.ts`; a new direct `settings.describe` caller in client code is a regression against it.
|
||||
`ctx.settingsScope.bind(spec)` returns a per-namespace scope derived from the mirror on the caller's context: the scope's disposer belongs to the calling fiber, binding adds no wire read, and a row's activation never blocks on the settings transport. Writes stay per-scope: `set` and `unset` are single-operation forms of `mutate`, which copies and queues several ordered field operations behind one namespace revision as `expectedRevision`. A committed mutation folds its answer in, a rejected or failed latest mutation triggers one recovery read, and a superseded one leaves recovery to its successor. The cold-boot read count is pinned by `../../../apps/web/tests/startup-rpc-budget.e2e.ts`; a new direct `settings.describe` caller in client code is a regression against it.
|
||||
|
||||
### Schema service
|
||||
|
||||
@@ -95,7 +95,6 @@ None; this package neither assembles nor sends a provider request.
|
||||
These limits define where the settings transport cannot reach; they are current package constraints.
|
||||
|
||||
- **Non-loopback pages get no durable settings** — this Client keeps Host persistence disabled there, so a scope starts `unavailable` and never crosses the wire; every row it backs is inert even though Connection authentication covers the API.
|
||||
- **One field per write** — `set` sends a single `set` op, so a row that must move two fields together has no transaction and publishes two revisions.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
@@ -29,7 +29,7 @@ kind: "package-reference"
|
||||
|
||||
### 绑定命名空间
|
||||
|
||||
功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:单一字段路径以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。
|
||||
功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:`set` 与 `unset` 提交一个操作,`mutate` 则原子提交多个有序操作。每次写入都以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。暂存编辑器可以把开始草拟时读取的 revision 作为固定围栏传入;否则 scope 使用最新排队或镜像 revision。
|
||||
|
||||
### 填充设置 slot
|
||||
|
||||
@@ -55,7 +55,7 @@ kind: "package-reference"
|
||||
|
||||
### Scope 派生
|
||||
|
||||
`ctx.settingsScope.bind(spec)` 在调用方的 context 上返回一个由镜像派生的按命名空间 scope:scope 的 disposer 归调用方 fiber 所有,绑定不新增任何线路读取,某一行的激活绝不会阻塞在设置传输层上。写入仍归各 scope,以命名空间 revision 作为 `expectedRevision` 围栏;提交成功的写入把应答折回镜像,被拒绝或失败的最新写入触发一次恢复读取,被取代的写入把恢复留给后继者。冷启动读取次数由 `../../../apps/web/tests/startup-rpc-budget.e2e.ts` 钉住;客户端代码中新增直连 `settings.describe` 调用即是对它的回归。
|
||||
`ctx.settingsScope.bind(spec)` 在调用方的 context 上返回一个由镜像派生的按命名空间 scope:scope 的 disposer 归调用方 fiber 所有,绑定不新增任何线路读取,某一行的激活绝不会阻塞在设置传输层上。写入仍归各 scope:`set` 与 `unset` 是 `mutate` 的单操作形式,后者会复制操作列表,并把多个有序字段操作排在同一个作为 `expectedRevision` 的命名空间 revision 之后。提交成功的 mutation 把应答折回镜像,被拒绝或失败的最新 mutation 触发一次恢复读取,被取代的 mutation 把恢复留给后继者。冷启动读取次数由 `../../../apps/web/tests/startup-rpc-budget.e2e.ts` 钉住;客户端代码中新增直连 `settings.describe` 调用即是对它的回归。
|
||||
|
||||
### Schema 服务
|
||||
|
||||
@@ -95,7 +95,6 @@ kind: "package-reference"
|
||||
这些限制说明设置传输层够不到的地方;它们是当前包约束。
|
||||
|
||||
- **非 loopback 页面没有持久化设置**:本 Client 在那里禁用 Host 持久化,因此 scope 以 `unavailable` 起步且从不跨线路;尽管 Connection 认证覆盖 API,它支撑的每一行仍在那里无效。
|
||||
- **每次写入仅一个字段**:`set` 只发送单个 `set` op,因此需要同时改动两个字段的行没有事务可用,会发布两个 revision。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user