Merge remote-tracking branch 'origin/master' into pr/tui-details-fold
# Conflicts: # docs/cordis-catalog/services.md # packages/ui/tui/README.i18n.yaml # packages/ui/tui/README.md # packages/ui/tui/README.zh.md # packages/ui/tui/src/index.ts
This commit is contained in:
@@ -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: 54f754842d9a6673ed6791b94656139f0f1be6a3
|
||||
README.zh.md: dd56084812e8241f0db24601ce2baeba51252d42
|
||||
README.md: 7e0466c40583e6f5b22e0d5ef25d211d595c3216
|
||||
README.zh.md: abb796aaa9fd6f8e6ee0578423382ed7f23909ab
|
||||
|
||||
@@ -8,16 +8,22 @@ 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 a post-`boot()` unhandled 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 |
|
||||
| `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
|
||||
| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Mount the statically imported Include builtin and retain the exact root entry used by personal-config HMR |
|
||||
| `watchPersonalPatches(ctx, options)` | Register `$DSH_HOME/config.yaml` with the existing Cordis HMR service; each add/change/removal transactionally recomposes the full patch list through the caller's `compose` closure (app-owned layers around the current personal overlay) and returns an async disposer |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error |
|
||||
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline — the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts — and render YAML with `!!js` expressions verbatim; each run of same-provenance rows is preceded by a `# ==` comment naming the contributing file and the layers that patched it, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
|
||||
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |
|
||||
|
||||
Two Loader failure classes require separate guards because tree settlement propagates neither to its caller. A failed plugin import leaves a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every unresolved plugin. A plugin callback or config failure leaves a failed fiber because `loader.await()` settles lifecycle tasks without propagating that error; `assertEntriesActivated` awaits the fiber explicitly and includes its original stack in the startup rejection. 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.
|
||||
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.
|
||||
|
||||
@@ -25,11 +31,13 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](..
|
||||
|
||||
## Personal config
|
||||
|
||||
A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the official `dsh` surfaces ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files:
|
||||
A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the `dsh` CLI's TUI, Web, and headless surfaces ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files:
|
||||
|
||||
- **`.env`** — the credential store of [`dsh-credentials-local`](../../credentials/credentials-local/README.md), read by that provider alone. No surface hoists it into `process.env`: doing so would make every stored key look like a read-only launch override on the next run, blocking rotation from the TUI and the web page. The environment layers are the ambient one and the invoking directory's `.env` (loaded by the bin; `process.loadEnvFile` never overrides), and a composition without the credential provider keeps resolving keys from those alone.
|
||||
- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the booted tree is a silent no-op. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file.
|
||||
|
||||
The TUI and Web keep `config.yaml` live through `watchPersonalPatches`; one-shot headless runs read only the startup value. The watcher targets the exact personal path even when the file or immediate parent does not exist, serializes bursts, and recomposes the personal patches inside the caller's layer order (surface overlay below, app-generated patches above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh.
|
||||
|
||||
Subprocess test launchers point `DSH_HOME` at an isolated per-test directory so a developer's personal overlay can never leak into fixtures.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -8,16 +8,22 @@
|
||||
|---|---|
|
||||
| `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?)` | 将 `boot()` 之后未处理的 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`,文件不可读、不可解析或内容不是数组时抛出异常 |
|
||||
| `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文 |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 |
|
||||
| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 挂载静态导入的 Include builtin,并保留个人配置 HMR(热模块替换)使用的确切根配置项 |
|
||||
| `watchPersonalPatches(ctx, options)` | 向现有 Cordis HMR 服务注册 `$DSH_HOME/config.yaml`;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前个人 overlay)以事务方式重新组合完整 patch 列表,并返回异步 disposer |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject |
|
||||
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`),因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML,`!!js` 表达式原样保留;每段来源相同的连续行之前都有一条 `# ==` 注释,标明贡献该段的文件以及修补过它的层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取/解析/形状失败则抛出 |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 |
|
||||
| `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 |
|
||||
|
||||
Loader 树结算不会向调用方传播两类故障,因此需要分别保护。插件导入失败会留下没有 fiber 的配置项,`assertEntriesLoaded` 将其转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下失败的 fiber,因为 `loader.await()` 只结算生命周期任务,不传播该错误;`assertEntriesActivated` 会显式等待该 fiber,并把原始错误堆栈写入启动 rejection。抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。
|
||||
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()`。
|
||||
|
||||
@@ -25,11 +31,13 @@ Loader 树结算不会向调用方传播两类故障,因此需要分别保护
|
||||
|
||||
## 个人配置
|
||||
|
||||
开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由官方 `dsh` 界面([`apps/cli`](../../../apps/cli/README.md))使用;demo bin 会原样启动仓库中提交的树。这里有两个可选文件:
|
||||
开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由 `dsh` CLI(命令行界面)的 TUI、Web 和无头界面([`apps/cli`](../../../apps/cli/README.md))使用;demo bin 会原样启动仓库中提交的树。这里有两个可选文件:
|
||||
|
||||
- **`.env`**:[`dsh-credentials-local`](../../credentials/credentials-local/README.md) 的凭据存储,只由该 provider 读取。没有任何表层会把它提升进 `process.env`:那样做会让每个已存密钥在下次运行时看起来都像只读的启动时覆盖,从而阻断从 TUI 与 Web 页面轮换密钥。环境层次由环境中的值与调用目录的 `.env` 构成(由 bin 加载;`process.loadEnvFile` 从不覆盖已有值),没有凭据 provider 的组合仍然只从这两者解析密钥。
|
||||
- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与交付的 surface overlay 相同:按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在已启动树中,则静默不执行任何操作。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。
|
||||
|
||||
TUI 和 Web 会持续应用 `config.yaml` 的变更,具体由 `watchPersonalPatches` 负责;一次性无头运行只读取启动时的值。即使该文件或其直接父目录不存在,watcher 仍会监视确切的个人配置路径;它会串行处理突发变更,并按调用方的层次顺序重新组合个人 patch(surface overlay 在下、应用生成的 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离 observer 失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。
|
||||
|
||||
子进程测试 launcher 会把 `DSH_HOME` 指向逐测试隔离的目录,确保开发者的个人 overlay 不会泄漏到 fixture(测试前置数据)中。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"js-yaml": "^4.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cordisjs/plugin-hmr": "^1.0.15",
|
||||
"@cordisjs/plugin-include": "^1.0.4",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
@@ -37,9 +38,16 @@
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@cordisjs/plugin-hmr": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-hmr": "workspace:^",
|
||||
"@cordisjs/plugin-include": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@cordisjs/plugin-timer": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Shared boot glue for the app bins (`dsh`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored
|
||||
* `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the
|
||||
* optional personal overlay patches from the Harness home (`~/.dsh`), and drive the cordis Loader
|
||||
* against a leaf `cordis.yml` until the whole tree has settled.
|
||||
* optional personal overlay patches from the Harness home (`~/.dsh`), expose its path resolver to
|
||||
* config expressions, and drive the Cordis Loader against a leaf `cordis.yml` until the tree settles.
|
||||
* @module @deepseek-ai/dsh-app-boot
|
||||
*/
|
||||
|
||||
@@ -11,12 +11,20 @@ import { readFileSync } from 'node:fs'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { Context, type FiberState } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import Loader, { type Entry, type EntryOptions } from '@cordisjs/plugin-loader'
|
||||
import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import type {} from '@cordisjs/plugin-hmr'
|
||||
// Side-effect type import: resolves `ctx.get('systemPrompt')` to the service.
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Harness-home path resolver available to Loader `!!js` config expressions. */
|
||||
dshHomePath?: typeof dshHomePath
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the config to boot. Replay swaps a `cordis.yml` basename for
|
||||
* `cordis.snapshot.yml` in the same directory; every other mode keeps the path.
|
||||
@@ -60,16 +68,14 @@ export function loadEnv(
|
||||
/** File inside the Harness home holding the personal loader overlay patches. */
|
||||
export const PERSONAL_CONFIG_FILENAME = 'config.yaml'
|
||||
|
||||
// The include's YAML dialect: `!!js` scalars become expression nodes the
|
||||
// Loader interpolates against each entry's context at mount time. Personal
|
||||
// patches are parsed with the same schema so they may reference `process.env`.
|
||||
// Load-only: this schema never dumps, so no `predicate`/`represent`.
|
||||
const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
|
||||
kind: 'scalar',
|
||||
resolve: data => typeof data === 'string',
|
||||
construct: data => ({ __jsExpr: String(data) }),
|
||||
})
|
||||
const personalPatchesSchema = yaml.JSON_SCHEMA.extend(jsExprType)
|
||||
const bootstrapIncludes = new WeakMap<Context, Entry>()
|
||||
|
||||
// The include's YAML dialect (`!!js` scalars become expression nodes the
|
||||
// Loader interpolates against each entry's context at mount time), imported
|
||||
// from the include itself so patch parsing and config dumping can never drift
|
||||
// from what the include mounts. Personal patches share it so they may
|
||||
// reference `process.env`.
|
||||
const personalPatchesSchema = entryListSchema
|
||||
|
||||
/**
|
||||
* Load the optional personal overlay patches (`config.yaml` under the Harness
|
||||
@@ -149,6 +155,234 @@ function parsePatchList(
|
||||
return parsed as PatchOptions[]
|
||||
}
|
||||
|
||||
/** One overlay patch list with the label provenance comments print for it. */
|
||||
export interface ConfigDumpLayer {
|
||||
/** Source name shown in provenance comments (a file basename or path). */
|
||||
label: string
|
||||
/** The layer's patches, from {@link loadOverlayPatches} / {@link loadPersonalPatches}. */
|
||||
patches: PatchOptions[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the effective entry list exactly as `boot()` would mount it: parse
|
||||
* the base config file with the include's entry-list dialect, apply every
|
||||
* layer's patches as ONE flattened list through the include's own patch
|
||||
* algorithm (`applyEntryPatches`) — the same single call `boot()` makes, so
|
||||
* even patch-visibility corner cases (a later layer targeting a group child a
|
||||
* plain config replacement introduced, which the single-pass id index never
|
||||
* sees) compose identically — then render the result as YAML in the same
|
||||
* dialect (`!!js` expressions print verbatim, unevaluated).
|
||||
*
|
||||
* Every run of rows with the same provenance is preceded by a `# ==` comment
|
||||
* naming the file that contributed the rows and any layers that patched them,
|
||||
* so the output stays a loadable YAML document while showing which section
|
||||
* comes from which file. Provenance is derived from single-call prefix
|
||||
* snapshots (base + layers 1..k), diffed positionally: the patch algorithm
|
||||
* only rewrites rows in place or appends, so a top-level index identifies one
|
||||
* row across snapshots, and a layer whose addition changes the row (config
|
||||
* replacement, disable, group insert) is listed as having patched it.
|
||||
*
|
||||
* A patch that matches no row is reported through `warn` with its layer
|
||||
* label, mirroring the Loader's boot-time warning. Earlier layers' patches
|
||||
* see an identical preceding state in every snapshot that includes them, so
|
||||
* each snapshot's warning list extends the previous one and the new tail
|
||||
* belongs to the added layer.
|
||||
* @param binName - the diagnostic prefix on read/parse errors.
|
||||
* @param absoluteConfigPath - the base config file `boot()` would include.
|
||||
* @param layers - overlay layers in application order (later wins).
|
||||
* @param warn - sink for skipped-patch diagnostics; defaults to stderr.
|
||||
* @returns the composed entry list rendered as a YAML document with
|
||||
* provenance comment separators.
|
||||
*/
|
||||
export function renderConfigDump(
|
||||
binName: string,
|
||||
absoluteConfigPath: string,
|
||||
layers: ConfigDumpLayer[],
|
||||
warn: (line: string) => void = line => void process.stderr.write(`${line}\n`),
|
||||
): string {
|
||||
let content: string
|
||||
try {
|
||||
content = readFileSync(absoluteConfigPath, 'utf8')
|
||||
} catch (error) {
|
||||
throw new Error(`${binName}: failed to read config ${absoluteConfigPath}: ${String(error)}`)
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = yaml.load(content, { schema: entryListSchema })
|
||||
} catch (error) {
|
||||
throw new Error(`${binName}: failed to parse config ${absoluteConfigPath}: ${String(error)}`)
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error(`${binName}: config ${absoluteConfigPath} must be a top-level YAML array of entries`)
|
||||
}
|
||||
const baseLabel = basename(absoluteConfigPath)
|
||||
// The YAML boundary yields untyped rows; the include validates entry shape
|
||||
// at mount, and the dump prints whatever the file holds, so `EntryOptions`
|
||||
// here is structural trust in the same file `boot()` would include.
|
||||
const base = parsed as Parameters<typeof applyEntryPatches>[0]
|
||||
// snapshot_k = ONE application of layers 1..k flattened — boot's exact call
|
||||
// shape for that prefix. snapshot_N is therefore the mounted composition.
|
||||
// The patches are cloned per call: applyEntryPatches detaches the entry
|
||||
// list but pushes `insert` rows by reference from the patch list, so
|
||||
// sharing patch objects across snapshot calls would leak a later
|
||||
// snapshot's mutations into an earlier one's result.
|
||||
const snapshot = (count: number, warnings: string[]): ReturnType<typeof applyEntryPatches> => {
|
||||
const flattened = structuredClone(layers.slice(0, count).flatMap(layer => layer.patches))
|
||||
return applyEntryPatches(base, flattened, (message: string, ...args: unknown[]) => {
|
||||
// The include logs through cordis's printf-style logger (`%C` = code); a
|
||||
// dump has no logger, so substitute inline for a plain line.
|
||||
let index = 0
|
||||
warnings.push(message.replace(/%C/g, () => JSON.stringify(args[index++])))
|
||||
})
|
||||
}
|
||||
let previous = base
|
||||
let previousWarnings: string[] = []
|
||||
const provenance: { origin: string; patchedBy: string[] }[] = base.map(() => ({ origin: baseLabel, patchedBy: [] }))
|
||||
let composed = base
|
||||
for (let count = 1; count <= layers.length; count += 1) {
|
||||
const layer = layers[count - 1]
|
||||
/* v8 ignore next -- count iterates 1..length, so the slot exists */
|
||||
if (layer === undefined) continue
|
||||
const warnings: string[] = []
|
||||
composed = snapshot(count, warnings)
|
||||
for (const line of warnings.slice(previousWarnings.length)) {
|
||||
warn(`${binName}: [${layer.label}] ${line}`)
|
||||
}
|
||||
const before = previous.map(entry => JSON.stringify(entry))
|
||||
for (let index = 0; index < composed.length; index += 1) {
|
||||
if (index >= before.length) provenance.push({ origin: layer.label, patchedBy: [] })
|
||||
else if (JSON.stringify(composed[index]) !== before[index]) provenance[index]?.patchedBy.push(layer.label)
|
||||
}
|
||||
previous = composed
|
||||
previousWarnings = warnings
|
||||
}
|
||||
return groupedDump(composed, provenance)
|
||||
}
|
||||
|
||||
/** Render the composed rows grouped under one provenance comment per contiguous run. */
|
||||
function groupedDump(
|
||||
composed: readonly unknown[],
|
||||
provenance: readonly { origin: string; patchedBy: string[] }[],
|
||||
): string {
|
||||
const lines: string[] = []
|
||||
let currentLabel: string | undefined
|
||||
let group: unknown[] = []
|
||||
const flush = (): void => {
|
||||
if (currentLabel === undefined || group.length === 0) return
|
||||
lines.push(`# == ${currentLabel}`)
|
||||
lines.push(yaml.dump(group, { schema: entryListSchema, noRefs: true }).trimEnd())
|
||||
group = []
|
||||
}
|
||||
for (let index = 0; index < composed.length; index += 1) {
|
||||
const record = provenance[index]
|
||||
/* v8 ignore next -- provenance is index-aligned with composed by construction */
|
||||
if (record === undefined) continue
|
||||
const label = record.patchedBy.length === 0
|
||||
? record.origin
|
||||
: `${record.origin}, patched by ${record.patchedBy.join(', ')}`
|
||||
if (label !== currentLabel) {
|
||||
flush()
|
||||
currentLabel = label
|
||||
}
|
||||
group.push(composed[index])
|
||||
}
|
||||
flush()
|
||||
return lines.join('\n') + '\n'
|
||||
}
|
||||
|
||||
/** Options for live personal-config reconciliation. */
|
||||
export interface PersonalPatchWatchOptions {
|
||||
/** Diagnostic prefix used by {@link loadPersonalPatches}. */
|
||||
binName: string
|
||||
/** Harness home containing `config.yaml`; defaults to {@link resolveDshHome}. */
|
||||
dir?: string
|
||||
/**
|
||||
* Compose the full patch list for a fresh personal-overlay generation —
|
||||
* the same composition the app booted with, so a reload can interleave the
|
||||
* new personal patches between app-owned layers (surface overlay below,
|
||||
* profile/flag patches above). Identity when omitted: the personal overlay
|
||||
* is the whole patch list.
|
||||
*/
|
||||
compose?: (personalPatches: PatchOptions[]) => PatchOptions[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch the personal overlay through Cordis HMR and transactionally reapply it to the boot include.
|
||||
* @param ctx - settled app context containing the root Include and an active HMR service.
|
||||
* @param options - diagnostic, Harness-home, and patch-composition inputs.
|
||||
* @returns an asynchronous disposer after the exact-path watcher is ready.
|
||||
* @throws when HMR or the root Include is absent, watcher setup fails, or initial path resolution fails.
|
||||
*/
|
||||
export async function watchPersonalPatches(
|
||||
ctx: Context,
|
||||
options: PersonalPatchWatchOptions,
|
||||
): Promise<() => Promise<void>> {
|
||||
const { binName, dir = resolveDshHome(), compose = (patches: PatchOptions[]) => patches } = options
|
||||
const hmr = ctx.get('hmr')
|
||||
if (hmr === undefined) throw new Error(`${binName}: personal config watching requires the Cordis HMR service`)
|
||||
const entry = bootstrapIncludes.get(ctx)
|
||||
if (entry === undefined) throw new Error(`${binName}: personal config watching requires the root Include entry`)
|
||||
const filename = join(dir, PERSONAL_CONFIG_FILENAME)
|
||||
const register = hmr.registerConfig(filename, async () => {
|
||||
// Re-read the include's non-patch options per refresh: a writer that
|
||||
// updates the root Include's other options between refreshes (none exists
|
||||
// today) must not have them silently reverted by a personal reload.
|
||||
const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config
|
||||
const personalPatches = loadPersonalPatches(binName, dir) ?? []
|
||||
const patches = compose(personalPatches)
|
||||
await entry.update({
|
||||
config: {
|
||||
...includeConfig,
|
||||
patches,
|
||||
},
|
||||
})
|
||||
})
|
||||
try {
|
||||
return await register
|
||||
} catch (error) {
|
||||
// A surface can dispose the whole tree while the watcher is still opening
|
||||
// (a TUI `/exit` typed during startup): the HMR effect registration then
|
||||
// fails with INACTIVE_EFFECT. That is the app exiting exactly as asked,
|
||||
// not a watch failure — return a no-op disposer instead of crashing.
|
||||
if ((error as { code?: string } | null)?.code === 'INACTIVE_EFFECT') return async () => {}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount and remember the exact root Include entry used by app boot and personal-config HMR.
|
||||
* @param ctx - context carrying an initialized Loader service.
|
||||
* @param absoluteConfigPath - absolute YAML or JSON configuration path.
|
||||
* @param patches - initial app and personal patches, applied in order.
|
||||
* @returns the created root Include entry, or `undefined` when a surface
|
||||
* disposed the whole tree (taking the Loader service with it) while the
|
||||
* transactional create was still settling entry lifecycle.
|
||||
*/
|
||||
export async function mountRootInclude(
|
||||
ctx: Context,
|
||||
absoluteConfigPath: string,
|
||||
patches: readonly PatchOptions[] = [],
|
||||
): Promise<Entry | undefined> {
|
||||
ctx.loader.builtins.include = Include
|
||||
// Pinned id: the bootstrap include is app glue, not a config row, and its
|
||||
// id appears in Loader failure chains — a random id would make startup
|
||||
// diagnostics unstable across runs (and snapshot fixtures).
|
||||
const rootInclude: EntryOptions = {
|
||||
id: 'include',
|
||||
name: 'cordis:include',
|
||||
config: {
|
||||
path: pathToFileURL(absoluteConfigPath).href,
|
||||
...patches.length > 0 ? { patches: [...patches] } : {},
|
||||
},
|
||||
}
|
||||
const includeId = await ctx.loader.create(rootInclude)
|
||||
const loader = ctx.get('loader')
|
||||
if (loader === undefined) return undefined
|
||||
const entry = loader.resolve(includeId)
|
||||
bootstrapIncludes.set(ctx, entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
/**
|
||||
* The slice of `process` {@link installFailLoud} needs — injectable so tests
|
||||
* exercise the handler without registering on (or exiting) the real process.
|
||||
@@ -157,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
|
||||
}
|
||||
|
||||
@@ -187,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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -292,12 +588,13 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro
|
||||
* `cordis:include` builtin, loading through the ambient module pipeline
|
||||
* (vite/tsx/plain ESM) while the included tree's own specifiers stay
|
||||
* config-relative. The package build embeds Include while leaving Loader
|
||||
* external, so the built include tree and host share one Loader peer. A
|
||||
* missing fiber rejects here; a later init rejection is rethrown with its
|
||||
* original stack by {@link assertEntriesActivated}; later unhandled
|
||||
* rejections remain covered by {@link installFailLoud}. Built bins need the
|
||||
* Loader's native helper for bare plugin specifiers; relative specifiers do
|
||||
* not.
|
||||
* external, so the built include tree and host share one Loader peer. Loader
|
||||
* settlement rejects startup failures, which `boot` wraps after disposing the
|
||||
* partial context; a missing fiber or never-activating entry is rejected by
|
||||
* the final audit, {@link assertEntriesActivated}, which rethrows a plugin's
|
||||
* init rejection with its original stack; later unhandled rejections remain
|
||||
* covered by {@link installFailLoud}. Built bins need the Loader's native
|
||||
* helper for bare plugin specifiers; relative specifiers do not.
|
||||
* @param binName - the diagnostic prefix for load-failure errors.
|
||||
* @param absoluteConfigPath - the config to include; must already be absolute
|
||||
* (see {@link resolveConfigPath}).
|
||||
@@ -306,6 +603,9 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro
|
||||
* @param prepare - optional host setup run after Loader installation and before any config-tree entry mounts.
|
||||
* @returns the root context once every entry has started, or as soon as a
|
||||
* surface disposed the tree while startup was still in flight.
|
||||
* @throws a labelled error after disposing the partial context — `host
|
||||
* preparation failed` when `prepare` threw before any config-tree entry
|
||||
* mounted, `plugin tree failed to load` afterwards.
|
||||
*/
|
||||
export async function boot(
|
||||
binName: string,
|
||||
@@ -314,38 +614,55 @@ export async function boot(
|
||||
prepare?: (ctx: Context) => Promise<void> | void,
|
||||
): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
await prepare?.(ctx)
|
||||
await ctx.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: {
|
||||
path: pathToFileURL(absoluteConfigPath).href,
|
||||
...patches !== undefined && patches.length > 0 ? { patches } : {},
|
||||
},
|
||||
})
|
||||
await ctx.loader.await()
|
||||
// A surface can finish and dispose the whole tree while that await is still
|
||||
// pending: the TUI renders as soon as its own fiber starts, so an `/exit`
|
||||
// typed before the last entry settles tears the context down under us. The
|
||||
// Loader service goes with it, and the activation audit describes a live
|
||||
// tree — reading `ctx.loader` here would throw a TypeError over an app that
|
||||
// exited exactly as asked.
|
||||
if (ctx.get('loader') === undefined) return ctx
|
||||
await assertEntriesActivated(ctx, binName)
|
||||
return ctx
|
||||
// Two failure labels: `prepare` runs before any config-tree entry mounts,
|
||||
// so its failure is host setup, not the plugin tree.
|
||||
let stage = 'host preparation failed'
|
||||
try {
|
||||
ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
|
||||
ctx.provide('dshHomePath', dshHomePath)
|
||||
await ctx.plugin(Loader)
|
||||
await prepare?.(ctx)
|
||||
stage = 'plugin tree failed to load'
|
||||
await mountRootInclude(ctx, absoluteConfigPath, patches)
|
||||
// A surface can finish and dispose the whole tree while startup is still
|
||||
// in flight: the TUI renders as soon as its own fiber starts, so an `/exit`
|
||||
// typed before the last entry settles tears the context down under us. The
|
||||
// Loader service goes with it, and the activation audit describes a live
|
||||
// tree — reading `ctx.loader` past this point would throw a TypeError over
|
||||
// an app that exited exactly as asked. Transactional group updates settle
|
||||
// lifecycle inside the mount, so the teardown can land before it returns;
|
||||
// re-check after every await.
|
||||
await ctx.get('loader')?.await()
|
||||
if (ctx.get('loader') === undefined) return ctx
|
||||
await assertEntriesActivated(ctx, binName)
|
||||
return ctx
|
||||
} catch (cause) {
|
||||
// Root-fiber disposal contains cleanup failures per observer (Cordis
|
||||
// fiber.ts hardening) and a repeated call returns the settled single-shot
|
||||
// result, so this await cannot reject and replace `cause`.
|
||||
await ctx.fiber.dispose()
|
||||
const detail = cause instanceof Error ? cause.message : String(cause)
|
||||
// The transactional Loader wraps a failing entry apply in one message per
|
||||
// tree layer; every layer's message is folded into `detail` above, and the
|
||||
// deepest cause is the plugin's own thrown error, whose stack names the
|
||||
// real failure site — append it so the startup diagnostic preserves the
|
||||
// original activation error instead of only the wrap chain.
|
||||
let deepest: unknown = cause
|
||||
while (deepest instanceof Error && deepest.cause !== undefined) deepest = deepest.cause
|
||||
const stack = deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : ''
|
||||
throw new Error(`${binName}: ${stage}: ${detail}${stack}`, { cause })
|
||||
}
|
||||
}
|
||||
|
||||
/** Prompt-section name for the harness-source location line an app bin adds after boot. */
|
||||
export const HARNESS_SOURCE_SECTION = 'harness:source'
|
||||
|
||||
/**
|
||||
* Add a global prompt section naming the on-disk path to the harness source
|
||||
* checkout the running bin was launched from, so the agent knows where its own
|
||||
* source lives (the self-referential `dsh-tool-cordis` toolset reads and edits
|
||||
* it). Call once on the settled boot context ({@link boot}); the section orders
|
||||
* just after the harness identity opener (`-100`) and before the deployment
|
||||
* Add a global prompt section naming the on-disk harness source checkout while
|
||||
* explicitly distinguishing it from the task workspace and current working
|
||||
* directory. The self-referential `dsh-tool-cordis` toolset reads and edits this
|
||||
* checkout. Call once on the settled boot context ({@link boot}); the section
|
||||
* orders just after the harness identity opener (`-100`) and before the deployment
|
||||
* persona (`0`). A booted tree with no `systemPrompt` service has no prompt to
|
||||
* augment, so this is then a no-op that returns `undefined`. The section is
|
||||
* registered against the `systemPrompt` service's fiber, so a dev HMR reload of
|
||||
@@ -360,6 +677,6 @@ export function addHarnessSourceSection(ctx: Context, sourceRoot: string): (() =
|
||||
return systemPrompt.section({
|
||||
name: HARNESS_SOURCE_SECTION,
|
||||
order: -99,
|
||||
text: `Your own source code is the checkout at ${sourceRoot}; you can read it there to learn how dsh works and how to extend it.`,
|
||||
text: `The DeepSeek Harness implementation checkout is at ${sourceRoot}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
@@ -325,6 +390,50 @@ describe('boot', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('disposes partial host setup and labels non-Error preparation failures', async () => {
|
||||
const dir = tmp()
|
||||
const failure = 42
|
||||
let disposed = false
|
||||
const task = boot(NAME, join(dir, 'cordis.yml'), undefined, (ctx) => {
|
||||
ctx.effect(() => () => { disposed = true })
|
||||
throw failure
|
||||
})
|
||||
|
||||
await expect(task).rejects.toMatchObject({
|
||||
message: `${NAME}: host preparation failed: ${failure}`,
|
||||
cause: failure,
|
||||
})
|
||||
expect(disposed).toBe(true)
|
||||
})
|
||||
|
||||
it('exposes dshHomePath to Loader config expressions', async () => {
|
||||
const dir = tmp()
|
||||
const dshHome = join(dir, 'home')
|
||||
vi.stubEnv('DSH_HOME', dshHome)
|
||||
writeFileSync(join(dir, 'capture.mjs'), [
|
||||
'export const name = "capture"',
|
||||
'export function apply(ctx, config) {',
|
||||
' ctx.provide("capturedPath", config.path)',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(dir, 'cordis.yml'), [
|
||||
'- id: capture',
|
||||
' name: ./capture.mjs',
|
||||
' config:',
|
||||
" path: !!js dshHomePath('sessions')",
|
||||
'',
|
||||
].join('\n'))
|
||||
let ctx: Context | undefined
|
||||
try {
|
||||
ctx = await boot(NAME, join(dir, 'cordis.yml'))
|
||||
expect(ctx.get('capturedPath')).toBe(join(dshHome, 'sessions'))
|
||||
} finally {
|
||||
await ctx?.fiber.dispose()
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns instead of asserting over a tree a surface disposed mid-startup', async () => {
|
||||
// What a TUI `/exit` does (ui-tui's disposeRootAndExit): dispose the root
|
||||
// fiber, which lands while boot() is still awaiting the Loader whenever the
|
||||
@@ -347,7 +456,37 @@ describe('boot', () => {
|
||||
it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n')
|
||||
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`)
|
||||
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(
|
||||
`${NAME}: plugin tree failed to load: failed to apply loader entry`,
|
||||
)
|
||||
})
|
||||
|
||||
it('appends the deepest cause with its original stack to the load failure', async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'failing.mjs'), [
|
||||
'export function apply() {',
|
||||
" const failure = new Error('pinned activation failure')",
|
||||
" failure.stack = 'Error: pinned activation failure\\n at failing-fixture'",
|
||||
' throw failure',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: failing\n name: ./failing.mjs\n')
|
||||
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(new RegExp([
|
||||
String.raw`failed to apply loader entry failing \(\./failing\.mjs\): pinned activation failure\n`,
|
||||
String.raw`Error: pinned activation failure\n {4}at failing-fixture$`,
|
||||
].join('')))
|
||||
})
|
||||
|
||||
it('falls back to the deepest cause message when its stack was erased', async () => {
|
||||
const dir = tmp()
|
||||
const deepest = new Error('stackless deep failure')
|
||||
delete (deepest as { stack?: string }).stack
|
||||
await expect(boot(NAME, join(dir, 'cordis.yml'), undefined, () => {
|
||||
throw new Error('wrapped setup failure', { cause: deepest })
|
||||
})).rejects.toThrow(
|
||||
`${NAME}: host preparation failed: wrapped setup failure\nstackless deep failure`,
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a pending real Loader fiber and the service unresolved in its own context', async () => {
|
||||
@@ -363,9 +502,9 @@ describe('boot', () => {
|
||||
|
||||
describe('addHarnessSourceSection', () => {
|
||||
const SOURCE_ROOT = `${sep}opt${sep}harness-src`
|
||||
const EXPECTED = `Your own source code is the checkout at ${SOURCE_ROOT}; you can read it there to learn how dsh works and how to extend it.`
|
||||
const EXPECTED = `The DeepSeek Harness implementation checkout is at ${SOURCE_ROOT}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`
|
||||
|
||||
it('adds the source path between the harness identity and the deployment persona', async () => {
|
||||
it('distinguishes the source path from the current workdir between identity and persona', async () => {
|
||||
const ctx = new Context()
|
||||
try {
|
||||
await ctx.plugin(SystemPrompt, { persona: 'You are a coding agent.' })
|
||||
|
||||
187
packages/ui/app-boot/tests/config-dump.spec.ts
Normal file
187
packages/ui/app-boot/tests/config-dump.spec.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* `renderConfigDump` behavior: the offline composition must equal what
|
||||
* `boot()` mounts (same parser, same patch algorithm), print `!!js`
|
||||
* expressions verbatim, separate provenance runs with comment lines while
|
||||
* staying one loadable YAML document, and report skipped patches through
|
||||
* `warn` instead of failing — mirroring the Loader's boot-time warning for a
|
||||
* shared overlay whose row exists only on another surface.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { entryListSchema } from '@cordisjs/plugin-include'
|
||||
import { loadOverlayPatches, renderConfigDump } from '../src/index.ts'
|
||||
|
||||
const NAME = 'dsh-test-bin'
|
||||
|
||||
const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-config-dump-'))
|
||||
|
||||
function writeBase(dir: string): string {
|
||||
const base = join(dir, 'base.yml')
|
||||
writeFileSync(base, [
|
||||
'- id: shared',
|
||||
' name: ./noop.mjs',
|
||||
' config:',
|
||||
' value: base',
|
||||
' key: !!js process.env.DSH_DUMP_SPEC',
|
||||
'- id: untouched',
|
||||
' name: ./noop.mjs',
|
||||
'',
|
||||
].join('\n'))
|
||||
return base
|
||||
}
|
||||
|
||||
describe('renderConfigDump', () => {
|
||||
it('composes overlay layers in order, prints !!js verbatim, and labels each section with its provenance', () => {
|
||||
const dir = tmp()
|
||||
const base = writeBase(dir)
|
||||
const surface = join(dir, 'surface.yml')
|
||||
writeFileSync(surface, [
|
||||
'- id: shared',
|
||||
' config:',
|
||||
' value: surface',
|
||||
' key: !!js process.env.DSH_DUMP_SPEC',
|
||||
'- insert:',
|
||||
' - id: surface-extra',
|
||||
' name: ./noop.mjs',
|
||||
'',
|
||||
].join('\n'))
|
||||
const personal = join(dir, 'personal.yml')
|
||||
writeFileSync(personal, [
|
||||
'- id: surface-extra',
|
||||
' config:',
|
||||
' value: personal',
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
const dump = renderConfigDump(NAME, base, [
|
||||
{ label: 'surface.yml', patches: loadOverlayPatches(NAME, surface) },
|
||||
{ label: 'personal.yml', patches: loadOverlayPatches(NAME, personal) },
|
||||
], () => {})
|
||||
// Comments do not break loadability: the dump parses as one document
|
||||
// equal to what boot() would mount.
|
||||
const parsed = yaml.load(dump, { schema: entryListSchema }) as {
|
||||
id: string
|
||||
config?: Record<string, unknown>
|
||||
}[]
|
||||
expect(parsed).toEqual([
|
||||
{
|
||||
id: 'shared',
|
||||
name: './noop.mjs',
|
||||
config: { value: 'surface', key: { __jsExpr: 'process.env.DSH_DUMP_SPEC' } },
|
||||
},
|
||||
{ id: 'untouched', name: './noop.mjs' },
|
||||
{ id: 'surface-extra', name: './noop.mjs', config: { value: 'personal' } },
|
||||
])
|
||||
// Unevaluated: the expression text round-trips as a !!js scalar.
|
||||
expect(dump).toContain('!!js process.env.DSH_DUMP_SPEC')
|
||||
// Provenance separators: origin file, plus every layer that changed the
|
||||
// row; an inserted row carries the inserting layer as its origin.
|
||||
expect(dump).toContain('# == base.yml, patched by surface.yml')
|
||||
expect(dump).toContain('# == base.yml\n- id: untouched')
|
||||
expect(dump).toContain('# == surface.yml, patched by personal.yml\n- id: surface-extra')
|
||||
expect(dump.indexOf('# == base.yml, patched by surface.yml')).toBeLessThan(dump.indexOf('# == base.yml\n- id: untouched'))
|
||||
})
|
||||
|
||||
it('groups contiguous same-provenance rows under one separator', () => {
|
||||
const dir = tmp()
|
||||
const base = join(dir, 'base.yml')
|
||||
writeFileSync(base, [
|
||||
'- id: a',
|
||||
' name: ./noop.mjs',
|
||||
'- id: b',
|
||||
' name: ./noop.mjs',
|
||||
'',
|
||||
].join('\n'))
|
||||
const dump = renderConfigDump(NAME, base, [], () => {})
|
||||
expect(dump.match(/# == base\.yml/g)).toHaveLength(1)
|
||||
expect(dump).toContain('# == base.yml\n- id: a')
|
||||
})
|
||||
|
||||
it('composes all layers as one flattened patch list, exactly like boot()', () => {
|
||||
// boot() flattens every layer into ONE applyEntryPatches call, whose id
|
||||
// index sees inserted rows but NOT children introduced by a plain group
|
||||
// `config` replacement. A per-layer composition would rebuild the index
|
||||
// between layers and let the second layer patch that child — a tree the
|
||||
// real boot never mounts. Pin the single-call semantics: the child patch
|
||||
// is skipped (with the layer-labeled warning), matching boot.
|
||||
const dir = tmp()
|
||||
const base = join(dir, 'base.yml')
|
||||
writeFileSync(base, [
|
||||
'- id: g',
|
||||
' name: ./group.mjs',
|
||||
' group: true',
|
||||
' config: []',
|
||||
'',
|
||||
].join('\n'))
|
||||
const warnings: string[] = []
|
||||
const dump = renderConfigDump(NAME, base, [
|
||||
{
|
||||
label: 'a.yml',
|
||||
patches: [{ id: 'g', config: [{ id: 'child', name: './noop.mjs', config: { v: 1 } }] }],
|
||||
},
|
||||
{ label: 'b.yml', patches: [{ id: 'child', config: { v: 2 } }] },
|
||||
], line => void warnings.push(line))
|
||||
expect(warnings).toEqual([`${NAME}: [b.yml] patch: entry "child" not found`])
|
||||
const parsed = yaml.load(dump, { schema: entryListSchema }) as {
|
||||
config?: { config?: { v?: number } }[]
|
||||
}[]
|
||||
expect(parsed[0]?.config?.[0]?.config?.v).toBe(1)
|
||||
// The skipped layer did not change the row, so it is not in provenance.
|
||||
expect(dump).toContain('# == base.yml, patched by a.yml\n- id: g')
|
||||
expect(dump).not.toContain('b.yml\n- id: g')
|
||||
})
|
||||
|
||||
it('reports a patch whose target row is absent through warn with its layer label and keeps composing', () => {
|
||||
const dir = tmp()
|
||||
const base = writeBase(dir)
|
||||
const overlay = join(dir, 'overlay.yml')
|
||||
writeFileSync(overlay, [
|
||||
'- id: only-on-another-surface',
|
||||
' config:',
|
||||
' value: ignored',
|
||||
'- id: shared',
|
||||
' config:',
|
||||
' value: patched',
|
||||
'',
|
||||
].join('\n'))
|
||||
const warnings: string[] = []
|
||||
const dump = renderConfigDump(
|
||||
NAME, base,
|
||||
[{ label: 'overlay.yml', patches: loadOverlayPatches(NAME, overlay) }],
|
||||
line => void warnings.push(line),
|
||||
)
|
||||
expect(warnings).toEqual([`${NAME}: [overlay.yml] patch: entry "only-on-another-surface" not found`])
|
||||
const parsed = yaml.load(dump, { schema: entryListSchema }) as { config?: { value?: string } }[]
|
||||
expect(parsed[0]?.config?.value).toBe('patched')
|
||||
})
|
||||
|
||||
it('defaults its warn sink to one stderr line per skipped patch', () => {
|
||||
const dir = tmp()
|
||||
const base = writeBase(dir)
|
||||
const write = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
|
||||
try {
|
||||
renderConfigDump(NAME, base, [{ label: 'x.yml', patches: [{ id: 'absent', config: {} }] }])
|
||||
expect(write).toHaveBeenCalledWith(`${NAME}: [x.yml] patch: entry "absent" not found\n`)
|
||||
} finally {
|
||||
write.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('fails loud on a missing, unparsable, or non-array base config', () => {
|
||||
const dir = tmp()
|
||||
expect(() => renderConfigDump(NAME, join(dir, 'absent.yml'), [], () => {}))
|
||||
.toThrow(new RegExp(`^${NAME}: failed to read config `))
|
||||
const invalid = join(dir, 'invalid.yml')
|
||||
writeFileSync(invalid, 'invalid: [unclosed\n')
|
||||
expect(() => renderConfigDump(NAME, invalid, [], () => {}))
|
||||
.toThrow(new RegExp(`^${NAME}: failed to parse config `))
|
||||
const scalar = join(dir, 'scalar.yml')
|
||||
writeFileSync(scalar, 'id: not-a-list\n')
|
||||
expect(() => renderConfigDump(NAME, scalar, [], () => {}))
|
||||
.toThrow('must be a top-level YAML array of entries')
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,7 @@
|
||||
/**
|
||||
* Config hot-reload resilience of the booted include tree. `dsh-app-boot`
|
||||
* installs a fail-loud unhandled-rejection handler, so a `refresh()` that
|
||||
* rethrows a config-file parse error would kill a live app on one bad
|
||||
* `cordis.yml` edit (the HMR watcher awaits `refresh()` in an async event
|
||||
* callback nobody else catches). These tests pin the vendored
|
||||
* `@cordisjs/plugin-include` contract that boot relies on: an invalid file
|
||||
* keeps the last good tree, and a valid re-read re-applies overlay patches
|
||||
* exactly like the initial load.
|
||||
* Transactional config replacement through the booted Include and Loader tree.
|
||||
* HMR contains rejected refreshes; direct callers receive the error after the
|
||||
* previous generation has been retained or restored.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
@@ -15,6 +10,7 @@ import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import type { Include } from '@cordisjs/plugin-include'
|
||||
import { Group } from '@cordisjs/plugin-loader'
|
||||
import { boot } from '../src/index.ts'
|
||||
|
||||
const NAME = 'dsh-test-bin'
|
||||
@@ -27,9 +23,10 @@ interface TreeFixture {
|
||||
include: Include
|
||||
}
|
||||
|
||||
async function bootTree(configBody: string): Promise<TreeFixture> {
|
||||
async function bootTree(configBody: string, files: Record<string, string> = {}): Promise<TreeFixture> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-'))
|
||||
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
|
||||
for (const [name, content] of Object.entries(files)) writeFileSync(join(dir, name), content)
|
||||
writeFileSync(join(dir, 'cordis.yml'), configBody)
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
|
||||
const entry = [...ctx.loader.entries()].find(candidate => candidate.subtree !== undefined)
|
||||
@@ -41,20 +38,41 @@ function entryConfig(ctx: Context, id: string): unknown {
|
||||
return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config
|
||||
}
|
||||
|
||||
function entryById(ctx: Context, id: string) {
|
||||
const entry = [...ctx.loader.entries()].find(entry => entry.options.id === id)
|
||||
if (!entry) throw new Error(`missing loader entry ${id}`)
|
||||
return entry
|
||||
}
|
||||
|
||||
function plugin(name: string, body = ''): string {
|
||||
return `export default function ${name}(_ctx, config = {}) { ${body} }\n`
|
||||
}
|
||||
|
||||
async function expectUpdateFailure(task: Promise<void>, stage: string): Promise<void> {
|
||||
try {
|
||||
await task
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as Error).message).toContain(`failed to ${stage} loader entry`)
|
||||
return
|
||||
}
|
||||
throw new Error(`expected loader update to fail during ${stage}`)
|
||||
}
|
||||
|
||||
describe('include refresh with an invalid file', () => {
|
||||
it('keeps the last good tree instead of throwing, then applies the next valid edit', async () => {
|
||||
it('rejects while keeping the last good tree, then applies the next valid edit', async () => {
|
||||
const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n config:\n value: 1\n')
|
||||
try {
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
|
||||
|
||||
writeFileSync(join(dir, 'cordis.yml'), 'invalid: [unclosed\n')
|
||||
await expect(include.refresh()).resolves.toBeUndefined()
|
||||
await expect(include.refresh()).rejects.toThrow('failed to parse config file')
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
|
||||
|
||||
// An empty file parses to `undefined` without a YAML error; it must be
|
||||
// treated exactly like a parse failure, not crash the entry walk.
|
||||
writeFileSync(join(dir, 'cordis.yml'), '')
|
||||
await expect(include.refresh()).resolves.toBeUndefined()
|
||||
await expect(include.refresh()).rejects.toThrow('failed to validate config file')
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
|
||||
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: 2\n')
|
||||
@@ -67,6 +85,200 @@ describe('include refresh with an invalid file', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('loader entry replacement', () => {
|
||||
it('imports a changed name before replacing the running plugin', async () => {
|
||||
const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', {
|
||||
'old.mjs': plugin('oldPlugin'),
|
||||
'new.mjs': plugin('newPlugin'),
|
||||
})
|
||||
try {
|
||||
const entry = entryById(ctx, 'target')
|
||||
await entry.update({ name: './new.mjs' })
|
||||
expect(entry.options.name).toBe('./new.mjs')
|
||||
expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options)
|
||||
expect(entry.fiber?.runtime?.callback.name).toBe('newPlugin')
|
||||
expect(entry.options.disabled).toBeUndefined()
|
||||
await entry.fiber?.await()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('retains the running plugin when the replacement cannot be imported', async () => {
|
||||
const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', {
|
||||
'old.mjs': plugin('oldPlugin'),
|
||||
})
|
||||
try {
|
||||
const entry = entryById(ctx, 'target')
|
||||
const fiber = entry.fiber
|
||||
await expectUpdateFailure(entry.update({ name: './missing.mjs' }), 'import')
|
||||
expect(entry.options.name).toBe('./old.mjs')
|
||||
expect(entry.fiber === fiber).toBe(true)
|
||||
await fiber?.await()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('restores the previous plugin after replacement application fails', async () => {
|
||||
const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', {
|
||||
'old.mjs': plugin('oldPlugin'),
|
||||
'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'),
|
||||
})
|
||||
try {
|
||||
const entry = entryById(ctx, 'target')
|
||||
const previous = entry.fiber
|
||||
await expectUpdateFailure(entry.update({ name: './bad.mjs' }), 'apply')
|
||||
expect(entry.options.name).toBe('./old.mjs')
|
||||
expect(entry.fiber === previous).toBe(false)
|
||||
expect(entry.fiber?.runtime?.callback.name).toBe('oldPlugin')
|
||||
expect(entry.options.disabled).toBeUndefined()
|
||||
await entry.fiber?.await()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('restores the previous config when an in-place restart fails', async () => {
|
||||
const { ctx } = await bootTree('- id: target\n name: ./configurable.mjs\n config:\n fail: false\n', {
|
||||
'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'),
|
||||
})
|
||||
try {
|
||||
const entry = entryById(ctx, 'target')
|
||||
const fiber = entry.fiber
|
||||
await expectUpdateFailure(entry.update({ config: { fail: true } }), 'apply')
|
||||
expect(entry.options.config).toEqual({ fail: false })
|
||||
expect(entry.fiber === fiber).toBe(true)
|
||||
await fiber?.await()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not persist a failed direct fiber update', async () => {
|
||||
const { ctx } = await bootTree('- id: target\n name: ./configurable.mjs\n config:\n fail: false\n', {
|
||||
'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'),
|
||||
})
|
||||
try {
|
||||
const entry = entryById(ctx, 'target')
|
||||
const fiber = entry.fiber
|
||||
if (!fiber) throw new Error('target entry has no fiber')
|
||||
await expect(fiber.update({ fail: true })).rejects.toThrow('candidate config failed')
|
||||
expect(entry.options.config).toEqual({ fail: false })
|
||||
expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('loader tree replacement', () => {
|
||||
it('rolls back earlier updates and additions when a later entry fails', async () => {
|
||||
const { ctx, dir, include } = await bootTree([
|
||||
'- id: existing',
|
||||
' name: ./configurable.mjs',
|
||||
' config:',
|
||||
' value: old',
|
||||
'',
|
||||
].join('\n'), {
|
||||
'configurable.mjs': plugin('configurablePlugin'),
|
||||
'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'),
|
||||
})
|
||||
try {
|
||||
writeFileSync(join(dir, 'cordis.yml'), [
|
||||
'- id: existing',
|
||||
' name: ./configurable.mjs',
|
||||
' config:',
|
||||
' value: candidate',
|
||||
'- id: added',
|
||||
' name: ./noop.mjs',
|
||||
'- id: bad',
|
||||
' name: ./bad.mjs',
|
||||
'',
|
||||
].join('\n'))
|
||||
await expect(include.refresh()).rejects.toThrow('failed to apply loader entry bad')
|
||||
expect(entryConfig(ctx, 'existing')).toEqual({ value: 'old' })
|
||||
expect([...ctx.loader.entries()].some(entry => entry.options.id === 'added')).toBe(false)
|
||||
expect([...ctx.loader.entries()].some(entry => entry.options.id === 'bad')).toBe(false)
|
||||
|
||||
writeFileSync(join(dir, 'cordis.yml'), [
|
||||
'- id: existing',
|
||||
' name: ./configurable.mjs',
|
||||
' config:',
|
||||
' value: committed',
|
||||
'- id: added',
|
||||
' name: ./noop.mjs',
|
||||
'',
|
||||
].join('\n'))
|
||||
await include.refresh()
|
||||
expect(entryConfig(ctx, 'existing')).toEqual({ value: 'committed' })
|
||||
expect(entryById(ctx, 'added').fiber).toBeDefined()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('stops and restores descendants when an ancestor group is disabled and re-enabled', async () => {
|
||||
const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n')
|
||||
ctx.loader.builtins.group = Group
|
||||
try {
|
||||
const config = (disabled: boolean) => [
|
||||
'- id: parent',
|
||||
' name: cordis:group',
|
||||
' group: true',
|
||||
` disabled: ${disabled}`,
|
||||
' config:',
|
||||
' - id: child',
|
||||
' name: ./noop.mjs',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
writeFileSync(join(dir, 'cordis.yml'), config(false))
|
||||
await include.refresh()
|
||||
expect(entryById(ctx, 'child').fiber).toBeDefined()
|
||||
|
||||
writeFileSync(join(dir, 'cordis.yml'), config(true))
|
||||
await include.refresh()
|
||||
expect(entryById(ctx, 'child').fiber).toBeUndefined()
|
||||
|
||||
writeFileSync(join(dir, 'cordis.yml'), config(false))
|
||||
await include.refresh()
|
||||
expect(entryById(ctx, 'child').fiber).toBeDefined()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('restores a programmatic entry move when its update fails', async () => {
|
||||
const { ctx } = await bootTree('- id: noop\n name: ./noop.mjs\n', {
|
||||
'movable.mjs': plugin('movablePlugin', 'if (config.fail) throw new Error("candidate config failed")'),
|
||||
})
|
||||
ctx.loader.builtins.group = Group
|
||||
try {
|
||||
const groupId = await ctx.loader.create({ name: 'cordis:group', group: true, config: [] })
|
||||
const targetId = await ctx.loader.create({ name: './movable.mjs', config: { fail: false } })
|
||||
const target = entryById(ctx, targetId)
|
||||
const source = target.parent
|
||||
const sourceIndex = source.data.indexOf(target.options)
|
||||
const destination = entryById(ctx, groupId).subgroup
|
||||
if (!destination) throw new Error('created loader group has no subgroup')
|
||||
|
||||
await expectUpdateFailure(
|
||||
ctx.loader.update(targetId, { config: { fail: true } }, groupId),
|
||||
'apply',
|
||||
)
|
||||
|
||||
expect(target.parent).toBe(source)
|
||||
expect(Object.getPrototypeOf(target.ctx)).toBe(source.ctx)
|
||||
expect(source.data.indexOf(target.options)).toBe(sourceIndex)
|
||||
expect(destination.data).not.toContain(target.options)
|
||||
expect(target.options.config).toEqual({ fail: false })
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('include refresh with overlay patches', () => {
|
||||
it('re-applies entry patches and inserted entries on every re-read (parity with initial load)', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-overlay-'))
|
||||
@@ -116,9 +328,9 @@ describe('include refresh with overlay patches', () => {
|
||||
await ctx.loader.await()
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' })
|
||||
|
||||
// Removing every patch must revert to the file's own values: patching
|
||||
// may not bake earlier patch results into the cached parse.
|
||||
await entry.update({ config: { path: './base.yml', patches: [] } })
|
||||
// Omitting the patch list must remove the overlay rather than reuse the
|
||||
// Include's previous config through a default parameter.
|
||||
await entry.update({ config: { path: './base.yml' } })
|
||||
await ctx.loader.await()
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'edited-2' })
|
||||
} finally {
|
||||
|
||||
142
packages/ui/app-boot/tests/hmr-config.spec.ts
Normal file
142
packages/ui/app-boot/tests/hmr-config.spec.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import Hmr from '@cordisjs/plugin-hmr'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Timer from '@cordisjs/plugin-timer'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
async function bootHmr(dir: string): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(dir).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
await ctx.plugin(Timer)
|
||||
await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function eventually(test: () => boolean, message: string): Promise<void> {
|
||||
const deadline = Date.now() + 10_000
|
||||
while (!test()) {
|
||||
if (Date.now() >= deadline) throw new Error(message)
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
describe('HMR exact config paths', () => {
|
||||
it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
|
||||
const filename = join(dir, 'plugins.yml')
|
||||
const ctx = await bootHmr(dir)
|
||||
const observed: string[] = []
|
||||
try {
|
||||
await ctx.hmr.registerConfig(filename, () => {
|
||||
try {
|
||||
observed.push(readFileSync(filename, 'utf8'))
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
observed.push('missing')
|
||||
}
|
||||
})
|
||||
|
||||
writeFileSync(filename, 'one', { flag: 'wx' })
|
||||
await eventually(() => observed.includes('one'), 'HMR did not observe config creation')
|
||||
writeFileSync(filename, 'two')
|
||||
await eventually(() => observed.includes('two'), 'HMR did not observe config change')
|
||||
unlinkSync(filename)
|
||||
await eventually(() => observed.includes('missing'), 'HMR did not observe config removal')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
|
||||
const dir = join(root, 'later')
|
||||
const filename = join(dir, 'plugins.yml')
|
||||
const ctx = await bootHmr(root)
|
||||
const observed: string[] = []
|
||||
try {
|
||||
await ctx.hmr.registerConfig(filename, () => {
|
||||
observed.push(readFileSync(filename, 'utf8'))
|
||||
})
|
||||
mkdirSync(dir)
|
||||
writeFileSync(filename, 'created')
|
||||
await eventually(() => observed.includes('created'), 'HMR did not observe config creation under a new parent')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('serializes refreshes and waits for them during disposal', { timeout: 20_000 }, async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
|
||||
const filename = join(dir, 'plugins.yml')
|
||||
writeFileSync(filename, 'one')
|
||||
const ctx = await bootHmr(dir)
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const observed: string[] = []
|
||||
let active = 0
|
||||
let maxActive = 0
|
||||
try {
|
||||
const dispose = await ctx.hmr.registerConfig(filename, async () => {
|
||||
active += 1
|
||||
maxActive = Math.max(maxActive, active)
|
||||
observed.push(readFileSync(filename, 'utf8'))
|
||||
if (observed.length === 1) {
|
||||
started.resolve(undefined)
|
||||
await release.promise
|
||||
}
|
||||
active -= 1
|
||||
})
|
||||
await started.promise
|
||||
writeFileSync(filename, 'two')
|
||||
// Chokidar coalesces atomic writes for 100 ms by default. Wait beyond
|
||||
// that window so this edit is queued before registration disposal.
|
||||
await new Promise(resolve => setTimeout(resolve, 250))
|
||||
|
||||
let disposed = false
|
||||
const disposal = dispose().then(() => { disposed = true })
|
||||
await Promise.resolve()
|
||||
expect(disposed).toBe(false)
|
||||
release.resolve(undefined)
|
||||
await disposal
|
||||
expect(maxActive).toBe(1)
|
||||
expect(observed).toEqual(['one', 'two'])
|
||||
} finally {
|
||||
release.resolve(undefined)
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('normalizes refresh failures and broadcasts them without escaping the watcher', { timeout: 20_000 }, async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
|
||||
const filename = join(dir, 'plugins.yml')
|
||||
const ctx = await bootHmr(dir)
|
||||
const failure = Promise.withResolvers<{ filename: string; error: Error }>()
|
||||
let failureCount = 0
|
||||
try {
|
||||
ctx.on('hmr/config-update-failed', () => {
|
||||
throw new Error('observer failed')
|
||||
})
|
||||
ctx.on('hmr/config-update-failed', (failedFilename, error) => {
|
||||
failureCount += 1
|
||||
failure.resolve({ filename: failedFilename, error })
|
||||
})
|
||||
await ctx.hmr.registerConfig(filename, () => { throw 42 })
|
||||
writeFileSync(filename, 'invalid')
|
||||
|
||||
const observed = await failure.promise
|
||||
expect(observed.filename).toBe(filename)
|
||||
expect(observed.error).toBeInstanceOf(Error)
|
||||
expect(observed.error.message).toBe('42')
|
||||
|
||||
writeFileSync(filename, 'invalid again')
|
||||
await eventually(() => failureCount === 2, 'HMR stopped broadcasting after an observer rejected')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -4,21 +4,36 @@
|
||||
* a real Loader tree.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { mkdirSync, mkdtempSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import { Context } from 'cordis'
|
||||
import Hmr from '@cordisjs/plugin-hmr'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Timer from '@cordisjs/plugin-timer'
|
||||
import {
|
||||
boot,
|
||||
loadPersonalPatches,
|
||||
PERSONAL_CONFIG_FILENAME,
|
||||
watchPersonalPatches,
|
||||
} from '../src/index.ts'
|
||||
|
||||
const NAME = 'dsh-test-bin'
|
||||
|
||||
const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-personal-config-'))
|
||||
|
||||
async function eventually(test: () => boolean, message: string): Promise<void> {
|
||||
const deadline = Date.now() + 10_000
|
||||
while (!test()) {
|
||||
if (Date.now() >= deadline) throw new Error(message)
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
const settleChokidarChangeThrottle = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 75))
|
||||
|
||||
describe('loadPersonalPatches', () => {
|
||||
afterEach(() => {
|
||||
delete process.env.DSH_HOME
|
||||
@@ -86,7 +101,13 @@ describe('loadPersonalPatches', () => {
|
||||
|
||||
describe('boot with personal patches', () => {
|
||||
function writeTree(dir: string): string {
|
||||
writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
|
||||
writeFileSync(join(dir, 'noop.mjs'), [
|
||||
'export const name = "noop"',
|
||||
'export function apply(_ctx, config = {}) {',
|
||||
' if (config.fail) throw new Error("candidate config failed")',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n')
|
||||
return join(dir, 'cordis.yml')
|
||||
}
|
||||
@@ -138,4 +159,112 @@ describe('boot with personal patches', () => {
|
||||
await ctxEmpty.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('watches add, failure, recovery, and removal through transactional HMR', { timeout: 20_000 }, async () => {
|
||||
const dir = tmp()
|
||||
const personal = tmp()
|
||||
const filename = join(personal, PERSONAL_CONFIG_FILENAME)
|
||||
const basePatches = [{ id: 'noop', config: { value: 'generated' } }]
|
||||
const ctx = await boot(NAME, writeTree(dir), basePatches)
|
||||
await ctx.plugin(Timer)
|
||||
await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
|
||||
const failures: Array<{ filename: string; error: Error }> = []
|
||||
ctx.on('hmr/config-update-failed', (failedFilename, error) => {
|
||||
failures.push({ filename: failedFilename, error })
|
||||
})
|
||||
const dispose = await watchPersonalPatches(ctx, {
|
||||
binName: NAME,
|
||||
dir: personal,
|
||||
compose: personalPatches => [...basePatches, ...personalPatches],
|
||||
})
|
||||
try {
|
||||
writeFileSync(filename, '- id: noop\n config:\n value: live\n')
|
||||
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'live', 'personal config addition was not applied')
|
||||
|
||||
writeFileSync(filename, '- id: noop\n config:\n fail: true\n')
|
||||
await eventually(() => failures.length === 1, 'failed candidate was not broadcast')
|
||||
expect(failures[0]).toMatchObject({ filename })
|
||||
expect(failures[0]?.error).toBeInstanceOf(Error)
|
||||
expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live')
|
||||
await settleChokidarChangeThrottle()
|
||||
|
||||
writeFileSync(filename, 'invalid: [unclosed\n')
|
||||
await eventually(() => failures.length === 2, 'parse failure was not broadcast')
|
||||
expect(failures[1]?.error).toBeInstanceOf(Error)
|
||||
expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live')
|
||||
await settleChokidarChangeThrottle()
|
||||
|
||||
writeFileSync(filename, '- id: noop\n config:\n value: recovered\n')
|
||||
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'recovered', 'valid recovery was not applied')
|
||||
await settleChokidarChangeThrottle()
|
||||
|
||||
unlinkSync(filename)
|
||||
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'generated', 'personal config removal did not restore the app-owned patch')
|
||||
expect(failures).toHaveLength(2)
|
||||
await settleChokidarChangeThrottle()
|
||||
|
||||
// Default compose: the personal overlay IS the whole patch list, so a
|
||||
// fresh generation replaces the app-owned layer instead of stacking on it.
|
||||
await dispose()
|
||||
const disposeDefault = await watchPersonalPatches(ctx, { binName: NAME, dir: personal })
|
||||
try {
|
||||
writeFileSync(filename, '- id: noop\n config:\n value: identity\n')
|
||||
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'identity', 'default-compose personal patch was not applied')
|
||||
} finally {
|
||||
await disposeDefault()
|
||||
}
|
||||
} finally {
|
||||
await dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('fails loud when the exact watcher lacks HMR or a root Include', async () => {
|
||||
const dir = tmp()
|
||||
const withoutHmr = await boot(NAME, writeTree(dir))
|
||||
await expect(watchPersonalPatches(withoutHmr, { binName: NAME, dir: tmp() })).rejects.toThrow('requires the Cordis HMR service')
|
||||
await withoutHmr.fiber.dispose()
|
||||
|
||||
const withoutInclude = new Context()
|
||||
withoutInclude.baseUrl = pathToFileURL(`${tmp()}/`).href
|
||||
await withoutInclude.plugin(Loader)
|
||||
await withoutInclude.plugin(Timer)
|
||||
await withoutInclude.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
|
||||
await expect(watchPersonalPatches(withoutInclude, { binName: NAME, dir: tmp() })).rejects.toThrow('requires the root Include entry')
|
||||
await withoutInclude.fiber.dispose()
|
||||
})
|
||||
|
||||
it('returns a no-op disposer when the tree is disposed while the watcher opens', async () => {
|
||||
// A TUI `/exit` typed during startup disposes the whole tree while
|
||||
// registerConfig's effect registration is still in flight (the HMR effect
|
||||
// then fails with INACTIVE_EFFECT); the app is exiting exactly as asked,
|
||||
// so the watcher must not crash the process. The stub makes the race
|
||||
// deterministic — the live-teardown ordering itself is not stageable.
|
||||
const dir = tmp()
|
||||
const ctx = await boot(NAME, writeTree(dir))
|
||||
try {
|
||||
const teardown = Object.assign(new Error('cannot create effect on inactive context'), { code: 'INACTIVE_EFFECT' })
|
||||
ctx.provide('hmr', { registerConfig: () => Promise.reject(teardown) })
|
||||
const dispose = await watchPersonalPatches(ctx, { binName: NAME, dir: tmp() })
|
||||
await expect(dispose()).resolves.toBeUndefined()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('propagates registration failures other than mid-teardown', async () => {
|
||||
const dir = tmp()
|
||||
const personal = tmp()
|
||||
const ctx = await boot(NAME, writeTree(dir))
|
||||
try {
|
||||
await ctx.plugin(Timer)
|
||||
await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
|
||||
const dispose = await watchPersonalPatches(ctx, { binName: NAME, dir: personal })
|
||||
// Same personal path registered twice: HMR refuses; not a teardown race.
|
||||
await expect(watchPersonalPatches(ctx, { binName: NAME, dir: personal })).rejects.toThrow('already registered')
|
||||
await dispose()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
159
packages/ui/app-boot/tests/repository-cache.spec.ts
Normal file
159
packages/ui/app-boot/tests/repository-cache.spec.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { promisify } from 'node:util'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BUNDLED_PNPM_VERSION, RepositoryCache, type RepositoryInstall } from '@cordisjs/plugin-loader/repository'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const roots: string[] = []
|
||||
|
||||
async function temporaryRoot(name: string): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), `cordis-${name}-`))
|
||||
roots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
async function fakePackage(directory: string): Promise<void> {
|
||||
const target = join(directory, 'node_modules', 'repository')
|
||||
await mkdir(target, { recursive: true })
|
||||
await writeFile(join(target, 'package.json'), '{"name":"fixture"}\n')
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs()
|
||||
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('RepositoryCache', () => {
|
||||
it('single-flights and permanently reuses an exact specifier', async () => {
|
||||
const root = await temporaryRoot('repository-cache')
|
||||
const calls: string[] = []
|
||||
const install: RepositoryInstall = async (directory) => {
|
||||
calls.push(directory)
|
||||
await fakePackage(directory)
|
||||
}
|
||||
const cache = new RepositoryCache(root, install)
|
||||
const specifier = 'github:owner/repository#0123456789abcdef'
|
||||
|
||||
const [first, concurrent] = await Promise.all([cache.resolve(specifier), cache.resolve(specifier)])
|
||||
expect(concurrent).toBe(first)
|
||||
expect(calls).toHaveLength(1)
|
||||
|
||||
const reopened = new RepositoryCache(root, async () => { throw new Error('cache miss') })
|
||||
expect(await reopened.resolve(specifier)).toBe(first)
|
||||
expect(JSON.parse(await readFile(join(first, '..', '..', 'package.json'), 'utf8'))).toMatchObject({
|
||||
packageManager: `pnpm@${BUNDLED_PNPM_VERSION}`,
|
||||
dependencies: { repository: specifier },
|
||||
})
|
||||
|
||||
const second = await cache.resolve('github:owner/repository#fedcba9876543210')
|
||||
expect(second).not.toBe(first)
|
||||
expect(calls).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('accepts the valid winner when independent cache instances race', async () => {
|
||||
const root = await temporaryRoot('repository-race')
|
||||
const bothStarted = Promise.withResolvers<undefined>()
|
||||
let starts = 0
|
||||
const install: RepositoryInstall = async (directory) => {
|
||||
await fakePackage(directory)
|
||||
starts += 1
|
||||
if (starts === 2) bothStarted.resolve(undefined)
|
||||
await bothStarted.promise
|
||||
}
|
||||
const specifier = 'github:owner/repository#race'
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
new RepositoryCache(root, install).resolve(specifier),
|
||||
new RepositoryCache(root, install).resolve(specifier),
|
||||
])
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(starts).toBe(2)
|
||||
expect(await readdir(root)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('removes a failed staging tree and permits an exact retry', async () => {
|
||||
const root = await temporaryRoot('repository-retry')
|
||||
let attempts = 0
|
||||
const cache = new RepositoryCache(root, async (directory) => {
|
||||
attempts += 1
|
||||
if (attempts === 1) throw new Error('install failed')
|
||||
await fakePackage(directory)
|
||||
})
|
||||
|
||||
await expect(cache.resolve('github:owner/repository#ref')).rejects.toThrow('failed to prepare repository')
|
||||
expect(await readdir(root)).toEqual([])
|
||||
await expect(cache.resolve('github:owner/repository#ref')).resolves.toContain('node_modules')
|
||||
expect(attempts).toBe(2)
|
||||
})
|
||||
|
||||
it('rejects empty or padded specifiers before touching the cache', async () => {
|
||||
const root = await temporaryRoot('repository-input')
|
||||
const cache = new RepositoryCache(root, fakePackage)
|
||||
expect(() => cache.resolve('')).toThrow('non-empty unpadded string')
|
||||
expect(() => cache.resolve(' github:owner/repository#ref')).toThrow('non-empty unpadded string')
|
||||
await expect(readdir(root)).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('fails loud on a corrupt published marker instead of reinstalling it', async () => {
|
||||
const root = await temporaryRoot('repository-corrupt')
|
||||
const specifier = 'github:owner/repository#corrupt'
|
||||
const key = createHash('sha256').update(specifier).digest('hex')
|
||||
const entry = join(root, key)
|
||||
await mkdir(join(entry, 'node_modules', 'repository'), { recursive: true })
|
||||
await writeFile(join(entry, '.repository-cache.json'), '{}\n')
|
||||
const cache = new RepositoryCache(root, async () => { throw new Error('must not reinstall') })
|
||||
|
||||
await expect(cache.resolve(specifier)).rejects.toThrow('repository cache marker is invalid')
|
||||
})
|
||||
|
||||
it('selects and prepares a root .dsh-plugin Git subpath through the bundled pnpm', { timeout: 60_000 }, async () => {
|
||||
const root = await temporaryRoot('repository-pnpm')
|
||||
const repository = join(root, 'source')
|
||||
await mkdir(join(repository, '.dsh-plugin'), { recursive: true })
|
||||
await mkdir(join(repository, 'skills', 'fixture'), { recursive: true })
|
||||
await writeFile(join(repository, 'package.json'), `${JSON.stringify({
|
||||
name: 'repository-fixture',
|
||||
version: '1.0.0',
|
||||
})}\n`)
|
||||
await writeFile(join(repository, 'skills', 'fixture', 'SKILL.md'), 'repository skill source\n')
|
||||
await writeFile(join(repository, '.dsh-plugin', 'package.json'), `${JSON.stringify({
|
||||
name: 'repository-plugin-fixture',
|
||||
version: '1.0.0',
|
||||
scripts: { prepare: 'node prepare.mjs' },
|
||||
dsh: { skills: ['../skills'] },
|
||||
})}\n`)
|
||||
await writeFile(join(repository, '.dsh-plugin', 'prepare.mjs'), [
|
||||
"import { cp, mkdir, writeFile } from 'node:fs/promises'",
|
||||
"await mkdir('dsh-plugin-assets/skills', { recursive: true })",
|
||||
"await cp('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })",
|
||||
"await writeFile('dsh-plugin.mjs', 'export function apply() {}\\n')",
|
||||
"await writeFile('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}\\n`)",
|
||||
'',
|
||||
].join('\n'))
|
||||
await execFileAsync('git', ['init', '--quiet'], { cwd: repository })
|
||||
await execFileAsync('git', ['add', '.'], { cwd: repository })
|
||||
await execFileAsync('git', [
|
||||
'-c', 'user.name=Repository Fixture',
|
||||
'-c', 'user.email=repository@example.invalid',
|
||||
'commit', '--quiet', '-m', 'fixture',
|
||||
], { cwd: repository })
|
||||
const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repository, encoding: 'utf8' })
|
||||
const specifier = `git+${pathToFileURL(repository).href}#${stdout.trim()}&path:/.dsh-plugin`
|
||||
vi.stubEnv('REPOSITORY_TEST_VISIBLE', 'visible')
|
||||
vi.stubEnv('REPOSITORY_TEST_TOKEN', 'hidden')
|
||||
|
||||
const installed = await new RepositoryCache(join(root, 'cache')).resolve(specifier)
|
||||
await expect(readFile(join(installed, 'prepared.txt'), 'utf8')).resolves.toBe('visible|absent\n')
|
||||
await expect(readFile(join(installed, 'dsh-plugin.mjs'), 'utf8')).resolves.toContain('export function apply')
|
||||
await expect(readFile(join(installed, 'dsh-plugin-assets/skills/0/fixture/SKILL.md'), 'utf8'))
|
||||
.resolves.toBe('repository skill source\n')
|
||||
await expect(readFile(join(installed, 'package.json'), 'utf8'))
|
||||
.resolves.toContain('repository-plugin-fixture')
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,9 @@
|
||||
{
|
||||
"path": "../../../vendor/include"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/hmr"
|
||||
},
|
||||
{
|
||||
"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/ui/tui/README.md
|
||||
README.md: 66d71419945edc9d9f5fe10bf9bcd810d05ba20c
|
||||
README.zh.md: 9b67d1a4a2e4a3d27d263421cc909188ff2e34cc
|
||||
README.md: a577fb2f858f61eb765d4a1a9564f452d01d92c1
|
||||
README.zh.md: 61e1b9d526a00e3c8cbc2c9ed0cc483e2ab8dba2
|
||||
|
||||
@@ -10,9 +10,9 @@ Interactive terminals on macOS, Linux, and Windows are supported. Windows uses p
|
||||
|
||||
This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `llm`, `systemPrompt`, `tokenMeter`, `tools`, and `userInteraction`, optionally reads a `skills` service (present only when one is mounted), then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries.
|
||||
|
||||
After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme, display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives.
|
||||
After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme (including terminal-safe DeepSeek `brand` treatment), display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives.
|
||||
|
||||
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear.
|
||||
The TUI rebuilds resumed history from the append-origin session events, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. A surface replacement never rewrites the rendered transcript: the conversation it shadows stays readable, and a landed compaction checkpoint adds one dim `… earlier context was compacted …` marker at its log position, so the terminal reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies — a pruned tool result, a regenerated assistant message — render nothing.
|
||||
|
||||
An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session `cwd`.
|
||||
|
||||
@@ -22,7 +22,7 @@ Typing `@` at a token boundary searches files and directories under the session
|
||||
|
||||
When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:<payload>)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.followup()` from the status after that asynchronous preparation, so idle follow-ups still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook.
|
||||
|
||||
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/details`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. The hidden phase also folds each turn's assistant steps into one message: the first step with visible text or reasoning keeps the turn's single `Assistant` header, later steps render as headerless continuations, and a step without a visible body renders nothing; leaving the hidden phase restores the per-step headers. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/details` names the same state those two shortcuts cycle: bare it opens a centered keyboard toggle with one entry per dimension — `Tool cards` and `Reasoning` — showing the live values, where Tab cycles the highlighted entry and applies the change immediately (the transcript behind the dialog is the preview), and Enter, Esc, or Ctrl+C closes; `/details collapsed|expanded|hidden` jumps tool cards to that phase directly, and `/details reasoning [on|off]` sets — or bare `reasoning` toggles — reasoning-block display; arguments combine in one invocation, an unknown argument fails with the usage line, and a combined invocation applies reasoning first so its transcript rebuild never drops the card notice.
|
||||
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/details`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. During a live standalone compaction bracket, a fixed `Context being compacted <elapsed>` row appears above the prompt, the idle prompt caret becomes a one-cell throbbing `⊙`, and terminal progress stays active until close; the row and glyph share the bracket's one refresh timer. This live state is never reconstructed from the log; a failed close adds `Compaction failed: <error>` to the transcript, while a resumed orphaned start never activates the indicator ([decision](../../../.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md)). Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. The hidden phase also folds each turn's assistant steps into one message: the first step with visible text or reasoning keeps the turn's single `Assistant` header, later steps render as headerless continuations, and a step without a visible body renders nothing; leaving the hidden phase restores the per-step headers. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/details` names the same state those two shortcuts cycle: bare it opens a centered keyboard toggle with one entry per dimension — `Tool cards` and `Reasoning` — showing the live values, where Tab cycles the highlighted entry and applies the change immediately (the transcript behind the dialog is the preview), and Enter, Esc, or Ctrl+C closes; `/details collapsed|expanded|hidden` jumps tool cards to that phase directly, and `/details reasoning [on|off]` sets — or bare `reasoning` toggles — reasoning-block display; arguments combine in one invocation, an unknown argument fails with the usage line, and a combined invocation applies reasoning first so its transcript rebuild never drops the card notice.
|
||||
|
||||
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: a filter box above the list narrows rows by a case-insensitive substring over each row's `provider/model` label, model name, and description, keeping the highlighted row selected when it survives the filter; Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape clears a non-empty filter before a second Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `Default`, which clears an explicit selection and preserves the provider default; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.
|
||||
|
||||
@@ -32,7 +32,6 @@ The footer sums the session's reported usage as `↑<uncached input> ↓<output>
|
||||
|
||||
`/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, selected reasoning effort or default behavior, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer.
|
||||
|
||||
|
||||
`/resume` opens a full-viewport keyboard selector instead of a centered dialog. Two scopes cover the same candidate set: the current workspace, which it opens on, and all workspaces, which Tab toggles to. The scope line under the search field names the active scope and the count the other holds, and each row in the all-workspaces scope also reports its own workspace. Toggling clears the search and selection so the highlighted row always belongs to the visible list.
|
||||
|
||||
Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Candidates are sorted by last logged activity and searchable by log-backed title or session id, and by workspace label in the all-workspaces scope; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, a session with no recorded workspace to run in, or a session whose logged provider has no current adapter remains visible but disabled; a workspace other than the current one is a scope rather than a disabled reason, because resume enters that directory.
|
||||
@@ -81,9 +80,9 @@ Startup fails before mounting when either process stream is not a TTY. The compo
|
||||
|
||||
## Color
|
||||
|
||||
Every SGR code the TUI emits lives in one table, `paletteSpec` in `components/theme.ts`, which `createPalette` derives its wrappers from and `/palette` prints; no component writes an escape of its own. The table holds only the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so the TUI stays readable on light and dark backgrounds alike — the startup banner's brand gradient is the one deliberate exception. Body text keeps the terminal's default foreground rather than a fixed shade.
|
||||
Every general-purpose SGR code the TUI emits lives in one table, `paletteSpec` in `components/theme.ts`, which `createPalette` derives its wrappers from and `/palette` prints; no component writes an escape of its own. The table holds only the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so the TUI stays readable on light and dark backgrounds alike. The startup banner gradient and the official mark's exact `#4D6BFE` ink are the two deliberate truecolor brand exceptions. Body text keeps the terminal's default foreground rather than a fixed shade.
|
||||
|
||||
There is one role per visual meaning: `dim` is the single recessed tone and `accent` the single emphasis color, while `success` and `error` double as a diff's added and removed lines. Colors and attributes are separately typed, so `bold(accent(x))` compiles and `accent(error(x))` does not — SGR has no color stack, so nesting one color inside another silently drops the outer color at the inner one's close. Attributes occupy independent SGR groups and compose with any color in either order. Run `/palette` to see every role as your terminal renders it, with its SGR pair.
|
||||
There is one role per visual meaning: `dim` is the single recessed tone, `accent` the single interaction emphasis, and `brand` the DeepSeek mark's standard-ANSI fallback, while `success` and `error` double as a diff's added and removed lines. Colors and attributes are separately typed, so `bold(accent(x))` compiles and `accent(error(x))` does not — SGR has no color stack, so nesting one color inside another silently drops the outer color at the inner one's close. Attributes occupy independent SGR groups and compose with any color in either order. Run `/palette` to see every role as your terminal renders it, with its SGR pair.
|
||||
|
||||
Grouped regions (user prompts, assistant replies, tool cards) are separated by a bold, underlined role header in the role color and blank-line spacing rather than a filled block or a per-line prefix, so a mouse drag-select copies the message text without any leading bar or indent; a tool card's status (pending, error, success) shows in its colored, underlined title glyph and title. Inside a tool card, the whole body — presenter title, a terminal `$` command and cwd, and the tool's own output — renders in one dim tone, so only the status-colored header carries color and the body reads as one recessed block instead of a run of competing shades; an injected-context card's prose is the same tone as its header. A diff card's `+`/`-` lines and a `[signal …]` marker stay colored, because there the color is the meaning rather than emphasis. The question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling.
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ DeepSeek Harness agent(智能体)的交互式终端入口,基于 [`@earend
|
||||
|
||||
本包(package)只持有交互式终端展示和输入。它注入 `agents`、[`commands`](../commands/README.md)、`llm`、`systemPrompt`、`tokenMeter`、`tools` 和 `userInteraction`,可选读取 `skills` 服务(仅在已挂载时存在),然后驱动由 app 或开发者代码创建或恢复的 agent。Agent 生命周期、持久化与模型侧 [`ask_user_question`](../tool-ask-user/README.md) 工具仍是独立组合项。
|
||||
|
||||
终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题、显示文本转义、重绘、关闭和生命周期信号,但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect,因此卸载会移除排队工作,或在清理结算前关闭可见工作;终端关闭会先卸载依赖项,再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。
|
||||
终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题(包括终端安全的 DeepSeek `brand` 样式)、显示文本转义、重绘、关闭和生命周期信号,但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect,因此卸载会移除排队工作,或在清理结算前关闭可见工作;终端关闭会先卸载依赖项,再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。
|
||||
|
||||
TUI 从活跃会话表层重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 `<session title> — <configured title>`。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换事件会重建 transcript,使经过压缩(compaction)的历史不会再次出现。
|
||||
TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 `<session title> — <configured title>`。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换从不重写已渲染的 transcript:被它遮蔽的对话仍可阅读,而已落地的压缩(compaction)检查点会在其日志位置添加一行暗色 `… earlier context was compacted …` 标记,因此终端报告的是模型从何处起不再看到那段历史,而不是把它抹掉。仅供模型使用的替换副本——被裁剪的工具结果、重新生成的 assistant 消息——不渲染任何内容。
|
||||
|
||||
如果逻辑工作区标签与会话宿主目录不同,嵌入方可以提供 `TuiRuntime.formatCwd`。该覆盖只改变 footer 标签;工具仍使用会话 `cwd`。
|
||||
|
||||
@@ -22,7 +22,7 @@ TUI 从活跃会话表层重建已恢复历史,渲染 Markdown 响应与 reaso
|
||||
|
||||
挂载可选的 `ctx.sessionReferences` 后,同一个 `@` 菜单还会提供仅含元数据的会话候选项,插入 `@[label](dsh-session:<payload>)`,并在分派前准备所选快照。会话引用保持结构化,因为模型没有类似文件系统的工具可在稍后检索会话快照。准备期间会禁止重复提交,并在失败时恢复编辑器输入。TUI 会在异步准备后根据状态选择 `agent.steer()` 或 `agent.followup()`,因此空闲 followup 仍会分派 `agent/prompt-submit`,而轮次中的 steering 会在检查点加入且不触发该 hook。
|
||||
|
||||
Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/details`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。隐藏阶段还会把每个轮次的 assistant 步骤折叠为一条消息:第一个有可见文本或 reasoning 的步骤保留该轮次唯一的 `Assistant` 标题,之后的步骤渲染为无标题的续段,没有可见正文的步骤则不渲染任何内容;离开隐藏阶段会恢复每步各自的标题。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。`/details` 命名的正是这两个快捷键循环的同一份状态:不带参数时打开一个居中的键盘开关,每个维度一个条目——`Tool cards` 与 `Reasoning`——显示实时值,Tab 循环高亮条目并立即应用变更(对话框背后的 transcript 即是预览),Enter、Esc 或 Ctrl+C 关闭;`/details collapsed|expanded|hidden` 让工具卡片直接跳到该阶段,`/details reasoning [on|off]` 设置——或裸 `reasoning` 切换——reasoning 块显示;参数可在一次调用中组合,未知参数会以用法行报错,组合调用先应用 reasoning,使其 transcript 重建不会丢掉卡片通知。
|
||||
Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/details`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。在实时独立压缩(compaction)标记对处于开启状态期间,提示词上方会显示固定的 `Context being compacted <elapsed>` 状态行,空闲提示符光标会变成占一个终端字符单元并呈呼吸律动的 `⊙`,终端进度状态则会保持活跃,直至标记对闭合;该状态行和字形共用标记对的同一个刷新定时器。该实时状态绝不会从日志中重建;闭合失败时会向 transcript 添加 `Compaction failed: <error>`,而恢复会话时遇到的陈旧未匹配 start 绝不会激活该指示器([决策](../../../.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md))。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。隐藏阶段还会把每个轮次的 assistant 步骤折叠为一条消息:第一个有可见文本或 reasoning 的步骤保留该轮次唯一的 `Assistant` 标题,之后的步骤渲染为无标题的续段,没有可见正文的步骤则不渲染任何内容;离开隐藏阶段会恢复每步各自的标题。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。`/details` 命名的正是这两个快捷键循环的同一份状态:不带参数时打开一个居中的键盘开关,每个维度一个条目——`Tool cards` 与 `Reasoning`——显示实时值,Tab 循环高亮条目并立即应用变更(对话框背后的 transcript 即是预览),Enter、Esc 或 Ctrl+C 关闭;`/details collapsed|expanded|hidden` 让工具卡片直接跳到该阶段,`/details reasoning [on|off]` 设置——或裸 `reasoning` 切换——reasoning 块显示;参数可在一次调用中组合,未知参数会以用法行报错,组合调用先应用 reasoning,使其 transcript 重建不会丢掉卡片通知。
|
||||
|
||||
`/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:列表上方设有一个过滤框,按对每行 `provider/model` 标签、模型名称和描述的大小写不敏感子串匹配来缩小行集,并在高亮行仍通过过滤时保持其选中状态;Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度,Escape 会先清除非空过滤内容,再次按下才关闭选择器。适配器未公布默认推理强度时,循环还会包含 `Default`,该项会清除显式选择并保留提供方默认行为;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表(包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model <model>` 仍可直接选择无歧义的模型 id,`/model <provider>/<model>` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。
|
||||
|
||||
@@ -32,7 +32,6 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
|
||||
|
||||
`/status` 会向 transcript 添加一张时间点诊断卡片,并在 agent 运行时保持可用。它报告会话 id、标题、工作目录、所选提供方/模型、所选推理强度或默认行为、reasoning 块可见性、agent 状态、事件/轮次/步骤/工具调用计数、精确输入/输出/缓存 token bucket、KV-cache 命中率、token-meter 上下文用量与容量、创建时间和最新事件时间。缺失标题、模型、缓存输入或上下文容量时会明确标记,而非推断。该卡片只存在于终端,不会重复紧凑 footer。
|
||||
|
||||
|
||||
`/resume` 会打开全 viewport 键盘选择器,而非居中对话框。两个作用域覆盖同一候选项集合:打开时所处的当前工作区,以及按 Tab 切换到的所有工作区。搜索字段下方的作用域行会给出当前作用域的名称以及另一个作用域包含的数量,且在所有工作区作用域中每行还会报告自身所属的工作区。切换会清除搜索与选择,使高亮行始终属于可见列表。
|
||||
|
||||
获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker,使终端 IME 组合保持锚定在字段内。候选项按最近记录的活动排序,可按日志支持的标题或会话 id 搜索,在所有工作区作用域中还可按工作区标签搜索;每行报告 current/live/persisted 状态、上一轮次结果、近期提供方/模型,以及存在时的持久目标阶段。Up/Down 与 Page Up/Page Down 导航,Enter 恢复,Escape 会先清除非空搜索,再次按下才取消,Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志、没有可运行的已记录工作区的会话,或日志所记提供方没有当前适配器的会话仍会显示,但不可选择;不同于当前工作区的工作区属于作用域而非禁用原因,因为恢复会进入该目录。
|
||||
@@ -81,9 +80,9 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
|
||||
|
||||
## 颜色
|
||||
|
||||
TUI 发出的所有 SGR 代码都集中在一个表中,即 `components/theme.ts` 内的 `paletteSpec`;`createPalette` 从该表派生包装层,`/palette` 则打印该表,任何组件都不会自行写入转义序列。该表仅包含标准 16 色 ANSI 前景色和 SGR 属性;每个终端都会将它们重新映射到当前配色方案,因此 TUI 在浅色与深色背景下都保持可读——启动 banner 的品牌渐变是唯一一个有意保留的例外。正文使用终端默认前景色,而非固定色调。
|
||||
TUI 发出的所有通用 SGR 代码都集中在一个表中,即 `components/theme.ts` 内的 `paletteSpec`;`createPalette` 从该表派生包装层,`/palette` 则打印该表,任何组件都不会自行写入转义序列。该表仅包含标准 16 色 ANSI 前景色和 SGR 属性;每个终端都会将它们重新映射到当前配色方案,因此 TUI 在浅色与深色背景下都保持可读。启动 banner 渐变与官方标志使用的精确 `#4D6BFE` 色值是两处有意保留的真彩色品牌例外。正文使用终端默认前景色,而非固定色调。
|
||||
|
||||
每种视觉语义只对应一个角色:`dim` 是唯一的弱化色调,`accent` 是唯一的强调色,`success` 和 `error` 还分别充当 diff 的新增行与删除行。颜色和属性分属不同类型,因此 `bold(accent(x))` 可以通过编译,`accent(error(x))` 则不行——SGR 没有颜色栈;在一种颜色内嵌套另一种颜色时,内层颜色闭合时会静默丢弃外层颜色。各属性占用彼此独立的 SGR 组,可以按任一顺序与任何颜色组合。运行 `/palette` 可查看每个角色在你的终端上的实际渲染效果及其 SGR 码对。
|
||||
每种视觉语义只对应一个角色:`dim` 是唯一的弱化色调,`accent` 是唯一的交互强调色,`brand` 是 DeepSeek 标志的标准 ANSI 回退色,`success` 和 `error` 还分别充当 diff 的新增行与删除行。颜色和属性分属不同类型,因此 `bold(accent(x))` 可以通过编译,`accent(error(x))` 则不行——SGR 没有颜色栈;在一种颜色内嵌套另一种颜色时,内层颜色闭合时会静默丢弃外层颜色。各属性占用彼此独立的 SGR 组,可以按任一顺序与任何颜色组合。运行 `/palette` 可查看每个角色在你的终端上的实际渲染效果及其 SGR 码对。
|
||||
|
||||
成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。在工具卡片内部,整个正文——presenter 标题、终端 `$` 命令与 cwd,以及工具自身的输出——统一以同一种暗色渲染,因此只有带状态色的表头携带颜色,正文读作一个整体弱化的区块,而不是一串互相竞争的色调;注入上下文卡片的正文与其表头也是同一种色调。diff 卡片的 `+`/`-` 行与 `[signal …]` 标记保留颜色,因为那里的颜色本身就是语义,而非强调。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Per-step timing model and running-status glyph animation for the terminal
|
||||
* Per-step timing model and prompt-status glyph animation for the terminal
|
||||
* front door. Timing buckets are replayed from the session event stream; the
|
||||
* running glyph fades in on turn start, throbs while the turn runs, and fades
|
||||
* out on turn end.
|
||||
* active glyph fades in when work starts, throbs while work runs, and fades out
|
||||
* when it ends.
|
||||
* @module @deepseek-ai/dsh-tui/chat/timing
|
||||
*/
|
||||
|
||||
@@ -10,25 +10,25 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { Palette } from '../components/theme.ts'
|
||||
|
||||
/**
|
||||
* Render cadence of the running prompt while active, and while the glyph fades
|
||||
* out after a turn ends. ~20 fps so the truecolor glyph fade reads smoothly;
|
||||
* Render cadence of the status prompt while active, and while the glyph fades
|
||||
* out after work ends. ~20 fps so the truecolor glyph fade reads smoothly;
|
||||
* the same tick keeps the elapsed-time text (0.1 s resolution) current. Only
|
||||
* changed terminal cells are re-emitted, so the faster tick stays cheap.
|
||||
*/
|
||||
export const STATUS_ANIMATION_INTERVAL_MS = 50
|
||||
|
||||
/**
|
||||
* Milliseconds over which the running glyph fades in when a turn starts and
|
||||
* fades out after it ends. The fade is an envelope over the running pulse:
|
||||
* Milliseconds over which the status glyph fades in when work starts and fades
|
||||
* out after it ends. The fade is an envelope over the active pulse:
|
||||
* inside it the glyph throbs (see {@link STATUS_PULSE_PERIOD_MS}).
|
||||
*/
|
||||
export const STATUS_FADE_MS = 300
|
||||
|
||||
/** Milliseconds for one full brightness throb of the running glyph. */
|
||||
/** Milliseconds for one full brightness throb of the active status glyph. */
|
||||
export const STATUS_PULSE_PERIOD_MS = 1400
|
||||
|
||||
/**
|
||||
* Brightness floor of the running throb, as a fraction of the settled gray. At
|
||||
* Brightness floor of the status throb, as a fraction of the settled gray. At
|
||||
* 0 the pulse swells from the near-background trough up to full and back. The
|
||||
* trough is still rendered as the dimmest gray, not clipped to a blank, so the
|
||||
* cosine breathes symmetrically bold→dim→bold.
|
||||
@@ -36,7 +36,7 @@ export const STATUS_PULSE_PERIOD_MS = 1400
|
||||
export const STATUS_PULSE_FLOOR = 0
|
||||
|
||||
/**
|
||||
* Muted-gray foreground the truecolor running glyph fades through, from the
|
||||
* Muted-gray foreground the truecolor status glyph fades through, from the
|
||||
* near-background trough (opacity 0) to the settled dim gray (opacity 1). Same
|
||||
* hue-free gray as the idle caret, so the glyph reads as the caret dimly
|
||||
* appearing rather than a colored indicator. Foreground-only, matching the
|
||||
@@ -185,6 +185,9 @@ export const TIMING_BUCKET_GLYPHS: Record<TimingBucket, string> = {
|
||||
tools: '⚙',
|
||||
}
|
||||
|
||||
/** Status glyph for a live standalone compaction bracket. */
|
||||
const COMPACTING_GLYPH = '⊙'
|
||||
|
||||
/**
|
||||
* Derive the currently open step's active timing bucket, or `undefined` when no
|
||||
* step is open. The open step is the last `step/start` with no later matching
|
||||
@@ -219,25 +222,32 @@ export function openStepPhase(events: readonly SessionEvent[]): TimingBucket | u
|
||||
}
|
||||
|
||||
/**
|
||||
* The running agent's phase glyph, or `undefined` when idle. A running turn
|
||||
* with no open step falls back to the pre-first-token wait so a glyph is always
|
||||
* available while the agent works; it fades in on turn start, throbs while the
|
||||
* turn runs, and fades out on turn end (see {@link fadeGlyph}).
|
||||
* The active status glyph, or `undefined` when idle. A running turn takes
|
||||
* precedence over standalone compaction and falls back to the pre-first-token
|
||||
* wait when no step is open. The caller applies the shared fade and throb
|
||||
* animation (see {@link fadeGlyph}).
|
||||
* @param events - Session events to derive the phase from.
|
||||
* @param running - Whether the agent is currently running.
|
||||
* @returns The phase glyph, or `undefined` when idle.
|
||||
* @param compacting - Whether a live standalone compaction bracket is open.
|
||||
* @returns The active status glyph, or `undefined` when idle.
|
||||
*/
|
||||
export function runningPhaseGlyph(events: readonly SessionEvent[], running: boolean): string | undefined {
|
||||
if (!running) return undefined
|
||||
const bucket = openStepPhase(events) ?? 'ttft'
|
||||
return TIMING_BUCKET_GLYPHS[bucket]
|
||||
export function runningPhaseGlyph(
|
||||
events: readonly SessionEvent[],
|
||||
running: boolean,
|
||||
compacting: boolean,
|
||||
): string | undefined {
|
||||
if (running) {
|
||||
const bucket = openStepPhase(events) ?? 'ttft'
|
||||
return TIMING_BUCKET_GLYPHS[bucket]
|
||||
}
|
||||
return compacting ? COMPACTING_GLYPH : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The running throb's brightness at continuous clock `nowMs`: a cosine between
|
||||
* The status throb's brightness at continuous clock `nowMs`: a cosine between
|
||||
* {@link STATUS_PULSE_FLOOR} and 1 over {@link STATUS_PULSE_PERIOD_MS}, so the
|
||||
* dim glyph breathes bold→dim→bold without ever blinking off. Multiplied by the
|
||||
* fade envelope, which alone drives appear/disappear at turn boundaries.
|
||||
* fade envelope, which alone drives appear/disappear at work boundaries.
|
||||
*
|
||||
* @param nowMs - Monotonic render clock in milliseconds.
|
||||
* @returns Brightness fraction in [{@link STATUS_PULSE_FLOOR}, 1].
|
||||
@@ -249,14 +259,14 @@ export function pulseLevel(nowMs: number): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* One frame of the running glyph at fade `opacity` (0 = near-background trough
|
||||
* One frame of the status glyph at fade `opacity` (0 = near-background trough
|
||||
* gray, 1 = settled dim gray). The character and its width never change — only
|
||||
* the gray fades — so the prompt caret column stays fixed and the glyph reads as
|
||||
* the caret dimly breathing, never a colored indicator.
|
||||
*
|
||||
* With truecolor the glyph's 24-bit gray foreground interpolates continuously
|
||||
* between {@link STATUS_FADE_GRAY}'s trough and settled stops, so both the fade
|
||||
* and the running throb render as a smooth, symmetric brightness swing with no
|
||||
* and the status throb render as a smooth, symmetric brightness swing with no
|
||||
* hard cutoff to clip the trough into a blank. Without truecolor there is no
|
||||
* per-frame gray, so `visible` (driven by the fade envelope, not the opacity)
|
||||
* shows the glyph in the palette's muted role or leaves a blank column — a
|
||||
@@ -264,7 +274,7 @@ export function pulseLevel(nowMs: number): number {
|
||||
* no throb-driven blink. With color off entirely a visible glyph is bare,
|
||||
* holding the caret column on a monochrome terminal.
|
||||
*
|
||||
* @param glyph - The phase glyph to paint.
|
||||
* @param glyph - The status glyph to paint.
|
||||
* @param palette - Active palette supplying the muted (dim gray) role.
|
||||
* @param colorEnabled - Whether ANSI is emitted at all.
|
||||
* @param truecolor - Whether the terminal accepts 24-bit foreground codes.
|
||||
|
||||
@@ -46,6 +46,8 @@ export type AttributeRole = <T extends string>(text: T) => T
|
||||
*/
|
||||
export interface Palette {
|
||||
accent: ColorRole
|
||||
/** DeepSeek brand ink; exact gradient callers may override it on truecolor terminals. */
|
||||
brand: ColorRole
|
||||
/** The terminal's own default foreground; still a color, so it does not stack. */
|
||||
text: ColorRole
|
||||
/** The one recessed tone, below `text`: tool-card bodies, chrome, reasoning, footers. */
|
||||
@@ -63,7 +65,7 @@ export interface Palette {
|
||||
}
|
||||
|
||||
/** Names of the palette's color roles, in the order `/palette` prints them. */
|
||||
export const COLOR_ROLES = ['text', 'dim', 'accent', 'code', 'success', 'warning', 'error'] as const
|
||||
export const COLOR_ROLES = ['text', 'dim', 'accent', 'brand', 'code', 'success', 'warning', 'error'] as const
|
||||
|
||||
/** Names of the palette's attribute roles, in the order `/palette` prints them. */
|
||||
export const ATTRIBUTE_ROLES = ['bold', 'italic', 'underline', 'strike', 'selected'] as const
|
||||
@@ -86,8 +88,9 @@ export interface RoleSpec {
|
||||
*
|
||||
* Only the standard 16-color set and SGR attributes appear here. Terminals remap
|
||||
* those to the user's active theme, so the TUI stays legible on any background;
|
||||
* a fixed 24-bit color would not. The brand gradient is the one deliberate
|
||||
* exception ({@link gradientText}).
|
||||
* a fixed 24-bit color would not. The startup gradient and exact official mark
|
||||
* color are the two deliberate brand exceptions ({@link gradientText},
|
||||
* {@link brandText}).
|
||||
*
|
||||
* @param scheme - Active terminal color scheme; only `code` differs between them.
|
||||
* @returns The SGR spec for every color and attribute role.
|
||||
@@ -109,6 +112,7 @@ export function paletteSpec(scheme: TerminalColorScheme): {
|
||||
// prominent text on screen.
|
||||
dim: { open: '2;39', close: '22;39', purpose: 'The one recessed tone: tool bodies, chrome, footers' },
|
||||
accent: { open: '95', close: '39', purpose: 'The one emphasis color: role headers, prompt, borders' },
|
||||
brand: { open: '34', close: '39', purpose: 'DeepSeek brand art when truecolor is unavailable' },
|
||||
// ANSI 36 (cyan) is difficult to read on a light background — use ANSI 34
|
||||
// (blue) which is legible on both light and dark schemes.
|
||||
code: scheme === 'light'
|
||||
@@ -168,6 +172,19 @@ const BRAND_GRADIENT = [
|
||||
[36, 152, 255], // #2498FF
|
||||
] as const
|
||||
|
||||
/** Official DeepSeek icon ink from the shipped 24x24 SVG. */
|
||||
const DEEPSEEK_BRAND_RGB = BRAND_GRADIENT[0]
|
||||
|
||||
/**
|
||||
* Paint trusted static DeepSeek brand art with the official `#4D6BFE` ink.
|
||||
* @param text - Static brand text or raster cells.
|
||||
* @returns text wrapped in the official truecolor foreground and a foreground reset.
|
||||
*/
|
||||
export function brandText(text: string): string {
|
||||
const [r, g, b] = DEEPSEEK_BRAND_RGB
|
||||
return `\x1b[38;2;${r};${g};${b}m${text}\x1b[39m`
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample {@link BRAND_GRADIENT} at fraction `t` via piecewise-linear
|
||||
* interpolation across its stops.
|
||||
@@ -199,13 +216,12 @@ function brandColorAt(t: number): readonly [number, number, number] {
|
||||
* @returns `text` wrapped in truecolor SGR foreground codes.
|
||||
*/
|
||||
export function gradientText(text: string): string {
|
||||
// The sole caller passes the ASCII product name, so UTF-16 unit iteration
|
||||
// samples exactly one color per visible letter.
|
||||
const last = Math.max(1, text.length - 1)
|
||||
const glyphs = Array.from(text)
|
||||
const last = Math.max(1, glyphs.length - 1)
|
||||
let painted = ''
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
for (let index = 0; index < glyphs.length; index += 1) {
|
||||
const [r, g, b] = brandColorAt(index / last)
|
||||
painted += `\x1b[38;2;${r};${g};${b}m${text.charAt(index)}`
|
||||
painted += `\x1b[38;2;${r};${g};${b}m${glyphs[index]}`
|
||||
}
|
||||
return `${painted}\x1b[39m`
|
||||
}
|
||||
|
||||
@@ -126,8 +126,8 @@ export interface Config extends TuiConfig {
|
||||
/**
|
||||
* Skill name auto-invoked as this session's first user turn, exactly as if
|
||||
* the user typed `/skill:<name>`. Set only by a launcher for a fresh
|
||||
* skill-guided session (`dsh migrate`/`dsh upgrade`); absent leaves the first
|
||||
* turn to the user.
|
||||
* skill-guided session (`dsh migrate`/`dsh upgrade`); absent
|
||||
* leaves the first turn to the user.
|
||||
*/
|
||||
initialSkill?: string
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ export interface TuiFocusable {
|
||||
export interface TuiTheme {
|
||||
/** Render ordinary foreground text. */
|
||||
readonly text: (value: string) => string
|
||||
/** Render trusted static brand art with the host's configured brand treatment. */
|
||||
readonly brand: (value: string) => string
|
||||
/** Render secondary information and low-emphasis hints, the one tone below `text`. */
|
||||
readonly dim: (value: string) => string
|
||||
/** Render the active accent role. */
|
||||
|
||||
@@ -69,7 +69,7 @@ import type {
|
||||
TuiTheme,
|
||||
} from './extension/types.ts'
|
||||
import { displayInlineText, displayText } from './components/text.ts'
|
||||
import { createPalette, markdownTheme, renderPalette, selectTheme } from './components/theme.ts'
|
||||
import { brandText, createPalette, markdownTheme, renderPalette, selectTheme } from './components/theme.ts'
|
||||
import { contentText, parseArguments } from './components/content.ts'
|
||||
import {
|
||||
cacheHitRate,
|
||||
@@ -80,6 +80,7 @@ import {
|
||||
import {
|
||||
fadeGlyph,
|
||||
formatQueuedStatus,
|
||||
formatStatusDuration,
|
||||
openStepPhase,
|
||||
openTurn,
|
||||
pulseLevel,
|
||||
@@ -228,9 +229,9 @@ export const TUI_GOODBYE_MESSAGE_KEY = 'tuiGoodbyeMessage'
|
||||
/**
|
||||
* Context key a launcher sets before any Loader entry mounts
|
||||
* (`ctx.provide(INITIAL_SKILL_KEY, name)`) to seed a fresh session's first user
|
||||
* turn with `/skill:<name>` — the `dsh migrate`/`dsh upgrade` guided-session
|
||||
* entry. The launcher sets it only when minting a fresh session, so it never
|
||||
* re-fires on a resumed one. Absent leaves the first turn to the user.
|
||||
* turn with `/skill:<name>` — the `dsh migrate`/`dsh upgrade`
|
||||
* guided-session entry. The launcher sets it only when minting a fresh session,
|
||||
* so it never re-fires on a resumed one. Absent leaves the first turn to the user.
|
||||
*/
|
||||
export const INITIAL_SKILL_KEY = 'tuiInitialSkill'
|
||||
|
||||
@@ -331,6 +332,7 @@ export function createTuiChat(
|
||||
})
|
||||
editor.hintPrefix = initialInputPrompt
|
||||
const todo = new TodoComponent(palette)
|
||||
const compactionStatusLine = new Text('', 0, 0)
|
||||
let showReasoning = resolved.showReasoning
|
||||
// Ctrl+O cycles collapsed -> expanded -> hidden. Codex-style: hidden drops
|
||||
// tool cards entirely, collapsed previews, expanded shows full bodies.
|
||||
@@ -343,6 +345,14 @@ export function createTuiChat(
|
||||
const assistantSteps = new Map<number, StreamingAssistantComponent[]>()
|
||||
let runningStatus: RunningStatus | undefined
|
||||
let fadingStatus: FadingStatus | undefined
|
||||
/**
|
||||
* Live standalone compaction observed by this process. Never derive this
|
||||
* state from history: a resumed log may contain a stale orphaned start.
|
||||
*/
|
||||
let compacting: {
|
||||
startedAt: number
|
||||
timer: ReturnType<typeof setInterval>
|
||||
} | undefined
|
||||
// TUI steering submissions that the inbox has not yet claimed or discarded.
|
||||
// Correlation ids avoid guessing whether a running-state submission actually
|
||||
// joined steering or fell back to the queued-turn FIFO during turn close.
|
||||
@@ -406,6 +416,7 @@ export function createTuiChat(
|
||||
throw new Error('TUI prompt built-ins failed to initialize')
|
||||
}
|
||||
const updatePromptValues = (): void => {
|
||||
const renderTime = now()
|
||||
cwdValue.set(palette.bold(palette.accent(formattedCwd)))
|
||||
gitValue.set(branch === undefined ? undefined : palette.dim(` (${displayText(branch)})`))
|
||||
const rate = cacheHitRate(tokens)
|
||||
@@ -419,23 +430,31 @@ export function createTuiChat(
|
||||
const queued = runningStatus === undefined ? undefined : formatQueuedStatus(pendingSteering.size)
|
||||
queuedValue.set(queued === undefined ? undefined : palette.dim(queued))
|
||||
symbolValue.set(palette.bold(palette.accent('dsh')))
|
||||
compactionStatusLine.setText(compacting === undefined
|
||||
? ''
|
||||
: palette.dim(`Context being compacted ${formatStatusDuration(renderTime - compacting.startedAt)}`))
|
||||
// `${indicator}` owns the caret column and its trailing gap before the
|
||||
// cursor. The phase glyph replaces the `>` caret in place — same width
|
||||
// every frame — fading in as a turn starts, throbbing while it runs, and
|
||||
// fading out after it ends before the plain `>` returns. Only the gray
|
||||
// cursor. The active status glyph replaces the `>` caret in place — same
|
||||
// width every frame — fading in when work starts, throbbing while it runs,
|
||||
// and fading out after it ends before the plain `>` returns. Only the gray
|
||||
// brightness changes, so the cursor never shifts.
|
||||
const runningGlyph = runningPhaseGlyph(agent.session.events, runningStatus !== undefined)
|
||||
const statusGlyph = runningPhaseGlyph(
|
||||
agent.session.events,
|
||||
runningStatus !== undefined,
|
||||
compacting !== undefined,
|
||||
)
|
||||
// Remember the live phase glyph so the fade-out shows it, not the ttft
|
||||
// fallback the derivation returns once the closing turn's step has ended.
|
||||
if (runningStatus !== undefined && runningGlyph !== undefined) runningStatus.lastGlyph = runningGlyph
|
||||
// The fade envelope gates appear/disappear; the running throb breathes the
|
||||
// glyph the whole turn. Truecolor opacity is envelope × throb; the
|
||||
if (runningStatus !== undefined && statusGlyph !== undefined) runningStatus.lastGlyph = statusGlyph
|
||||
// The fade envelope gates appear/disappear; the active throb breathes the
|
||||
// glyph throughout the operation. Truecolor opacity is envelope × throb; the
|
||||
// non-truecolor fallback keys visibility off the envelope alone, so the
|
||||
// throb never blinks it. `envelope` clamps to [0, 1].
|
||||
const envelope = runningStatus !== undefined && runningGlyph !== undefined
|
||||
? { glyph: runningGlyph, level: Math.min(1, (now() - runningStatus.startedAt) / STATUS_FADE_MS) }
|
||||
const activeSince = runningStatus?.startedAt ?? compacting?.startedAt
|
||||
const envelope = activeSince !== undefined && statusGlyph !== undefined
|
||||
? { glyph: statusGlyph, level: Math.min(1, (renderTime - activeSince) / STATUS_FADE_MS) }
|
||||
: fadingStatus !== undefined
|
||||
? { glyph: fadingStatus.glyph, level: Math.max(0, 1 - (now() - fadingStatus.endedAt) / STATUS_FADE_MS) }
|
||||
? { glyph: fadingStatus.glyph, level: Math.max(0, 1 - (renderTime - fadingStatus.endedAt) / STATUS_FADE_MS) }
|
||||
: undefined
|
||||
const caret = envelope === undefined
|
||||
? palette.dim('>')
|
||||
@@ -444,7 +463,7 @@ export function createTuiChat(
|
||||
palette,
|
||||
resolved.theme.color,
|
||||
resolved.theme.color && resolved.theme.truecolor,
|
||||
envelope.level * pulseLevel(now()),
|
||||
envelope.level * pulseLevel(renderTime),
|
||||
envelope.level >= 0.5,
|
||||
)
|
||||
indicatorValue.set(`${caret}${palette.dim(' ')}`)
|
||||
@@ -459,6 +478,7 @@ export function createTuiChat(
|
||||
ui.addChild(new Spacer(1))
|
||||
todoContainer.addChild(todo)
|
||||
ui.addChild(todoContainer)
|
||||
ui.addChild(compactionStatusLine)
|
||||
ui.addChild(promptContext)
|
||||
ui.addChild(editor)
|
||||
ui.setFocus(editor)
|
||||
@@ -492,6 +512,9 @@ export function createTuiChat(
|
||||
|
||||
const extensionTheme: TuiTheme = Object.freeze({
|
||||
text: (value: string) => palette.text(value),
|
||||
brand: (value: string) => resolved.theme.color
|
||||
? resolved.theme.truecolor ? brandText(value) : palette.brand(value)
|
||||
: value,
|
||||
dim: (value: string) => palette.dim(value),
|
||||
accent: (value: string) => palette.accent(value),
|
||||
success: (value: string) => palette.success(value),
|
||||
@@ -543,8 +566,8 @@ export function createTuiChat(
|
||||
requestRender()
|
||||
}
|
||||
|
||||
/** Stop the running and fade-out timers and drop both states at once. */
|
||||
const clearStatus = (): void => {
|
||||
/** Stop the turn-phase running and fade-out timers and drop both states. */
|
||||
const clearTurnStatus = (): void => {
|
||||
if (runningStatus !== undefined) {
|
||||
clearInterval(runningStatus.timer)
|
||||
runningStatus = undefined
|
||||
@@ -553,21 +576,30 @@ export function createTuiChat(
|
||||
clearInterval(fadingStatus.timer)
|
||||
fadingStatus = undefined
|
||||
}
|
||||
runtime.terminal.setProgress(false)
|
||||
runtime.terminal.setProgress(compacting !== undefined)
|
||||
}
|
||||
|
||||
/** Hard clear: drop every indicator, including a live compaction bracket. */
|
||||
const clearStatus = (): void => {
|
||||
if (compacting !== undefined) {
|
||||
clearInterval(compacting.timer)
|
||||
compacting = undefined
|
||||
}
|
||||
clearTurnStatus()
|
||||
}
|
||||
|
||||
/**
|
||||
* On the running → non-running edge, hand the last rendered glyph to a
|
||||
* fade-out that re-renders until it settles on the `>` caret, then stops its
|
||||
* own timer. A hard clear (teardown) skips this via {@link clearStatus}.
|
||||
* Hand the last active glyph to a fade-out that re-renders until it settles
|
||||
* on the `>` caret, then stops its own timer. A hard clear (teardown) skips
|
||||
* this via {@link clearStatus}.
|
||||
*/
|
||||
const beginFadeOut = (glyph: string): void => {
|
||||
clearStatus()
|
||||
clearTurnStatus()
|
||||
const fading: FadingStatus = {
|
||||
glyph,
|
||||
endedAt: now(),
|
||||
timer: setInterval(() => {
|
||||
if (now() - fading.endedAt >= STATUS_FADE_MS) clearStatus()
|
||||
if (now() - fading.endedAt >= STATUS_FADE_MS) clearTurnStatus()
|
||||
renderStatus()
|
||||
}, STATUS_ANIMATION_INTERVAL_MS),
|
||||
}
|
||||
@@ -577,9 +609,9 @@ export function createTuiChat(
|
||||
const setStatus = (status: AgentStatus): void => {
|
||||
const priorTurn = runningStatus?.turn
|
||||
const fadeOutGlyph = status !== 'running' ? runningStatus?.lastGlyph : undefined
|
||||
if (status === 'running') clearStatus()
|
||||
if (status === 'running') clearTurnStatus()
|
||||
else if (fadeOutGlyph !== undefined) beginFadeOut(fadeOutGlyph)
|
||||
else clearStatus()
|
||||
else clearTurnStatus()
|
||||
editor.borderColor = status === 'running' ? text => palette.accent(text) : text => palette.dim(text)
|
||||
editor.hint = status === 'running' ? palette.dim(displayInlineText(resolved.theme.inputPlaceholder)) : undefined
|
||||
if (status === 'running') {
|
||||
@@ -1626,6 +1658,32 @@ export function createTuiChat(
|
||||
if (event.type === 'tool/result') fileSearch.invalidate()
|
||||
recordEventUsage(tokens, event)
|
||||
if (event.type === 'turn/start' && runningStatus !== undefined) runningStatus.turn = event.data.turn
|
||||
// Track live standalone compaction state.
|
||||
if (event.type === 'compact/start' && event.data.turn === null) {
|
||||
if (compacting === undefined) {
|
||||
const startedAt = now()
|
||||
compacting = {
|
||||
startedAt,
|
||||
timer: setInterval(renderStatus, STATUS_ANIMATION_INTERVAL_MS),
|
||||
}
|
||||
runtime.terminal.setProgress(true)
|
||||
}
|
||||
requestRender()
|
||||
return
|
||||
}
|
||||
if (event.type === 'compact/end' && event.data.turn === null && compacting !== undefined) {
|
||||
const fadeOutGlyph = runningPhaseGlyph(agent.session.events, false, true)
|
||||
clearInterval(compacting.timer)
|
||||
compacting = undefined
|
||||
if (event.data.error !== undefined) {
|
||||
appendNotice(`Compaction failed: ${event.data.error}`, 'warning')
|
||||
}
|
||||
// A concurrently running turn owns the indicator. Keep its timer and
|
||||
// progress bit instead of letting the compaction fade clear that state.
|
||||
if (runningStatus === undefined && fadeOutGlyph !== undefined) beginFadeOut(fadeOutGlyph)
|
||||
requestRender()
|
||||
return
|
||||
}
|
||||
// A replacement mutates only the model surface, so the rendered transcript
|
||||
// keeps what it already showed; a landed summary checkpoint adds its marker.
|
||||
if (isReplacementSurfaceEvent(event)) {
|
||||
@@ -1669,6 +1727,9 @@ export function createTuiChat(
|
||||
// TUI stays mounted. Retained agents accept deliveries after detachment, so
|
||||
// without this a later send would drive a zombie agent/session; mark
|
||||
// disposed so dispatchMessage reports it instead.
|
||||
// The hard clear also retires live compaction. A later compact/end is
|
||||
// intentionally presentation-silent: this disposal notice owns the
|
||||
// terminal outcome, and no animation may survive agent detachment.
|
||||
clearStatus()
|
||||
appendNotice(`Agent "${agent.id}" was disposed.`, 'warning')
|
||||
disposed = true
|
||||
@@ -1757,11 +1818,11 @@ export function createTuiChat(
|
||||
})
|
||||
startBannerReveal()
|
||||
|
||||
// A launcher-seeded first turn (`dsh migrate`/`dsh upgrade`): invoke the
|
||||
// named skill exactly as a typed `/skill:<name>` would, once the chat is live
|
||||
// and the agent is idle. The launcher sets this only for a fresh session, so
|
||||
// there is no prior turn to collide with; invokeSkill reports an unknown skill
|
||||
// as a notice.
|
||||
// A launcher-seeded first turn (`dsh migrate`/`dsh upgrade`):
|
||||
// invoke the named skill exactly as a typed `/skill:<name>` would, once the
|
||||
// chat is live and the agent is idle. The launcher sets this only for a fresh
|
||||
// session, so there is no prior turn to collide with; invokeSkill reports an
|
||||
// unknown skill as a notice.
|
||||
if (config.initialSkill !== undefined) invokeSkill(config.initialSkill, '')
|
||||
|
||||
return {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
|
||||
const theme: TuiTheme = Object.freeze({
|
||||
text: (value: string) => `text:${value}`,
|
||||
brand: (value: string) => `brand:${value}`,
|
||||
muted: (value: string) => `muted:${value}`,
|
||||
dim: (value: string) => `dim:${value}`,
|
||||
accent: (value: string) => `accent:${value}`,
|
||||
|
||||
@@ -228,13 +228,14 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
steeredOptions.push(input)
|
||||
const id = input.id
|
||||
steeredIds.push(id)
|
||||
return id
|
||||
return { outcome: Promise.resolve({ status: 'admitted' as const, turn: 1, step: 1 }) }
|
||||
},
|
||||
inject(input) {
|
||||
injected.push(input.content)
|
||||
injectedOptions.push(input)
|
||||
return input.id
|
||||
},
|
||||
reserveTurnAdmission: () => undefined,
|
||||
cancel(cause) {
|
||||
cancelled.push(cause)
|
||||
},
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
type TuiRuntime,
|
||||
} from '../src/index.ts'
|
||||
import { WorkspaceFileSearch } from '../src/chat/file-autocomplete.ts'
|
||||
import { ATTRIBUTE_ROLES, COLOR_ROLES, paletteSpec } from '../src/components/theme.ts'
|
||||
import { ATTRIBUTE_ROLES, brandText, COLOR_ROLES, paletteSpec } from '../src/components/theme.ts'
|
||||
import {
|
||||
appendAssistant,
|
||||
appendUser,
|
||||
@@ -138,6 +138,12 @@ async function tick(): Promise<void> {
|
||||
await new Promise(resolve => setTimeout(resolve, 25))
|
||||
}
|
||||
|
||||
function promptWidth(output: string): number {
|
||||
const row = output.split('\n').find(line => line.includes('dsh'))
|
||||
if (row === undefined) throw new Error('prompt row not rendered')
|
||||
return visibleWidth(row.slice(row.indexOf('dsh'), row.indexOf('dsh') + 6))
|
||||
}
|
||||
|
||||
async function setup(options: TuiHarnessOptions = {}) {
|
||||
const terminal = new FakeTerminal()
|
||||
const exit = vi.fn()
|
||||
@@ -1954,12 +1960,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
// `dsh <glyph> ` with the same visible width as the idle `dsh > `, so the
|
||||
// cursor never shifts. Assert both the glyph slot and that constant width
|
||||
// (color is off in this harness, so output carries no ANSI to strip).
|
||||
const promptWidth = (): number => {
|
||||
const row = result.terminal.output.split('\n').find(line => line.includes('dsh'))
|
||||
if (row === undefined) throw new Error('prompt row not rendered')
|
||||
return visibleWidth(row.slice(row.indexOf('dsh'), row.indexOf('dsh') + 6))
|
||||
}
|
||||
|
||||
// Each phase swaps only the glyph character in the same slot at equal width.
|
||||
const phaseGlyph: [() => void, string][] = [
|
||||
[() => result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'weighing' } }), 'dsh ✻ '],
|
||||
@@ -1972,8 +1972,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
drive()
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain(expected)
|
||||
runningWidth ??= promptWidth()
|
||||
expect(promptWidth()).toBe(runningWidth)
|
||||
runningWidth ??= promptWidth(result.terminal.output)
|
||||
expect(promptWidth(result.terminal.output)).toBe(runningWidth)
|
||||
}
|
||||
|
||||
// Idle begins a fade-out; once it settles (clock past the fade window) the
|
||||
@@ -1990,12 +1990,189 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
return rows.at(-1) ?? ''
|
||||
}
|
||||
expect(promptRow()).toContain('dsh > ')
|
||||
expect(promptRow()).not.toMatch(/dsh(?:\x1b\[[0-9;]*m| )*[◍✻●⚙]/u)
|
||||
expect(promptWidth()).toBe(runningWidth)
|
||||
expect(promptRow()).not.toMatch(/dsh(?:\x1b\[[0-9;]*m| )*[◍✻●⚙⊙]/u)
|
||||
expect(promptWidth(result.terminal.output)).toBe(runningWidth)
|
||||
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('shows a live standalone compaction in the fixed status area', async () => {
|
||||
let clock = 0
|
||||
const result = await setup({ omitInitialLifecycle: true, now: () => clock })
|
||||
const idleWidth = promptWidth(result.terminal.output)
|
||||
|
||||
result.session.append('compact/start', { turn: null })
|
||||
clock = 1_000
|
||||
result.terminal.output = ''
|
||||
await new Promise(resolve => setTimeout(resolve, 75))
|
||||
|
||||
expect(result.terminal.output).toContain('dsh ⊙ ')
|
||||
expect(result.terminal.output).toContain('Context being compacted 1.0s')
|
||||
expect(promptWidth(result.terminal.output)).toBe(idleWidth)
|
||||
expect(result.terminal.progress.at(-1)).toBe(true)
|
||||
|
||||
clock = 1_450
|
||||
result.terminal.output = ''
|
||||
await new Promise(resolve => setTimeout(resolve, 75))
|
||||
expect(result.terminal.output).toContain('Context being compacted 1.4s')
|
||||
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('ignores a numbered compaction bracket while the status line is idle', async () => {
|
||||
const result = await setup({ now: () => 1_000 })
|
||||
result.session.append('compact/start', { turn: 1 })
|
||||
await tick()
|
||||
|
||||
expect(result.terminal.output).toContain('dsh > ')
|
||||
expect(result.terminal.output).not.toContain('dsh ⊙ ')
|
||||
expect(result.terminal.progress.at(-1)).toBe(false)
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('fades a closed standalone compaction back to the plain caret', async () => {
|
||||
let clock = 0
|
||||
const result = await setup({ omitInitialLifecycle: true, now: () => clock })
|
||||
clock = 1_000
|
||||
result.session.append('compact/start', { turn: null })
|
||||
await tick()
|
||||
result.session.append('compact/end', { turn: null })
|
||||
await tick()
|
||||
|
||||
clock = 2_000
|
||||
await new Promise(resolve => setTimeout(resolve, 120))
|
||||
result.terminal.output = ''
|
||||
result.terminal.resize(result.terminal.columns + 1)
|
||||
await tick()
|
||||
|
||||
expect(result.terminal.output).toContain('dsh > ')
|
||||
expect(result.terminal.output).not.toMatch(/dsh [◍✻●⚙⊙]/u)
|
||||
expect(result.terminal.output).not.toContain('Context being compacted')
|
||||
expect(result.terminal.progress.at(-1)).toBe(false)
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('reports a failed standalone compaction when its live bracket closes', async () => {
|
||||
const result = await setup({ omitInitialLifecycle: true, now: () => 1_000 })
|
||||
result.session.append('compact/start', { turn: null })
|
||||
result.terminal.output = ''
|
||||
result.session.append('compact/end', { turn: null, error: 'summary failed' })
|
||||
await tick()
|
||||
|
||||
expect(result.terminal.output).toContain('Compaction failed: summary failed')
|
||||
expect(result.terminal.progress.at(-1)).toBe(false)
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('preserves live compaction progress across an idle status edge', async () => {
|
||||
let clock = 0
|
||||
const result = await setup({ omitInitialLifecycle: true, now: () => clock })
|
||||
result.session.append('compact/start', { turn: null })
|
||||
clock = 1_000
|
||||
result.terminal.output = ''
|
||||
result.ctx.emit('agent/status', result.agent, 'idle')
|
||||
result.terminal.resize(result.terminal.columns + 1)
|
||||
await tick()
|
||||
|
||||
expect(result.terminal.output).toContain('dsh ⊙ ')
|
||||
expect(result.terminal.progress.at(-1)).toBe(true)
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('keeps a running turn phase glyph ahead of standalone compaction', async () => {
|
||||
let clock = 0
|
||||
const result = await setup({ status: 'running', now: () => clock })
|
||||
clock = 1_000
|
||||
result.terminal.output = ''
|
||||
result.session.append('compact/start', { turn: null })
|
||||
await tick()
|
||||
|
||||
expect(result.terminal.output).toContain('dsh ◍ ')
|
||||
expect(result.terminal.output).not.toContain('dsh ⊙ ')
|
||||
result.session.append('compact/end', { turn: null })
|
||||
await tick()
|
||||
result.terminal.output = ''
|
||||
result.terminal.resize(result.terminal.columns + 1)
|
||||
await tick()
|
||||
|
||||
expect(result.terminal.output).toContain('dsh ◍ ')
|
||||
expect(result.terminal.output).not.toContain('dsh ⊙ ')
|
||||
expect(result.terminal.progress.at(-1)).toBe(true)
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('treats duplicate live compaction starts as one owned bracket', async () => {
|
||||
const intervalSpy = vi.spyOn(globalThis, 'setInterval')
|
||||
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval')
|
||||
let result: Awaited<ReturnType<typeof setup>> | undefined
|
||||
let didDispose = false
|
||||
let clock = 0
|
||||
try {
|
||||
result = await setup({ omitInitialLifecycle: true, now: () => clock })
|
||||
intervalSpy.mockClear()
|
||||
clearIntervalSpy.mockClear()
|
||||
result.session.append('compact/start', { turn: null })
|
||||
clock = 1_000
|
||||
result.session.append('compact/start', { turn: null })
|
||||
await tick()
|
||||
|
||||
expect(intervalSpy).toHaveBeenCalledOnce()
|
||||
expect(result.terminal.output).toContain('dsh ⊙ ')
|
||||
expect(result.terminal.progress.at(-1)).toBe(true)
|
||||
|
||||
result.session.append('compact/end', { turn: null })
|
||||
await tick()
|
||||
expect(clearIntervalSpy).toHaveBeenCalledOnce()
|
||||
expect(result.terminal.progress.at(-1)).toBe(false)
|
||||
|
||||
await dispose(result)
|
||||
didDispose = true
|
||||
} finally {
|
||||
if (result !== undefined && !didDispose) await dispose(result)
|
||||
intervalSpy.mockRestore()
|
||||
clearIntervalSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not show compaction progress for a resumed orphaned start', async () => {
|
||||
const result = await setup({
|
||||
omitInitialLifecycle: true,
|
||||
now: () => 1_000,
|
||||
beforeMount(session) {
|
||||
session.append('compact/start', { turn: null })
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.terminal.output).toContain('dsh > ')
|
||||
expect(result.terminal.output).not.toContain('dsh ⊙ ')
|
||||
expect(result.terminal.output).not.toContain('Context being compacted')
|
||||
expect(result.terminal.progress.at(-1)).toBe(false)
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('releases the live compaction timer and progress bit on dispose', async () => {
|
||||
const intervalSpy = vi.spyOn(globalThis, 'setInterval')
|
||||
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval')
|
||||
let result: Awaited<ReturnType<typeof setup>> | undefined
|
||||
let didDispose = false
|
||||
try {
|
||||
result = await setup({ omitInitialLifecycle: true, now: () => 1_000 })
|
||||
intervalSpy.mockClear()
|
||||
clearIntervalSpy.mockClear()
|
||||
result.session.append('compact/start', { turn: null })
|
||||
expect(intervalSpy).toHaveBeenCalledOnce()
|
||||
|
||||
await dispose(result)
|
||||
didDispose = true
|
||||
expect(clearIntervalSpy).toHaveBeenCalledOnce()
|
||||
expect(result.terminal.progress.at(-1)).toBe(false)
|
||||
} finally {
|
||||
if (result !== undefined && !didDispose) await dispose(result)
|
||||
intervalSpy.mockRestore()
|
||||
clearIntervalSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
// Extract the running glyph's interpolated gray channel from a rendered frame.
|
||||
const glyphGray = (frame: string): number => {
|
||||
const m = /\x1b\[38;2;(\d+);(\d+);(\d+)m●/u.exec(frame)
|
||||
@@ -2103,7 +2280,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
it('shows the plain prompt caret while idle', async () => {
|
||||
const result = await setup({ now: () => 0 })
|
||||
expect(result.terminal.output).toContain('dsh > ')
|
||||
expect(result.terminal.output).not.toMatch(/dsh [◍✻●⚙]/u)
|
||||
expect(result.terminal.output).not.toMatch(/dsh [◍✻●⚙⊙]/u)
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
@@ -4115,8 +4292,9 @@ describe('skill slash command', () => {
|
||||
source: 'runtime',
|
||||
content: 'Dynamic body.',
|
||||
})
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('DYNAMIC_COMPLETION_MARKER')
|
||||
await vi.waitFor(() => {
|
||||
expect(result.terminal.output).toContain('DYNAMIC_COMPLETION_MARKER')
|
||||
})
|
||||
|
||||
result.terminal.send('\x03')
|
||||
disposeSkill()
|
||||
@@ -5401,6 +5579,7 @@ describe('TUI extension service', () => {
|
||||
host.theme.accent(`${label} plugin overlay`),
|
||||
[
|
||||
host.theme.text('text'),
|
||||
host.theme.brand('brand'),
|
||||
host.theme.dim('dim'),
|
||||
host.theme.success('success'),
|
||||
host.theme.warning('warning'),
|
||||
@@ -5568,7 +5747,7 @@ describe('terminal mounting', () => {
|
||||
const session = ctx.sessions.create(SessionId('main'))
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
const terminal = new FakeTerminal()
|
||||
mountTui(ctx, { theme: { color: false } }, { terminal, exit: vi.fn() })
|
||||
@@ -5593,7 +5772,7 @@ describe('terminal mounting', () => {
|
||||
const session = ctx.sessions.create(SessionId('main'))
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
const terminal = new FakeTerminal()
|
||||
// Mirror dsh-tui's own inject (minus loader, the absence under test).
|
||||
@@ -5628,14 +5807,14 @@ describe('terminal mounting', () => {
|
||||
const otherSession = ctx.sessions.create(SessionId('other-session'))
|
||||
ctx.agents.register({
|
||||
id: otherSession.id, options: {}, session: otherSession, status: 'idle', acceptsNextStep: false, ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
expect(terminal.started).toBe(0)
|
||||
|
||||
const session = ctx.sessions.create(SessionId('late-session'))
|
||||
const agent = {
|
||||
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
} as Agent
|
||||
ctx.agents.register(agent)
|
||||
await tick()
|
||||
@@ -5666,7 +5845,7 @@ describe('terminal mounting', () => {
|
||||
const session = ctx.sessions.create(SessionId('main-session'))
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
await tick()
|
||||
expect(terminal.started).toBe(0)
|
||||
@@ -5710,7 +5889,7 @@ describe('terminal mounting', () => {
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, status: 'running', acceptsNextStep: true, ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
const terminal = new FakeTerminal()
|
||||
terminal.start = () => { throw new Error('terminal startup failed') }
|
||||
@@ -5778,6 +5957,10 @@ describe('terminal mounting', () => {
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('uses the official DeepSeek SVG ink for truecolor brand art', () => {
|
||||
expect(brandText('mark')).toBe('\x1b[38;2;77;107;254mmark\x1b[39m')
|
||||
})
|
||||
|
||||
it('detects a light terminal color scheme and switches the scheme-dependent code role', async () => {
|
||||
const result = await setup({ config: { theme: { color: true } } })
|
||||
// `dim` is scheme-independent (SGR 2 over the default foreground), so the
|
||||
|
||||
@@ -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/user-approval/README.md
|
||||
README.md: 38bcfbfe81c3ff5f16d1835259bd4c35a06dcb64
|
||||
README.zh.md: 7f2678d8572b191ec88a326374420dde7deed3dc
|
||||
README.md: 7b87a75d1c7c43874c484bc11f8deed45cb523ce
|
||||
README.zh.md: c15871073231b6e97f37fc0338f4824025ba86ca
|
||||
|
||||
@@ -8,38 +8,37 @@ Each request must belong to an open agent turn. The service appends a paired `ap
|
||||
|
||||
Answerers are `approval/request` waterfall listeners. Return an outcome to answer for an owned agent or call `next()` to delegate. Agent-scoped listeners receive only that agent's requests; compose one terminal answerer per deployment because sibling listener order is not a policy priority mechanism. The ACP automation bridge supplies one-shot machine decisions for sessions it owns.
|
||||
|
||||
`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice, attributed to the user when the override follows the last `request/header` and to operator/config otherwise.
|
||||
`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch. Both policies contribute their complete current meaning to the cache-safe runtime-context snapshot.
|
||||
|
||||
The tools pipeline routes `ask` decisions through this seam and fails closed when it is absent; the sandboxed bash tool also uses it for escalated retries. The ACP automation bridge answers calls for its own agents through the client's machine policy. Audit events remain log-only, so the model sees only the asking consumer's result. See the [approval-seam Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
|
||||
|
||||
## Model Experience
|
||||
|
||||
### System prompt and policy notice
|
||||
### Current approval policy context
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Under `ask`, every agent request carries the ask-policy prompt section below. Under `never`, it carries the never-policy prompt section below. A policy switch injects exactly `The approval policy changed from "<old>" to "<new>" (changed by the user).` or `The approval policy changed from "<old>" to "<new>" (changed by the operator/config).` before the next step.
|
||||
The first request and each effective policy change append a full runtime-context snapshot after retained history. Under `ask`, the approval contribution states that configured answerers may be consulted and absence fails closed. Under `never`, it states the deterministic rejection and non-escalation consequence. Unchanged requests retain the earlier snapshot without adding another message.
|
||||
|
||||
##### Ask-policy prompt section
|
||||
##### Ask-policy contribution
|
||||
|
||||
```markdown
|
||||
<!-- dsh-user-approval-policy:ask -->
|
||||
Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.
|
||||
```
|
||||
|
||||
##### Never-policy prompt section
|
||||
##### Never-policy contribution
|
||||
|
||||
```markdown
|
||||
Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).
|
||||
<!-- dsh-user-approval-policy:never -->
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
Small fixed per-request cost, larger under `never`; a change notice is conditional and retained in history.
|
||||
One concise context message on the first request and on an effective change; unchanged requests add no duplicate policy tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable while the approval policy is unchanged. An `ask`/`never` switch changes the system-prompt section and invalidates reuse from its first changed token; the accompanying notice is append-only.
|
||||
Append-only after retained history. An `ask`/`never` switch preserves the stable system and conversation prefix instead of rewriting the first wire message.
|
||||
|
||||
### Tool outcome
|
||||
|
||||
|
||||
@@ -8,38 +8,37 @@
|
||||
|
||||
应答者是 `approval/request` waterfall(瀑布式事件)监听器。要回答其负责的 agent 请求,请返回一个结果;否则调用 `next()` 委托。限定到 agent 的监听器只接收该 agent 的请求;每项部署应当组合一个最终应答者,因为同级监听器的顺序不是策略优先级机制。ACP(Agent Client Protocol)自动化桥接层为其负责的会话提供一次性机器决定。
|
||||
|
||||
`ApprovalPolicy` 为 `'ask'` 或 `'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求,也是提示词中唯一声明的策略。切换最多产生一条合并通知:如果覆盖发生在最后一个 `request/header` 之后,则归因于用户;否则归因于操作方/配置。
|
||||
`ApprovalPolicy` 为 `'ask'` 或 `'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求。两种策略都会将各自完整的当前含义贡献给缓存安全的运行时上下文快照。
|
||||
|
||||
工具流水线通过此 seam 路由 `ask` 决定,并在该 seam 缺失时以拒绝方式关闭;沙箱 bash 工具也会将它用于升权重试。ACP 自动化桥接层根据客户端的机器策略,回答其自有 agent 的调用。审计事件仍只写入日志,因此模型只会看到发起请求的消费方所返回的结果。详见[审批 seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md)和[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 系统提示词与策略通知
|
||||
### 当前审批策略上下文
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
在 `ask` 下,每个 agent 请求都会携带下方的 ask 策略提示词段。在 `never` 下,请求会携带下方的 never 策略提示词段。策略切换会在下一步骤前精确注入 `The approval policy changed from "<old>" to "<new>" (changed by the user).` 或 `The approval policy changed from "<old>" to "<new>" (changed by the operator/config).`。
|
||||
首次请求和有效策略每次变化时,都会在保留的历史后追加一份完整运行时上下文快照。在 `ask` 下,批准贡献会说明可咨询已配置的应答者,缺少应答者时以拒绝方式关闭。在 `never` 下,它会说明确定性的拒绝与非升权后果。未变化的请求会保留先前快照,不增加另一条消息。
|
||||
|
||||
##### Ask 策略提示词段
|
||||
##### Ask 策略贡献
|
||||
|
||||
```markdown
|
||||
<!-- dsh-user-approval-policy:ask -->
|
||||
Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.
|
||||
```
|
||||
|
||||
##### Never 策略提示词段
|
||||
##### Never 策略贡献
|
||||
|
||||
```markdown
|
||||
Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).
|
||||
<!-- dsh-user-approval-policy:never -->
|
||||
```
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每个请求有少量固定成本,`never` 下的成本更高;变更通知按条件出现,并保留在历史中。
|
||||
首次请求和策略实际变化时增加一条简洁的上下文消息;未变化的请求不增加重复的策略 token。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
审批策略不变时,前缀保持稳定。`ask`/`never` 切换会改变系统提示词段,并从首个变化的 token 开始使复用失效;随附通知只会追加。
|
||||
在保留的历史之后仅追加。`ask`/`never` 切换会保留稳定的系统与对话前缀,而不会改写第一条 wire 消息。
|
||||
|
||||
### 工具结果
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { randomUUID } from 'node:crypto'
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage, type CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
@@ -59,7 +59,7 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
/**
|
||||
* The session's approval policy was switched — log-only, durable,
|
||||
* replayable, never in the model transcript (the model learns the policy
|
||||
* from the prompt section and the narrator's notices). The LAST such
|
||||
* from the cache-safe runtime-context snapshot). The LAST such
|
||||
* event is the session's override ({@link effectiveApprovalPolicy}).
|
||||
* `source: 'delegation'` marks an override seeded into a child; an absent
|
||||
* source is a runtime switch.
|
||||
@@ -90,41 +90,17 @@ const OUTCOMES: readonly ApprovalOutcome[] = ['allowed-once', 'rejected', 'cance
|
||||
* (exactly today's behavior).
|
||||
* - `'never'` — never prompt anyone: every ask resolves `'rejected'`
|
||||
* deterministically. The strict headless stance (CI, unattended runs) and
|
||||
* the only policy value stated in the system prompt — unlike `'ask'`, its
|
||||
* outcome is knowable without asking, so stating it cannot overclaim.
|
||||
* the policy whose outcome is knowable without asking.
|
||||
*/
|
||||
export type ApprovalPolicy = 'ask' | 'never'
|
||||
|
||||
/** Every {@link ApprovalPolicy}, for option advertisement and runtime validation of untrusted policy strings. */
|
||||
export const APPROVAL_POLICIES: readonly ApprovalPolicy[] = ['ask', 'never']
|
||||
|
||||
/**
|
||||
* The prompt sentence stating a `'never'` policy — visibility for the one
|
||||
* deterministic policy (see {@link ApprovalPolicy}). Narrator persistence
|
||||
* does NOT parse this prose: deployments can quote it in a persona or another
|
||||
* section, so the section also emits a source-owned marker.
|
||||
*/
|
||||
/** Model-facing statement for the deterministic `'never'` policy. */
|
||||
const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).'
|
||||
|
||||
/** Source-owned prompt markers used to reconstruct the policy in a logged header. */
|
||||
const POLICY_MARKERS = {
|
||||
ask: '<!-- dsh-user-approval-policy:ask -->',
|
||||
never: '<!-- dsh-user-approval-policy:never -->',
|
||||
} as const satisfies Record<ApprovalPolicy, string>
|
||||
|
||||
/**
|
||||
* Read the policy fact emitted by this service from a logged system prompt.
|
||||
* The section is ordered after deployment persona text, and the last marker
|
||||
* wins so a persona quoting an earlier marker cannot shadow the service's own
|
||||
* contribution. Ordinary policy prose is deliberately ignored.
|
||||
*/
|
||||
function toldApprovalPolicy(system: string | undefined): ApprovalPolicy | undefined {
|
||||
if (system === undefined) return undefined
|
||||
const ask = system.lastIndexOf(POLICY_MARKERS.ask)
|
||||
const never = system.lastIndexOf(POLICY_MARKERS.never)
|
||||
if (ask < 0 && never < 0) return undefined
|
||||
return never > ask ? 'never' : 'ask'
|
||||
}
|
||||
/** Model-facing statement for an interactive policy that may still fail closed. */
|
||||
const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.'
|
||||
|
||||
/**
|
||||
* The session's approval-policy override: the last `approval/policy` event in
|
||||
@@ -212,7 +188,7 @@ export interface Config {
|
||||
/**
|
||||
* Approval service that applies session policy before answerers and logs every
|
||||
* ask/outcome pair to the requesting session. It exposes deterministic policy
|
||||
* changes to the model through prompt and pre-step notices.
|
||||
* changes to the model through the cache-safe runtime-context snapshot.
|
||||
*/
|
||||
export class ApprovalService extends Service {
|
||||
static Config: z<Config> = z.object({
|
||||
@@ -224,9 +200,10 @@ export class ApprovalService extends Service {
|
||||
|
||||
const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent.session)
|
||||
|
||||
// State only deterministic policy; a marker records the otherwise silent state.
|
||||
// The complete current value travels after retained history, so switching
|
||||
// policy does not rewrite the stable system-prompt cache prefix.
|
||||
ctx.inject(['systemPrompt'], (scope: Context) => {
|
||||
scope.systemPrompt.section({
|
||||
scope.systemPrompt.context({
|
||||
name: 'approval:policy',
|
||||
order: 115,
|
||||
text: (context) => {
|
||||
@@ -234,54 +211,10 @@ export class ApprovalService extends Service {
|
||||
// A bare assemble() (tests, diagnostics) has no session to state.
|
||||
if (agent === undefined) return ''
|
||||
const policy = effective(agent)
|
||||
return policy === 'never' ? `${NEVER_SENTENCE}\n${POLICY_MARKERS.never}` : POLICY_MARKERS.ask
|
||||
return policy === 'never' ? NEVER_SENTENCE : ASK_SENTENCE
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Visibility layer 2: the boundary narrator. agent/step runs before the
|
||||
// request history is derived, so the notice is
|
||||
// seen by THIS step's request: idle-time flip-flops coalesce at the
|
||||
// turn's first step (net-zero → nothing), and a mid-turn switch is
|
||||
// narrated no later than the next step. What each session was last told
|
||||
// is in-memory with a log-derived fallback (the folded header's system
|
||||
// text), so restarts lose nothing. Attribution is positional: an
|
||||
// override event after the log's last `request/header` was a runtime
|
||||
// switch by the user; otherwise the configured default moved under the
|
||||
// session (operator/config).
|
||||
const narrated = new WeakMap<Agent['session'], ApprovalPolicy>()
|
||||
ctx.on('agent/step', (agent) => {
|
||||
const session = agent.session
|
||||
const events = session.events
|
||||
let overrideIndex = -1
|
||||
let overrideSource: 'delegation' | undefined
|
||||
let headerIndex = -1
|
||||
for (let index = events.length - 1; index >= 0 && (overrideIndex < 0 || headerIndex < 0); index -= 1) {
|
||||
const event = events[index] as (typeof events)[number]
|
||||
if (overrideIndex < 0 && event.type === 'approval/policy') {
|
||||
overrideIndex = index
|
||||
overrideSource = event.data.source
|
||||
} else if (headerIndex < 0 && event.type === 'request/header') {
|
||||
headerIndex = index
|
||||
}
|
||||
}
|
||||
// Same fold effectivePolicy performs — override is scanned here anyway
|
||||
// for POSITIONAL attribution; the default lives once, in the method.
|
||||
const current = this.effectivePolicy(session)
|
||||
const header = session.requestHeader()
|
||||
const told = narrated.get(session) ?? toldApprovalPolicy(header?.system)
|
||||
narrated.set(session, current)
|
||||
// Cold start (nothing ever told) narrates nothing — the section about
|
||||
// to go out states the truth, and there is no delta to explain.
|
||||
if (told === undefined || told === current) return
|
||||
const cause = overrideSource === 'delegation'
|
||||
? 'inherited from the delegating session'
|
||||
: overrideIndex > headerIndex ? 'changed by the user' : 'changed by the operator/config'
|
||||
agent.inject(createUserMessage({
|
||||
content: [{ type: 'text', text: `The approval policy changed from "${told}" to "${current}" (${cause}).` }],
|
||||
source: { kind: 'plugin', plugin: 'user-approval' },
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { carrierKeyOf, createScope } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
@@ -351,33 +351,17 @@ describe('ApprovalService.request', () => {
|
||||
|
||||
describe('approval policy (the approval/policy fold)', () => {
|
||||
const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).'
|
||||
const ASK_MARKER = '<!-- dsh-user-approval-policy:ask -->'
|
||||
const NEVER_MARKER = '<!-- dsh-user-approval-policy:never -->'
|
||||
const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.'
|
||||
|
||||
/**
|
||||
* An agent stand-in over a REAL Session — gate, section, and narrator fold
|
||||
* real events; the opened turn satisfies request()'s enclosure precondition.
|
||||
*/
|
||||
function sessionAgent(id: string): { agent: Agent; session: Session; injected: string[] } {
|
||||
/** Agent stand-in over a real Session; the opened turn satisfies request()'s enclosure precondition. */
|
||||
function sessionAgent(id: string): { agent: Agent; session: Session } {
|
||||
const session = new Session(SessionId(id))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const injected: string[] = []
|
||||
const agent = {
|
||||
id,
|
||||
session,
|
||||
inject: (input: { content: Array<{ type: string; text: string }> }) => {
|
||||
injected.push(input.content[0]?.text ?? '')
|
||||
},
|
||||
} as unknown as Agent
|
||||
return { agent, session, injected }
|
||||
}
|
||||
|
||||
const preStep = (ctx: Context, agent: Agent): Promise<void> =>
|
||||
agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal)
|
||||
|
||||
/** Append a `request/header` snapshot whose system text is exactly `system`. */
|
||||
function appendHeader(session: Session, system: string): void {
|
||||
session.append('request/header', { header: { config: { provider: 'mock', model: 'mock' }, system }, reason: 'initial' })
|
||||
return { agent, session }
|
||||
}
|
||||
|
||||
it('folds to the last event, or undefined without one', () => {
|
||||
@@ -464,131 +448,46 @@ describe('approval policy (the approval/policy fold)', () => {
|
||||
await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected')
|
||||
})
|
||||
|
||||
it('states never (and only never) in prose while recording either policy with a source-owned marker', async () => {
|
||||
it('contributes the complete current ask or never policy as cache-safe context', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ApprovalService)
|
||||
const askAgent = sessionAgent('sess-sect-ask').agent
|
||||
const { agent: neverAgent, session } = sessionAgent('sess-sect-never')
|
||||
setApprovalPolicy(session, 'never')
|
||||
const sectionFor = async (context: object) =>
|
||||
(await ctx.systemPrompt.assemble(context)).sections.find(s => s.name === 'approval:policy')?.text
|
||||
expect(await sectionFor({ agent: askAgent })).toBe(ASK_MARKER)
|
||||
expect(await sectionFor({ agent: neverAgent })).toBe(`${NEVER_SENTENCE}\n${NEVER_MARKER}`)
|
||||
const contextFor = async (context: object) =>
|
||||
(await ctx.systemPrompt.assemble(context)).contexts.find(entry => entry.name === 'approval:policy')?.text
|
||||
expect(await contextFor({ agent: askAgent })).toBe(ASK_SENTENCE)
|
||||
expect(await contextFor({ agent: neverAgent })).toBe(NEVER_SENTENCE)
|
||||
// A bare assemble (no agent) has no session to state.
|
||||
expect(await sectionFor({})).toBe('')
|
||||
expect(await contextFor({})).toBe('')
|
||||
})
|
||||
|
||||
it('narrates nothing cold, once per coalesced switch (user wording), and idempotently', async () => {
|
||||
it('reflects the latest durable switch and stays byte-stable while unchanged', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ApprovalService)
|
||||
const { agent, session, injected } = sessionAgent('sess-narr-1')
|
||||
await preStep(ctx, agent)
|
||||
expect(injected).toEqual([])
|
||||
const { agent, session } = sessionAgent('sess-context-switch')
|
||||
const contextFor = async () =>
|
||||
(await ctx.systemPrompt.assemble({ agent })).contexts.find(entry => entry.name === 'approval:policy')?.text
|
||||
expect(await contextFor()).toBe(ASK_SENTENCE)
|
||||
expect(await contextFor()).toBe(ASK_SENTENCE)
|
||||
setApprovalPolicy(session, 'never')
|
||||
setApprovalPolicy(session, 'ask')
|
||||
setApprovalPolicy(session, 'never')
|
||||
await preStep(ctx, agent)
|
||||
expect(injected).toEqual(['The approval policy changed from "ask" to "never" (changed by the user).'])
|
||||
await preStep(ctx, agent)
|
||||
expect(injected).toHaveLength(1)
|
||||
setApprovalPolicy(session, 'ask')
|
||||
setApprovalPolicy(session, 'never')
|
||||
await preStep(ctx, agent)
|
||||
expect(injected).toHaveLength(1)
|
||||
expect(await contextFor()).toBe(NEVER_SENTENCE)
|
||||
expect(await contextFor()).toBe(NEVER_SENTENCE)
|
||||
})
|
||||
|
||||
it('reads what the model was told back from the folded header text after a restart', async () => {
|
||||
// A session whose last request carried the never sentence resumes under
|
||||
// an ask default: the narrator attributes the change to the operator.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService)
|
||||
const { agent, session, injected } = sessionAgent('sess-narr-2')
|
||||
appendHeader(session, `persona\n\n${NEVER_SENTENCE}\n${NEVER_MARKER}`)
|
||||
await preStep(ctx, agent)
|
||||
expect(injected).toEqual(['The approval policy changed from "never" to "ask" (changed by the operator/config).'])
|
||||
})
|
||||
|
||||
it('attributes a constructor-seeded policy event to delegation', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService)
|
||||
const { agent, session, injected } = sessionAgent('sess-narr-inherited')
|
||||
appendHeader(session, ASK_MARKER)
|
||||
session.append('approval/policy', { policy: 'never', source: 'delegation' })
|
||||
|
||||
await preStep(ctx, agent)
|
||||
|
||||
expect(injected).toEqual(['The approval policy changed from "ask" to "never" (inherited from the delegating session).'])
|
||||
})
|
||||
|
||||
it('narrates a config default drift from the logged ask marker', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService, { policy: 'never' })
|
||||
const { agent, session, injected } = sessionAgent('sess-narr-3')
|
||||
appendHeader(session, `persona only\n${ASK_MARKER}`)
|
||||
await preStep(ctx, agent)
|
||||
expect(injected).toEqual(['The approval policy changed from "ask" to "never" (changed by the operator/config).'])
|
||||
})
|
||||
|
||||
it('a pinned override survives a default change silently', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService, { policy: 'never' })
|
||||
const { agent, session, injected } = sessionAgent('sess-narr-4')
|
||||
appendHeader(session, `persona only\n${ASK_MARKER}`)
|
||||
setApprovalPolicy(session, 'ask')
|
||||
appendHeader(session, `persona only\n${ASK_MARKER}`)
|
||||
await preStep(ctx, agent)
|
||||
expect(injected).toEqual([])
|
||||
})
|
||||
|
||||
it('does not infer never from deployment prose that quotes the never sentence', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService)
|
||||
const { agent, session, injected } = sessionAgent('sess-narr-spoof-prose')
|
||||
appendHeader(session, `persona quotes this warning: ${NEVER_SENTENCE}\n${ASK_MARKER}`)
|
||||
await preStep(ctx, agent)
|
||||
expect(injected).toEqual([])
|
||||
})
|
||||
|
||||
it('treats a legacy header with no source-owned marker as untold', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService, { policy: 'never' })
|
||||
const { agent, session, injected } = sessionAgent('sess-narr-unmarked-header')
|
||||
appendHeader(session, 'legacy persona-only header')
|
||||
await preStep(ctx, agent)
|
||||
expect(injected).toEqual([])
|
||||
})
|
||||
|
||||
it('uses the service marker after an earlier persona marker', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService)
|
||||
const { agent, session, injected } = sessionAgent('sess-narr-spoof-marker')
|
||||
appendHeader(session, `persona quotes ${NEVER_MARKER}\n${ASK_MARKER}`)
|
||||
await preStep(ctx, agent)
|
||||
expect(injected).toEqual([])
|
||||
})
|
||||
|
||||
it('disposes the service prompt section and pre-step narrator together (HMR safety)', async () => {
|
||||
it('disposes the service context contribution with its fiber (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
const fiber = await ctx.plugin(ApprovalService)
|
||||
const live = sessionAgent('sess-hmr-service-live')
|
||||
const afterDispose = sessionAgent('sess-hmr-service-disposed')
|
||||
const sectionFor = async () =>
|
||||
(await ctx.systemPrompt.assemble({ agent: live.agent })).sections.find(section => section.name === 'approval:policy')
|
||||
expect(await sectionFor()).toBeDefined()
|
||||
|
||||
appendHeader(live.session, `persona\n${ASK_MARKER}`)
|
||||
setApprovalPolicy(live.session, 'never')
|
||||
await preStep(ctx, live.agent)
|
||||
expect(live.injected).toEqual(['The approval policy changed from "ask" to "never" (changed by the user).'])
|
||||
|
||||
appendHeader(afterDispose.session, `persona\n${ASK_MARKER}`)
|
||||
setApprovalPolicy(afterDispose.session, 'never')
|
||||
const contextFor = async () =>
|
||||
(await ctx.systemPrompt.assemble({ agent: live.agent })).contexts.find(context => context.name === 'approval:policy')
|
||||
expect(await contextFor()).toBeDefined()
|
||||
await fiber.dispose()
|
||||
|
||||
expect(await sectionFor()).toBeUndefined()
|
||||
await preStep(ctx, afterDispose.agent)
|
||||
expect(afterDispose.injected).toEqual([])
|
||||
expect(await contextFor()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user