Merge remote-tracking branch 'origin/master' into fix/web-sources-scroll
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md
|
||||
2026-07-31-fail-loud-releases-the-terminal.md: 2a6e7fcbbdd5d35bcf70dee09fdb9e5592486b78
|
||||
2026-07-31-fail-loud-releases-the-terminal.zh.md: f75c21cf79b241e6714c10ec7df9ac25f3d978b4
|
||||
@@ -0,0 +1,59 @@
|
||||
# Agent Note: fail-loud releases the terminal before exiting
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-31-fail-loud-releases-the-terminal.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A `dsh` launch whose config failed validation printed its diagnostic and returned the user to a broken shell. Typing was invisible, and the next command was mangled by stray text:
|
||||
|
||||
```
|
||||
dsh: fatal load failure: ValidationError: invalid config:
|
||||
- $.providers expected object but got [object Object] (at providers)
|
||||
$ 1;2;4cecho hello
|
||||
zsh: command not found: 4cecho
|
||||
```
|
||||
|
||||
The Loader mounts entries concurrently, so entry failure order is not startup order. `ui-tui` activates and calls pi-tui's `ProcessTerminal.start()`, which puts stdin in raw mode, enables bracketed paste, and writes the Kitty keyboard-protocol probe — a sequence ending in a Device Attributes query (`ESC [ c`). A sibling entry (here `llm-pi-ai`) then rejects on its own config. At the time, that rejection surfaced as an unhandled rejection, and `installFailLoud` wrote one stderr line and called `process.exit(1)` immediately. (The transactional Loader now settles config-tree failures through `boot()`, which disposes the partial context itself; the release hook remains the guard for rejections `boot()` cannot see — a plugin's detached async work rejecting during or after mounting.)
|
||||
|
||||
Nothing disposed the tree, so `ProcessTerminal.stop()` never ran: raw mode, bracketed paste, and the keyboard protocol stayed set on the shell that outlived the process. The terminal's answer to the Device Attributes query (`1;2;4c`) arrived after exit and was read by the shell as typed input — the literal text above.
|
||||
|
||||
The `/exit` path was never affected, because it disposes the tree and reaches the TUI's own `shutdown()`, which calls `drainInput()` (absorbing the pending reply) and then `ui.stop()`. The defect was that a *failed boot* had no path to that same teardown.
|
||||
|
||||
## Decision
|
||||
|
||||
`installFailLoud` takes an optional `release` teardown, awaited between the diagnostic and the exit:
|
||||
|
||||
- The diagnostic is written **before** the release, so a hanging or failing disposer cannot swallow the reason.
|
||||
- A latch, not an uninstall, keeps the first rejection the reported one. Removing the listener during teardown would let a second concurrent rejection become uncaught, and Node would kill the process mid-teardown — stranding exactly the terminal state this restores. Later rejections, including the release's own, fall through to the pending exit.
|
||||
- The release is bounded by `FAIL_LOUD_RELEASE_TIMEOUT_MS` (2s) and its rejection is swallowed. A wedged or failing disposer delays the fatal exit; it never cancels it. That timer stays **referenced**: an `unref()`ed one lets Node reach an empty event loop and exit 0 on the very failure being reported, because an `unhandledRejection` listener suppresses the default fatal exit.
|
||||
- Omitting `release` keeps the previous behavior exactly, so the ACP, JSON-RPC, and demo bins are unchanged.
|
||||
|
||||
`dsh`'s TUI launcher passes a release that disposes the root context, which runs the TUI's existing `shutdown()` and hands the terminal back.
|
||||
|
||||
The launcher captures the root context in `boot()`'s `prepare` hook rather than from its return value. The rejection arrives while `boot()` is still in flight, so `app.current` assigned after the `await` would still be `undefined` at exactly the moment the hook needs it. `prepare` runs after the Loader installs and before any config-tree entry mounts, which covers the whole window in which an entry can reject.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Reset the terminal from the fail-loud handler** (write `ESC [ ? 2004 l`, pop the keyboard protocol, clear raw mode). This duplicates pi-tui's teardown in a package that owns no terminal, and would drift as pi-tui's startup sequence changes. It also cannot absorb the in-flight Device Attributes reply, which is what corrupts the next prompt — only draining stdin while it is still raw does that.
|
||||
|
||||
**Register a `process.on('exit')` terminal reset in the TUI.** Exit handlers are synchronous, so they cannot await `drainInput()`; the stray reply would still land. It also puts teardown on a global hook rather than the disposal path that already exists.
|
||||
|
||||
**Have the TUI refuse to start until the tree settles.** This serializes a deliberately concurrent Loader and delays first paint for every healthy launch to fix a failure path.
|
||||
|
||||
**Reorder config entries so `llm-pi-ai` mounts before `ui-tui`.** Ordering is not a guarantee the Loader makes, and any future entry could fail after the TUI mounts.
|
||||
|
||||
## Consequences
|
||||
|
||||
A failed boot now costs one tree disposal (bounded at 2s) before exit, and the exit code stays 1. In exchange, a misconfigured `dsh` returns a usable shell instead of one needing `stty sane` or `reset`.
|
||||
|
||||
The guarantee belongs to whichever bin owns the terminal: a surface that grabs terminal state and does not pass `release` reintroduces this defect. `installFailLoud` cannot detect that on its own, since it has no view of what a mounted plugin did to the process.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/app-boot/tests/app-boot.spec.ts` covers the release contract: the hook is awaited before the exit commits, a rejecting hook still exits 1, a never-settling hook exits after `FAIL_LOUD_RELEASE_TIMEOUT_MS`, and a burst of rejections reports only the first while the release still completes.
|
||||
|
||||
Those fake-process tests cannot observe the two failure modes that matter most — process exit code with a real event loop, and terminal state after exit — so the regression lives in `apps/cli/tests/tui-keyless-smoke.e2e.ts`. It boots the shipped tree in a real PTY over `fixtures/tui-invalid-provider.cordis.yml` (a list-shaped `providers`, the mistake users actually make), expects exit 1, and asserts the captured bytes contain both the labelled boot rejection (`dsh: plugin tree failed to load:`) and `ESC[?2004l`. The same case pins the boot path end to end: it caught the [HMR initial-scan boot deadlock](2026-08-03-hmr-initial-scan-boot-deadlock.md) that silently exited 13 with the terminal stranded.
|
||||
|
||||
Testing policy requires a PTY case whenever terminal teardown changes, and this is it. The `/exit` path keeps its existing assertion that the same reset appears on a clean exit.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Agent Note:fail-loud 在退出前释放终端
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-31-fail-loud-releases-the-terminal.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
配置校验失败的 `dsh` 启动会打印诊断信息,然后把用户丢回一个损坏的 shell:输入不可见,下一条命令还会被残留文本弄乱:
|
||||
|
||||
```
|
||||
dsh: fatal load failure: ValidationError: invalid config:
|
||||
- $.providers expected object but got [object Object] (at providers)
|
||||
$ 1;2;4cecho hello
|
||||
zsh: command not found: 4cecho
|
||||
```
|
||||
|
||||
Loader 并发挂载各个条目,因此条目失败的顺序并不等于启动顺序。`ui-tui` 会先激活并调用 pi-tui 的 `ProcessTerminal.start()`,它把 stdin 置为 raw 模式、启用 bracketed paste,并写出 Kitty 键盘协议探测序列——该序列以一个 Device Attributes 查询(`ESC [ c`)结尾。随后某个同级条目(这里是 `llm-pi-ai`)因自身配置而 rejection。
|
||||
|
||||
在当时,该 rejection 以未处理 rejection 的形式浮现,而 `installFailLoud` 只写一行 stderr 就立即调用 `process.exit(1)`。(事务化 Loader 现在让配置树失败经 `boot()` 结算,由它自行释放部分构建的上下文;release 回调仍然守护 `boot()` 看不到的 rejection——插件游离的异步工作在挂载期间或挂载之后失败。)没有任何环节释放这棵树,因此 `ProcessTerminal.stop()` 从未执行:raw 模式、bracketed paste 和键盘协议都残留在比进程活得更久的 shell 上。终端对 Device Attributes 查询的回应(`1;2;4c`)在进程退出之后才到达,被 shell 当作用户输入读入——也就是上面那段字面文本。
|
||||
|
||||
`/exit` 路径从不受影响,因为它会释放整棵树,从而进入 TUI 自身的 `shutdown()`:先 `drainInput()`(吸收尚未返回的响应),再 `ui.stop()`。缺陷在于**启动失败**没有通往这同一套拆卸流程的路径。
|
||||
|
||||
## Decision
|
||||
|
||||
`installFailLoud` 新增可选的 `release` 拆卸回调,在诊断信息与退出之间被等待:
|
||||
|
||||
- 诊断信息在 release **之前**写出,因此卡住或失败的 disposer 无法吞掉失败原因。
|
||||
- 使用闩锁(latch)而非卸载监听器,来保证被报告的始终是第一个 rejection。若在拆卸期间移除监听器,第二个并发 rejection 就会变成未捕获错误,Node 会在拆卸中途杀死进程——恰好残留下本次要恢复的终端状态。后续 rejection(包括 release 自身的)都会落入已挂起的退出流程。
|
||||
- release 以 `FAIL_LOUD_RELEASE_TIMEOUT_MS`(2 秒)为上限,且其 rejection 被吞掉。卡住或失败的 disposer 只会延迟致命退出,绝不会取消它。该定时器保持 **referenced**:一旦 `unref()`,Node 就会在事件循环清空后、恰恰在报告这次失败时以 0 退出,因为 `unhandledRejection` 监听器抑制了默认的致命退出。
|
||||
- 不传 `release` 时行为与此前完全一致,因此 ACP、JSON-RPC 和各 demo bin 均无变化。
|
||||
|
||||
`dsh` 的 TUI 启动器传入的 release 会释放根上下文,从而执行 TUI 已有的 `shutdown()` 并把终端交还。
|
||||
|
||||
启动器在 `boot()` 的 `prepare` 回调中捕获根上下文,而不是取其返回值。rejection 到达时 `boot()` 尚未结算,因此在 `await` 之后赋值的 `app.current` 恰好在回调需要它的那一刻仍是 `undefined`。`prepare` 在 Loader 安装之后、任何配置树条目挂载之前运行,覆盖了条目可能 rejection 的整个窗口。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**在 fail-loud 处理函数里直接重置终端**(写 `ESC [ ? 2004 l`、弹出键盘协议、清除 raw 模式)。这会在一个并不拥有终端的包里重复 pi-tui 的拆卸逻辑,并随 pi-tui 启动序列的变化而漂移。它同样无法吸收尚未返回的 Device Attributes 响应——而这正是弄乱下一个提示符的原因,只有在 stdin 仍处于 raw 模式时排空它才能解决。
|
||||
|
||||
**在 TUI 中注册 `process.on('exit')` 终端重置。** exit 处理函数是同步的,无法等待 `drainInput()`,残留响应依旧会落到 shell;而且这把拆卸挂到全局钩子上,而非已经存在的释放路径。
|
||||
|
||||
**让 TUI 等整棵树结算后再启动。** 这会把刻意并发的 Loader 串行化,并为修复一条失败路径而拖慢每一次正常启动的首次绘制。
|
||||
|
||||
**调整配置顺序,让 `llm-pi-ai` 先于 `ui-tui` 挂载。** 顺序并不是 Loader 提供的保证,而且未来任何条目都可能在 TUI 挂载之后失败。
|
||||
|
||||
## Consequences
|
||||
|
||||
启动失败现在会在退出前多付出一次树释放的代价(上限 2 秒),退出码仍为 1。作为交换,配置错误的 `dsh` 会交还一个可用的 shell,而不是需要 `stty sane` 或 `reset` 才能恢复的终端。
|
||||
|
||||
这项保证属于**拥有终端的那个 bin**:任何抢占终端状态却不传 `release` 的界面都会重新引入该缺陷。`installFailLoud` 自身无法察觉这一点,因为它看不到已挂载的插件对进程做了什么。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/app-boot/tests/app-boot.spec.ts` 覆盖 release 契约:退出提交前会等待该回调;回调 rejection 时仍退出 1;永不结算的回调会在 `FAIL_LOUD_RELEASE_TIMEOUT_MS` 后退出;以及一连串 rejection 只报告第一个,同时 release 仍能跑完。
|
||||
|
||||
这些基于假进程的测试无法观测到最关键的两种失败形态——真实事件循环下的进程退出码,以及退出之后的终端状态——因此回归用例放在 `apps/cli/tests/tui-keyless-smoke.e2e.ts`。它在真实 PTY 中以 `fixtures/tui-invalid-provider.cordis.yml`(`providers` 为列表形状,正是用户真实会犯的错误)启动出厂配置树,期望退出码为 1,并断言捕获到的字节流同时包含带标签的启动 rejection(`dsh: plugin tree failed to load:`)与 `ESC[?2004l`。同一用例端到端钉住了启动路径:正是它发现了以 13 静默退出、终端状态被残留的 [HMR 初始扫描启动死锁](2026-08-03-hmr-initial-scan-boot-deadlock.md)。
|
||||
|
||||
测试规范要求:只要改动终端拆卸,就必须有 PTY 用例——这就是它。`/exit` 路径保留其原有断言,确认正常退出时同样会出现该重置序列。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md
|
||||
2026-08-03-hmr-initial-scan-boot-deadlock.md: 4b3e259c216d258c321ab06c41225b33ed240d19
|
||||
2026-08-03-hmr-initial-scan-boot-deadlock.zh.md: ce1bc8396ac6e7fb6ecb1647fe2b29cdc788c7e1
|
||||
@@ -0,0 +1,41 @@
|
||||
# Agent Note: HMR's initial scan deadlocked a failing boot into a silent exit 13
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-03-hmr-initial-scan-boot-deadlock.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A `dsh` launch whose config-tree failed validation exited 13 (unsettled top-level await) with no diagnostic at all, and left the TUI's terminal state stranded on the shell — the exact symptom the [fail-loud release](2026-07-31-fail-loud-releases-the-terminal.md) fixed, reintroduced through a different mechanism after the [transactional config reload](2026-07-20-config-hot-reload-resilience.md).
|
||||
|
||||
Two defects compounded:
|
||||
|
||||
1. **Concurrent Include applies corrupt the transactional group update.** The HMR main watcher's chokidar initial scan re-announces every existing file as `add`. Its `add` for the config file triggered `Include.refresh()` while the Include's initial apply was still in flight (`this.content`, the changed-content dedup key, commits only after apply). Two concurrent `EntryGroup.update` calls on one group interleave create and rollback on the same entries, and the Include fiber never settles — `loader.create` hangs, `boot()` neither resolves nor rejects, and Node exits 13 once the loop drains.
|
||||
2. **Serialized applies alone deadlock the failure rollback.** With Include mutations queued, a failing initial apply rolls back by disposing every mounted entry — including `hmr`, whose teardown drains its refresh tasks. The scan-triggered refresh task sits in the Include queue behind the very apply whose rollback is disposing HMR: rollback waits on HMR, HMR waits on the refresh, the refresh waits on the apply.
|
||||
|
||||
## Decision
|
||||
|
||||
Both halves are fixed in the vendored packages (logged in `vendor/README.md`):
|
||||
|
||||
- `include/src/index.ts` funnels every child-tree mutation — initial apply, refresh, and `internal/update` patch re-application — through one per-Include promise queue. The group's transactional `update` is not reentrant, so serialization is a correctness requirement, not a throughput choice. `refresh()` also reads inside the queue so its changed-content check compares against the predecessor's committed state.
|
||||
- `hmr/src/index.ts` passes `ignoreInitial: true` to the main watcher. The initial scan only re-announces files boot has just consumed; suppressing it removes both the boot-time refresh and the spurious `add` events for already-loaded modules. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a personal config present at registration must apply exactly once.
|
||||
|
||||
With both in place a failing boot follows the intended path: the single apply fails, the rollback disposes the tree (running the TUI's own shutdown, restoring the terminal), `loader.create` rejects, and `boot()` rethrows the labelled diagnostic with exit 1.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Only `ignoreInitial: true`.** Removes the trigger but leaves the corruption: any genuinely concurrent refresh (a config edit racing a slow apply) still interleaves two group updates and strands the fiber.
|
||||
|
||||
**Only serialization.** Converts the corruption into the rollback deadlock described above; the process still exits 13 silently.
|
||||
|
||||
**Cancel queued refreshes on HMR teardown.** Requires cancellation plumbing through `refreshConfig`'s task loop and the Include queue for a case `ignoreInitial` already removes from every boot; not worth the machinery until a real trigger remains.
|
||||
|
||||
## Consequences
|
||||
|
||||
A config file edit landing inside the watcher's startup scan window is now picked up by the next `change` event rather than the scan itself; steady-state reload behavior is unchanged.
|
||||
|
||||
One latent gap remains: a config edit made during a *failing* initial apply can still queue a refresh that the rollback's HMR teardown waits on — the same deadlock shape with a human-scale trigger window of one failing boot. If that ever bites, the fix is refresh-task cancellation at HMR teardown.
|
||||
|
||||
## Testing
|
||||
|
||||
The `dsh` invalid-provider PTY case in `apps/cli/tests/tui-keyless-smoke.e2e.ts` pins the end-to-end contract: exit 1, the labelled `dsh: plugin tree failed to load:` diagnostic naming `$.providers`, and the bracketed-paste reset proving the tree was disposed. Before this fix the same case observed exit 13 with no diagnostic. Reload behavior stays covered by `packages/ui/app-boot/tests/config-reload.spec.ts` and `packages/ui/app-boot/tests/hmr-config.spec.ts`.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Agent Note:HMR 初始扫描使失败的启动死锁为静默的 exit 13
|
||||
|
||||
状态:已实现
|
||||
|
||||
[English](2026-08-03-hmr-initial-scan-boot-deadlock.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
当 `dsh` 启动时配置树校验失败,进程以 13 退出(未结算的顶层 await),不输出任何诊断,并把 TUI 的终端状态残留在 shell 上——这正是 [fail-loud release](2026-07-31-fail-loud-releases-the-terminal.md) 修复过的症状,在[事务化配置重载](2026-07-20-config-hot-reload-resilience.md)之后经由另一条机制重新出现。
|
||||
|
||||
两个缺陷叠加:
|
||||
|
||||
1. **并发的 Include apply 破坏事务化的 group update。** HMR 主 watcher 的 chokidar 初始扫描会把每个已存在的文件重新宣告为 `add`。其中配置文件的 `add` 在 Include 的首次 apply 尚未结束时触发了 `Include.refresh()`(内容去重键 `this.content` 只在 apply 完成后才提交)。同一 group 上两个并发的 `EntryGroup.update` 会在相同条目上交错执行 create 与回滚,导致 Include fiber 永远无法结算:`loader.create` 挂起,`boot()` 既不 resolve 也不 reject,事件循环排空后 Node 以 13 退出。
|
||||
2. **仅序列化 apply 会让失败回滚死锁。** 将 Include 的变更排入队列后,首次 apply 失败时的回滚会释放每个已挂载条目——包括 `hmr`,而它的拆卸会等待自身的 refresh 任务排空。扫描触发的 refresh 任务正排在 Include 队列中、位于正在回滚的那次 apply 之后:回滚等 HMR,HMR 等 refresh,refresh 等 apply。
|
||||
|
||||
## 决定
|
||||
|
||||
两处修复都落在 vendored 包中(记录于 `vendor/README.md`):
|
||||
|
||||
- `include/src/index.ts` 将每次子树变更——首次 apply、refresh、`internal/update` 补丁重应用——汇入每个 Include 一条的 promise 队列。group 的事务化 `update` 不可重入,因此序列化是正确性要求,而不是吞吐取舍。`refresh()` 也在队列内读取文件,使其内容变更判断与前一任务提交后的状态比较。
|
||||
- `hmr/src/index.ts` 给主 watcher 传入 `ignoreInitial: true`。初始扫描只会重新宣告启动刚刚消费过的文件;抑制它同时消除了启动期 refresh 和对已加载模块的多余 `add` 事件。`registerConfig()` 保留自己 `ignoreInitial: false` 的 watcher,因为注册时已存在的个人配置必须恰好应用一次。
|
||||
|
||||
两者齐备后,失败的启动走上预期路径:唯一一次 apply 失败,回滚释放整棵树(执行 TUI 自身的 shutdown、恢复终端),`loader.create` reject,`boot()` 重新抛出带标签的诊断并以 1 退出。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**只加 `ignoreInitial: true`。** 消除了触发条件,但保留了破坏本身:任何真正并发的 refresh(配置编辑与缓慢的 apply 竞争)仍会交错两次 group update 并使 fiber 悬置。
|
||||
|
||||
**只做序列化。** 把破坏转化为上述回滚死锁;进程仍然静默地以 13 退出。
|
||||
|
||||
**在 HMR 拆卸时取消排队中的 refresh。** 需要在 `refreshConfig` 的任务循环和 Include 队列中铺设取消机制,而 `ignoreInitial` 已把该场景从每次启动中移除;在真实触发条件出现之前不值得引入这套机构。
|
||||
|
||||
## 后果
|
||||
|
||||
落在 watcher 启动扫描窗口内的配置文件编辑,现在由下一个 `change` 事件而非扫描本身拾取;稳态的重载行为不变。
|
||||
|
||||
仍留有一个潜在缺口:在一次*失败的*首次 apply 期间进行的配置编辑,仍可能排入一个被回滚的 HMR 拆卸所等待的 refresh——同样的死锁形态,但触发窗口缩小到一次失败启动的人力尺度。若它真的发生,修复方向是在 HMR 拆卸时取消 refresh 任务。
|
||||
|
||||
## 测试
|
||||
|
||||
`apps/cli/tests/tui-keyless-smoke.e2e.ts` 中 `dsh` 无效 provider 的 PTY 用例钉住了端到端契约:以 1 退出、带标签的 `dsh: plugin tree failed to load:` 诊断指明 `$.providers`、以及证明整棵树已被释放的 bracketed-paste 复位序列。此修复之前,同一用例观察到的是无诊断的 exit 13。重载行为仍由 `packages/ui/app-boot/tests/config-reload.spec.ts` 与 `packages/ui/app-boot/tests/hmr-config.spec.ts` 覆盖。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
|
||||
2026-07-27-web-subagent-conversations.md: b959fd35a4f5e2a6fa68deed8776ccbae86a0647
|
||||
2026-07-27-web-subagent-conversations.zh.md: dc0297d92acb5ab05dcdc5c682fd0a7fe2a2a18a
|
||||
2026-07-27-web-subagent-conversations.md: 03c805d30dbdbbdb33d0b06ba8036ec181035ac6
|
||||
2026-07-27-web-subagent-conversations.zh.md: e83f07fb21f58ad175ab3e5638981648fa4cef05
|
||||
|
||||
@@ -33,7 +33,7 @@ The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Ha
|
||||
| The session header opens a compact child list. | The trigger aggregates the complete subagent-only descendant lineage; the tree shows every direct catalog entry in service order, including disabled diagnostics. |
|
||||
| Selecting a row reuses the conversation UI. | Addressed history never activates the child; only a continuable row with a live parent retains the ordinary composer. |
|
||||
| Nested agents expand progressively. | Each row carries a one-level `hasChildren` snapshot; disclosure reserves known direct-descendant rows immediately, then loads only that row's direct catalog and retains its own parent address. |
|
||||
| Rows show labels, state, and relative time without duplicating sidebar rows. | Mode and `running`/`inactive` activity are textual as well as visual; optional title and time come from summaries. `SessionHeader.origin` removes duplicate navigation rows but grants no capability. |
|
||||
| Rows show labels, state, usage, and active duration without duplicating sidebar rows. | Mode and `running`/`inactive` activity are textual as well as visual; optional title, durable token usage, and active-turn duration come from the list's retained projection values. Compact duration loses smaller units above one day, while hover and accessible naming retain exact whole seconds. `SessionHeader.origin` removes duplicate navigation rows but grants no capability. |
|
||||
|
||||
## Product contract
|
||||
|
||||
@@ -41,6 +41,8 @@ The header action is absent only when a complete empty direct-catalog response a
|
||||
|
||||
`running` means the exact child Agent driver is draining work at the Host sampling boundary; `inactive` means that driver is idle or absent. The UI does not translate either value into success, failure, cancellation, completeness, or resumability. `subagent.list` supplies the current driver-status baseline, `host/session-status` updates known activity in place, request-local replay prevents an older in-flight list response from overwriting a newer transition, and `host/session-removed` returns a known row to `inactive`; reconnect reads a fresh baseline. A `host/session-added` frame for a direct subagent immediately flips any loaded parent row to `hasChildren: true`, and that positive hint survives an older in-flight catalog response; membership, labels, mode, diagnostics, and the authoritative snapshot still require a debounced `subagent.list` refresh while the affected branch is open. A prompt response remains delivery-time authority.
|
||||
|
||||
Healthy rows reuse the standard session projections retained in the list mirror. The token figure sums the four disjoint `tokenUsage` buckets across the durable log. `subagentTiming` resets at every descriptor so an inherited fork seed cannot enter the child's total, accumulates completed `turn/start` → `turn/end` spans, and carries same-cut `active.since` and `active.through` bounds for an open turn. Existing session events advance `active.through` while that turn remains open; the menu adds no separate timer or log read and advances its local clock only while a known descendant is running. Below one day it formats whole seconds; longer visual values retain at most two adjacent units, using approximate 30-day months and 365-day years, while hover and accessible naming preserve the exact day/hour/minute/second duration. An inactive row bounds an interrupted open turn with `active.through`, so a stale projection never borrows newer session metadata and reopening the menu never restarts completed work. Neither metric implies a durable outcome.
|
||||
|
||||
Selecting a row records its exact address before opening the resident client `Session`. History pagination, event folding, tool render intents, titles, and live mux reconciliation reuse the ordinary conversation machinery. Breadcrumbs use catalog labels, follow parent links only through `origin: 'subagent'` rows, include the first ordinary owner, and keep ordinary forks single-level. Forking an addressed subagent creates an ordinary fork with direct source lineage and attaches it to the nearest workspace-owning ancestor. The catalog is an ARIA tree with lazy ArrowRight/ArrowLeft disclosure, linear ArrowUp/ArrowDown navigation, Home/End, Escape, and focus restoration.
|
||||
|
||||
A one-shot row always replaces the composer with copy explaining that the execution record is read-only. A continuable row does so only while `parentAvailable` is false. When enabled, its Send action admits another FIFO turn even if the child is currently running; it never becomes Stop. Prompt failures retain the draft through the ordinary error behavior.
|
||||
@@ -65,7 +67,7 @@ The adapter stays in `dsh-host-apiproxy`; `dsh-host-webserver` remains a carrier
|
||||
|
||||
## Client object layer and presentation
|
||||
|
||||
The React-free runtime owns catalogs, single-flight refreshes, retained addresses, availability hints, and transport selection. Re-selecting a known child retains its address so navigation cannot silently switch to ordinary session APIs. A missing intermediate breadcrumb address can be recovered from an already-loaded ancestor catalog, but it is not retained for transport and creates no scope until the user selects that breadcrumb. Restored navigation persists the full mode-bearing address.
|
||||
The React-free runtime owns catalogs, single-flight refreshes, retained addresses, availability hints, transport selection, and a reference-stable map of each list row's current projection values. Re-selecting a known child retains its address so navigation cannot silently switch to ordinary session APIs. A missing intermediate breadcrumb address can be recovered from an already-loaded ancestor catalog, but it is not retained for transport and creates no scope until the user selects that breadcrumb. Restored navigation persists the full mode-bearing address.
|
||||
|
||||
Catalogs ride the standard `useSessions` snapshot. Component-local state owns menu visibility, expanded branches, and focus. `ui-conversation` declares the generic header-action list slot and dispatches the current conversation snapshot through its composer chain; it contains no subagent-specific takeover flag. `@deepseek-ai/dsh-client-ui-subagent` registers the catalog action and elects a reason-specific read-only composer from ordinary owner props. Components receive derived props and callbacks, never `ctx`.
|
||||
|
||||
@@ -102,14 +104,14 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence
|
||||
- Host protocol tests pin schemas including required boolean expandability, id echoing, mode verification, non-activating history, exact-parent enforcement, FIFO admission receipts, cancellation, and sanitized failure mapping.
|
||||
- Generic Host tests pin attached and cold history and forks without Agent publication, cold projection folding, descriptor/origin/runtime-owner denial, explicit-id adoption denial, and the direct queue-control fence.
|
||||
- Client object tests pin retained and restored addresses, one-shot read-only rejection, history routing, continuable prompt routing, no addressed cancellation, suppression of Agent-bound model controls, live activity flips including in-flight response replay and detach fallback, subagent-parent expandability flips, and membership refresh.
|
||||
- jsdom tests pin the aggregate descendant count and activity, the summary-backed root action across absent and stale-empty catalogs, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons.
|
||||
- The keyless assembled Web snapshot contains an inactive continuable child, an inactive one-shot sibling, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response and aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only.
|
||||
- jsdom tests pin the aggregate descendant count and activity, token totals, second-precision running and frozen inactive durations, adaptive long-duration units with exact accessible text, the summary-backed root action across absent and stale-empty catalogs, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons.
|
||||
- The keyless assembled Web snapshot contains an inactive continuable child with durable usage, an inactive one-shot sibling with a deterministic long duration, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response, usage and timing rows, adaptive long-duration presentation, and aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only.
|
||||
- Navigation tests pin subagent-only breadcrumbs, workspace placement for forks created from subagents, and `origin: 'subagent'` sidebar filtering without hiding ordinary forks.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Catalog reads may rescan persisted lineage and each direct candidate's descriptor log, but expandability reuses only descendant headers already present in that trace; the Web activity baseline adds one Agent-registry lookup per healthy row and then uses existing live frames, while membership refresh stays debounced and single-flight.
|
||||
- Catalog reads may rescan persisted lineage and each direct candidate's descriptor log, but expandability reuses only descendant headers already present in that trace; the Web activity baseline adds one Agent-registry lookup per healthy row and then uses existing live frames, while usage and duration reuse projection baselines and pushes with no per-row log read, and membership refresh stays debounced and single-flight.
|
||||
- Parent availability, child activity, and `hasChildren` are snapshots. Publication, disposal, another sender, or another process may win after listing; typed prompt failure remains expected.
|
||||
- A child may publish between history fetch and mux subscription, so the existing sequence reconciliation also covers the cold-to-live addressed path.
|
||||
- Persisted origin adds one deliberately weak product-classification field to child headers and list projections; it cannot become an authorization shortcut.
|
||||
- The UI has no child cancellation, durable outcome, activation duration, deletion, or independently interactive offline mode, and its text must not imply those capabilities.
|
||||
- The UI has no child cancellation, durable outcome, Activation identity, deletion, or independently interactive offline mode, and its text must not imply those capabilities. Active-turn duration measures logged work rather than Activation residency.
|
||||
|
||||
@@ -33,7 +33,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5
|
||||
| 会话页头可打开紧凑的 child 列表。 | 触发器会汇总仅含 subagent 的完整后代谱系;树按服务顺序显示每个直接目录条目,包括已禁用的 diagnostic。 |
|
||||
| 选择一行会复用对话 UI。 | 已寻址历史绝不激活 child;只有 parent 存活的可继续行才保留普通输入框。 |
|
||||
| 嵌套 agent 会逐层展开。 | 每行携带一层 `hasChildren` 快照;展开时会立即预留已知直接后代行,随后仍只加载该行的直接目录,并保留其自身的 parent 地址。 |
|
||||
| 条目显示 label、状态与相对时间,同时避免侧边栏条目重复。 | mode 与 `running`/`inactive` 活动状态会同时以文字和视觉呈现;可选 title 与时间来自摘要。`SessionHeader.origin` 会移除重复的导航条目,但不授予任何功能权限。 |
|
||||
| 条目显示 label、状态、token 用量与活跃耗时,同时避免侧边栏条目重复。 | mode 与 `running`/`inactive` 活动状态会同时以文字和视觉呈现;可选 title、持久化 token 用量与活跃轮次耗时来自列表保留的投影值。紧凑耗时从一天起省略更小的单位,而悬停和无障碍名称仍保留精确的整秒数。`SessionHeader.origin` 会移除重复的导航条目,但不授予任何功能权限。 |
|
||||
|
||||
## 产品契约
|
||||
|
||||
@@ -41,6 +41,8 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5
|
||||
|
||||
`running` 表示在 Host 采样边界,确切 child Agent driver 正在处理工作;`inactive` 表示该 driver 空闲或不存在。UI 不会把任一值解释为成功、失败、取消、完成状态或可恢复性。`subagent.list` 提供当前 driver 状态基线,`host/session-status` 会就地更新已知活动状态,请求内回放会阻止更早发起但尚未完成的列表响应覆盖较新的状态转换,`host/session-removed` 则会使已知行恢复为 `inactive`;重连时会读取新的基线。直接 subagent 的 `host/session-added` 帧会立即把任何已加载的 parent 行翻转为 `hasChildren: true`,并使这项正向提示不被更早发起但尚未完成的目录响应覆盖;受影响分支打开期间,成员、label、mode、diagnostic 与权威快照仍需要通过去抖动的 `subagent.list` 刷新来更新。消息投递时仍以提示词响应为权威依据。
|
||||
|
||||
健康行会复用列表镜像中保留的标准会话投影。token 用量数值会汇总持久化日志中四个互不重叠的 `tokenUsage` 桶。`subagentTiming` 会在每个描述符处重置,使继承的 fork 种子不会计入 child 总量;它会累加已完成的 `turn/start` → `turn/end` 时段,并携带未结束轮次同一切面的 `active.since` 和 `active.through` 边界。该轮次保持未结束期间,现有会话事件会推进 `active.through`;菜单不会增加单独的计时器或日志读取,且仅在有已知后代处于运行状态时才推进其本地时钟。不足一天时,菜单会以整秒格式化时间;达到一天后的视觉值最多保留两个相邻单位,其中月份按近似 30 天计算,年份按近似 365 天计算,而悬停信息与无障碍名称会保留精确的天/小时/分钟/秒耗时。对 inactive 行,菜单以 `active.through` 为被中断未结束轮次的上界,因此陈旧投影绝不会借用更新的会话元数据,且重新打开菜单绝不会让已完成工作重新计时。这两项指标都不蕴含持久化结果语义。
|
||||
|
||||
选择一行后,系统会先记录其确切地址,再打开常驻客户端 `Session`。历史分页、事件 fold、工具渲染意图、title 与实时 mux 归并都会复用普通对话机制。面包屑导航使用目录 label,只会沿 `origin: 'subagent'` 行的父链接逐级回溯,包含第一个普通 owner,并让普通 fork 保持单层。从已寻址 subagent 创建 fork 时,会生成具有直接源谱系的普通 fork,并将其附加到最近拥有 Workspace 的祖先。目录是一棵 ARIA 树,支持懒加载式 ArrowRight/ArrowLeft 展开与折叠、线性 ArrowUp/ArrowDown 导航、Home/End、Escape 以及焦点恢复。
|
||||
|
||||
one-shot 行始终会用文案替代输入框,说明执行记录为只读。可继续行仅在 `parentAvailable` 为 false 时如此。启用后,即使 child 正在运行,其 Send 操作也会准入另一个 FIFO 轮次,绝不会变成 Stop。提示词失败会通过普通错误行为保留草稿。
|
||||
@@ -65,7 +67,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
|
||||
|
||||
## 客户端对象层与呈现
|
||||
|
||||
不依赖 React 的运行时负责目录、单次并发刷新、保留的地址、可用性提示与传输选择。再次选择已知 child 时会保留其地址,避免导航静默切换到普通会话 API。缺失的中间面包屑地址可以从已加载的祖先目录恢复,但在用户选择该面包屑之前不会保留为传输地址,也不会创建 scope。恢复的导航会持久化包含 mode 的完整地址。
|
||||
不依赖 React 的运行时负责目录、单次并发刷新、保留的地址、可用性提示、传输选择,以及每个列表行当前投影值的引用稳定映射。再次选择已知 child 时会保留其地址,避免导航静默切换到普通会话 API。缺失的中间面包屑地址可以从已加载的祖先目录恢复,但在用户选择该面包屑之前不会保留为传输地址,也不会创建 scope。恢复的导航会持久化包含 mode 的完整地址。
|
||||
|
||||
目录通过标准 `useSessions` 快照传递。组件局部状态负责菜单可见性、已展开分支与焦点。`ui-conversation` 声明通用页头操作列表 slot,并通过其编辑器链分发当前对话快照;其中没有 subagent 专用的接管标记。`@deepseek-ai/dsh-client-ui-subagent` 注册目录操作,并根据普通 owner props 选择按原因区分的只读编辑器。组件只接收派生 props 与回调,绝不接收 `ctx`。
|
||||
|
||||
@@ -102,14 +104,14 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
|
||||
- 宿主协议测试固定 schema(包括必需的布尔可展开性)、id 回显、mode 校验、非激活式历史、确切 parent 强制要求、FIFO 准入回执、取消与脱敏后的失败映射。
|
||||
- 通用 Host 测试固定在不发布 Agent 的情况下读取已附加与冷态历史及执行 fork、冷态投影归并、按描述符/origin/运行时 owner 拒绝、拒绝显式 id 接纳,以及直接队列控制栅栏。
|
||||
- 客户端对象测试固定已保留与已恢复的地址、one-shot 只读拒绝、历史路由、可继续提示词路由、已寻址对话不提供取消、屏蔽绑定到 agent 的模型控件、实时活动状态翻转(包括在途响应回放与 detach 回退)、subagent parent 可展开性翻转与成员刷新。
|
||||
- jsdom 测试固定后代聚合计数与活动状态、目录缺失或为陈旧空目录时由摘要支撑的根操作、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。
|
||||
- 无密钥的组装 Web 快照包含一个 inactive 的可继续 child、一个 inactive 的 one-shot sibling 和一个持久化 grandchild;它会固定触发器在一次陈旧的空目录响应后仍显示三个后代,并固定聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。
|
||||
- jsdom 测试固定后代聚合计数与活动状态、token 用量总计、精确到秒的运行中耗时与冻结后 inactive 耗时、采用自适应单位的长耗时及其精确无障碍文本、目录缺失或为陈旧空目录时由摘要支撑的根操作、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。
|
||||
- 无密钥的组装 Web 快照包含一个具有持久化 token 用量的 inactive 可继续 child、一个具有确定性长耗时的 inactive one-shot sibling 和一个持久化 grandchild;它会固定触发器在一次陈旧的空目录响应后仍显示三个后代,并固定 token 用量与计时行、自适应长耗时呈现以及聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。
|
||||
- 导航测试固定仅含 subagent 的面包屑导航、从 subagent 创建 fork 时的 Workspace 归属,以及 `origin: 'subagent'` 侧边栏过滤,同时不隐藏普通 fork。
|
||||
|
||||
## 后果
|
||||
|
||||
- 目录读取可能重新扫描持久化谱系与每个直接候选的描述符日志,但可展开性只复用该追踪中已有的后代 header;Web 活动基线会为每个健康行增加一次 Agent 注册表查找,随后使用现有实时帧,而成员刷新保持去抖动和单次并发。
|
||||
- 目录读取可能重新扫描持久化谱系与每个直接候选的描述符日志,但可展开性只复用该追踪中已有的后代 header;Web 活动基线会为每个健康行增加一次 Agent 注册表查找,随后使用现有实时帧,而 token 用量与耗时会复用投影基线和推送,无需按行读取日志,成员刷新则保持去抖动和单次并发。
|
||||
- parent 可用性、child 活动状态与 `hasChildren` 都是快照。列出之后,发布、dispose、其他发送方或其他进程都可能抢先改变状态;类型化提示词失败仍属预期行为。
|
||||
- child 可能在历史获取与 mux 订阅之间发布,因此现有序号归并也涵盖从冷态转为存活的已寻址路径。
|
||||
- 持久化 origin 会为 child header 与列表投影添加一个有意保持弱约束的产品分类字段;它不能变成授权捷径。
|
||||
- UI 不提供 child 取消、持久化结果、激活耗时、删除或可独立交互的离线 mode,其文案不得暗示这些功能已经存在。
|
||||
- UI 不提供 child 取消、持久化结果、Activation 身份、删除或可独立交互的离线 mode,其文案不得暗示这些功能已经存在。活跃轮次耗时度量的是已记录工作,而非 Activation 驻留时间。
|
||||
|
||||
@@ -115,7 +115,6 @@ export async function runTui(
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
installFailLoud(NAME)
|
||||
// The bin already loaded the invoking directory's .env, and that is the
|
||||
// whole environment: $DSH_HOME/.env is credentials-local's writable store,
|
||||
// and hoisting it would make every stored key read as a read-only ambient
|
||||
@@ -142,6 +141,19 @@ export async function runTui(
|
||||
const entry = process.argv[1]
|
||||
const execve = process.execve?.bind(process)
|
||||
const app: { current?: Context } = {}
|
||||
// The Loader mounts entries concurrently, so `ui-tui` can already hold the
|
||||
// terminal (raw mode, bracketed paste, keyboard protocol) when something
|
||||
// else fails. A config-tree failure settles through `boot`, which disposes
|
||||
// the tree itself; this release covers the rejections `boot` cannot see — a
|
||||
// plugin's detached async work rejecting while mounting is still in flight
|
||||
// or after the tree settled. Disposing the tree runs the TUI's own shutdown,
|
||||
// which stops the terminal and hands the shell back; without it such a
|
||||
// failure returns to a corrupted prompt. `app.current` is captured from
|
||||
// boot's `prepare` hook, so it holds the root context for the whole mounting
|
||||
// window rather than only after boot resolves.
|
||||
installFailLoud(NAME, process, async () => {
|
||||
await app.current?.fiber.dispose()
|
||||
})
|
||||
// Resume always enters the default surface because meta rejects
|
||||
// parent options, including `--resume`. The resumed session already persists
|
||||
// its cwd.
|
||||
@@ -219,6 +231,10 @@ export async function runTui(
|
||||
bootConfig,
|
||||
patches,
|
||||
(hostCtx) => {
|
||||
// Runs after the Loader installs and before any config-tree entry mounts,
|
||||
// so the fail-loud release hook can reach the tree for the whole window in
|
||||
// which an entry may reject.
|
||||
app.current = hostCtx
|
||||
// The launcher owns session identity and the exit line: a config-mounted
|
||||
// app bundle reads both from these slots, so no cordis.yml key can drop
|
||||
// resume.
|
||||
|
||||
10
apps/cli/tests/fixtures/tui-invalid-provider.cordis.yml
vendored
Normal file
10
apps/cli/tests/fixtures/tui-invalid-provider.cordis.yml
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# An overlay whose `llm-pi-ai` config fails schema validation: `providers` is a
|
||||
# dict keyed by provider name, and a list is the shape users reach for. The
|
||||
# entry rejects while `ui-tui` — mounted concurrently by the Loader — already
|
||||
# holds the terminal, which is the boot failure the fail-loud release hook
|
||||
# exists for.
|
||||
- id: llm-pi-ai
|
||||
config:
|
||||
providers:
|
||||
- provider: openai
|
||||
apiKey: keyless-invalid-shape
|
||||
@@ -26,6 +26,9 @@ const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
|
||||
// `--config` layers an overlay over the shared base, so the default surface
|
||||
// needs no config argument at all; these are the overlays under test.
|
||||
const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url))
|
||||
// An overlay whose `llm-pi-ai` config fails validation, so an entry rejects
|
||||
// while the TUI already holds the terminal.
|
||||
const invalidProviderConfigPath = fileURLToPath(new URL('./fixtures/tui-invalid-provider.cordis.yml', import.meta.url))
|
||||
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
|
||||
const firstRunSnapshots = fileURLToPath(new URL('./tui-first-run-snapshots/', import.meta.url))
|
||||
const synchronizedFrameEnd = '\x1b[?2026l'
|
||||
@@ -414,6 +417,28 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
|
||||
expect(output).toContain('\u001B[?2004l')
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
// The Loader mounts entries concurrently, so `ui-tui` can already own the
|
||||
// terminal when a sibling entry rejects on its config. Exiting without the
|
||||
// tree's own teardown left raw mode and bracketed paste set on the user's
|
||||
// shell, and the pending Device Attributes reply landed there as literal
|
||||
// text. The transactional mount must settle (an HMR initial-scan refresh
|
||||
// once deadlocked its rollback into a silent exit 13) so `boot` disposes
|
||||
// the tree — reaching the TUI's own shutdown — and rejects with the
|
||||
// labelled diagnostic.
|
||||
it('restores the terminal when a sibling entry fails to validate during boot', async () => {
|
||||
const output = await smoke({
|
||||
label: 'dsh invalid provider config',
|
||||
tempDirPrefix: 'dsh-tui-invalid-config-',
|
||||
configPath: invalidProviderConfigPath,
|
||||
expectedExitCode: 1,
|
||||
})
|
||||
expect(output).toContain('dsh: plugin tree failed to load:')
|
||||
expect(output).toContain('$.providers')
|
||||
// Bracketed paste is disabled again, which only `ProcessTerminal.stop()`
|
||||
// writes — proof the tree was disposed rather than exited out from under.
|
||||
expect(output).toContain('\u001B[?2004l')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('switches models, streams a response, answers a user-question dialog, and exits cleanly', async () => {
|
||||
const output = await smoke({
|
||||
label: 'dsh conversation',
|
||||
|
||||
82
apps/web/tests/bash-abort-row.e2e.ts
Normal file
82
apps/web/tests/bash-abort-row.e2e.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// Web e2e scenario: a cancelled Bash call can settle without terminal-card
|
||||
// material. Borrow the real cancellation fixture and prove the keyed Bash row
|
||||
// still exposes the recorded command and full error without any model call.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('../../../examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl', import.meta.url))
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/bash-abort-row', import.meta.url))
|
||||
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'bash-abort-row-web-e2e'
|
||||
const PROMPT = 'Run two shell commands: wait for cancellation, then write skipped.txt.'
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
const fixture = await readFile(FIXTURE, 'utf8')
|
||||
expect(fixtureUserPrompts(fixture)).toEqual([PROMPT])
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, fixture, SEED_ID)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
|
||||
const groupRow = page.locator('[role="treeitem"]').first()
|
||||
await groupRow.waitFor({ timeout: 15_000 })
|
||||
await groupRow.click()
|
||||
const sessionRow = page.locator('[role="treeitem"]').nth(1)
|
||||
await sessionRow.waitFor({ timeout: 10_000 })
|
||||
await sessionRow.click()
|
||||
await page.locator('[data-sample="bash"]').nth(1).waitFor({ timeout: 15_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('expands the aborted row to its command and full error', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-bash-abort-row'))
|
||||
const row = page.locator('[data-sample="bash"]').first()
|
||||
const call = row.locator('xpath=..')
|
||||
await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false')
|
||||
await expect.poll(() => call.getByText('Error: command aborted', { exact: true }).count()).toBe(1)
|
||||
await row.click()
|
||||
|
||||
await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true')
|
||||
await call.getByText('IN', { exact: true }).waitFor()
|
||||
await call.getByText('OUT', { exact: true }).waitFor()
|
||||
await call.getByText('Wait until cancellation', { exact: false }).waitFor()
|
||||
await call.getByText('setInterval(() => {}, 1000)', { exact: false }).waitFor()
|
||||
await expect.poll(() => call.getByText('Error: command aborted', { exact: true }).count()).toBe(2)
|
||||
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
// The borrowed fixture's UTC date is still the previous day in PDT;
|
||||
// the disclosure golden must not depend on the runner timezone.
|
||||
.replace(/\b\d{1,2}\/\d{1,2}(?= \{\{clock\}\})/g, '{{date}}')
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps its snapshot inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -511,7 +511,14 @@ function normalizeAria(snapshot: string, workspaceCwd: string): string {
|
||||
.split(workspaceCwd).join('{{cwd}}')
|
||||
.split(base).join('{{workspace}}')
|
||||
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '{{uuid}}')
|
||||
.replace(/\b\d+(?:\.\d+)?(?:ms|s|秒)\b/g, '{{duration}}')
|
||||
.replace(
|
||||
/~\d+(?:y(?: \d+mo)?|mo(?: \d+d)?)|\b(?:\d+d(?: \d+h(?: \d+m \d+s)?)?|\d+h \d+m \d+s|\d+m \d+s|\d+(?:\.\d+)?s|\d+(?:\.\d+)?ms)\b/g,
|
||||
duration => duration.startsWith('~') ? duration : '{{duration}}',
|
||||
)
|
||||
.replace(
|
||||
/约\d+(?:年(?:\d+个月)?|个月(?:\d+天)?)|\d+(?:天(?:\d+小时(?:\d+分\d+秒)?)?|小时\d+分\d+秒|分\d+秒|(?:\.\d+)?秒)/g,
|
||||
duration => duration.startsWith('约') ? duration : '{{duration}}',
|
||||
)
|
||||
// Message IconActions clocks widen by calendar day/year; collapse every
|
||||
// shape so goldens stay stable across midnight and year boundaries.
|
||||
.replace(/\d{4}年\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}')
|
||||
|
||||
32
apps/web/tests/snapshots/bash-abort-row/ui.expected.md
Normal file
32
apps/web/tests/snapshots/bash-abort-row/ui.expected.md
Normal file
@@ -0,0 +1,32 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- 'button "Run two shell commands: wait" [disabled]'
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: "Run two shell commands: wait for cancellation, then write skipped.txt. {{date}} {{clock}}"
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- 'button "Failed Bash Error: command aborted" [expanded]':
|
||||
- img
|
||||
- text: "Failed Bash Error: command aborted"
|
||||
- text: "IN { \"command\": \"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\", \"description\": \"Wait until cancellation\" } OUT Error: command aborted"
|
||||
- button "Inspect"
|
||||
- 'button "Failed Bash Error: tool call aborted before dispatch"':
|
||||
- img
|
||||
- text: "Failed Bash Error: tool call aborted before dispatch"
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps Tool call {{duration}} Cache hit 0% Input 10 tok · Output 10 tok
|
||||
@@ -0,0 +1,2 @@
|
||||
- tree "Subagent sessions":
|
||||
- treeitem "example editor continuable · not running 0 tok · {{duration}}" [level=1]: example editor continuable · not running 0 tok {{duration}}
|
||||
@@ -1,8 +1,8 @@
|
||||
- tree "Subagent sessions":
|
||||
- treeitem "event-sourcing researcher Explain event sourcing in one · continuable · not running just now" [expanded] [level=1]:
|
||||
- treeitem "event-sourcing reviewer one-shot · not running 0 tok · {{duration}}" [level=1]: event-sourcing reviewer one-shot · not running 0 tok ~6mo 12d
|
||||
- treeitem "event-sourcing researcher Explain event sourcing in one · continuable · not running 7.9K tok · {{duration}}" [expanded] [level=1]:
|
||||
- button "Collapse event-sourcing researcher descendants":
|
||||
- img
|
||||
- text: event-sourcing researcher Explain event sourcing in one · continuable · not running just now
|
||||
- text: event-sourcing researcher Explain event sourcing in one · continuable · not running 7.9K tok {{duration}}
|
||||
- group:
|
||||
- treeitem "example editor continuable · not running just now" [level=2]
|
||||
- treeitem "event-sourcing reviewer one-shot · not running just now" [level=1]
|
||||
- treeitem "example editor continuable · not running 0 tok · {{duration}}" [level=2]: example editor continuable · not running 0 tok {{duration}}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
|
||||
const BASE_FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url))
|
||||
const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/ui.expected.md', import.meta.url))
|
||||
const TREE_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/tree.expected.md', import.meta.url))
|
||||
const BRANCHLESS_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/branchless.expected.md', import.meta.url))
|
||||
const STALE_CATALOG_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/stale-catalog.expected.md', import.meta.url))
|
||||
const SIDEBAR_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/sidebar.expected.md', import.meta.url))
|
||||
const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/nested.expected.md', import.meta.url))
|
||||
@@ -108,7 +109,8 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
childId = started.childId
|
||||
await waitForAgentToSettle(scaffold, childId)
|
||||
oneShotId = sessionId('recorded-one-shot')
|
||||
const oneShotAt = Date.now()
|
||||
const oneShotDurationMs = 192 * 24 * 60 * 60 * 1_000
|
||||
const oneShotAt = Date.now() - oneShotDurationMs
|
||||
await scaffold.ctx.sessionPersistence.create({
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: oneShotId,
|
||||
@@ -146,10 +148,11 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
{
|
||||
type: 'turn/end',
|
||||
seq: 3,
|
||||
time: oneShotAt + 3,
|
||||
time: oneShotAt + oneShotDurationMs,
|
||||
data: { turn: 1, reason: { kind: 'completed' } },
|
||||
},
|
||||
] as SessionEvent[])
|
||||
await scaffold.ctx.sessionProjectionCache.coldSnapshot(oneShotId)
|
||||
grandchildId = sessionId('recorded-grandchild')
|
||||
const authoredAt = Date.now()
|
||||
await scaffold.ctx.sessionPersistence.create({
|
||||
@@ -193,18 +196,19 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
data: { turn: 1, reason: { kind: 'completed' } },
|
||||
},
|
||||
] as SessionEvent[])
|
||||
await scaffold.ctx.sessionProjectionCache.coldSnapshot(grandchildId)
|
||||
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
|
||||
expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined()
|
||||
expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
|
||||
await expect(scaffold.ctx.subagents.listChildren(parent.id)).resolves.toMatchObject([
|
||||
{
|
||||
kind: 'child', id: childId, mode: 'continuable', label: LABEL,
|
||||
activity: 'inactive', hasChildren: true,
|
||||
},
|
||||
{
|
||||
kind: 'child', id: oneShotId, mode: 'one-shot',
|
||||
label: ONE_SHOT_LABEL, activity: 'inactive', hasChildren: false,
|
||||
},
|
||||
{
|
||||
kind: 'child', id: childId, mode: 'continuable', label: LABEL,
|
||||
activity: 'inactive', hasChildren: true,
|
||||
},
|
||||
])
|
||||
await expect(scaffold.ctx.subagents.listChildren(childId)).resolves.toMatchObject([
|
||||
{
|
||||
@@ -299,7 +303,14 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
expect(await page.getByRole('button', {
|
||||
name: `Expand ${ONE_SHOT_LABEL} descendants`,
|
||||
}).count()).toBe(0)
|
||||
const oneShotRow = page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) })
|
||||
expect(await oneShotRow.getByText('~6mo 12d', { exact: true }).count()).toBe(1)
|
||||
expect(await oneShotRow.getAttribute('aria-label')).toContain('192d 00h 00m 00s')
|
||||
await page.getByRole('button', { name: `Expand ${LABEL} descendants` }).click()
|
||||
const childRow = page.getByRole('treeitem', { name: new RegExp(LABEL) })
|
||||
const childLabel = await childRow.getAttribute('aria-label')
|
||||
await page.waitForTimeout(1_100)
|
||||
expect(await childRow.getAttribute('aria-label')).toBe(childLabel)
|
||||
await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor({ timeout: 15_000 })
|
||||
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
|
||||
expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
|
||||
@@ -386,7 +397,15 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
it('opens an unavailable persisted grandchild after recording the available child', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-grandchild'))
|
||||
await page.getByRole('button', { name: '1 subagent' }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).click()
|
||||
const tree = page.getByRole('tree', { name: 'Subagent sessions' })
|
||||
const nestedRow = tree.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) })
|
||||
expect(await nestedRow.locator(':scope > *').count()).toBe(1)
|
||||
await compareOrRefreshGolden(
|
||||
BRANCHLESS_EXPECTED,
|
||||
await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd),
|
||||
MODE,
|
||||
)
|
||||
await nestedRow.click()
|
||||
await page.getByText('The parent session is offline; reopen it to continue sending messages.').waitFor()
|
||||
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
|
||||
const crumbs = await hierarchy.getByRole('button').allTextContents()
|
||||
|
||||
@@ -53,7 +53,8 @@
|
||||
"tests/shipped-composition.e2e.ts",
|
||||
"tests/goal-bar.e2e.ts",
|
||||
"tests/startup-auto-selection.e2e.ts",
|
||||
"tests/subagent-conversation.e2e.ts"
|
||||
"tests/subagent-conversation.e2e.ts",
|
||||
"tests/bash-abort-row.e2e.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
|
||||
@@ -794,7 +794,7 @@ A published child settled. Scope-filtered dispatch uses the same delegating pare
|
||||
|
||||
Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md)
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:158`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:160`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
### `subagent/provider-added` — emit
|
||||
|
||||
@@ -811,7 +811,7 @@ A provider became resolvable in the registry.
|
||||
|
||||
Types: [SubagentProvider](../core-data-structures/subagent.md)
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:132`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:134`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
### `subagent/provider-removed` — emit
|
||||
|
||||
@@ -826,7 +826,7 @@ A provider left the registry. Accepted runs remain holder-owned.
|
||||
'subagent/provider-removed'(name: string): void
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:138`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:140`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
### `subagent/start` — emit
|
||||
|
||||
@@ -848,7 +848,7 @@ A provider established a published child. For in-process providers, `ctx.agents.
|
||||
|
||||
Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md)
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:149`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:151`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
## `system-prompt/*`
|
||||
|
||||
|
||||
@@ -2072,7 +2072,7 @@ async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableSetupContribution](../core-data-structures/subagent.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [MessageId](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) · [SubagentFollowupOptions](../core-data-structures/subagent.md) · [SubagentListEntry](../core-data-structures/subagent.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentReportOptions](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md)
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:163`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:165`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
## `ctx.subprocess` — `SubprocessService` (abstract seam)
|
||||
|
||||
|
||||
@@ -41,10 +41,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:150`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` |
|
||||
| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:137`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
|
||||
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:132`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:138`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:149`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:160`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:134`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:151`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) |
|
||||
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
|
||||
| `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - |
|
||||
|
||||
@@ -737,6 +737,7 @@ flowchart TD
|
||||
pkg_subagent --> pkg_scope
|
||||
pkg_subagent --> pkg_session
|
||||
pkg_subagent --> pkg_session_persistence
|
||||
pkg_subagent --> pkg_session_projection
|
||||
pkg_subagent --> pkg_session_query
|
||||
pkg_subagent --> pkg_tasks
|
||||
pkg_subagent --> pkg_tools
|
||||
@@ -832,13 +833,6 @@ flowchart TD
|
||||
pkg_client_ui_goal --> pkg_client_ui_slots
|
||||
pkg_client_ui_goal --> pkg_goal
|
||||
pkg_client_ui_goal --> pkg_invariants
|
||||
pkg_client_ui_subagent --> pkg_client_locale
|
||||
pkg_client_ui_subagent --> pkg_client_runtime
|
||||
pkg_client_ui_subagent --> pkg_client_ui_conversation
|
||||
pkg_client_ui_subagent --> pkg_client_ui_primitives
|
||||
pkg_client_ui_subagent --> pkg_client_ui_slash
|
||||
pkg_client_ui_subagent --> pkg_client_ui_slots
|
||||
pkg_client_ui_subagent --> pkg_invariants
|
||||
pkg_session_reference --> pkg_agent
|
||||
pkg_session_reference --> pkg_compact
|
||||
pkg_session_reference --> pkg_invariants
|
||||
@@ -981,6 +975,15 @@ flowchart TD
|
||||
pkg_client_ui_plan --> pkg_client_ui_slots
|
||||
pkg_client_ui_plan --> pkg_invariants
|
||||
pkg_client_ui_plan --> pkg_plan_mode
|
||||
pkg_client_ui_subagent --> pkg_client_locale
|
||||
pkg_client_ui_subagent --> pkg_client_runtime
|
||||
pkg_client_ui_subagent --> pkg_client_ui_conversation
|
||||
pkg_client_ui_subagent --> pkg_client_ui_primitives
|
||||
pkg_client_ui_subagent --> pkg_client_ui_slash
|
||||
pkg_client_ui_subagent --> pkg_client_ui_slots
|
||||
pkg_client_ui_subagent --> pkg_invariants
|
||||
pkg_client_ui_subagent --> pkg_subagent
|
||||
pkg_client_ui_subagent --> pkg_token_meter
|
||||
pkg_agent_spine_demo --> pkg_agent
|
||||
pkg_agent_spine_demo --> pkg_agent_loop
|
||||
pkg_agent_spine_demo --> pkg_goal
|
||||
@@ -1201,7 +1204,7 @@ flowchart TD
|
||||
| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) |
|
||||
| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |
|
||||
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
|
||||
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-query`](../packages/session-query/session-query), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
|
||||
| [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |
|
||||
| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) |
|
||||
| [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
@@ -1218,7 +1221,6 @@ flowchart TD
|
||||
| [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
|
||||
| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
|
||||
| [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
|
||||
| [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) |
|
||||
@@ -1239,6 +1241,7 @@ flowchart TD
|
||||
| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) |
|
||||
| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) |
|
||||
| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) |
|
||||
| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
|
||||
| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
|
||||
README.md: eca7db1f9b2d5c7e28fa86a363ca4408703b99df
|
||||
README.zh.md: 6a2e8c6085d06a9f04c1270e5976452b995a7e77
|
||||
README.md: 82f1bc95a6128245f88f01a0de0849494cb98359
|
||||
README.zh.md: f3aba75d18671fe377ec8305693ea5025d51e0de
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`.
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
|
||||
|
||||
## Workspace and Session lists
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
|
||||
|
||||
## Workspace 与 Session 列表
|
||||
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
// Orphaned lineage degrades to root level; cycles fail soft and emit as roots.
|
||||
|
||||
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
|
||||
/** Host list summary enriched with the latest mux-projected durable title. */
|
||||
export interface TitledSessionSummary extends SessionSummary {
|
||||
title?: string
|
||||
/** Current host-computed projection values for list consumers. */
|
||||
projectionValues?: Readonly<Partial<SessionProjectionMap>>
|
||||
}
|
||||
|
||||
/** One flattened session-list row (summary + lineage indent depth + live pending-approval bit). */
|
||||
@@ -21,6 +24,8 @@ export interface SessionListEntry {
|
||||
/** Coarse durable origin for navigation filtering; not a continuation capability. */
|
||||
origin?: 'subagent'
|
||||
cwd?: string
|
||||
/** Current host-computed projection values for list consumers. */
|
||||
projectionValues?: Readonly<Partial<SessionProjectionMap>>
|
||||
/** An approval question is pending on this session (mux-frame derived; the sidebar's amber dot). */
|
||||
waitingApproval: boolean
|
||||
/** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */
|
||||
|
||||
@@ -846,10 +846,14 @@ export class SessionManager {
|
||||
const merged: TitledSessionSummary[] = this.summaries.map((summary) => {
|
||||
// List rows read the generic 'title' projection key (host-computed unit
|
||||
// value; the bespoke session/title frame is retired).
|
||||
const title = this.projectionStores.get(summary.sessionId)?.get('title')
|
||||
return typeof title === 'string' && title !== ''
|
||||
? { ...summary, title }
|
||||
: summary
|
||||
const projectionStore = this.projectionStores.get(summary.sessionId)
|
||||
const title = projectionStore?.get('title')
|
||||
const projectionValues = projectionStore?.values()
|
||||
return {
|
||||
...summary,
|
||||
...(typeof title === 'string' && title !== '' ? { title } : {}),
|
||||
...(projectionValues === undefined ? {} : { projectionValues }),
|
||||
}
|
||||
})
|
||||
const fresh = flattenLineage(merged, new Set(this.waitingApprovals.keys()))
|
||||
const items = fresh.map((entry) => {
|
||||
@@ -860,6 +864,7 @@ export class SessionManager {
|
||||
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
|
||||
&& prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
|
||||
&& prev.waitingApproval === entry.waitingApproval
|
||||
&& prev.projectionValues === entry.projectionValues
|
||||
) return prev
|
||||
this.entryCache.set(entry.sessionId, entry)
|
||||
return entry
|
||||
|
||||
@@ -74,6 +74,7 @@ interface Channel {
|
||||
export class ProjectionValueStore {
|
||||
private readonly rows = new Map<string, Row>()
|
||||
private readonly channels = new Map<string, Channel>()
|
||||
private valuesCache: Readonly<Partial<SessionProjectionMap>> | undefined
|
||||
/** Coarse any-key channel (no snapshot cache to rebuild: reads hit rows directly). */
|
||||
private readonly anyNotifier = new Notifier(() => {})
|
||||
|
||||
@@ -98,6 +99,19 @@ export class ProjectionValueStore {
|
||||
return this.rows.get(key)?.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every current projection value as one reference-stable snapshot.
|
||||
* @returns The same frozen value map until a row changes.
|
||||
*/
|
||||
values(): Readonly<Partial<SessionProjectionMap>> {
|
||||
if (this.valuesCache === undefined) {
|
||||
this.valuesCache = Object.freeze(Object.fromEntries(
|
||||
[...this.rows].map(([key, row]) => [key, row.value]),
|
||||
))
|
||||
}
|
||||
return this.valuesCache
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to any-key changes (microtask-batched) — the manager's list
|
||||
* rebuild channel.
|
||||
@@ -160,6 +174,7 @@ export class ProjectionValueStore {
|
||||
}
|
||||
|
||||
private changed(key: string): void {
|
||||
this.valuesCache = undefined
|
||||
this.channels.get(key)?.notifier.markDirty()
|
||||
this.anyNotifier.markDirty()
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type {
|
||||
HostObservable, SessionMaybeProvideInfo, SessionProvideInfo,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
import type { SnapshotStore } from '../contract/store.ts'
|
||||
import { createSnapshotStore } from '../contract/store.ts'
|
||||
import type { SessionFace } from '../contract/session.ts'
|
||||
@@ -57,6 +58,8 @@ export interface SessionSummary {
|
||||
*/
|
||||
blank: boolean
|
||||
updatedAt: number
|
||||
/** Current host-computed projection values retained by the object layer. */
|
||||
projectionValues?: Readonly<Partial<SessionProjectionMap>>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -613,6 +616,9 @@ export class SessionsService implements ISessions {
|
||||
waitingApproval: entry.waitingApproval,
|
||||
blank: entry.blank,
|
||||
updatedAt: entry.updatedAt,
|
||||
...(entry.projectionValues === undefined
|
||||
? {}
|
||||
: { projectionValues: entry.projectionValues }),
|
||||
...(entry.title !== undefined ? { title: entry.title } : {}),
|
||||
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
|
||||
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
|
||||
|
||||
@@ -86,6 +86,17 @@ describe('ProjectionValueStore semantics', () => {
|
||||
const store = new ProjectionValueStore()
|
||||
expect(store.faceOf('test/marks')).toBe(store.faceOf('test/marks'))
|
||||
})
|
||||
|
||||
it('publishes one reference-stable whole-value snapshot until a row changes', () => {
|
||||
const store = new ProjectionValueStore()
|
||||
const empty = store.values()
|
||||
expect(store.values()).toBe(empty)
|
||||
store.apply('test/marks', { marks: ['a'] }, 1)
|
||||
const populated = store.values()
|
||||
expect(populated).toEqual({ 'test/marks': { marks: ['a'] } })
|
||||
expect(populated).not.toBe(empty)
|
||||
expect(store.values()).toBe(populated)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Session tail-page seeding', () => {
|
||||
@@ -167,6 +178,36 @@ describe('manager frame routing', () => {
|
||||
expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
it('projects every retained value into list rows with stable snapshot identity', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{
|
||||
sessionId: sid('s1'), updatedAt: 1, running: false, blank: false,
|
||||
projections: {
|
||||
asOfSeq: 2,
|
||||
values: { 'test/marks': { marks: ['baseline'] } },
|
||||
},
|
||||
}],
|
||||
}) as never)
|
||||
await manager.refreshList()
|
||||
const baseline = manager.getListSnapshot().items[0]?.projectionValues
|
||||
expect(baseline).toEqual({ 'test/marks': { marks: ['baseline'] } })
|
||||
expect(manager.getListSnapshot().items[0]?.projectionValues).toBe(baseline)
|
||||
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'p2' as never,
|
||||
payload: {
|
||||
type: 'session/projection', sessionId: sid('s1'), key: 'test/marks',
|
||||
value: { marks: ['live'] }, seq: 3,
|
||||
} as never,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(manager.getListSnapshot().items[0]?.projectionValues)
|
||||
.toEqual({ 'test/marks': { marks: ['live'] } })
|
||||
expect(manager.getListSnapshot().items[0]?.projectionValues).not.toBe(baseline)
|
||||
})
|
||||
|
||||
it('drops the projection store with the removed session', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: e610b990dd89204fd7e22e8b86f807d10b8ba439
|
||||
README.zh.md: 268e05a806db1468ba689608c178646af132fe25
|
||||
README.md: 728d67aaccee609d5f28ee8744c1f11ca74b040a
|
||||
README.zh.md: eec1d36b9758dddcf63eb75c3a74c51b4afb0a72
|
||||
|
||||
@@ -20,7 +20,7 @@ A Think row stays collapsed by default and exposes live reasoning throughput wit
|
||||
|
||||
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
|
||||
|
||||
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
|
||||
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound. A Bash execution failure that settles on the generic path instead exposes its original arguments and full error through the same bounded IN/OUT disclosure, while successful generic results such as a background-start acknowledgement remain summary-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
|
||||
|
||||
A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; it composes the shared `ToolRow`, feeding the card as ToolRow's `web` body, so the retrieval is the row's collapsed-by-default expanded card (the same unified expand every card row has). A web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which routes the card through ToolRow the same way, and the details panel renders it at the primitive's full source allowance and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Rows cap at `CHAT_WEB_MAX_SOURCES` (8) against the panel's 16, the same summary-versus-reading split the terminal card draws ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)).
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时
|
||||
|
||||
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。
|
||||
|
||||
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
|
||||
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限。若 Bash 执行失败时落在通用路径,则改用同样有界的 IN/OUT 展开区暴露原始参数和完整错误;后台启动确认等成功的通用结果仍只显示摘要([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
|
||||
|
||||
声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值,wire 上不可信其为 `search` 或 `fetch`),它返回 null,落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search` 与 `web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;它组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `web` body 传入,因此检索成为该行默认折叠的展开卡片(与每个卡片行相同的统一展开交互)。没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它以同样方式经 ToolRow 渲染卡片,详情面板则以原语的完整 source 额度渲染它,并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。行的上限是 `CHAT_WEB_MAX_SOURCES`(8),面板为 16,与终端卡片所画的摘要面对阅读面的同一划分([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md))。
|
||||
|
||||
|
||||
@@ -20,6 +20,63 @@
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
/* A bash execution error can settle without terminal-card material (for
|
||||
example, command cancellation). Preserve ToolRow's bounded IN/OUT fallback
|
||||
so the original command and full error remain available from this keyed row. */
|
||||
.ioCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 4px 0 4px 4px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
font: var(--dsw-font-markdown-code-block-small);
|
||||
}
|
||||
|
||||
.ioSection {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
column-gap: 14px;
|
||||
align-items: baseline;
|
||||
padding: 12px 16px;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ioSection::-webkit-scrollbar-thumb {
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.ioSection::-webkit-scrollbar-track {
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.ioLabel {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
align-self: start;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.ioDivider {
|
||||
flex: none;
|
||||
height: 1px;
|
||||
background: var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.ioText {
|
||||
min-width: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.ioText[data-error] {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* ToolRow's unified expand interaction, replicated per the registrant
|
||||
posture: pointer on the expandable row (the icon→chevron hover preview is
|
||||
the affordance, no row fill). */
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
// (ctx.slots.register + ToolRowProps only — never imports the chat domain).
|
||||
// Product chrome matches ToolRow / Think (figma: Bash · {description}).
|
||||
//
|
||||
// A bash call declares the terminal render intent, so this row renders the
|
||||
// command's own output through TerminalBlock — expand-gated exactly like
|
||||
// A bash call normally declares the terminal render intent, so this row renders
|
||||
// the command's own output through TerminalBlock. Execution failures that
|
||||
// settle without terminal material use the bounded generic IN/OUT fallback —
|
||||
// both are expand-gated exactly like
|
||||
// ToolRow's unified interaction: collapsed by default, the whole summary row
|
||||
// is the toggle (click / Enter / Space, icon→chevron hover preview; the
|
||||
// summary stays inline while open),
|
||||
@@ -48,7 +50,7 @@ function stateStatus(state: ToolRowState, t: BashRowProps['t']): string | null {
|
||||
|
||||
/**
|
||||
* Bash row: icon + Bash · {description} in the shared ToolRow chrome, the
|
||||
* whole row toggling the command's terminal card (ToolRow's unified
|
||||
* whole row toggling the command's terminal or generic error card (ToolRow's unified
|
||||
* expand interaction, replicated locally per the registrant posture).
|
||||
*/
|
||||
export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: BashRowProps) {
|
||||
@@ -64,7 +66,13 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
|
||||
: model.state
|
||||
const status = stateStatus(state, t)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const expandable = terminal !== null
|
||||
// Execution failures (for example cancellation before the process reports a
|
||||
// terminal result) use the generic presenter. Keep their recorded args and
|
||||
// full error reachable instead of collapsing the row to the first line.
|
||||
const genericError = terminal === null
|
||||
&& model.state === 'error'
|
||||
&& (model.body !== null || model.output !== null)
|
||||
const expandable = terminal !== null || genericError
|
||||
const open = expanded && expandable
|
||||
const failureLine = model.state === 'error' ? model.errorSummary : null
|
||||
const toggleExpand = () => {
|
||||
@@ -109,16 +117,40 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
|
||||
{failureLine ?? terminal?.description ?? model.summary}
|
||||
</span>
|
||||
</div>
|
||||
{terminal !== null && open && (
|
||||
{open && (
|
||||
/* Same hover-Inspect posture as ToolRow's expanded body, replicated
|
||||
locally per the registrant posture. */
|
||||
<div className={css.bodyWrap}>
|
||||
<TerminalBlock
|
||||
{...terminal.card}
|
||||
maxLines={Infinity}
|
||||
labels={terminalBlockLabels(t)}
|
||||
className={css.terminal}
|
||||
/>
|
||||
{terminal !== null
|
||||
? (
|
||||
<TerminalBlock
|
||||
{...terminal.card}
|
||||
maxLines={Infinity}
|
||||
labels={terminalBlockLabels(t)}
|
||||
className={css.terminal}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className={css.ioCard}>
|
||||
{model.body !== null && (
|
||||
<div className={css.ioSection}>
|
||||
<span className={css.ioLabel}>IN</span>
|
||||
<span className={css.ioText}>{model.body}</span>
|
||||
</div>
|
||||
)}
|
||||
{model.body !== null && model.output !== null && (
|
||||
<span className={css.ioDivider} aria-hidden />
|
||||
)}
|
||||
{model.output !== null && (
|
||||
<div className={css.ioSection}>
|
||||
<span className={css.ioLabel}>OUT</span>
|
||||
<span className={css.ioText} data-error>
|
||||
{model.output}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{inspect !== undefined && (
|
||||
<button type="button" className={css.inspectButton} onClick={inspect}>
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||
|
||||
@@ -414,6 +414,28 @@ describe('BashRow terminal card', () => {
|
||||
}))} />)
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
expect(view.queryByText(/a\.ts/)).toBeNull()
|
||||
expect(view.container.querySelector('[data-sample="bash"]')?.getAttribute('role')).toBeNull()
|
||||
})
|
||||
|
||||
it('expands a generic execution error to its original args and full output', () => {
|
||||
const view = render(<BashRow {...rowProps(settled({
|
||||
content: [{ type: 'text', text: 'Error: command aborted' }],
|
||||
isError: true,
|
||||
callView: { card: 'generic', title: 'ls -la', kind: 'execute' },
|
||||
resultView: { card: 'generic' },
|
||||
}))} />)
|
||||
const row = view.container.querySelector('[data-sample="bash"]')!
|
||||
expect(row.getAttribute('role')).toBe('button')
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(view.queryByText(/"command": "ls -la"/)).toBeNull()
|
||||
|
||||
fireEvent.click(row)
|
||||
|
||||
expect(row.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(view.getByText('IN')).toBeTruthy()
|
||||
expect(view.getByText('OUT')).toBeTruthy()
|
||||
expect(view.getByText(/"command": "ls -la"/)).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-error]')?.textContent).toBe('Error: command aborted')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-subagent/README.md
|
||||
README.md: f6b3fa2e9cdf1479a739e0b4eab15a5423e878e4
|
||||
README.zh.md: fdfba385e9188cd42bd973b6f32bc01fe8d004f2
|
||||
README.md: cb210b219a8c66985eb4e1370468372eed9614b4
|
||||
README.zh.md: 7b87fa1095c404eda96066189b1e4480cd6d4c3c
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Web subagent feature owner: contributes the lazily expandable catalog tree to `conversation.session.header.actions`, reason-specific read-only replacements to the conversation composer chain, and the existing `@` reference source to `ctx.slash`.
|
||||
|
||||
The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity, an optional log-backed title, and session-summary activity time; an unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, and keyboard focus. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only.
|
||||
The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity and an optional log-backed title, while the trailing column stacks total durable provider usage above active-turn duration. Token totals sum the four disjoint `tokenUsage` buckets. Visual duration stays exact to the second below one day, then uses at most two adjacent units—days/hours, approximate months/days, or approximate years/months—while hover and the accessible name retain the exact day/hour/minute/second value. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by its same-cut `active.through`, never by newer session metadata. An unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; a catalog level reserves the disclosure column only when at least one healthy row is a branch, allowing branchless levels to start at the leading status marker. Expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, keyboard focus, and the running-duration clock. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only.
|
||||
|
||||
A one-shot child always elects a read-only composer that identifies the transcript as a completed execution record. A continuable child does so only when its exact parent is unavailable, with copy explaining the recovery path. A continuable child with a live parent keeps the ordinary input chrome, whose Session routes through `subagent.prompt`; running input remains Send because every follow-up joins the child's FIFO inbox, and addressed sessions never expose Stop. This package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md).
|
||||
|
||||
@@ -30,5 +30,5 @@ Append-only. This package never edits earlier request tokens.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The catalog has coarse activity only** — it cannot show durable outcome, elapsed time, Activation identity, or an authority-safe cancel button.
|
||||
- **The catalog has no durable outcome** — activity and timing do not distinguish completion, failure, or cancellation, and the UI exposes neither Activation identity nor an authority-safe cancel button.
|
||||
- **`@` references remain display-title text** — duplicate or renamed labels are ambiguous, so they intentionally do not acquire continuation semantics.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Web subagent 功能 owner:向 `conversation.session.header.actions` 贡献可懒加载展开的目录树,向会话编辑器链贡献按原因区分的只读替代呈现,并保留注册到 `ctx.slash` 的既有 `@` 引用 source。
|
||||
|
||||
页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态、由日志支撑的可选 title 与会话摘要中的活动时间;没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支与键盘焦点。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。
|
||||
页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态和由日志支撑的可选 title,尾随列则在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。视觉耗时在不足一天时精确到秒,达到一天后则最多使用两个相邻单位——天/小时、近似月份/天或近似年份/月份——而悬停信息与无障碍名称会保留精确的天/小时/分钟/秒数值。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;每层目录仅在其中至少一个健康行是分支时才预留展开列,使完全不含分支的层级能从最前面的状态标记开始。展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支、键盘焦点与运行中耗时时钟。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。
|
||||
|
||||
one-shot child 始终选用只读编辑器,并将 transcript(文本记录)说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用时选用只读编辑器,并以文案说明恢复路径。确切 parent 存活时,可继续 child 保留普通输入 chrome,其 Session 会通过 `subagent.prompt` 路由;child 运行期间,输入操作仍为 Send,因为每条后续消息都会进入 child 的 FIFO inbox,且已寻址会话绝不公开 Stop。本包绝不接收宿主 context,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)规定。
|
||||
|
||||
@@ -30,5 +30,5 @@ one-shot child 始终选用只读编辑器,并将 transcript(文本记录)
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **目录只有粗粒度活动状态**:它不能显示持久化结果、耗时、Activation 身份或具备安全授权的取消按钮。
|
||||
- **目录没有持久化结果**:活动状态与计时无法区分完成、失败或取消,且 UI 既不公开 Activation 身份,也不公开具备安全授权的取消按钮。
|
||||
- **`@` 引用仍是显示标题文本**:重复或改名后的 label 会有歧义,因此它们刻意不获得继续执行语义。
|
||||
|
||||
@@ -48,6 +48,8 @@
|
||||
"@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-token-meter": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -59,6 +61,8 @@
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
|
||||
@@ -173,15 +173,28 @@
|
||||
}
|
||||
|
||||
.summary,
|
||||
.time {
|
||||
.metrics {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.time {
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-rows: 18px 16px;
|
||||
flex: none;
|
||||
margin-top: 16px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metricToken {
|
||||
grid-row: 1;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.metricDuration {
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.children {
|
||||
|
||||
@@ -2,7 +2,8 @@ import {
|
||||
useEffect, useRef, useState, type KeyboardEvent, type MouseEvent,
|
||||
} from 'react'
|
||||
import type {
|
||||
SessionId, SessionListState, SessionSummary, SubagentAddress, SubagentCatalogSnapshot,
|
||||
SessionId, SessionListState, SessionProjectionMap, SessionSummary, SubagentAddress,
|
||||
SubagentCatalogSnapshot,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
IconChevronDownOutline14, IconChevronRightOutline14, IconRefreshOutline14, StateDot,
|
||||
@@ -10,6 +11,8 @@ import {
|
||||
import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { NS } from './locales.ts'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {} from '@deepseek-ai/dsh-subagent/client'
|
||||
import type {} from '@deepseek-ai/dsh-token-meter/client'
|
||||
import css from './SubagentCatalogAction.module.css'
|
||||
|
||||
type CatalogEntry = SubagentCatalogSnapshot['entries'][number]
|
||||
@@ -57,23 +60,115 @@ function treeItems(root: HTMLDivElement | null): HTMLElement[] {
|
||||
: Array.from(root.querySelectorAll<HTMLElement>('[role="treeitem"]:not([aria-disabled="true"])'))
|
||||
}
|
||||
|
||||
/** Compact trailing activity time for a catalog row. */
|
||||
function relativeTime(
|
||||
updatedAt: number | undefined,
|
||||
/** Compact token count shared in shape with the conversation stats strip. */
|
||||
function formatTokens(value: number): string {
|
||||
const scaled = (next: number): string => next >= 100
|
||||
? String(Math.round(next))
|
||||
: String(Math.round(next * 10) / 10)
|
||||
if (value < 1_000) return String(value)
|
||||
if (value < 1_000_000) return `${scaled(value / 1_000)}K`
|
||||
return `${scaled(value / 1_000_000)}M`
|
||||
}
|
||||
|
||||
/** Sum the four disjoint durable provider-usage buckets. */
|
||||
function tokenTotal(
|
||||
usage: SessionProjectionMap['tokenUsage'] | undefined,
|
||||
): number | undefined {
|
||||
return usage === undefined
|
||||
? undefined
|
||||
: usage.uncachedInputTokens + usage.outputTokens
|
||||
+ usage.cacheReadTokens + usage.cacheWriteTokens
|
||||
}
|
||||
|
||||
/** Exact whole-second active-turn duration for one catalog row. */
|
||||
function activityDuration(
|
||||
summary: SessionSummary | undefined,
|
||||
activity: 'running' | 'inactive',
|
||||
now: number,
|
||||
t: TranslateNS<typeof NS>,
|
||||
): string | undefined {
|
||||
if (updatedAt === undefined) return undefined
|
||||
const minute = 60_000
|
||||
const hour = 60 * minute
|
||||
const day = 24 * hour
|
||||
const diff = Math.max(0, now - updatedAt)
|
||||
if (diff < minute) return t('time.justNow')
|
||||
if (diff < hour) return t('time.minutes', { n: Math.floor(diff / minute) })
|
||||
if (diff < day) return t('time.hours', { n: Math.floor(diff / hour) })
|
||||
if (diff < 30 * day) return t('time.days', { n: Math.floor(diff / day) })
|
||||
if (diff < 365 * day) return t('time.months', { n: Math.floor(diff / (30 * day)) })
|
||||
return t('time.years', { n: Math.floor(diff / (365 * day)) })
|
||||
): number | undefined {
|
||||
if (summary === undefined) return undefined
|
||||
const timing: SessionProjectionMap['subagentTiming'] | undefined
|
||||
= summary.projectionValues?.subagentTiming
|
||||
if (timing === undefined) return undefined
|
||||
if (timing.active === undefined) return timing.settledMs
|
||||
const end = activity === 'running'
|
||||
? now
|
||||
: timing.active.through
|
||||
return timing.settledMs + Math.max(0, end - timing.active.since)
|
||||
}
|
||||
|
||||
interface DurationParts {
|
||||
seconds: number
|
||||
minutes: number
|
||||
hours: number
|
||||
days: number
|
||||
totalMinutes: number
|
||||
totalHours: number
|
||||
}
|
||||
|
||||
function splitDuration(ms: number): DurationParts {
|
||||
const totalSeconds = Math.floor(Math.max(0, ms) / 1_000)
|
||||
const totalMinutes = Math.floor(totalSeconds / 60)
|
||||
const totalHours = Math.floor(totalMinutes / 60)
|
||||
return {
|
||||
seconds: totalSeconds % 60,
|
||||
minutes: totalMinutes % 60,
|
||||
hours: totalHours % 24,
|
||||
days: Math.floor(totalHours / 24),
|
||||
totalMinutes,
|
||||
totalHours,
|
||||
}
|
||||
}
|
||||
|
||||
/** Format a duration with decreasing visual precision at larger scales. */
|
||||
function formatDuration(ms: number, t: TranslateNS<typeof NS>): string {
|
||||
const { seconds, minutes, hours, days, totalMinutes, totalHours } = splitDuration(ms)
|
||||
if (days >= 365) {
|
||||
const years = Math.floor(days / 365)
|
||||
const months = Math.floor((days % 365) / 30)
|
||||
return months === 0
|
||||
? t('duration.years', { years })
|
||||
: t('duration.yearsMonths', { years, months })
|
||||
}
|
||||
if (days >= 30) {
|
||||
const months = Math.floor(days / 30)
|
||||
const remainingDays = days % 30
|
||||
return remainingDays === 0
|
||||
? t('duration.months', { months })
|
||||
: t('duration.monthsDays', { months, days: remainingDays })
|
||||
}
|
||||
if (days > 0) {
|
||||
return hours === 0
|
||||
? t('duration.days', { days })
|
||||
: t('duration.daysHours', { days, hours })
|
||||
}
|
||||
if (totalHours > 0) {
|
||||
return t('duration.hours', {
|
||||
hours: totalHours,
|
||||
minutes: String(minutes).padStart(2, '0'),
|
||||
seconds: String(seconds).padStart(2, '0'),
|
||||
})
|
||||
}
|
||||
if (totalMinutes > 0) {
|
||||
return t('duration.minutes', {
|
||||
minutes: totalMinutes,
|
||||
seconds: String(seconds).padStart(2, '0'),
|
||||
})
|
||||
}
|
||||
return t('duration.seconds', { seconds })
|
||||
}
|
||||
|
||||
/** Preserve exact whole seconds for hover and accessible naming. */
|
||||
function formatExactDuration(ms: number, t: TranslateNS<typeof NS>): string {
|
||||
const { seconds, minutes, hours, days } = splitDuration(ms)
|
||||
return days === 0
|
||||
? formatDuration(ms, t)
|
||||
: t('duration.exactDays', {
|
||||
days,
|
||||
hours: String(hours).padStart(2, '0'),
|
||||
minutes: String(minutes).padStart(2, '0'),
|
||||
seconds: String(seconds).padStart(2, '0'),
|
||||
})
|
||||
}
|
||||
|
||||
/** Aggregate the complete subagent-only descendant subtree from flat summaries. */
|
||||
@@ -142,6 +237,9 @@ function CatalogRows({
|
||||
openChild, refresh, toggleBranch, closeCatalog, t,
|
||||
}: CatalogRowsProps & { t: TranslateNS<typeof NS> }) {
|
||||
const emptyLoading = catalog.state === 'loading' && catalog.entries.length === 0
|
||||
const reserveDisclosure = catalog.entries.some(
|
||||
entry => entry.kind === 'child' && entry.hasChildren,
|
||||
)
|
||||
return (
|
||||
<>
|
||||
{emptyLoading && (
|
||||
@@ -178,7 +276,7 @@ function CatalogRows({
|
||||
className={`${css.row} ${css.disabled}`}
|
||||
title={reason}
|
||||
>
|
||||
<span className={css.disclosureSpace} />
|
||||
{reserveDisclosure && <span className={css.disclosureSpace} />}
|
||||
<StateDot state="error" />
|
||||
<span className={css.content}>
|
||||
<span className={css.label}>{entry.id}</span>
|
||||
@@ -201,7 +299,24 @@ function CatalogRows({
|
||||
const secondary = [summary?.title, mode, activity]
|
||||
.filter(value => value !== undefined)
|
||||
.join(' · ')
|
||||
const time = relativeTime(summary?.updatedAt, now, t)
|
||||
const totalTokens = tokenTotal(summary?.projectionValues?.tokenUsage)
|
||||
const durationMs = activityDuration(
|
||||
summary,
|
||||
entry.activity,
|
||||
now,
|
||||
)
|
||||
const tokenMetric = totalTokens === undefined
|
||||
? undefined
|
||||
: `${formatTokens(totalTokens)} tok`
|
||||
const durationMetric = durationMs === undefined
|
||||
? undefined
|
||||
: {
|
||||
compact: formatDuration(durationMs, t),
|
||||
exact: formatExactDuration(durationMs, t),
|
||||
}
|
||||
const metrics = [tokenMetric, durationMetric?.exact]
|
||||
.filter(value => value !== undefined)
|
||||
.join(' · ')
|
||||
|
||||
const open = (): void => {
|
||||
openChild({ parentSessionId, childSessionId: entry.id, mode: entry.mode })
|
||||
@@ -233,14 +348,14 @@ function CatalogRows({
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
aria-level={level}
|
||||
aria-label={[label, secondary, time].filter(value => value !== undefined).join(' ')}
|
||||
aria-label={[label, secondary, metrics].filter(value => value !== '').join(' ')}
|
||||
{...knownLeaf ? {} : { 'aria-expanded': isExpanded }}
|
||||
className={css.row}
|
||||
onClick={open}
|
||||
onKeyDown={handleKey}
|
||||
>
|
||||
{knownLeaf
|
||||
? <span className={css.disclosureSpace} />
|
||||
? reserveDisclosure && <span className={css.disclosureSpace} />
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
@@ -258,7 +373,19 @@ function CatalogRows({
|
||||
<span className={css.label}>{label}</span>
|
||||
<span className={css.summary}>{secondary}</span>
|
||||
</span>
|
||||
{time !== undefined && <span className={css.time}>{time}</span>}
|
||||
{metrics !== '' && (
|
||||
<span className={css.metrics}>
|
||||
{tokenMetric !== undefined && <span className={css.metricToken}>{tokenMetric}</span>}
|
||||
{durationMetric !== undefined && (
|
||||
<span
|
||||
className={css.metricDuration}
|
||||
title={t('duration.exactTitle', { duration: durationMetric.exact })}
|
||||
>
|
||||
{durationMetric.compact}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && !knownLeaf && (
|
||||
@@ -313,6 +440,7 @@ export function SubagentCatalogAction({
|
||||
const summaries = useSessions(state => state.byId)
|
||||
const catalog = catalogs[sessionId]
|
||||
const [open, setOpen] = useState(false)
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const [expanded, setExpanded] = useState<ReadonlySet<SessionId>>(() => new Set())
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
@@ -355,7 +483,10 @@ export function SubagentCatalogAction({
|
||||
|
||||
const changeOpen = (next: boolean, restoreFocus = false): void => {
|
||||
setOpen(next)
|
||||
if (next) observeCatalog(sessionId, true)
|
||||
if (next) {
|
||||
setNow(Date.now())
|
||||
observeCatalog(sessionId, true)
|
||||
}
|
||||
else closeAllCatalogs()
|
||||
if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() })
|
||||
}
|
||||
@@ -395,6 +526,12 @@ export function SubagentCatalogAction({
|
||||
return () => { document.removeEventListener('pointerdown', closeOutside) }
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !descendants.running) return
|
||||
const timer = setInterval(() => { setNow(Date.now()) }, 1_000)
|
||||
return () => { clearInterval(timer) }
|
||||
}, [open, descendants.running])
|
||||
|
||||
useEffect(() => () => {
|
||||
for (const parentSessionId of observedCatalogs.current) {
|
||||
setCatalogOpenRef.current(parentSessionId, false)
|
||||
@@ -471,7 +608,7 @@ export function SubagentCatalogAction({
|
||||
summaries={summaries}
|
||||
expanded={expanded}
|
||||
level={1}
|
||||
now={Date.now()}
|
||||
now={now}
|
||||
openChild={openChild}
|
||||
refresh={refresh}
|
||||
toggleBranch={toggleBranch}
|
||||
|
||||
@@ -8,12 +8,17 @@ export const zh = {
|
||||
'diagnostic.corrupt': '会话记录损坏',
|
||||
'diagnostic.unsupported': '子代理记录版本不受支持',
|
||||
'diagnostic.unavailable': '会话记录暂不可用',
|
||||
'time.justNow': '刚刚',
|
||||
'time.minutes': '{n}分钟',
|
||||
'time.hours': '{n}小时',
|
||||
'time.days': '{n}天',
|
||||
'time.months': '{n}个月',
|
||||
'time.years': '{n}年',
|
||||
'duration.seconds': '{seconds}秒',
|
||||
'duration.minutes': '{minutes}分{seconds}秒',
|
||||
'duration.hours': '{hours}小时{minutes}分{seconds}秒',
|
||||
'duration.days': '{days}天',
|
||||
'duration.daysHours': '{days}天{hours}小时',
|
||||
'duration.months': '约{months}个月',
|
||||
'duration.monthsDays': '约{months}个月{days}天',
|
||||
'duration.years': '约{years}年',
|
||||
'duration.yearsMonths': '约{years}年{months}个月',
|
||||
'duration.exactDays': '{days}天{hours}小时{minutes}分{seconds}秒',
|
||||
'duration.exactTitle': '总活跃耗时:{duration}',
|
||||
'loading.label': '正在加载子代理…',
|
||||
'loading.aria': '正在加载子代理',
|
||||
'load.error': '无法加载子代理',
|
||||
@@ -40,12 +45,17 @@ export const en: Record<SubagentKey, string> = {
|
||||
'diagnostic.corrupt': 'corrupted session record',
|
||||
'diagnostic.unsupported': 'unsupported subagent record version',
|
||||
'diagnostic.unavailable': 'session record temporarily unavailable',
|
||||
'time.justNow': 'just now',
|
||||
'time.minutes': '{n}m',
|
||||
'time.hours': '{n}h',
|
||||
'time.days': '{n}d',
|
||||
'time.months': '{n}mo',
|
||||
'time.years': '{n}y',
|
||||
'duration.seconds': '{seconds}s',
|
||||
'duration.minutes': '{minutes}m {seconds}s',
|
||||
'duration.hours': '{hours}h {minutes}m {seconds}s',
|
||||
'duration.days': '{days}d',
|
||||
'duration.daysHours': '{days}d {hours}h',
|
||||
'duration.months': '~{months}mo',
|
||||
'duration.monthsDays': '~{months}mo {days}d',
|
||||
'duration.years': '~{years}y',
|
||||
'duration.yearsMonths': '~{years}y {months}mo',
|
||||
'duration.exactDays': '{days}d {hours}h {minutes}m {seconds}s',
|
||||
'duration.exactTitle': 'Total active duration: {duration}',
|
||||
'loading.label': 'Loading subagents…',
|
||||
'loading.aria': 'Loading subagents',
|
||||
'load.error': 'Unable to load subagents',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type {
|
||||
SessionId, SessionListState, SessionSummary, SubagentCatalogSnapshot,
|
||||
@@ -13,6 +13,7 @@ import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
@@ -150,6 +151,7 @@ describe('SubagentCatalogAction', () => {
|
||||
expect(diagnostic.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(screen.getByRole('button', { name: '展开 worker 的下级子代理' })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: '展开 reviewer 的下级子代理' })).toBeNull()
|
||||
expect(screen.getByRole('treeitem', { name: /reviewer/ }).children).toHaveLength(2)
|
||||
|
||||
fireEvent.click(screen.getByRole('treeitem', { name: /worker/ }))
|
||||
expect(input.openChild).toHaveBeenCalledWith({
|
||||
@@ -176,6 +178,19 @@ describe('SubagentCatalogAction', () => {
|
||||
expect(translate).toHaveBeenCalledWith('count.total.one', { count: 1 })
|
||||
})
|
||||
|
||||
it('removes the disclosure column from branchless catalog levels', () => {
|
||||
const input = props(catalog({
|
||||
entries: [{
|
||||
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
|
||||
activity: 'running', hasChildren: false,
|
||||
}],
|
||||
}))
|
||||
render(<SubagentCatalogAction {...input} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /1 个子代理/ }))
|
||||
|
||||
expect(screen.getByRole('treeitem', { name: /worker/ }).children).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('supports trigger/menu keyboard traversal, Escape focus restore, and outside close', async () => {
|
||||
const input = props(catalog())
|
||||
render(<SubagentCatalogAction {...input} />)
|
||||
@@ -239,42 +254,99 @@ describe('SubagentCatalogAction', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('renders compact activity times across every unit and clamps future timestamps', () => {
|
||||
it('shows durable token totals, ticks active duration by seconds, and freezes inactive rows', async () => {
|
||||
const now = 2_000_000_000_000
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now)
|
||||
const minute = 60_000
|
||||
const hour = 60 * minute
|
||||
const day = 24 * hour
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(now)
|
||||
const rows = [
|
||||
['future', now + minute],
|
||||
['minutes', now - 2 * minute],
|
||||
['hours', now - 2 * hour],
|
||||
['days', now - 2 * day],
|
||||
['months', now - 60 * day],
|
||||
['years', now - 2 * 365 * day],
|
||||
['running', 'running', 65_000, now - 5_000, now - 1_000, now],
|
||||
['finished', 'inactive', 3_723_000, undefined, undefined, now - 60_000],
|
||||
['interrupted', 'inactive', 2_000, now - 7_000, now - 3_000, now + 60_000],
|
||||
['days', 'inactive', 12 * day + 5 * hour + 6 * minute + 7_000, undefined, undefined, now],
|
||||
['whole-day', 'inactive', day, undefined, undefined, now],
|
||||
['months', 'inactive', 192 * day, undefined, undefined, now],
|
||||
['whole-month', 'inactive', 30 * day, undefined, undefined, now],
|
||||
['years', 'inactive', 832 * day, undefined, undefined, now],
|
||||
['whole-year', 'inactive', 365 * day, undefined, undefined, now],
|
||||
] as const
|
||||
const entries = rows.map(([id]) => ({
|
||||
const usageById = {
|
||||
running: {
|
||||
uncachedInputTokens: 1_000,
|
||||
outputTokens: 200,
|
||||
cacheReadTokens: 3_000,
|
||||
cacheWriteTokens: 400,
|
||||
},
|
||||
finished: {
|
||||
uncachedInputTokens: 123,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
},
|
||||
interrupted: {
|
||||
uncachedInputTokens: 123_000_000,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
},
|
||||
} as const
|
||||
const entries = rows.map(([id, activity]) => ({
|
||||
kind: 'child' as const,
|
||||
id: id as SessionId,
|
||||
mode: 'continuable' as const,
|
||||
label: id,
|
||||
activity: 'inactive' as const,
|
||||
activity,
|
||||
hasChildren: false,
|
||||
}))
|
||||
const summaries = Object.fromEntries(rows.map(([id, updatedAt]) => [
|
||||
id,
|
||||
summary(id as SessionId, updatedAt),
|
||||
])) as Record<SessionId, SessionSummary>
|
||||
const summaries = Object.fromEntries(rows.map(([
|
||||
id, activity, settledMs, activeSince, activeThrough, updatedAt,
|
||||
]) => {
|
||||
const childId = id as SessionId
|
||||
return [id, {
|
||||
...summary(childId, updatedAt),
|
||||
parentId: PARENT,
|
||||
origin: 'subagent' as const,
|
||||
running: activity === 'running',
|
||||
projectionValues: {
|
||||
subagentTiming: {
|
||||
settledMs,
|
||||
...(activeSince === undefined || activeThrough === undefined
|
||||
? {}
|
||||
: { active: { since: activeSince, through: activeThrough } }),
|
||||
},
|
||||
tokenUsage: id in usageById
|
||||
? usageById[id as keyof typeof usageById]
|
||||
: undefined,
|
||||
},
|
||||
}]
|
||||
})) as Record<SessionId, SessionSummary>
|
||||
const input = props(catalog({ entries }), {}, summaries)
|
||||
render(<SubagentCatalogAction {...input} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /6 个子代理/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /9 个子代理/ }))
|
||||
|
||||
expect(screen.getByRole('treeitem', { name: /future.*刚刚/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /minutes.*2分钟/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /hours.*2小时/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /days.*2天/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /months.*2个月/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /years.*2年/ })).toBeTruthy()
|
||||
const runningRow = screen.getByRole('treeitem', { name: /running.*4\.6K tok · 1分10秒/ })
|
||||
const runningMetrics = within(runningRow)
|
||||
const tokenMetric = runningMetrics.getByText('4.6K tok')
|
||||
const durationMetric = runningMetrics.getByText('1分10秒')
|
||||
expect(tokenMetric.parentElement).toBe(durationMetric.parentElement)
|
||||
expect(tokenMetric.nextElementSibling).toBe(durationMetric)
|
||||
expect(screen.getByRole('treeitem', { name: /finished.*123 tok · 1小时02分03秒/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /interrupted.*123M tok · 6秒/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /days.*12天05小时06分07秒/ })).toBeTruthy()
|
||||
expect(screen.getByText('12天5小时').getAttribute('title'))
|
||||
.toBe('总活跃耗时:12天05小时06分07秒')
|
||||
expect(screen.getByText('1天')).toBeTruthy()
|
||||
expect(screen.getByText('约6个月12天')).toBeTruthy()
|
||||
expect(screen.getByText('约1个月')).toBeTruthy()
|
||||
expect(screen.getByText('约2年3个月')).toBeTruthy()
|
||||
expect(screen.getByText('约1年')).toBeTruthy()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(screen.getByRole('treeitem', { name: /running.*4\.6K tok · 1分11秒/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /finished.*123 tok · 1小时02分03秒/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /interrupted.*123M tok · 6秒/ })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('lazily expands and collapses descendant catalogs with direct-parent navigation', () => {
|
||||
|
||||
@@ -29,6 +29,12 @@
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/token-meter"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md
|
||||
README.md: 9aea27a0f150d90a41d9a7cb4cd422a75e6107fe
|
||||
README.zh.md: 3f0b534deae53b8d5aff2765974050f26b931953
|
||||
README.md: e54f0b98ec3649cec428a47026e6657a9749608b
|
||||
README.zh.md: 1624fa59854d9b61770c5ef0f9d89f7882198da4
|
||||
|
||||
@@ -92,6 +92,8 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen
|
||||
|
||||
Continuable children do not create `SubagentRun` or Tasks. The continuation manager directly owns one process-local Activation and retained `AgentHandle` per resident child Session, uses the Agent inbox as the only FIFO, and cold-resumes from the durable descriptor. Exact live direct-parent identity authorizes parent-to-child delivery. Exact live child identity authorizes reports; the manager derives the recipient from durable `parentSession`, and `MessageSource` remains provenance rather than authority.
|
||||
|
||||
When `ctx.sessionProjections` is available, the service registers `subagentTiming`. The projection resets at each descriptor so a fork seed's ancestor work cannot enter the child's total, then accumulates `turn/start` → `turn/end` active time and retains same-cut `active.since` and `active.through` bounds for an open turn. While that turn remains open, `active.through` follows the latest folded event, giving an inactive consumer a conservative crash bound without mixing in newer session metadata.
|
||||
|
||||
`registerContinuableSetup()` lets optional packages add child-scoped capabilities without teaching the continuation manager their names. Contributions install synchronously before Activation publication, roll back with failed setup, and are released with the child scope. New grants wait for the next Activation, while contribution removal revokes every resident installation immediately.
|
||||
|
||||
## Collection model
|
||||
|
||||
@@ -92,6 +92,8 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
可继续子级不会创建 `SubagentRun` 或 Task。延续管理器为每个驻留子 Session 直接拥有一个仅存在于当前进程的 Activation 和一个留存的 `AgentHandle`,使用 Agent inbox 作为唯一 FIFO,并从持久化描述符冷恢复。父到子投递由准确的实时直接父级身份授权。上报则由准确的实时子级身份授权;管理器根据持久化的 `parentSession` 推导接收方,`MessageSource` 仍只表示来源,不表示权限。
|
||||
|
||||
当 `ctx.sessionProjections` 可用时,服务会注册 `subagentTiming`。该投影会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start` → `turn/end` 活跃时间,并为未结束的轮次保留同一切面的 `active.since` 和 `active.through` 边界。在该轮次保持未结束期间,`active.through` 会跟随最近折叠的事件,从而为 inactive 消费方提供保守的崩溃上界,又不会混入更新的会话元数据。
|
||||
|
||||
`registerContinuableSetup()` 允许可选包添加子级作用域功能,而无需让延续管理器知道这些功能的名称。贡献会在 Activation 发布前同步安装,在设置失败时一并回滚,并随子级作用域释放。新授权须等到下一个 Activation,移除贡献则会立即撤销每个驻留安装项。
|
||||
|
||||
## 收集模型
|
||||
|
||||
@@ -15,17 +15,25 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client.d.ts",
|
||||
"default": "./lib/types/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
@@ -35,6 +43,7 @@
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-query": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-projection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
@@ -46,6 +55,9 @@
|
||||
"@deepseek-ai/dsh-session-query": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-session-projection": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-tasks": {
|
||||
"optional": true
|
||||
}
|
||||
@@ -59,6 +71,7 @@
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
|
||||
7
packages/subagent/subagent/src/client.ts
Normal file
7
packages/subagent/subagent/src/client.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Browser-safe subagent projection vocabulary.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent/client
|
||||
*/
|
||||
|
||||
export type { SubagentTimingProjection } from './projection-types.ts'
|
||||
@@ -65,6 +65,7 @@ import type { ContinuableSetupContribution } from './activation-setup-registry.t
|
||||
import { listChildren as listSubagentChildren } from './list-children.ts'
|
||||
import type { SubagentListEntry } from './list-children.ts'
|
||||
import { snapshotSubagentDescriptor } from './descriptor.ts'
|
||||
import { subagentTimingProjectionDefinition } from './projection.ts'
|
||||
|
||||
export * from './out-of-process.ts'
|
||||
export { SubagentRunId } from './types.ts'
|
||||
@@ -117,6 +118,7 @@ export type {
|
||||
export type { ContinuableSetupContribution } from './activation-setup-registry.ts'
|
||||
export type { SubagentListEntry } from './list-children.ts'
|
||||
export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts'
|
||||
export type { SubagentTimingProjection } from './projection-types.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
@@ -186,6 +188,9 @@ export class SubagentService extends Service {
|
||||
if (this.continuations === manager) this.continuations = undefined
|
||||
}, 'subagents.continuationBinding()')
|
||||
})
|
||||
ctx.inject(['sessionProjections'], (projectionCtx) => {
|
||||
projectionCtx.sessionProjections.register(subagentTimingProjectionDefinition)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
25
packages/subagent/subagent/src/projection-types.ts
Normal file
25
packages/subagent/subagent/src/projection-types.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Pure client-safe subagent projection vocabulary.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent/projection-types
|
||||
*/
|
||||
|
||||
/** Durable active-turn timing for one descriptor-backed child session. */
|
||||
export interface SubagentTimingProjection {
|
||||
/** Milliseconds accumulated across completed turns after the child's own descriptor. */
|
||||
settledMs: number
|
||||
/** Same-cut bounds of the currently open turn, when one has not reached `turn/end`. */
|
||||
active?: {
|
||||
/** Start of the open turn. */
|
||||
since: number
|
||||
/** Latest event time folded into this projection cut. */
|
||||
through: number
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionMap {
|
||||
/** Active-turn duration for a descriptor-backed subagent session. */
|
||||
subagentTiming: SubagentTimingProjection
|
||||
}
|
||||
}
|
||||
82
packages/subagent/subagent/src/projection.ts
Normal file
82
packages/subagent/subagent/src/projection.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Pure session projection for subagent active-turn duration.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent/projection
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
||||
import type { SubagentTimingProjection } from './projection-types.ts'
|
||||
|
||||
interface TimingState {
|
||||
/** Milliseconds accumulated across completed post-descriptor turns. */
|
||||
settledMs: number
|
||||
/** Current open interval kept paired inside the fold. */
|
||||
active?: { since: number; through: number }
|
||||
/** Latest pre-descriptor turn start, promoted when the child's own descriptor arrives. */
|
||||
pendingTurnStart?: number
|
||||
/** Whether the fold has crossed a descriptor in this logical log. */
|
||||
descriptorSeen: boolean
|
||||
}
|
||||
|
||||
// Zod's optional output includes explicit `undefined`; with
|
||||
// exactOptionalPropertyTypes the public interface permits omission only.
|
||||
const projectionSchema = z.object({
|
||||
settledMs: z.number().int().nonnegative(),
|
||||
active: z.object({
|
||||
since: z.number().int().nonnegative(),
|
||||
through: z.number().int().nonnegative(),
|
||||
}).strict().optional(),
|
||||
}).strict() as unknown as z.ZodType<SubagentTimingProjection>
|
||||
|
||||
/**
|
||||
* Fold turn boundaries around the child's own durable descriptor.
|
||||
*
|
||||
* A fork seed may contain an ancestor descriptor and completed turns. Every
|
||||
* descriptor therefore resets the accumulated state; the healthy catalog
|
||||
* admits only a child with exactly one descriptor in its own suffix, making
|
||||
* the final reset the child's authoritative timing origin.
|
||||
*/
|
||||
export const subagentTimingProjectionDefinition:
|
||||
ProjectionDefinition<'subagentTiming', TimingState> = {
|
||||
key: 'subagentTiming',
|
||||
schema: projectionSchema,
|
||||
init: () => ({ descriptorSeen: false, settledMs: 0 }),
|
||||
apply: (state, event) => {
|
||||
if (event.type === 'turn/start') {
|
||||
return state.descriptorSeen
|
||||
? { ...state, active: { since: event.time, through: event.time } }
|
||||
: { ...state, pendingTurnStart: event.time }
|
||||
}
|
||||
if (event.type === 'subagent/descriptor') {
|
||||
const activeSince = state.active?.since ?? state.pendingTurnStart
|
||||
return {
|
||||
descriptorSeen: true,
|
||||
settledMs: 0,
|
||||
...(activeSince === undefined
|
||||
? {}
|
||||
: { active: { since: activeSince, through: event.time } }),
|
||||
}
|
||||
}
|
||||
if (event.type === 'turn/end') {
|
||||
if (!state.descriptorSeen) {
|
||||
if (state.pendingTurnStart === undefined) return state
|
||||
const { pendingTurnStart: _closed, ...next } = state
|
||||
return next
|
||||
}
|
||||
if (state.active === undefined) return state
|
||||
const { active, ...rest } = state
|
||||
return {
|
||||
...rest,
|
||||
settledMs: state.settledMs + Math.max(0, event.time - active.since),
|
||||
}
|
||||
}
|
||||
if (state.active === undefined) return state
|
||||
return { ...state, active: { ...state.active, through: event.time } }
|
||||
},
|
||||
view: state => ({
|
||||
settledMs: state.settledMs,
|
||||
...(state.active === undefined ? {} : { active: state.active }),
|
||||
}),
|
||||
stateVersion: 2,
|
||||
}
|
||||
80
packages/subagent/subagent/tests/timing-projection.spec.ts
Normal file
80
packages/subagent/subagent/tests/timing-projection.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import SubagentService from '../src/index.ts'
|
||||
import { subagentTimingProjectionDefinition } from '../src/projection.ts'
|
||||
|
||||
function event(type: SessionEvent['type'], seq: number, time: number): SessionEvent {
|
||||
return { type, seq, time, data: {} } as SessionEvent
|
||||
}
|
||||
|
||||
function fold(events: SessionEvent[]) {
|
||||
let state = subagentTimingProjectionDefinition.init()
|
||||
for (const item of events) state = subagentTimingProjectionDefinition.apply(state, item)
|
||||
return subagentTimingProjectionDefinition.view(state)
|
||||
}
|
||||
|
||||
describe('subagent timing projection', () => {
|
||||
it('registers with the optional session projection registry', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
const serviceFiber = await ctx.plugin(SubagentService)
|
||||
|
||||
expect(ctx.sessionProjections.snapshot(ctx.sessions.create()).values.subagentTiming)
|
||||
.toEqual({ settledMs: 0 })
|
||||
await serviceFiber.dispose()
|
||||
expect(ctx.sessionProjections.snapshot(ctx.sessions.create()).values.subagentTiming)
|
||||
.toBeUndefined()
|
||||
})
|
||||
|
||||
it('resets inherited seed timing at the child descriptor and sums later completed turns', () => {
|
||||
expect(fold([
|
||||
event('turn/start', 0, 100),
|
||||
event('subagent/descriptor', 1, 110),
|
||||
event('turn/end', 2, 300),
|
||||
event('turn/start', 3, 1_000),
|
||||
event('subagent/descriptor', 4, 1_100),
|
||||
event('turn/end', 5, 4_100),
|
||||
event('turn/start', 6, 10_000),
|
||||
event('turn/end', 7, 12_000),
|
||||
])).toEqual({ settledMs: 5_100 })
|
||||
})
|
||||
|
||||
it('exposes an open turn start and never subtracts time for reversed boundaries', () => {
|
||||
expect(fold([
|
||||
event('turn/start', 0, 1_000),
|
||||
event('subagent/descriptor', 1, 1_100),
|
||||
event('turn/end', 2, 900),
|
||||
event('turn/start', 3, 2_000),
|
||||
event('assistant/chunk', 4, 2_500),
|
||||
])).toEqual({ settledMs: 0, active: { since: 2_000, through: 2_500 } })
|
||||
})
|
||||
|
||||
it('ignores completed pre-descriptor turns and unrelated events', () => {
|
||||
const initial = subagentTimingProjectionDefinition.init()
|
||||
expect(subagentTimingProjectionDefinition.apply(
|
||||
initial,
|
||||
event('assistant/chunk', 0, 1),
|
||||
)).toBe(initial)
|
||||
expect(subagentTimingProjectionDefinition.apply(
|
||||
initial,
|
||||
event('turn/end', 1, 2),
|
||||
)).toBe(initial)
|
||||
const descriptor = subagentTimingProjectionDefinition.apply(
|
||||
initial,
|
||||
event('subagent/descriptor', 2, 3),
|
||||
)
|
||||
expect(subagentTimingProjectionDefinition.apply(
|
||||
descriptor,
|
||||
event('turn/end', 3, 4),
|
||||
)).toBe(descriptor)
|
||||
expect(fold([
|
||||
event('turn/start', 0, 100),
|
||||
event('turn/end', 1, 200),
|
||||
event('subagent/descriptor', 2, 300),
|
||||
])).toEqual({ settledMs: 0 })
|
||||
})
|
||||
})
|
||||
@@ -32,6 +32,9 @@
|
||||
{
|
||||
"path": "../../session-query/session-query"
|
||||
},
|
||||
{
|
||||
"path": "../../session-projection/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
},
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/ui/app-boot/README.md
|
||||
README.md: 2eb9e904d574df39b0884558fc0a53f9dc04cdc1
|
||||
README.zh.md: 78bd99943fcadebf42a5d772d49f3bfcf6a8790a
|
||||
README.md: 7e0466c40583e6f5b22e0d5ef25d211d595c3216
|
||||
README.zh.md: abb796aaa9fd6f8e6ee0578423382ed7f23909ab
|
||||
|
||||
@@ -8,7 +8,8 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c
|
||||
|---|---|
|
||||
| `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` |
|
||||
| `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) |
|
||||
| `installFailLoud(binName, proc?)` | Turn an unhandled boot or later Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) |
|
||||
| `installFailLoud(binName, proc?, release?)` | Turn an unhandled boot or later Loader rejection into one labelled stderr line + `exit(1)`; the optional `release` teardown is awaited between the two (bounded by `FAIL_LOUD_RELEASE_TIMEOUT_MS`) so a terminal-owning surface restores the terminal before exit; returns the uninstaller (for tests) |
|
||||
| `FAIL_LOUD_RELEASE_TIMEOUT_MS` | How long `installFailLoud` waits for its `release` hook; a wedged disposer delays the fatal exit, never cancels it |
|
||||
| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure |
|
||||
| `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services |
|
||||
| `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
|
||||
@@ -22,6 +23,8 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c
|
||||
|
||||
Loader settlement rejects import and lifecycle failures with the failing entry and stage; `boot()` disposes the partial context and wraps that failure with the bin name. Entries settlement leaves behind are audited separately: `assertEntriesLoaded` turns an enabled fiber-less entry into a rejection naming every unresolved plugin, and `assertEntriesActivated` awaits each failed fiber to include its original stack in the startup rejection and names each pending entry's unresolved services. Before throwing, the audit marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while every unrelated unhandled rejection remains fatal.
|
||||
|
||||
The Loader mounts entries concurrently, so a surface can already own the terminal when something else fails: exiting without the tree's own teardown would leave raw mode, bracketed paste, and the keyboard protocol set on the user's shell, and an in-flight terminal query's reply would land as literal text at the next prompt. A config-tree failure settles through `boot()`, whose disposal of the partial context runs the surface's own shutdown before the labelled rejection. For the rejections `boot()` cannot see — a plugin's detached async work rejecting during or after mounting — a terminal-owning bin passes `release` to dispose the tree before the exit commits; `dsh` captures the root context in `boot()`'s `prepare` hook rather than from its return value so the hook covers the whole mounting window. While a release is in flight the handler stays installed and latched: the first rejection is the reported one, and later rejections (teardown's own included) are swallowed rather than becoming uncaught and killing the process mid-teardown.
|
||||
|
||||
Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers.
|
||||
|
||||
This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution.
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
|---|---|
|
||||
| `resolveConfigPath(path, snapshotMode, cwd?)` | 生成绝对配置路径;当 `snapshotMode === 'replay'` 时,把 basename 为 `cordis.yml`/`.yaml` 的文件替换为同级 `cordis.snapshot.yml` |
|
||||
| `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) |
|
||||
| `installFailLoud(binName, proc?)` | 将启动期或后续未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) |
|
||||
| `installFailLoud(binName, proc?, release?)` | 将启动期或后续未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;两者之间会等待可选的 `release` 拆卸回调(以 `FAIL_LOUD_RELEASE_TIMEOUT_MS` 为上限),使持有终端的界面能在退出前恢复终端;返回卸载函数(供测试使用) |
|
||||
| `FAIL_LOUD_RELEASE_TIMEOUT_MS` | `installFailLoud` 等待其 `release` 回调的时长;卡住的 disposer 只会延迟致命退出,而不会取消它 |
|
||||
| `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 |
|
||||
| `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 |
|
||||
| `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 |
|
||||
@@ -22,6 +23,8 @@
|
||||
|
||||
Loader 结算会在导入或生命周期失败时 reject,并携带失败的配置项与阶段;`boot()` 会 dispose 部分构造的上下文,并用 bin 名称包装该失败。结算后遗留的配置项由独立审计处理:`assertEntriesLoaded` 将已启用却没有 fiber 的配置项转换为 rejection 并列出每个未解析插件;`assertEntriesActivated` 会显式等待每个失败的 fiber,把原始错误堆栈写入启动 rejection,并列出每个等待中配置项尚未解析的服务。抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。
|
||||
|
||||
Loader 并发挂载各个条目,因此当其他环节失败时,某个界面可能已经持有终端:此时不经过整棵树自身的拆卸就退出,会把 raw 模式、bracketed paste 和键盘协议残留在用户的 shell 上,而尚未返回的终端查询响应会在下一个提示符处显示为字面文本。配置树失败会经 `boot()` 结算:它先释放部分构建的上下文(从而执行该界面自身的 shutdown),再抛出带标签的 rejection。对于 `boot()` 看不到的 rejection(插件游离的异步工作在挂载期间或挂载完成后失败),持有终端的 bin 会传入 `release`,在提交退出前释放整棵树;`dsh` 在 `boot()` 的 `prepare` 回调中捕获根上下文,而不是取其返回值,使该回调覆盖整个挂载窗口。release 执行期间处理函数保持注册并加闩:被报告的始终是第一个 rejection,后续 rejection(包括拆卸自身的)会被吞掉,而不会变成未捕获错误、在拆卸中途杀死进程。
|
||||
|
||||
配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与 host 会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。
|
||||
|
||||
此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。
|
||||
|
||||
@@ -391,6 +391,11 @@ export interface FailLoudProcess {
|
||||
on(event: 'unhandledRejection', handler: (err: unknown) => void): unknown
|
||||
off(event: 'unhandledRejection', handler: (err: unknown) => void): unknown
|
||||
stderr: { write(chunk: string): unknown }
|
||||
/**
|
||||
* Terminate the process. Callers treat this as the end of the run, as
|
||||
* `process.exit` is; a fake that returns lets the caller continue, which only
|
||||
* a test observes.
|
||||
*/
|
||||
exit(code: number): void
|
||||
}
|
||||
|
||||
@@ -421,24 +426,81 @@ async function observeLoaderRejectionCheckpoint(reasons: readonly unknown[]): Pr
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How long {@link installFailLoud} waits for its `release` hook before exiting
|
||||
* anyway. A wedged disposer must delay the fatal exit, never cancel it.
|
||||
*/
|
||||
export const FAIL_LOUD_RELEASE_TIMEOUT_MS = 2_000
|
||||
|
||||
/**
|
||||
* Install before boot to turn a late unhandled plugin-init rejection into one
|
||||
* labelled stderr diagnostic and `exit(1)`. A rejection already included by
|
||||
* {@link assertEntriesActivated} is ignored during its process checkpoint;
|
||||
* every other rejection remains fatal. Stdout remains untouched for ACP; the
|
||||
* returned function removes the handler.
|
||||
*
|
||||
* The Loader mounts entries concurrently, so a surface that owns the terminal
|
||||
* can already hold it when a sibling entry rejects. Exiting straight from the
|
||||
* handler would strand raw mode, bracketed paste, and the keyboard protocol on
|
||||
* the user's shell, and leave an in-flight terminal query's reply to land as
|
||||
* literal text at the next prompt. `release` is the terminal owner's chance to
|
||||
* hand it back; it is awaited under {@link FAIL_LOUD_RELEASE_TIMEOUT_MS}, whose
|
||||
* timer stays referenced so a never-settling disposer cannot let Node reach an
|
||||
* empty event loop and exit 0 instead of failing.
|
||||
*
|
||||
* The diagnostic is written before the release so a hanging or failing disposer
|
||||
* cannot swallow the reason. The handler stays installed while the release runs
|
||||
* — removing it would let a second concurrent rejection become uncaught and kill
|
||||
* the process mid-teardown, stranding exactly the terminal state this restores —
|
||||
* so a latch keeps the first rejection the reported one and lets later
|
||||
* rejections (including the release's own) fall through to the pending exit.
|
||||
* @param binName - the diagnostic prefix on the fatal-failure line.
|
||||
* @param proc - the process slice to register on; tests inject a fake.
|
||||
* @param release - optional teardown awaited before exit, used by a
|
||||
* terminal-owning surface to restore the terminal. Its own failure is
|
||||
* swallowed because the pending fatal exit already owns the outcome.
|
||||
* @returns the uninstaller that removes the rejection handler.
|
||||
*/
|
||||
export function installFailLoud(binName: string, proc: FailLoudProcess = process): () => void {
|
||||
export function installFailLoud(
|
||||
binName: string,
|
||||
proc: FailLoudProcess = process,
|
||||
release?: () => Promise<void> | void,
|
||||
): () => void {
|
||||
let exiting = false
|
||||
const handler = (err: unknown): void => {
|
||||
if (assembledActivationRejections.has(err)) return
|
||||
// A release in flight already owns the exit. Swallow later rejections
|
||||
// (teardown's own included) rather than reporting a second failure over the
|
||||
// real one or letting Node kill the process before the terminal is back.
|
||||
if (exiting) return
|
||||
exiting = true
|
||||
proc.stderr.write(`${binName}: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
|
||||
proc.exit(1)
|
||||
if (release === undefined) {
|
||||
proc.exit(1)
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
// Definitely assigned: the timeout promise's executor runs synchronously
|
||||
// while the race is being constructed, before the first await.
|
||||
let timer!: ReturnType<typeof setTimeout>
|
||||
try {
|
||||
await Promise.race([
|
||||
(async () => release())(),
|
||||
new Promise<void>((resolve) => {
|
||||
timer = setTimeout(resolve, FAIL_LOUD_RELEASE_TIMEOUT_MS)
|
||||
}),
|
||||
])
|
||||
} catch {
|
||||
// The terminal release failed; the fatal exit below is the outcome that
|
||||
// matters, and no reporter runs after it.
|
||||
}
|
||||
clearTimeout(timer)
|
||||
proc.exit(1)
|
||||
})()
|
||||
}
|
||||
const uninstall = (): void => void proc.off('unhandledRejection', handler)
|
||||
proc.on('unhandledRejection', handler)
|
||||
return () => void proc.off('unhandledRejection', handler)
|
||||
return uninstall
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,8 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import {
|
||||
addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
|
||||
addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot,
|
||||
FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION,
|
||||
installFailLoud, loadEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
|
||||
} from '../src/index.ts'
|
||||
|
||||
@@ -109,16 +110,22 @@ describe('installFailLoud', () => {
|
||||
expect(proc.exits).toEqual([1])
|
||||
})
|
||||
|
||||
// One rejection is reported per install: the first is the diagnosis, so each
|
||||
// formatting case needs its own handler rather than reusing a latched one.
|
||||
it('stringifies a non-Error rejection and an Error without a stack falls back to its message', () => {
|
||||
const proc = fakeProc()
|
||||
installFailLoud(NAME, proc)
|
||||
proc.handlers[0]!('plain failure')
|
||||
expect(proc.written[0]).toContain('plain failure')
|
||||
const plain = fakeProc()
|
||||
installFailLoud(NAME, plain)
|
||||
plain.handlers[0]!('plain failure')
|
||||
expect(plain.written[0]).toContain('plain failure')
|
||||
expect(plain.exits).toEqual([1])
|
||||
|
||||
const stackless = new Error('no stack')
|
||||
delete (stackless as { stack?: string }).stack
|
||||
proc.handlers[0]!(stackless)
|
||||
expect(proc.written[1]).toContain('no stack')
|
||||
expect(proc.exits).toEqual([1, 1])
|
||||
const bare = fakeProc()
|
||||
installFailLoud(NAME, bare)
|
||||
bare.handlers[0]!(stackless)
|
||||
expect(bare.written[0]).toContain('no stack')
|
||||
expect(bare.exits).toEqual([1])
|
||||
})
|
||||
|
||||
it('returns an uninstaller that removes the handler (and defaults to the real process)', () => {
|
||||
@@ -162,6 +169,64 @@ describe('installFailLoud', () => {
|
||||
proc.handlers[0]!(error)
|
||||
expect(proc.exits).toEqual([1])
|
||||
})
|
||||
|
||||
// The Loader mounts entries concurrently, so a terminal-owning surface can
|
||||
// already hold raw mode when a sibling entry rejects. Exiting without running
|
||||
// its teardown strands the terminal on the user's shell.
|
||||
it('awaits the release hook before exiting so the terminal owner can restore it', async () => {
|
||||
const proc = fakeProc()
|
||||
const order: string[] = []
|
||||
installFailLoud(NAME, proc, async () => {
|
||||
await Promise.resolve()
|
||||
order.push('released')
|
||||
})
|
||||
proc.handlers[0]!(new Error('sibling entry rejected'))
|
||||
expect(proc.written[0]).toContain(`${NAME}: fatal load failure: `)
|
||||
// The release is in flight, so the exit has not committed yet.
|
||||
expect(proc.exits).toEqual([])
|
||||
await vi.waitFor(() => { expect(proc.exits).toEqual([1]) })
|
||||
expect(order).toEqual(['released'])
|
||||
})
|
||||
|
||||
it('still exits when the release hook rejects', async () => {
|
||||
const proc = fakeProc()
|
||||
installFailLoud(NAME, proc, () => Promise.reject(new Error('terminal stop failed')))
|
||||
proc.handlers[0]!(new Error('boom'))
|
||||
await vi.waitFor(() => { expect(proc.exits).toEqual([1]) })
|
||||
})
|
||||
|
||||
it('exits without waiting when a release hook never settles', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const proc = fakeProc()
|
||||
installFailLoud(NAME, proc, () => new Promise<void>(() => {}))
|
||||
proc.handlers[0]!(new Error('boom'))
|
||||
expect(proc.exits).toEqual([])
|
||||
await vi.advanceTimersByTimeAsync(FAIL_LOUD_RELEASE_TIMEOUT_MS)
|
||||
expect(proc.exits).toEqual([1])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
// Loader failures arrive in bursts, and teardown's own disposers may reject.
|
||||
// Only the first rejection is the diagnosis; the handler must stay installed
|
||||
// so a later one cannot become uncaught and kill the process mid-teardown.
|
||||
it('reports only the first rejection and keeps handling later ones during the release', async () => {
|
||||
const proc = fakeProc()
|
||||
let released = false
|
||||
installFailLoud(NAME, proc, async () => {
|
||||
await Promise.resolve()
|
||||
released = true
|
||||
})
|
||||
proc.handlers[0]!(new Error('first rejection'))
|
||||
proc.handlers[0]!(new Error('second rejection'))
|
||||
expect(proc.handlers).toHaveLength(1)
|
||||
expect(proc.written).toHaveLength(1)
|
||||
expect(proc.written[0]).toContain('first rejection')
|
||||
await vi.waitFor(() => { expect(proc.exits).toEqual([1]) })
|
||||
expect(released).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertEntriesLoaded', () => {
|
||||
|
||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@@ -1903,6 +1903,12 @@ importers:
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
'@deepseek-ai/dsh-subagent':
|
||||
specifier: workspace:^
|
||||
version: link:../../subagent/subagent
|
||||
'@deepseek-ai/dsh-token-meter':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/token-meter
|
||||
'@types/react':
|
||||
specifier: ~18.3.1
|
||||
version: 18.3.31
|
||||
@@ -4888,6 +4894,10 @@ importers:
|
||||
version: link:../../../vendor/cordis
|
||||
|
||||
packages/subagent/subagent:
|
||||
dependencies:
|
||||
zod:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-agent':
|
||||
specifier: workspace:^
|
||||
@@ -4910,6 +4920,9 @@ importers:
|
||||
'@deepseek-ai/dsh-session-persistence':
|
||||
specifier: workspace:^
|
||||
version: link:../../session-persistence/session-persistence
|
||||
'@deepseek-ai/dsh-session-projection':
|
||||
specifier: workspace:^
|
||||
version: link:../../session-projection/session-projection
|
||||
'@deepseek-ai/dsh-session-query':
|
||||
specifier: workspace:^
|
||||
version: link:../../session-query/session-query
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"apps/web/tests/goal-bar.e2e.ts",
|
||||
"apps/web/tests/startup-auto-selection.e2e.ts",
|
||||
"apps/web/tests/subagent-conversation.e2e.ts",
|
||||
"apps/web/tests/bash-abort-row.e2e.ts",
|
||||
"apps/cli/tests/**/*.ts",
|
||||
"examples/*/src/**/*.ts",
|
||||
"examples/*/start.ts",
|
||||
|
||||
1
vendor/README.md
vendored
1
vendor/README.md
vendored
@@ -42,6 +42,7 @@ Keep this log exhaustive — every divergence from upstream must be listed.
|
||||
10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/ui/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git prepare run through the bundled pnpm.
|
||||
11. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions.
|
||||
12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes one shared base (`apps/cli/config/base.cordis.yml`) with a surface overlay, an optional `--config` overlay, and the personal `~/.dsh/config.yaml` as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`.
|
||||
13. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic and the TUI's terminal state stranded. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a personal config present at registration must apply once. Covered by the `dsh` invalid-provider PTY case in `apps/cli/tests/tui-keyless-smoke.e2e.ts`.
|
||||
|
||||
## Sync procedure
|
||||
|
||||
|
||||
8
vendor/hmr/src/index.ts
vendored
8
vendor/hmr/src/index.ts
vendored
@@ -209,6 +209,14 @@ class Hmr extends Service {
|
||||
...this.config,
|
||||
cwd: this.baseDir,
|
||||
ignored: path => match(relative(this.baseDir, path)),
|
||||
// The initial scan re-announces files the boot just consumed: an `add`
|
||||
// for a config file refreshes an include whose initial apply may still
|
||||
// be in flight, and a failing apply then rolls this plugin back while
|
||||
// the scan-triggered refresh waits on that apply — a teardown deadlock
|
||||
// that strands boot without a diagnostic. Only events after the scan
|
||||
// matter here; `registerConfig` keeps its own initial scan because a
|
||||
// personal config present at registration must apply once.
|
||||
ignoreInitial: true,
|
||||
})
|
||||
|
||||
// Collect externals: framework modules reachable from the main entry.
|
||||
|
||||
40
vendor/include/src/index.ts
vendored
40
vendor/include/src/index.ts
vendored
@@ -171,6 +171,7 @@ export class Include extends EntryTree {
|
||||
private content?: string
|
||||
private data?: EntryOptions[]
|
||||
private writeTask?: NodeJS.Timeout
|
||||
private applyQueue: Promise<unknown> = Promise.resolve()
|
||||
|
||||
constructor(ctx: Context, public config: Include.Config) {
|
||||
super(ctx)
|
||||
@@ -186,12 +187,29 @@ export class Include extends EntryTree {
|
||||
|
||||
ctx.on('internal/update', async (config, _, next) => {
|
||||
if (config.path !== this.config.path) return next()
|
||||
const data = this.applyPatches(this.data!, config.patches)
|
||||
await this.root.update(data)
|
||||
this.config = config
|
||||
await this.enqueue(async () => {
|
||||
const data = this.applyPatches(this.data!, config.patches)
|
||||
await this.root.update(data)
|
||||
this.config = config
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize one child-tree mutation behind every earlier one. The group's
|
||||
* transactional `update` is not reentrant: two concurrent applies (the init
|
||||
* apply racing an HMR-triggered refresh from the watcher's initial scan)
|
||||
* interleave create and rollback on the same entries and strand the include
|
||||
* fiber without settling, so every apply path funnels through this queue.
|
||||
* A predecessor's failure is its own caller's outcome and never gates the
|
||||
* next task.
|
||||
*/
|
||||
private enqueue<T>(task: () => Promise<T>): Promise<T> {
|
||||
const run = this.applyQueue.then(task, task)
|
||||
this.applyQueue = run.then(() => {}, () => {})
|
||||
return run
|
||||
}
|
||||
|
||||
private async checkAccess() {
|
||||
if (!this.type) return
|
||||
try {
|
||||
@@ -262,12 +280,20 @@ export class Include extends EntryTree {
|
||||
* @throws when reading, parsing, validation, application, or rollback fails; the last good tree remains active when rollback succeeds.
|
||||
*/
|
||||
async refresh() {
|
||||
const candidate = await this.read()
|
||||
if (!candidate) return
|
||||
await this.apply(candidate)
|
||||
// Read inside the queue so the changed-content check compares against the
|
||||
// predecessor's committed state, not a mid-apply snapshot.
|
||||
await this.enqueue(async () => {
|
||||
const candidate = await this.read()
|
||||
if (!candidate) return
|
||||
await this._apply(candidate)
|
||||
})
|
||||
}
|
||||
|
||||
private async apply(candidate: ReadCandidate) {
|
||||
private apply(candidate: ReadCandidate) {
|
||||
return this.enqueue(() => this._apply(candidate))
|
||||
}
|
||||
|
||||
private async _apply(candidate: ReadCandidate) {
|
||||
const data = this.applyPatches(candidate.data, this.config.patches)
|
||||
await this.root.update(data)
|
||||
this.content = candidate.content
|
||||
|
||||
Reference in New Issue
Block a user