Merge branch 'master' into worktree/web-clock-flake-20260926
This commit is contained in:
commit
ef2c281e6e
339 files changed
+6408
-934
No files matched your search
+10
-10
@@ -3,17 +3,17 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.md
|
||||
/agent-note-schedule-as-an-opt-in-optional-bundle:
|
||||
en: d234bf064c5990f1
|
||||
zh: c54b941429b4658c
|
||||
en: 8c2381fd778a9888
|
||||
zh: 8877255cb980eea1
|
||||
/agent-note-schedule-as-an-opt-in-optional-bundle/problem:
|
||||
en: 2409ea3a006ea5d9
|
||||
zh: dbcda58a4c6b5b9f
|
||||
en: bb23c2f7f93a462b
|
||||
zh: 9fb600fc8e203323
|
||||
/agent-note-schedule-as-an-opt-in-optional-bundle/decision:
|
||||
en: 5aa944749a231645
|
||||
zh: c14651be6367ee13
|
||||
en: 483d1dd51b9cc073
|
||||
zh: b069e2e2385c8132
|
||||
/agent-note-schedule-as-an-opt-in-optional-bundle/alternatives-considered:
|
||||
en: 1782c8ecdbe6bf87
|
||||
zh: dd6997d067257562
|
||||
en: 8e5f4d8fa8c2e78d
|
||||
zh: 3353c7042c6a1d6b
|
||||
/agent-note-schedule-as-an-opt-in-optional-bundle/consequences:
|
||||
en: 8ea1d9aaf25f1702
|
||||
zh: 32e721347ea55475
|
||||
en: 36cd0eb71abeedbe
|
||||
zh: 7c134095cc19fcfd
|
||||
+9
-8
@@ -4,19 +4,17 @@ Status: implemented
|
||||
|
||||
English | [中文](2026-09-24-schedule-opt-in-optional-bundle.zh.md)
|
||||
|
||||
> Supersedes [Schedule in the shipped Web composition](../../archived/architecture/2026-09-24-web-default-schedule-composition.md): the three rows now ship disabled, and switching them on is this bundle's job.
|
||||
|
||||
## Problem
|
||||
|
||||
`packages/bundle/web-app/cordis.patch.yml` inserts `time-context`, `schedule`, and `ui-schedule` with `disabled: true`, so the shipped Web composition exposes no Schedule surface: no `schedule_*` tools, no Session reminder catalog, no Automation tasks page, and no per-step clock reading. The rows have no switch on the product surface; a deployment that wants them edits a profile patch layer or passes a `--patch` overlay, and both are configuration files a person using the shipped Web profile cannot reach.
|
||||
Schedule adds four tool schemas to every live root Agent request and one durable clock message per eligible step, so the shipped Web composition must not mount it by default. A person using the shipped Web profile still needs a product-surface switch to turn it on; a profile patch layer or a `--patch` overlay is a configuration file that person cannot reach.
|
||||
|
||||
## Decision
|
||||
|
||||
The rows stay in the composition because an id-targeted patch overrides an existing row: a profile patch or a bundle sets a row's `disabled` to `false` without inserting it again.
|
||||
`packages/bundle/web-app/cordis.patch.yml` carries none of `time-context`, `schedule`, and `ui-schedule`, so the shipped Web composition exposes no Schedule surface: no `schedule_*` tools, no Session reminder catalog, no Automation tasks page, and no per-step clock reading.
|
||||
|
||||
`@deepseek-ai/dsh-experimental-schedule-bundle` (`packages/experimental/schedule-bundle/`) carries three id-targeted patches in its `cordis.patch.yml`, one per row, each setting `disabled: false`; it inserts no row. `OPTIONAL_BUNDLES` in `packages/boot/app-boot/src/profile.ts` names the package, and `apps/cli` declares it as a runtime dependency, so every installation ships it switched off. The [experimental-as-optional-bundles decision](2026-09-21-experimental-capabilities-as-optional-bundles.md) owns the `OPTIONAL_BUNDLES` conventions and the localized `icon` and `meta.title` / `meta.description` metadata the Web Plugins page renders in its Official group.
|
||||
`@deepseek-ai/dsh-experimental-schedule-bundle` (`packages/experimental/schedule-bundle/`) inserts the three rows in its `cordis.patch.yml` and depends on their packages, as every other optional bundle inserts the rows it ships. `OPTIONAL_BUNDLES` in `packages/boot/app-boot/src/profile.ts` names the package, and `apps/cli` declares it as a runtime dependency, so every installation ships it switched off. The [experimental-as-optional-bundles decision](2026-09-21-experimental-capabilities-as-optional-bundles.md) owns the `OPTIONAL_BUNDLES` conventions and the localized `icon` and `meta.title` / `meta.description` metadata the Web Plugins page renders in its Official group.
|
||||
|
||||
Enabling the bundle turns on `time-context` (a per-step clock reading with the sampled instant, the browser zone attached to the open request, and the elapsed time since the preceding model-visible message), `schedule` (durable reminders plus `schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete` on live root Agents), and `ui-schedule` (the Session reminder catalog and the Automation tasks page). The bundle switches `time-context` on with `schedule` because a reminder request states a wall-clock target: the sampled instant is what lets the model turn a request such as "tomorrow at nine" into an offset-bearing `at` value. The [Schedule subsystem](../../../../docs/subsystems/schedule.md) owns the durable records, delivery, and management operations; the [Web bundle](../../../../packages/bundle/web-app/README.md) owns the composition that carries the disabled rows. Disabling the bundle restores the shipped composition, and the Host stops scheduling while the `schedule` row stays disabled; stored task records remain in the Schedule domain.
|
||||
Enabling the bundle inserts `time-context` (a per-step clock reading with the sampled instant, the browser zone attached to the open request, and the elapsed time since the preceding model-visible message), `schedule` (durable reminders plus `schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete` on live root Agents), and `ui-schedule` (the Session reminder catalog and the Automation tasks page). The bundle carries `time-context` with `schedule` because a reminder request states a wall-clock target: the sampled instant is what lets the model turn a request such as "tomorrow at nine" into an offset-bearing `at` value. The [Schedule subsystem](../../../../docs/subsystems/schedule.md) owns the durable records, delivery, and management operations; the [Web bundle](../../../../packages/bundle/web-app/README.md) owns the composition the bundle adds the rows to. Disabling the bundle restores the shipped composition, and the Host stops scheduling while the `schedule` row is absent; stored task records remain in the Schedule domain.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -24,11 +22,14 @@ Enabling the bundle turns on `time-context` (a per-step clock reading with the s
|
||||
|
||||
**Keep a `--patch` overlay file.** An overlay is a launch-time argument rather than a product-surface switch, so a person using the shipped Web profile cannot reach it. Re-declaring the rows through its `insert` list does not override them: `applyEntryPatches` appends that list without de-duplicating ids, and the Loader collapses the composed list to one entry per id with the last declaration winning, so the overlay replaces the Web bundle's rows instead of setting fields on them.
|
||||
|
||||
**Extract the three rows out of the Web composition into the bundle.** The bundle would insert the rows instead of overriding them, so a profile that also declares one of those ids would have two declarations for it, of which the Loader keeps only the last. The disabled rows are what an override-style switch targets.
|
||||
**Keep the rows in the Web composition with `disabled: true` and switch them on with id-targeted patches.** The bundle would then set fields on existing rows, but the plugin manager lists only the rows a bundle inserts, so the bundle's page reported no components and needed a second listing path for overridden rows, while every other optional bundle inserts its rows. A profile that also declares one of the ids resolves the same way as for any other bundle: the Loader keeps the last declaration.
|
||||
|
||||
**Lock the three rows to the bundle switch.** A bundle manifest field that makes the plugin manager refuse to switch the rows one by one adds a plugin-manager concept only this bundle would use. The bundle's page offers a switch per row, as for every other bundle, and the bundle README records that the three rows work only together.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A default `dsh web` session carries no Schedule tool schemas, no Session reminder catalog, no Automation tasks page, and no per-step clock reading. An installation that wants them enables the optional bundle from the Plugins page or lists the package in a profile's `dsh.profile.bundles`.
|
||||
- An enabled installation adds four tool schemas to every live root Agent and one durable clock message per eligible step; the reading is model-visible and durable, so it replays, compacts, and appears in exported Session logs like any other user message.
|
||||
- A composition that does not carry the three Web rows reports one `patch: entry <id> not found` warning per patch and mounts nothing from this bundle, and its package declares no plugin dependency, so enabling it cannot mount a Host service twice.
|
||||
- The Plugins page lists the three rows under the bundle with the titles their packages' `locale/*.json` declare, their state, and, while the bundle is on, a switch per row; switching one of them off leaves Schedule without that part.
|
||||
- A profile patch or `--patch` overlay that targets one of the three ids matches no row while the bundle is not selected, and the loader warns `patch: entry <id> not found` for it; selecting the bundle replaces switching the rows on by id.
|
||||
- The switch is configuration-only: `src/index.ts` is an empty module, the patch carries the runtime content, and the package owns no mutable runtime state, so it publishes no invariant companion.
|
||||
+9
-8
@@ -4,19 +4,17 @@ Status: implemented
|
||||
|
||||
[English](2026-09-24-schedule-opt-in-optional-bundle.md) | 中文
|
||||
|
||||
> 取代[默认 Web 组合中的 Schedule](../../archived/architecture/2026-09-24-web-default-schedule-composition.md):这三行现在默认禁用,打开它们是此 bundle 的职责。
|
||||
|
||||
## Problem
|
||||
|
||||
`packages/bundle/web-app/cordis.patch.yml` 以 `disabled: true` 插入 `time-context`、`schedule` 与 `ui-schedule`,因此随发行版交付的 Web 组合不提供任何定时能力:没有 `schedule_*` 工具、没有 Session 提醒目录、没有自动化任务页面,也没有逐步时钟读数。这些条目在产品界面上没有开关;想要它们的部署要改 profile patch 层或传 `--patch` overlay,而两者都是使用随发行版 Web profile 的人无法触及的配置文件。
|
||||
Schedule 会给每个活跃根 Agent 的请求增加四个工具 schema,并在每个符合条件的步骤追加一条持久时钟消息,因此随发行版交付的 Web 组合不能默认挂载它。使用随发行版 Web profile 的人仍需要产品界面上的开关来打开它;profile patch 层或 `--patch` overlay 都是这个人无法触及的配置文件。
|
||||
|
||||
## Decision
|
||||
|
||||
这三行仍留在组合中,因为按 id 定位的 patch 覆盖的是已存在的行:profile patch 或 bundle 把某行的 `disabled` 设为 `false`,无需再次插入它。
|
||||
`packages/bundle/web-app/cordis.patch.yml` 不含 `time-context`、`schedule` 与 `ui-schedule` 中的任何一行,因此随发行版交付的 Web 组合不提供任何定时能力:没有 `schedule_*` 工具、没有 Session 提醒目录、没有自动化任务页面,也没有逐步时钟读数。
|
||||
|
||||
`@deepseek-ai/dsh-experimental-schedule-bundle`(`packages/experimental/schedule-bundle/`)在其 `cordis.patch.yml` 中携带三个按 id 定位的 patch,每行一个,各自把 `disabled` 设为 `false`;它不插入任何行。`packages/boot/app-boot/src/profile.ts` 的 `OPTIONAL_BUNDLES` 列出该包,`apps/cli` 将其声明为运行时依赖,因此每次安装都随包携带且默认关闭。[实验性能力作为可选 bundle 的决策](2026-09-21-experimental-capabilities-as-optional-bundles.zh.md)负责 `OPTIONAL_BUNDLES` 的约定,以及 Web 插件管理页在 Official 分组中渲染的本地化 `icon` 与 `meta.title` / `meta.description` 元数据。
|
||||
`@deepseek-ai/dsh-experimental-schedule-bundle`(`packages/experimental/schedule-bundle/`)在其 `cordis.patch.yml` 中插入这三行并依赖它们的包,与其他可选 bundle 插入各自随附的行相同。`packages/boot/app-boot/src/profile.ts` 的 `OPTIONAL_BUNDLES` 列出该包,`apps/cli` 将其声明为运行时依赖,因此每次安装都随包携带且默认关闭。[实验性能力作为可选 bundle 的决策](2026-09-21-experimental-capabilities-as-optional-bundles.zh.md)负责 `OPTIONAL_BUNDLES` 的约定,以及 Web 插件管理页在“官方”分组中渲染的本地化 `icon` 与 `meta.title` / `meta.description` 元数据。
|
||||
|
||||
启用该 bundle 会打开 `time-context`(每个步骤的时钟读数,包含采样瞬时、附加到当前开放请求的浏览器时区,以及自前一条模型可见消息以来的经过时长)、`schedule`(持久提醒,以及每个活跃根 Agent 上的 `schedule_create`、`schedule_list`、`schedule_update` 与 `schedule_delete`),以及 `ui-schedule`(Session 提醒目录与自动化任务页面)。该 bundle 把 `time-context` 与 `schedule` 一起打开,因为提醒请求给出的是挂钟时间目标:采样瞬时正是让模型把“明天九点”这类请求转换成带偏移量的 `at` 值的依据。持久记录、投递与管理操作由 [Schedule 子系统](../../../../docs/subsystems/schedule.zh.md)负责;承载这些禁用行的组合由 [Web bundle](../../../../packages/bundle/web-app/README.zh.md)负责。禁用该 bundle 会恢复随发行版交付的组合,`schedule` 行保持禁用期间 Host 不再调度;已存储的任务记录保留在 Schedule domain 中。
|
||||
启用该 bundle 会插入 `time-context`(每个步骤的时钟读数,包含采样瞬时、附加到当前开放请求的浏览器时区,以及自前一条模型可见消息以来的经过时长)、`schedule`(持久提醒,以及每个活跃根 Agent 上的 `schedule_create`、`schedule_list`、`schedule_update` 与 `schedule_delete`)与 `ui-schedule`(Session 提醒目录与自动化任务页面)。该 bundle 把 `time-context` 与 `schedule` 一起携带,因为提醒请求给出的是挂钟时间目标:采样瞬时正是让模型把“明天九点”这类请求转换成带偏移量的 `at` 值的依据。持久记录、投递与管理操作由 [Schedule 子系统](../../../../docs/subsystems/schedule.zh.md)负责;该 bundle 向其加入这些行的组合由 [Web bundle](../../../../packages/bundle/web-app/README.zh.md) 负责。禁用该 bundle 会恢复随发行版交付的组合,`schedule` 行不存在期间 Host 不再调度;已存储的任务记录保留在 Schedule domain 中。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -24,11 +22,14 @@ Status: implemented
|
||||
|
||||
**保留一个 `--patch` overlay 文件。** overlay 是启动时的参数,而不是产品界面上的开关,因此使用随发行版 `web` profile 的人无法触及它。通过它的 `insert` 列表重新声明这些条目并不能覆盖它们:`applyEntryPatches` 追加该列表时不对 id 去重,而 Loader 会把组合出的列表按 id 收敛为一个条目、最后一个声明生效,因此该 overlay 会替换 Web Bundle 的条目,而不是给它们设值。
|
||||
|
||||
**把这三行从 Web 组合中抽出、移入 bundle。** 这样 bundle 会插入这些行而不是覆盖它们;某个 profile 若也声明了这些 id,同一个 id 就会有两份声明,而 Loader 只保留最后一份。按需开关针对的正是这些禁用行。
|
||||
**把这三行以 `disabled: true` 留在 Web 组合中,用按 id 定位的 patch 打开。** 这样 bundle 只是给已有的行设值,但插件管理器只列出 bundle 插入的行,因此该 bundle 的页面显示不含任何组件,需要为被覆盖的行另建一条列出路径;而其他可选 bundle 都插入各自的行。某个 profile 若也声明了其中某个 id,处理方式与任何其他 bundle 相同:Loader 保留最后一份声明。
|
||||
|
||||
**把这三行锁定在 bundle 开关上。** 让插件管理器拒绝逐行开关的 bundle 清单字段,会给插件管理器增加一个只有这个 bundle 使用的概念。该 bundle 的页面像其他所有 bundle 一样为每行提供开关,由 bundle README 说明这三行只能一起工作。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 默认的 `dsh web` 会话不带 Schedule 工具 schema、Session 提醒目录、自动化任务页面与逐步时钟读数。需要它们的安装可从插件管理页启用该可选 bundle,或把该包列进 profile 的 `dsh.profile.bundles`。
|
||||
- 启用后的安装会给每个活跃根 Agent 增加四个工具 schema,并在每个符合条件的步骤追加一条持久时钟消息;该读数对模型可见且持久,因此它与其他 user 消息一样参与回放、压缩,并出现在导出的 Session 日志中。
|
||||
- 在不携带这三行 Web 行的组合中,该 bundle 的每个补丁都会报告一条 `patch: entry <id> not found` 警告且不挂载任何东西;其包不声明插件依赖,因此启用它不会重复挂载同一个 Host 服务。
|
||||
- 插件管理页在该 bundle 下列出这三行,显示各自包的 `locale/*.json` 声明的标题、它们的状态,并在 bundle 打开时为每行提供开关;关掉其中一行会让 Schedule 缺少这一部分。
|
||||
- 未选中该 bundle 时,按 id 定位这三行之一的 profile 补丁或 `--patch` overlay 匹配不到任何条目,加载器会为它报告 `patch: entry <id> not found` 警告;选中该 bundle 即可取代按 id 打开这些行。
|
||||
- 该开关只涉及配置:`src/index.ts` 是空模块,运行时内容由 patch 承载,包本身不拥有可变的运行时状态,因此不发布不变量伴随模块。
|
||||
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-09-19-failed-step-tool-results.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.md
|
||||
/agent-note-record-unanswered-tool-results-before-closing-a-failed-step:
|
||||
en: 8fd2a50a041b1b18
|
||||
zh: 01aed07a6425a322
|
||||
/agent-note-record-unanswered-tool-results-before-closing-a-failed-step/problem:
|
||||
en: 1fa7525f83af9035
|
||||
zh: 401ce968e3dd5434
|
||||
/agent-note-record-unanswered-tool-results-before-closing-a-failed-step/decision:
|
||||
en: 3d58367a05183b4a
|
||||
zh: f9ccc3a7011a6240
|
||||
/agent-note-record-unanswered-tool-results-before-closing-a-failed-step/alternatives-considered:
|
||||
en: 37da4d9c8533b736
|
||||
zh: e52a59763ae2b62e
|
||||
/agent-note-record-unanswered-tool-results-before-closing-a-failed-step/consequences:
|
||||
en: 1ea72421a98551d8
|
||||
zh: c01e02ddac8a3454
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Record unanswered tool results before closing a failed step
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-19-failed-step-tool-results.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A committed assistant message can request tools whose scheduler fails before all results are recorded. Closing the step and turn with those calls unanswered makes later model requests invalid for providers that require paired tool calls and results. An error turn is already closed, so crash-tail repair cannot recover it on resume.
|
||||
|
||||
## Decision
|
||||
|
||||
The AgentLoop driver records recovery results for unanswered calls before it closes a failed step, then propagates the original failure. Live step recovery and `interruptedTurnClosers` share incremental pending-call tracking and result construction; the live driver still owns its step and turn events. A step-local observer retains pending identities from committed events without copying session history. The scheduler stops replenishment and drains started dispatches before rejecting, so result recovery cannot race a live dispatch.
|
||||
|
||||
Recovery preserves committed results and follows the assistant's call order. A recorded `tool/call` without a result receives `TOOL_OUTCOME_UNKNOWN`; its text warns against blindly retrying an operation with possible side effects. An assistant request with no call record receives `TOOL_NOT_STARTED`. A call record precedes preparation and does not establish that the tool body ran, so unknown outcomes remain conservative.
|
||||
|
||||
This decision supersedes only the missing-result policy in [parallel tool-call execution](../feature/2026-07-10-parallel-tool-call-execution.md). Its classification, concurrency limit, ordering, and quiescence rules remain applicable.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Leave calls unanswered to avoid inventing tool outcomes.** An unanswered call invalidates later provider requests. An explicit unknown-outcome error records the limit of the available evidence without claiming success, cancellation, or absence of side effects.
|
||||
|
||||
**Repair only when resuming or forking.** The ordinary failure path already records `step/end` and `turn/end`; open-tail repair intentionally preserves closed history. Recovery belongs before those records, while the driver owns the failed step.
|
||||
|
||||
**Repair inside the tool scheduler alone.** Failure can occur after the assistant message is recorded but before scheduling starts. The step driver covers that interval and reuses the existing session repair semantics rather than adding a second implementation of call/result matching.
|
||||
|
||||
**Retry automatically.** A dispatch can have side effects even when its result is missing. The error result supplies retry guidance, while the turn retains the original failure and waits for later work.
|
||||
|
||||
## Consequences
|
||||
|
||||
Later requests, resume, and fork retain a usable call/result history after a live step failure. Recovery appends results; it does not remove assistant messages, replace completed results, execute tools, or convert the failed turn into success. The log uses existing event types and recovery codes, with no format change.
|
||||
|
||||
Already-closed inconsistent historical turns remain unchanged. The live recovery path requires the session to accept the missing result records; it cannot promise recovery from a failed log append.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: 在关闭失败步骤前补齐工具结果
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-19-failed-step-tool-results.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
已提交的 assistant 消息可能请求多个工具,而调度器在全部结果记录完成之前失败。如果关闭步骤和轮次时仍有调用尚无结果,后续模型请求就不满足要求工具调用与结果配对的提供方。错误轮次已经关闭,因此恢复会话时的崩溃尾部修复无法恢复它。
|
||||
|
||||
## 决策
|
||||
|
||||
AgentLoop 驱动器在关闭失败步骤之前,为尚无结果的调用记录恢复结果,随后传播原始失败。实时步骤恢复和 `interruptedTurnClosers` 共享增量的待处理调用跟踪与结果构造;实时驱动器仍拥有自己的步骤和轮次事件。步骤本地观察器从已提交事件中保留待处理调用标识,不复制会话历史。调度器在拒绝之前停止补充池并排空已启动的派发,因此结果恢复不会与运行中的派发竞争。
|
||||
|
||||
恢复保留已提交的结果,并遵循 assistant 的调用顺序。已有 `tool/call` 记录但尚无结果的调用获得 `TOOL_OUTCOME_UNKNOWN`;其文本警告不要盲目重试可能存在副作用的操作。没有调用记录的 assistant 请求获得 `TOOL_NOT_STARTED`。调用记录先于准备阶段,不能证明工具主体已经运行,因此未知结果保持保守。
|
||||
|
||||
本决策仅取代[并行工具调用执行](../feature/2026-07-10-parallel-tool-call-execution.zh.md)中的缺失结果策略。其中的分类、并发上限、顺序和完全停稳规则继续适用。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**让调用保持无结果,避免虚构工具执行结果。** 尚无结果的调用会使后续提供方请求无效。显式的未知结果错误记录可用证据的局限,而不声称成功、取消或没有副作用。
|
||||
|
||||
**仅在恢复或 fork 时修复。** 普通失败路径已经记录 `step/end` 和 `turn/end`;开放尾部修复有意保留已关闭历史。恢复应在这些记录之前执行,此时驱动器仍拥有失败步骤。
|
||||
|
||||
**仅在工具调度器内部修复。** 失败可能发生在 assistant 消息记录之后、调度开始之前。步骤驱动器覆盖这段区间,并复用已有的会话修复语义,避免另行实现调用/结果匹配。
|
||||
|
||||
**自动重试。** 即使结果缺失,派发也可能产生副作用。错误结果提供重试指引,而轮次保留原始失败并等待后续工作。
|
||||
|
||||
## 影响
|
||||
|
||||
实时步骤失败后,后续请求、恢复和 fork 会保留可用的调用/结果历史。恢复追加结果;它不移除 assistant 消息、不替换已完成结果、不执行工具,也不将失败轮次改为成功。日志使用已有事件类型和恢复代码,无需格式变更。
|
||||
|
||||
此前已关闭的不一致历史轮次保持不变。实时恢复路径要求会话能够接纳缺失结果记录;它不能保证从日志追加失败中恢复。
|
||||
@@ -12,8 +12,8 @@
|
||||
en: 532bb7268d9b93ce
|
||||
zh: ad47162233222ab0
|
||||
/agent-note-parallel-tool-call-execution-by-per-call-safety/scheduling-and-ordering:
|
||||
en: f0906d03fef98e7b
|
||||
zh: f9e43c4c832c9a05
|
||||
en: a485c530ea4366de
|
||||
zh: 6f9915d726de9ac7
|
||||
/agent-note-parallel-tool-call-execution-by-per-call-safety/safety-contract:
|
||||
en: 16c4ee08b65ead23
|
||||
zh: df80d6e49982615b
|
||||
@@ -24,8 +24,8 @@
|
||||
en: eb5a6aa84980f0ab
|
||||
zh: eed2680a160e8ed2
|
||||
/agent-note-parallel-tool-call-execution-by-per-call-safety/alternatives-considered:
|
||||
en: 44f8fb5851ba4e39
|
||||
zh: 4d75c937ac5277f7
|
||||
en: 17becc038bb7df7c
|
||||
zh: 673d9a9bdfa39edf
|
||||
/agent-note-parallel-tool-call-execution-by-per-call-safety/consequences:
|
||||
en: 5e5b2a1c7d850f83
|
||||
zh: b63311f28eb69366
|
||||
en: a5508057bc03f4f6
|
||||
zh: 96b46c59402474c3
|
||||
@@ -46,7 +46,7 @@ Only dispatch and the tool body overlap. `tools/pre-execute` and `tools/post-exe
|
||||
|
||||
Each started call appends `tool/call` immediately before its pre-execute gate. Completed dispatches occupy model-order slots, and a commit cursor appends `tool/result` and collects `additionalContexts` only when the next slot is ready. Live surfaces may show several pending calls, but results and post-tool context remain model-ordered.
|
||||
|
||||
An abort before a group starts records no calls from that group. An abort during a group stops replenishment, waits for already-started calls, commits their results in order, drains accepted batch context after those results, and then ends the step through the existing abort path. Calls that never start have no audit event. An unexpected scheduler failure stops new dispatches, waits for every already-started dispatch to settle, and rethrows the first failure. Because that failure is terminal internal state rather than a tool outcome, the loop does not invent tool results for rejected or uncommitted calls.
|
||||
An abort stops replenishment, waits for already-started calls, commits their results in order, drains accepted batch context after those results, and records synthetic aborted call/result pairs for undispatched calls. An unexpected scheduler failure stops new dispatches, waits for every already-started dispatch to settle, and rethrows the first failure. The driver records missing results before closing the failed step under the [failed-step recovery decision](../bug-fix/2026-09-19-failed-step-tool-results.md).
|
||||
|
||||
PTC mode remains outside this scheduler because the model emits one native `run_code` call. `run_code` and its internal dispatch queue remain serial; native sibling calls in `mode: 'both'` use the normal scheduler.
|
||||
|
||||
@@ -84,7 +84,7 @@ Snapshot coverage pins the visible multi-call transcript: pending calls may over
|
||||
|
||||
**Expose staged methods or a scheduling waterfall.** Public `prepare` / `dispatch` / `finalize` methods or a `tools/execution-mode` event add extension surface before another consumer needs it. The loop uses an internal scheduler view, while `executionMode(exec)` leaves an insertion point for a policy hook.
|
||||
|
||||
**Convert scheduler failures into tool results.** AgentLoop cannot determine whether a rejected dispatch invoked the tool body; ToolRuntime owns body-invocation state and typed tool outcomes. Internal scheduler failures therefore remain terminal instead of being reclassified as `ABORTED` results.
|
||||
**Convert scheduler failures into ordinary tool results.** AgentLoop cannot determine whether a rejected dispatch invoked the tool body; ToolRuntime owns body-invocation state and typed tool outcomes. Internal scheduler failures remain terminal instead of being reclassified as `ABORTED` results. Explicit recovery results preserve this uncertainty without leaving unanswered calls.
|
||||
|
||||
**Start calls while the model streams.** This may reduce latency further but changes assistant-message authority, replay, and call/result pairing. The scheduler starts only after the assistant message is complete.
|
||||
|
||||
@@ -96,7 +96,7 @@ Snapshot coverage pins the visible multi-call transcript: pending calls may over
|
||||
|
||||
The design is fail-closed and simple for tool authors, but it cannot exploit concurrency whose safety depends on comparing siblings. A tool that opts in too broadly can expose latent shared-state races.
|
||||
|
||||
Parallel calls may begin in cases where serial execution would have aborted before reaching them. The scheduler therefore records only started calls, drains them on abort, and never starts replacements after cancellation.
|
||||
Parallel calls may begin in cases where serial execution would have aborted before reaching them. The scheduler drains them on abort and never starts replacements after cancellation; undispatched calls receive synthetic aborted call/result pairs.
|
||||
|
||||
Ordered commits may hold a fast result behind a slow earlier sibling. This preserves replay and model-history order while live surfaces still show pending progress.
|
||||
|
||||
@@ -104,4 +104,4 @@ Concurrent external calls can compete for quota or process capacity. Providers o
|
||||
|
||||
Tool registration is a scheduling boundary. Registry mutations affect not-yet-started calls because the scheduler reclassifies after each barrier and before every pool replenishment. Already-started calls retain the scheduling decision under which they entered the pool.
|
||||
|
||||
A terminal scheduler failure may leave recorded calls without results before the failed step closes. Waiting for live dispatches preserves quiescence without misreporting those internal failures as tool outcomes.
|
||||
A terminal scheduler failure reaches step recovery only after live dispatches settle. Recovery reports uncommitted outcomes as unknown, preserving quiescence and the original failure while keeping later model history usable.
|
||||
@@ -46,7 +46,7 @@ Status: implemented
|
||||
|
||||
每个已启动的调用都会在进入 pre-execute 门禁之前立即追加 `tool/call`。已完成的派发占据模型顺序的槽位;提交游标只有在下一个槽位就绪时,才会追加 `tool/result` 并收集 `additionalContexts`。实时界面可以显示多个待处理调用,但结果和工具执行后的上下文仍按模型顺序排列。
|
||||
|
||||
如果在一组启动前中止,系统不会记录该组的任何调用。如果在一组执行期间中止,系统会停止补充池,等待已启动的调用,按顺序提交其结果,在这些结果之后排空已接受的批次上下文,然后通过现有中止路径结束该步骤。从未启动的调用没有审计事件。调度器发生意外故障时,会停止新的派发,等待每项已启动的派发结算,并重新抛出第一个故障。由于该故障是内部终态,而非工具结果,循环不会为被拒绝或未提交的调用虚构工具结果。
|
||||
中止会停止补充池,等待已启动的调用,按顺序提交其结果,在这些结果之后排空已接受的批次上下文,并为未派发的调用记录合成的中止调用/结果对。调度器发生意外故障时,会停止新的派发,等待每项已启动的派发结算,并重新抛出第一个故障。驱动器按照[失败步骤恢复决策](../bug-fix/2026-09-19-failed-step-tool-results.zh.md),在关闭失败步骤之前记录缺失结果。
|
||||
|
||||
PTC mode 仍不使用此调度器,因为模型只会发出一个原生 `run_code` 调用。`run_code` 及其内部派发队列仍按串行方式执行;`mode: 'both'` 中的原生并列调用使用常规调度器。
|
||||
|
||||
@@ -84,7 +84,7 @@ PTC mode 仍不使用此调度器,因为模型只会发出一个原生 `run_co
|
||||
|
||||
**公开分阶段方法或调度 waterfall(瀑布式事件)。** 公开的 `prepare` / `dispatch` / `finalize` 方法或 `tools/execution-mode` 事件,会在另一个消费方需要它之前扩大扩展接口。循环使用内部调度器视图,而 `executionMode(exec)` 为策略钩子保留了插入点。
|
||||
|
||||
**将调度器故障转换为工具结果。** AgentLoop 无法判断被拒绝的派发是否已调用工具主体;ToolRuntime 负责工具主体调用状态和类型化工具结果。因此,内部调度器故障保持为终态,而不会被重新分类为 `ABORTED` 结果。
|
||||
**将调度器故障转换为普通工具结果。** AgentLoop 无法判断被拒绝的派发是否已调用工具主体;ToolRuntime 负责工具主体调用状态和类型化工具结果。内部调度器故障保持为终态,而不会被重新分类为 `ABORTED` 结果。显式恢复结果保留这种不确定性,同时避免留下尚无结果的调用。
|
||||
|
||||
**在模型流式输出时启动调用。** 这可能进一步降低延迟,但会改变 assistant 消息的权威性、回放以及调用/结果配对。调度器只在 assistant 消息完成后才启动。
|
||||
|
||||
@@ -96,7 +96,7 @@ PTC mode 仍不使用此调度器,因为模型只会发出一个原生 `run_co
|
||||
|
||||
该设计遵循安全侧原则,对工具作者而言也很简单,但无法利用必须通过比较并列调用才能确认安全的并发性。工具过于宽泛地选择并行,可能暴露潜在的共享状态竞态。
|
||||
|
||||
在某些情形下,并行调用会先行启动,而串行执行原本会在轮到这些调用之前中止。因此,调度器只记录已启动的调用,在中止时将其排空,且取消后绝不启动替换调用。
|
||||
在某些情形下,并行调用会先行启动,而串行执行原本会在轮到这些调用之前中止。调度器在中止时将其排空,且取消后绝不启动替换调用;未派发的调用获得合成的中止调用/结果对。
|
||||
|
||||
有序提交可能会让快速结果等待较慢的早期并列调用。这保留了回放和模型历史顺序,同时实时界面仍可显示待处理进度。
|
||||
|
||||
@@ -104,4 +104,4 @@ PTC mode 仍不使用此调度器,因为模型只会发出一个原生 `run_co
|
||||
|
||||
工具注册是调度边界。调度器会在每个屏障之后以及每次补充池之前重新分类,因此注册表变更会影响尚未启动的调用。已启动的调用保留它们进入池时所依据的调度决策。
|
||||
|
||||
终态调度器故障可能会在故障步骤关闭前留下已记录但没有结果的调用。等待仍在运行的派发可确保完全停稳,而不会将这些内部故障误报为工具结果。
|
||||
终态调度器故障只有在运行中的派发结算后才会进入步骤恢复。恢复将未提交的执行结果报告为未知,保留完全停稳与原始失败,同时让后续模型历史保持可用。
|
||||
@@ -35,7 +35,7 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: read
|
||||
pages: read
|
||||
|
||||
@@ -28,7 +28,11 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur
|
||||
}
|
||||
writeFileSync(join(root, 'package.json'), '{"type":"module"}')
|
||||
writeFileSync(join(modules, 'dsh-app-boot', 'package.json'), '{"type":"module","exports":"./index.js"}')
|
||||
writeFileSync(join(modules, 'dsh-app-boot', 'index.js'), 'export const loadProfileDirectory = () => ({ skippedBundles: [] }); export const reportSkippedBundles = () => {}; export const loadLayeredEnv = () => ({})')
|
||||
writeFileSync(join(modules, 'dsh-app-boot', 'index.js'), `
|
||||
export const loadProfileDirectory = () => ({ skippedBundles: [] });
|
||||
export const reportSkippedBundles = () => {};
|
||||
export const loadLayeredEnv = () => ({});
|
||||
`)
|
||||
writeFileSync(join(modules, 'dsh', 'package.json'), '{"type":"module","exports":{"./profile-boot":"./profile-boot.js"}}')
|
||||
writeFileSync(join(modules, 'dsh', 'profile-boot.js'), `
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
@@ -29,15 +29,13 @@ it('boots default Web without experimental modules, scheduling, time context, or
|
||||
expect(roster.plugins.length).toBeGreaterThan(roster.entries.length)
|
||||
expect(roster.modules.some(url => modulePackage(url) === '@deepseek-ai/dsh')).toBe(true)
|
||||
expect(roster.client.entries.length).toBeGreaterThan(0)
|
||||
for (const name of [
|
||||
'@deepseek-ai/dsh-client-ui-sidebar-browser',
|
||||
'@deepseek-ai/dsh-time-context',
|
||||
'@deepseek-ai/dsh-schedule',
|
||||
'@deepseek-ai/dsh-client-ui-schedule',
|
||||
]) {
|
||||
const entry = roster.entries.find(entry => entry.name === name)
|
||||
expect(entry, name).toBeDefined()
|
||||
expect(entry!.state, name).toBeUndefined()
|
||||
const browser = roster.entries.find(entry => entry.name === '@deepseek-ai/dsh-client-ui-sidebar-browser')
|
||||
expect(browser).toBeDefined()
|
||||
expect(browser!.state).toBeUndefined()
|
||||
expect(delivered.entries.some(entry => entry.id === '@deepseek-ai/dsh-client-ui-sidebar-browser')).toBe(false)
|
||||
// The optional Schedule bundle inserts these rows; the shipped composition carries none of them.
|
||||
for (const name of ['@deepseek-ai/dsh-time-context', '@deepseek-ai/dsh-schedule', '@deepseek-ai/dsh-client-ui-schedule']) {
|
||||
expect(roster.entries.some(entry => entry.name === name), name).toBe(false)
|
||||
expect(delivered.entries.some(entry => entry.id === name), name).toBe(false)
|
||||
}
|
||||
expect(experimentalRuntimeReferences(roster)).toEqual([])
|
||||
|
||||
@@ -9,6 +9,7 @@ const state: AccountView = {
|
||||
}
|
||||
|
||||
class Account extends DeepSeekAccount {
|
||||
getDeviceIdentity = async () => ({ osVersion: 'test-os' })
|
||||
fail = false
|
||||
ended = Promise.withResolvers<undefined>()
|
||||
reading = Promise.withResolvers<undefined>()
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/desktop/README.md
|
||||
/deepseek-harness-desktop:
|
||||
en: 45561fc0dd3a1b20
|
||||
zh: f4b03355d4bd0693
|
||||
en: b8e4f357cf330721
|
||||
zh: cdaaf35a061f41b1
|
||||
/deepseek-harness-desktop/closing-the-window-and-quitting:
|
||||
en: c323e02e3f0305fe
|
||||
zh: 3c730db632136443
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Desktop analytics follows the [product collection policy](../../packages/client/product-analytics/README.md), including its live application setting. Web usage is excluded. Update installation waits for its local analytics intake before locking API admission and stopping the Host. Intake has a one-second deadline; failure does not prevent installation, and collector delivery is not awaited.
|
||||
|
||||
The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host, dropping connection-level response headers (`transfer-encoding`, `connection`, `keep-alive`) that describe the Node fetch rather than the resource, and marking plugin bundle responses `no-store` because their per-launch revisions would only accumulate in Chromium's disk cache; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it.
|
||||
|
||||
The first application-menu command, **About DeepSeek Harness**, opens Electron's native About panel with the application icon, product name, and installed release version. The menu follows the Desktop shell locale. On macOS, Hide, Hide Others, Show All, and Quit use localized labels; Hide and Quit include the DeepSeek Harness product name. These commands retain their native actions and shortcuts. macOS reads the icon from its application bundle, so an unpackaged development launch displays Electron's icon; Windows receives the packaged PNG.
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
桌面埋点遵循[产品采集策略](../../packages/client/product-analytics/README.zh.md)及其动态应用配置,不包含 Web 使用情况。安装更新会等待该操作的本地埋点接收请求结束,再锁定 API 准入并停止 Host。接收请求的时限为一秒,失败不会阻止安装,也不等待收集端完成发送。
|
||||
|
||||
桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host,转发时丢弃描述 Node fetch 连接而非资源本身的响应头(`transfer-encoding`、`connection`、`keep-alive`),并把插件 bundle 响应标记为 `no-store`,因为其每次启动都变化的 revision 只会在 Chromium 磁盘缓存中累积;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。
|
||||
|
||||
应用菜单第一项“**关于 DeepSeek Harness**”打开 Electron 原生关于面板,展示应用图标、产品名称和当前安装的发布版本。菜单文案跟随桌面壳的语言。macOS 的隐藏、隐藏其他、显示全部和退出条目使用本地化文案,隐藏和退出条目包含 DeepSeek Harness 产品名称。这些条目保留原生动作和快捷键。macOS 从应用包读取图标,因此未打包的开发启动会显示 Electron 图标;Windows 使用随包分发的 PNG。
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"vite": "^6.0.0",
|
||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-shortcuts": "workspace:*",
|
||||
"@deepseek-ai/dsh-atomic-write": "workspace:*"
|
||||
"@deepseek-ai/dsh-atomic-write": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-product-analytics": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -67,9 +67,13 @@ export interface DesktopAccountBackend {
|
||||
* @param listener - state recipient.
|
||||
* @param failed - stream failure recipient.
|
||||
* @param expired - live credential-expiry recipient.
|
||||
* @param onAnalyticsEnabledChanged - optional Desktop collection-policy recipient; disconnected streams publish false.
|
||||
* @returns stream disposer.
|
||||
*/
|
||||
watch(listener: (state: AccountView) => void, failed: () => void, expired: () => void): () => void
|
||||
watch(
|
||||
listener: (state: AccountView) => void, failed: () => void, expired: () => void,
|
||||
onAnalyticsEnabledChanged?: (enabled: boolean) => void,
|
||||
): () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,19 +90,21 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo
|
||||
state: () => call('getState'),
|
||||
start: client => call('startSignIn', { client, callbackOrigin: new URL(origin).origin, loginSource: 'desktop' }),
|
||||
cancel: attemptId => call('cancelSignIn', { attemptId }), signOut: client => call('signOut', { client }),
|
||||
watch(listener, failed, expired) {
|
||||
watch(listener, failed, expired, onAnalyticsEnabledChanged) {
|
||||
let closed = false
|
||||
let socket: WebSocket | undefined
|
||||
let retry: ReturnType<typeof setTimeout> | undefined
|
||||
const connect = (): void => {
|
||||
const streamId = randomUUID()
|
||||
const expiryStreamId = randomUUID()
|
||||
const analyticsPolicyStreamId = randomUUID()
|
||||
void cookies().then((cookie) => {
|
||||
if (closed) return
|
||||
const url = new URL(REMOTE_STREAM_MUX_PATH, origin)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
socket = new WebSocket(url, { headers: { cookie, origin }, maxPayload: 65_536 })
|
||||
socket.on('open', () => {
|
||||
if (onAnalyticsEnabledChanged !== undefined) socket?.send(JSON.stringify({ type: 'open', streamId: analyticsPolicyStreamId, endpoint: 'productAnalytics/watchPolicy', payload: { args: {} } }))
|
||||
socket?.send(JSON.stringify({ type: 'open', streamId: expiryStreamId, endpoint: 'account/watchExpiry', payload: { args: {} } }))
|
||||
socket?.send(JSON.stringify({ type: 'open', streamId, endpoint: 'account/watch', payload: { args: {} } }))
|
||||
})
|
||||
@@ -106,6 +112,11 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo
|
||||
try {
|
||||
const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data)
|
||||
const frame = parseRemoteStreamServerMessage(bytes.toString('utf8'))
|
||||
if (frame.streamId === analyticsPolicyStreamId) {
|
||||
if (frame.type === 'item' && typeof frame.value === 'boolean') onAnalyticsEnabledChanged?.(frame.value)
|
||||
else onAnalyticsEnabledChanged?.(false)
|
||||
return
|
||||
}
|
||||
if (frame.streamId === expiryStreamId && frame.type === 'item' && frame.value === 'session-expired') { expired(); return }
|
||||
if (frame.streamId !== streamId) throw new Error('desktop account: unexpected stream')
|
||||
if (frame.type === 'item') listener(accountView(frame.value))
|
||||
@@ -113,11 +124,11 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo
|
||||
} catch { socket?.close() }
|
||||
})
|
||||
socket.on('error', () => { socket?.close() })
|
||||
socket.on('close', () => { if (!closed) { failed(); retry = setTimeout(connect, 1000) } })
|
||||
}).catch(() => { if (!closed) { failed(); retry = setTimeout(connect, 1000) } })
|
||||
socket.on('close', () => { if (!closed) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } })
|
||||
}).catch(() => { if (!closed) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } })
|
||||
}
|
||||
connect()
|
||||
return () => { closed = true; clearTimeout(retry); socket?.close() }
|
||||
return () => { closed = true; onAnalyticsEnabledChanged?.(false); clearTimeout(retry); socket?.close() }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export function Welcome({ api }: { api: WelcomeApi }) {
|
||||
const [expiryNotice, setExpiryNotice] = useState(false)
|
||||
const [page, setPage] = useState<Page>('entry')
|
||||
const pageRef = useRef<Page>('entry')
|
||||
const visiblePage = useRef<Page>('entry')
|
||||
const [attempt, setAttempt] = useState<AccountView['attempt']>(null)
|
||||
const attemptRef = useRef<AccountView['attempt']>(null)
|
||||
const [starting, setStarting] = useState(false)
|
||||
@@ -69,12 +70,14 @@ export function Welcome({ api }: { api: WelcomeApi }) {
|
||||
}, [api, m.welcomeTitle])
|
||||
|
||||
useEffect(() => {
|
||||
if (page === 'entry' && visiblePage.current !== 'entry') void api.analytics?.('auth_page_view', {})
|
||||
visiblePage.current = page
|
||||
if (page === 'key') input.current?.focus()
|
||||
else if (page === 'entry' && focusEntry.current) {
|
||||
focusEntry.current = false
|
||||
keyButton.current?.focus()
|
||||
}
|
||||
}, [page])
|
||||
}, [page, api])
|
||||
|
||||
useEffect(() => {
|
||||
if (copyState !== 'copied' && copyState !== 'failed') return
|
||||
@@ -85,6 +88,7 @@ export function Welcome({ api }: { api: WelcomeApi }) {
|
||||
async function saveKey(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
if (busyRef.current) return
|
||||
void api.analytics?.('api_key_save_click', {})
|
||||
const value = draft.trim()
|
||||
if (!/^[\x21-\x7e]+$/.test(value) || /^[A-Z][A-Z0-9_]*=[^=]/.test(value)
|
||||
|| ((value.startsWith('"') || value.startsWith("'") || value.charCodeAt(0) === 96) && value.at(-1) === value[0])) {
|
||||
@@ -198,15 +202,15 @@ export function Welcome({ api }: { api: WelcomeApi }) {
|
||||
<button id="auth-loading" className="primary" type="button" hidden={failed} disabled aria-label={m.welcomeAuthExchanging}>
|
||||
<StateDot state="ongoing" size={16} className="welcome-loading" />
|
||||
</button>
|
||||
<button id="auth-retry" className="primary" type="button" hidden={!failed} onClick={() => { void start() }}>{m.welcomeAuthRetry}</button>
|
||||
<button id="auth-api-key" className="secondary" type="button" hidden={!failed} onClick={() => { navigate('key') }}>{m.welcomeApiKey}</button>
|
||||
<button id="auth-retry" className="primary" type="button" hidden={!failed} onClick={() => { void api.analytics?.('auth_page_click', { button_name: 'sign_in' }); void start() }}>{m.welcomeAuthRetry}</button>
|
||||
<button id="auth-api-key" className="secondary" type="button" hidden={!failed} onClick={() => { void api.analytics?.('auth_page_click', { button_name: 'api-key' }); navigate('key') }}>{m.welcomeApiKey}</button>
|
||||
<button id="auth-cancel" className="secondary" type="button" hidden={failed}
|
||||
disabled={cancelling || phase === 'committing' || phase === 'succeeded' || (phase === 'initializing' && !attempt?.id)}
|
||||
onClick={() => { void cancel() }}>{m.welcomeAuthCancel}</button>
|
||||
</div>
|
||||
<div id="entry-actions" className="actions" hidden={page !== 'entry'}>
|
||||
<button id="sign-in" className="primary" type="button" onClick={() => { void start() }}>{m.welcomeSignIn}</button>
|
||||
<button ref={keyButton} id="api-key" className="secondary" type="button" onClick={() => { navigate('key') }}>{m.welcomeApiKey}</button>
|
||||
<button id="sign-in" className="primary" type="button" onClick={() => { void api.analytics?.('auth_page_click', { button_name: 'sign_in' }); void start() }}>{m.welcomeSignIn}</button>
|
||||
<button ref={keyButton} id="api-key" className="secondary" type="button" onClick={() => { void api.analytics?.('auth_page_click', { button_name: 'api-key' }); navigate('key') }}>{m.welcomeApiKey}</button>
|
||||
</div>
|
||||
<div id="key-actions" className="actions" hidden={page !== 'key'}>
|
||||
<button id="save-key" className="primary" type="submit" form="key-form" disabled={busy || draft.trim() === ''}>{m.welcomeKeySave}</button>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ProductEventMap, ProductEvent } from '@deepseek-ai/dsh-client-product-analytics/types'
|
||||
import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts'
|
||||
/** Electron shell: desktop project ownership, custom protocol, windows, and lifecycle. */
|
||||
|
||||
@@ -44,7 +45,7 @@ import { DesktopUpdateSchedule, resolveDesktopUpdateScheduleConfig } from './upd
|
||||
import { desktopUpdateErrorSummary, presentDesktopUpdate } from './update-presentation.ts'
|
||||
import { desktopErrorState } from './startup-error.ts'
|
||||
import { DesktopMandatoryUpdatePolicy, resolveDesktopPolicyConfig, type DesktopPolicyState } from './mandatory-update-policy.ts'
|
||||
import { desktopClientMetadata } from './client-metadata.ts'
|
||||
import { desktopClientMetadata, desktopClientVersion } from './client-metadata.ts'
|
||||
import { DesktopMandatoryUpdateWindow } from './mandatory-update-window.ts'
|
||||
import { DesktopPolicyTestAuth } from './policy-test-auth.ts'
|
||||
import { DesktopUpdateDialog, type UpdateDialogOptions } from './update-dialog.ts'
|
||||
@@ -376,6 +377,14 @@ async function main(): Promise<void> {
|
||||
const browserGuests = new DesktopBrowserGuests(() => hostUrl)
|
||||
let injections: readonly unknown[] = []
|
||||
let welcomeBackend: DesktopWelcomeBackend | undefined
|
||||
let reportedLaunch = false
|
||||
let analyticsEnabled = false
|
||||
const track = async <K extends keyof ProductEventMap>(eventName: K, attributes: ProductEventMap[K]): Promise<void> => {
|
||||
const event = { eventName, attributes, timestamp: Date.now() } as ProductEvent
|
||||
try {
|
||||
if (analyticsEnabled) await welcomeBackend?.report(event)
|
||||
} catch (_error) { /* Analytics cannot interrupt native actions. */ }
|
||||
}
|
||||
let stopAccount: (() => void) | undefined
|
||||
let openedAttempt: string | undefined
|
||||
let returnedAttempt: string | undefined
|
||||
@@ -408,7 +417,7 @@ async function main(): Promise<void> {
|
||||
const backend = new DesktopBackendController((onFailure) => {
|
||||
const hostInspectPort = developmentHostInspectPort(development)
|
||||
const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject,
|
||||
hostInspectPort, process.env, onFailure,
|
||||
hostInspectPort, { ...process.env, DSH_CLIENT_VERSION: desktopClientVersion() }, onFailure,
|
||||
primaryRuntime,
|
||||
resources, (next) => { platformView.setSession(next) })
|
||||
return {
|
||||
@@ -419,6 +428,8 @@ async function main(): Promise<void> {
|
||||
if (ready.injections === undefined) throw new Error('Desktop Host did not provide boot injections')
|
||||
injections = ready.injections
|
||||
welcomeBackend = await connectDesktopWelcome(ready.url, (input, init) => net.fetch(input, init), async () => (await session.defaultSession.cookies.get({ url: ready.url })).map(cookie => `${cookie.name}=${cookie.value}`).join('; '))
|
||||
analyticsEnabled = await welcomeBackend.analyticsEnabled().catch(() => false)
|
||||
if (!reportedLaunch) { reportedLaunch = true; void track('desktop_app_launch', {}) }
|
||||
stopAccount?.()
|
||||
const accountBackend = welcomeBackend.account
|
||||
stopAccount = accountBackend.watch((state) => {
|
||||
@@ -456,9 +467,11 @@ async function main(): Promise<void> {
|
||||
const state = await accountBackend.state()
|
||||
if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) welcomeWindow.webContents.send(WELCOME_IPC.state, state)
|
||||
}).catch(() => undefined)
|
||||
})
|
||||
}, (enabled) => { analyticsEnabled = enabled })
|
||||
},
|
||||
stop: async () => {
|
||||
analyticsEnabled = false
|
||||
stopAccount?.()
|
||||
try { await host.stop(requireCleanStop) }
|
||||
catch (error) {
|
||||
if (!requireCleanStop || !(error instanceof DesktopHostUncleanExitError)) throw error
|
||||
@@ -567,6 +580,8 @@ async function main(): Promise<void> {
|
||||
const result = await updateDialog.show(parent, confirmation)
|
||||
if (result.response !== 0 || isMandatory()) return false
|
||||
}
|
||||
// The update lock rejects new HTTP requests, including analytics intake.
|
||||
await track('desktop_upgrade_install_restart_click', {})
|
||||
if (backend.host !== host) throw new DesktopUpdatePreparationError('tasks-unavailable', locale.messages.updateTasksUnavailable)
|
||||
try {
|
||||
const stillActive = await host.updateTasks('lock')
|
||||
@@ -591,11 +606,14 @@ async function main(): Promise<void> {
|
||||
}
|
||||
return true
|
||||
},
|
||||
undefined, undefined, undefined,
|
||||
(success, reason) => { void track('desktop_upgrade_download_result', { is_success: success, ...reason === undefined ? {} : { error_reason: reason } }) },
|
||||
)
|
||||
|
||||
const updateSchedule = new DesktopUpdateSchedule(updates, resolveDesktopUpdateScheduleConfig(process.env))
|
||||
|
||||
const downloadUpdate = async (version: string): Promise<DesktopUpdateState> => {
|
||||
void track('desktop_upgrade_click', {})
|
||||
updateJournal?.action('download-requested')
|
||||
const state = await updates.download(version)
|
||||
if (state.phase !== 'ready' || quitting) return state
|
||||
@@ -1085,12 +1103,15 @@ async function main(): Promise<void> {
|
||||
const showWelcome = (): Promise<void> => {
|
||||
if (quitting) return Promise.resolve()
|
||||
if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) {
|
||||
if (!welcomeWindow.isVisible()) void track('auth_page_view', {})
|
||||
welcomeWindow.show()
|
||||
welcomeWindow.focus()
|
||||
return Promise.resolve()
|
||||
}
|
||||
openingWelcome ??= (async () => {
|
||||
welcomeWindow = await openWelcomeWindow(locale, {
|
||||
analytics: track,
|
||||
analyticsEnabled: () => Promise.resolve(analyticsEnabled),
|
||||
takeNotice: () => {
|
||||
const notice = pendingWelcomeNotice
|
||||
pendingWelcomeNotice = undefined
|
||||
|
||||
@@ -10,6 +10,10 @@ const locale = process.argv.find(argument => argument.startsWith(prefix))?.slice
|
||||
if (locale === undefined) throw new Error('desktop welcome: missing window locale')
|
||||
const api: WelcomeApi = {
|
||||
...resolveDesktopLocale(locale),
|
||||
analytics: async (eventName, attributes) => {
|
||||
if (await api.analyticsEnabled()) await ipcRenderer.invoke(WELCOME_IPC.analytics, eventName, attributes)
|
||||
},
|
||||
analyticsEnabled: () => ipcRenderer.invoke(WELCOME_IPC.analyticsEnabled) as Promise<boolean>,
|
||||
takeNotice: () => ipcRenderer.invoke(WELCOME_IPC.takeNotice) as Promise<WelcomeNotice | undefined>,
|
||||
startSignIn: () => ipcRenderer.invoke(WELCOME_IPC.start) as Promise<AccountView>,
|
||||
cancelSignIn: (id: SignInAttemptId) => ipcRenderer.invoke(WELCOME_IPC.cancel, id) as Promise<AccountView>,
|
||||
|
||||
@@ -45,6 +45,7 @@ export class DesktopUpdateCoordinator {
|
||||
* @param updater - Process-owned Electron updater, replaceable at the network/platform test boundary.
|
||||
* @param enabled - Whether this process has a packaged update source.
|
||||
* @param currentVersion - Actual installed application version.
|
||||
* @param downloadResult - Once per completed download attempt, including platform preparation failures.
|
||||
*/
|
||||
constructor(
|
||||
private readonly publish: (state: DesktopUpdateState) => DesktopUpdateState,
|
||||
@@ -52,6 +53,7 @@ export class DesktopUpdateCoordinator {
|
||||
private readonly updater: AppUpdater = autoUpdater,
|
||||
private readonly enabled: () => boolean = () => app.isPackaged && existsSync(join(process.resourcesPath, 'app-update.yml')),
|
||||
private readonly currentVersion: () => string = () => app.getVersion(),
|
||||
private readonly downloadResult?: (success: boolean, reason?: string) => void,
|
||||
) {
|
||||
if (updater === autoUpdater) {
|
||||
// electron-updater omits this internal transport property from its public declarations.
|
||||
@@ -108,9 +110,11 @@ export class DesktopUpdateCoordinator {
|
||||
try {
|
||||
await this.updater.downloadUpdate()
|
||||
if (!this.downloaded) throw new Error('desktop update: platform preparation did not report readiness')
|
||||
this.downloadResult?.(true)
|
||||
return this.setState({ phase: 'ready', version })
|
||||
} catch (error) {
|
||||
this.downloaded = false
|
||||
this.downloadResult?.(false, error instanceof DesktopUpdatePreparationError ? error.kind : 'download_failed')
|
||||
return this.setState(this.failure(error, 'download'))
|
||||
}
|
||||
}).finally(() => { this.downloadOperation = undefined })
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
/** Operations available to the isolated native welcome renderer. */
|
||||
|
||||
import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
import type { ProductEventMap } from '@deepseek-ai/dsh-client-product-analytics/types'
|
||||
import type { DesktopLocale } from './locale.ts'
|
||||
|
||||
/** Private native welcome channels, installed only while its window exists. */
|
||||
export const WELCOME_IPC = {
|
||||
saveApiKey: 'dsh-welcome:save-api-key',
|
||||
analytics: 'dsh-welcome:analytics',
|
||||
analyticsEnabled: 'dsh-welcome:analytics-enabled',
|
||||
skip: 'dsh-welcome:skip',
|
||||
start: 'dsh-welcome:start',
|
||||
cancel: 'dsh-welcome:cancel',
|
||||
@@ -20,8 +23,14 @@ export type WelcomeSaveResult = { readonly ok: true } | { readonly ok: false }
|
||||
/** One-time notification retained by the main process until Welcome receives it. */
|
||||
export type WelcomeNotice = 'session-expired'
|
||||
|
||||
type WelcomeEventName = 'auth_page_view' | 'auth_page_click' | 'api_key_save_click'
|
||||
|
||||
/** Host-owned operations used by the welcome window. */
|
||||
export interface WelcomeOperations {
|
||||
/** @param eventName - allowed welcome event. @param attributes - approved fields without credentials. */
|
||||
analytics?<K extends WelcomeEventName>(eventName: K, attributes: ProductEventMap[K]): Promise<void>
|
||||
/** @returns the Host's current effective collection policy. */
|
||||
analyticsEnabled(): Promise<boolean>
|
||||
/** @returns the pending notification, clearing it before another renderer can receive it. */
|
||||
takeNotice(): Promise<WelcomeNotice | undefined>
|
||||
/** @returns account state after starting a login attempt. */
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ProductEvent } from '@deepseek-ai/dsh-client-product-analytics/types'
|
||||
/** Native welcome operations using the shared Web authentication and RPC APIs. */
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
@@ -13,7 +14,11 @@ export interface WelcomeState {
|
||||
|
||||
/** Narrow operations available to the native welcome flow. */
|
||||
export interface DesktopWelcomeBackend {
|
||||
/** @returns the current Host policy; every read observes live configuration. */
|
||||
analyticsEnabled(): Promise<boolean>
|
||||
readonly account: DesktopAccountBackend
|
||||
/** @param event - desktop-owned fields. @returns after local Host intake. */
|
||||
report(event: ProductEvent): Promise<void>
|
||||
/** @returns Configured-key presence and the shared language preference, without credential values. */
|
||||
read(): Promise<WelcomeState>
|
||||
/** @returns The saved UI language without account or provider requests. */
|
||||
@@ -44,11 +49,14 @@ export async function connectDesktopWelcome(
|
||||
const authenticated = await send(authenticatedUrl, { credentials: 'include' })
|
||||
await authenticated.body?.cancel()
|
||||
if (!authenticated.ok) throw new Error('desktop welcome: Web authentication failed')
|
||||
const invoke = async (request: { namespace: string; method: string; args: Record<string, unknown> }): Promise<unknown> => {
|
||||
const invoke = async (
|
||||
request: { namespace: string; method: string; args: Record<string, unknown> },
|
||||
signal?: AbortSignal,
|
||||
): Promise<unknown> => {
|
||||
const rpcId = randomUUID()
|
||||
const method = `${request.namespace}/${request.method}`
|
||||
const response = await send(new URL(`/api/${method}`, origin).href, {
|
||||
method: 'POST', credentials: 'include', redirect: 'error',
|
||||
method: 'POST', credentials: 'include', redirect: 'error', ...(signal === undefined ? {} : { signal }),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ type: 'client-request', rpcId, method, payload: { args: request.args } }),
|
||||
})
|
||||
@@ -115,6 +123,12 @@ export async function connectDesktopWelcome(
|
||||
return {
|
||||
account,
|
||||
read,
|
||||
async analyticsEnabled() {
|
||||
const enabled = await invoke({ namespace: 'productAnalytics', method: 'enabled', args: {} }, AbortSignal.timeout(1000))
|
||||
if (typeof enabled !== 'boolean') throw new Error('desktop analytics: invalid collection policy')
|
||||
return enabled
|
||||
},
|
||||
async report(event) { await invoke({ namespace: 'productAnalytics', method: 'report', args: { event } }, AbortSignal.timeout(1000)) },
|
||||
async readLocalePreference() {
|
||||
const settings = await invoke({ namespace: 'settings', method: 'describe', args: {} })
|
||||
if (!record(settings) || !Array.isArray(settings.namespaces)) throw new Error('desktop welcome: missing settings namespaces')
|
||||
|
||||
@@ -57,14 +57,16 @@ let disposeActiveHandlers: (() => void) | undefined
|
||||
* @returns the visible window; a failed load destroys it before rejecting.
|
||||
*/
|
||||
export async function openWelcomeWindow(locale: DesktopLocale, operations: WelcomeOperations): Promise<BrowserWindow> {
|
||||
const window = new BrowserWindow(welcomeWindowOptions(process.platform, locale))
|
||||
const options = welcomeWindowOptions(process.platform, locale)
|
||||
const window = new BrowserWindow(options)
|
||||
disposeActiveHandlers?.()
|
||||
let active = true
|
||||
const disposeHandlers = (): void => {
|
||||
if (!active) return
|
||||
active = false
|
||||
for (const channel of [
|
||||
WELCOME_IPC.takeNotice, WELCOME_IPC.saveApiKey, WELCOME_IPC.skip, WELCOME_IPC.start, WELCOME_IPC.cancel, WELCOME_IPC.copyLink,
|
||||
WELCOME_IPC.analyticsEnabled, WELCOME_IPC.analytics, WELCOME_IPC.takeNotice, WELCOME_IPC.saveApiKey,
|
||||
WELCOME_IPC.skip, WELCOME_IPC.start, WELCOME_IPC.cancel, WELCOME_IPC.copyLink,
|
||||
]) {
|
||||
ipcMain.removeHandler(channel)
|
||||
}
|
||||
@@ -76,6 +78,20 @@ export async function openWelcomeWindow(locale: DesktopLocale, operations: Welco
|
||||
throw new Error('desktop welcome: rejected action from an unowned frame')
|
||||
}
|
||||
}
|
||||
ipcMain.handle(WELCOME_IPC.analyticsEnabled, (event) => {
|
||||
assertSender(event)
|
||||
return operations.analyticsEnabled()
|
||||
})
|
||||
ipcMain.handle(WELCOME_IPC.analytics, async (event, eventName: unknown, attributes: unknown) => {
|
||||
assertSender(event)
|
||||
if (typeof attributes !== 'object' || attributes === null || Array.isArray(attributes)) throw new Error('desktop welcome: invalid analytics attributes')
|
||||
if (eventName === 'auth_page_click' && 'button_name' in attributes && Object.keys(attributes).length === 1
|
||||
&& (attributes.button_name === 'sign_in' || attributes.button_name === 'api-key')) {
|
||||
await operations.analytics?.(eventName, { button_name: attributes.button_name })
|
||||
} else if ((eventName === 'auth_page_view' || eventName === 'api_key_save_click') && Object.keys(attributes).length === 0) {
|
||||
await operations.analytics?.(eventName, {})
|
||||
} else throw new Error('desktop welcome: invalid analytics event')
|
||||
})
|
||||
ipcMain.handle(WELCOME_IPC.takeNotice, async (event) => { assertSender(event); return operations.takeNotice() })
|
||||
ipcMain.handle(WELCOME_IPC.saveApiKey, async (event, value: unknown) => {
|
||||
assertSender(event)
|
||||
@@ -108,6 +124,9 @@ export async function openWelcomeWindow(locale: DesktopLocale, operations: Welco
|
||||
throw error
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Another window can replace ownership during loadFile.
|
||||
if (active && !window.isDestroyed()) window.show()
|
||||
if (active && !window.isDestroyed()) {
|
||||
window.show()
|
||||
void operations.analytics?.('auth_page_view', {})
|
||||
}
|
||||
return window
|
||||
}
|
||||
@@ -49,14 +49,17 @@ it('receives live expiry separately from account snapshots', async () => {
|
||||
if (typeof address === 'string' || address === null) throw new Error('missing server address')
|
||||
const connected = once(server, 'connection')
|
||||
const expired = vi.fn()
|
||||
const onAnalyticsEnabledChanged = vi.fn()
|
||||
const snapshot = { status: 'signed-out', attempt: null,
|
||||
links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } }
|
||||
const received = Promise.withResolvers<undefined>()
|
||||
const backend = desktopAccountBackend(`http://127.0.0.1:${address.port}`, () => Promise.resolve(snapshot), () => Promise.resolve(''))
|
||||
const stop = backend.watch((value) => { expect(value).toEqual(snapshot); received.resolve(undefined) }, vi.fn(), expired)
|
||||
const stop = backend.watch(
|
||||
(value) => { expect(value).toEqual(snapshot); received.resolve(undefined) }, vi.fn(), expired, onAnalyticsEnabledChanged,
|
||||
)
|
||||
onTestFinished(stop)
|
||||
await connected
|
||||
// Both logical streams share the socket; observe their opening frames before sending data.
|
||||
// Logical streams share the socket; observe their opening frames before sending data.
|
||||
const streams = new Map<string, string>()
|
||||
const opened = Promise.withResolvers<undefined>()
|
||||
const socket = [...server.clients][0]!
|
||||
@@ -64,11 +67,19 @@ it('receives live expiry separately from account snapshots', async () => {
|
||||
if (!Buffer.isBuffer(data)) throw new Error('expected a Buffer WebSocket frame')
|
||||
const frame = JSON.parse(data.toString('utf8')) as { endpoint: string; streamId: string }
|
||||
streams.set(frame.endpoint, frame.streamId)
|
||||
if (streams.size === 2) opened.resolve(undefined)
|
||||
if (streams.size === 3) opened.resolve(undefined)
|
||||
})
|
||||
await opened.promise
|
||||
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('account/watch'), value: snapshot }))
|
||||
await received.promise
|
||||
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('productAnalytics/watchPolicy'), value: true }))
|
||||
await vi.waitFor(() => { expect(onAnalyticsEnabledChanged).toHaveBeenLastCalledWith(true) })
|
||||
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('productAnalytics/watchPolicy'), value: false }))
|
||||
await vi.waitFor(() => { expect(onAnalyticsEnabledChanged).toHaveBeenLastCalledWith(false) })
|
||||
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('productAnalytics/watchPolicy'), value: true }))
|
||||
await vi.waitFor(() => { expect(onAnalyticsEnabledChanged).toHaveBeenLastCalledWith(true) })
|
||||
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('productAnalytics/watchPolicy'), value: 'invalid policy' }))
|
||||
await vi.waitFor(() => { expect(onAnalyticsEnabledChanged).toHaveBeenLastCalledWith(false) })
|
||||
expect(expired).not.toHaveBeenCalled()
|
||||
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('account/watchExpiry'), value: 'session-expired' }))
|
||||
await vi.waitFor(() => { expect(expired).toHaveBeenCalledOnce() })
|
||||
|
||||
@@ -172,6 +172,9 @@ const harness = await vi.hoisted(async () => {
|
||||
}),
|
||||
})
|
||||
let accountListener: ((state: AccountView) => void) | undefined
|
||||
let analyticsEnabled = true
|
||||
let analyticsEnabledListener: ((enabled: boolean) => void) | undefined
|
||||
const analytics = vi.fn(async (_event: unknown) => {})
|
||||
const nativeTheme = { themeSource: 'system', shouldUseDarkColors: false }
|
||||
const trays: FakeTray[] = []
|
||||
class FakeTray extends EventEmitter {
|
||||
@@ -184,14 +187,21 @@ const harness = await vi.hoisted(async () => {
|
||||
const shellDialog = { isOpen: false, focus: vi.fn() }
|
||||
return {
|
||||
failWindow(error: Error) { windowFailure = error },
|
||||
windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, nativeTheme, trays, FakeTray, backgroundNotice, shellDialog,
|
||||
windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, nativeTheme, analytics,
|
||||
trays, FakeTray, backgroundNotice, shellDialog,
|
||||
menu, popup, socketHeaders: vi.fn(), updateCheck, updateDownload, updateInstall,
|
||||
platformDispose,
|
||||
platformCloseAndWait,
|
||||
|
||||
watchAccount: (listener: (state: AccountView) => void) => {
|
||||
get analyticsEnabled() { return analyticsEnabled },
|
||||
set analyticsEnabled(value: boolean) { analyticsEnabled = value; analyticsEnabledListener?.(value) },
|
||||
watchAccount: (
|
||||
listener: (state: AccountView) => void, _failed: () => void, _expired: () => void,
|
||||
onAnalyticsEnabledChanged?: (enabled: boolean) => void,
|
||||
) => {
|
||||
analyticsEnabledListener = onAnalyticsEnabledChanged
|
||||
accountListener = listener
|
||||
return () => { accountListener = undefined }
|
||||
return () => { accountListener = undefined; analyticsEnabledListener = undefined }
|
||||
},
|
||||
publishAccount(state: AccountView) { accountListener?.(state) },
|
||||
ipcOn: vi.fn<(channel: string, listener: (event: { sender: unknown; senderFrame: unknown }, ...args: unknown[]) => void) => void>(),
|
||||
@@ -343,6 +353,8 @@ vi.mock('../src/platform-view.ts', async importOriginal => ({
|
||||
}))
|
||||
vi.mock('../src/welcome-backend.ts', () => ({
|
||||
connectDesktopWelcome: async () => ({
|
||||
analyticsEnabled: async () => harness.analyticsEnabled,
|
||||
report: harness.analytics,
|
||||
readLocalePreference: async () => null,
|
||||
read: async (): Promise<unknown> => (await harness.hosts.at(-1)!.fetch()).json() as Promise<unknown>,
|
||||
save: async () => ({ ok: true }),
|
||||
@@ -396,6 +408,7 @@ beforeEach(() => {
|
||||
vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', undefined)
|
||||
vi.stubEnv('DSH_DESKTOP_UPDATE_JOURNAL_DIR', undefined)
|
||||
vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3')
|
||||
harness.analyticsEnabled = true
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -1638,6 +1651,23 @@ describe('desktop main startup', () => {
|
||||
await expect(preparing).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it.each(['accepted', 'failed'] as const)('settles analytics intake before locking API admission and continues after intake failure: %s', async (outcome) => {
|
||||
const host = await readyForUpdate()
|
||||
await vi.waitFor(() => { expect(harness.analytics).toHaveBeenCalledWith(expect.objectContaining({ eventName: 'desktop_app_launch' })) })
|
||||
const intake = Promise.withResolvers<undefined>()
|
||||
harness.analytics.mockImplementationOnce(() => intake.promise)
|
||||
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
|
||||
const preparing = harness.prepareUpdate()
|
||||
await vi.waitFor(() => { expect(harness.analytics).toHaveBeenLastCalledWith(expect.objectContaining({ eventName: 'desktop_upgrade_install_restart_click' })) })
|
||||
expect(host.updateTasks.mock.calls).toEqual([['inspect']])
|
||||
expect(host.stop).not.toHaveBeenCalled()
|
||||
if (outcome === 'accepted') intake.resolve(undefined)
|
||||
else intake.reject(new Error('local analytics intake timed out'))
|
||||
await host.stopping.promise
|
||||
host.exited.resolve()
|
||||
await expect(preparing).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('reports a Platform storage cleanup failure as preparation failure without stopping the Host', async () => {
|
||||
const host = await readyForUpdate()
|
||||
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
|
||||
@@ -2034,7 +2064,9 @@ describe('desktop main startup', () => {
|
||||
primaryRuntime: join('desktop-test-resources', 'runtime', 'primary-runtime'),
|
||||
profile: 'desktop-test-profile',
|
||||
})
|
||||
expect(harness.hosts[0]!.environment).toBe(process.env)
|
||||
expect(harness.hosts[0]!.environment).not.toBe(process.env)
|
||||
expect(harness.hosts[0]!.environment?.DSH_CLIENT_VERSION).toBe('1.2.3')
|
||||
expect(harness.analytics).toHaveBeenCalledExactlyOnceWith({ eventName: 'desktop_app_launch', timestamp: Date.now(), attributes: {} })
|
||||
expect(harness.hosts[0]!.start).toHaveBeenCalledTimes(1)
|
||||
expect(harness.windows).toHaveLength(1)
|
||||
expect(window.urls).toEqual(['dsh-app://app/'])
|
||||
@@ -2159,3 +2191,15 @@ it.each([['light', false], ['dark', true]] as const)('opens Platform authorizati
|
||||
harness.publishAccount(state)
|
||||
expect(harness.openExternal).toHaveBeenCalledExactlyOnceWith(`https://platform.deepseek.com/dsh/authorize?state=state-1&theme=${theme}`)
|
||||
})
|
||||
|
||||
it('disables native product events for a disabled Desktop launch', async () => {
|
||||
harness.analyticsEnabled = false
|
||||
await import('../src/main.ts')
|
||||
await harness.preparing.promise
|
||||
harness.prepared.resolve()
|
||||
await harness.hostStarted.promise
|
||||
harness.hosts[0]!.ready.resolve()
|
||||
await harness.navigated.promise
|
||||
expect(harness.analytics).not.toHaveBeenCalled()
|
||||
harness.analyticsEnabled = true
|
||||
})
|
||||
@@ -61,14 +61,15 @@ function fixture() {
|
||||
})
|
||||
const quitAndInstall = vi.fn()
|
||||
const beforeRestart = vi.fn(async () => true)
|
||||
const downloadResult = vi.fn()
|
||||
const states: DesktopUpdateState[] = []
|
||||
const updater = Object.assign(events, { checkForUpdates, downloadUpdate, quitAndInstall }) as unknown as AppUpdater
|
||||
const coordinator = new DesktopUpdateCoordinator(
|
||||
(state) => { states.push(state); return state },
|
||||
beforeRestart, updater, () => true, () => '1.1.0-alpha.1',
|
||||
beforeRestart, updater, () => true, () => '1.1.0-alpha.1', downloadResult,
|
||||
)
|
||||
coordinators.push(coordinator)
|
||||
return { coordinator, updater, events, states, checkForUpdates, downloadUpdate, quitAndInstall, beforeRestart }
|
||||
return { coordinator, updater, events, states, checkForUpdates, downloadUpdate, quitAndInstall, beforeRestart, downloadResult }
|
||||
}
|
||||
|
||||
describe('desktop update coordinator', () => {
|
||||
@@ -222,3 +223,22 @@ describe('desktop update coordinator', () => {
|
||||
expect(f.events.listenerCount('download-progress')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
it('reports one download result when callers share an operation and none for cached readiness', async () => {
|
||||
const f = fixture()
|
||||
await f.coordinator.check()
|
||||
await Promise.all([f.coordinator.download('1.1.0-rc.2'), f.coordinator.download('1.1.0-rc.2')])
|
||||
await f.coordinator.download('1.1.0-rc.2')
|
||||
expect(f.downloadResult).toHaveBeenCalledExactlyOnceWith(true)
|
||||
})
|
||||
|
||||
|
||||
it.each([new DesktopUpdatePreparationError('stop-failed', 'private diagnostic'), new Error('private URL')])('reports safe download failure classification: %s', async (error) => {
|
||||
const f = fixture()
|
||||
await f.coordinator.check()
|
||||
f.downloadUpdate.mockRejectedValueOnce(error)
|
||||
await f.coordinator.download('1.1.0-rc.2')
|
||||
expect(f.downloadResult).toHaveBeenCalledExactlyOnceWith(false, error instanceof DesktopUpdatePreparationError ? 'stop-failed' : 'download_failed')
|
||||
expect(JSON.stringify(f.downloadResult.mock.calls)).not.toContain('private')
|
||||
})
|
||||
@@ -20,6 +20,8 @@ function transport(preference?: string) {
|
||||
if (method === 'account/getState') value = { links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null }
|
||||
else if (method === 'settings/describe') value = { namespaces }
|
||||
else if (method === 'llm/listConfigurableProviders') value = [{ settingsNs: 'llm-pi-ai', settingsPath: ['profiles', 'example'] }]
|
||||
else if (method === 'productAnalytics/enabled') value = true
|
||||
else if (method === 'productAnalytics/report') value = undefined
|
||||
else if (method === 'credentials/set') keys.set(payload.args.ref, payload.args.value)
|
||||
else value = Object.fromEntries(payload.args.refs.map(ref => [ref, { configured: keys.has(ref), writable: true }]))
|
||||
return Response.json({ type: 'server-response', rpcId, result: { ok: true, value } })
|
||||
@@ -34,6 +36,7 @@ describe('desktop welcome Web operations', () => {
|
||||
const host = transport()
|
||||
const backend = await connectDesktopWelcome(url, host.send)
|
||||
expect(host.send).toHaveBeenCalledExactlyOnceWith(url, { credentials: 'include' })
|
||||
expect(await backend.analyticsEnabled()).toBe(true)
|
||||
expect(await backend.save('sk-example')).toEqual({ ok: true })
|
||||
expect(host.keys.get('CUSTOM_DEEPSEEK_KEY')).toBe('sk-example')
|
||||
expect(await backend.read()).toEqual({ loggedIn: false, hasApiKey: true, writable: true, localePreference: null })
|
||||
@@ -90,6 +93,19 @@ describe('desktop welcome Web operations', () => {
|
||||
await expect(backend.read()).rejects.toThrow('Web RPC failed')
|
||||
})
|
||||
|
||||
|
||||
it('submits native analytics through authenticated RPC with a bounded request', async () => {
|
||||
const host = transport()
|
||||
const backend = await connectDesktopWelcome(url, host.send)
|
||||
const event = { eventName: 'desktop_app_launch' as const, timestamp: 100, attributes: {} }
|
||||
await backend.report(event)
|
||||
const [input, init] = host.send.mock.calls.at(-1)!
|
||||
expect(input).toBe('http://127.0.0.1:19387/api/productAnalytics/report')
|
||||
expect(init?.credentials).toBe('include')
|
||||
expect(init?.signal).toBeInstanceOf(AbortSignal)
|
||||
expect(JSON.parse(init!.body as string)).toMatchObject({ payload: { args: { event } } })
|
||||
})
|
||||
|
||||
it('refuses an unauthenticated Web launch', async () => {
|
||||
const send = vi.fn<Parameters<typeof connectDesktopWelcome>[1]>(async () => new Response(null, { status: 401 }))
|
||||
await expect(connectDesktopWelcome(url, send)).rejects.toThrow('Web authentication failed')
|
||||
|
||||
@@ -16,6 +16,8 @@ function mount(language = 'zh-CN', takeNotice = vi.fn<() => Promise<WelcomeNotic
|
||||
const stopAccount = vi.fn()
|
||||
const api = {
|
||||
takeNotice,
|
||||
analytics: vi.fn(async (_event: string, _attributes: object) => {}),
|
||||
analyticsEnabled: async () => true,
|
||||
onAccountState: vi.fn((_listener: (state: AccountView) => void) => stopAccount),
|
||||
startSignIn: vi.fn(async (): Promise<AccountView> => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null })),
|
||||
cancelSignIn: vi.fn(async (): Promise<AccountView> => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null })),
|
||||
@@ -326,3 +328,16 @@ it.each(['zh-CN', 'en'])('returns from completed sign-in to the initial page aft
|
||||
expect(view.document.querySelector<HTMLElement>('#auth-page')!.hidden).toBe(true)
|
||||
await expect(view.copy()).toMatchFileSnapshot(`./expected/welcome/${language}.expected.txt`)
|
||||
})
|
||||
|
||||
it('reports each return to the welcome entry once, including account cancellation', async () => {
|
||||
const view = mount()
|
||||
expect(view.api.analytics).not.toHaveBeenCalled()
|
||||
fireEvent.click(view.button('#api-key'))
|
||||
fireEvent.click(view.button('#back-to-login'))
|
||||
expect(view.api.analytics.mock.calls.map(([action]) => action)).toEqual(['auth_page_click', 'auth_page_view'])
|
||||
const pending = Promise.withResolvers<AccountView>()
|
||||
view.api.startSignIn.mockReturnValueOnce(pending.promise)
|
||||
fireEvent.click(view.button('#sign-in'))
|
||||
await act(async () => { pending.resolve({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null }) })
|
||||
expect(view.api.analytics.mock.calls.map(([action]) => action)).toEqual(['auth_page_click', 'auth_page_view', 'auth_page_click', 'auth_page_view'])
|
||||
})
|
||||
@@ -119,6 +119,7 @@ vi.mock('../src/host-process.ts', () => ({
|
||||
}))
|
||||
vi.mock('../src/welcome-backend.ts', () => ({
|
||||
connectDesktopWelcome: async () => ({
|
||||
analyticsEnabled: async () => false,
|
||||
readLocalePreference: async () => state.preference,
|
||||
read: async () => {
|
||||
await state.beforeRead()
|
||||
@@ -175,6 +176,7 @@ it.each([false, true])('starts welcome onboarding without carrying update focus
|
||||
state.accountState.mockResolvedValue({ status: 'signed-out', attempt: null, links: { usageUrl: '', topUpUrl: '' } })
|
||||
if (updated) vi.stubGlobal('process', { ...process, platform: 'win32', argv: ['desktop', '--updated'] })
|
||||
vi.useFakeTimers()
|
||||
vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3')
|
||||
vi.stubEnv('DSH_DESKTOP_DEV_PROJECT_DIR', '/development-profile')
|
||||
vi.stubEnv('DSH_DESKTOP_NODE_BINARY', '/runtime/node')
|
||||
vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', '/runtime/pnpm')
|
||||
|
||||
@@ -6,13 +6,13 @@ import { needsWelcome, WELCOME_IPC } from '../src/welcome-api.ts'
|
||||
const electron = vi.hoisted(() => ({
|
||||
create: vi.fn<(options: unknown) => ReturnType<typeof createWindow>>(),
|
||||
root: '/desktop-app',
|
||||
handlers: new Map<string, (event: unknown, value?: unknown) => Promise<unknown>>(),
|
||||
handlers: new Map<string, (event: unknown, value?: unknown, attributes?: unknown) => Promise<unknown>>(),
|
||||
}))
|
||||
vi.mock('electron', () => ({
|
||||
app: { getAppPath: () => electron.root },
|
||||
BrowserWindow: vi.fn(function (options: unknown) { return electron.create(options) }),
|
||||
ipcMain: {
|
||||
handle: (name: string, handler: (event: unknown, value?: unknown) => Promise<unknown>) => {
|
||||
handle: (name: string, handler: (event: unknown, value?: unknown, attributes?: unknown) => Promise<unknown>) => {
|
||||
if (electron.handlers.has(name)) throw new Error(`duplicate IPC handler: ${name}`)
|
||||
electron.handlers.set(name, handler)
|
||||
},
|
||||
@@ -40,6 +40,7 @@ function createWindow() {
|
||||
beforeEach(() => { electron.create.mockReset(); electron.handlers.clear() })
|
||||
|
||||
const operations = {
|
||||
analyticsEnabled: async () => true,
|
||||
takeNotice: async () => undefined,
|
||||
startSignIn: async () => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out' as const, attempt: null }),
|
||||
cancelSignIn: async () => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out' as const, attempt: null }),
|
||||
@@ -186,3 +187,24 @@ describe('desktop welcome window', () => {
|
||||
expect(needsWelcome({ loggedIn: true, hasApiKey: true })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts only permitted event fields and reads the current policy over IPC', async () => {
|
||||
const window = createWindow()
|
||||
electron.create.mockReturnValue(window)
|
||||
const analytics = vi.fn(async () => {})
|
||||
let enabled = true
|
||||
await openWelcomeWindow(resolveDesktopLocale('en'), { ...operations, analytics, analyticsEnabled: async () => enabled })
|
||||
const own = { sender: window.webContents, senderFrame: window.webContents.mainFrame }
|
||||
const policy = electron.handlers.get(WELCOME_IPC.analyticsEnabled)!
|
||||
expect(await policy(own)).toBe(true)
|
||||
enabled = false
|
||||
expect(await policy(own)).toBe(false)
|
||||
const report = electron.handlers.get(WELCOME_IPC.analytics)!
|
||||
await report(own, 'auth_page_click', { button_name: 'api-key' })
|
||||
expect(analytics).toHaveBeenLastCalledWith('auth_page_click', { button_name: 'api-key' })
|
||||
await expect(report(own, 'auth_page_click', { button_name: 'api-key', token: 'private' })).rejects.toThrow('invalid analytics')
|
||||
await expect(report(own, 'desktop_app_launch', {})).rejects.toThrow('invalid analytics')
|
||||
await expect(report(own, 'auth_page_view', null)).rejects.toThrow('invalid analytics')
|
||||
await report(own, 'api_key_save_click', {})
|
||||
expect(analytics).toHaveBeenLastCalledWith('api_key_save_click', {})
|
||||
})
|
||||
@@ -15,6 +15,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../packages/credentials/deepseek-account"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/product-analytics/tsconfig.client.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -28,6 +28,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../packages/util/atomic-write"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/product-analytics/tsconfig.host.json"
|
||||
}
|
||||
],
|
||||
"exclude": [
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { expandOwningTurnProcess, newEnglishPage, openSettings, saveFailureShot } from './support.ts'
|
||||
|
||||
const MODE = webSnapshotMode()
|
||||
const HISTORY_SESSION_ID = 'chat-scroll-history-e2e'
|
||||
@@ -677,7 +677,7 @@ describe('web e2e: long Chat scroll contract', () => {
|
||||
})
|
||||
}, 180_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('follows a growing process group independently of the outer transcript', async () => {
|
||||
it.skipIf(MODE === 'record')('follows a growing Standard-mode process group independently of the outer transcript', async () => {
|
||||
const parts = [
|
||||
'GROUP_SCROLL_START\n\n',
|
||||
...Array.from({ length: 4 }, (_, batch) => Array.from({ length: 20 }, (_, row) =>
|
||||
@@ -701,6 +701,14 @@ describe('web e2e: long Chat scroll contract', () => {
|
||||
}, async (world) => {
|
||||
await world.page.emulateMedia({ reducedMotion: 'no-preference' })
|
||||
await openSeed(world.page, HISTORY_FIXTURE, HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns))
|
||||
await openSettings(world.page, 'en')
|
||||
await world.page.getByRole('dialog', { name: 'Settings', exact: true })
|
||||
.getByText('Work details', { exact: true }).locator('../..')
|
||||
.getByRole('button', { name: 'Detailed', exact: true }).click()
|
||||
await world.page.getByRole('menuitem', { name: 'Standard', exact: true }).click()
|
||||
await expect.poll(() => world.scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value)
|
||||
.toMatchObject({ transcriptView: 'standard' })
|
||||
await world.page.keyboard.press('Escape')
|
||||
const gates = parts.slice(1).map(() => Promise.withResolvers<undefined>())
|
||||
const dispose = world.scaffold.ctx.on('llm/stream', async function* (_options, next) {
|
||||
let index = -1
|
||||
|
||||
@@ -33,6 +33,7 @@ describe.skipIf(MODE === 'record')('web e2e: native and Client locale preference
|
||||
})
|
||||
await page.exposeFunction('nativeLocaleChanged', (locale: string) => { reported.push(locale) })
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(globalThis, 'dshDesktop', { value: { protocolVersion: 1 } })
|
||||
const bridge = globalThis as typeof globalThis & {
|
||||
nativeLocaleRead(): Promise<unknown>
|
||||
nativeLocaleChanged(locale: string): Promise<void>
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
- text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
|
||||
- switch "启用 自动授权审查"
|
||||
- listitem:
|
||||
- button "查看 定时与时间上下文": 定时与时间上下文
|
||||
- text: 实验性 启用持久提醒、自动化任务页面与逐步时钟上下文。
|
||||
- switch "启用 定时与时间上下文"
|
||||
- button "查看 自动化任务": 自动化任务
|
||||
- text: 实验性 按设定的时间或周期,在会话中自动执行任务。
|
||||
- switch "启用 自动化任务"
|
||||
- listitem:
|
||||
- button "查看 语音输入": 语音输入
|
||||
- text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖
|
||||
- text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。
|
||||
- switch "启用 语音输入"
|
||||
- listitem:
|
||||
- button "查看 终端": 终端
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
- button "关闭"
|
||||
- paragraph: 输入插件的包名、GitHub 仓库地址或本地目录路径。
|
||||
- textbox "包名或地址":
|
||||
- /placeholder: 例如 @deepseek-ai/dsh-subagent-codex
|
||||
- /placeholder: 例如 dsh-plugin-whale-pet
|
||||
- text: slow-package
|
||||
- button "插件安装引导和示例"
|
||||
- button "安装源 默认安装源"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
- listitem:
|
||||
- button "查看 slow-package": slow-package
|
||||
- switch "启用 slow-package" [checked]
|
||||
|
||||
[
|
||||
{
|
||||
"colorScheme": "light",
|
||||
"reducedMotion": "no-preference",
|
||||
"boxShadow": "oklab(0.589542 -0.0217781 -0.177995 / 0.4) 0px 0px 0px 2px",
|
||||
"animated": true
|
||||
},
|
||||
{
|
||||
"colorScheme": "light",
|
||||
"reducedMotion": "reduce",
|
||||
"boxShadow": "color(srgb 0.254902 0.462745 0.901961 / 0.4) 0px 0px 0px 2px",
|
||||
"animated": false
|
||||
},
|
||||
{
|
||||
"colorScheme": "dark",
|
||||
"reducedMotion": "no-preference",
|
||||
"boxShadow": "oklab(0.739592 -0.02038 -0.132631 / 0.4) 0px 0px 0px 2px",
|
||||
"animated": true
|
||||
},
|
||||
{
|
||||
"colorScheme": "dark",
|
||||
"reducedMotion": "reduce",
|
||||
"boxShadow": "color(srgb 0.478431 0.666667 1 / 0.4) 0px 0px 0px 2px",
|
||||
"animated": false
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,66 @@
|
||||
- radio "自定义地址" [checked]
|
||||
- text: 自定义地址
|
||||
- textbox "自定义地址" [invalid]:
|
||||
- /placeholder: https://npm.example.com/
|
||||
- text: invalid-registry
|
||||
- alert: 请输入以 http:// 或 https:// 开头的地址
|
||||
- text: 填写内网或私有 npm 源地址,以 http:// 或 https:// 开头。若为需要登录的源,请把凭据放在本机的 ~/.npmrc 里。
|
||||
|
||||
{
|
||||
"registry": [
|
||||
{
|
||||
"colorScheme": "light",
|
||||
"focused": {
|
||||
"outline": "none",
|
||||
"boxShadow": "rgb(65, 118, 230) 0px 0px 0px 0.5px inset",
|
||||
"borderColor": "rgb(236, 19, 19)"
|
||||
},
|
||||
"blurred": {
|
||||
"outline": "none",
|
||||
"boxShadow": "none",
|
||||
"borderColor": "rgb(236, 19, 19)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"colorScheme": "dark",
|
||||
"focused": {
|
||||
"outline": "none",
|
||||
"boxShadow": "rgb(122, 170, 255) 0px 0px 0px 0.5px inset",
|
||||
"borderColor": "rgb(242, 90, 90)"
|
||||
},
|
||||
"blurred": {
|
||||
"outline": "none",
|
||||
"boxShadow": "none",
|
||||
"borderColor": "rgb(242, 90, 90)"
|
||||
}
|
||||
}
|
||||
],
|
||||
"packageName": [
|
||||
{
|
||||
"colorScheme": "light",
|
||||
"focused": {
|
||||
"outline": "none",
|
||||
"boxShadow": "rgb(65, 118, 230) 0px 0px 0px 0.5px inset",
|
||||
"borderColor": "rgb(236, 19, 19)"
|
||||
},
|
||||
"blurred": {
|
||||
"outline": "none",
|
||||
"boxShadow": "none",
|
||||
"borderColor": "rgb(236, 19, 19)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"colorScheme": "dark",
|
||||
"focused": {
|
||||
"outline": "none",
|
||||
"boxShadow": "rgb(122, 170, 255) 0px 0px 0px 0.5px inset",
|
||||
"borderColor": "rgb(242, 90, 90)"
|
||||
},
|
||||
"blurred": {
|
||||
"outline": "none",
|
||||
"boxShadow": "none",
|
||||
"borderColor": "rgb(242, 90, 90)"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,14 +2,14 @@
|
||||
- heading "添加插件" [level=2]
|
||||
- button "关闭"
|
||||
- textbox "插件包名":
|
||||
- /placeholder: 例如 @deepseek-ai/dsh-subagent-codex
|
||||
- /placeholder: 例如 dsh-plugin-whale-pet
|
||||
- button "收起引导" [expanded]
|
||||
- button "安装源 中国大陆镜像源"
|
||||
- list:
|
||||
- listitem:
|
||||
- text: 包名 插件包名即 npm 包名(如 dsh-xxx 或 @作者/插件名),社区插件的 README 安装命令中 dsh plugin add 或 pnpm add 之后的部分。 示例:
|
||||
- code: "@deepseek-ai/dsh-subagent-codex"
|
||||
- button "填入示例 @deepseek-ai/dsh-subagent-codex": 填入示例
|
||||
- code: dsh-plugin-whale-pet
|
||||
- button "填入示例 dsh-plugin-whale-pet": 填入示例
|
||||
- listitem:
|
||||
- text: GitHub 仓库地址 插件在 GitHub 上的开源仓库地址,也支持其他 Git 仓库。 示例:
|
||||
- code: https://github.com/author/dsh-plugin
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
- heading "添加插件" [level=2]
|
||||
- button "关闭"
|
||||
- textbox "插件包名":
|
||||
- /placeholder: 例如 @deepseek-ai/dsh-subagent-codex
|
||||
- /placeholder: 例如 dsh-plugin-whale-pet
|
||||
- button "插件安装引导和示例"
|
||||
- button "安装源 中国大陆镜像源"
|
||||
- button "安装" [disabled]
|
||||
@@ -1,10 +1,10 @@
|
||||
- group "从哪个 npm 源下载插件":
|
||||
- radio "npm 官方源(registry.npmjs.org)"
|
||||
- text: npm 官方源(registry.npmjs.org)
|
||||
- radio "中国大陆镜像源(registry.npmmirror.com)" [checked]
|
||||
- text: 中国大陆镜像源(registry.npmmirror.com)
|
||||
- radio "npm 官方源 registry.npmjs.org"
|
||||
- text: npm 官方源 registry.npmjs.org
|
||||
- radio "中国大陆镜像源 registry.npmmirror.com" [checked]
|
||||
- text: 中国大陆镜像源 registry.npmmirror.com
|
||||
- radio "自定义地址"
|
||||
- text: 自定义地址
|
||||
- textbox "自定义地址" [disabled]:
|
||||
- textbox "自定义地址":
|
||||
- /placeholder: https://npm.example.com/
|
||||
- text: 公司内网或私有 npm 源;需要登录的源,把凭据放在本机的 ~/.npmrc 里
|
||||
- text: 填写内网或私有 npm 源地址,以 http:// 或 https:// 开头。若为需要登录的源,请把凭据放在本机的 ~/.npmrc 里。
|
||||
@@ -1,10 +1,10 @@
|
||||
- group "从哪个 npm 源下载插件":
|
||||
- radio "npm 官方源(registry.npmjs.org)" [checked]
|
||||
- text: npm 官方源(registry.npmjs.org)
|
||||
- radio "中国大陆镜像源(registry.npmmirror.com)"
|
||||
- text: 中国大陆镜像源(registry.npmmirror.com)
|
||||
- radio "npm 官方源 registry.npmjs.org" [checked]
|
||||
- text: npm 官方源 registry.npmjs.org
|
||||
- radio "中国大陆镜像源 registry.npmmirror.com"
|
||||
- text: 中国大陆镜像源 registry.npmmirror.com
|
||||
- radio "自定义地址"
|
||||
- text: 自定义地址
|
||||
- textbox "自定义地址" [disabled]:
|
||||
- textbox "自定义地址":
|
||||
- /placeholder: https://npm.example.com/
|
||||
- text: 公司内网或私有 npm 源;需要登录的源,把凭据放在本机的 ~/.npmrc 里
|
||||
- text: 填写内网或私有 npm 源地址,以 http:// 或 https:// 开头。若为需要登录的源,请把凭据放在本机的 ~/.npmrc 里。
|
||||
@@ -1,10 +1,10 @@
|
||||
- group "从哪个 npm 源下载插件":
|
||||
- radio "npm 官方源(registry.npmjs.org)"
|
||||
- text: npm 官方源(registry.npmjs.org)
|
||||
- radio "中国大陆镜像源(registry.npmmirror.com)" [checked]
|
||||
- text: 中国大陆镜像源(registry.npmmirror.com)
|
||||
- radio "npm 官方源 registry.npmjs.org"
|
||||
- text: npm 官方源 registry.npmjs.org
|
||||
- radio "中国大陆镜像源 registry.npmmirror.com" [checked]
|
||||
- text: 中国大陆镜像源 registry.npmmirror.com
|
||||
- radio "自定义地址"
|
||||
- text: 自定义地址
|
||||
- textbox "自定义地址" [disabled]:
|
||||
- textbox "自定义地址":
|
||||
- /placeholder: https://npm.example.com/
|
||||
- text: 公司内网或私有 npm 源;需要登录的源,把凭据放在本机的 ~/.npmrc 里
|
||||
- text: 填写内网或私有 npm 源地址,以 http:// 或 https:// 开头。若为需要登录的源,请把凭据放在本机的 ~/.npmrc 里。
|
||||
@@ -0,0 +1,62 @@
|
||||
Synthetic browser KeyboardEvents, not an OS input-method test.
|
||||
package / isComposing=true: inspect=0, install=0; editable; value retained
|
||||
- textbox "包名或地址":
|
||||
- /placeholder: 例如 dsh-plugin-whale-pet
|
||||
- text: ime-confirm-package
|
||||
package / Safari isComposing=false, keyCode=229: inspect=0, install=0; editable; value retained
|
||||
- textbox "包名或地址":
|
||||
- /placeholder: 例如 dsh-plugin-whale-pet
|
||||
- text: ime-confirm-package
|
||||
package / compositionstart + unmarked Enter: inspect=0, install=0; editable; value retained
|
||||
- textbox "包名或地址":
|
||||
- /placeholder: 例如 dsh-plugin-whale-pet
|
||||
- text: ime-confirm-package
|
||||
package / compositionend + immediate unmarked Enter: inspect=0, install=0; editable; value retained
|
||||
- textbox "包名或地址":
|
||||
- /placeholder: 例如 dsh-plugin-whale-pet
|
||||
- text: ime-confirm-package
|
||||
package / compositionend + 9ms unmarked Enter: inspect=0, install=0; editable; value retained
|
||||
- textbox "包名或地址":
|
||||
- /placeholder: 例如 dsh-plugin-whale-pet
|
||||
- text: ime-confirm-package
|
||||
package / plain Enter: inspect=1, install=1
|
||||
- dialog "插件安装失败":
|
||||
- button "返回编辑": 编辑
|
||||
- button "关闭"
|
||||
- alert: 插件安装失败
|
||||
- paragraph: "IME fixture: no package was installed"
|
||||
- paragraph: ime-confirm-package
|
||||
- paragraph: 版本 1.0.0
|
||||
- button "查看安装详情"
|
||||
- button "重试"
|
||||
custom registry / isComposing=true: inspect=0, install=0; editable; value retained
|
||||
- textbox "自定义地址":
|
||||
- /placeholder: https://npm.example.com/
|
||||
- text: https://registry.example.test/
|
||||
custom registry / Safari isComposing=false, keyCode=229: inspect=0, install=0; editable; value retained
|
||||
- textbox "自定义地址":
|
||||
- /placeholder: https://npm.example.com/
|
||||
- text: https://registry.example.test/
|
||||
custom registry / compositionstart + unmarked Enter: inspect=0, install=0; editable; value retained
|
||||
- textbox "自定义地址":
|
||||
- /placeholder: https://npm.example.com/
|
||||
- text: https://registry.example.test/
|
||||
custom registry / compositionend + immediate unmarked Enter: inspect=0, install=0; editable; value retained
|
||||
- textbox "自定义地址":
|
||||
- /placeholder: https://npm.example.com/
|
||||
- text: https://registry.example.test/
|
||||
custom registry / compositionend + 9ms unmarked Enter: inspect=0, install=0; editable; value retained
|
||||
- textbox "自定义地址":
|
||||
- /placeholder: https://npm.example.com/
|
||||
- text: https://registry.example.test/
|
||||
custom registry / plain Enter: inspect=1, install=1
|
||||
- dialog "插件安装失败":
|
||||
- button "返回编辑": 编辑
|
||||
- button "关闭"
|
||||
- alert: 插件安装失败
|
||||
- paragraph: "IME fixture: no package was installed"
|
||||
- paragraph: ime-confirm-registry
|
||||
- paragraph: 版本 1.0.0
|
||||
- button "查看安装详情"
|
||||
- button "重试"
|
||||
Profile manifest unchanged; controlled Host result performed no package installation.
|
||||
@@ -14,12 +14,12 @@
|
||||
- text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
|
||||
- switch "启用 自动授权审查"
|
||||
- listitem:
|
||||
- button "查看 定时与时间上下文": 定时与时间上下文
|
||||
- text: 实验性 启用持久提醒、自动化任务页面与逐步时钟上下文。
|
||||
- switch "启用 定时与时间上下文"
|
||||
- button "查看 自动化任务": 自动化任务
|
||||
- text: 实验性 按设定的时间或周期,在会话中自动执行任务。
|
||||
- switch "启用 自动化任务"
|
||||
- listitem:
|
||||
- button "查看 语音输入": 语音输入
|
||||
- text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖
|
||||
- text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。
|
||||
- switch "启用 语音输入"
|
||||
- listitem:
|
||||
- button "查看 终端": 终端
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
- heading "插件" [level=1]
|
||||
- paragraph: 添加和管理插件
|
||||
- button "刷新" [disabled]
|
||||
- button "添加插件" [disabled]
|
||||
- status "正在读取插件…"
|
||||
|
||||
1680px: 4 rows; page/group headers, rows, icons, text lines and blank action spaces align within 0.1px
|
||||
1000px: 4 rows; page/group headers, rows, icons, text lines and blank action spaces align within 0.1px
|
||||
Loading: header actions disabled; action spaces are empty 36×20 areas, with no switch placeholders
|
||||
Loaded: skeleton removed; header actions enabled
|
||||
Motion: 13 opacity-only pulses, 2000ms; reduced motion stops all pulses
|
||||
@@ -14,12 +14,12 @@
|
||||
- text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
|
||||
- switch "启用 自动授权审查"
|
||||
- listitem:
|
||||
- button "查看 定时与时间上下文": 定时与时间上下文
|
||||
- text: 实验性 启用持久提醒、自动化任务页面与逐步时钟上下文。
|
||||
- switch "启用 定时与时间上下文"
|
||||
- button "查看 自动化任务": 自动化任务
|
||||
- text: 实验性 按设定的时间或周期,在会话中自动执行任务。
|
||||
- switch "启用 自动化任务"
|
||||
- listitem:
|
||||
- button "查看 语音输入": 语音输入
|
||||
- text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖
|
||||
- text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。
|
||||
- switch "启用 语音输入"
|
||||
- listitem:
|
||||
- button "查看 终端": 终端
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
Hover at 499ms: 0 tooltips
|
||||
Hover at 500ms:
|
||||
- tooltip "刷新"
|
||||
Focus at 499ms: 0 tooltips
|
||||
Focus at 500ms:
|
||||
- tooltip "刷新"
|
||||
failure: pending
|
||||
- button "刷新" [disabled]
|
||||
{"busy":"true","spinners":1,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":[]}
|
||||
failure: settled
|
||||
- button "刷新"
|
||||
{"busy":"false","spinners":0,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]}
|
||||
@@ -1 +1,3 @@
|
||||
- text: Automation task
|
||||
|
||||
- paragraph: "Reminder: Check the deployment log."
|
||||
@@ -19,7 +19,7 @@
|
||||
- button "Increase font size"
|
||||
- button "Decrease font size"
|
||||
- text: px Work details Choose how much detail to show for tool calls
|
||||
- button "Standard"
|
||||
- button "Detailed"
|
||||
- text: Performance & usage Choose how much performance and usage information to show
|
||||
- button "Detailed"
|
||||
- text: Coding Tools Shows trajectory, code diffs, and Agent preset switching in new chats
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
- button "Increase font size"
|
||||
- button "Decrease font size"
|
||||
- text: px Work details Choose how much detail to show for tool calls
|
||||
- button "Standard"
|
||||
- button "Detailed"
|
||||
- text: Performance & usage Choose how much performance and usage information to show
|
||||
- button "Detailed"
|
||||
- text: Coding Tools Shows trajectory, code diffs, and Agent preset switching in new chats
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
- button "增大字号"
|
||||
- button "减小字号"
|
||||
- text: px 工作步骤展示 选择希望看到多少工具调用细节
|
||||
- button "标准"
|
||||
- button "详细"
|
||||
- text: 性能与用量 选择性能与用量信息展示的详细程度
|
||||
- button "详细"
|
||||
- text: 代码工作工具 开启后显示轨迹、本轮代码差异,新对话中的 Agent 预设切换
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
- list:
|
||||
- listitem:
|
||||
- button "tool-subagent-control, tool-subagent-control, 已启用":
|
||||
- button "tool-subagent-control, 已启用":
|
||||
- strong: tool-subagent-control
|
||||
- text: 已启用 Globally named send_message, interrupt_agent, and list_agents tools over ctx.subagents continuations
|
||||
- code: tool-subagent-control
|
||||
- text: Globally named send_message, interrupt_agent, and list_agents tools over ctx.subagents continuations
|
||||
- listitem:
|
||||
- button "tool-subagent-control/list-agents, tool-subagent-list-agents, 已启用":
|
||||
- strong: tool-subagent-control/list-agents
|
||||
- text: 已启用
|
||||
- code: tool-subagent-list-agents
|
||||
- listitem:
|
||||
- button "tool-subagent, tool-subagent, 已启用":
|
||||
- button "tool-subagent, 已启用":
|
||||
- strong: tool-subagent
|
||||
- text: 已启用 Model-facing subagent delegation tool over the ctx.subagents seam
|
||||
- code: tool-subagent
|
||||
- text: Model-facing subagent delegation tool over the ctx.subagents seam
|
||||
- listitem:
|
||||
- button "tool-subagent, tool-subagent-fork, 已启用":
|
||||
- strong: tool-subagent
|
||||
- text: 已启用 Model-facing subagent delegation tool over the ctx.subagents seam
|
||||
- text: Model-facing subagent delegation tool over the ctx.subagents seam
|
||||
- code: tool-subagent-fork
|
||||
- listitem:
|
||||
- button "tool-subagent, tool-subagent-codex, 已停用":
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
- listitem:
|
||||
- button "tool-subagent, tool-subagent, 已启用":
|
||||
- button "tool-subagent, 已启用":
|
||||
- strong: tool-subagent
|
||||
- text: 已启用 Model-facing subagent delegation tool over the ctx.subagents seam
|
||||
- code: tool-subagent
|
||||
- text: Model-facing subagent delegation tool over the ctx.subagents seam
|
||||
@@ -10,7 +10,7 @@
|
||||
- button "Copy"
|
||||
- status: Deep diving...
|
||||
- button "Deep diving for {{duration}}" [disabled] [expanded]
|
||||
- button "Analyzing the request · The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that."
|
||||
- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that."
|
||||
- text: "Interjection: include the word BANANA in your final reply."
|
||||
- button "Copy"
|
||||
- text: "Interjection: include the word ORANGE in your final reply."
|
||||
|
||||
@@ -4,12 +4,60 @@ import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium } from 'playwright'
|
||||
import { chromium, type Locator, type Page } from 'playwright'
|
||||
import { expect, it } from 'vitest'
|
||||
import { launchWebScaffold, captureStableAria, compareOrRefreshGolden, webSnapshotMode, watchConsole, type WebScaffold } from './scaffold.ts'
|
||||
import { ZH_BROWSER_LOCALE } from './support.ts'
|
||||
|
||||
it('cancels installation through the UI, restores files, and offers the spec again', async () => {
|
||||
async function invalidInputStyles(page: Page, input: Locator) {
|
||||
const styles = []
|
||||
try {
|
||||
for (const colorScheme of ['light', 'dark'] as const) {
|
||||
await page.emulateMedia({ colorScheme })
|
||||
await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe(colorScheme === 'dark' ? '' : null)
|
||||
await input.focus()
|
||||
await expect.poll(() => input.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
expect(await input.getAttribute('aria-invalid')).toBe('true')
|
||||
const { focused, focusRingColor, errorBorderColor } = await input.evaluate((element) => {
|
||||
const style = getComputedStyle(element)
|
||||
const probe = document.createElement('span')
|
||||
probe.style.display = 'none'
|
||||
probe.style.color = style.getPropertyValue('--dsw-focus-ring-color').trim() || style.getPropertyValue('--dsw-alias-state-business-primary')
|
||||
element.after(probe)
|
||||
try {
|
||||
const focusRingColor = getComputedStyle(probe).color
|
||||
probe.style.color = style.getPropertyValue('--dsw-alias-state-error-primary')
|
||||
return {
|
||||
focused: { outline: style.outlineStyle, boxShadow: style.boxShadow, borderColor: style.borderColor },
|
||||
focusRingColor,
|
||||
errorBorderColor: getComputedStyle(probe).color,
|
||||
}
|
||||
} finally {
|
||||
probe.remove()
|
||||
}
|
||||
})
|
||||
expect(focused.outline).toBe('none')
|
||||
expect(focused.boxShadow).toContain('inset')
|
||||
expect(focused.boxShadow).toContain('0px 0px 0px 0.5px')
|
||||
expect(focused.boxShadow).toContain(focusRingColor)
|
||||
expect(focused.borderColor).toBe(errorBorderColor)
|
||||
expect(focusRingColor).not.toBe(errorBorderColor)
|
||||
await input.evaluate((element) => { (element as HTMLInputElement).blur() })
|
||||
await expect.poll(() => input.evaluate(element => document.activeElement === element)).toBe(false)
|
||||
const blurred = await input.evaluate((element) => {
|
||||
const style = getComputedStyle(element)
|
||||
return { outline: style.outlineStyle, boxShadow: style.boxShadow, borderColor: style.borderColor }
|
||||
})
|
||||
expect(blurred).toEqual({ outline: 'none', boxShadow: 'none', borderColor: focused.borderColor })
|
||||
styles.push({ colorScheme, focused, blurred })
|
||||
}
|
||||
return styles
|
||||
} finally {
|
||||
await page.emulateMedia({ colorScheme: null })
|
||||
}
|
||||
}
|
||||
|
||||
it('cancels installation, retries and highlights the enabled plugin at 40% alpha, and recovers unknown results', async () => {
|
||||
const scratch = await mkdtemp(join(tmpdir(), 'dsh-install-cancel-'))
|
||||
const overlay = join(scratch, 'cordis.patch.yml')
|
||||
await writeFile(overlay, `- id: plugin-manager\n config: ${JSON.stringify({ pnpmCommand: process.execPath })}\n`)
|
||||
@@ -39,6 +87,7 @@ it('cancels installation through the UI, restores files, and offers the spec aga
|
||||
`)
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
const tripwire = watchConsole(page)
|
||||
await page.clock.install()
|
||||
await page.goto(scaffold.authenticatedUrl)
|
||||
await page.waitForSelector('[class*="frame"]')
|
||||
if (await page.getByRole('dialog', { name: '设置' }).count() > 0) await page.keyboard.press('Escape')
|
||||
@@ -48,6 +97,29 @@ it('cancels installation through the UI, restores files, and offers the spec aga
|
||||
// The dialog is named after its current screen, so it is found by role alone.
|
||||
const dialog = page.getByRole('dialog')
|
||||
await dialog.getByRole('textbox').fill('slow-package')
|
||||
await dialog.getByRole('button', { name: /^安装源/ }).click()
|
||||
const registryMenu = page.locator('[data-install-registry]')
|
||||
const customAddress = registryMenu.getByRole('textbox', { name: '自定义地址', exact: true })
|
||||
await customAddress.fill('invalid-registry')
|
||||
await page.keyboard.press('Escape')
|
||||
await registryMenu.waitFor({ state: 'hidden' })
|
||||
await dialog.getByRole('button', { name: '安装', exact: true }).click()
|
||||
await customAddress.waitFor({ state: 'visible' })
|
||||
await expect.poll(() => customAddress.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
expect(await customAddress.getAttribute('aria-invalid')).toBe('true')
|
||||
const registryStyles = await invalidInputStyles(page, customAddress)
|
||||
const invalidRegistryAria = await captureStableAria(page, '[data-install-registry] > [data-checked="true"]', scaffold.workspaceCwd)
|
||||
await registryMenu.getByRole('radio').first().check()
|
||||
await page.keyboard.press('Escape')
|
||||
await registryMenu.waitFor({ state: 'hidden' })
|
||||
const packageName = dialog.getByRole('textbox', { name: '包名或地址', exact: true })
|
||||
await packageName.fill('./relative')
|
||||
await packageName.press('Enter')
|
||||
await expect.poll(() => packageName.getAttribute('aria-invalid')).toBe('true')
|
||||
const packageStyles = await invalidInputStyles(page, packageName)
|
||||
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-cancel/invalid-registry.expected.md', import.meta.url)),
|
||||
`${invalidRegistryAria}\n\n${JSON.stringify({ registry: registryStyles, packageName: packageStyles }, null, 2)}`, webSnapshotMode())
|
||||
await packageName.fill('slow-package')
|
||||
// Hold request delivery and the real Host's cancellation reply independently.
|
||||
// Cancellation reaches the Host before the install it names.
|
||||
const cancellationArrived = Promise.withResolvers<undefined>()
|
||||
@@ -135,7 +207,46 @@ it('cancels installation through the UI, restores files, and offers the spec aga
|
||||
await dialog.getByRole('button', { name: '查看安装详情', exact: true }).click()
|
||||
await dialog.getByText('Retry completed', { exact: true }).waitFor()
|
||||
expect(JSON.parse(await readFile(manifestPath, 'utf8'))).toMatchObject({ dependencies: { 'slow-package': '1.0.0' } })
|
||||
await dialog.getByRole('button', { name: '关闭', exact: true }).click()
|
||||
// Freeze the real highlight's expiry timer and CSS first frame independently.
|
||||
await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1000))
|
||||
const pausedHighlight = await page.addStyleTag({ content: '[data-plugin-highlight] { animation-play-state: paused !important; }' })
|
||||
try {
|
||||
await dialog.getByRole('button', { name: '立即启用', exact: true }).click()
|
||||
await dialog.waitFor({ state: 'hidden' })
|
||||
const card = panel.locator('[data-plugin-package="slow-package"][data-plugin-highlight]')
|
||||
await card.waitFor({ state: 'visible' })
|
||||
expect(await panel.locator('[data-plugin-highlight]').count()).toBe(1)
|
||||
expect(await card.getAttribute('data-plugin-status')).toBe('running')
|
||||
const styles = []
|
||||
for (const colorScheme of ['light', 'dark'] as const) {
|
||||
await page.emulateMedia({ colorScheme })
|
||||
await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe(colorScheme === 'dark' ? '' : null)
|
||||
for (const reducedMotion of ['no-preference', 'reduce'] as const) {
|
||||
await page.emulateMedia({ reducedMotion })
|
||||
const style = await card.evaluate((element) => {
|
||||
const computed = getComputedStyle(element)
|
||||
return { boxShadow: computed.boxShadow, animationName: computed.animationName, animationDuration: computed.animationDuration }
|
||||
})
|
||||
expect(style.boxShadow).toMatch(/(?:\/|,)\s*0\.4\)/)
|
||||
expect(style.boxShadow).toContain('0px 0px 0px 2px')
|
||||
if (reducedMotion === 'reduce') {
|
||||
expect(style.animationName).toBe('none')
|
||||
} else {
|
||||
expect(style.animationName).toContain('dsh-plugin-highlight')
|
||||
expect(style.animationDuration).toBe('2.4s')
|
||||
}
|
||||
styles.push({ colorScheme, reducedMotion, boxShadow: style.boxShadow, animated: style.animationName !== 'none' })
|
||||
}
|
||||
}
|
||||
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-cancel/highlight.expected.md', import.meta.url)),
|
||||
`${await captureStableAria(page, '[data-plugin-package="slow-package"]', scaffold.workspaceCwd)}\n\n${JSON.stringify(styles, null, 2)}`, webSnapshotMode())
|
||||
await page.clock.runFor(2400)
|
||||
await card.waitFor({ state: 'detached' })
|
||||
} finally {
|
||||
await pausedHighlight.evaluate((element) => { element.parentNode?.removeChild(element) })
|
||||
await page.emulateMedia({ colorScheme: null, reducedMotion: null })
|
||||
await page.clock.resume()
|
||||
}
|
||||
await panel.getByRole('button', { name: '添加插件', exact: true }).click()
|
||||
await dialog.getByRole('textbox').fill('recovered-package')
|
||||
await page.route('**/api/pluginManager/installBundle', async (route) => {
|
||||
|
||||
@@ -75,7 +75,7 @@ it.each(['https://registry.npmjs.org/', MIRROR])('selects the fastest responding
|
||||
expect(await page.getByRole('radio').count()).toBe(0)
|
||||
await registryToggle.click()
|
||||
const options = page.getByRole('group', { name: '从哪个 npm 源下载插件', exact: true })
|
||||
const mirror = options.getByRole('radio', { name: '中国大陆镜像源(registry.npmmirror.com)', exact: true })
|
||||
const mirror = options.getByRole('radio', { name: '中国大陆镜像源 registry.npmmirror.com', exact: true })
|
||||
await expect.poll(async () => (await readFile(requestLog, 'utf8')).trim().split('\n').filter(Boolean)).toHaveLength(2)
|
||||
const pingUrls = (await readFile(requestLog, 'utf8')).trim().split('\n').sort()
|
||||
expect(pingUrls).toEqual(['https://registry.npmjs.org/-/ping', 'https://registry.npmmirror.com/-/ping'].map(url => JSON.stringify(url)))
|
||||
@@ -84,7 +84,7 @@ it.each(['https://registry.npmjs.org/', MIRROR])('selects the fastest responding
|
||||
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-registry/fastest-default.expected.md', import.meta.url)),
|
||||
await captureStableAria(page, '[data-install-registry]', scaffold.workspaceCwd), webSnapshotMode())
|
||||
} else {
|
||||
expect(await options.getByRole('radio', { name: 'npm 官方源(registry.npmjs.org)', exact: true }).isChecked()).toBe(true)
|
||||
expect(await options.getByRole('radio', { name: 'npm 官方源 registry.npmjs.org', exact: true }).isChecked()).toBe(true)
|
||||
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-registry/official-default.expected.md', import.meta.url)),
|
||||
await captureStableAria(page, '[data-install-registry]', scaffold.workspaceCwd), webSnapshotMode())
|
||||
await mirror.click()
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
// managed scaffold profile: installed bundles, their rows, and bundle enablement. Zero
|
||||
// model calls: everything is client state, seeded Session state, profile files, and the settings
|
||||
// document, so there is no fixture and a stray stream would fail loud on the open llm seam.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { mkdtemp, readFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import type { Browser, Locator, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { FiberState } from '@deepseek-ai/cordis'
|
||||
import { OPTIONAL_BUNDLES } from '@deepseek-ai/dsh-app-boot'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
SCAFFOLD_DEFAULTS_BUNDLE, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
@@ -93,6 +94,256 @@ describe('web e2e: plugin manager', () => {
|
||||
return readFile(join(scaffold.harnessHome, ...segments), 'utf8').catch(() => '')
|
||||
}
|
||||
|
||||
it('aligns the first-read skeleton with the loaded plugin cards', async () => {
|
||||
const facts: string[] = []
|
||||
let aria = ''
|
||||
const shots = MODE === 'refresh' ? await mkdtemp(join(tmpdir(), 'dsh-plugin-loading-')) : undefined
|
||||
for (const width of [1680, 1000]) {
|
||||
const context = await browser.newContext({ viewport: { width, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const listBundles = scaffold.ctx.pluginManager.listBundles.bind(scaffold.ctx.pluginManager)
|
||||
const reads: ReturnType<typeof listBundles>[] = []
|
||||
const spy = vi.spyOn(scaffold.ctx.pluginManager, 'listBundles').mockImplementation(() => {
|
||||
const read = release.promise.then(() => listBundles())
|
||||
reads.push(read)
|
||||
return read
|
||||
})
|
||||
try {
|
||||
const probe = await context.newPage()
|
||||
const consoleWatch = watchConsole(probe)
|
||||
await probe.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await probe.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await probe.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
|
||||
await expect.poll(() => spy.mock.calls.length, { timeout: 10_000 }).toBeGreaterThan(0)
|
||||
const panel = probe.locator('[data-plugin-panel]')
|
||||
const skeleton = panel.locator('[data-plugin-loading]')
|
||||
await skeleton.waitFor()
|
||||
await probe.evaluate(() => document.fonts.ready)
|
||||
expect(await skeleton.locator(':scope > ul > li').count()).toBe(4)
|
||||
expect(await skeleton.getAttribute('role')).toBe('status')
|
||||
expect(await skeleton.getAttribute('aria-label')).toBe('正在读取插件…')
|
||||
expect(await panel.getAttribute('aria-busy')).toBe('true')
|
||||
const actions = panel.locator(':scope > header button')
|
||||
expect(await actions.count()).toBe(2)
|
||||
for (const action of await actions.all()) expect(await action.isDisabled()).toBe(true)
|
||||
const loadingAria = await captureStableAria(probe, '[data-plugin-panel]', scaffold.workspaceCwd)
|
||||
if (aria === '') aria = loadingAria
|
||||
else expect(loadingAria).toBe(aria)
|
||||
|
||||
const measure = (group: Locator) => group.evaluate((element) => {
|
||||
const rect = (node: Element | null | undefined) => {
|
||||
if (node === null || node === undefined) throw new Error('Missing plugin layout element')
|
||||
const { x, y, width, height } = node.getBoundingClientRect()
|
||||
return { x, y, width, height }
|
||||
}
|
||||
return {
|
||||
pageHeader: rect(element.closest('[data-plugin-panel]')?.querySelector(':scope > header')),
|
||||
groupHeader: rect(element.firstElementChild),
|
||||
rows: Array.from(element.querySelectorAll(':scope > ul > li')).slice(0, 4).map((row) => {
|
||||
const head = row.firstElementChild
|
||||
const main = head?.children[1]
|
||||
return {
|
||||
row: rect(row), head: rect(head), icon: rect(head?.children[0]), main: rect(main),
|
||||
titleRow: rect(main?.children[0]), title: rect(main?.children[0]?.firstElementChild),
|
||||
description: rect(main?.children[1]), actions: rect(head?.children[2]),
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
const loading = await measure(skeleton)
|
||||
const blankActions = await skeleton.locator(':scope > ul > li > div > div:last-child').evaluateAll(nodes => nodes.map(node => ({
|
||||
children: node.childElementCount,
|
||||
background: getComputedStyle(node).backgroundColor,
|
||||
width: node.getBoundingClientRect().width,
|
||||
height: node.getBoundingClientRect().height,
|
||||
})))
|
||||
expect(blankActions).toEqual(Array.from({ length: 4 }, () => ({ children: 0, background: 'rgba(0, 0, 0, 0)', width: 36, height: 20 })))
|
||||
if (shots !== undefined) {
|
||||
for (const colorScheme of ['light', 'dark'] as const) {
|
||||
await probe.emulateMedia({ colorScheme })
|
||||
const path = join(shots, `loading-${width}-${colorScheme}.png`)
|
||||
await panel.screenshot({ path, animations: 'disabled' })
|
||||
console.log(`Plugin loading screenshot: ${path}`)
|
||||
}
|
||||
}
|
||||
const motion = await skeleton.evaluate(element => element.getAnimations({ subtree: true }).map(animation => ({
|
||||
duration: animation.effect?.getTiming().duration,
|
||||
opacityOnly: animation.effect instanceof KeyframeEffect
|
||||
&& animation.effect.getKeyframes().every(frame => frame.opacity !== undefined && frame.transform === undefined),
|
||||
})))
|
||||
expect(motion.length).toBe(13)
|
||||
expect(motion.every(animation => animation.duration === 2000 && animation.opacityOnly)).toBe(true)
|
||||
await probe.emulateMedia({ reducedMotion: 'reduce' })
|
||||
await expect.poll(() => skeleton.evaluate(element => element.getAnimations({ subtree: true }).length)).toBe(0)
|
||||
await probe.emulateMedia({ reducedMotion: 'no-preference', colorScheme: null })
|
||||
|
||||
release.resolve(undefined)
|
||||
await skeleton.waitFor({ state: 'detached' })
|
||||
expect(await panel.getAttribute('aria-busy')).toBe('false')
|
||||
for (const action of await actions.all()) expect(await action.isDisabled()).toBe(false)
|
||||
const official = panel.locator('[data-plugin-group="official"]')
|
||||
await official.locator('[data-plugin-package]').first().waitFor()
|
||||
const loaded = await measure(official)
|
||||
expect(loaded.rows).toHaveLength(4)
|
||||
const compare = (name: string, a: typeof loading.pageHeader, b: typeof loaded.pageHeader, axes: readonly (keyof typeof a)[] = ['x', 'y', 'width', 'height']) => {
|
||||
for (const axis of axes) {
|
||||
expect(Math.abs(a[axis] - b[axis]), `${width}px ${name}.${axis}: loading=${a[axis]}, loaded=${b[axis]}`).toBeLessThanOrEqual(0.1)
|
||||
}
|
||||
}
|
||||
compare('pageHeader', loading.pageHeader, loaded.pageHeader)
|
||||
compare('groupHeader', loading.groupHeader, loaded.groupHeader)
|
||||
for (const [index, row] of loading.rows.entries()) {
|
||||
const real = loaded.rows[index]!
|
||||
for (const part of ['row', 'head', 'icon', 'main', 'titleRow', 'actions'] as const) compare(`row ${index + 1} ${part}`, row[part], real[part])
|
||||
// Painted text bars are deliberately shorter than real copy; their line origins and heights align.
|
||||
for (const part of ['title', 'description'] as const) compare(`row ${index + 1} ${part}`, row[part], real[part], ['x', 'y', 'height'])
|
||||
}
|
||||
facts.push(`${width}px: 4 rows; page/group headers, rows, icons, text lines and blank action spaces align within 0.1px`)
|
||||
expect(consoleWatch.pageErrors).toEqual([])
|
||||
} finally {
|
||||
release.resolve(undefined)
|
||||
spy.mockRestore()
|
||||
try {
|
||||
await context.close()
|
||||
} finally {
|
||||
await Promise.allSettled(reads)
|
||||
}
|
||||
}
|
||||
}
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'loading.expected.md'), [
|
||||
aria, '', ...facts,
|
||||
'Loading: header actions disabled; action spaces are empty 36×20 areas, with no switch placeholders',
|
||||
'Loaded: skeleton removed; header actions enabled',
|
||||
'Motion: 13 opacity-only pulses, 2000ms; reduced motion stops all pulses',
|
||||
].join('\n'), MODE)
|
||||
})
|
||||
|
||||
it('delays refresh hints and keeps cards through manual refresh, failure, and retry', async () => {
|
||||
const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
const listBundles = scaffold.ctx.pluginManager.listBundles.bind(scaffold.ctx.pluginManager)
|
||||
const reads: ReturnType<typeof listBundles>[] = []
|
||||
try {
|
||||
const probe = await context.newPage()
|
||||
const consoleWatch = watchConsole(probe)
|
||||
onTestFailed(() => saveFailureShot(probe, 'web-e2e-plugin-manager-refresh'))
|
||||
await probe.clock.install()
|
||||
await probe.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await probe.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
|
||||
const panel = probe.locator('[data-plugin-panel]')
|
||||
const refresh = panel.getByRole('button', { name: '刷新', exact: true })
|
||||
const add = panel.getByRole('button', { name: '添加插件', exact: true })
|
||||
await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).waitFor()
|
||||
await expect.poll(() => refresh.isEnabled()).toBe(true)
|
||||
await probe.evaluate(() => document.fonts.ready)
|
||||
const cards = panel.locator('[data-plugin-package], [data-plugin-item]')
|
||||
const cardText = await cards.allTextContents()
|
||||
expect(cardText.length).toBeGreaterThan(0)
|
||||
const tooltip = probe.getByRole('tooltip').filter({ hasText: '刷新' })
|
||||
const trace: string[] = []
|
||||
const recordRefresh = async (phase: string) => {
|
||||
trace.push(phase, await refresh.ariaSnapshot(), JSON.stringify({
|
||||
busy: await panel.getAttribute('aria-busy'),
|
||||
spinners: await refresh.locator('[data-state="ongoing"]').count(),
|
||||
cards: await cards.count(),
|
||||
skeletons: await panel.locator('[data-plugin-loading]').count(),
|
||||
alerts: await panel.getByRole('alert').allTextContents(),
|
||||
retry: await panel.getByRole('button', { name: '重试', exact: true }).count(),
|
||||
toasts: await probe.locator('body > [role="alert"]').allTextContents(),
|
||||
}))
|
||||
}
|
||||
const bounds = await refresh.boundingBox()
|
||||
if (bounds === null) throw new Error('Refresh button has no visible bounds')
|
||||
await probe.clock.pauseAt(await probe.evaluate(() => Date.now() + 1000))
|
||||
try {
|
||||
await probe.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2)
|
||||
await probe.clock.runFor(499)
|
||||
expect(await tooltip.count()).toBe(0)
|
||||
trace.push(`Hover at 499ms: ${await tooltip.count()} tooltips`)
|
||||
await probe.clock.runFor(1)
|
||||
await tooltip.waitFor()
|
||||
trace.push('Hover at 500ms:', await tooltip.ariaSnapshot())
|
||||
await probe.mouse.move(0, 999)
|
||||
await tooltip.waitFor({ state: 'detached' })
|
||||
|
||||
await add.focus()
|
||||
await probe.keyboard.press('Shift+Tab')
|
||||
expect(await refresh.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await probe.clock.runFor(499)
|
||||
expect(await tooltip.count()).toBe(0)
|
||||
trace.push(`Focus at 499ms: ${await tooltip.count()} tooltips`)
|
||||
await probe.clock.runFor(1)
|
||||
await tooltip.waitFor()
|
||||
trace.push('Focus at 500ms:', await tooltip.ariaSnapshot())
|
||||
await probe.keyboard.press('Tab')
|
||||
await tooltip.waitFor({ state: 'detached' })
|
||||
} finally {
|
||||
await probe.clock.resume()
|
||||
}
|
||||
|
||||
await probe.clock.pauseAt(await probe.evaluate(() => Date.now() + 1000))
|
||||
const spy = vi.spyOn(scaffold.ctx.pluginManager, 'listBundles')
|
||||
try {
|
||||
{
|
||||
// One failure round exercises the full path: kept cards and spinner while pending,
|
||||
// then the failure toast with no inline error over the stale cards. Success and retry
|
||||
// outcomes are the store's deterministic concern, covered by manager-store.client.spec.ts.
|
||||
const outcome = 'failure' as const
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const called = spy.mock.calls.length
|
||||
spy.mockImplementationOnce(() => {
|
||||
const read = release.promise.then(() => { throw new Error('Fixture plugin refresh failed') })
|
||||
reads.push(read)
|
||||
return read
|
||||
})
|
||||
try {
|
||||
await refresh.click()
|
||||
await expect.poll(() => spy.mock.calls.length).toBeGreaterThan(called)
|
||||
expect(await refresh.isDisabled()).toBe(true)
|
||||
expect(await refresh.getAttribute('aria-busy')).toBe('true')
|
||||
expect(await panel.getAttribute('aria-busy')).toBe('true')
|
||||
const spinner = refresh.locator('[data-state="ongoing"]')
|
||||
await spinner.waitFor()
|
||||
expect(await spinner.evaluate(element => element.getAnimations({ subtree: true }).length)).toBeGreaterThan(0)
|
||||
expect(await cards.allTextContents()).toEqual(cardText)
|
||||
expect(await panel.locator('[data-plugin-loading]').count()).toBe(0)
|
||||
expect(await tooltip.count()).toBe(0)
|
||||
expect(await panel.getByRole('alert').count()).toBe(0)
|
||||
expect(await probe.locator('body > [role="alert"]').count()).toBe(0)
|
||||
await recordRefresh(`${outcome}: pending`)
|
||||
|
||||
release.resolve(undefined)
|
||||
await probe.clock.runFor(400)
|
||||
await expect.poll(() => refresh.isEnabled()).toBe(true)
|
||||
expect(await refresh.getAttribute('aria-busy')).toBe('false')
|
||||
expect(await panel.getAttribute('aria-busy')).toBe('false')
|
||||
expect(await spinner.count()).toBe(0)
|
||||
expect(await cards.allTextContents()).toEqual(cardText)
|
||||
expect(await panel.locator('[data-plugin-loading]').count()).toBe(0)
|
||||
expect(await panel.getByRole('alert').count()).toBe(0)
|
||||
expect(await panel.getByRole('button', { name: '重试', exact: true }).count()).toBe(0)
|
||||
const toasts = probe.locator('body > [role="alert"]')
|
||||
await expect.poll(() => toasts.allTextContents()).toEqual(['刷新失败,请重试'])
|
||||
await recordRefresh(`${outcome}: settled`)
|
||||
} finally {
|
||||
release.resolve(undefined)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
await probe.clock.resume()
|
||||
}
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'refresh.expected.md'), trace.join('\n'), MODE)
|
||||
expect(consoleWatch.pageErrors).toEqual([])
|
||||
expect(consoleWatch.warnings).toEqual([])
|
||||
} finally {
|
||||
try {
|
||||
await context.close()
|
||||
} finally {
|
||||
await Promise.allSettled(reads)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('starts with an unavailable selected bundle and lets the user clear its error', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-missing-bundle'))
|
||||
const panel = await openPluginsPanel()
|
||||
@@ -123,6 +374,26 @@ describe('web e2e: plugin manager', () => {
|
||||
await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).waitFor({ timeout: 20_000 })
|
||||
const toggle = panel.getByRole('switch', { name: '启用 @fixture/bundle' })
|
||||
expect(await toggle.getAttribute('aria-checked')).toBe('false')
|
||||
const card = panel.locator('[data-plugin-package="@fixture/bundle"]')
|
||||
const open = card.getByRole('button', { name: '查看 @fixture/bundle', exact: true })
|
||||
await open.hover()
|
||||
const hoverRadius = await card.evaluate(element => getComputedStyle(element).borderRadius)
|
||||
await toggle.focus()
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
expect(await open.evaluate(element => element.matches(':focus-visible'))).toBe(true)
|
||||
const focusRing = await open.evaluate((element) => {
|
||||
const style = getComputedStyle(element, '::after')
|
||||
return {
|
||||
radius: style.borderRadius,
|
||||
cardRadius: style.getPropertyValue('--dsw-radius-xl').trim(),
|
||||
outlineStyle: style.outlineStyle,
|
||||
outlineWidth: Number.parseFloat(style.outlineWidth),
|
||||
}
|
||||
})
|
||||
expect(focusRing.radius).toBe(hoverRadius)
|
||||
expect(focusRing.radius).toBe(focusRing.cardRadius)
|
||||
expect(focusRing.outlineStyle).toBe('solid')
|
||||
expect(focusRing.outlineWidth).toBeGreaterThan(0)
|
||||
// The profile's own group holds its fixture bundle and the scaffold's defaults bundle; the installation's
|
||||
// optional bundles open the Official group, followed by the official plugins that registered their
|
||||
// configuration, and its other bundles stay off the page.
|
||||
@@ -239,6 +510,8 @@ describe('web e2e: plugin manager', () => {
|
||||
await panel.getByRole('button', { name: 'Back to plugins' }).click()
|
||||
await panel.getByRole('button', { name: 'View Agent Teams', exact: true }).waitFor()
|
||||
expect(await panel.getByRole('switch', { name: 'Enable Agent Teams', exact: true }).count()).toBe(1)
|
||||
await panel.getByRole('button', { name: 'View Automation tasks', exact: true }).waitFor()
|
||||
expect(await panel.getByText('Run tasks in your sessions at a set time or on a repeating schedule.', { exact: true }).count()).toBe(1)
|
||||
// The official configuration pages follow the language too, from their own dictionary.
|
||||
for (const title of ['Shell', 'Agent loop', 'Subagent', 'Web search']) {
|
||||
await panel.getByRole('button', { name: `View ${title}`, exact: true }).waitFor()
|
||||
@@ -312,6 +585,172 @@ describe('web e2e: plugin manager', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('lists the Automation tasks rows and mounts them with the bundle switch', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-schedule'))
|
||||
const panel = await openPluginsPanel()
|
||||
const scheduleRows = () => [...scaffold.ctx.loader.entries()]
|
||||
.filter(entry => ['time-context', 'schedule', 'ui-schedule'].includes(entry.options.id))
|
||||
const running = () => scheduleRows().filter(entry => entry.fiber?.state === FiberState.ACTIVE).length
|
||||
expect(running()).toBe(0)
|
||||
await panel.getByRole('button', { name: '查看 自动化任务', exact: true }).click()
|
||||
const rows = panel.locator('[data-plugin-rows]')
|
||||
for (const title of ['时间感知', '任务调度', '任务界面']) {
|
||||
await rows.locator('[data-plugin-row]', { hasText: title }).waitFor()
|
||||
}
|
||||
// A bundle that is off offers no row switches.
|
||||
expect(await rows.getByRole('switch').count()).toBe(0)
|
||||
const toggle = panel.getByRole('switch', { name: '启用 自动化任务', exact: true })
|
||||
await toggle.click()
|
||||
try {
|
||||
await expect.poll(running, { timeout: 20_000 }).toBe(3)
|
||||
await expect.poll(() => rows.locator('[data-plugin-row]', { hasText: '运行中' }).count(), { timeout: 20_000 }).toBe(3)
|
||||
for (const title of ['时间感知', '任务调度', '任务界面']) {
|
||||
await rows.getByRole('switch', { name: `启用组件 ${title}`, exact: true }).waitFor()
|
||||
}
|
||||
await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '自动化任务', exact: true }).waitFor()
|
||||
} finally {
|
||||
if (await toggle.getAttribute('aria-checked') === 'true') await toggle.click()
|
||||
await expect.poll(running, { timeout: 20_000 }).toBe(0)
|
||||
await panel.getByRole('button', { name: '返回插件列表' }).click()
|
||||
}
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps IME confirmation Enter in install fields and submits only a plain Enter', async () => {
|
||||
const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
const manager = scaffold.ctx.pluginManager
|
||||
const manifest = await homeFile('profiles', 'scaffold', 'package.json')
|
||||
const registrySpy = vi.spyOn(manager, 'registries').mockResolvedValue({
|
||||
registry: null, fallbackRegistries: [], resolved: 'https://registry.npmjs.org/',
|
||||
})
|
||||
const inspectSpy = vi.spyOn(manager, 'inspect').mockImplementation(async (spec, options) => ({
|
||||
status: 'accepted', kind: 'registry', name: spec, version: '1.0.0', bundle: true, registry: options?.registry ?? null,
|
||||
}))
|
||||
const installSpy = vi.spyOn(manager, 'installBundle').mockImplementation(async spec => ({
|
||||
changed: false, application: 'failed', stage: 'install', target: spec,
|
||||
error: { code: 'operation-error', diagnostic: 'IME fixture: no package was installed' },
|
||||
}))
|
||||
try {
|
||||
const probe = await context.newPage()
|
||||
const consoleWatch = watchConsole(probe)
|
||||
onTestFailed(() => saveFailureShot(probe, 'web-e2e-plugin-manager-ime-enter'))
|
||||
await probe.clock.install()
|
||||
await probe.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await probe.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
|
||||
const panel = probe.locator('[data-plugin-panel]')
|
||||
await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).waitFor({ timeout: 20_000 })
|
||||
const trace: string[] = ['Synthetic browser KeyboardEvents, not an OS input-method test.']
|
||||
for (const target of ['package', 'custom registry'] as const) {
|
||||
inspectSpy.mockClear()
|
||||
installSpy.mockClear()
|
||||
await panel.getByRole('button', { name: '添加插件', exact: true }).click()
|
||||
const dialog = probe.getByRole('dialog', { name: '添加插件', exact: true })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
const spec = target === 'package' ? 'ime-confirm-package' : 'ime-confirm-registry'
|
||||
const specField = dialog.getByRole('textbox', { name: '包名或地址', exact: true })
|
||||
await specField.fill(spec)
|
||||
let field = specField
|
||||
const customRegistry = 'https://registry.example.test/'
|
||||
if (target === 'custom registry') {
|
||||
const registryToggle = dialog.getByRole('button', { name: /^安装源/ })
|
||||
await registryToggle.click()
|
||||
const registry = probe.locator('[data-install-registry]')
|
||||
const offered = registry.getByRole('radio', { name: 'npm 官方源 registry.npmjs.org', exact: true })
|
||||
const custom = registry.getByRole('radio', { name: '自定义地址', exact: true })
|
||||
field = registry.getByRole('textbox', { name: '自定义地址', exact: true })
|
||||
expect(await offered.isChecked()).toBe(true)
|
||||
expect(await custom.isChecked()).toBe(false)
|
||||
await offered.focus()
|
||||
await probe.keyboard.press('Tab')
|
||||
expect(await field.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
expect(await offered.isChecked()).toBe(true)
|
||||
expect(await custom.isChecked()).toBe(false)
|
||||
await probe.keyboard.press('Shift+Tab')
|
||||
expect(await offered.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await probe.keyboard.press('Shift+Tab')
|
||||
await registry.waitFor({ state: 'detached' })
|
||||
expect(await registryToggle.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await probe.keyboard.press('Enter')
|
||||
await custom.focus()
|
||||
await probe.keyboard.press('Space')
|
||||
expect(await custom.isChecked()).toBe(true)
|
||||
expect(await offered.isChecked()).toBe(false)
|
||||
expect(await field.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await field.fill(customRegistry)
|
||||
await probe.keyboard.press('Shift+Tab')
|
||||
expect(await custom.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await probe.keyboard.press('Tab')
|
||||
expect(await field.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await probe.keyboard.press('Tab')
|
||||
await registry.waitFor({ state: 'detached' })
|
||||
expect(await registryToggle.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await probe.keyboard.press('Enter')
|
||||
await custom.focus()
|
||||
await probe.keyboard.press('Tab')
|
||||
expect(await field.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
expect(await custom.isChecked()).toBe(true)
|
||||
}
|
||||
const value = target === 'package' ? spec : customRegistry
|
||||
const assertUnsubmitted = async (label: string) => {
|
||||
expect(await field.inputValue()).toBe(value)
|
||||
expect(await field.isEditable()).toBe(true)
|
||||
expect(await dialog.getByRole('button', { name: '安装', exact: true }).isEnabled()).toBe(true)
|
||||
expect(inspectSpy).not.toHaveBeenCalled()
|
||||
expect(installSpy).not.toHaveBeenCalled()
|
||||
trace.push(`${target} / ${label}: inspect=0, install=0; editable; value retained`, await field.ariaSnapshot())
|
||||
}
|
||||
await probe.clock.pauseAt(await probe.evaluate(() => Date.now() + 1000))
|
||||
try {
|
||||
for (const event of [
|
||||
{ label: 'isComposing=true', isComposing: true, keyCode: 13 },
|
||||
{ label: 'Safari isComposing=false, keyCode=229', isComposing: false, keyCode: 229 },
|
||||
]) {
|
||||
await field.dispatchEvent('keydown', {
|
||||
key: 'Enter', code: 'Enter', bubbles: true, cancelable: true,
|
||||
isComposing: event.isComposing, keyCode: event.keyCode,
|
||||
})
|
||||
await assertUnsubmitted(event.label)
|
||||
}
|
||||
const unmarkedEnter = { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true, isComposing: false, keyCode: 13 }
|
||||
await field.dispatchEvent('compositionstart')
|
||||
await field.dispatchEvent('keydown', unmarkedEnter)
|
||||
await assertUnsubmitted('compositionstart + unmarked Enter')
|
||||
await field.dispatchEvent('compositionend')
|
||||
await field.dispatchEvent('keydown', unmarkedEnter)
|
||||
await assertUnsubmitted('compositionend + immediate unmarked Enter')
|
||||
await probe.clock.runFor(9)
|
||||
await field.dispatchEvent('keydown', unmarkedEnter)
|
||||
await assertUnsubmitted('compositionend + 9ms unmarked Enter')
|
||||
await probe.clock.runFor(2)
|
||||
} finally {
|
||||
await probe.clock.resume()
|
||||
}
|
||||
await field.press('Enter')
|
||||
await expect.poll(() => inspectSpy.mock.calls.length, { timeout: 10_000 }).toBe(1)
|
||||
await expect.poll(() => installSpy.mock.calls.length, { timeout: 10_000 }).toBe(1)
|
||||
expect(inspectSpy.mock.calls[0]?.[0]).toBe(spec)
|
||||
expect(installSpy.mock.calls[0]?.[0]).toBe(spec)
|
||||
expect(installSpy.mock.calls[0]?.[1]).toMatchObject({ enabled: false, registry: target === 'package' ? null : customRegistry })
|
||||
const failed = probe.getByRole('dialog', { name: '插件安装失败', exact: true })
|
||||
await failed.waitFor({ timeout: 10_000 })
|
||||
trace.push(`${target} / plain Enter: inspect=1, install=1`, await failed.ariaSnapshot())
|
||||
await failed.getByRole('button', { name: '关闭', exact: true }).click()
|
||||
await failed.waitFor({ state: 'hidden', timeout: 10_000 })
|
||||
}
|
||||
expect(await homeFile('profiles', 'scaffold', 'package.json')).toBe(manifest)
|
||||
expect(consoleWatch.pageErrors).toEqual([])
|
||||
trace.push('Profile manifest unchanged; controlled Host result performed no package installation.')
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'ime-enter.expected.md'), trace.join('\n'), MODE)
|
||||
} finally {
|
||||
try { await context.close() }
|
||||
finally {
|
||||
registrySpy.mockRestore()
|
||||
inspectSpy.mockRestore()
|
||||
installSpy.mockRestore()
|
||||
}
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it('checks a spec before installing it and words what the check refused', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-install'))
|
||||
const panel = await openPluginsPanel()
|
||||
@@ -383,7 +822,8 @@ describe('web e2e: plugin manager', () => {
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', 'icons.expected.md',
|
||||
'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', 'icons.expected.md', 'loading.expected.md', 'refresh.expected.md',
|
||||
'ime-enter.expected.md',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -81,7 +81,8 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => {
|
||||
await row.waitFor({ state: 'attached', timeout: 30_000 })
|
||||
await expandOwningTurnProcess(page, row)
|
||||
await row.waitFor({ state: 'visible' })
|
||||
await page.getByRole('button', { name: 'Preparing to run code', exact: true }).waitFor()
|
||||
await row.getByText('Code', { exact: true }).waitFor()
|
||||
expect(await row.getAttribute('data-tool')).toBe('run_code')
|
||||
expect(sessionEvents.some(event => event.type === 'tool/call')).toBe(false)
|
||||
expect(await row.getByRole('button').count()).toBe(0)
|
||||
expect(await row.locator('pre').count()).toBe(0)
|
||||
|
||||
@@ -462,9 +462,12 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
|
||||
await row.waitFor({ timeout: 15_000 })
|
||||
expect(await row.getAttribute('data-chat-flow-kind')).toBe('assistant-step')
|
||||
expect(await row.textContent()).toContain(AFTER_REPLY)
|
||||
const triggerTitle = '[data-turn-trigger] > button > span:nth-child(2)'
|
||||
await page.locator(triggerTitle).waitFor()
|
||||
expect(await page.locator(triggerTitle).textContent()).toBe('Automation task')
|
||||
await compareOrRefreshGolden(
|
||||
AFTER_EXPECTED,
|
||||
await captureStableAria(page, selector, scaffold.workspaceCwd),
|
||||
`${await captureStableAria(page, triggerTitle, scaffold.workspaceCwd)}\n\n${await captureStableAria(page, selector, scaffold.workspaceCwd)}`,
|
||||
MODE,
|
||||
)
|
||||
await expectNoReminderEntry(page)
|
||||
@@ -839,19 +842,20 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
|
||||
|
||||
it('reads stored tasks without a live Session and deletes them through the catalog', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-catalog'))
|
||||
const shipped = composeEntries([
|
||||
const layers = [
|
||||
loadOverlayPatches('Schedule catalog shipped roster', BASE_PATCH),
|
||||
...WEB_PATCHES.map(file => loadOverlayPatches('Schedule catalog shipped roster', file)),
|
||||
])
|
||||
expect(shipped.find(entry => entry.id === 'ui-schedule')).toMatchObject({
|
||||
name: '@deepseek-ai/dsh-client-ui-schedule',
|
||||
})
|
||||
expect(shipped.find(entry => entry.id === 'ui-schedule')?.disabled).toBe(true)
|
||||
]
|
||||
const shipped = composeEntries(layers)
|
||||
const withBundle = composeEntries([...layers, loadOverlayPatches('Schedule catalog bundle', SCHEDULE_BUNDLE)])
|
||||
for (const row of [
|
||||
{ id: 'time-context', name: '@deepseek-ai/dsh-time-context' },
|
||||
{ id: 'schedule', name: '@deepseek-ai/dsh-schedule' },
|
||||
{ id: 'ui-schedule', name: '@deepseek-ai/dsh-client-ui-schedule' },
|
||||
]) {
|
||||
expect(shipped.filter(entry => entry.id === row.id && entry.name === row.name)).toHaveLength(1)
|
||||
// The shipped Web composition carries none of the rows; the bundle inserts each once, switched on.
|
||||
expect(shipped.some(entry => entry.id === row.id)).toBe(false)
|
||||
expect(withBundle.filter(entry => entry.id === row.id && entry.name === row.name && entry.disabled !== true)).toHaveLength(1)
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Automation tasks', exact: true }).click()
|
||||
|
||||
@@ -71,6 +71,10 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await dialog.getByRole('button', { name: '工作区内修改' }).waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
|
||||
await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
|
||||
await dialog.getByText('工作步骤展示', { exact: true }).locator('../..')
|
||||
.getByRole('button', { name: '详细', exact: true }).waitFor({ timeout: 10_000 })
|
||||
expect(await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8'))
|
||||
.not.toContain('transcriptView:')
|
||||
const openDocument = dialog.getByRole('button', { name: '打开配置文件' })
|
||||
await openDocument.waitFor({ timeout: 10_000 })
|
||||
let openRequests = 0
|
||||
@@ -111,16 +115,17 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
// rewrite this surface's golden.
|
||||
await dialog.getByRole('button', { name: '内置插件', exact: true }).click()
|
||||
await dialog.getByRole('heading', { name: '内置插件', exact: true }).waitFor({ timeout: 10_000 })
|
||||
// Both groups start collapsed; the preset group's header still carries its display-only switcher.
|
||||
// The preset group starts open under its display-only switcher; the global group starts folded.
|
||||
const presetSwitcher = dialog.getByRole('button', { name: '选择要查看的 Agent 预设' })
|
||||
await presetSwitcher.waitFor({ timeout: 10_000 })
|
||||
// The shipped default's zh display name comes from the zh dictionaries.
|
||||
expect(await presetSwitcher.textContent()).toBe('标准模式(默认)')
|
||||
const presetToggle = dialog.getByRole('button', { name: '会话插件', exact: true })
|
||||
expect(await presetToggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(await dialog.locator('[data-plugin-scope="preset"] [data-plugin-entry]').count()).toBe(0)
|
||||
await presetToggle.click()
|
||||
await dialog.getByRole('button', { name: /^全局/ }).click()
|
||||
expect(await presetToggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(await dialog.locator('[data-plugin-scope="preset"] [data-plugin-entry]').count()).toBeGreaterThan(0)
|
||||
const globalToggle = dialog.getByRole('button', { name: /^全局/ })
|
||||
expect(await globalToggle.getAttribute('aria-expanded')).toBe('false')
|
||||
await globalToggle.click()
|
||||
const pluginRow = dialog.locator(PLUGIN_ROW_SELECTOR)
|
||||
await pluginRow.waitFor({ timeout: 10_000 })
|
||||
const expectedPluginCount = [...scaffold.ctx.loader.entries()]
|
||||
@@ -132,11 +137,12 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
// presets took over included, preset compositions excluded.
|
||||
expect(await dialog.locator('[data-plugin-scope="global"] [data-plugin-entry]').count())
|
||||
.toBe(expectedPluginCount)
|
||||
// The enablement tag is the row's collapsed status: an active fiber draws no
|
||||
// dot, so no global row names the active phase. Guard the assertion against
|
||||
// matching nothing because no row is enabled.
|
||||
expect(await dialog.locator('[data-plugin-scope="global"] [data-plugin-entry] button[aria-label$="已启用"]').count())
|
||||
.toBeGreaterThan(0)
|
||||
// A plainly enabled row states its status only in its accessible name: it
|
||||
// carries no tag, and an active fiber draws no dot, so no global row shows
|
||||
// either. Guard the assertions against matching nothing because no row is enabled.
|
||||
const enabledRows = dialog.locator('[data-plugin-scope="global"] [data-plugin-entry] button[aria-label$="已启用"]')
|
||||
expect(await enabledRows.count()).toBeGreaterThan(0)
|
||||
expect(await enabledRows.getByText('已启用', { exact: true }).count()).toBe(0)
|
||||
expect(await dialog.locator('[data-plugin-scope="global"] [role="img"][aria-label="运行中"]').count()).toBe(0)
|
||||
expect(await dialog.locator('[data-plugin-count]').getAttribute('data-plugin-count'))
|
||||
.toBe(String(expectedPluginCount))
|
||||
@@ -159,10 +165,17 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
] as const
|
||||
for (const [entryId, status] of instanceRows) {
|
||||
const row = dialog.locator(`[data-plugin-scope="preset"] [data-plugin-entry="${entryId}"]`)
|
||||
const trigger = row.getByRole('button', { name: `tool-subagent, ${entryId}, ${status}`, exact: true })
|
||||
// An id that repeats the title adds nothing, so its card shows no chip and its name omits it.
|
||||
const repeatsTitle = entryId === 'tool-subagent'
|
||||
const name = repeatsTitle ? `tool-subagent, ${status}` : `tool-subagent, ${entryId}, ${status}`
|
||||
const trigger = row.getByRole('button', { name, exact: true })
|
||||
await trigger.waitFor({ timeout: 10_000 })
|
||||
expect(await trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
const identity = row.locator('code')
|
||||
if (repeatsTitle) {
|
||||
expect(await identity.count()).toBe(0)
|
||||
continue
|
||||
}
|
||||
expect(await identity.textContent()).toBe(entryId)
|
||||
expect(await identity.getAttribute('title')).toBe(entryId)
|
||||
}
|
||||
@@ -560,7 +573,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
}, 90_000)
|
||||
|
||||
it.each([
|
||||
['compact', '简洁'], ['detailed', '详细'], ['verbose', '完全展开'],
|
||||
['compact', '简洁'], ['standard', '标准'], ['verbose', '完全展开'],
|
||||
] as const)('persists the %s work-details mode across reload', async (mode, label) => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-transcript-view'))
|
||||
await openSettings(page, 'zh')
|
||||
@@ -568,7 +581,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByText('工作步骤展示', { exact: true }).waitFor({ timeout: 10_000 })
|
||||
const details = dialog.getByText('工作步骤展示', { exact: true }).locator('../..')
|
||||
await details.getByRole('button', { name: '标准', exact: true }).click()
|
||||
await details.getByRole('button', { name: '详细', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: label, exact: true }).click()
|
||||
await details.getByRole('button', { name: label, exact: true }).waitFor({ timeout: 10_000 })
|
||||
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8'), { timeout: 5_000 })
|
||||
@@ -585,10 +598,10 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await restoredDetails.getByRole('button', { name: label, exact: true }).waitFor({ timeout: 10_000 })
|
||||
|
||||
await restoredDetails.getByRole('button', { name: label, exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: '标准', exact: true }).click()
|
||||
await restoredDetails.getByRole('button', { name: '标准', exact: true }).waitFor({ timeout: 10_000 })
|
||||
await page.getByRole('menuitem', { name: '详细', exact: true }).click()
|
||||
await restoredDetails.getByRole('button', { name: '详细', exact: true }).waitFor({ timeout: 10_000 })
|
||||
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8'), { timeout: 5_000 })
|
||||
.toContain('transcriptView: standard')
|
||||
.toContain('transcriptView: detailed')
|
||||
await page.keyboard.press('Escape')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 90_000)
|
||||
@@ -798,7 +811,8 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await withoutBrowser.goto(fresh.authenticatedUrl, { waitUntil: 'load' })
|
||||
await openSettings(withoutBrowser, 'en')
|
||||
const dialog = withoutBrowser.getByRole('dialog', { name: 'Settings' })
|
||||
await dialog.getByRole('button', { name: 'Detailed', exact: true }).waitFor()
|
||||
await dialog.getByText('Work details', { exact: true }).locator('../..')
|
||||
.getByRole('button', { name: 'Detailed', exact: true }).waitFor()
|
||||
expect(await dialog.getByText('Open chat links in', { exact: true }).count()).toBe(0)
|
||||
const snapshot = await captureStableAria(withoutBrowser, '[role="dialog"]', fresh.workspaceCwd, versionCapture)
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'dialog-no-browser.expected.md'), snapshot, MODE)
|
||||
|
||||
@@ -244,7 +244,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record').each([
|
||||
['compact', 'Compact'], ['detailed', 'Detailed'], ['verbose', 'Verbose'],
|
||||
['compact', 'Compact'], ['standard', 'Standard'], ['verbose', 'Verbose'],
|
||||
] as const)('applies whole-Turn presentation in %s mode', async (mode, label) => {
|
||||
await launch()
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-process-setting'))
|
||||
@@ -259,7 +259,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
|
||||
await openSettings(page, 'en')
|
||||
const dialog = page.getByRole('dialog', { name: 'Settings' })
|
||||
await dialog.getByText('Work details', { exact: true }).locator('../..')
|
||||
.getByRole('button', { name: 'Standard', exact: true }).click()
|
||||
.getByRole('button', { name: 'Detailed', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: label, exact: true }).click()
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
en: 214c3c81c21df6f9
|
||||
zh: 75d2cdc3c9fc9930
|
||||
/deepseek-harness-architecture/turn-flow:
|
||||
en: e13500f6d5b614d6
|
||||
zh: 7e6acb96a520ded2
|
||||
en: badc0ffea030cdce
|
||||
zh: 3241f0b37c3ec067
|
||||
/deepseek-harness-architecture/session-log:
|
||||
en: e1e3a44140036f25
|
||||
zh: 84c70c969e5ad216
|
||||
|
||||
@@ -114,6 +114,8 @@ One inbox feeds the driver; injected context waits for a waking message. AgentLo
|
||||
|
||||
The loop sends immutable requests with live cancellation and reuses freeze evidence for frozen identities; [agent-loop](../packages/core/agent-loop/README.md) owns construction and cancellation causes.
|
||||
|
||||
Failed steps [record missing tool results](../packages/core/agent-loop/README.md#understand-the-implementation).
|
||||
|
||||
Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-execution-pipeline.md), and [cancellation and error recovery](subsystems/core.md#the-agent-handle).
|
||||
|
||||
## Session log
|
||||
|
||||
@@ -118,6 +118,8 @@ turn/end
|
||||
|
||||
循环发送不可变请求,同时保持取消有效,仅对完全冻结的对象复用冻结证据;[agent-loop](../packages/core/agent-loop/README.zh.md) 负责请求构建与取消原因。
|
||||
|
||||
失败步骤会[记录缺失的工具结果](../packages/core/agent-loop/README.zh.md#understand-the-implementation)。
|
||||
|
||||
详情见[时序图](agent-lifecycle.zh.md)、[工具流水线](tool-execution-pipeline.zh.md)和[取消与错误恢复](subsystems/core.zh.md#the-agent-handle)。
|
||||
|
||||
## 会话日志
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
en: 41d772192075e133
|
||||
zh: 5ef3da69b4de1d8a
|
||||
/capability-seams-and-core-services:
|
||||
en: 8d7ae3eb1265031d
|
||||
zh: 970ce559f5f95a65
|
||||
en: 00ee1236e43f5538
|
||||
zh: aed1f351bf657eea
|
||||
@@ -112,6 +112,8 @@ flowchart LR
|
||||
pkg_api_account_controller["api-account-controller"]
|
||||
pkg_authorization["authorization"]
|
||||
svc_authorization["ctx.authorization<br/>Authorization flow registry"]
|
||||
pkg_client_product_analytics["client-product-analytics"]
|
||||
svc_productAnalytics["ctx.productAnalytics<br/>Desktop interaction collection"]
|
||||
pkg_otel["otel"]
|
||||
svc_otel["ctx.otel<br/>Shared OTel reporting channels"]
|
||||
pkg_host_product_telemetry_otel["host-product-telemetry-otel"]
|
||||
@@ -298,6 +300,7 @@ flowchart LR
|
||||
pkg_client_connection --> svc_connection
|
||||
pkg_client_file_upload --> svc_fileUploads
|
||||
pkg_client_modules --> svc_clientModules
|
||||
pkg_client_product_analytics --> svc_productAnalytics
|
||||
pkg_client_ui_plugin_manager --> svc_pluginRegistryProbe
|
||||
pkg_command_feedback --> svc_sessionFeedback
|
||||
pkg_commands --> svc_commands
|
||||
@@ -610,6 +613,7 @@ flowchart LR
|
||||
| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the settings controller exposes value-free views and write-only storage. |
|
||||
| `ctx.deepseekAccount` | `seam` | [`deepseek-account`](../packages/credentials/deepseek-account) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | [`api-account-controller`](../packages/api/account-controller), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | The Host owns browser authorization and local credentials; UI consumers receive state without tokens. |
|
||||
| `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Flows are registered by the plugin that knows how to obtain one credential and keyed by the record they write; the seam owns the conversation and the one-attempt-per-key lifecycle, never the protocol. |
|
||||
| `ctx.productAnalytics` | `service` | [`client-product-analytics`](../packages/client/product-analytics) | - | - | - | Accepts selected Desktop events, enriches available login identity, and observes live compaction under the live Host collection policy. |
|
||||
| `ctx.otel` | `service` | [`otel`](../packages/telemetry/otel) | - | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel), [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | Product analytics and Session feedback adapters create independent reporting channels through one injected service. |
|
||||
| `ctx.productTelemetry` | `service` | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | - | - | - | Exports explicitly submitted analytics events through OTLP/HTTP; mounting alone collects nothing. |
|
||||
| `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. |
|
||||
|
||||
@@ -114,6 +114,8 @@ flowchart LR
|
||||
pkg_api_account_controller["api-account-controller"]
|
||||
pkg_authorization["authorization"]
|
||||
svc_authorization["ctx.authorization<br/>Authorization flow registry"]
|
||||
pkg_client_product_analytics["client-product-analytics"]
|
||||
svc_productAnalytics["ctx.productAnalytics<br/>Desktop interaction collection"]
|
||||
pkg_otel["otel"]
|
||||
svc_otel["ctx.otel<br/>Shared OTel reporting channels"]
|
||||
pkg_host_product_telemetry_otel["host-product-telemetry-otel"]
|
||||
@@ -300,6 +302,7 @@ flowchart LR
|
||||
pkg_client_connection --> svc_connection
|
||||
pkg_client_file_upload --> svc_fileUploads
|
||||
pkg_client_modules --> svc_clientModules
|
||||
pkg_client_product_analytics --> svc_productAnalytics
|
||||
pkg_client_ui_plugin_manager --> svc_pluginRegistryProbe
|
||||
pkg_command_feedback --> svc_sessionFeedback
|
||||
pkg_commands --> svc_commands
|
||||
@@ -612,6 +615,7 @@ flowchart LR
|
||||
| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;settings controller 提供不含实际值的视图和只写存储。 |
|
||||
| `ctx.deepseekAccount` | `seam` | [`deepseek-account`](../packages/credentials/deepseek-account) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | [`api-account-controller`](../packages/api/account-controller), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | Host 负责浏览器授权和本地凭证;UI 使用方只接收不含 token 的状态。 |
|
||||
| `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | flow 由知道如何取得某份凭据的插件注册,并以其写入的记录为键;seam 拥有这段对话与"每个键同时只跑一次尝试"的生命周期,而非协议本身。 |
|
||||
| `ctx.productAnalytics` | `service` | [`client-product-analytics`](../packages/client/product-analytics) | - | - | - | 根据 Host 动态采集策略接收指定桌面事件、补充可用登录身份并监听实时压缩。 |
|
||||
| `ctx.otel` | `service` | [`otel`](../packages/telemetry/otel) | - | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel), [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | 产品埋点和 Session 反馈适配器通过同一个注入服务创建独立上报通道。 |
|
||||
| `ctx.productTelemetry` | `service` | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | - | - | - | 通过 OTLP/HTTP 发送显式提交的分析事件;仅挂载插件不会采集信息。 |
|
||||
| `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | 该 seam 捕获会话记录、进行脱敏并交给一个后端;没有其他组件消费该服务,其输出会离开当前进程。 |
|
||||
|
||||
+21
-1
@@ -493,6 +493,26 @@ export interface Config {
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-client-hmr -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-client-product-analytics -->
|
||||
<a id="deepseek-aidsh-client-product-analytics"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-client-product-analytics`
|
||||
|
||||
- `inject`: `deepseekAccount` · `productTelemetry`
|
||||
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
|
||||
- `source`: [`packages/client/product-analytics/src/index.ts:14`](../packages/client/product-analytics/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Application-owned collection policy; no user settings surface. */
|
||||
export interface Config {
|
||||
/** Live application collection policy; ordinary Web does not mount this service. */
|
||||
enabled: Volatile<boolean>
|
||||
/** Running Desktop release, absent when unavailable. */
|
||||
appVersion?: string
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-client-product-analytics -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-client-shortcuts -->
|
||||
<a id="deepseek-aidsh-client-shortcuts"></a>
|
||||
|
||||
@@ -4122,7 +4142,7 @@ export interface Config {
|
||||
|
||||
- `inject`: `web`
|
||||
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
|
||||
- `source`: [`packages/web/web-search-deepseek/src/index.ts:46`](../packages/web/web-search-deepseek/src/index.ts)
|
||||
- `source`: [`packages/web/web-search-deepseek/src/index.ts:49`](../packages/web/web-search-deepseek/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
|
||||
|
||||
@@ -495,6 +495,26 @@ export interface Config {
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-client-hmr -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-client-product-analytics -->
|
||||
<a id="deepseek-aidsh-client-product-analytics"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-client-product-analytics`
|
||||
|
||||
- `inject`: `deepseekAccount` · `productTelemetry`
|
||||
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
|
||||
- `source`: [`packages/client/product-analytics/src/index.ts:14`](../packages/client/product-analytics/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Application-owned collection policy; no user settings surface. */
|
||||
export interface Config {
|
||||
/** Live application collection policy; ordinary Web does not mount this service. */
|
||||
enabled: Volatile<boolean>
|
||||
/** Running Desktop release, absent when unavailable. */
|
||||
appVersion?: string
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-client-product-analytics -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-client-shortcuts -->
|
||||
<a id="deepseek-aidsh-client-shortcuts"></a>
|
||||
|
||||
@@ -4124,7 +4144,7 @@ export interface Config {
|
||||
|
||||
- `inject`: `web`
|
||||
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
|
||||
- `source`: [`packages/web/web-search-deepseek/src/index.ts:46`](../packages/web/web-search-deepseek/src/index.ts)
|
||||
- `source`: [`packages/web/web-search-deepseek/src/index.ts:49`](../packages/web/web-search-deepseek/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
|
||||
|
||||
@@ -63,7 +63,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:77`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:77`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:86`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` |
|
||||
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
|
||||
@@ -102,7 +102,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `internal/service` | - | [`agent-preset-registry`](../packages/preset/agent-preset-registry), `gateway` |
|
||||
| `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, [`web`](../packages/web/web) |
|
||||
| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) |
|
||||
| `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `speech-to-text` |
|
||||
| `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `product-analytics`, `speech-to-text` |
|
||||
| `slots/changed` | `ui-renderer` (`emit`) | - |
|
||||
<!-- END GENERATED event-producer-consumer:undeclared -->
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:77`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:77`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:86`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` |
|
||||
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
|
||||
@@ -104,7 +104,7 @@
|
||||
| `internal/service` | - | [`agent-preset-registry`](../packages/preset/agent-preset-registry), `gateway` |
|
||||
| `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, [`web`](../packages/web/web) |
|
||||
| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) |
|
||||
| `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `speech-to-text` |
|
||||
| `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `product-analytics`, `speech-to-text` |
|
||||
| `slots/changed` | `ui-renderer` (`emit`) | - |
|
||||
<!-- END GENERATED event-producer-consumer:undeclared -->
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ flowchart TD
|
||||
pkg_client_hmr["client-hmr"]
|
||||
pkg_client_locale["client-locale"]
|
||||
pkg_client_modules["client-modules"]
|
||||
pkg_client_product_analytics["client-product-analytics"]
|
||||
pkg_client_resources["client-resources"]
|
||||
pkg_client_shortcuts["client-shortcuts"]
|
||||
pkg_client_store["client-store"]
|
||||
@@ -632,11 +633,6 @@ flowchart TD
|
||||
pkg_goal --> pkg_session
|
||||
pkg_goal --> pkg_session_projection
|
||||
pkg_goal --> pkg_typert_protocol
|
||||
pkg_web_search_deepseek --> pkg_agent
|
||||
pkg_web_search_deepseek --> pkg_credentials
|
||||
pkg_web_search_deepseek --> pkg_launch_environment
|
||||
pkg_web_search_deepseek --> pkg_session
|
||||
pkg_web_search_deepseek --> pkg_web
|
||||
pkg_file_reference --> pkg_agent
|
||||
pkg_time_context --> pkg_agent
|
||||
pkg_time_context --> pkg_invariants
|
||||
@@ -724,6 +720,12 @@ flowchart TD
|
||||
pkg_fs_sandbox --> pkg_fs_local
|
||||
pkg_fs_sandbox --> pkg_sandbox
|
||||
pkg_fs_sandbox --> pkg_sandbox_policy
|
||||
pkg_web_search_deepseek --> pkg_agent
|
||||
pkg_web_search_deepseek --> pkg_credentials
|
||||
pkg_web_search_deepseek --> pkg_deepseek_account
|
||||
pkg_web_search_deepseek --> pkg_launch_environment
|
||||
pkg_web_search_deepseek --> pkg_session
|
||||
pkg_web_search_deepseek --> pkg_web
|
||||
pkg_api_account_controller --> pkg_agent
|
||||
pkg_api_account_controller --> pkg_deepseek_account
|
||||
pkg_api_account_controller --> pkg_typert_protocol
|
||||
@@ -1401,6 +1403,7 @@ flowchart TD
|
||||
| [`client-hmr`](../packages/client/hmr) | `client` | — |
|
||||
| [`client-locale`](../packages/client/locale) | `client` | — |
|
||||
| [`client-modules`](../packages/client/modules) | `client` | — |
|
||||
| [`client-product-analytics`](../packages/client/product-analytics) | `client` | — |
|
||||
| [`client-resources`](../packages/client/resources) | `client` | — |
|
||||
| [`client-shortcuts`](../packages/client/shortcuts) | `client` | — |
|
||||
| [`client-store`](../packages/client/store) | `client` | — |
|
||||
@@ -1562,7 +1565,6 @@ flowchart TD
|
||||
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) |
|
||||
| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
|
||||
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) |
|
||||
| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) |
|
||||
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
|
||||
| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) |
|
||||
@@ -1582,6 +1584,7 @@ flowchart TD
|
||||
| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) |
|
||||
| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
|
||||
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) |
|
||||
| [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | `credentials` | [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`host-webserver`](../packages/host/webserver) |
|
||||
|
||||
@@ -156,6 +156,7 @@ flowchart TD
|
||||
pkg_client_hmr["client-hmr"]
|
||||
pkg_client_locale["client-locale"]
|
||||
pkg_client_modules["client-modules"]
|
||||
pkg_client_product_analytics["client-product-analytics"]
|
||||
pkg_client_resources["client-resources"]
|
||||
pkg_client_shortcuts["client-shortcuts"]
|
||||
pkg_client_store["client-store"]
|
||||
@@ -634,11 +635,6 @@ flowchart TD
|
||||
pkg_goal --> pkg_session
|
||||
pkg_goal --> pkg_session_projection
|
||||
pkg_goal --> pkg_typert_protocol
|
||||
pkg_web_search_deepseek --> pkg_agent
|
||||
pkg_web_search_deepseek --> pkg_credentials
|
||||
pkg_web_search_deepseek --> pkg_launch_environment
|
||||
pkg_web_search_deepseek --> pkg_session
|
||||
pkg_web_search_deepseek --> pkg_web
|
||||
pkg_file_reference --> pkg_agent
|
||||
pkg_time_context --> pkg_agent
|
||||
pkg_time_context --> pkg_invariants
|
||||
@@ -726,6 +722,12 @@ flowchart TD
|
||||
pkg_fs_sandbox --> pkg_fs_local
|
||||
pkg_fs_sandbox --> pkg_sandbox
|
||||
pkg_fs_sandbox --> pkg_sandbox_policy
|
||||
pkg_web_search_deepseek --> pkg_agent
|
||||
pkg_web_search_deepseek --> pkg_credentials
|
||||
pkg_web_search_deepseek --> pkg_deepseek_account
|
||||
pkg_web_search_deepseek --> pkg_launch_environment
|
||||
pkg_web_search_deepseek --> pkg_session
|
||||
pkg_web_search_deepseek --> pkg_web
|
||||
pkg_api_account_controller --> pkg_agent
|
||||
pkg_api_account_controller --> pkg_deepseek_account
|
||||
pkg_api_account_controller --> pkg_typert_protocol
|
||||
@@ -1403,6 +1405,7 @@ flowchart TD
|
||||
| [`client-hmr`](../packages/client/hmr) | `client` | — |
|
||||
| [`client-locale`](../packages/client/locale) | `client` | — |
|
||||
| [`client-modules`](../packages/client/modules) | `client` | — |
|
||||
| [`client-product-analytics`](../packages/client/product-analytics) | `client` | — |
|
||||
| [`client-resources`](../packages/client/resources) | `client` | — |
|
||||
| [`client-shortcuts`](../packages/client/shortcuts) | `client` | — |
|
||||
| [`client-store`](../packages/client/store) | `client` | — |
|
||||
@@ -1564,7 +1567,6 @@ flowchart TD
|
||||
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) |
|
||||
| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
|
||||
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) |
|
||||
| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) |
|
||||
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
|
||||
| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) |
|
||||
@@ -1584,6 +1586,7 @@ flowchart TD
|
||||
| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) |
|
||||
| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
|
||||
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) |
|
||||
| [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | `credentials` | [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`host-webserver`](../packages/host/webserver) |
|
||||
|
||||
@@ -356,6 +356,12 @@ abstract rejectToken(token: string): Promise<void>
|
||||
* @returns a Host-only snapshot, or null while signed out or when the credential changed during the read.
|
||||
*/
|
||||
abstract getPlatformSession(): Promise<PlatformSession | null>
|
||||
|
||||
/**
|
||||
* Read existing login identity without creating a device or returning credentials.
|
||||
* @returns optional device/account identifiers and the provider's OS version string.
|
||||
*/
|
||||
abstract getDeviceIdentity(): Promise<{ deviceId?: string; userId?: AccountUserId; osVersion: string }>
|
||||
```
|
||||
|
||||
Source: [`packages/credentials/deepseek-account/src/index.ts`](../../packages/credentials/deepseek-account/src/index.ts)
|
||||
|
||||
@@ -356,6 +356,12 @@ abstract rejectToken(token: string): Promise<void>
|
||||
* @returns a Host-only snapshot, or null while signed out or when the credential changed during the read.
|
||||
*/
|
||||
abstract getPlatformSession(): Promise<PlatformSession | null>
|
||||
|
||||
/**
|
||||
* Read existing login identity without creating a device or returning credentials.
|
||||
* @returns optional device/account identifiers and the provider's OS version string.
|
||||
*/
|
||||
abstract getDeviceIdentity(): Promise<{ deviceId?: string; userId?: AccountUserId; osVersion: string }>
|
||||
```
|
||||
|
||||
Source: [`packages/credentials/deepseek-account/src/index.ts`](../../packages/credentials/deepseek-account/src/index.ts)
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/product-telemetry.md
|
||||
/product-telemetry:
|
||||
en: e1a8950c3b4743ed
|
||||
zh: 3fff7e01a8f271e1
|
||||
en: 04e9a32dfbe69355
|
||||
zh: 0e6c78274dd97466
|
||||
/product-telemetry/record-types:
|
||||
en: bf5eadedbc896f5a
|
||||
zh: 158fce1609a85d6d
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](product-telemetry.zh.md)
|
||||
|
||||
The [product telemetry plugin](../../packages/host/product-telemetry-otel/README.md) sends explicitly selected analytics events through OTLP/HTTP. Its `productTelemetry` service owns submission only; product consumers own when an event occurs and which fields are approved. No Session data or identifiers are collected automatically.
|
||||
The [product telemetry plugin](../../packages/host/product-telemetry-otel/README.md) sends explicitly selected analytics events through OTLP/HTTP. Its `productTelemetry` service owns submission only; product consumers own when an event occurs and which fields are approved. The exporter collects no Session data or identifiers automatically. The [Desktop analytics consumer](../../packages/client/product-analytics/README.md) selects interaction and live compaction events, adds available login identity, and follows the Desktop launch-time collection switch. Ordinary Web clients do not collect product events.
|
||||
|
||||
`ProductTelemetryRecord` requires an event name, string body, and Unix-millisecond timestamp. Attributes accept scalar strings, numbers, booleans, and one-level objects of those values (`ProductTelemetryScalar`). Optional severity uses OTel severity numbers and defaults to INFO. Observation time is assigned when the record is enqueued.
|
||||
|
||||
@@ -30,6 +30,36 @@ Source: [`packages/host/product-telemetry-otel/src/index.ts`](../../packages/hos
|
||||
|
||||
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
|
||||
|
||||
<a id="ctxproductanalytics--productanalytics"></a>
|
||||
|
||||
### `ctx.productAnalytics` — `ProductAnalytics`
|
||||
|
||||
Authenticated event intake; disabled instances do not inspect identity or accept new events.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Read the collection policy.
|
||||
* @returns whether this Host currently accepts Desktop analytics.
|
||||
*/
|
||||
@Remote enabled(): boolean
|
||||
|
||||
/**
|
||||
* Stream the effective policy initially and after live configuration edits.
|
||||
* @param signal - subscriber lifetime.
|
||||
* @returns current policy values until cancellation or service disposal.
|
||||
*/
|
||||
@Remote({ mode: 'stream' }) async *watchPolicy(signal: AbortSignal): AsyncIterable<boolean>
|
||||
|
||||
/**
|
||||
* Submit selected Desktop fields; missing identity is omitted and never generated.
|
||||
* @param event - typed product event without message contents or credentials.
|
||||
* @returns after local submission; no delivery or warehouse acknowledgement.
|
||||
*/
|
||||
@Remote async report(event: ProductEvent): Promise<void>
|
||||
```
|
||||
|
||||
Source: [`packages/client/product-analytics/src/index.ts`](../../packages/client/product-analytics/src/index.ts)
|
||||
|
||||
<a id="ctxproducttelemetry--producttelemetry"></a>
|
||||
|
||||
### `ctx.productTelemetry` — `ProductTelemetry`
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](product-telemetry.md) | 中文
|
||||
|
||||
[产品埋点插件](../../packages/host/product-telemetry-otel/README.zh.md) 通过 OTLP/HTTP 发送明确选定的分析事件。`productTelemetry` 服务仅负责提交;产品消费方决定事件发生时机与获准采集的字段。不自动采集 Session 数据或标识。
|
||||
[产品埋点插件](../../packages/host/product-telemetry-otel/README.zh.md) 通过 OTLP/HTTP 发送明确选定的分析事件。`productTelemetry` 服务仅负责提交;产品消费方决定事件发生时机与获准采集的字段。导出器不自动采集 Session 数据或标识。[桌面埋点消费方](../../packages/client/product-analytics/README.zh.md)选择交互与实时压缩事件,补充可用的登录身份,并遵守桌面启动时的采集开关。普通 Web 客户端不采集产品事件。
|
||||
|
||||
`ProductTelemetryRecord` 要求事件名称、字符串 body 和 Unix 毫秒时间戳。属性接受字符串、数字、布尔标量,以及这些值组成的单层对象(`ProductTelemetryScalar`)。可选的严重程度使用 OTel 严重程度数字,默认为 INFO。记录入队时填写观测时间。
|
||||
|
||||
@@ -30,6 +30,36 @@ Source: [`packages/host/product-telemetry-otel/src/index.ts`](../../packages/hos
|
||||
|
||||
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
|
||||
|
||||
<a id="ctxproductanalytics--productanalytics"></a>
|
||||
|
||||
### `ctx.productAnalytics` — `ProductAnalytics`
|
||||
|
||||
Authenticated event intake; disabled instances do not inspect identity or accept new events.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Read the collection policy.
|
||||
* @returns whether this Host currently accepts Desktop analytics.
|
||||
*/
|
||||
@Remote enabled(): boolean
|
||||
|
||||
/**
|
||||
* Stream the effective policy initially and after live configuration edits.
|
||||
* @param signal - subscriber lifetime.
|
||||
* @returns current policy values until cancellation or service disposal.
|
||||
*/
|
||||
@Remote({ mode: 'stream' }) async *watchPolicy(signal: AbortSignal): AsyncIterable<boolean>
|
||||
|
||||
/**
|
||||
* Submit selected Desktop fields; missing identity is omitted and never generated.
|
||||
* @param event - typed product event without message contents or credentials.
|
||||
* @returns after local submission; no delivery or warehouse acknowledgement.
|
||||
*/
|
||||
@Remote async report(event: ProductEvent): Promise<void>
|
||||
```
|
||||
|
||||
Source: [`packages/client/product-analytics/src/index.ts`](../../packages/client/product-analytics/src/index.ts)
|
||||
|
||||
<a id="ctxproducttelemetry--producttelemetry"></a>
|
||||
|
||||
### `ctx.productTelemetry` — `ProductTelemetry`
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
en: 41cd197b5c74bfa3
|
||||
zh: 18ed11259036d726
|
||||
/host-wide-schedule/absolute-time-input:
|
||||
en: 0876a14a60ba2160
|
||||
zh: e7d4bd9099c14fb2
|
||||
en: 8186d070ab412545
|
||||
zh: 968b752df9e03b68
|
||||
/host-wide-schedule/daily-wall-clock-input:
|
||||
en: 2d249edc3dfccb8a
|
||||
zh: 33fecda132b03b84
|
||||
@@ -27,8 +27,8 @@
|
||||
en: 0656173de50ee132
|
||||
zh: 54176540371d0f28
|
||||
/host-wide-schedule/web-catalog:
|
||||
en: 60c88b08abde9bb0
|
||||
zh: 0a3644cfa86d014d
|
||||
en: 9955c4857340103a
|
||||
zh: edfa0491c8a08db7
|
||||
/host-wide-schedule/timing-edits:
|
||||
en: ce5d2095a3ad2e82
|
||||
zh: 167b3715f8eaffc1
|
||||
|
||||
@@ -116,7 +116,7 @@ interface LocalAtInput {
|
||||
type AtInput = string | LocalAtInput
|
||||
```
|
||||
|
||||
The shipped Web composition carries the `time-context` row disabled; the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle`, enabled from the Plugins page, mounts time-context, which samples the browser's IANA zone for every prompt. Time-context tells the model to interpret otherwise-unqualified natural-language dates and times in that request-local zone when the open turn has one unambiguous browser zone; mixed or missing browser-zone records tell the model to ask. That guidance is not a durable Session default: the model must still pass an offset in the string form or `time_zone` in the local form, and Schedule never reads browser, Session, process, or model context.
|
||||
The shipped Web composition carries no `time-context` row; the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle`, enabled from the Plugins page, inserts and mounts time-context, which samples the browser's IANA zone for every prompt. Time-context tells the model to interpret otherwise-unqualified natural-language dates and times in that request-local zone when the open turn has one unambiguous browser zone; mixed or missing browser-zone records tell the model to ask. That guidance is not a durable Session default: the model must still pass an offset in the string form or `time_zone` in the local form, and Schedule never reads browser, Session, process, or model context.
|
||||
|
||||
Schedule rejects invalid offsets and zones, offset-free strings, non-future targets, and local times inside daylight-saving gaps. A daylight-saving overlap chooses its first, earlier instant. Successful creation stores only canonical UTC `scheduledAt`, so replay never depends on ambient time-zone state.
|
||||
|
||||
@@ -339,7 +339,7 @@ type ScheduleCatalogEntry = ScheduleRecord & {
|
||||
|
||||
The Remote `schedule.list({ sessionId })`, model `schedule_list`, and Session-header catalog return only active tasks. A model view's derived timing `state` remains distinct from stored lifecycle `status`. Deletion uses `schedule.delete({ sessionId, id })` with the entry's original binding; a mismatched binding returns not found. Model tools supply the current Agent's Session, whereas the global user interface supplies the selected task's binding. The binding check alone does not establish caller authorization. The payload-free `schedule/changed` event invalidates client lists; reconnecting clients fetch current state again.
|
||||
|
||||
The shipped Web composition carries the `ui-schedule` row disabled; the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle`, enabled from the Plugins page, mounts `ui-schedule` with the Host capability. The [client package](../../packages/client/ui-schedule/README.md) owns the catalog, empty state, and deletion controls. The page separately filters all, active, and inactive tasks, retains inactive details and an original-Session control in the detail tab strip, and requires explicit confirmed deletion. Rules and Delivery records separate task settings from lazily paged saved receipts. Neither a receipt nor inactive status confirms model execution.
|
||||
The shipped Web composition carries no `ui-schedule` row; the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle`, enabled from the Plugins page, inserts and mounts `ui-schedule` with the Host capability. The [client package](../../packages/client/ui-schedule/README.md) owns the catalog, empty state, and deletion controls. The page separately filters all, active, and inactive tasks, retains inactive details and an original-Session control in the detail tab strip, and requires explicit confirmed deletion. Rules and Delivery records separate task settings from lazily paged saved receipts. Neither a receipt nor inactive status confirms model execution.
|
||||
|
||||
## Timing edits
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ interface LocalAtInput {
|
||||
type AtInput = string | LocalAtInput
|
||||
```
|
||||
|
||||
随发行版交付的 Web 组合把 `time-context` 行保持禁用;在插件管理页启用可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle` 会挂载 time-context,它为每条提示词采样浏览器的 IANA 时区。当 open turn 只有一个无歧义的浏览器时区时,Time-context 会告诉模型按该请求本地时区解释未明确限定时区的自然语言日期和时间;浏览器时区记录混合或缺失时,则告诉模型询问用户。该指引不是持久 Session 默认值:模型仍必须在字符串形式中传入偏移量,或在本地形式中传入 `time_zone`;Schedule 绝不会读取浏览器、Session、进程或模型上下文。
|
||||
随发行版交付的 Web 组合不含 `time-context` 行;在插件管理页启用可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle` 会插入并挂载 time-context,它为每条提示词采样浏览器的 IANA 时区。当 open turn 只有一个无歧义的浏览器时区时,Time-context 会告诉模型按该请求本地时区解释未明确限定时区的自然语言日期和时间;浏览器时区记录混合或缺失时,则告诉模型询问用户。该指引不是持久 Session 默认值:模型仍必须在字符串形式中传入偏移量,或在本地形式中传入 `time_zone`;Schedule 绝不会读取浏览器、Session、进程或模型上下文。
|
||||
|
||||
Schedule 会拒绝无效偏移量与时区、不带偏移量的字符串、非未来目标,以及落在夏令时缺口内的本地时间。遇到夏令时重叠时,会选择第一次出现的较早时点。创建成功后只存储规范化后的 UTC `scheduledAt`,因此回放绝不依赖环境时区状态。
|
||||
|
||||
@@ -339,7 +339,7 @@ type ScheduleCatalogEntry = ScheduleRecord & {
|
||||
|
||||
Remote 方法 `schedule.list({ sessionId })`、模型 `schedule_list` 和 Session 页头目录仅返回活动任务。模型视图派生的时间 `state` 与存储的生命周期 `status` 仍是不同概念。删除通过 `schedule.delete({ sessionId, id })` 使用条目的原始绑定;绑定不匹配时返回未找到。模型工具传入当前 Agent 的 Session,全局用户界面则传入所选任务的绑定。仅校验绑定并不构成调用者鉴权。无 payload 的 `schedule/changed` 事件通知客户端刷新列表;重新连接后,客户端再次获取当前状态。
|
||||
|
||||
随发行版交付的 Web 组合把 `ui-schedule` 行保持禁用;在插件管理页启用可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle` 会将它与宿主能力一起挂载。[客户端包](../../packages/client/ui-schedule/README.zh.md) 负责目录、空状态与删除控件。页面单独筛选全部、活动和未运行任务,保留未运行任务详情及详情页签条内的原 Session 入口,并要求显式确认删除。“规则”和“发送记录”将任务设置与按需分页加载的已保存回执分开。回执与未运行状态均不确认模型执行。
|
||||
随发行版交付的 Web 组合不含 `ui-schedule` 行;在插件管理页启用可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle` 会插入它并与宿主能力一起挂载。[客户端包](../../packages/client/ui-schedule/README.zh.md) 负责目录、空状态与删除控件。页面单独筛选全部、活动和未运行任务,保留未运行任务详情及详情页签条内的原 Session 入口,并要求显式确认删除。“规则”和“发送记录”将任务设置与按需分页加载的已保存回执分开。回执与未运行状态均不确认模型执行。
|
||||
|
||||
## 修改时间
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
en: b3b2a6e0ce02bdee
|
||||
zh: 2ddb168de79fd19c
|
||||
/schedule-reminders/create-reminders:
|
||||
en: c76ee99f59529942
|
||||
zh: be1d75c226231dd7
|
||||
en: 68763ffeedaeea3c
|
||||
zh: 739af012a337d448
|
||||
/schedule-reminders/inspect-and-delete-tasks:
|
||||
en: a44fc1aabc2ab957
|
||||
zh: 31f444d4c7fd5c7a
|
||||
|
||||
@@ -17,7 +17,7 @@ Create reminders in a conversation, then inspect active and inactive tasks and e
|
||||
<a id="create-reminders"></a>
|
||||
## Create reminders
|
||||
|
||||
The shipped Web profile carries the `time-context`, `schedule`, and `ui-schedule` rows disabled. Open Plugins and enable `Scheduling and time context` in the Official group; the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` then mounts Schedule with the clock context that gives the model the current time and the browser's zone. Configure a model provider before asking it to create reminders.
|
||||
The shipped Web profile does not include Schedule. Open Plugins and enable Automation tasks in the Official group; the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` then adds Schedule with the clock context that gives the model the current time and the browser's zone. Configure a model provider before asking it to create reminders.
|
||||
|
||||
Ask the model to create, list, edit, or delete reminders. It uses `schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete` (update changes one reminder in place and keeps its id and saved delivery records); the Automation tasks page's New action opens a New Session for a creation instead, with the request already written in its composer. Supported choices are a one-time delay in positive whole seconds, an absolute date and time, a fixed interval of at least one minute, a daily local time with an IANA time zone, a weekly local time with an IANA time zone and ISO weekdays from Monday 1 through Sunday 7, or a five-field cron expression with an explicit IANA time zone, stored in canonical form.
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<a id="create-reminders"></a>
|
||||
## 创建提醒
|
||||
|
||||
发布的 Web profile 把 `time-context`、`schedule` 与 `ui-schedule` 三行保持禁用。打开插件管理页,在 Official 分组中启用“定时与时间上下文”;可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle` 随即挂载 Schedule 及其时钟上下文,后者把当前时间和浏览器时区交给模型。在请求模型创建提醒前,先配置模型 provider。
|
||||
发布的 Web profile 不含 Schedule。打开插件管理页,在“官方”分组中启用“自动化任务”;可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle` 随即加入 Schedule 及其时钟上下文,后者把当前时间和浏览器时区交给模型。在请求模型创建提醒前,先配置模型 provider。
|
||||
|
||||
向模型提出创建、列出、修改或删除提醒的请求。模型使用 `schedule_create`、`schedule_list`、`schedule_update` 和 `schedule_delete`(更新原地修改一条提醒,保留其 id 与已保存的投递记录);「自动化任务」页面的「新建」操作则为创建打开一个新会话,其 composer 已写好该请求。支持正整数秒的一次性延迟、绝对日期时间、至少一分钟的固定间隔、带 IANA 时区的每日本地时间、带 IANA 时区和 ISO 星期(周一 1 至周日 7)的每周本地时间,以及带显式 IANA 时区的五字段 cron 表达式(表达式以规范化形式保存)。
|
||||
|
||||
|
||||
+2
-2
@@ -181,8 +181,8 @@
|
||||
"gen-third-party-notices": "tsx scripts/gen-third-party-notices.ts",
|
||||
"verify-third-party-notices": "tsx scripts/gen-third-party-notices.ts --check",
|
||||
"gen-module-graph": "tsx scripts/gen-module-graph.ts",
|
||||
"gen-scoped-events": "tsx scripts/gen-scoped-events.ts",
|
||||
"verify-scoped-events": "tsx scripts/gen-scoped-events.ts --check",
|
||||
"gen-scoped-events": "node --max-old-space-size=4096 --import tsx/esm scripts/gen-scoped-events.ts",
|
||||
"verify-scoped-events": "node --max-old-space-size=4096 --import tsx/esm scripts/gen-scoped-events.ts --check",
|
||||
"verify-module-graph": "tsx scripts/gen-module-graph.ts --check",
|
||||
"constraints": "tsx scripts/check-workspace-constraints.ts",
|
||||
"doc-sync": "tsx scripts/run-gates.ts doc-sync",
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
en: 1c32778606f3027a
|
||||
zh: db7994fdb50333c8
|
||||
/deepseek-ai-dsh-api-remotes:
|
||||
en: b5349178e12f23fe
|
||||
zh: dfdb4de9d99fbc7b
|
||||
en: f0e152bd9c846ec4
|
||||
zh: dba8ab5fa1181276
|
||||
/deepseek-ai-dsh-api-remotes/summary:
|
||||
en: 3c54c157bb823fd0
|
||||
zh: 0db6778e94ae671c
|
||||
|
||||
@@ -7,6 +7,8 @@ kind: "package-reference"
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Desktop analytics follows the [product collection policy](../../client/product-analytics/README.md), including its live application setting. Web usage is excluded.
|
||||
|
||||
## Summary
|
||||
|
||||
Two-sided BFF for Host Remote capabilities selected by this application. The Host entry owns the forwarded-event selection and registers its application event source with API Gateway; the Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.remote.$mount()`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Gateway implementation or individual Remote runtime entries.
|
||||
|
||||
@@ -7,6 +7,8 @@ kind: "package-reference"
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
桌面埋点遵循[产品采集策略](../../client/product-analytics/README.zh.md)及其动态应用配置,不包含 Web 使用情况。
|
||||
|
||||
## 概述
|
||||
|
||||
为本应用选定的 Host Remote 能力提供双侧 BFF。Host 入口拥有转发事件名单并向 API Gateway 注册应用事件 source;Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.remote.$mount()` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖该外观,而不依赖 Gateway 实现或单独的 Remote 运行时入口。
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*",
|
||||
"@deepseek-ai/dsh-office-to-pdf": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-account-controller": "workspace:*",
|
||||
"@deepseek-ai/dsh-deepseek-account": "workspace:*"
|
||||
"@deepseek-ai/dsh-deepseek-account": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-product-analytics": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
/** Platform-neutral assembly of generated Host Remote contributions. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import productAnalyticsRemote from '@deepseek-ai/dsh-client-product-analytics/remote'
|
||||
export type {} from '@deepseek-ai/dsh-client-product-analytics/remote'
|
||||
import agentPresetsRemote from '@deepseek-ai/dsh-agent-preset-registry/remote'
|
||||
import commandsRemote from '@deepseek-ai/dsh-commands/remote'
|
||||
import accountRemote from '@deepseek-ai/dsh-api-account-controller/remote'
|
||||
@@ -175,7 +177,8 @@ export async function apply(ctx: Context): Promise<() => Promise<void>> {
|
||||
const disposers: Array<() => Promise<void>> = []
|
||||
try {
|
||||
for (const contribution of [
|
||||
agentPresetsRemote, commandsRemote, settingsControllerRemote, accountRemote, goalsRemote, llmRemote, dynamicRemote, scheduleRemote,
|
||||
productAnalyticsRemote, agentPresetsRemote, commandsRemote, settingsControllerRemote, accountRemote,
|
||||
goalsRemote, llmRemote, dynamicRemote, scheduleRemote,
|
||||
pluginInventoryRemote, pluginManagerRemote, pluginRegistryProbeRemote, messageFeedbackRemote, sessionFeedbackRemote,
|
||||
fileUploadsRemote, sessionReferencesRemote,
|
||||
permissionPresetsRemote, subagentsRemote, sessionRemote, jobRemote, workspaceRemote, workspaceFilesRemote, terminalRemote,
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"src/types.ts"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "../../client/product-analytics/tsconfig.client.json" },
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
en: 82789442228fc62a
|
||||
zh: f1d511ad838187de
|
||||
/session-controller:
|
||||
en: fb78499ee63f41b9
|
||||
zh: 6fff916b1fa26314
|
||||
en: c7a4c58ea6d3437b
|
||||
zh: 45c2b472a074bb1b
|
||||
/session-controller/summary:
|
||||
en: 4914824c347fdab5
|
||||
zh: 684a7b7fa970c39f
|
||||
|
||||
@@ -6,6 +6,8 @@ kind: "package-reference"
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Desktop analytics follows the [product collection policy](../../client/product-analytics/README.md), including its launch-time switch. Web usage is excluded.
|
||||
|
||||
## Summary
|
||||
|
||||
`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `session`, `skills`, and `fileReferences` Remote namespaces. It serves Session lifecycle and history, the Host-generation model catalog, human background-job kill, workspace-path opening, user-invocable skill discovery, and Agent-scoped file references. Use it through API Gateway when a Client needs operations addressed by a Session.
|
||||
|
||||
@@ -6,6 +6,8 @@ kind: "package-reference"
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
桌面埋点遵循[产品采集策略](../../client/product-analytics/README.zh.md)及其启动时开关,不包含 Web 使用情况。
|
||||
|
||||
## 概述
|
||||
|
||||
`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务,以及生成的 Client `session`、`skills` 和 `fileReferences` Remote namespace。它提供 Session 生命周期与历史、Host generation 模型目录、人工后台任务终止、工作区路径打开、用户可调用 skill(技能)发现和 Agent(智能体)范围的文件引用。当 Client 需要按 Session 寻址的操作时,请通过 API Gateway 使用它。
|
||||
|
||||
@@ -125,11 +125,16 @@ export interface ISessions {
|
||||
* seq (a real event seq the caller already knows; a cut inside an open
|
||||
* turn is balanced Host-side with synthetic closers, and omission selects
|
||||
* the latest completed-turn prefix), and whether to increment an
|
||||
* inherited durable title before resolving.
|
||||
* inherited durable title before resolving. `onCreated` observes the catalogued child before that optional rename.
|
||||
* @returns the child session id.
|
||||
* @throws when the fork fails, or when a requested child-title rename fails after creation.
|
||||
*/
|
||||
fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise<SessionId>
|
||||
fork(opts: {
|
||||
sessionId: SessionId
|
||||
atSeq?: number
|
||||
increaseTitle?: boolean
|
||||
onCreated?: (childId: SessionId) => void
|
||||
}): Promise<SessionId>
|
||||
/**
|
||||
* Borrow an already-retained Agent-scoped Context without extending its lifetime.
|
||||
* @param id - session id.
|
||||
|
||||
@@ -452,6 +452,7 @@ export class ClientSessions implements ISessions {
|
||||
sessionId: SessionId
|
||||
atSeq?: number
|
||||
increaseTitle?: boolean
|
||||
onCreated?: (childId: SessionId) => void
|
||||
}): Promise<SessionId> {
|
||||
const sourceTitle = opts.increaseTitle
|
||||
? this.list.getSnapshot().byId[opts.sessionId]?.title
|
||||
@@ -463,6 +464,7 @@ export class ClientSessions implements ISessions {
|
||||
if (!result.ok) throw new SessionForkError(result.error, opts.sessionId)
|
||||
this.projectList()
|
||||
const childId = result.value.sessionId
|
||||
opts.onCreated?.(childId)
|
||||
if (sourceTitle !== undefined) {
|
||||
const renamed = await this.manager.rename(childId, increasedForkTitle(sourceTitle))
|
||||
if (!renamed.ok) throw new Error(`fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`)
|
||||
|
||||
@@ -983,8 +983,10 @@ describe('fork', () => {
|
||||
b.mock.remote.session.fork.mockResolvedValue(ok({ sessionId: sid('child') }))
|
||||
b.mock.remote.session.rename.mockResolvedValue(err(new RemoteError('session/title-invalid', 'rejected', { sessionId: sid('child') })))
|
||||
|
||||
await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true }))
|
||||
const onCreated = vi.fn()
|
||||
await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true, onCreated }))
|
||||
.rejects.toThrow('fork child rename failed: session/title-invalid: rejected')
|
||||
expect(onCreated).toHaveBeenCalledExactlyOnceWith('child')
|
||||
expect(b.svc.list.getSnapshot().byId[sid('child')]).toBeDefined()
|
||||
expect(b.svc.binding(sid('child'))).toBeUndefined()
|
||||
expect(b.svc.retainInfo(sid('child')).getSnapshot().referenceCount).toBe(0)
|
||||
|
||||
Loaded 100 of 339 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user