mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-11-09 20:07:43 +08:00
merge: reconcile desktop analytics with latest master
This commit is contained in:
+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
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
en: 0d11af8639977a6d
|
||||
zh: 86b086b12d2210d3
|
||||
/deepseek-harness-desktop/develop:
|
||||
en: 9b9b362a9865e763
|
||||
zh: c748901168e2db8c
|
||||
en: 40d2ad4014bf445b
|
||||
zh: 6a08de06df9ed042
|
||||
/deepseek-harness-desktop/develop/startup-onboarding:
|
||||
en: a51346b8d880ddba
|
||||
zh: 7913214c1c66063c
|
||||
|
||||
@@ -136,7 +136,7 @@ pnpm run start:desktop
|
||||
|
||||
The Web counterparts are `pnpm run dev:web` and `pnpm run start:web`, documented in the [development guide](../../docs/development.md). Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. The Host uses runtime module resolution in both development and packaged builds without creating official-package fallback links; developer-installed packages, including links, retain native priority. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths.
|
||||
|
||||
The [native/renderer keyboard tests](tests/keyboard.spec.ts) compile in [their own Client test project](../../tsconfig.desktop-keyboard-tests.json), reached by the repository Client typecheck. Its Desktop imports are limited to Cordis-free input, persistence, IPC, browser-guest, and overlay modules.
|
||||
The [native/renderer keyboard tests](tests/keyboard.spec.ts) compile as part of the repository Client typecheck. Their Desktop imports are limited to Cordis-free input, persistence, IPC, browser-guest, and overlay modules.
|
||||
|
||||
### Startup onboarding
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ pnpm run start:desktop
|
||||
|
||||
Web 侧的对应命令是 `pnpm run dev:web` 与 `pnpm run start:web`,见[开发指南](../../docs/development.zh.md)。Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。Host 在开发与打包构建中都使用 runtime 模块解析,不创建官方包的 fallback 链接;开发者安装的包(包括链接)保留原生优先级。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。
|
||||
|
||||
[原生输入与渲染进程键盘测试](tests/keyboard.spec.ts)在[独立的 Client 测试项目](../../tsconfig.desktop-keyboard-tests.json)中编译,由仓库 Client 类型检查纳入。它只导入不依赖 Cordis 的 Desktop 输入、持久化、IPC、浏览器 guest 和蒙层模块。
|
||||
[原生输入与渲染进程键盘测试](tests/keyboard.spec.ts)直接纳入仓库 Client 类型检查。它只导入不依赖 Cordis 的 Desktop 输入、持久化、IPC、浏览器 guest 和蒙层模块。
|
||||
|
||||
### 启动引导
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
- text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
|
||||
- switch "启用 自动授权审查"
|
||||
- listitem:
|
||||
- button "查看 定时与时间上下文": 定时与时间上下文
|
||||
- text: 实验性 启用持久提醒、自动化任务页面与逐步时钟上下文。
|
||||
- switch "启用 定时与时间上下文"
|
||||
- button "查看 自动化任务": 自动化任务
|
||||
- text: 实验性 按设定的时间或周期,在会话中自动执行任务。
|
||||
- switch "启用 自动化任务"
|
||||
- listitem:
|
||||
- button "查看 语音输入": 语音输入
|
||||
- text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
- text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
|
||||
- switch "启用 自动授权审查"
|
||||
- listitem:
|
||||
- button "查看 定时与时间上下文": 定时与时间上下文
|
||||
- text: 实验性 启用持久提醒、自动化任务页面与逐步时钟上下文。
|
||||
- switch "启用 定时与时间上下文"
|
||||
- button "查看 自动化任务": 自动化任务
|
||||
- text: 实验性 按设定的时间或周期,在会话中自动执行任务。
|
||||
- switch "启用 自动化任务"
|
||||
- listitem:
|
||||
- button "查看 语音输入": 语音输入
|
||||
- text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
- text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
|
||||
- switch "启用 自动授权审查"
|
||||
- listitem:
|
||||
- button "查看 定时与时间上下文": 定时与时间上下文
|
||||
- text: 实验性 启用持久提醒、自动化任务页面与逐步时钟上下文。
|
||||
- switch "启用 定时与时间上下文"
|
||||
- button "查看 自动化任务": 自动化任务
|
||||
- text: 实验性 按设定的时间或周期,在会话中自动执行任务。
|
||||
- switch "启用 自动化任务"
|
||||
- listitem:
|
||||
- button "查看 语音输入": 语音输入
|
||||
- text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -239,6 +239,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 +314,37 @@ 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('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()
|
||||
|
||||
@@ -839,19 +839,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()
|
||||
|
||||
@@ -111,16 +111,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 +133,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 +161,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)
|
||||
}
|
||||
|
||||
@@ -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)。
|
||||
|
||||
## 会话日志
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
en: b7ab01cfe9fed40f
|
||||
zh: 23703ab7727c3ab5
|
||||
/deepseek-ai-dsh-web-app/use-this-package/configuration:
|
||||
en: cea7ecccb2a03b79
|
||||
zh: 0af72c22e46045d3
|
||||
en: a93760e9e451faa6
|
||||
zh: 5dab82dd20aa952f
|
||||
/deepseek-ai-dsh-web-app/use-this-package/lan-access-and-trusted-hosts:
|
||||
en: 2522c806db384ddc
|
||||
zh: 7e96f11194af9f9f
|
||||
|
||||
@@ -53,7 +53,7 @@ Most users never set these; the command-line flags feed the four settings below
|
||||
| `surfaceContext` | `true` | Give the agent GUI-orientation context and expose `DSH_WEB_URL` to its shell commands |
|
||||
| `trustedHosts` | `[]` | Extra hosts allowed to reach the GUI from the network |
|
||||
|
||||
The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-web-app) is the exhaustive source for every accepted field and its JSDoc. The shipped composition disables `schedule`, `ui-schedule`, and `time-context` by default, and the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` switches those three rows on from the Plugins page.
|
||||
The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-web-app) is the exhaustive source for every accepted field and its JSDoc. The shipped composition carries no `time-context`, `schedule`, or `ui-schedule` row; the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` inserts those three rows from the Plugins page.
|
||||
|
||||
### LAN access and trusted hosts
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ dsh --profile web --no-open --port 8080
|
||||
| `surfaceContext` | `true` | 给 agent 提供 GUI 定位上下文,并把 `DSH_WEB_URL` 暴露给其 shell 命令 |
|
||||
| `trustedHosts` | `[]` | 允许从网络访问 GUI 的额外主机 |
|
||||
|
||||
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-web-app)是每个受支持字段及其 JSDoc 的穷尽式真源。 随发行版交付的组合默认禁用 `schedule`、`ui-schedule` 和 `time-context`,可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle` 可在插件管理页把这三行打开。
|
||||
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-web-app)是每个受支持字段及其 JSDoc 的穷尽式真源。 随发行版交付的组合不含 `time-context`、`schedule` 和 `ui-schedule` 行,可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle` 可在插件管理页插入这三行。
|
||||
|
||||
### LAN 访问与可信主机
|
||||
|
||||
|
||||
@@ -131,19 +131,6 @@
|
||||
- id: workspace-files
|
||||
name: '@deepseek-ai/dsh-api-workspace-files'
|
||||
|
||||
# Durable Host-wide reminders. The Schedule service owns the task store and
|
||||
# delivers each due occurrence as a follow-up in its original Session;
|
||||
# `time-context` supplies the current time, browser zone, and elapsed time
|
||||
# the model needs to resolve an unqualified date or time. The browser half
|
||||
# is the `ui-schedule` row below.
|
||||
- id: time-context
|
||||
name: '@deepseek-ai/dsh-time-context'
|
||||
disabled: true
|
||||
|
||||
- id: schedule
|
||||
name: '@deepseek-ai/dsh-schedule'
|
||||
disabled: true
|
||||
|
||||
# Configuration-surface reads and writes over Typert Remote. Each method
|
||||
# reports an actionable error when its settings-domain provider is absent.
|
||||
- id: ui-settings-account
|
||||
@@ -383,12 +370,6 @@
|
||||
- id: ui-jobs
|
||||
name: '@deepseek-ai/dsh-client-ui-jobs'
|
||||
|
||||
# Task management and Session reminder catalog over the Host Schedule
|
||||
# service mounted above.
|
||||
- id: ui-schedule
|
||||
name: '@deepseek-ai/dsh-client-ui-schedule'
|
||||
disabled: true
|
||||
|
||||
# Goal surface: GoalBar in the input dock over the goal session projection.
|
||||
- id: ui-goal
|
||||
name: '@deepseek-ai/dsh-client-ui-goal'
|
||||
|
||||
@@ -91,7 +91,6 @@
|
||||
"@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-reference": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-schedule": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-session": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-settings-general": "workspace:*",
|
||||
@@ -138,7 +137,6 @@
|
||||
"@deepseek-ai/dsh-persona": "workspace:*",
|
||||
"@deepseek-ai/dsh-plan-mode": "workspace:*",
|
||||
"@deepseek-ai/dsh-plugin-manager": "workspace:*",
|
||||
"@deepseek-ai/dsh-schedule": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-log-export": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-reference": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-stats": "workspace:*",
|
||||
@@ -147,7 +145,6 @@
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:*",
|
||||
"@deepseek-ai/dsh-terminal": "workspace:*",
|
||||
"@deepseek-ai/dsh-terminal-bash": "workspace:*",
|
||||
"@deepseek-ai/dsh-time-context": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-ask-user": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-bash": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-bash-persistent": "workspace:*",
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 405b30feaf48c087
|
||||
/deepseek-ai-dsh-client-ui-schedule/use-this-package:
|
||||
en: bf8096e6e576ac07
|
||||
zh: 1936c0a75dc5cd4b
|
||||
en: c129ae5d0a150a5d
|
||||
zh: d81976f2840cb059
|
||||
/deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source:
|
||||
en: 6bd6882dbbbefcb0
|
||||
zh: 03aa15f1a65d13dc
|
||||
|
||||
@@ -25,7 +25,7 @@ Manage active and inactive tasks from the Automation tasks page and the right Si
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
The shipped Web composition carries the `ui-schedule` row disabled; enable the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` from the Plugins page (Official group), or list it in a profile's `dsh.profile.bundles`, to mount `ui-schedule` with the Host Schedule service and the clock context that supplies the model's current time. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Its task list supports text search and one All, Enabled, and Inactive status filter row; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state keeps the heading's New action beside the notice. Selecting an active or inactive task opens a second detail level with Rules and Delivery records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation.
|
||||
The shipped Web composition carries no `ui-schedule` row; enable the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` from the Plugins page (Official group), or list it in a profile's `dsh.profile.bundles`, to insert and mount `ui-schedule` with the Host Schedule service and the clock context that supplies the model's current time. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Its task list supports text search and one All, Enabled, and Inactive status filter row; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state keeps the heading's New action beside the notice. Selecting an active or inactive task opens a second detail level with Rules and Delivery records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation.
|
||||
|
||||
Selecting a task from the list opens Rules; refreshing that task preserves the selected tab. Rules edits an active task's name, instruction, and run time; the edits stay in a local draft until saved, and inactive tasks remain read-only. Unmodified Left/Right arrows cycle the tabs, Home/End select the first/last tab, and keyboard focus follows the selection. The tabs do not consume these keys with Alt, Ctrl, Meta, or Shift. The linked-Session control in the detail tab strip stays available on the Rules tab, and confirmed deletion is available from either view.
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
随发行版交付的 Web 组合把 `ui-schedule` 行保持禁用;在插件管理页(Official 分组)启用可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle`,或把它列进 profile 的 `dsh.profile.bundles`,即可将 `ui-schedule` 与 Host Schedule 服务、以及为模型提供当前时间的时钟上下文一起挂载。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示旁保留页头的“新建”操作。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。
|
||||
随发行版交付的 Web 组合不含 `ui-schedule` 行;在插件管理页的“官方”分组中启用可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle`,或把它列进 profile 的 `dsh.profile.bundles`,即可插入 `ui-schedule` 并将它与 Host Schedule 服务、以及为模型提供当前时间的时钟上下文一起挂载。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示旁保留页头的“新建”操作。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。
|
||||
|
||||
从列表选择任务时默认打开“规则”;刷新同一任务会保留当前页签。活动任务的“规则”修改名称、指令和运行时间;这些修改保留在本地草稿中直到保存,未运行任务保持只读。不带修饰键的左右方向键循环切换页签,Home/End 选择第一个/最后一个页签,键盘焦点随选择移动。页签不会拦截这些键与 Alt、Ctrl、Meta 或 Shift 的组合。共用的会话入口位于详情页签条内,在“规则”页签可见;确认删除操作在两种页签下都可用。
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "Task interface",
|
||||
"description": "The Automation tasks page in the sidebar, plus task cards and reminder marks in sessions."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "任务界面",
|
||||
"description": "侧栏的自动化任务页面,以及会话里的任务卡片和提醒标记。"
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,8 @@
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
"./package.json": "./package.json",
|
||||
"./locale/*.json": "./locale/*.json"
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
@@ -81,6 +82,7 @@
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"locale/*.json",
|
||||
"lib/index.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
en: 463eecf002ec0f54
|
||||
zh: b8dc6003ddccd14a
|
||||
/deepseek-ai-dsh-client-ui-settings-plugin-inventory/summary:
|
||||
en: 56f5349b6e502525
|
||||
zh: 691a0fe90b739e9d
|
||||
en: f52353cf99816eb8
|
||||
zh: e172c1751289882f
|
||||
/deepseek-ai-dsh-client-ui-settings-plugin-inventory/table-of-contents:
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
@@ -18,14 +18,14 @@
|
||||
en: 67637808e7940157
|
||||
zh: d4d0716ea4d33e4a
|
||||
/deepseek-ai-dsh-client-ui-settings-plugin-inventory/use-this-package/reading-a-card:
|
||||
en: e615aaa891b5055f
|
||||
zh: 38769e763c8e54ca
|
||||
en: 141d6a73e99fee51
|
||||
zh: 2aa28b174867c967
|
||||
/deepseek-ai-dsh-client-ui-settings-plugin-inventory/use-this-package/the-preset-switcher:
|
||||
en: 84e5938329199a22
|
||||
zh: 6f5cfaac56433e8b
|
||||
/deepseek-ai-dsh-client-ui-settings-plugin-inventory/use-this-package/retrying-a-failed-read:
|
||||
en: 2859449aaae5ab59
|
||||
zh: 06380059ae429026
|
||||
en: 673c7574e14078be
|
||||
zh: b093e897f0341973
|
||||
/deepseek-ai-dsh-client-ui-settings-plugin-inventory/understand-the-implementation:
|
||||
en: cab883d26642c932
|
||||
zh: 54e7321bd781b400
|
||||
@@ -33,8 +33,8 @@
|
||||
en: e4792880c5916ce0
|
||||
zh: 4c8171a446dd153f
|
||||
/deepseek-ai-dsh-client-ui-settings-plugin-inventory/understand-the-implementation/rendering:
|
||||
en: b9aa850e5f101b68
|
||||
zh: 8a29025f19f6ce37
|
||||
en: 779146e3d3732803
|
||||
zh: 0dfafa328dfeda75
|
||||
/deepseek-ai-dsh-client-ui-settings-plugin-inventory/further-exploration:
|
||||
en: ae3819f0c5da95bd
|
||||
zh: 40972ab96d5c95e2
|
||||
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
The **Plugin list** tab lets Web users inspect plugins without changing their configuration. It lists agent presets, then the global inventory; both start collapsed and open on demand or during a search. Cards show localized titles and descriptions when available, identify instances by stable entry id, and expose enablement, source details, runtime status, disabled conditions, and discovery failures; preset-provided global entries name their presets. Search covers both groups and points to matches in other presets. The tab handles loading, empty, no-match, failure, and retry states without exposing transport details, and still shows the global inventory without a preset roster.
|
||||
The **Plugin list** tab lets Web users inspect plugins without changing their configuration. It lists agent presets, open by default, then the collapsed global inventory; a search opens both. Cards show localized titles and descriptions when available, identify instances by stable entry id, and expose enablement, source details, runtime status, disabled conditions, and discovery failures; preset-provided global entries name their presets. Search covers both groups and points to matches in other presets. The tab handles loading, empty, no-match, failure, and retry states without exposing transport details, and still shows the global inventory without a preset roster.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -31,9 +31,9 @@ A failed installation with pending pnpm build permissions offers **Allow these s
|
||||
|
||||
### Reading a card
|
||||
|
||||
Each collapsed card uses the plugin's localized title and description when available. Settings shortens literal package-name or module-name fallbacks by removing the npm scope and the `cordis:`, `cordis-plugin-`, or `dsh-`/`dsh-host-`/`dsh-client-` prefix; locale titles remain unchanged. The Host supplies locale and package-field fallbacks in `meta`; the Client selects the current language. Full module specifiers remain available in details and search. Missing descriptions are omitted, and metadata diagnostics appear with the card. This compact naming applies only to Settings, not the sidebar's Plugins page.
|
||||
Each collapsed card uses the plugin's localized title and description when available; a description shows at most two lines until its card is expanded. Settings shortens literal package-name or module-name fallbacks by removing the npm scope and the `cordis:`, `cordis-plugin-`, or `dsh-`/`dsh-host-`/`dsh-client-` prefix; locale titles remain unchanged. The Host supplies locale and package-field fallbacks in `meta`; the Client selects the current language. Full module specifiers remain available in details and search. Missing descriptions are omitted, and metadata diagnostics appear with the card. This compact naming applies only to Settings, not the sidebar's Plugins page.
|
||||
|
||||
The stable entry id appears underneath the title, alongside a small enablement tag; a colored root-fiber status dot marks only the phases the tag cannot state: `pending` maps to idle, while `loading` and `unloading` map to ongoing. An `active` or `failed` fiber shows its tag without a dot. A composition-generated subtitle omits its leading `include:` marker, while hover, search, the accessible name, and expanded details retain the complete id. Long entry ids truncate in the row and remain available on hover. Expanding one card reveals the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its disable condition when it carries one; a preset-provided global row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Preset names resolve through the shared `presetDisplayText` fold (`dsh-agent-preset-registry/display`) over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries: shipped presets follow the active locale while user-authored ones keep their own metadata, so an English surface never echoes the preset declarations' Chinese names. Search filters both groups by localized title and description, module name, and entry id.
|
||||
A small enablement tag marks only the states that differ from plainly enabled: disabled, conditional, provided by presets, and failed; a plainly enabled row carries no tag. A colored root-fiber status dot marks the live phases no tag states: `pending` maps to idle, while `loading` and `unloading` map to ongoing; an `active` or `failed` fiber shows no dot. The stable entry id appears in a code chip at the card's bottom-left, in line with the title, only when it differs from the title: a composition-generated id drops its leading `include:` marker there, and an id that then repeats the title is omitted. Whenever the chip shows, its hover text and the card's accessible name carry the complete id; search and expanded details always retain it. Long entry ids truncate in the chip and remain available on hover. Expanding one card reveals its full description, the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its disable condition when it carries one; a preset-provided global row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Preset names resolve through the shared `presetDisplayText` fold (`dsh-agent-preset-registry/display`) over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries: shipped presets follow the active locale while user-authored ones keep their own metadata, so an English surface never echoes the preset declarations' Chinese names. Search filters both groups by localized title and description, module name, and entry id.
|
||||
|
||||
### The preset switcher
|
||||
|
||||
@@ -41,7 +41,7 @@ The switcher is the same selector-pill-plus-menu control the General settings ro
|
||||
|
||||
### Retrying a failed read
|
||||
|
||||
A failed read renders the shared error marker inside the tab; loading and current-page synchronization use the shared ongoing loader. Retrying re-runs the lazy `list()` call without exposing transport details.
|
||||
A failed read renders the shared error marker inside the tab. Loading shows skeleton cards in the card grid, and current-page synchronization uses the shared ongoing loader. Retrying re-runs the lazy `list()` call without exposing transport details.
|
||||
|
||||
The Plugin list also shows synchronization failures on the current page. Its retry reapplies the latest client graph without changing Host enablement or refreshing the page.
|
||||
|
||||
@@ -61,7 +61,7 @@ The browser plugin registers one localized `settings.plugins.tab` contribution w
|
||||
|
||||
### Rendering
|
||||
|
||||
Row keys are scope-qualified (`global:`, `preset:<id>:<index>`), so one module appearing in both scopes keeps distinct disclosure state; a declared entry id appears in expanded details and supplies the collapsed subtitle after removal of a leading composition `include:` marker, while a row without one stays unlabeled. The preset-provided marking is derived client-side: a global entry carries it when it is disabled there while at least one preset row for the same module specifier is actually enabled, so a module every preset gates off (or declares only conditionally) stays plainly disabled rather than over-claiming provision.
|
||||
Row keys are scope-qualified (`global:`, `preset:<id>:<index>`), so one module appearing in both scopes keeps distinct disclosure state; a declared entry id appears in expanded details and, after removal of a leading composition `include:` marker, supplies the collapsed id chip when the result differs from the title, while a row without one stays unlabeled. The preset-provided marking is derived client-side: a global entry carries it when it is disabled there while at least one preset row for the same module specifier is actually enabled, so a module every preset gates off (or declares only conditionally) stays plainly disabled rather than over-claiming provision.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
**插件列表**标签页让 Web 用户查看插件,而不改变其配置。它先列 agent(智能体)预设分组,再列全局清单;两个分组默认都收起,按需展开,搜索期间保持展开。卡片显示可用的本地化标题与描述,以稳定的条目 id 标识实例,并展示启停状态、出处、运行状态、禁用条件与发现失败;由预设提供的全局条目会列出对应预设。搜索覆盖两个分组,并指出其他预设中的匹配。标签页处理加载、空结果、无匹配、失败与重试状态,且不暴露传输细节;没有预设 roster 时仍会展示全局清单。
|
||||
**插件列表**标签页让 Web 用户查看插件,而不改变其配置。它先列 agent(智能体)预设分组,再列全局清单;预设分组默认展开,全局分组默认收起,搜索期间两组都展开。卡片显示可用的本地化标题与描述,以稳定的条目 id 标识实例,并展示启停状态、出处、运行状态、禁用条件与发现失败;由预设提供的全局条目会列出对应预设。搜索覆盖两个分组,并指出其他预设中的匹配。标签页处理加载、空结果、无匹配、失败与重试状态,且不暴露传输细节;没有预设 roster 时仍会展示全局清单。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -31,9 +31,9 @@ kind: "package-reference"
|
||||
|
||||
### 阅读卡片
|
||||
|
||||
每张收起的卡片使用插件可用的本地化标题与描述。设置页会缩短字面的包名或模块名回退值,移除 npm scope 以及 `cordis:`、`cordis-plugin-` 或 `dsh-`/`dsh-host-`/`dsh-client-` 前缀;locale 标题保持原样。Host 在 `meta` 中提供 locale 与包字段的回退结果,Client 选择当前语言。详情与搜索仍保留完整模块标识。缺失的描述不显示,元信息诊断随卡片展示。这种短名显示仅用于设置页,不影响侧栏的插件页。
|
||||
每张收起的卡片使用插件可用的本地化标题与描述;描述最多显示两行,展开卡片后显示全文。设置页会缩短字面的包名或模块名回退值,移除 npm scope 以及 `cordis:`、`cordis-plugin-` 或 `dsh-`/`dsh-host-`/`dsh-client-` 前缀;locale 标题保持原样。Host 在 `meta` 中提供 locale 与包字段的回退结果,Client 选择当前语言。详情与搜索仍保留完整模块标识。缺失的描述不显示,元信息诊断随卡片展示。这种短名显示仅用于设置页,不影响侧栏的插件页。
|
||||
|
||||
稳定的条目 id 显示在标题下方,卡片带有表示启停状态的小标签;彩色根 fiber 状态圆点只标记标签无法表达的阶段:`pending` 映射为 idle,`loading` 与 `unloading` 映射为 ongoing。`active` 或 `failed` fiber 只显示标签,不显示圆点。组合生成的次标题省略开头的 `include:` 标记;悬停、搜索、无障碍名称与展开详情仍保留完整 id。长条目 id 会在行内截断,悬停时仍可查看完整值。展开卡片后会显示声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的禁用条件;被预设提供的全局行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。预设名经共享的 `presetDisplayText` 纯函数(`dsh-agent-preset-registry/display`)叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析:内置预设走当前语言,用户自建预设保留自己的元数据,因此英文界面不会回显预设声明里的中文名。搜索按本地化标题与描述、模块名称及条目 id 过滤两组。
|
||||
表示启停状态的小标签只标记与普通启用不同的状态:已停用、条件启用、由预设提供与启动失败;普通启用的行不带标签。彩色根 fiber 状态圆点标记没有标签表达的存活阶段:`pending` 映射为 idle,`loading` 与 `unloading` 映射为 ongoing;`active` 或 `failed` fiber 不显示圆点。稳定的条目 id 仅在与标题不同时显示,位于卡片左下角的代码标签中,与标题左对齐:组合生成的 id 在此省略开头的 `include:` 标记,省略后与标题相同的 id 不再重复显示。显示代码标签时,其悬停提示与卡片的无障碍名称带完整 id;搜索与展开详情始终保留完整 id。长条目 id 会在代码标签内截断,悬停时仍可查看完整值。展开卡片后会显示完整描述、声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的禁用条件;被预设提供的全局行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。预设名经共享的 `presetDisplayText` 纯函数(`dsh-agent-preset-registry/display`)叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析:内置预设走当前语言,用户自建预设保留自己的元数据,因此英文界面不会回显预设声明里的中文名。搜索按本地化标题与描述、模块名称及条目 id 过滤两组。
|
||||
|
||||
### 预设切换器
|
||||
|
||||
@@ -41,7 +41,7 @@ kind: "package-reference"
|
||||
|
||||
### 重试失败的读取
|
||||
|
||||
读取失败会在标签页内渲染共享 error 标记;加载与当前页面同步使用共享 ongoing loading。重试会重新执行懒 `list()` 调用,且不会暴露传输细节。
|
||||
读取失败会在标签页内渲染共享 error 标记。加载时在卡片网格中显示骨架卡片,当前页面同步使用共享 ongoing loading。重试会重新执行懒 `list()` 调用,且不会暴露传输细节。
|
||||
|
||||
插件列表还显示当前页面的同步失败。重试会重新应用最新客户端图,不改变 Host 启用状态,也不刷新页面。
|
||||
|
||||
@@ -61,7 +61,7 @@ kind: "package-reference"
|
||||
|
||||
### 渲染
|
||||
|
||||
行 key 按作用域限定(`global:`、`preset:<id>:<index>`),因此同一模块出现在两个作用域时保持各自的展开状态;声明的条目 id 出现在展开详情中,并在去掉开头的组合 `include:` 标记后作为收起次标题,没有 id 的行不显示次级标签。预设提供标记在客户端推导:一个全局条目在全局被停用、且至少一个预设行对同一模块标识实际启用时才携带它,因此被所有预设关掉(或仅条件声明)的模块保持单纯的已停用,而不是夸大提供关系。
|
||||
行 key 按作用域限定(`global:`、`preset:<id>:<index>`),因此同一模块出现在两个作用域时保持各自的展开状态;声明的条目 id 出现在展开详情中;去掉开头的组合 `include:` 标记后若与标题不同,则作为收起卡片的 id 代码标签,没有 id 的行不显示次级标签。预设提供标记在客户端推导:一个全局条目在全局被停用、且至少一个预设行对同一模块标识实际启用时才携带它,因此被所有预设关掉(或仅条件声明)的模块保持单纯的已停用,而不是夸大提供关系。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+101
-34
@@ -1,4 +1,6 @@
|
||||
/* The tab's own width, not the window's, decides the card columns below. */
|
||||
.section {
|
||||
container: plugin-inventory / inline-size;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
@@ -7,7 +9,6 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.catalogHeading h3,
|
||||
.status,
|
||||
.failure p {
|
||||
margin: 0;
|
||||
@@ -85,37 +86,21 @@
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)) 18%, transparent);
|
||||
}
|
||||
|
||||
.catalogHeading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.catalogHeading h3 {
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.catalogHeading span {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* Cards stretch to their grid row's height. The button fills a stretched card:
|
||||
the title row and description stay at the top, in line with the other card's,
|
||||
and the spare height sits above the entry id chip or at the bottom. */
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 0.5px solid var(--dsw-alias-settings-card-stroke);
|
||||
@@ -127,11 +112,22 @@
|
||||
border-color: var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
/* The other card in an expanded card's row keeps its own height instead of
|
||||
stretching alongside the details. The selectors assume the two columns
|
||||
`.cards` declares, where each row starts at an odd position, and must change
|
||||
with that count; in one column the matched card sits alone in its row. */
|
||||
.card:nth-child(odd)[data-open='true'] + .card,
|
||||
.card:nth-child(odd):has(+ .card[data-open='true']) {
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
border: 0;
|
||||
@@ -173,11 +169,35 @@
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* The Loader entry id keeps one size in the card subtitle and the row it expands into;
|
||||
the collapsed subtitle stays on the quieter label tier, while the expanded row carries
|
||||
/* Two lines at most while collapsed; the expanded card shows the full text. */
|
||||
.cardDescription {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
text-wrap: pretty;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.card[data-open='true'] .cardDescription {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* The entry id is metadata, not prose: a code chip in the card's bottom-left
|
||||
corner whose left edge lines up with the title. */
|
||||
.cardMeta {
|
||||
display: flex;
|
||||
margin-top: auto;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
/* The Loader entry id keeps one size in the card chip and the row it expands into;
|
||||
the collapsed chip stays on the quieter label tier, while the expanded row carries
|
||||
the card's primary value. */
|
||||
.cardIdentity,
|
||||
.entryValue {
|
||||
@@ -188,15 +208,19 @@
|
||||
|
||||
.cardIdentity {
|
||||
display: block;
|
||||
width: 100%;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
border-radius: var(--dsw-radius-xs);
|
||||
padding: 1px 6px;
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.cardTrailing {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
gap: 8px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
@@ -213,11 +237,13 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Every group header takes the preset switcher's 36px height, so each subtitle
|
||||
sits the same distance below its title. */
|
||||
.groupTitleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 32px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.group + .group {
|
||||
@@ -266,7 +292,7 @@
|
||||
.groupSub {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
row-gap: 4px;
|
||||
margin: -6px 0 0 20px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
@@ -274,6 +300,13 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* A middle dot with equal space on both sides separates the subtitle's parts. */
|
||||
.groupSub > * + *::before {
|
||||
content: '·';
|
||||
padding: 0 6px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.failedCount {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
@@ -330,7 +363,7 @@
|
||||
padding: 8px 10px;
|
||||
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font-size: 12.5px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-line;
|
||||
@@ -343,7 +376,7 @@
|
||||
gap: 4px 8px;
|
||||
margin: 0;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12.5px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
@@ -353,10 +386,34 @@
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Loading placeholder in the cards grid, sized like a card with one description
|
||||
line; its bars pulse through the shared skeleton token as the input-trigger
|
||||
menu's pending rows do. */
|
||||
.skeletonCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border: 0.5px solid var(--dsw-alias-settings-card-stroke);
|
||||
border-radius: var(--dsw-radius-xl);
|
||||
padding: 15px 14px;
|
||||
background: var(--dsw-alias-settings-card-fill);
|
||||
}
|
||||
|
||||
.skeletonBar {
|
||||
width: 40%;
|
||||
height: 14px;
|
||||
border-radius: var(--dsw-radius-xs);
|
||||
background: var(--dsw-alias-bg-skeleton);
|
||||
}
|
||||
|
||||
.skeletonBar + .skeletonBar {
|
||||
width: 80%;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.enabledIn {
|
||||
display: flex;
|
||||
@@ -430,9 +487,19 @@
|
||||
.chevron {
|
||||
transition: transform 140ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.skeletonBar {
|
||||
animation: plugin-inventory-skeleton 2s cubic-bezier(0.36, 0, 0.64, 1) infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
@keyframes plugin-inventory-skeleton {
|
||||
0% { opacity: 1; }
|
||||
40% { opacity: 0.6; }
|
||||
80%, 100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@container plugin-inventory (max-width: 520px) {
|
||||
.cards {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
+42
-20
@@ -58,6 +58,9 @@ const PHASE_KEYS = {
|
||||
unloading: 'unloading',
|
||||
} satisfies Record<Exclude<PluginFiberPhase, null>, PluginInventoryLocaleKey>
|
||||
|
||||
/** Placeholder cards the loading skeleton lays out in the cards grid. */
|
||||
const SKELETON_CARDS = [0, 1, 2, 3] as const
|
||||
|
||||
/** Localized accessible label for one root Fiber phase. */
|
||||
function phaseLabel(phase: PluginFiberPhase, t: Translate): string {
|
||||
return phase === null ? t('unobserved') : t(PHASE_KEYS[phase])
|
||||
@@ -77,6 +80,16 @@ function entrySubtitle(entryId: string): string {
|
||||
return entryId.replace(/^include:/, '')
|
||||
}
|
||||
|
||||
/** Whether a card shows its entry id: the id exists and, without its `include:` marker, differs from the title. */
|
||||
function idAddsToTitle(entryId: string | null, title: string): entryId is string {
|
||||
return entryId !== null && entrySubtitle(entryId) !== title
|
||||
}
|
||||
|
||||
/** Accessible card name: the title, the complete entry id when the card shows one, then the enablement state. */
|
||||
function cardLabel(title: string, entryId: string | null, state: string): string {
|
||||
return idAddsToTitle(entryId, title) ? `${title}, ${entryId}, ${state}` : `${title}, ${state}`
|
||||
}
|
||||
|
||||
/** Preserve translated titles and shorten literal package or module name fallbacks in Settings. */
|
||||
function pluginText(row: PluginInventoryEntry | AgentPresetRow, resolveText: PluginInventorySettingsTabInjected['resolveText']) {
|
||||
const title = row.meta?.title
|
||||
@@ -151,8 +164,12 @@ function PluginCard({
|
||||
<IconChevronDownOutlineRegular className={css.chevron} size={12} aria-hidden="true" />
|
||||
</span>
|
||||
</span>
|
||||
{description === undefined ? null : <span className={css.hint} id={descriptionId}>{description}</span>}
|
||||
{entryId === null ? null : <code className={css.cardIdentity} title={entryId}>{entrySubtitle(entryId)}</code>}
|
||||
{description === undefined ? null : <span className={css.cardDescription} id={descriptionId}>{description}</span>}
|
||||
{idAddsToTitle(entryId, title) ? (
|
||||
<span className={css.cardMeta}>
|
||||
<code className={css.cardIdentity} title={entryId}>{entrySubtitle(entryId)}</code>
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
{metadataError === undefined ? null : <p className={css.brokenNote} role="status" data-package-meta-error>{metadataError}</p>}
|
||||
{open ? <div className={css.cardDetails} id={detailId}>{children}</div> : null}
|
||||
@@ -189,15 +206,15 @@ function CardFacts({ moduleName, moduleLabel, entryId, facts }: {
|
||||
/* `pending` is the only dotted phase with no work under way. `loading` and
|
||||
* `unloading` are both live transitions the Host is running — an async
|
||||
* disposer can hold `unloading` for a while — so both animate. `active` and
|
||||
* `failed` carry no dot: a settled enabled row shows its enablement tag alone,
|
||||
* and a failed row has the failure tag. */
|
||||
* `failed` carry no dot: a settled enabled row needs no marker, and a failed
|
||||
* row has the failure tag. */
|
||||
const PHASE_DOT_STATES = {
|
||||
pending: 'idle',
|
||||
loading: 'ongoing',
|
||||
unloading: 'ongoing',
|
||||
} as const satisfies Partial<Record<NonNullable<PluginFiberPhase>, StateDotState>>
|
||||
|
||||
/** A live root-fiber phase whose dot still adds to the row's enablement tag. */
|
||||
/** A live root-fiber phase the row marks with a dot. */
|
||||
type DotPhase = keyof typeof PHASE_DOT_STATES
|
||||
|
||||
/** Whether a live root-fiber phase carries a dot of its own. */
|
||||
@@ -220,16 +237,15 @@ function PhaseDot({ phase, t }: { readonly phase: DotPhase; readonly t: Translat
|
||||
type EnablementKind = 'enabled' | 'disabled' | 'conditional' | 'preset' | 'failed'
|
||||
|
||||
const TAG_TONES = {
|
||||
enabled: 'success',
|
||||
disabled: 'neutral',
|
||||
conditional: 'warning',
|
||||
preset: 'info',
|
||||
failed: 'danger',
|
||||
} as const satisfies Record<EnablementKind, TagTone>
|
||||
} as const satisfies Record<Exclude<EnablementKind, 'enabled'>, TagTone>
|
||||
|
||||
/** Enablement tag; `kind` selects the palette. */
|
||||
/** Enablement tag for the states that depart from the default; a plainly enabled row carries none. */
|
||||
function StateTag({ kind, label }: { readonly kind: EnablementKind; readonly label: string }): ReactNode {
|
||||
return <Tag tone={TAG_TONES[kind]}>{label}</Tag>
|
||||
return kind === 'enabled' ? null : <Tag tone={TAG_TONES[kind]}>{label}</Tag>
|
||||
}
|
||||
|
||||
/** Render the read-only plugin inventory: agent presets first, then the global plane. */
|
||||
@@ -296,8 +312,8 @@ export function PluginInventorySettingsTab(
|
||||
const otherMatchCount = otherPresetMatches
|
||||
.reduce((total, preset) => total + preset.rows.filter(rowMatch).length, 0)
|
||||
|
||||
// Both groups start collapsed; a search opens them for as long as it lasts.
|
||||
const presetEffectiveOpen = searching || (presetOpen ?? false)
|
||||
// The preset group starts open and the larger global plane folded; a search opens both for as long as it lasts.
|
||||
const presetEffectiveOpen = searching || (presetOpen ?? true)
|
||||
const globalEffectiveOpen = searching || (globalOpen ?? false)
|
||||
const nothingMatches = searching && globalCount === 0 && selectedRows.length === 0
|
||||
&& otherPresetMatches.length === 0
|
||||
@@ -331,7 +347,7 @@ export function PluginInventorySettingsTab(
|
||||
failed={failed}
|
||||
expanded={expanded}
|
||||
onToggle={toggleRow}
|
||||
ariaLabel={`${title}${row.entryId === null ? '' : `, ${row.entryId}`}, ${stateText}`}
|
||||
ariaLabel={cardLabel(title, row.entryId, stateText)}
|
||||
trailing={(
|
||||
<>
|
||||
{row.enabled === true && showsPhaseDot(row.fiberPhase)
|
||||
@@ -380,7 +396,7 @@ export function PluginInventorySettingsTab(
|
||||
failed={failed}
|
||||
expanded={expanded}
|
||||
onToggle={toggleRow}
|
||||
ariaLabel={`${title}, ${entry.entryId}, ${stateText}`}
|
||||
ariaLabel={cardLabel(title, entry.entryId, stateText)}
|
||||
trailing={(
|
||||
<>
|
||||
{entry.enabled && showsPhaseDot(entry.fiberPhase)
|
||||
@@ -436,9 +452,15 @@ export function PluginInventorySettingsTab(
|
||||
</div>
|
||||
)}
|
||||
{state.status === 'loading' ? (
|
||||
<p className={`${css.status} ${css.statusWithDot}`} role="status">
|
||||
<StateDot state="ongoing" />{t('loading')}
|
||||
</p>
|
||||
<div className={css.cards} role="status">
|
||||
<span className={css.visuallyHidden}>{t('loading')}</span>
|
||||
{SKELETON_CARDS.map(slot => (
|
||||
<div key={slot} className={css.skeletonCard} aria-hidden="true">
|
||||
<span className={css.skeletonBar} />
|
||||
<span className={css.skeletonBar} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{state.status === 'error' ? (
|
||||
<div className={css.failure}>
|
||||
@@ -506,9 +528,9 @@ export function PluginInventorySettingsTab(
|
||||
</div>
|
||||
</div>
|
||||
<p className={css.groupSub}>
|
||||
{t('presetSubtitle')}
|
||||
<span>{t('presetSubtitle')}</span>
|
||||
<span data-preset-plugin-count={selectedRows.length}>
|
||||
{` · ${String(selectedRows.length)} ${t('countUnit')}`}
|
||||
{`${String(selectedRows.length)} ${t('countUnit')}`}
|
||||
</span>
|
||||
</p>
|
||||
{presetEffectiveOpen ? (
|
||||
@@ -556,8 +578,8 @@ export function PluginInventorySettingsTab(
|
||||
</button>
|
||||
</div>
|
||||
<p className={css.groupSub}>
|
||||
{t('globalSubtitle')}
|
||||
<span data-plugin-count={globalCount}>{` · ${String(globalCount)} ${t('countUnit')}`}</span>
|
||||
<span>{t('globalSubtitle')}</span>
|
||||
<span data-plugin-count={globalCount}>{`${String(globalCount)} ${t('countUnit')}`}</span>
|
||||
{filteredFailed.length > 0 ? (
|
||||
<span className={css.failedCount}>{filteredFailed.length} {t('failedCountLabel')}</span>
|
||||
) : null}
|
||||
|
||||
@@ -76,7 +76,7 @@ export const en = {
|
||||
enabledTag: 'Enabled',
|
||||
disabledTag: 'Disabled',
|
||||
conditionalTag: 'Conditional',
|
||||
presetEnabledTag: 'Enabled via presets',
|
||||
presetEnabledTag: 'Via presets',
|
||||
failedTag: 'Failed',
|
||||
moduleLabel: 'Module',
|
||||
fromPreset: 'From',
|
||||
|
||||
@@ -128,7 +128,7 @@ describe('PluginInventorySettingsTab', () => {
|
||||
? { entries: rows }
|
||||
: { entries: [], agentPresets: [{ id: 'custom', isDefault: true, rows }] }
|
||||
const view = await renderReady(snapshot)
|
||||
fireEvent.click(scope === 'global' ? globalToggle() : presetToggle())
|
||||
if (scope === 'global') fireEvent.click(globalToggle())
|
||||
for (const [index, [moduleName, title]] of names.entries()) {
|
||||
for (const fromManifest of [false, true]) {
|
||||
const entryId = `include:short-${String(index)}-${String(fromManifest)}`
|
||||
@@ -194,7 +194,6 @@ describe('PluginInventorySettingsTab', () => {
|
||||
const view = render(<PluginInventorySettingsTab {...pageProps} />)
|
||||
await screen.findByRole('searchbox', { name: en.search })
|
||||
fireEvent.click(globalToggle())
|
||||
fireEvent.click(presetToggle())
|
||||
const global = screen.getByRole('button', { name: 'Navigation, include:global-navigation, Enabled' })
|
||||
const preset = screen.getByRole('button', { name: 'Session runner, include:preset-runner, Enabled' })
|
||||
expect(document.getElementById(global.getAttribute('aria-describedby')!)?.textContent).toBe('Global navigation controls')
|
||||
@@ -256,7 +255,7 @@ describe('PluginInventorySettingsTab', () => {
|
||||
const { locale, pageProps } = localizedProps(list)
|
||||
const view = render(<PluginInventorySettingsTab {...pageProps} />)
|
||||
await screen.findByRole('searchbox', { name: en.search })
|
||||
fireEvent.click(scope === 'global' ? globalToggle() : presetToggle())
|
||||
if (scope === 'global') fireEvent.click(globalToggle())
|
||||
expect(screen.getByRole('button', { name: 'sidebar/navigation, include:navigation, Enabled' })).toBeTruthy()
|
||||
expect(screen.getByText('Navigation description.')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'English title, include:commands, Enabled' })).toBeTruthy()
|
||||
@@ -302,14 +301,14 @@ describe('PluginInventorySettingsTab', () => {
|
||||
})
|
||||
fireEvent.click(globalToggle())
|
||||
expect(screen.getByText(en.metadataError.replace('{error}', error))).toBeTruthy()
|
||||
const card = screen.getByRole('button', { name: 'legacy, include:legacy, Disabled' })
|
||||
const card = screen.getByRole('button', { name: 'legacy, Disabled' })
|
||||
expect(card).toHaveProperty('disabled', false)
|
||||
expect(card.closest('li')?.getAttribute('data-failed')).toBeNull()
|
||||
fireEvent.click(card)
|
||||
expect(screen.getByText(en.moduleLabel).nextElementSibling?.textContent).toBe('@acme/dsh-legacy')
|
||||
})
|
||||
|
||||
it('shows the default preset first with both groups collapsed', async () => {
|
||||
it('shows the default preset first with its group open and the global plane folded', async () => {
|
||||
const view = await renderReady()
|
||||
|
||||
const switcher = screen.getByRole('button', { name: en.switcherLabel })
|
||||
@@ -325,18 +324,16 @@ describe('PluginInventorySettingsTab', () => {
|
||||
expect(screen.getByText(en.presetSubtitle)).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('6')
|
||||
|
||||
// Both groups start collapsed; opening the preset group lists its rows while the global plane stays folded.
|
||||
expect(presetToggle().getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.queryAllByRole('listitem')).toHaveLength(0)
|
||||
fireEvent.click(presetToggle())
|
||||
// The preset group starts open with its rows while the larger global plane stays folded.
|
||||
expect(presetToggle().getAttribute('aria-expanded')).toBe('true')
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(6)
|
||||
expect(screen.getAllByText(en.enabledTag)).toHaveLength(3)
|
||||
// Only the states that depart from plainly enabled carry a tag.
|
||||
expect(screen.queryByText(en.enabledTag)).toBeNull()
|
||||
expect(screen.getByText(en.conditionalTag)).toBeTruthy()
|
||||
expect(screen.getByText(en.disabledTag)).toBeTruthy()
|
||||
expect(screen.getByText(en.failedTag)).toBeTruthy()
|
||||
// The enablement tag is the row's one settled status signal: an active fiber
|
||||
// and a row with no live fiber both render without a phase dot.
|
||||
// A settled row carries no phase dot: an active fiber and a row with no live
|
||||
// fiber both render without one.
|
||||
expect(screen.queryByRole('img', { name: en.active })).toBeNull()
|
||||
expect(screen.queryByRole('img', { name: en.unobserved })).toBeNull()
|
||||
|
||||
@@ -345,16 +342,16 @@ describe('PluginInventorySettingsTab', () => {
|
||||
expect(screen.getByText(`1 ${en.failedCountLabel}`)).toBeTruthy()
|
||||
|
||||
// A preset row expands into its source facts.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'pwsh, pwsh, Conditional' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'pwsh, Conditional' }))
|
||||
expect(screen.getByText(en.fromPreset)).toBeTruthy()
|
||||
expect(screen.getByText('标准模式')).toBeTruthy()
|
||||
expect(screen.getByText(en.condition)).toBeTruthy()
|
||||
expect(screen.getByText('process.platform === \'win32\'')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'pwsh, pwsh, Conditional' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'pwsh, Conditional' }))
|
||||
expect(screen.queryByText(en.condition)).toBeNull()
|
||||
|
||||
// A failed preset row names its runtime state instead of a condition.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'crashy, crashy, Failed' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'crashy, Failed' }))
|
||||
expect(screen.getByText(en.runtime)).toBeTruthy()
|
||||
expect(screen.getByText('Failed to start')).toBeTruthy()
|
||||
|
||||
@@ -364,7 +361,7 @@ describe('PluginInventorySettingsTab', () => {
|
||||
expect(screen.getByText(en.moduleLabel).nextElementSibling?.textContent).toBe('@fixture/anonymous')
|
||||
})
|
||||
|
||||
it('keeps the phase dot for a live phase the enablement tag does not state', async () => {
|
||||
it('marks a live phase with a dot on an untagged enabled row', async () => {
|
||||
await renderReady({
|
||||
entries: [
|
||||
{ entryId: 'booting', moduleName: '@fixture/booting', enabled: true, fiberPhase: 'loading' },
|
||||
@@ -383,8 +380,8 @@ describe('PluginInventorySettingsTab', () => {
|
||||
} as unknown as Snapshot)
|
||||
|
||||
fireEvent.click(globalToggle())
|
||||
fireEvent.click(presetToggle())
|
||||
expect(screen.getAllByText(en.enabledTag)).toHaveLength(6)
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(6)
|
||||
expect(screen.queryByText(en.enabledTag)).toBeNull()
|
||||
expect(screen.getByRole('img', { name: en.loadingPhase })).toBeTruthy()
|
||||
expect(screen.getByRole('img', { name: en.pending })).toBeTruthy()
|
||||
expect(screen.getByRole('img', { name: en.unloading })).toBeTruthy()
|
||||
@@ -407,7 +404,6 @@ describe('PluginInventorySettingsTab', () => {
|
||||
}],
|
||||
})
|
||||
|
||||
fireEvent.click(presetToggle())
|
||||
expect(screen.getByRole('button', { name: 'tool-subagent, tool-subagent-primary, Enabled' })
|
||||
.getAttribute('aria-expanded')).toBe('false')
|
||||
const secondary = screen.getByRole('button', { name: `tool-subagent, ${longId}, Disabled` })
|
||||
@@ -425,6 +421,25 @@ describe('PluginInventorySettingsTab', () => {
|
||||
expect(filteredSecondary.getAttribute('aria-expanded')).toBe('true')
|
||||
})
|
||||
|
||||
it('shows the entry id only where it adds to the title', async () => {
|
||||
await renderReady({
|
||||
entries: [
|
||||
{ entryId: 'timer' as PluginEntryId, moduleName: 'cordis:timer', enabled: true, fiberPhase: 'active' },
|
||||
{ entryId: 'include:hmr' as PluginEntryId, moduleName: '@deepseek-ai/cordis-plugin-hmr', enabled: true, fiberPhase: 'active' },
|
||||
{ entryId: 'typert' as PluginEntryId, moduleName: '@fixture/typert-registry', enabled: true, fiberPhase: null },
|
||||
],
|
||||
})
|
||||
fireEvent.click(globalToggle())
|
||||
|
||||
// An id equal to the title leaves both the card and its accessible name.
|
||||
expect(screen.getByRole('button', { name: 'timer, Enabled' }).querySelector('code')).toBeNull()
|
||||
// A composition id that matches the title once its include: marker drops is left out the same way.
|
||||
expect(screen.getByRole('button', { name: 'hmr, Enabled' }).querySelector('code')).toBeNull()
|
||||
const typert = screen.getByRole('button', { name: 'typert-registry, typert, Enabled' }).querySelector('code')
|
||||
expect(typert?.textContent).toBe('typert')
|
||||
expect(typert?.getAttribute('title')).toBe('typert')
|
||||
})
|
||||
|
||||
it('expands the global plane with failures first and preset-provided rows inline', async () => {
|
||||
const view = await renderReady()
|
||||
|
||||
@@ -439,13 +454,13 @@ describe('PluginInventorySettingsTab', () => {
|
||||
// Rows the presets took over sit inline, marked instead of plainly disabled.
|
||||
expect(screen.getAllByText(en.presetEnabledTag)).toHaveLength(2)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'tool-bash, bash-host, Enabled via presets' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'tool-bash, bash-host, Via presets' }))
|
||||
expect(screen.getByText(en.presetProvidedDetail)).toBeTruthy()
|
||||
expect(screen.getByText(en.enabledIn)).toBeTruthy()
|
||||
expect(screen.getByText('标准模式 · ptc')).toBeTruthy()
|
||||
|
||||
// The failed global card reports its runtime state.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'telemetry, telemetry, Failed' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'telemetry, Failed' }))
|
||||
expect(screen.getByText('Failed to start')).toBeTruthy()
|
||||
|
||||
// An enabled entry with no live fiber says so in its details, dot-free.
|
||||
@@ -453,7 +468,7 @@ describe('PluginInventorySettingsTab', () => {
|
||||
expect(screen.getByText('Not running')).toBeTruthy()
|
||||
|
||||
// A disabled row outside every preset stays plainly disabled.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'dormant, dormant, Disabled' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'dormant, Disabled' }))
|
||||
expect(screen.queryByText(en.presetProvidedDetail)).toBeNull()
|
||||
|
||||
fireEvent.click(globalToggle())
|
||||
@@ -470,7 +485,6 @@ describe('PluginInventorySettingsTab', () => {
|
||||
|
||||
pickPreset('ptc')
|
||||
expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('3')
|
||||
fireEvent.click(presetToggle())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'tool-bash, bash, Enabled' }))
|
||||
// An unnamed preset labels its source by id.
|
||||
expect(screen.getByText(en.fromPreset).nextElementSibling?.textContent).toBe('ptc')
|
||||
@@ -480,18 +494,17 @@ describe('PluginInventorySettingsTab', () => {
|
||||
expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('0')
|
||||
})
|
||||
|
||||
it('keeps the preset group folded until opened or searched', async () => {
|
||||
it('folds the preset group on demand and reopens it for a search', async () => {
|
||||
const view = await renderReady()
|
||||
const toggle = presetToggle()
|
||||
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(view.container.querySelectorAll('[data-plugin-scope="preset"] li')).toHaveLength(6)
|
||||
// The header keeps its count while the rows are folded away.
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('6')
|
||||
expect(view.container.querySelectorAll('[data-plugin-scope="preset"] li')).toHaveLength(0)
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
|
||||
fireEvent.change(screen.getByRole('searchbox', { name: en.search }), { target: { value: 'pwsh' } })
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true')
|
||||
@@ -521,12 +534,11 @@ describe('PluginInventorySettingsTab', () => {
|
||||
])
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
fireEvent.click(presetToggle())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'pwsh, pwsh, Conditional' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'pwsh, Conditional' }))
|
||||
expect(screen.getByText(en.fromPreset).nextElementSibling?.textContent).toBe('Localized standard')
|
||||
|
||||
fireEvent.click(globalToggle())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'tool-bash, bash-host, Enabled via presets' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'tool-bash, bash-host, Via presets' }))
|
||||
expect(screen.getByText('Localized standard · Localized ptc')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -536,7 +548,7 @@ describe('PluginInventorySettingsTab', () => {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'ptc' }))
|
||||
|
||||
fireEvent.click(globalToggle())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'tool-bash, bash-host, Enabled via presets' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'tool-bash, bash-host, Via presets' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: en.viewInPreset }))
|
||||
expect(screen.getByRole('button', { name: en.switcherLabel }).textContent)
|
||||
.toBe('标准模式 (default)')
|
||||
@@ -588,10 +600,10 @@ describe('PluginInventorySettingsTab', () => {
|
||||
expect(globalToggle().getAttribute('aria-expanded')).toBe('true')
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(2)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'hmr, hmr, Enabled' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'hmr, Enabled' }))
|
||||
expect(screen.getByText(en.runtime)).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe('hmr')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'off, off, Disabled' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'off, Disabled' }))
|
||||
expect(screen.getAllByText(en.moduleLabel).length).toBeGreaterThan(0)
|
||||
expect(screen.queryByText(en.runtime)).toBeNull()
|
||||
})
|
||||
@@ -608,8 +620,6 @@ describe('PluginInventorySettingsTab', () => {
|
||||
|
||||
expect(screen.queryByRole('button', { name: (name: string) => name.startsWith(en.globalTitle) })).toBeNull()
|
||||
expect(screen.queryByText(en.empty)).toBeNull()
|
||||
expect(screen.queryAllByRole('listitem')).toHaveLength(0)
|
||||
fireEvent.click(presetToggle())
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -634,7 +644,8 @@ describe('PluginInventorySettingsTab', () => {
|
||||
|
||||
const deferred = Promise.withResolvers<Snapshot>()
|
||||
const pending = render(<PluginInventorySettingsTab {...props(() => deferred.promise)} />)
|
||||
expect(screen.getByText(en.loading).querySelector('[data-state="ongoing"]')).not.toBeNull()
|
||||
// Loading shows skeleton cards; the status region announces the label as visually hidden text.
|
||||
expect(screen.getByRole('status').textContent).toBe(en.loading)
|
||||
pending.unmount()
|
||||
await act(async () => { deferred.resolve(SNAPSHOT) })
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
en: ec051915b3d81cda
|
||||
zh: 3a0ef832e3a5e419
|
||||
/deepseek-ai-dsh-time-context/summary:
|
||||
en: 8160d6d20a8bc98c
|
||||
zh: b7010a1edf57079b
|
||||
en: d152f34674644f83
|
||||
zh: 5521772386c3737e
|
||||
/deepseek-ai-dsh-time-context/table-of-contents:
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
`dsh-time-context` gives the model a clock: on eligible steps it appends a durable, source-attributed reading with the current time, the browser zone attached to the open request, and the elapsed time since the preceding model-visible message. It helps the model interpret otherwise-unqualified dates and times in the user's browser zone, and tells it to ask when current-turn browser zones are mixed or missing. The shipped Web composition carries its row disabled; the optional `@deepseek-ai/dsh-experimental-schedule-bundle` mounts it with Schedule from the Plugins page. Readings default to a 10-minute minimum interval; `refreshIntervalMs: 0` injects at every eligible step.
|
||||
`dsh-time-context` gives the model a clock: on eligible steps it appends a durable, source-attributed reading with the current time, the browser zone attached to the open request, and the elapsed time since the preceding model-visible message. It helps the model interpret otherwise-unqualified dates and times in the user's browser zone, and tells it to ask when current-turn browser zones are mixed or missing. The shipped Web composition carries no row for it; the optional `@deepseek-ai/dsh-experimental-schedule-bundle` inserts and mounts it with Schedule from the Plugins page. Readings default to a 10-minute minimum interval; `refreshIntervalMs: 0` injects at every eligible step.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
`dsh-time-context` 给模型一只时钟:在符合条件的步骤上,它追加一条持久、带来源的读数,包含当前时间、附加到当前开放请求的浏览器时区,以及自前一条模型可见消息以来的经过时长。它帮助模型按用户的浏览器时区解释未明确限定时区的日期与时间;时区来源混杂或缺失时,它告诉模型去询问。随发行版交付的 Web 组合把该行保持禁用;可选的 `@deepseek-ai/dsh-experimental-schedule-bundle` 在插件管理页会随 Schedule 一起挂载它。读数默认采用 10 分钟的最小间隔;`refreshIntervalMs: 0` 会在每个符合条件的步骤注入。
|
||||
`dsh-time-context` 给模型一只时钟:在符合条件的步骤上,它追加一条持久、带来源的读数,包含当前时间、附加到当前开放请求的浏览器时区,以及自前一条模型可见消息以来的经过时长。它帮助模型按用户的浏览器时区解释未明确限定时区的日期与时间;时区来源混杂或缺失时,它告诉模型去询问。随发行版交付的 Web 组合不含该行;可选的 `@deepseek-ai/dsh-experimental-schedule-bundle` 在插件管理页会插入它并随 Schedule 一起挂载。读数默认采用 10 分钟的最小间隔;`refreshIntervalMs: 0` 会在每个符合条件的步骤注入。
|
||||
|
||||
## 目录
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "Time awareness",
|
||||
"description": "Tells the model the current time, the browser's time zone, and the time since the previous message at each step."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "时间感知",
|
||||
"description": "每一步把当前时间、浏览器时区和距上一条消息的时长告诉模型。"
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,11 @@
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
"./package.json": "./package.json",
|
||||
"./locale/*.json": "./locale/*.json"
|
||||
},
|
||||
"files": [
|
||||
"locale/*.json",
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
|
||||
@@ -48,8 +48,8 @@
|
||||
en: ed5137ef858b3424
|
||||
zh: af2ff3bc15576456
|
||||
/deepseek-ai-dsh-agent-loop/understand-the-implementation/failure-and-cancellation:
|
||||
en: 6bd69c9cf8790e14
|
||||
zh: e2812f6bb4c26f10
|
||||
en: 548b6ee23c3a1e5d
|
||||
zh: e89428f427b4462e
|
||||
/deepseek-ai-dsh-agent-loop/further-exploration:
|
||||
en: 6c73cd2390e24b86
|
||||
zh: ba042ca93cc0f595
|
||||
@@ -92,9 +92,21 @@
|
||||
/deepseek-ai-dsh-agent-loop/model-experience/undispatched-calls-after-cancellation/kv-cache-effect:
|
||||
en: 1750eea3cc5bf2d8
|
||||
zh: 519d37b0cb4467a7
|
||||
/deepseek-ai-dsh-agent-loop/model-experience/unanswered-calls-after-step-failure:
|
||||
en: 0f150799ee294e40
|
||||
zh: 4c283b07367dfa4a
|
||||
/deepseek-ai-dsh-agent-loop/model-experience/unanswered-calls-after-step-failure/what-the-model-sees:
|
||||
en: a62feae35f09ff13
|
||||
zh: d20a0172d86f6515
|
||||
/deepseek-ai-dsh-agent-loop/model-experience/unanswered-calls-after-step-failure/token-effect:
|
||||
en: cefdf878a21a681a
|
||||
zh: 96d8271b257478d6
|
||||
/deepseek-ai-dsh-agent-loop/model-experience/unanswered-calls-after-step-failure/kv-cache-effect:
|
||||
en: 83531f2a13cc1f0e
|
||||
zh: 81a780d637dd8c5d
|
||||
/deepseek-ai-dsh-agent-loop/known-limitations-and-deferred-work:
|
||||
en: f658b8bc2935578f
|
||||
zh: 4d503701fa17b982
|
||||
en: 7f93fa258b0e39ba
|
||||
zh: 819075791542b01c
|
||||
/deepseek-ai-dsh-agent-loop/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: 3bb0c20c93f16f98
|
||||
|
||||
@@ -126,6 +126,8 @@ Prompt admission uses the actual `prepareCall()` result, not the preceding `requ
|
||||
|
||||
Final adapter selection, dispatch, and iteration failures arrive as terminal finishes and enter `agent/request-error`; a handling listener returns `{ kind: 'retry' }` without calling `next()`, while an unhandled failure is terminal. Middleware, result-processing, tool, and other extension failures remain thrown and close the turn directly — plugin failure ends the turn, not the loop. Undispatched model tool calls after cancellation receive synthetic `tool/call` plus `ABORTED_BEFORE_DISPATCH` result pairs. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the signal lifecycle.
|
||||
|
||||
Before closing a failed step, the driver records an error result for each unanswered assistant tool call. A recorded `tool/call` without a committed result receives `TOOL_OUTCOME_UNKNOWN`; a request without a call record receives `TOOL_NOT_STARTED`. Committed results remain intact, started dispatches settle before recovery, and the turn retains the original failure. These results let later requests use paired tool history without automatically retrying uncertain operations ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.md)).
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
@@ -189,6 +191,20 @@ One fixed error result per skipped call remains in history until compaction shad
|
||||
|
||||
Append-only; each synthetic result follows the reusable request prefix and does not invalidate existing KV Cache entries.
|
||||
|
||||
### Unanswered calls after step failure
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Each unanswered tool call receives an error result in later history. For a recorded call, the result states `Its outcome is unknown.` and permits retries only for read-only or idempotent operations; possible side effects require checking external state or asking the user first. A call without a start record states `The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.`
|
||||
|
||||
#### Token effect
|
||||
|
||||
One recovery result per unanswered call remains in history until compaction shadows it.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Recovery results append after the existing history and preserve its reusable prefix.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
@@ -197,6 +213,7 @@ Append-only; each synthetic result follows the reusable request prefix and does
|
||||
These limits define when the loop needs special care. They are current package constraints, not a task backlog.
|
||||
|
||||
- **Classification is unary** — calls whose safety depends on comparing siblings or resources must remain exclusive ([rationale](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)).
|
||||
- **Previously closed inconsistent history** — failed-step recovery does not rewrite unanswered calls in already-closed historical turns.
|
||||
- **Config labels are fresh by default** — omitting `sessionId` creates a fresh `${id}-session-<uuid>` on every startup; exact resume-or-create behavior requires an explicit stable `sessionId`, while `resumeSessionId` requires existing persisted history.
|
||||
- **Config agents have no per-agent persona field or setup hook** — they use the deployment persona; scoped persona and tool composition are available only through the programmatic `ctx.agents.create()` / `resume()` factory options.
|
||||
- **No built-in turn budget** — tool calls or steering continue the current turn; a policy that bounds runaway turns must cancel from an existing lifecycle extension point such as `agent/turn-stopping`.
|
||||
|
||||
@@ -126,6 +126,8 @@ const handle = await ctx.agents.create({
|
||||
|
||||
最终适配器选择、分发与迭代失败以终止结束的形式到达并进入 `agent/request-error`;处理该失败的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`,未被处理的失败则是终态。Middleware、结果处理、工具及其他扩展失败仍会抛出并直接关闭轮次——插件失败结束的是轮次,不是循环。取消后未分发的模型工具调用会收到合成的 `tool/call` 加 `ABORTED_BEFORE_DISPATCH` 结果对。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md)拥有信号生命周期。
|
||||
|
||||
关闭失败步骤之前,驱动器为每个尚无结果的 assistant 工具调用记录错误结果。已有 `tool/call` 记录但尚无已提交结果的调用获得 `TOOL_OUTCOME_UNKNOWN`;没有调用记录的请求获得 `TOOL_NOT_STARTED`。已提交的结果保持完整,已启动的派发先结算再恢复,轮次保留原始失败。这些结果让后续请求使用配对完整的工具历史,而不自动重试结果不明的操作([决策](../../../.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.zh.md))。
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
@@ -189,6 +191,20 @@ const handle = await ctx.agents.create({
|
||||
|
||||
仅追加;每个合成结果都位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
|
||||
|
||||
### 步骤失败后尚无结果的调用
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
每个尚无结果的工具调用都会在后续历史中获得错误结果。对于已有记录的调用,结果说明 `Its outcome is unknown.`,且仅允许重试只读或幂等操作;可能存在副作用时,必须先核验外部状态或询问用户。没有启动记录的调用说明 `The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.`。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每个尚无结果的调用都会在历史中保留一个恢复结果,直到压缩将其遮蔽。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
恢复结果追加在既有历史之后,保留其可复用前缀。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
@@ -197,6 +213,7 @@ const handle = await ctx.agents.create({
|
||||
这些限制说明循环何时需要特别留意。它们是当前包约束,不是任务积压。
|
||||
|
||||
- **分类是一元的**:安全性取决于比较同级调用或资源的调用必须保持独占([原理](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md))。
|
||||
- **此前已关闭的不一致历史**:失败步骤恢复不会改写已关闭历史轮次中尚无结果的调用。
|
||||
- **配置标签默认对应新会话**:省略 `sessionId` 时,每次启动都会创建新的 `${id}-session-<uuid>`;如需确切的恢复或创建行为,必须显式提供稳定的 `sessionId`,而 `resumeSessionId` 要求已有持久化历史。
|
||||
- **配置 agent 没有逐 agent persona 字段或 setup 钩子**:它们使用部署 persona;只有编程式 `ctx.agents.create()` / `resume()` 工厂选项支持带作用域的 persona 与工具组合。
|
||||
- **没有内置轮次预算**:工具调用或 steering 会让当前轮次继续;限制失控轮次的策略必须从既有生命周期扩展点(如 `agent/turn-stopping`)执行取消。
|
||||
|
||||
@@ -28,7 +28,7 @@ import { assertNever, deepFreeze } from '@deepseek-ai/dsh-util-values'
|
||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import type { EpochHeader, RequestContext, Session, SessionId, SessionSeq, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
|
||||
import { canonicalHeader, headerEquals, ToolCallRecovery } from '@deepseek-ai/dsh-session'
|
||||
import { joinContextSections, renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
@@ -328,6 +328,10 @@ export class ReactLoopAgent implements Agent {
|
||||
signal.throwIfAborted()
|
||||
this.session.append('step/start', { turn, step })
|
||||
phase.step = step
|
||||
const toolRecovery = new ToolCallRecovery()
|
||||
const stopRecovery = this.ctx.on('session/event', (session, event) => {
|
||||
if (session === this.session) toolRecovery.observe(event)
|
||||
})
|
||||
try {
|
||||
// max-tokens is sticky: once any step hits the ceiling, later steps
|
||||
// that complete normally must not downgrade the turn outcome.
|
||||
@@ -335,7 +339,20 @@ export class ReactLoopAgent implements Agent {
|
||||
// max-tokens stays sticky: a later completed step must not
|
||||
// downgrade the turn outcome.
|
||||
if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd
|
||||
} catch (error: unknown) {
|
||||
try {
|
||||
for (const event of toolRecovery.results()) {
|
||||
this.session.append('tool/result', event.data, {
|
||||
surfaceOp: 'append',
|
||||
...event.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: event.sourceEventSeqs },
|
||||
})
|
||||
}
|
||||
} catch (recoveryError: unknown) {
|
||||
throw new AggregateError([error, recoveryError], 'Step failed and its pending tool results could not be recorded', { cause: error })
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
stopRecovery()
|
||||
this.session.append('step/end', { turn, step })
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* and drains started calls.
|
||||
*
|
||||
* Abort records synthetic error results for skipped calls so replay stays
|
||||
* valid. A terminal scheduler failure preserves already-recorded `tool/call`
|
||||
* events without fabricating results.
|
||||
* valid. A terminal scheduler failure rejects after draining; the owning step
|
||||
* records conservative recovery results before closing.
|
||||
* @module dsh-agent-loop/tool-calls
|
||||
*/
|
||||
|
||||
@@ -45,8 +45,8 @@ interface GroupOutcome {
|
||||
* the signal still aborted after accepting started-call context through the
|
||||
* caller-supplied acceptor (the machine stages it in its next-step inbox for the
|
||||
* step boundary). An internal scheduler failure stops new dispatches, drains
|
||||
* already-started dispatches, and rejects with the first failure without
|
||||
* fabricating tool results.
|
||||
* already-started dispatches, and rejects with the first failure. The owning
|
||||
* step supplies error results for requests without a committed outcome.
|
||||
* The committed step's AgentLoop driver boundary supplies the initiating Agent
|
||||
* that becomes each explicit {@link ToolExecutionInput.agent}.
|
||||
*
|
||||
@@ -116,8 +116,8 @@ function parseArguments(raw: string): unknown {
|
||||
* drain and remains for the caller's next barrier. Results and contexts commit
|
||||
* in model order. Abort stops starts, drains and commits started calls, accepts
|
||||
* their contexts into the owning batch, records results for skipped calls, and
|
||||
* returns an aborted outcome. Scheduler failure drains dispatches without
|
||||
* committing synthetic recovery results.
|
||||
* returns an aborted outcome. Scheduler failure drains dispatches and rejects
|
||||
* for the owning step to record recovery results.
|
||||
*/
|
||||
async function runGroup(
|
||||
ctx: Context,
|
||||
|
||||
@@ -6,10 +6,10 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import LlmRuntime from '@deepseek-ai/dsh-llm'
|
||||
import type { ContextFormed } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionLogOffset, SessionSeq, Session, SessionId, TOOL_OUTCOME_UNKNOWN } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SessionLogOffset, SessionSeq, Session, SessionId, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRuntime from '@deepseek-ai/dsh-tools'
|
||||
import ToolRuntime, { defineContentToolFixture, TOOL_RUNTIME_SCHEDULER } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { SessionHandle } from '@deepseek-ai/dsh-session-persistence'
|
||||
|
||||
@@ -976,6 +976,71 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
await ctx1.fiber.dispose()
|
||||
})
|
||||
|
||||
it.each(['resume', 'fork'] as const)('%s continues persisted history after a live scheduler failure', async (mode) => {
|
||||
const firstAdapter = new MockAdapter([[
|
||||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
||||
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: ToolCallId('started'), name: 'probe', arguments: '{}' } },
|
||||
{ type: 'block-start', index: 1, blockType: 'tool-call' },
|
||||
{ type: 'block-end', index: 1, block: { type: 'tool-call', id: ToolCallId('pending'), name: 'probe', arguments: '{}' } },
|
||||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||||
]])
|
||||
const { ctx: first, root } = await persistentHarness(firstAdapter)
|
||||
let next: Context | undefined
|
||||
try {
|
||||
first.tools.register(defineContentToolFixture({
|
||||
name: 'probe', description: 'probe', parameters: {},
|
||||
async execute() { return [{ type: 'text', text: 'unexpected execution' }] },
|
||||
}))
|
||||
first.tools[TOOL_RUNTIME_SCHEDULER].prepare = () => { throw new Error('scheduler prepare failed') }
|
||||
const sessionId = SessionId('failed-tool-history')
|
||||
const initial = await first.agents.create({ sessionId, agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
const failed = waitForIdle(first, initial.agent)
|
||||
initial.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'try tools' }], source: { kind: 'user' } }))
|
||||
await failed
|
||||
await initial.dispose()
|
||||
await first.fiber.dispose()
|
||||
|
||||
const nextAdapter = new MockAdapter([textResponse('continued after failure')])
|
||||
next = await mountPersistentHarness(root, nextAdapter)
|
||||
const stored = await readStoredEvents(next, sessionId)
|
||||
expect(stored.filter(event => event.type === 'tool/result').map(event => [
|
||||
event.data.message.toolCallId, event.data.error?.code,
|
||||
])).toEqual([[ToolCallId('started'), TOOL_OUTCOME_UNKNOWN], [ToolCallId('pending'), TOOL_NOT_STARTED]])
|
||||
expect(stored.at(-1)).toMatchObject({
|
||||
type: 'turn/end', data: { reason: { kind: 'error', error: { code: 'UNKNOWN', message: 'scheduler prepare failed' } } },
|
||||
})
|
||||
|
||||
const continued = mode === 'resume'
|
||||
? await next.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
: await next.agents.create({
|
||||
sessionId: SessionId('failed-tool-history-fork'), seed: stored,
|
||||
meta: { parentSession: sessionId, isSeeded: true },
|
||||
inheritedEventCount: SessionLogOffset(stored.length),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const done = waitForIdle(next, continued.agent)
|
||||
continued.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }))
|
||||
await done
|
||||
|
||||
expect(nextAdapter.requests[0]?.messages.flatMap((message): string[] => {
|
||||
if (message.role === 'tool') return [`result:${message.toolCallId}`]
|
||||
return message.content.flatMap((block): string[] => {
|
||||
if (block.type === 'tool-call') return [`call:${block.id}`]
|
||||
if (message.role === 'user' && block.type === 'text') return [`user:${block.text}`]
|
||||
return []
|
||||
})
|
||||
})).toEqual(['user:try tools', 'call:started', 'call:pending', 'result:started', 'result:pending', 'user:continue'])
|
||||
await continued.dispose()
|
||||
const reopened = await readStoredEvents(next, continued.agent.session.id)
|
||||
expect(reopened.slice(0, stored.length)).toEqual(stored)
|
||||
expect(reopened.filter(event => event.type === 'tool/result')).toHaveLength(2)
|
||||
expect(reopened.at(-1)).toMatchObject({ type: 'turn/end', data: { turn: 2, reason: { kind: 'completed' } } })
|
||||
} finally {
|
||||
await next?.fiber.dispose()
|
||||
await first.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('resume reloads a persisted session: history + turn numbering continue, no duplicate seqs', async () => {
|
||||
// Lifecycle 1: run one full turn, persisting it.
|
||||
const adapter1 = new MockAdapter([textResponse('first answer')])
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
* ACP expected outputs own transcript-facing coverage.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, onTestFinished } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { createUserMessage, ToolCallId, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SessionEvent, SessionId, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import LlmRuntime from '@deepseek-ai/dsh-llm'
|
||||
import type { ContextFormed } from '@deepseek-ai/dsh-llm'
|
||||
@@ -631,6 +631,180 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
})
|
||||
|
||||
describe('tool-call scheduler: failure quiescence', () => {
|
||||
it.each(['execution-mode', 'prepare', 'finalize'] as const)('pairs outstanding requests after %s fails and preserves committed results', async (phase) => {
|
||||
const adapter = new MockAdapter([
|
||||
multiCall([
|
||||
{ id: 'c1', name: 'p', args: { id: '1' } },
|
||||
{ id: 'c2', name: 'p', args: { id: '2' } },
|
||||
{ id: 'c3', name: 'p', args: { id: '3' } },
|
||||
]),
|
||||
textResponse('continued'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
const executed: string[] = []
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'p', description: 'exclusive', parameters: { id: { type: 'string', required: true } },
|
||||
async execute(args) {
|
||||
executed.push(args.id)
|
||||
return [{ type: 'text', text: `done-${args.id}` }]
|
||||
},
|
||||
}))
|
||||
const scheduler = ctx.tools[TOOL_RUNTIME_SCHEDULER]
|
||||
const executionMode = ctx.tools.executionMode.bind(ctx.tools)
|
||||
const prepare = scheduler.prepare.bind(scheduler)
|
||||
const finalize = scheduler.finalize.bind(scheduler)
|
||||
const failure = new Error(`${phase} failed`)
|
||||
if (phase === 'execution-mode') {
|
||||
ctx.tools.executionMode = (exec) => {
|
||||
if (exec.callId === ToolCallId('c2')) throw failure
|
||||
return executionMode(exec)
|
||||
}
|
||||
} else if (phase === 'prepare') {
|
||||
scheduler.prepare = (exec) => {
|
||||
if (exec.callId === ToolCallId('c2')) throw failure
|
||||
return prepare(exec)
|
||||
}
|
||||
} else {
|
||||
scheduler.finalize = (exec, result) => {
|
||||
if (exec.callId === ToolCallId('c2')) throw failure
|
||||
return finalize(exec, result)
|
||||
}
|
||||
}
|
||||
const agent = await ctx.agentLoop.create(SessionId(`failure-${phase}`), { provider: 'mock', model: 'mock' })
|
||||
const listenerCount = () => ctx.events.dispatch('emit', ['session/event', agent.session, events(agent).at(-1)]).length
|
||||
const idleListenerCount = listenerCount()
|
||||
const failedTurn = waitForIdle(ctx, agent)
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await failedTurn
|
||||
|
||||
const failedEvents = events(agent)
|
||||
const calls = failedEvents.filter(event => event.type === 'tool/call')
|
||||
const results = failedEvents.filter(event => event.type === 'tool/result')
|
||||
expect(executed).toEqual(phase === 'finalize' ? ['1', '2'] : ['1'])
|
||||
expect(results.map(event => [event.data.message.toolCallId, event.data.error?.code])).toEqual([
|
||||
[ToolCallId('c1'), undefined],
|
||||
[ToolCallId('c2'), phase === 'execution-mode' ? TOOL_NOT_STARTED : TOOL_OUTCOME_UNKNOWN],
|
||||
[ToolCallId('c3'), TOOL_NOT_STARTED],
|
||||
])
|
||||
expect(results[0]?.data.message).toMatchObject({
|
||||
role: 'tool', toolCallId: ToolCallId('c1'), isError: false, content: [{ type: 'text', text: 'done-1' }],
|
||||
})
|
||||
expect(results.slice(1).map(event => event.data.message.isError)).toEqual([true, true])
|
||||
expect(results.map(event => event.sourceEventSeqs)).toEqual([
|
||||
[calls[0]?.seq],
|
||||
phase === 'execution-mode' ? undefined : [calls[1]?.seq],
|
||||
undefined,
|
||||
])
|
||||
expect(failedEvents.slice(-3).map(event => event.type)).toEqual(['tool/result', 'step/end', 'turn/end'])
|
||||
expect(failedEvents.at(-1)).toMatchObject({
|
||||
data: { reason: { kind: 'error', error: { message: failure.message, code: 'UNKNOWN' } } },
|
||||
})
|
||||
expect(listenerCount()).toBe(idleListenerCount)
|
||||
|
||||
ctx.tools.executionMode = executionMode
|
||||
scheduler.prepare = prepare
|
||||
scheduler.finalize = finalize
|
||||
const nextTurn = waitForIdle(ctx, agent)
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }))
|
||||
await nextTurn
|
||||
|
||||
expect(adapter.requests[1]?.messages.flatMap((message): string[] => {
|
||||
if (message.role === 'tool') return [`result:${message.toolCallId}`]
|
||||
return message.content.flatMap((block): string[] => {
|
||||
if (block.type === 'tool-call') return [`call:${block.id}`]
|
||||
if (message.role === 'user' && block.type === 'text') return [`user:${block.text}`]
|
||||
return []
|
||||
})
|
||||
})).toEqual(['user:go', 'call:c1', 'call:c2', 'call:c3', 'result:c1', 'result:c2', 'result:c3', 'user:continue'])
|
||||
expect(events(agent).filter(event => event.type === 'tool/result')).toHaveLength(3)
|
||||
expect(events(agent).findLast(event => event.type === 'turn/end')?.data.reason.kind).toBe('completed')
|
||||
expect(listenerCount()).toBe(idleListenerCount)
|
||||
})
|
||||
|
||||
it('preserves the scheduler error when recording recovery results also fails', async () => {
|
||||
const adapter = new MockAdapter([multiCall([{ id: 'c1', name: 'p', args: {} }])])
|
||||
const ctx = await harness(adapter)
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
const failure = new Error('prepare failed')
|
||||
const recoveryFailure = new Error('recovery result rejected')
|
||||
ctx.tools[TOOL_RUNTIME_SCHEDULER].prepare = () => { throw failure }
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name === 'session/event' && (args[1] as SessionEvent).type === 'tool/result') throw recoveryFailure
|
||||
})
|
||||
const errors: unknown[] = []
|
||||
ctx.on('agent/error', ({ error }) => { errors.push(error) })
|
||||
const agent = await ctx.agentLoop.create(SessionId('recovery-result-failure'), { provider: 'mock', model: 'mock' })
|
||||
const done = waitForIdle(ctx, agent)
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await done
|
||||
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]).toBeInstanceOf(AggregateError)
|
||||
expect(errors[0]).toMatchObject({ cause: failure, errors: [failure, recoveryFailure] })
|
||||
expect(events(agent).filter(event => event.type === 'tool/result')).toEqual([])
|
||||
expect(events(agent).at(-1)).toMatchObject({ data: { reason: { kind: 'error', error: { code: 'UNKNOWN' } } } })
|
||||
})
|
||||
|
||||
it('isolates pending requests from child agents and other sessions in the same scope', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
multiCall([{ id: 'shared', name: 'p', args: {} }, { id: 'pending', name: 'p', args: {} }]),
|
||||
multiCall([{ id: 'shared', name: 'p', args: {} }]),
|
||||
textResponse('second agent done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const enteredDispatch = Promise.withResolvers<undefined>()
|
||||
const releaseDispatch = Promise.withResolvers<undefined>()
|
||||
onTestFinished(async () => {
|
||||
releaseDispatch.resolve(undefined)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'p', description: 'probe', parameters: {},
|
||||
async execute() { return [{ type: 'text', text: 'completed' }] },
|
||||
}))
|
||||
const scheduler = ctx.tools[TOOL_RUNTIME_SCHEDULER]
|
||||
const dispatch = scheduler.dispatch.bind(scheduler)
|
||||
scheduler.dispatch = async (exec) => {
|
||||
if (exec.agent?.id === SessionId('first-agent')) {
|
||||
enteredDispatch.resolve(undefined)
|
||||
await releaseDispatch.promise
|
||||
throw new Error('first agent dispatch failed')
|
||||
}
|
||||
return dispatch(exec)
|
||||
}
|
||||
const first = await ctx.agentLoop.create(SessionId('first-agent'), { provider: 'mock', model: 'mock' })
|
||||
const { agent: second } = await first.ctx.agents.create({
|
||||
sessionId: SessionId('second-agent'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
parentAgent: first,
|
||||
})
|
||||
const firstDone = waitForIdle(ctx, first)
|
||||
first.followup(createUserMessage({ content: [{ type: 'text', text: 'first' }], source: { kind: 'user' } }))
|
||||
await enteredDispatch.promise
|
||||
const secondDone = waitForIdle(ctx, second)
|
||||
second.followup(createUserMessage({ content: [{ type: 'text', text: 'second' }], source: { kind: 'user' } }))
|
||||
await secondDone
|
||||
const side = first.ctx.sessions.create(SessionId('same-scope-session'))
|
||||
side.append('turn/start', { turn: 1 })
|
||||
side.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'side session' }], source: { kind: 'user' } }), { surfaceOp: 'append' })
|
||||
side.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
releaseDispatch.resolve(undefined)
|
||||
await firstDone
|
||||
|
||||
const firstResults = events(first).filter(event => event.type === 'tool/result')
|
||||
expect(firstResults.map(event => [
|
||||
event.data.message.toolCallId, event.data.error?.code,
|
||||
])).toEqual([[ToolCallId('shared'), TOOL_OUTCOME_UNKNOWN], [ToolCallId('pending'), TOOL_NOT_STARTED]])
|
||||
expect(firstResults.map(event => event.sourceEventSeqs)).toEqual([
|
||||
[events(first).find(event => event.type === 'tool/call')?.seq], undefined,
|
||||
])
|
||||
const secondResults = events(second).filter(event => event.type === 'tool/result')
|
||||
expect(secondResults).toHaveLength(1)
|
||||
expect(secondResults[0]?.data.message).toMatchObject({ role: 'tool', toolCallId: ToolCallId('shared'), isError: false, content: [{ type: 'text', text: 'completed' }] })
|
||||
expect(events(second).findLast(event => event.type === 'turn/end')?.data.reason.kind).toBe('completed')
|
||||
})
|
||||
|
||||
it('stops new dispatches and drains started bodies before surfacing the first failure', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
multiCall([
|
||||
@@ -660,6 +834,12 @@ describe('tool-call scheduler: failure quiescence', () => {
|
||||
const schedulerError = new Error('scheduler exploded')
|
||||
const drainedError = new Error('sibling failed while draining')
|
||||
let rejectFirst: ((error: Error) => void) | undefined
|
||||
onTestFinished(async () => {
|
||||
prepareGate.resolve(undefined)
|
||||
rejectFirst?.(schedulerError)
|
||||
for (const id of gated.pending()) gated.release(id)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
scheduler.dispatch = exec => exec.callId === ToolCallId('c1')
|
||||
? new Promise((_resolve, reject) => { rejectFirst = reject })
|
||||
: dispatch(exec).then(() => { throw drainedError })
|
||||
@@ -677,13 +857,22 @@ describe('tool-call scheduler: failure quiescence', () => {
|
||||
const startedBeforeDrain = [...gated.started]
|
||||
const idleBeforeDrain = idle
|
||||
const turnEndBeforeDrain = events(agent).find(event => event.type === 'turn/end')
|
||||
const resultsBeforeDrain = events(agent).filter(event => event.type === 'tool/result')
|
||||
for (const id of gated.pending()) gated.release(id)
|
||||
await idlePromise
|
||||
|
||||
expect(startedBeforeDrain).toEqual(['2'])
|
||||
expect(idleBeforeDrain).toBe(false)
|
||||
expect(turnEndBeforeDrain).toBeUndefined()
|
||||
expect(resultsBeforeDrain).toEqual([])
|
||||
expect(gated.pending()).toEqual([])
|
||||
expect(events(agent).filter(event => event.type === 'tool/result').map(event => [
|
||||
event.data.message.toolCallId, event.data.error?.code,
|
||||
])).toEqual([
|
||||
[ToolCallId('c1'), TOOL_OUTCOME_UNKNOWN],
|
||||
[ToolCallId('c2'), TOOL_OUTCOME_UNKNOWN],
|
||||
[ToolCallId('c3'), TOOL_OUTCOME_UNKNOWN],
|
||||
])
|
||||
expect(events(agent).findLast(event => event.type === 'turn/end')).toMatchObject({
|
||||
data: { reason: { kind: 'error', error: { message: schedulerError.message, code: 'UNKNOWN' } } },
|
||||
})
|
||||
|
||||
@@ -42,11 +42,14 @@
|
||||
en: bbfb53c50100ab13
|
||||
zh: 303a8ee47f4f656a
|
||||
/deepseek-ai-dsh-session/understand-the-implementation/source-map:
|
||||
en: e87f7ef34effbad2
|
||||
zh: 774a98d23611f5f5
|
||||
en: 1268738282846f1d
|
||||
zh: 75a9fe0d5088ea09
|
||||
/deepseek-ai-dsh-session/understand-the-implementation/append-validation:
|
||||
en: 5c09583bc8e1ffc6
|
||||
zh: 3b25552de2d1f9b6
|
||||
/deepseek-ai-dsh-session/understand-the-implementation/shared-recovery:
|
||||
en: 76bb076736b2dd0f
|
||||
zh: de8306afc1e5faa1
|
||||
/deepseek-ai-dsh-session/understand-the-implementation/derived-history:
|
||||
en: 0cc7a06f76bd7eca
|
||||
zh: 1db25185b15cad23
|
||||
@@ -71,16 +74,16 @@
|
||||
/deepseek-ai-dsh-session/model-experience/derived-message-history/kv-cache-effect:
|
||||
en: 6c8079c7de029ed4
|
||||
zh: 634d6f93ce6373c4
|
||||
/deepseek-ai-dsh-session/model-experience/crash-repair-and-fork-results:
|
||||
en: 4aa585f38846e017
|
||||
zh: ef28f54125a9021d
|
||||
/deepseek-ai-dsh-session/model-experience/crash-repair-and-fork-results/what-the-model-sees:
|
||||
/deepseek-ai-dsh-session/model-experience/tool-result-recovery-and-fork-results:
|
||||
en: 9c2efc3e9064e3c4
|
||||
zh: 9a7783991b322750
|
||||
/deepseek-ai-dsh-session/model-experience/tool-result-recovery-and-fork-results/what-the-model-sees:
|
||||
en: b2ae41b23519cdc6
|
||||
zh: 05ce1c9a8b8c3283
|
||||
/deepseek-ai-dsh-session/model-experience/crash-repair-and-fork-results/token-effect:
|
||||
en: ac63e0f30175f72d
|
||||
zh: 20f63deeffa249f2
|
||||
/deepseek-ai-dsh-session/model-experience/crash-repair-and-fork-results/kv-cache-effect:
|
||||
zh: 0e80c34e3267d098
|
||||
/deepseek-ai-dsh-session/model-experience/tool-result-recovery-and-fork-results/token-effect:
|
||||
en: 1339facbd3d7cee0
|
||||
zh: 9d392a053ef90032
|
||||
/deepseek-ai-dsh-session/model-experience/tool-result-recovery-and-fork-results/kv-cache-effect:
|
||||
en: f5d5f97a153f7c70
|
||||
zh: b84ef7f75f2e25d0
|
||||
/deepseek-ai-dsh-session/model-experience/logged-request-header:
|
||||
|
||||
@@ -100,13 +100,17 @@ The package is built on event sourcing: a `Session` is an append-only log of typ
|
||||
| [`src/surface.ts`](src/surface.ts) | Ordered surface projection, replacement validation, `deriveEventMessage` |
|
||||
| [`src/request-header.ts`](src/request-header.ts) | `request/header` folding and reconstruction |
|
||||
| [`dsh-util-values`](../../util/values/README.md) | Shared lossless JSON validation and detached snapshots |
|
||||
| [`src/repair.ts`](src/repair.ts) | Cold repair of crash-orphaned logs |
|
||||
| [`src/repair.ts`](src/repair.ts) | Shared tool-result recovery for failed steps, interrupted logs, and fork seeds |
|
||||
| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: seq, turn/step enclosure, tool call/result pairing |
|
||||
|
||||
### Append validation
|
||||
|
||||
Every append uses the shared iterative `snapshotJsonValue()` pass, which reads, validates, and copies each nested value once, so a stateful getter cannot supply one value to validation and another to storage. Non-lossless-JSON payloads (BigInt, cycles, sparse arrays, `-0`, exotic prototypes) are rejected at the append site, before any backend flush. The append path constructs each `SessionSeq`; surface events additionally validate marker shape, cited source-event sequences, and complete shadowed-node coverage for replacements.
|
||||
|
||||
### Shared recovery
|
||||
|
||||
`ToolCallRecovery` tracks unanswered requests from committed events without retaining event history. AgentLoop observes live steps; crash recovery and fork-seed construction replay their prefixes through `openTurnClosers`. Live failures and crash recovery use interrupted-result wording by default; fork construction passes the fork cause to select its distinct retry guidance. The caller appends recovery results before closing the step ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.md)).
|
||||
|
||||
### Derived history
|
||||
|
||||
`deriveMessages()` caches deep-frozen projections and returns a fresh array per call. The surface event types (`system/message`, `developer/message`, `user/message`, `assistant/message`, `tool/result`) supply their recorded message identities and content; empty-content system and developer nodes project to no message. Plugin-owned projections change derived content without mutating recorded messages. Replacements and projection decisions invalidate the cache. Embedded Assistant streams and `assistant/attempt` events remain replay and diagnostic data only.
|
||||
@@ -149,7 +153,7 @@ Appended surface entries are resent on later steps. A `replace` surface operatio
|
||||
|
||||
Appended surface entries preserve reusable prefixes. A `replace` operation invalidates reuse from the first shadowed message even though the underlying event log stays append-only.
|
||||
|
||||
### Crash-repair and fork results
|
||||
### Tool-result recovery and fork results
|
||||
|
||||
#### What the model sees
|
||||
|
||||
@@ -157,7 +161,7 @@ If recovery finds an assistant tool request with no durable `tool/call`, its syn
|
||||
|
||||
#### Token effect
|
||||
|
||||
Zero tokens in an intact session. Each repaired call adds its retained risk-specific error text on resume.
|
||||
Zero tokens in an intact session. Each repaired call adds its retained risk-specific error text to later requests.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
|
||||
@@ -100,13 +100,17 @@ session.deriveMessages() // the derived model history
|
||||
| [`src/surface.ts`](src/surface.ts) | 有序 surface 投影、替换校验、`deriveEventMessage` |
|
||||
| [`src/request-header.ts`](src/request-header.ts) | `request/header` 折叠与重建 |
|
||||
| [`dsh-util-values`](../../util/values/README.zh.md) | 共享无损 JSON 校验与分离式快照 |
|
||||
| [`src/repair.ts`](src/repair.ts) | 崩溃遗留日志的冷修复 |
|
||||
| [`src/repair.ts`](src/repair.ts) | 失败步骤、中断日志与 fork 种子共享的工具结果恢复 |
|
||||
| [`src/invariant.ts`](src/invariant.ts) | 不变式配套:序号、轮次/步骤闭合、工具调用/结果配对 |
|
||||
|
||||
### 追加校验
|
||||
|
||||
每次追加都会使用共享的迭代式 `snapshotJsonValue()` 流程,对每个嵌套值只读取、校验并复制一次,因此有状态的 getter 无法给校验提供一个值、给存储提供另一个值。非无损 JSON 载荷(BigInt、循环、稀疏数组、`-0`、特殊原型)会在追加位置被拒绝,先于任何后端刷新。追加路径会构造每个 `SessionSeq`;surface 事件还会校验标记形态、被引用的源事件序号,以及替换的完整遮蔽节点覆盖。
|
||||
|
||||
### 共享恢复逻辑
|
||||
|
||||
`ToolCallRecovery` 从已提交事件中跟踪尚无结果的请求,不保留事件历史。AgentLoop 观察实时步骤;崩溃恢复与 fork 种子构造通过 `openTurnClosers` 回放各自的前缀。实时失败与崩溃恢复默认使用中断结果文案;fork 构造传入 fork 原因,以选择其专用的重试指引。调用方在关闭步骤之前追加恢复结果([决策](../../../.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.zh.md))。
|
||||
|
||||
### 派生历史
|
||||
|
||||
`deriveMessages()` 缓存深度冻结的派生消息,每次调用返回新数组。surface 事件类型(`system/message`、`developer/message`、`user/message`、`assistant/message`、`tool/result`)提供记录的消息身份和内容,空内容的 system 和 developer 节点不派生消息。插件拥有的投影修改派生内容,不修改记录的消息。替换和投影决策使缓存失效。嵌入式 Assistant stream 与 `assistant/attempt` 事件只保留回放和诊断数据。
|
||||
@@ -149,17 +153,15 @@ session.deriveMessages() // the derived model history
|
||||
|
||||
追加的 surface 条目会保留可复用前缀。即使底层事件日志保持仅追加,`replace` 操作也会从首条被遮蔽消息起使缓存复用失效。
|
||||
|
||||
### 崩溃修复结果
|
||||
### 工具结果恢复与 fork 结果
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
如果恢复发现 assistant 工具请求没有持久 `tool/call`,其合成 `TOOL_NOT_STARTED` 结果内容为 `The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.`。如果持久 `tool/call` 没有结果,其 `TOOL_OUTCOME_UNKNOWN` 结果内容为 `The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.`。
|
||||
|
||||
Fork 生成的结果只描述继承记录:父会话可能已经在所选事件之后启动或完成调用。`TOOL_NOT_STARTED` 表示前缀中没有启动记录;`TOOL_OUTCOME_UNKNOWN` 表示有启动记录但没有结果。两者都要求模型仅对只读或幂等操作直接重试;有副作用的操作需要先验证外部状态或询问用户。参见 [fork 决策](../../../.agents/notes/implemented/feature/2026-08-18-arbitrary-seq-session-fork.zh.md)。
|
||||
如果恢复发现 assistant 工具请求没有持久 `tool/call`,其合成 `TOOL_NOT_STARTED` 结果内容为 `The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.`。如果持久 `tool/call` 没有结果,其 `TOOL_OUTCOME_UNKNOWN` 结果内容为 `The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.`。 Fork 生成的结果只描述继承记录:父会话可能已经在所选事件之后启动或完成调用。`TOOL_NOT_STARTED` 表示前缀中没有启动记录;`TOOL_OUTCOME_UNKNOWN` 表示有启动记录但没有结果。两者都要求模型仅对只读或幂等操作直接重试;有副作用的操作需要先验证外部状态或询问用户。参见 [fork 决策](../../../.agents/notes/implemented/feature/2026-08-18-arbitrary-seq-session-fork.zh.md)。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
未受损会话的 token 增量为零。恢复时,每个修复后的调用都会添加保留的、针对具体风险的错误文本。
|
||||
未受损会话的 token 增量为零。每个修复后的调用都会向后续请求添加保留的、针对具体风险的错误文本。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export * from './types.ts'
|
||||
export { SessionPreparation } from './preparation.ts'
|
||||
export type { SessionPreparationOptions } from './preparation.ts'
|
||||
export type { AssistantMessage, DeveloperMessage, SystemMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm'
|
||||
export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
|
||||
export { interruptedTurnClosers, ToolCallRecovery, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
|
||||
export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult, SessionMessageProjection, SessionMessageProjectionContext } from './surface.ts'
|
||||
export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
|
||||
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
/**
|
||||
* Synthetic closer events that balance a session log whose tail turn is open.
|
||||
* Two producers share the mechanism: crash recovery closes an interrupted
|
||||
* persisted log on reload, and fork-seed construction closes a prefix cut
|
||||
* inside the source's open turn. Both preserve every fully written event and
|
||||
* close the unfinished step and turn. Calls in already closed steps remain
|
||||
* unchanged, including any missing results.
|
||||
* Pending tool-result recovery shared by failed live steps, interrupted logs,
|
||||
* and fork seeds. Tail repair preserves closed steps and supplies only missing
|
||||
* tool results and lifecycle boundaries, with cause-specific retry guidance.
|
||||
* @module @deepseek-ai/dsh-session/repair
|
||||
*/
|
||||
|
||||
@@ -45,65 +42,35 @@ const CLOSER_TEXT = {
|
||||
|
||||
/**
|
||||
* Return deterministic synthetic events that close an open tail turn. Unmatched
|
||||
* calls in its open step receive error results, followed by `step/end` and a
|
||||
* `turn/end` carrying the cause's reason. Calls in closed steps remain unchanged.
|
||||
* Sequences continue the log and timestamps reuse the last real event. A balanced or empty log returns no
|
||||
* events.
|
||||
* calls receive error results first, followed by an open `step/end` and a
|
||||
* cause-specific `turn/end`; sequences continue the log and timestamps reuse the
|
||||
* last real event. A balanced or empty log returns no events.
|
||||
*
|
||||
* Package-internal: each cause has exactly one owner, so external callers go
|
||||
* through {@link interruptedTurnClosers} (persistence crash recovery) or
|
||||
* `buildForkSeed` in `./fork.ts` (fork seeds) instead of selecting a cause.
|
||||
*
|
||||
* @param events - the log to scan: a valid committed prefix, possibly ending
|
||||
* inside an open turn (a crash tail or a mid-turn fork cut).
|
||||
* @param cause - why the turn is being closed; selects the `turn/end` reason
|
||||
* and the model-visible wording of synthetic error tool results.
|
||||
* @param events - the loaded durable log to scan (a valid committed prefix, possibly with a crash tail).
|
||||
* @param cause - the owning close operation: selects result wording and the turn-ending reason.
|
||||
* @returns the synthetic closer events to append after `events`, in order; empty when the log is already balanced.
|
||||
*/
|
||||
export function openTurnClosers(events: readonly SessionEvent[], cause: OpenTurnCloseCause): SessionEvent[] {
|
||||
let openTurn: number | null = null
|
||||
let openStep: number | null = null
|
||||
// Reset at each turn boundary so earlier calls cannot leak into tail repair.
|
||||
// Assistant blocks register calls; later `tool/call` events add their seqs to `sourceEventSeqs`.
|
||||
const pendingCalls = new Map<ToolCallId, { step: number; callSeq?: SessionSeqType }>()
|
||||
const recovery = new ToolCallRecovery(cause)
|
||||
for (const event of events) {
|
||||
recovery.observe(event)
|
||||
switch (event.type) {
|
||||
case 'turn/start':
|
||||
openTurn = event.data.turn
|
||||
openStep = null
|
||||
pendingCalls.clear()
|
||||
break
|
||||
case 'turn/end':
|
||||
openTurn = null
|
||||
openStep = null
|
||||
pendingCalls.clear()
|
||||
break
|
||||
case 'step/start':
|
||||
openStep = event.data.step
|
||||
break
|
||||
case 'step/end':
|
||||
pendingCalls.clear()
|
||||
openStep = null
|
||||
break
|
||||
case 'assistant/message':
|
||||
// The assistant message carries the tool-call blocks; each is pending
|
||||
// until a tool/result event with the same callId is logged.
|
||||
for (const block of event.data.message.content) {
|
||||
if (block.type === 'tool-call') pendingCalls.set(block.id, { step: event.data.step })
|
||||
}
|
||||
break
|
||||
case 'tool/call':
|
||||
// Cite the `tool/call` seq from the synthetic result.
|
||||
{
|
||||
const entry = pendingCalls.get(event.data.callId)
|
||||
if (entry) {
|
||||
entry.callSeq = event.seq
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'tool/result':
|
||||
pendingCalls.delete(event.data.message.source.callId)
|
||||
break
|
||||
// Other event types do not move the turn/step boundary cursor.
|
||||
default:
|
||||
break
|
||||
@@ -118,44 +85,11 @@ export function openTurnClosers(events: readonly SessionEvent[], cause: OpenTurn
|
||||
// The last real event supplies the seq base and the timestamp for the
|
||||
// synthetic closers (reusing the last timestamp keeps them deterministic and
|
||||
// never invents a "future" time).
|
||||
let seq = last.seq + 1
|
||||
const closers: SessionEvent[] = recovery.results()
|
||||
let seq = last.seq + closers.length + 1
|
||||
const time = last.time
|
||||
const closers: SessionEvent[] = []
|
||||
|
||||
// Close calls before their step; Map insertion order preserves transcript order.
|
||||
const text = CLOSER_TEXT[cause.kind]
|
||||
for (const [callId, { step, callSeq }] of pendingCalls) {
|
||||
const started = callSeq !== undefined
|
||||
const message: ToolResultMessage = deepFreeze({
|
||||
id: brandString<MessageId>(`${cause.kind}-tool-result-${callId}-${seq}`),
|
||||
role: 'tool',
|
||||
toolCallId: callId,
|
||||
isError: true,
|
||||
source: { kind: 'tool', callId },
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: started ? text.started : text.notStarted,
|
||||
}],
|
||||
})
|
||||
closers.push({
|
||||
type: 'tool/result',
|
||||
seq: SessionSeq(seq++),
|
||||
time,
|
||||
data: {
|
||||
turn: openTurn,
|
||||
step,
|
||||
message,
|
||||
error: started
|
||||
? { name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN }
|
||||
: { name: 'ToolNotStartedError', code: TOOL_NOT_STARTED },
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
...started ? { sourceEventSeqs: [callSeq] } : {},
|
||||
})
|
||||
}
|
||||
|
||||
// Close an open step next — a turn/end while a step is open is an invariant
|
||||
// violation, so the step's boundary must be synthesized before the turn's.
|
||||
// Close an open step before its turn.
|
||||
if (openStep !== null) {
|
||||
closers.push({ type: 'step/end', seq: SessionSeq(seq++), time, data: { turn: openTurn, step: openStep } })
|
||||
}
|
||||
@@ -163,11 +97,111 @@ export function openTurnClosers(events: readonly SessionEvent[], cause: OpenTurn
|
||||
return closers
|
||||
}
|
||||
|
||||
/**
|
||||
* Track unanswered assistant tool requests from one Session's committed events.
|
||||
* Observe from the start of the owned step or replay prefix, and recover before
|
||||
* its step closes. This state retains pending identities, not event history.
|
||||
*/
|
||||
export class ToolCallRecovery {
|
||||
private readonly pendingCalls = new Map<ToolCallId, { turn: number; step: number; callSeq?: SessionSeqType }>()
|
||||
private last: Pick<SessionEvent, 'seq' | 'time'> | undefined
|
||||
|
||||
/** @param cause - defaults to interrupted live/crash recovery; fork-seed construction supplies its own cause. */
|
||||
constructor(private readonly cause: OpenTurnCloseCause = { kind: 'interrupted' }) {}
|
||||
|
||||
/**
|
||||
* Consume the next committed event; closed steps and turn boundaries discard pending requests.
|
||||
* @param event - the next event from the same Session, in sequence order.
|
||||
*/
|
||||
observe(event: SessionEvent): void {
|
||||
this.last = { seq: event.seq, time: event.time }
|
||||
switch (event.type) {
|
||||
case 'turn/start':
|
||||
case 'turn/end':
|
||||
case 'step/end':
|
||||
this.pendingCalls.clear()
|
||||
break
|
||||
case 'assistant/message':
|
||||
for (const block of event.data.message.content) {
|
||||
if (block.type === 'tool-call') {
|
||||
this.pendingCalls.set(block.id, { turn: event.data.turn, step: event.data.step })
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'tool/call': {
|
||||
const entry = this.pendingCalls.get(event.data.callId)
|
||||
if (entry) entry.callSeq = event.seq
|
||||
break
|
||||
}
|
||||
case 'tool/result': {
|
||||
const callId = event.data.message.source.callId
|
||||
const entry = this.pendingCalls.get(callId)
|
||||
if (event.surfaceOp === 'append' && entry !== undefined
|
||||
&& entry.turn === event.data.turn && entry.step === event.data.step) {
|
||||
this.pendingCalls.delete(callId)
|
||||
}
|
||||
break
|
||||
}
|
||||
// SessionEvent is merge-extensible; unrelated events retain pending requests.
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build conservative error results in assistant order without changing tracked state.
|
||||
* Sequences follow the latest observed event and timestamps reuse its time.
|
||||
* Callers commit the results and observe those commits before recovering again.
|
||||
* @returns pending tool-result events, empty when no request remains unanswered.
|
||||
*/
|
||||
results(): SessionEvent<'tool/result'>[] {
|
||||
if (this.last === undefined) return []
|
||||
let seq = this.last.seq + 1
|
||||
const time = this.last.time
|
||||
const results: SessionEvent<'tool/result'>[] = []
|
||||
|
||||
const text = CLOSER_TEXT[this.cause.kind]
|
||||
// Close calls before their step: providers reject dangling assistant calls,
|
||||
// and Map insertion order preserves their transcript order.
|
||||
for (const [callId, { turn, step, callSeq }] of this.pendingCalls) {
|
||||
const started = callSeq !== undefined
|
||||
const message: ToolResultMessage = deepFreeze({
|
||||
id: brandString<MessageId>(`${this.cause.kind}-tool-result-${callId}-${seq}`),
|
||||
role: 'tool',
|
||||
toolCallId: callId,
|
||||
isError: true,
|
||||
source: { kind: 'tool', callId },
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: started ? text.started : text.notStarted,
|
||||
}],
|
||||
})
|
||||
results.push({
|
||||
type: 'tool/result',
|
||||
seq: SessionSeq(seq++),
|
||||
time,
|
||||
data: {
|
||||
turn,
|
||||
step,
|
||||
message,
|
||||
error: started
|
||||
? { name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN }
|
||||
: { name: 'ToolNotStartedError', code: TOOL_NOT_STARTED },
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
...started ? { sourceEventSeqs: [callSeq] } : {},
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crash-recovery entry point: synthetic closers that balance a persisted log
|
||||
* whose tail turn was interrupted. Used by crash-recovery callers; fork
|
||||
* seeds receive their `forked`-cause closers through `buildForkSeed` in
|
||||
* `./fork.ts`, and cause selection stays internal to those two owners.
|
||||
* `./fork.ts`.
|
||||
*
|
||||
* @param events - the persisted log to scan, possibly ending inside an open turn.
|
||||
* @returns the synthetic `interrupted` closer events to append after `events`; empty when the log is already balanced.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ToolCallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { interruptedTurnClosers as repairInterruptedTurn, SessionSeq, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts'
|
||||
import { interruptedTurnClosers as repairInterruptedTurn, SessionSeq, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN, ToolCallRecovery } from '../src/index.ts'
|
||||
import { openTurnClosers as closeOpenTurn, type OpenTurnCloseCause } from '../src/repair.ts'
|
||||
import type { SessionEvent as LogicalSessionEvent, SurfaceEvent } from '../src/index.ts'
|
||||
|
||||
@@ -34,6 +34,70 @@ function openTurnClosers(events: readonly SessionEvent[], cause: OpenTurnCloseCa
|
||||
const userTurnStart = (turn: number, seq: number): SessionEvent =>
|
||||
({ type: 'turn/start', seq, time: seq, data: { turn } })
|
||||
|
||||
function liveToolRequests(): LogicalSessionEvent<'assistant/message'> {
|
||||
return {
|
||||
type: 'assistant/message', seq: SessionSeq(21), time: 110, surfaceOp: 'append',
|
||||
data: {
|
||||
turn: 3, step: 2, stream: [],
|
||||
message: createMessage({
|
||||
role: 'assistant', source: { kind: 'model', provider: 'mock', model: 'mock' },
|
||||
content: [
|
||||
{ type: 'tool-call', id: ToolCallId('started'), name: 'bash', arguments: '{}' },
|
||||
{ type: 'tool-call', id: ToolCallId('pending'), name: 'bash', arguments: '{}' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('ToolCallRecovery', () => {
|
||||
it('observes a live step without earlier history and retains repairs until their commits arrive', () => {
|
||||
const recovery = new ToolCallRecovery()
|
||||
expect(recovery.results()).toEqual([])
|
||||
recovery.observe({ type: 'step/start', seq: SessionSeq(20), time: 100, data: { turn: 3, step: 2 } })
|
||||
recovery.observe(liveToolRequests())
|
||||
recovery.observe({
|
||||
type: 'tool/call', seq: SessionSeq(22), time: 120,
|
||||
data: { turn: 3, step: 2, callId: ToolCallId('started'), name: 'bash', arguments: '{}' },
|
||||
})
|
||||
|
||||
const results = recovery.results()
|
||||
expect(results.map(event => ({
|
||||
seq: event.seq, time: event.time, turn: event.data.turn, step: event.data.step,
|
||||
callId: event.data.message.toolCallId, code: event.data.error?.code, sourceEventSeqs: event.sourceEventSeqs,
|
||||
}))).toEqual([
|
||||
{ seq: 23, time: 120, turn: 3, step: 2, callId: 'started', code: TOOL_OUTCOME_UNKNOWN, sourceEventSeqs: [22] },
|
||||
{ seq: 24, time: 120, turn: 3, step: 2, callId: 'pending', code: TOOL_NOT_STARTED, sourceEventSeqs: undefined },
|
||||
])
|
||||
expect(recovery.results()).toEqual(results)
|
||||
for (const [index, event] of results.entries()) {
|
||||
recovery.observe(event)
|
||||
expect(recovery.results()).toEqual(results.slice(index + 1))
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ kind: 'replacement', turn: 3, step: 2 },
|
||||
{ kind: 'other turn', turn: 2, step: 2 },
|
||||
{ kind: 'other step', turn: 3, step: 1 },
|
||||
])('does not acknowledge a pending call with $kind result reusing its id', ({ kind, turn, step }) => {
|
||||
const recovery = new ToolCallRecovery()
|
||||
recovery.observe(liveToolRequests())
|
||||
recovery.observe({
|
||||
type: 'tool/result', seq: SessionSeq(22), time: 120,
|
||||
surfaceOp: kind === 'replacement' ? { op: 'replace', startSeq: SessionSeq(10), endSeq: SessionSeq(10) } : 'append',
|
||||
...kind === 'replacement' ? { sourceEventSeqs: [SessionSeq(10)] } : {},
|
||||
data: {
|
||||
turn, step,
|
||||
message: createToolResultMessage({ callId: ToolCallId('started'), content: [{ type: 'text', text: 'old result' }], isError: false }),
|
||||
},
|
||||
})
|
||||
|
||||
expect(recovery.results().map(event => event.data.message.toolCallId)).toEqual([ToolCallId('started'), ToolCallId('pending')])
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
const causes: OpenTurnCloseCause[] = [{ kind: 'interrupted' }, { kind: 'forked' }]
|
||||
|
||||
describe.each(causes)('openTurnClosers (cause %o)', (cause) => {
|
||||
@@ -72,7 +136,7 @@ describe.each(causes)('openTurnClosers (cause %o)', (cause) => {
|
||||
const events: SessionEvent[] = [
|
||||
userTurnStart(2, 0),
|
||||
{ type: 'step/start', seq: 1, time: 1, data: { turn: 2, step: 1 } },
|
||||
{ type: 'assistant/message', seq: 2, time: 2, data: {
|
||||
{ type: 'assistant/message', seq: 2, time: 2, surfaceOp: 'append', data: {
|
||||
turn: 2, step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
@@ -115,7 +179,7 @@ describe.each(causes)('openTurnClosers (cause %o)', (cause) => {
|
||||
const events: SessionEvent[] = [
|
||||
userTurnStart(2, 0),
|
||||
{ type: 'step/start', seq: 1, time: 1, data: { turn: 2, step: 1 } },
|
||||
{ type: 'assistant/message', seq: 2, time: 2, data: {
|
||||
{ type: 'assistant/message', seq: 2, time: 2, surfaceOp: 'append', data: {
|
||||
turn: 2, step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
@@ -128,7 +192,7 @@ describe.each(causes)('openTurnClosers (cause %o)', (cause) => {
|
||||
},
|
||||
}),
|
||||
} },
|
||||
{ type: 'tool/result', seq: 3, time: 3, data: {
|
||||
{ type: 'tool/result', seq: 3, time: 3, surfaceOp: 'append', data: {
|
||||
turn: 2, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: ToolCallId('call-1'),
|
||||
@@ -146,7 +210,7 @@ describe.each(causes)('openTurnClosers (cause %o)', (cause) => {
|
||||
const events: SessionEvent[] = [
|
||||
userTurnStart(2, 0),
|
||||
{ type: 'step/start', seq: 1, time: 1, data: { turn: 2, step: 1 } },
|
||||
{ type: 'assistant/message', seq: 2, time: 2, data: {
|
||||
{ type: 'assistant/message', seq: 2, time: 2, surfaceOp: 'append', data: {
|
||||
turn: 2, step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
@@ -174,7 +238,7 @@ describe.each(causes)('openTurnClosers (cause %o)', (cause) => {
|
||||
const events: SessionEvent[] = [
|
||||
userTurnStart(1, 0),
|
||||
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
|
||||
{ type: 'assistant/message', seq: 2, time: 2, data: {
|
||||
{ type: 'assistant/message', seq: 2, time: 2, surfaceOp: 'append', data: {
|
||||
turn: 1, step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
@@ -187,7 +251,7 @@ describe.each(causes)('openTurnClosers (cause %o)', (cause) => {
|
||||
},
|
||||
}),
|
||||
} },
|
||||
{ type: 'tool/result', seq: 3, time: 3, data: {
|
||||
{ type: 'tool/result', seq: 3, time: 3, surfaceOp: 'append', data: {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: ToolCallId('old-call'),
|
||||
@@ -199,7 +263,7 @@ describe.each(causes)('openTurnClosers (cause %o)', (cause) => {
|
||||
{ type: 'turn/end', seq: 5, time: 5, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
userTurnStart(2, 6),
|
||||
{ type: 'step/start', seq: 7, time: 7, data: { turn: 2, step: 1 } },
|
||||
{ type: 'assistant/message', seq: 8, time: 8, data: {
|
||||
{ type: 'assistant/message', seq: 8, time: 8, surfaceOp: 'append', data: {
|
||||
turn: 2, step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
@@ -223,7 +287,7 @@ describe.each(causes)('openTurnClosers (cause %o)', (cause) => {
|
||||
const events: SessionEvent[] = [
|
||||
userTurnStart(1, 0),
|
||||
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
|
||||
{ type: 'assistant/message', seq: 2, time: 2, data: {
|
||||
{ type: 'assistant/message', seq: 2, time: 2, surfaceOp: 'append', data: {
|
||||
turn: 1, step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
@@ -238,7 +302,7 @@ describe.each(causes)('openTurnClosers (cause %o)', (cause) => {
|
||||
}),
|
||||
} },
|
||||
// call-a got answered before the cut; call-b did not.
|
||||
{ type: 'tool/result', seq: 3, time: 3, data: {
|
||||
{ type: 'tool/result', seq: 3, time: 3, surfaceOp: 'append', data: {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: ToolCallId('call-a'),
|
||||
@@ -257,7 +321,7 @@ describe.each(causes)('openTurnClosers (cause %o)', (cause) => {
|
||||
const events: SessionEvent[] = [
|
||||
userTurnStart(1, 0),
|
||||
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
|
||||
{ type: 'assistant/message', seq: 2, time: 2, data: {
|
||||
{ type: 'assistant/message', seq: 2, time: 2, surfaceOp: 'append', data: {
|
||||
turn: 1, step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 1eb35b015a6c423f
|
||||
zh: a8a3dceee2c9e943
|
||||
/packages-experimental/packages:
|
||||
en: 74635f0629fe1e84
|
||||
zh: 6119c5bbc6b807ac
|
||||
en: a64c1a1461fcad89
|
||||
zh: 8a54ffde7c24af47
|
||||
/packages-experimental/related-documentation:
|
||||
en: 31282a28dd828c81
|
||||
zh: ebb1d3cacdc1bd6d
|
||||
|
||||
@@ -33,7 +33,7 @@ The experimental group contains prototype capabilities whose contracts can chang
|
||||
| [`agent-team`](agent-team/README.md) | Named teammates with durable messages and a shared task board | `ctx.agentTeams` |
|
||||
| [`client-ui-agent-team`](client-ui-agent-team/README.md) | Team roster, task board, and teammate navigation for Web | — |
|
||||
| [`auto-review`](auto-review/README.md) | Explicit Web layer for same-model review before each native or PTC inner tool call | — |
|
||||
| [`schedule-bundle`](schedule-bundle/README.md) | Opt-in Schedule and time-context switch over the shipped Web composition | — |
|
||||
| [`schedule-bundle`](schedule-bundle/README.md) | Optional Schedule, task page, and time context for the shipped Web composition | — |
|
||||
| [`ptc-runtime-python`](ptc-runtime-python/README.md) | CPython subprocess backend for the PTC execution seam | `ctx.ptcRuntime` |
|
||||
| [`computer-use-cua-driver-mcp`](computer-use-cua-driver-mcp/README.md) | Use an installed Cua Driver through MCP | `ctx.computerUse` |
|
||||
| [`computer-use-cua-driver-native`](computer-use-cua-driver-native/README.md) | Embed the Cua Driver native npm runtime | `ctx.computerUse` |
|
||||
|
||||
@@ -33,7 +33,7 @@ kind: "package-group"
|
||||
| [`agent-team`](agent-team/README.zh.md) | 具名 teammate,成员之间持久消息与共享任务板 | `ctx.agentTeams` |
|
||||
| [`client-ui-agent-team`](client-ui-agent-team/README.zh.md) | Web Team roster、任务板与 teammate 导航 | — |
|
||||
| [`auto-review`](auto-review/README.zh.md) | 显式 Web 层,在每个原生或 PTC inner 工具调用前使用同一模型审查 | — |
|
||||
| [`schedule-bundle`](schedule-bundle/README.zh.md) | 在出厂 Web 组合上按需开启定时与时间上下文的开关 | — |
|
||||
| [`schedule-bundle`](schedule-bundle/README.zh.md) | 为出厂 Web 组合按需加入定时服务、任务页面与时间上下文 | — |
|
||||
| [`ptc-runtime-python`](ptc-runtime-python/README.zh.md) | PTC 执行 seam 的 CPython 子进程后端 | `ctx.ptcRuntime` |
|
||||
| [`computer-use-cua-driver-mcp`](computer-use-cua-driver-mcp/README.zh.md) | 通过 MCP 使用已安装的 Cua Driver | `ctx.computerUse` |
|
||||
| [`computer-use-cua-driver-native`](computer-use-cua-driver-native/README.zh.md) | 嵌入 Cua Driver 原生 npm 运行时 | `ctx.computerUse` |
|
||||
|
||||
@@ -3,26 +3,26 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/experimental/schedule-bundle/README.md
|
||||
/:
|
||||
en: 7504834d27adc717
|
||||
zh: 464587026083c661
|
||||
en: 06ac0f83120e2636
|
||||
zh: 24407e82f408f03c
|
||||
/deepseek-ai-dsh-experimental-schedule-bundle:
|
||||
en: d7b8b6126df4e3c4
|
||||
zh: 546d528efd4edbde
|
||||
/deepseek-ai-dsh-experimental-schedule-bundle/summary:
|
||||
en: 832752e963df6177
|
||||
zh: 30d1d292230a17c1
|
||||
en: cd6bb19197192724
|
||||
zh: 983a579ee54aa098
|
||||
/deepseek-ai-dsh-experimental-schedule-bundle/table-of-contents:
|
||||
en: d152484eb41ac6b4
|
||||
zh: 364c80ee00a374ff
|
||||
/deepseek-ai-dsh-experimental-schedule-bundle/use-this-package:
|
||||
en: d84cae0d3f3869c6
|
||||
zh: c19c74c0671f3127
|
||||
en: 632033572617d7bc
|
||||
zh: b2a050e68cf3f3e1
|
||||
/deepseek-ai-dsh-experimental-schedule-bundle/understand-the-implementation:
|
||||
en: b17882af7c7eaf2c
|
||||
zh: bfc71d0152557af5
|
||||
en: fce278be320c1ade
|
||||
zh: 8f81ac0154ea01a1
|
||||
/deepseek-ai-dsh-experimental-schedule-bundle/further-exploration:
|
||||
en: 99a29dddf9805540
|
||||
zh: 13a6df3ee31abfc1
|
||||
en: c66c056c527188f1
|
||||
zh: 2781b2ff3420c319
|
||||
/deepseek-ai-dsh-experimental-schedule-bundle/model-experience:
|
||||
en: 215e7ba838619b7b
|
||||
zh: a311da3843709f90
|
||||
@@ -39,8 +39,8 @@
|
||||
en: 0b01d8c40b422f5b
|
||||
zh: 57fede4db59a62b8
|
||||
/deepseek-ai-dsh-experimental-schedule-bundle/known-limitations-and-deferred-work:
|
||||
en: a4a2674fb3c5a101
|
||||
zh: cb4c54ba37730708
|
||||
en: 6c0599acfe520c6a
|
||||
zh: 69063300060b5785
|
||||
/deepseek-ai-dsh-experimental-schedule-bundle/known-limitations-and-deferred-work/dev-note:
|
||||
en: 0225ba087c5266f3
|
||||
zh: 4e706f1b2b984e63
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "Enable the shipped Schedule services, reminder catalog, and Automation tasks page from the plugin manager."
|
||||
description: "Add Schedule, its reminder catalog, and the Automation tasks page from the plugin manager."
|
||||
kind: "package-bundle"
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
This optional bundle switches on the three Schedule rows that `@deepseek-ai/dsh-web-app` inserts with `disabled: true`: `time-context`, `schedule`, and `ui-schedule`. Its patch appends no row and declares no plugin dependency, so enabling it cannot mount a Host service twice. Shipped profiles leave it switched off.
|
||||
This optional bundle inserts the three Schedule rows the shipped Web composition leaves out: `time-context`, `schedule`, and `ui-schedule`. Shipped profiles leave it switched off.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -25,7 +25,7 @@ This optional bundle switches on the three Schedule rows that `@deepseek-ai/dsh-
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Open Plugins in the Web sidebar and enable Scheduling and time context, marked by a clock icon. A live root Agent then receives `schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete`, the Session header shows its reminder catalog, the sidebar's Automation tasks entry opens the task-management page and the right Sidebar holds the selected task's detail, and each eligible step appends one clock reading with the current time, the browser zone attached to the open request, and the elapsed time since the preceding model-visible message. Disabling the bundle restores the shipped composition; stored tasks remain on disk.
|
||||
Open Plugins in the Web sidebar and enable Automation tasks, marked by an alarm clock icon. A live root Agent then receives `schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete`, the Session header shows its reminder catalog, the sidebar's Automation tasks entry opens the task-management page and the right Sidebar holds the selected task's detail, and each eligible step appends one clock reading with the current time, the browser zone attached to the open request, and the elapsed time since the preceding model-visible message. The bundle's page lists Time awareness, Task scheduling, and Task interface with their state. Disabling the bundle restores the shipped composition; stored tasks remain on disk.
|
||||
|
||||
-----
|
||||
|
||||
@@ -35,11 +35,12 @@ Open Plugins in the Web sidebar and enable Scheduling and time context, marked b
|
||||
<details>
|
||||
<summary>Maintainer details — click to expand</summary>
|
||||
|
||||
`cordis.patch.yml` carries three id-targeted patches, each setting `disabled: false` on a row the Web bundle already inserts. `OPTIONAL_BUNDLES` in `packages/boot/app-boot/src/profile.ts` names this package and `apps/cli` depends on it, so every installation ships it switched off and the plugin manager offers it in the Official group. Selecting it appends the bundle to the tail of the profile's `dsh.profile.bundles` list; bundle layers apply in list order, so the patches reach the rows only because that list places `@deepseek-ai/dsh-web-app` before this package. A hand-ordered list with this package first leaves the rows disabled while the Plugins page still shows the switch as on. No runtime invariant companion is published because this configuration-only package owns no mutable runtime state.
|
||||
`cordis.patch.yml` inserts the three rows, and `package.json` depends on their packages so each row resolves from this bundle. `OPTIONAL_BUNDLES` in `packages/boot/app-boot/src/profile.ts` names this package and `apps/cli` depends on it, so every installation ships it switched off and the plugin manager offers it in the Official group. Selecting it appends the bundle to the profile's `dsh.profile.bundles` list. No runtime invariant companion is published because this configuration-only package owns no mutable runtime state.
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| [`cordis.patch.yml`](cordis.patch.yml) | Three `disabled: false` patches over the shipped Web rows |
|
||||
| [`cordis.patch.yml`](cordis.patch.yml) | Inserts the `time-context`, `schedule`, and `ui-schedule` rows |
|
||||
| [`package.json`](package.json) | The row packages as dependencies |
|
||||
| [`locale/en.json`](locale/en.json), [`locale/zh.json`](locale/zh.json) | Plugin-manager title and description |
|
||||
| [`icon.svg`](icon.svg) | Plugin-manager icon |
|
||||
| [`src/index.ts`](src/index.ts) | Empty module entry; the patch is the runtime content |
|
||||
@@ -53,7 +54,7 @@ Open Plugins in the Web sidebar and enable Scheduling and time context, marked b
|
||||
|
||||
- [Schedule subsystem](../../../docs/subsystems/schedule.md) — durable tasks, occurrence resolution, and delivery.
|
||||
- [Schedule service](../../schedule/schedule/README.md) — Host task storage, activation, and the record format.
|
||||
- [Web bundle](../../bundle/web-app/README.md) — the composition that carries these rows.
|
||||
- [Web bundle](../../bundle/web-app/README.md) — the composition this bundle adds the rows to.
|
||||
|
||||
-----
|
||||
|
||||
@@ -78,8 +79,8 @@ The tool schemas change the request prefix once when the bundle mounts; each app
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- The switch targets rows `@deepseek-ai/dsh-web-app` inserts. In a profile without that bundle the three patches match no row: the loader reports one `patch: entry <id> not found` warning per row and mounts nothing from this bundle; such a profile mounts no Plugins page either.
|
||||
- The bundle's detail page has no per-row switches. The Host reports the three overridden ids under `overrides`, and the Web client renders only the rows a bundle inserts, so opening this card shows "This plugin pack contains no components." The switch for the three rows is on the Official-group list row.
|
||||
- While the bundle is on, its page offers a switch per row, as for every bundle. The three rows work only together: switching `schedule` off leaves the task page without its service, and switching `time-context` off leaves the model without the current time for new reminders.
|
||||
- A profile patch or `--patch` overlay that targets `time-context`, `schedule`, or `ui-schedule` by id matches no row while this bundle is not selected: the loader warns `patch: entry <id> not found` for each such patch. Select this bundle instead of switching the rows on by id.
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "从插件管理页启用出厂的定时服务、提醒目录与自动化任务页面。"
|
||||
description: "从插件管理页加入定时服务、提醒目录与自动化任务页面。"
|
||||
kind: "package-bundle"
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-bundle"
|
||||
|
||||
## 概述
|
||||
|
||||
此可选 Bundle 打开 `@deepseek-ai/dsh-web-app` 以 `disabled: true` 插入的三个定时条目:`time-context`、`schedule` 与 `ui-schedule`。它的补丁不追加条目,也不声明插件依赖,因此启用它不会重复挂载同一个 Host 服务。随包配置默认禁用。
|
||||
此可选 Bundle 插入随发行版交付的 Web 组合所不含的三个定时条目:`time-context`、`schedule` 与 `ui-schedule`。随包配置默认禁用。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -25,7 +25,7 @@ kind: "package-bundle"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用此包
|
||||
|
||||
打开 Web 侧栏的插件管理页并启用带时钟图标的“定时与时间上下文”。此后,活跃的根智能体获得 `schedule_create`、`schedule_list`、`schedule_update` 与 `schedule_delete`,会话头部显示提醒目录,侧栏的自动化任务入口打开任务管理页面、右侧栏承载所选任务的详情,每个符合条件的步骤追加一条时钟读数,包含当前时间、打开请求所带的浏览器时区,以及距上一条模型可见消息的经过时间。禁用此 Bundle 会恢复随包组合;已存储的任务保留在磁盘上。
|
||||
打开 Web 侧栏的插件管理页并启用带闹钟图标的“自动化任务”。此后,活跃的根智能体获得 `schedule_create`、`schedule_list`、`schedule_update` 与 `schedule_delete`,会话头部显示提醒目录,侧栏的自动化任务入口打开任务管理页面、右侧栏承载所选任务的详情,每个符合条件的步骤追加一条时钟读数,包含当前时间、打开请求所带的浏览器时区,以及距上一条模型可见消息的经过时间。此 Bundle 的页面列出时间感知、任务调度与任务界面及其状态。禁用此 Bundle 会恢复随包组合;已存储的任务保留在磁盘上。
|
||||
|
||||
-----
|
||||
|
||||
@@ -35,11 +35,12 @@ kind: "package-bundle"
|
||||
<details>
|
||||
<summary>维护者信息 — 点击展开</summary>
|
||||
|
||||
`cordis.patch.yml` 包含三个按 id 定位的补丁,各自把 Web Bundle 已插入条目的 `disabled` 设为 `false`。`packages/boot/app-boot/src/profile.ts` 的 `OPTIONAL_BUNDLES` 列出此包,`apps/cli` 依赖它,因此每次安装都随包携带且默认禁用,插件管理页在 Official 分组中提供它。选中后会把该 Bundle 追加到 profile 的 `dsh.profile.bundles` 列表末尾;Bundle 层按列表顺序应用,因此这些补丁能命中条目,只是因为该列表把 `@deepseek-ai/dsh-web-app` 排在此包之前。手写顺序把此包排在最前时,这些条目保持禁用,而插件管理页仍把该开关显示为已启用。此纯配置包不拥有可变的运行时状态,因此不发布不变量伴随模块。
|
||||
`cordis.patch.yml` 插入这三个条目,`package.json` 依赖它们的包,使每个条目都从此 Bundle 解析。`packages/boot/app-boot/src/profile.ts` 的 `OPTIONAL_BUNDLES` 列出此包,`apps/cli` 依赖它,因此每次安装都随包携带且默认禁用,插件管理页在“官方”分组中提供它。选中后会把该 Bundle 追加到 profile 的 `dsh.profile.bundles` 列表。此纯配置包不拥有可变的运行时状态,因此不发布不变量伴随模块。
|
||||
|
||||
| 文件 | 作用 |
|
||||
|---|---|
|
||||
| [`cordis.patch.yml`](cordis.patch.yml) | 针对出厂 Web 条目的三个 `disabled: false` 补丁 |
|
||||
| [`cordis.patch.yml`](cordis.patch.yml) | 插入 `time-context`、`schedule` 与 `ui-schedule` 三个条目 |
|
||||
| [`package.json`](package.json) | 以依赖声明这些条目的包 |
|
||||
| [`locale/en.json`](locale/en.json)、[`locale/zh.json`](locale/zh.json) | 插件管理页的标题与描述 |
|
||||
| [`icon.svg`](icon.svg) | 插件管理页图标 |
|
||||
| [`src/index.ts`](src/index.ts) | 空的模块入口;补丁即运行时内容 |
|
||||
@@ -53,7 +54,7 @@ kind: "package-bundle"
|
||||
|
||||
- [定时子系统](../../../docs/subsystems/schedule.zh.md) — 持久任务、发生时刻解析与投递。
|
||||
- [定时服务](../../schedule/schedule/README.zh.md) — Host 任务存储、激活与记录格式。
|
||||
- [Web Bundle](../../bundle/web-app/README.zh.md) — 携带这些条目的组合。
|
||||
- [Web Bundle](../../bundle/web-app/README.zh.md) — 此 Bundle 向其加入这些条目的组合。
|
||||
|
||||
-----
|
||||
|
||||
@@ -78,8 +79,8 @@ kind: "package-bundle"
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- 此开关针对 `@deepseek-ai/dsh-web-app` 插入的条目。在没有该 Bundle 的 profile 中,三个补丁匹配不到任何条目:加载器为每个条目报告一条 `patch: entry <id> not found` 警告,且不从该 Bundle 挂载任何东西;这样的 profile 也不挂载插件管理页。
|
||||
- 该 Bundle 的详情页没有逐条目开关。Host 把被覆盖的三个 id 报告在 `overrides` 下,而 Web 客户端只渲染 Bundle 插入的条目,因此打开这张卡片会显示“这个插件包不包含任何组件。”。控制这三个条目的开关在 Official 分组列表行上。
|
||||
- 此 Bundle 打开时,其页面像其他所有 Bundle 一样为每个条目提供开关。这三个条目只能一起工作:关掉 `schedule` 会让任务页面失去服务,关掉 `time-context` 会让模型在创建新提醒时拿不到当前时间。
|
||||
- 未选中此 Bundle 时,按 id 定位 `time-context`、`schedule` 或 `ui-schedule` 的 profile 补丁或 `--patch` overlay 匹配不到任何条目:加载器为每条这样的补丁报告一条 `patch: entry <id> not found` 警告。请选中此 Bundle,而不是按 id 打开这些条目。
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
# Experimental Schedule switch over the shipped Web composition. `dsh-web-app`
|
||||
# inserts the three rows with `disabled: true`; this layer flips them on and
|
||||
# appends nothing, so enabling it cannot mount a Host service twice.
|
||||
# Experimental Schedule over the shipped Web composition, which carries none of
|
||||
# these rows. The Schedule service owns the task store and delivers each due
|
||||
# occurrence as a follow-up in its original Session; `time-context` supplies the
|
||||
# current time, browser zone, and elapsed time the model needs to resolve an
|
||||
# unqualified date or time; `ui-schedule` is the task page and the Session
|
||||
# reminder catalog over that service.
|
||||
|
||||
- id: time-context
|
||||
disabled: false
|
||||
- insert:
|
||||
- id: time-context
|
||||
name: '@deepseek-ai/dsh-time-context'
|
||||
|
||||
- id: schedule
|
||||
disabled: false
|
||||
- id: schedule
|
||||
name: '@deepseek-ai/dsh-schedule'
|
||||
|
||||
- id: ui-schedule
|
||||
disabled: false
|
||||
- id: ui-schedule
|
||||
name: '@deepseek-ai/dsh-client-ui-schedule'
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="18" cy="18" r="11.5" fill="url(#a)" />
|
||||
<path d="M18 11.8V18.6L22.6 22.4" stroke="#FFFFFF" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<circle cx="18" cy="18.6" r="1.5" fill="#FFFFFF" />
|
||||
<path d="M13.35 9.53A11 11 0 0 0 8.03 14.85" stroke="#45D9E7" stroke-width="2.6" stroke-linecap="round" />
|
||||
<path d="M22.65 9.53A11 11 0 0 1 27.97 14.85" stroke="#45D9E7" stroke-width="2.6" stroke-linecap="round" />
|
||||
<circle cx="18" cy="19.5" r="8.2" fill="url(#a)" />
|
||||
<path d="M18 15.1V19.7L20.9 21.5" stroke="#FFFFFF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<defs>
|
||||
<linearGradient id="a" x1="18" y1="6.5" x2="18" y2="29.5" gradientUnits="userSpaceOnUse">
|
||||
<linearGradient id="a" x1="18" y1="11.3" x2="18" y2="27.7" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#7CB7FF" />
|
||||
<stop offset="1" stop-color="#145AF3" />
|
||||
</linearGradient>
|
||||
|
||||
|
Before Width: | Height: | Size: 550 B After Width: | Height: | Size: 717 B |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "Scheduling and time context",
|
||||
"description": "Enable durable reminders, the Automation tasks page, and per-step clock context."
|
||||
"title": "Automation tasks",
|
||||
"description": "Run tasks in your sessions at a set time or on a repeating schedule."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "定时与时间上下文",
|
||||
"description": "启用持久提醒、自动化任务页面与逐步时钟上下文。"
|
||||
"title": "自动化任务",
|
||||
"description": "按设定的时间或周期,在会话中自动执行任务。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-experimental-schedule-bundle",
|
||||
"description": "Optional bundle that switches the shipped Schedule, time-context, and reminder-catalog rows on",
|
||||
"description": "Optional bundle that adds Schedule, its task page, and time context to the Web composition",
|
||||
"version": "0.1.7-rc.2",
|
||||
"icon": "./icon.svg",
|
||||
"publishConfig": {
|
||||
@@ -32,6 +32,11 @@
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-ui-schedule": "workspace:*",
|
||||
"@deepseek-ai/dsh-schedule": "workspace:*",
|
||||
"@deepseek-ai/dsh-time-context": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** The experimental Schedule switch must flip exactly the three rows the Web bundle inserts. */
|
||||
/** The experimental Schedule bundle inserts the three Schedule rows the shipped Web composition leaves out. */
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
@@ -19,13 +19,6 @@ interface Manifest {
|
||||
dsh?: { bundle?: { patch?: string } }
|
||||
}
|
||||
|
||||
interface Patch {
|
||||
id?: string
|
||||
name?: string
|
||||
disabled?: boolean
|
||||
insert?: unknown[]
|
||||
}
|
||||
|
||||
describe('experimental Schedule bundle', () => {
|
||||
const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as Manifest
|
||||
|
||||
@@ -37,20 +30,18 @@ describe('experimental Schedule bundle', () => {
|
||||
expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml')
|
||||
expect(manifest.exports?.['./locale/*.json']).toBe('./locale/*.json')
|
||||
expect(manifest.exports?.['./cordis.patch.yml']).toBe('./cordis.patch.yml')
|
||||
// The patch only overrides rows another layer inserts, so it depends on no plugin package.
|
||||
expect(manifest.dependencies).toBeUndefined()
|
||||
// Each inserted row names a package the bundle depends on, so the rows resolve from the bundle.
|
||||
expect(Object.keys(manifest.dependencies ?? {}).sort()).toEqual([
|
||||
'@deepseek-ai/dsh-client-ui-schedule', '@deepseek-ai/dsh-schedule', '@deepseek-ai/dsh-time-context',
|
||||
])
|
||||
})
|
||||
|
||||
it('flips the three shipped rows on and appends none', () => {
|
||||
it('inserts the three Schedule rows switched on', () => {
|
||||
const parsed = yaml.load(readFileSync(resolve(root, './cordis.patch.yml'), 'utf8'), { schema: entryListSchema })
|
||||
expect(parsed).toEqual([
|
||||
{ id: 'time-context', disabled: false },
|
||||
{ id: 'schedule', disabled: false },
|
||||
{ id: 'ui-schedule', disabled: false },
|
||||
])
|
||||
for (const patch of parsed as Patch[]) {
|
||||
expect(patch.insert).toBeUndefined()
|
||||
expect(patch.name).toBeUndefined()
|
||||
}
|
||||
expect(parsed).toEqual([{ insert: [
|
||||
{ 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' },
|
||||
] }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 23386452a66b2fbd
|
||||
zh: 4a8d91b2f313fead
|
||||
/deepseek-ai-dsh-schedule/use-this-package:
|
||||
en: db292f4e03cef41b
|
||||
zh: 064cb553698d548c
|
||||
en: 318cad8a73818a95
|
||||
zh: 267b9c86cb9f06d1
|
||||
/deepseek-ai-dsh-schedule/understand-the-implementation:
|
||||
en: 676b7c508f37a1b7
|
||||
zh: c5e8520b3350f0b0
|
||||
|
||||
@@ -23,7 +23,7 @@ Schedule delivers one-shot, fixed-rate, daily, weekly, and cron wall-clock remin
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
The shipped Web composition carries the `schedule` row disabled; enable the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` from the Plugins page (Official group), or list it in a profile's `dsh.profile.bundles`, to mount the service alongside storage-domain and the Session controller. Its `Config` states `deliveryHistoryDays` (default 30) and `deliveryHistoryRecords` (default 200). Storage backend routing belongs to storage-domain; Session model and preset restoration belong to the Session controller. Schedule cannot be mounted alone in a headless or SDK-only composition: delivery requires the Host Web Session controller and a Session persistence backend, because a delivery commits only after the Session acknowledges `session/flush`.
|
||||
The shipped Web composition carries no `schedule` row; enable the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` from the Plugins page (Official group), or list it in a profile's `dsh.profile.bundles`, to insert and mount the service alongside storage-domain and the Session controller. Its `Config` states `deliveryHistoryDays` (default 30) and `deliveryHistoryRecords` (default 200). Storage backend routing belongs to storage-domain; Session model and preset restoration belong to the Session controller. Schedule cannot be mounted alone in a headless or SDK-only composition: delivery requires the Host Web Session controller and a Session persistence backend, because a delivery commits only after the Session acknowledges `session/flush`.
|
||||
|
||||
The Agent receives `schedule_create`, `schedule_list`, `schedule_delete`, and `schedule_update`. Update replaces the name, instruction, or timing of one reminder in place and keeps its id and saved records; it is not offered for the relative `after` delay. Creation requires a non-empty prompt, a title, and exactly one of six selectors:
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ Schedule 将一次性、固定周期、按每日、按每周以及 cron 本地
|
||||
<a id="use-this-package"></a>
|
||||
## 使用此包
|
||||
|
||||
随发行版交付的 Web 组合把 `schedule` 行保持禁用;在插件管理页(Official 分组)启用可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle`,或把它列进 profile 的 `dsh.profile.bundles`,即可将此服务与 storage-domain、Session controller 一起挂载。其 `Config` 声明 `deliveryHistoryDays`(默认 30)与 `deliveryHistoryRecords`(默认 200)。存储后端路由由 storage-domain 管理;会话模型与 preset 恢复由 Session controller 管理。Schedule 无法在 headless 或仅 SDK 的组合中单独挂载:投递需要 Host 的 Web Session controller 和 Session 持久化后端,因为只有在 Session 确认 `session/flush` 之后一次投递才会提交。
|
||||
随发行版交付的 Web 组合不含 `schedule` 行;在插件管理页的“官方”分组中启用可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle`,或把它列进 profile 的 `dsh.profile.bundles`,即可插入此服务并将它与 storage-domain、Session controller 一起挂载。其 `Config` 声明 `deliveryHistoryDays`(默认 30)与 `deliveryHistoryRecords`(默认 200)。存储后端路由由 storage-domain 管理;会话模型与 preset 恢复由 Session controller 管理。Schedule 无法在 headless 或仅 SDK 的组合中单独挂载:投递需要 Host 的 Web Session controller 和 Session 持久化后端,因为只有在 Session 确认 `session/flush` 之后一次投递才会提交。
|
||||
|
||||
Agent 获得 `schedule_create`、`schedule_list`、`schedule_delete` 和 `schedule_update`。更新原地替换一条提醒的名称、指令或时间,保留其 id 与已保存记录;相对的 `after` 延迟不支持更新。创建时需要非空提示文本、标题,且必须只提供以下六个选择器之一:
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "Task scheduling",
|
||||
"description": "Stores automation tasks, runs each in its original session when it is due, and gives the agent tools to manage them."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "任务调度",
|
||||
"description": "保存自动化任务,到点后在原会话中执行,并为智能体提供管理任务的工具。"
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json",
|
||||
"./locale/*.json": "./locale/*.json",
|
||||
"./typert": {
|
||||
"types": "./lib/typert.host.d.ts",
|
||||
"default": "./lib/typert.host.js"
|
||||
@@ -38,6 +39,7 @@
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"locale/*.json",
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
|
||||
Generated
+10
-9
@@ -2479,9 +2479,6 @@ importers:
|
||||
'@deepseek-ai/dsh-client-ui-renderer':
|
||||
specifier: workspace:*
|
||||
version: link:../../client/ui-renderer
|
||||
'@deepseek-ai/dsh-client-ui-schedule':
|
||||
specifier: workspace:*
|
||||
version: link:../../client/ui-schedule
|
||||
'@deepseek-ai/dsh-client-ui-session':
|
||||
specifier: workspace:*
|
||||
version: link:../../client/ui-session
|
||||
@@ -2629,9 +2626,6 @@ importers:
|
||||
'@deepseek-ai/dsh-plugin-manager':
|
||||
specifier: workspace:*
|
||||
version: link:../../boot/plugin-manager
|
||||
'@deepseek-ai/dsh-schedule':
|
||||
specifier: workspace:*
|
||||
version: link:../../schedule/schedule
|
||||
'@deepseek-ai/dsh-session-log-export':
|
||||
specifier: workspace:*
|
||||
version: link:../../session-query/session-log-export
|
||||
@@ -2656,9 +2650,6 @@ importers:
|
||||
'@deepseek-ai/dsh-terminal-bash':
|
||||
specifier: workspace:*
|
||||
version: link:../../terminal/terminal-bash
|
||||
'@deepseek-ai/dsh-time-context':
|
||||
specifier: workspace:*
|
||||
version: link:../../context/time-context
|
||||
'@deepseek-ai/dsh-tool-ask-user':
|
||||
specifier: workspace:*
|
||||
version: link:../../interaction/tool-ask-user
|
||||
@@ -7861,6 +7852,16 @@ importers:
|
||||
version: link:../../util/values
|
||||
|
||||
packages/experimental/schedule-bundle:
|
||||
dependencies:
|
||||
'@deepseek-ai/dsh-client-ui-schedule':
|
||||
specifier: workspace:*
|
||||
version: link:../../client/ui-schedule
|
||||
'@deepseek-ai/dsh-schedule':
|
||||
specifier: workspace:*
|
||||
version: link:../../schedule/schedule
|
||||
'@deepseek-ai/dsh-time-context':
|
||||
specifier: workspace:*
|
||||
version: link:../../context/time-context
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:~
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
en: 8e99640e4936332d
|
||||
zh: 729d5de1e0ad2869
|
||||
/python-contributor-workflows/validate-the-sdk:
|
||||
en: fbdd6bb379edd159
|
||||
zh: f3a803a5d99a7b92
|
||||
en: 0830603b2ed0ecc0
|
||||
zh: 252809ba6cad5848
|
||||
/python-contributor-workflows/run-against-node-source:
|
||||
en: 4d30c74632781443
|
||||
zh: 13191cb05f99dd31
|
||||
|
||||
@@ -36,6 +36,8 @@ uv run --project python/sdk python scripts/smoke-python-runtime.py \
|
||||
|
||||
SDK scenarios compare committed expected output under `scripts/snapshots/python-sdk-single-exe/`. `minimal/model-visible.json` pins the Linux/macOS `sdk-minimal` profile's assembled system prompts, advertised tool schemas, and model-visible messages; `minimal/win-x64/model-visible.json` pins its PowerShell counterpart. A plugin that contributes an unintended system section or user message therefore fails the job, and every message the profile emits is compared. `advanced/` pins one complex process's SDK result and parent/child session logs across every target. `restart/` launches two complete SDK runtime processes against one persistence root and snapshots their isolated model histories, high-level results, and separate durable logs across every target. `sdk-minimal-in-history` reuses the persistent-shell and editor scenario with a section that changes after the first successful shell call. `minimal-in-history/prompt-history.json` pins both prompt versions, the unchanged leading prompt in later requests, appended SDK system-message events, and `request/context.systemPromptUpdate`; tool schemas stay fixed and the editor file is checked independently. `sdk-dynamic-tools` pins native tool additions and removals in `dynamic-tools/tool-history.json`, including historical header references and agreement between Python SDK results, notifications, and persisted events. Rerun the owning scenario with `--update-snapshots` and review that diff before committing it.
|
||||
|
||||
`scheduler-recovery/` records a failed tool turn and a subsequent completed turn. It preserves the original `UNKNOWN` turn error, the first tool's completed result, and `TOOL_OUTCOME_UNKNOWN` / `TOOL_NOT_STARTED` results for the remaining calls. `sdk-snapshot` and `all` include this case; `--scenario sdk-recovery` runs it alone.
|
||||
|
||||
The `advanced` and `restart` comparisons match native delivery qualifiers against each input's own Session generation, including SDK events and notifications. Captured and other delivery generations retain their numeric values. Each side must use one generation across its Session roles, and fresh logs must identify the current writer. Comparison never rewrites committed recordings.
|
||||
|
||||
Trusted pull requests and master pushes also run `--scenario sdk-live --installed-wheel` on each selected native target. That scenario performs two tool-using turns against `https://api.deepseek.com`: it checks the created file immediately, replaces its content with a host-only random challenge, and requires the second turn to copy the changed content into a fresh receipt without modifying the source. Both turns must complete with the exact sentinel answer and a model-requested tool call; external byte comparisons check the files. Missing repository secrets fail instead of self-skipping. Fork and Dependabot pull requests run the complete keyless installed-wheel path but receive no key.
|
||||
|
||||
@@ -36,6 +36,8 @@ uv run --project python/sdk python scripts/smoke-python-runtime.py \
|
||||
|
||||
SDK 场景会比对 `scripts/snapshots/python-sdk-single-exe/` 下已提交的期望输出。`minimal/model-visible.json` 固定 Linux/macOS `sdk-minimal` profile 所组装的系统提示词、对外公布的工具 schema 与模型可见消息;`minimal/win-x64/model-visible.json` 固定对应的 PowerShell 版本。因此,插件一旦贡献出计划外的系统分段或 user 消息,该任务即失败,且该 profile 发出的每条消息都会参与比对。`advanced/` 跨所有目标固定一个复杂进程的 SDK 结果及父/子会话日志。`restart/` 针对同一持久化根目录启动两个完整 SDK 运行时进程,并跨所有目标固定其彼此隔离的模型历史、高层结果与独立持久日志。`sdk-minimal-in-history` 复用持久 shell 与编辑器场景,并在首次 shell 调用成功后更改一个分段。`minimal-in-history/prompt-history.json` 固定两个提示词版本、后续请求中不变的首条提示词、追加的 SDK system-message 事件及 `request/context.systemPromptUpdate`;工具 schema 保持不变,并独立检查编辑器创建的文件。`sdk-dynamic-tools` 在 `dynamic-tools/tool-history.json` 中固定原生工具的新增与移除,包括历史 header 引用,以及 Python SDK 结果、通知与持久事件的一致性。重新运行对应场景时加上 `--update-snapshots`,并在提交前审阅该差异。
|
||||
|
||||
`scheduler-recovery/` 记录一个失败的工具轮次及后续完成的轮次。它保留原始的 `UNKNOWN` 轮次错误、第一个工具已完成的结果,以及其余调用的 `TOOL_OUTCOME_UNKNOWN` / `TOOL_NOT_STARTED` 结果。`sdk-snapshot` 和 `all` 包含此场景;`--scenario sdk-recovery` 单独运行它。
|
||||
|
||||
`advanced` 与 `restart` 的比较按每份输入自身的 Session 代际匹配原生 delivery 限定值,包括 SDK 事件与通知。捕获的代际及其他 delivery 代际保留数值。每一侧的 Session 角色必须使用同一个代际,新日志必须标识当前写入器。比较绝不重写已提交的记录。
|
||||
|
||||
可信拉取请求与 master 推送还会在各自选定的原生目标上运行 `--scenario sdk-live --installed-wheel`。该场景面向 `https://api.deepseek.com` 执行两个使用工具的轮次:立即检查已创建文件,将其内容替换为仅宿主知道的随机挑战值,并要求第二轮将变更后的内容复制到全新的回执文件,且不修改源文件。两个轮次都必须完成、返回精确的哨兵答案并由模型请求调用工具;文件通过外部逐字节比较验证。仓库密钥缺失时失败,而不是自行 skip。Fork 与 Dependabot 拉取请求会运行完整的 keyless 安装后 wheel 路径,但不会获得密钥。
|
||||
|
||||
@@ -315,6 +315,34 @@ def test_snapshot_value_scrubs_system_nodes_without_erasing_header_fields() -> N
|
||||
assert normalize(empty, []) == empty
|
||||
|
||||
|
||||
def test_snapshot_value_preserves_deterministic_tool_repair_ids() -> None:
|
||||
normalize = SMOKE["normalize_snapshot_value"]
|
||||
repair = {
|
||||
"type": "tool/result", "seq": 19, "time": 123,
|
||||
"data": {"message": {
|
||||
"id": "interrupted-tool-result-scheduler-unstarted-19",
|
||||
"role": "tool", "source": {"kind": "tool", "callId": "scheduler-unstarted"},
|
||||
"toolCallId": "scheduler-unstarted", "isError": True,
|
||||
"content": [{"type": "text", "text": "The tool did not start."}],
|
||||
}},
|
||||
}
|
||||
normalized = normalize(repair, [])
|
||||
assert normalized["data"]["message"]["id"] == repair["data"]["message"]["id"]
|
||||
assert normalized["seq"] == 19
|
||||
assert normalized["time"] == 0
|
||||
ordinary = {
|
||||
"id": "521f38bb-fb22-45cb-af5f-a9cc370a1814", "role": "tool",
|
||||
"toolCallId": "scheduler-complete",
|
||||
"source": {"kind": "tool", "callId": "scheduler-complete"}, "content": [],
|
||||
}
|
||||
assert normalize(ordinary, [])["id"] == "{{messageId}}"
|
||||
wrong_call = {**repair["data"]["message"], "toolCallId": "other"}
|
||||
assert normalize(wrong_call, [])["id"] == "{{messageId}}"
|
||||
historical = {**repair["data"]["message"], "role": "user"}
|
||||
historical.pop("toolCallId")
|
||||
assert normalize(historical, [])["id"] == repair["data"]["message"]["id"]
|
||||
|
||||
|
||||
def test_snapshot_value_normalizes_embedded_assistant_stream_timing() -> None:
|
||||
normalize = SMOKE["normalize_snapshot_value"]
|
||||
event = {
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
* `Events` parameter tuple. Zero matches require `@dshScopeScan unsupported`;
|
||||
* multiple matches are ambiguous and always fail loud.
|
||||
*
|
||||
* `tsx scripts/gen-scoped-events.ts` -> write the generated source
|
||||
* `tsx scripts/gen-scoped-events.ts --check` -> exit 1 when it is stale
|
||||
* `pnpm run gen-scoped-events` -> write the generated source
|
||||
* `pnpm run verify-scoped-events` -> exit 1 when it is stale
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
mkdtempSync,
|
||||
lstatSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
@@ -158,11 +159,15 @@ function gitDirectory(fixture: Fixture, root: string): string {
|
||||
return git(fixture, root, ['rev-parse', '--absolute-git-dir'])
|
||||
}
|
||||
|
||||
// The installer resolves the common directory against Git's own top-level path, and the host
|
||||
// spells that path differently from the fixture directory this file created: Git canonicalizes
|
||||
// Windows 8.3 short names and the macOS `/var` symlink, while `mkdtempSync` returns the temp
|
||||
// directory's own spelling. Canonicalizing the fixture-side directory collapses both spellings
|
||||
// onto the one directory the installer touches, so an injected failure on this path reaches the
|
||||
// installer's own lock instead of a differently spelled name for it.
|
||||
function commonDirectory(fixture: Fixture): string {
|
||||
// Git expands Windows short paths and filesystem aliases before the installer resolves its lock.
|
||||
const root = git(fixture, fixture.main, ['rev-parse', '--show-toplevel'])
|
||||
const output = git(fixture, fixture.main, ['rev-parse', '--git-common-dir'])
|
||||
return isAbsolute(output) ? output : resolve(root, output)
|
||||
return realpathSync.native(isAbsolute(output) ? output : resolve(fixture.main, output))
|
||||
}
|
||||
|
||||
function hooksPath(fixture: Fixture, root: string): string {
|
||||
@@ -323,14 +328,11 @@ describe('worktree-local Lefthook installer', { timeout: 90_000 }, () => {
|
||||
expect(existsSync(join(hooksPath(fixture, fixture.main), '.fake-lefthook-running'))).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ operation: 'openSync', code: 'EPERM', expires: false },
|
||||
{ operation: 'readFileSync', code: 'EPERM', expires: false },
|
||||
{ operation: 'lstatSync', code: 'EPERM', expires: false },
|
||||
{ operation: 'openSync', code: 'EPERM', expires: true },
|
||||
{ operation: 'openSync', code: 'EACCES', expires: false },
|
||||
])('handles $operation $code with expired deadline=$expires', async ({ operation, code, expires }) => {
|
||||
const fixture = createFixture()
|
||||
/** Inject one lock-access failure and assert the installer's documented outcome. */
|
||||
async function expectInjectedLockAccessFailure(
|
||||
fixture: Fixture,
|
||||
{ operation, code, expires }: { operation: string; code: string; expires: boolean },
|
||||
): Promise<void> {
|
||||
const lockPath = installLockPath(fixture)
|
||||
const probe = join(fixture.container, 'lock-access-probe')
|
||||
const preload = join(fixture.container, 'lock-access.cjs')
|
||||
@@ -372,6 +374,28 @@ syncBuiltinESMExports()
|
||||
expect(result.stderr).toContain('injected lock access failure')
|
||||
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
|
||||
}
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ operation: 'openSync', code: 'EPERM', expires: false },
|
||||
{ operation: 'readFileSync', code: 'EPERM', expires: false },
|
||||
{ operation: 'lstatSync', code: 'EPERM', expires: false },
|
||||
{ operation: 'openSync', code: 'EPERM', expires: true },
|
||||
{ operation: 'openSync', code: 'EACCES', expires: false },
|
||||
])('handles $operation $code with expired deadline=$expires', async ({ operation, code, expires }) => {
|
||||
await expectInjectedLockAccessFailure(createFixture(), { operation, code, expires })
|
||||
})
|
||||
|
||||
// The alias forces the installer's resolved common directory to differ from the path this spec
|
||||
// composes on every host — a junction on Windows, a directory symlink elsewhere — instead of
|
||||
// depending on a runner whose temp directory happens to carry a short name.
|
||||
it('handles an injected lock failure through an aliased worktree root', async () => {
|
||||
const fixture = createFixture()
|
||||
const alias = join(fixture.container, 'main-alias')
|
||||
symlinkSync(fixture.main, alias, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
fixture.main = alias
|
||||
|
||||
await expectInjectedLockAccessFailure(fixture, { operation: 'openSync', code: 'EPERM', expires: false })
|
||||
})
|
||||
|
||||
it('waits for a concurrent installer to finish publishing its lock record', async () => {
|
||||
|
||||
@@ -64,15 +64,16 @@ describe('optional bundles', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('composes the Schedule bundle over the three shipped Web rows', () => {
|
||||
it('adds the three Schedule rows the shipped Web composition leaves out', () => {
|
||||
const { patches } = bundle('@deepseek-ai/dsh-experimental-schedule-bundle')
|
||||
const composed = composeEntries([...shipped, patches])
|
||||
expect(composed.filter(entry => entry.id === 'time-context' || entry.id === 'schedule' || entry.id === 'ui-schedule'))
|
||||
.toMatchObject([
|
||||
{ id: 'time-context', name: '@deepseek-ai/dsh-time-context', disabled: false },
|
||||
{ id: 'schedule', name: '@deepseek-ai/dsh-schedule', disabled: false },
|
||||
{ id: 'ui-schedule', name: '@deepseek-ai/dsh-client-ui-schedule', disabled: false },
|
||||
])
|
||||
const scheduleRows = (entries: ReturnType<typeof composeEntries>) =>
|
||||
entries.filter(entry => ['time-context', 'schedule', 'ui-schedule'].includes(entry.id))
|
||||
expect(scheduleRows(composeEntries(shipped))).toEqual([])
|
||||
expect(scheduleRows(composeEntries([...shipped, patches]))).toEqual([
|
||||
{ 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' },
|
||||
])
|
||||
})
|
||||
|
||||
it.each(OPTIONAL_BUNDLES)('%s resolves a title, description, and icon in both shipped languages', (name) => {
|
||||
|
||||
@@ -105,6 +105,11 @@ RESTART_SECOND_PROMPT = "Complete the second isolated Python SDK process turn."
|
||||
RESTART_SECOND_TEXT = "PROCESS_TWO_OK"
|
||||
RESTART_FIRST_SESSION_ID = "process-one"
|
||||
RESTART_SECOND_SESSION_ID = "process-two"
|
||||
RECOVERY_PROMPT = "Run three todo updates; the scheduler will reject the second call."
|
||||
RECOVERY_CONTINUE_PROMPT = "Continue after the scheduler failure without repeating any tools."
|
||||
RECOVERY_TEXT = "SCHEDULER_RECOVERY_OK"
|
||||
RECOVERY_SESSION_ID = "scheduler-recovery"
|
||||
RECOVERY_CALL_IDS = ("scheduler-complete", "scheduler-fail", "scheduler-unstarted")
|
||||
SNAPSHOT_WORKFLOW_SCRIPT = (
|
||||
"phase('Delegate')\n"
|
||||
f"const reply = await agent('{SNAPSHOT_WORKFLOW_CHILD_PROMPT}', {{ label: 'workflow-child' }})\n"
|
||||
@@ -136,6 +141,10 @@ RESTART_SNAPSHOT_DIRECTORY = (
|
||||
RESTART_SNAPSHOT_FILENAMES = (
|
||||
"result.json", "requests.json", "session.1.v3.jsonl", "session.2.v3.jsonl",
|
||||
)
|
||||
RECOVERY_SNAPSHOT_DIRECTORY = (
|
||||
Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "scheduler-recovery"
|
||||
)
|
||||
RECOVERY_SNAPSHOT_FILENAMES = ("result.json", "requests.json", "session.v4.jsonl")
|
||||
MCP_SERVER_SCRIPT = """\
|
||||
import json
|
||||
import os
|
||||
@@ -316,6 +325,32 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
|
||||
if not isinstance(latest, dict):
|
||||
raise AssertionError(f"model request has an invalid latest message: {body}")
|
||||
|
||||
recovery_prompt = next((
|
||||
block.get("text")
|
||||
for block in latest.get("content", [])
|
||||
if isinstance(block, dict) and block.get("type") == "text"
|
||||
and block.get("text") in {RECOVERY_PROMPT, RECOVERY_CONTINUE_PROMPT}
|
||||
), None)
|
||||
if recovery_prompt == RECOVERY_PROMPT:
|
||||
assert_advertised_tool(body, "todo_write")
|
||||
chunks = [message_start()]
|
||||
for index, call_id in enumerate(RECOVERY_CALL_IDS):
|
||||
call = tool_call_chunks(call_id, "todo_write", {"todos": []})
|
||||
chunks.extend({**chunk, "index": index} for chunk in call[1:-2])
|
||||
chunks.extend(call[-2:])
|
||||
return chunks
|
||||
if recovery_prompt == RECOVERY_CONTINUE_PROMPT:
|
||||
results = [
|
||||
block for message in messages
|
||||
for block in message.get("content", [])
|
||||
if isinstance(block, dict) and block.get("type") == "tool_result"
|
||||
]
|
||||
if [result.get("tool_use_id") for result in results] != list(RECOVERY_CALL_IDS):
|
||||
raise AssertionError(f"recovered history lost tool-result pairing: {results}")
|
||||
if [result.get("is_error", False) for result in results] != [False, True, True]:
|
||||
raise AssertionError(f"recovered history changed completed or pending outcomes: {results}")
|
||||
return text_chunks(RECOVERY_TEXT)
|
||||
|
||||
tool_results = [
|
||||
block for block in latest.get("content", [])
|
||||
if isinstance(block, dict) and block.get("type") == "tool_result"
|
||||
@@ -743,7 +778,7 @@ def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--scenario",
|
||||
choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-minimal-in-history", "sdk-dynamic-tools", "sdk-fs-search", "sdk-spawn-node", "sdk-mcp", "sdk-snapshot", "sdk-restart", "sdk-profile-plugin", "sdk-office", "sdk-authoring", "sdk-live", "runner", "direct"),
|
||||
choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-minimal-in-history", "sdk-dynamic-tools", "sdk-fs-search", "sdk-spawn-node", "sdk-mcp", "sdk-snapshot", "sdk-recovery", "sdk-restart", "sdk-profile-plugin", "sdk-office", "sdk-authoring", "sdk-live", "runner", "direct"),
|
||||
default="all",
|
||||
)
|
||||
parser.add_argument("--exe", type=Path)
|
||||
@@ -762,10 +797,10 @@ def main() -> None:
|
||||
parser.error("--scenario sdk-profile-plugin requires --installed-wheel")
|
||||
if args.installed_wheel:
|
||||
args.exe = assert_installed_wheel_environment()
|
||||
if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-minimal-in-history", "sdk-dynamic-tools", "sdk-fs-search", "sdk-spawn-node", "sdk-snapshot", "sdk-restart", "sdk-office", "sdk-authoring", "runner", "direct"} and args.exe is None:
|
||||
parser.error("--exe is required for custom, minimal, dynamic-tools, fs-search, spawn-node, snapshot, restart, office, runner, and direct scenarios")
|
||||
if args.update_snapshots and args.scenario not in {"all", "sdk-minimal", "sdk-minimal-in-history", "sdk-dynamic-tools", "sdk-snapshot", "sdk-restart", "sdk-authoring"}:
|
||||
parser.error("--update-snapshots requires --scenario sdk-minimal, sdk-minimal-in-history, sdk-dynamic-tools, sdk-snapshot, sdk-restart, sdk-authoring, or all")
|
||||
if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-minimal-in-history", "sdk-dynamic-tools", "sdk-fs-search", "sdk-spawn-node", "sdk-snapshot", "sdk-recovery", "sdk-restart", "sdk-office", "sdk-authoring", "runner", "direct"} and args.exe is None:
|
||||
parser.error("--exe is required for custom, minimal, dynamic-tools, fs-search, spawn-node, snapshot, recovery, restart, office, runner, and direct scenarios")
|
||||
if args.update_snapshots and args.scenario not in {"all", "sdk-minimal", "sdk-minimal-in-history", "sdk-dynamic-tools", "sdk-snapshot", "sdk-recovery", "sdk-restart", "sdk-authoring"}:
|
||||
parser.error("--update-snapshots requires --scenario sdk-minimal, sdk-minimal-in-history, sdk-dynamic-tools, sdk-snapshot, sdk-recovery, sdk-restart, sdk-authoring, or all")
|
||||
if args.exe is not None and not args.exe.is_file():
|
||||
parser.error(f"runtime executable does not exist: {args.exe}")
|
||||
|
||||
@@ -817,6 +852,9 @@ def main() -> None:
|
||||
if args.scenario in {"all", "sdk-snapshot"}:
|
||||
assert args.exe is not None
|
||||
smoke_sdk_snapshot(model.url, args.exe.resolve(), args.update_snapshots)
|
||||
if args.scenario in {"all", "sdk-snapshot", "sdk-recovery"}:
|
||||
assert args.exe is not None
|
||||
smoke_sdk_scheduler_recovery(model.url, args.exe.resolve(), args.update_snapshots)
|
||||
if args.scenario in {"all", "sdk-restart"}:
|
||||
assert args.exe is not None
|
||||
smoke_sdk_restart_snapshot(model.url, args.exe.resolve(), args.update_snapshots)
|
||||
@@ -1641,6 +1679,94 @@ def smoke_sdk_restart_snapshot(base_url: str, executable: Path, update_snapshots
|
||||
)
|
||||
|
||||
|
||||
def smoke_sdk_scheduler_recovery(base_url: str, executable: Path, update_snapshots: bool) -> None:
|
||||
"""Keep a failed tool turn usable through the real Messages serializer and SDK."""
|
||||
from deepseek_harness import DeepSeekHarness
|
||||
|
||||
first_request = len(MockModelHandler.requests)
|
||||
with tempfile.TemporaryDirectory(prefix="dsh-sdk-scheduler-recovery-") as temporary:
|
||||
root = Path(temporary).resolve()
|
||||
dsh_home = root / "home"
|
||||
sessions = dsh_home / "sessions"
|
||||
base_patch = write_advanced_profile_patch(root, "base.patch.yml", sessions)
|
||||
patch = write_profile_patch(root, "recovery.patch.yml", sessions, [
|
||||
{"id": "tool-todo", "disabled": False},
|
||||
{"id": "session-title-llm", "disabled": True},
|
||||
{"id": "session-log-deepseek", "config": {"enabled": False}},
|
||||
{"insert": [{
|
||||
"id": "scheduler-failure-fixture",
|
||||
"name": (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "snapshots/sdk/tool-scheduler-recovery/scheduler-failure.mjs"
|
||||
).as_uri(),
|
||||
}]},
|
||||
])
|
||||
|
||||
def connect() -> DeepSeekHarness:
|
||||
return DeepSeekHarness(
|
||||
provider="deepseek-official", model="smoke-model", cwd=str(root),
|
||||
dsh_bin=str(executable), dsh_home=str(dsh_home),
|
||||
patches=(str(base_patch), str(patch)),
|
||||
env={"DSH_PERMISSION_MODE": "danger-full-access", "DSH_TELEMETRY_DISABLED": "1"},
|
||||
api_key="sk-keyless-smoke", base_url=base_url, request_timeout_seconds=60,
|
||||
)
|
||||
|
||||
with connect() as harness:
|
||||
failed = harness.run(RECOVERY_PROMPT, session_id=RECOVERY_SESSION_ID)
|
||||
continued = harness.run(RECOVERY_CONTINUE_PROMPT, session_id=RECOVERY_SESSION_ID)
|
||||
results = [failed, continued]
|
||||
if [result.finish_reason for result in results] != ["error", "completed"]:
|
||||
outcomes = [
|
||||
event["data"]["reason"] for result in results
|
||||
for event in result.events if event.get("type") == "turn/end"
|
||||
]
|
||||
raise AssertionError(f"scheduler recovery did not preserve turn outcomes: {outcomes}")
|
||||
if continued.final_response != RECOVERY_TEXT:
|
||||
raise AssertionError("scheduler recovery lost the continued response")
|
||||
requests = MockModelHandler.requests[first_request:]
|
||||
if len(requests) != 2:
|
||||
raise AssertionError(f"scheduler recovery expected two serialized model requests: {requests}")
|
||||
logs = read_session_logs(sessions)
|
||||
records = logs[RECOVERY_SESSION_ID]
|
||||
calls = [record["data"]["callId"] for record in records if record.get("type") == "tool/call"]
|
||||
if calls != list(RECOVERY_CALL_IDS[:2]):
|
||||
raise AssertionError(f"scheduler recovery dispatched the unstarted call: {calls}")
|
||||
tool_results = [
|
||||
record["data"]["message"]
|
||||
for record in records if record.get("type") == "tool/result"
|
||||
]
|
||||
if [(message.get("role"), message.get("toolCallId"), message.get("isError", False)) for message in tool_results] != [
|
||||
("tool", RECOVERY_CALL_IDS[0], False), ("tool", RECOVERY_CALL_IDS[1], True), ("tool", RECOVERY_CALL_IDS[2], True),
|
||||
]:
|
||||
raise AssertionError(f"scheduler recovery duplicated or lost tool outcomes: {tool_results}")
|
||||
if sum(record.get("type") == "todo/write" for record in records) != 1:
|
||||
raise AssertionError("scheduler recovery executed an unstarted todo update")
|
||||
replacements = [(str(root), "{{cwd}}"), (RECOVERY_SESSION_ID, "{{parent}}")]
|
||||
result_value = [{
|
||||
"session_id": result.session_id,
|
||||
"final_response": result.final_response,
|
||||
"finish_reason": result.finish_reason,
|
||||
"eventTypes": [event.get("type") for event in result.events],
|
||||
"notificationMethods": [notification.method for notification in result.notifications],
|
||||
} for result in results]
|
||||
request_value = [{
|
||||
"model": request.get("model"),
|
||||
"messages": restart_request_messages(request),
|
||||
} for request in requests]
|
||||
normalized_records = project_session_snapshot([
|
||||
normalize_snapshot_value(record, replacements) for record in records
|
||||
])
|
||||
content = render_jsonl(normalized_records)
|
||||
files = {
|
||||
"result.json": json.dumps(normalize_snapshot_value(result_value, replacements), indent=2) + "\n",
|
||||
"requests.json": json.dumps(normalize_snapshot_value(request_value, replacements), indent=2) + "\n",
|
||||
snapshot_session_filename(0, session_header_version(content, "scheduler recovery")): content,
|
||||
}
|
||||
compare_snapshot_files(
|
||||
files, update_snapshots, RECOVERY_SNAPSHOT_DIRECTORY, RECOVERY_SNAPSHOT_FILENAMES,
|
||||
)
|
||||
|
||||
|
||||
def smoke_direct(base_url: str, executable: Path) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="dsh-direct-") as temporary:
|
||||
root = Path(temporary).resolve()
|
||||
@@ -2433,7 +2559,19 @@ def normalize_snapshot_value(
|
||||
if isinstance(dt, list):
|
||||
member["dt"] = [0] * len(dt)
|
||||
if isinstance(normalized.get("id"), str) and normalized.get("role") in ("assistant", "system", "user", "tool", "developer"):
|
||||
if not normalized["id"].startswith("{{message:"):
|
||||
# Replay fixtures retain deterministic repair ids with the call id and original event sequence.
|
||||
source = normalized.get("source")
|
||||
call_id = normalized.get("toolCallId") if normalized.get("role") == "tool" else (
|
||||
source.get("callId") if isinstance(source, dict) and source.get("kind") == "tool" else None
|
||||
)
|
||||
repair_prefix = (
|
||||
f"interrupted-tool-result-{call_id}-" if isinstance(call_id, str) else None
|
||||
)
|
||||
repair_id = (
|
||||
repair_prefix is not None and normalized["id"].startswith(repair_prefix)
|
||||
and normalized["id"][len(repair_prefix):].isdigit()
|
||||
)
|
||||
if not normalized["id"].startswith("{{message:") and not repair_id:
|
||||
normalized["id"] = "{{messageId}}"
|
||||
if normalized.get("type") in ("feedback/message-put", "feedback/message-delete"):
|
||||
data = normalized.get("data")
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
[
|
||||
{
|
||||
"model": "smoke-model",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Run three todo updates; the scheduler will reject the second call."
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically \u2014 do not request sandbox escalation (do not set `sandbox_permissions`)."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"model": "smoke-model",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Run three todo updates; the scheduler will reject the second call."
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically \u2014 do not request sandbox escalation (do not set `sandbox_permissions`)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "scheduler-complete",
|
||||
"name": "todo_write",
|
||||
"input": {
|
||||
"todos": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "scheduler-fail",
|
||||
"name": "todo_write",
|
||||
"input": {
|
||||
"todos": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "scheduler-unstarted",
|
||||
"name": "todo_write",
|
||||
"input": {
|
||||
"todos": []
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "scheduler-complete",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Updated todo list: 0 pending, 0 in progress, 0 completed."
|
||||
}
|
||||
],
|
||||
"is_error": false
|
||||
},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "scheduler-fail",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."
|
||||
}
|
||||
],
|
||||
"is_error": true
|
||||
},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "scheduler-unstarted",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed."
|
||||
}
|
||||
],
|
||||
"is_error": true
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Continue after the scheduler failure without repeating any tools."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,78 @@
|
||||
[
|
||||
{
|
||||
"session_id": "{{parent}}",
|
||||
"final_response": "",
|
||||
"finish_reason": "error",
|
||||
"eventTypes": [
|
||||
"agent/inbox/spliced",
|
||||
"turn/start",
|
||||
"agent/inbox/spliced",
|
||||
"step/start",
|
||||
"system/message",
|
||||
"user/message",
|
||||
"user/message",
|
||||
"request/header",
|
||||
"request/context",
|
||||
"session/title",
|
||||
"assistant/message",
|
||||
"tool/call",
|
||||
"todo/write",
|
||||
"tool/result",
|
||||
"tool/call",
|
||||
"tool/result",
|
||||
"tool/result",
|
||||
"step/end",
|
||||
"turn/end"
|
||||
],
|
||||
"notificationMethods": [
|
||||
"session.event",
|
||||
"session.status",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.status"
|
||||
]
|
||||
},
|
||||
{
|
||||
"session_id": "{{parent}}",
|
||||
"final_response": "SCHEDULER_RECOVERY_OK",
|
||||
"finish_reason": "completed",
|
||||
"eventTypes": [
|
||||
"agent/inbox/spliced",
|
||||
"turn/start",
|
||||
"agent/inbox/spliced",
|
||||
"step/start",
|
||||
"user/message",
|
||||
"assistant/message",
|
||||
"step/end",
|
||||
"turn/end"
|
||||
],
|
||||
"notificationMethods": [
|
||||
"session.event",
|
||||
"session.status",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.event",
|
||||
"session.status"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
{"type":"session","version":3,"id":"{{parent}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
|
||||
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
|
||||
{"type":"sandbox/mode","data":{"mode":"danger-full-access"}}
|
||||
{"type":"approval/policy","data":{"policy":"never"}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run three todo updates; the scheduler will reject the second call."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}}
|
||||
{"type":"turn/start","data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","data":{"turn":1,"step":1}}
|
||||
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{messageId}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Run three todo updates; the scheduler will reject the second call."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["cordis_inspect_list","cordis_inspect_query","job_kill","job_list","job_output","run_code","subagent","todo_write","workflow"]},"reason":"initial"}}
|
||||
{"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}}
|
||||
{"type":"session/title","data":{"title":"Run three todo updates; the","messageSeqs":[8],"source":{"kind":"fallback"}}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"scheduler-complete","name":"todo_write","arguments":"{\"todos\": []}"},{"type":"tool-call","id":"scheduler-fail","name":"todo_write","arguments":"{\"todos\": []}"},{"type":"tool-call","id":"scheduler-unstarted","name":"todo_write","arguments":"{\"todos\": []}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"},{"type":"tool-call"},{"type":"tool-call"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"scheduler-complete","name":"todo_write","args":[""]},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"scheduler-complete","args":["{\"todos\": []}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"scheduler-complete","name":"todo_write","arguments":"{\"todos\": []}"}}},{"type":"chunk","time":0,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":1,"dt":[],"id":"scheduler-fail","name":"todo_write","args":[""]},{"type":"tool-call-chunks","time0":0,"index":1,"dt":[],"id":"scheduler-fail","args":["{\"todos\": []}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"scheduler-fail","name":"todo_write","arguments":"{\"todos\": []}"}}},{"type":"chunk","time":0,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":2,"dt":[],"id":"scheduler-unstarted","name":"todo_write","args":[""]},{"type":"tool-call-chunks","time0":0,"index":2,"dt":[],"id":"scheduler-unstarted","args":["{\"todos\": []}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"scheduler-unstarted","name":"todo_write","arguments":"{\"todos\": []}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"},{"type":"tool-call"},{"type":"tool-call"}]}}}]},"surfaceOp":"append"}
|
||||
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"scheduler-complete","name":"todo_write","arguments":"{\"todos\": []}"}}
|
||||
{"type":"todo/write","data":{"todos":[]}}
|
||||
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"scheduler-complete"},"content":[{"type":"tool-result","toolCallId":"scheduler-complete","content":[{"type":"text","text":"Updated todo list: 0 pending, 0 in progress, 0 completed."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
|
||||
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"scheduler-fail","name":"todo_write","arguments":"{\"todos\": []}"}}
|
||||
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"id":"interrupted-tool-result-scheduler-fail-18","role":"user","source":{"kind":"tool","callId":"scheduler-fail"},"content":[{"type":"tool-result","toolCallId":"scheduler-fail","isError":true,"content":[{"type":"text","text":"The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."}]}]},"error":{"name":"ToolOutcomeUnknownError","code":"TOOL_OUTCOME_UNKNOWN"}},"sourceEventSeqs":[17],"surfaceOp":"append"}
|
||||
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"id":"interrupted-tool-result-scheduler-unstarted-19","role":"user","source":{"kind":"tool","callId":"scheduler-unstarted"},"content":[{"type":"tool-result","toolCallId":"scheduler-unstarted","isError":true,"content":[{"type":"text","text":"The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed."}]}]},"error":{"name":"ToolNotStartedError","code":"TOOL_NOT_STARTED"}},"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"error","error":{"message":"Snapshot scheduler preparation failed","code":"UNKNOWN"}}}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Continue after the scheduler failure without repeating any tools."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}}
|
||||
{"type":"turn/start","data":{"turn":2}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","data":{"turn":2,"step":1}}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Continue after the scheduler failure without repeating any tools."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SCHEDULER_RECOVERY_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["SCHEDULER_RECOVERY_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SCHEDULER_RECOVERY_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}}}]},"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":2,"step":1}}
|
||||
{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,31 @@
|
||||
{"type":"session","version":4,"id":"{{parent}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
|
||||
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
|
||||
{"type":"sandbox/mode","data":{"mode":"danger-full-access"}}
|
||||
{"type":"approval/policy","data":{"policy":"never"}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run three todo updates; the scheduler will reject the second call."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}}
|
||||
{"type":"turn/start","data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","data":{"turn":1,"step":1}}
|
||||
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{messageId}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Run three todo updates; the scheduler will reject the second call."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["cordis_inspect_list","cordis_inspect_query","job_kill","job_list","job_output","run_code","subagent","todo_write","workflow"]},"reason":"initial"}}
|
||||
{"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}}
|
||||
{"type":"session/title","data":{"title":"Run three todo updates; the","messageSeqs":[8],"source":{"kind":"fallback"}}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"scheduler-complete","name":"todo_write","arguments":"{\"todos\": []}"},{"type":"tool-call","id":"scheduler-fail","name":"todo_write","arguments":"{\"todos\": []}"},{"type":"tool-call","id":"scheduler-unstarted","name":"todo_write","arguments":"{\"todos\": []}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"},{"type":"tool-call"},{"type":"tool-call"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"scheduler-complete","name":"todo_write","args":[""]},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"scheduler-complete","args":["{\"todos\": []}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"scheduler-complete","name":"todo_write","arguments":"{\"todos\": []}"}}},{"type":"chunk","time":0,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":1,"dt":[],"id":"scheduler-fail","name":"todo_write","args":[""]},{"type":"tool-call-chunks","time0":0,"index":1,"dt":[],"id":"scheduler-fail","args":["{\"todos\": []}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"scheduler-fail","name":"todo_write","arguments":"{\"todos\": []}"}}},{"type":"chunk","time":0,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":2,"dt":[],"id":"scheduler-unstarted","name":"todo_write","args":[""]},{"type":"tool-call-chunks","time0":0,"index":2,"dt":[],"id":"scheduler-unstarted","args":["{\"todos\": []}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"scheduler-unstarted","name":"todo_write","arguments":"{\"todos\": []}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"},{"type":"tool-call"},{"type":"tool-call"}]}}}]},"surfaceOp":"append"}
|
||||
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"scheduler-complete","name":"todo_write","arguments":"{\"todos\": []}"}}
|
||||
{"type":"todo/write","data":{"todos":[]}}
|
||||
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"scheduler-complete"},"toolCallId":"scheduler-complete","content":[{"type":"text","text":"Updated todo list: 0 pending, 0 in progress, 0 completed."}],"isError":false,"id":"{{messageId}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
|
||||
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"scheduler-fail","name":"todo_write","arguments":"{\"todos\": []}"}}
|
||||
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"id":"interrupted-tool-result-scheduler-fail-18","role":"tool","toolCallId":"scheduler-fail","isError":true,"source":{"kind":"tool","callId":"scheduler-fail"},"content":[{"type":"text","text":"The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."}]},"error":{"name":"ToolOutcomeUnknownError","code":"TOOL_OUTCOME_UNKNOWN"}},"sourceEventSeqs":[17],"surfaceOp":"append"}
|
||||
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"id":"interrupted-tool-result-scheduler-unstarted-19","role":"tool","toolCallId":"scheduler-unstarted","isError":true,"source":{"kind":"tool","callId":"scheduler-unstarted"},"content":[{"type":"text","text":"The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed."}]},"error":{"name":"ToolNotStartedError","code":"TOOL_NOT_STARTED"}},"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"error","error":{"message":"Snapshot scheduler preparation failed","code":"UNKNOWN"}}}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Continue after the scheduler failure without repeating any tools."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}}
|
||||
{"type":"turn/start","data":{"turn":2}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","data":{"turn":2,"step":1}}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Continue after the scheduler failure without repeating any tools."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SCHEDULER_RECOVERY_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["SCHEDULER_RECOVERY_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SCHEDULER_RECOVERY_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}}}]},"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":2,"step":1}}
|
||||
{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user