Merge remote-tracking branch 'origin/master' into feat/search-presenter

# Conflicts:
#	docs/config-catalog.md
#	docs/cookbook/adding-a-tool.i18n.yaml
#	docs/cookbook/adding-a-tool.md
#	docs/cookbook/adding-a-tool.zh.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/tools.i18n.yaml
#	docs/core-data-structures/tools.md
#	docs/core-data-structures/tools.zh.md
#	docs/event-producer-consumer.md
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/tools/README.i18n.yaml
#	packages/core/tools/README.md
#	packages/core/tools/README.zh.md
#	packages/core/tools/src/index.ts
#	packages/core/tools/src/presentation.ts
#	packages/fs/tool-fs-search/src/glob.ts
#	packages/fs/tool-fs-search/src/index.ts
#	packages/ui/tui/src/components/transcript.ts
#	packages/ui/tui/tests/tui.spec.ts
This commit is contained in:
Chinesezjc
2026-07-31 11:31:32 +08:00
946 changed files with 28921 additions and 4610 deletions

View File

@@ -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: 4d8c7de65515a251f227075c7baf041fc1b210c8
README.zh.md: d9b69a2b102a60685524288f75edeaabb21802db
README.md: 54f754842d9a6673ed6791b94656139f0f1be6a3
README.zh.md: dd56084812e8241f0db24601ce2baeba51252d42

View File

@@ -10,16 +10,16 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c
| `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) |
| `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 |
| `assertEntriesActive(ctx, binName)` | Throw when a settled enabled fiber is not ACTIVE, including missing injected services for PENDING entries |
| `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 ACTIVE, and return the root context |
| `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 |
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |
Two failure classes the guards handle: `loader.await()` swallows init rejections (`Promise.allSettled`) — Node still exits non-zero on the resulting unhandled rejection, and `installFailLoud` replaces the noisy dump with one labelled line and a guaranteed `exit(1)`; a failed plugin import is only logged by the Loader (the process would otherwise exit 0 on a usable config typo), leaving a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every failed plugin.
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.
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 `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.
Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers.
This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution.
@@ -27,8 +27,8 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](..
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:
- **`.env`** — loaded after the invoking directory's `.env`; `process.loadEnvFile` never overrides, so precedence is ambient environment > project `.env` > personal `.env`.
- **`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 — so a personal `apiKey` can reference the personal `.env`. 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.
- **`.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.
Subprocess test launchers point `DSH_HOME` at an isolated per-test directory so a developer's personal overlay can never leak into fixtures.

View File

@@ -10,16 +10,16 @@
| `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr |
| `installFailLoud(binName, proc?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) |
| `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 |
| `assertEntriesActive(ctx, binName)` | 树结算后,如果已启用的 fiber 未处于 ACTIVE 状态,则抛出异常;对于 PENDING 条目还会列出缺失的注入服务 |
| `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 树结算,断言所有条目均已加载且处于 ACTIVE 状态,最后返回根上下文 |
| `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热模块替换重新加载系统提示词后它会消失直至下次启动 |
| `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 |
这些保护处理两类故障。`loader.await()` 会吞掉初始化 rejection`Promise.allSettled`Node 仍会因随后产生的未处理 rejection 以非零状态退出,而 `installFailLoud` 会把冗长转储替换为一行带标签的消息,并确保执行 `exit(1)`。插件导入失败则只会由 Loader 记录日志(否则,即使配置存在拼写错误,进程也会以代码 0 退出),并留下没有 fiber 的条目;`assertEntriesLoaded` 会将其转换为 `boot()` rejection并在其中列出每个导入失败插件的名称
Loader 树结算不会向调用方传播两类故障,因此需要分别保护。插件导入失败会留下没有 fiber 的配置项,`assertEntriesLoaded` 将其转换为 `boot()` rejection并列出每个未解析插件。插件回调或配置失败则会留下失败的 fiber因为 `loader.await()` 只结算生命周期任务,不传播该错误;`assertEntriesActivated` 会显式等待该 fiber并把原始错误堆栈写入启动 rejection。抛出错误前审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命
配置中的裸插件 specifier`@deepseek-ai/dsh-*`、npm 包package通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper并以配置目录为基准解析。`dsh` 源码启动器还会将 manifest元数据清单声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUIWeb 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`
配置中的裸插件 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 源码;其配置门禁要求每个 TUIWeb 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`
此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper构建后的消费方仍使用普通 Node 包解析。
@@ -27,8 +27,8 @@
开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由官方 `dsh` 界面([`apps/cli`](../../../apps/cli/README.md)使用demo bin 会原样启动仓库中提交的树。这里有两个可选文件:
- **`.env`**调用目录的 `.env` 之后加载;`process.loadEnvFile` 从不覆盖已有值,因此优先级为环境中的值 > 项目 `.env` > 个人 `.env`
- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch语义与交付的 surface overlay 相同:按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值,因此个人 `apiKey` 可以引用个人 `.env`。如果 patch 指定的条目 id 不在已启动树中,则静默不执行任何操作。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay请使用 `[]` 或删除该文件。
- **`.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请使用 `[]` 或删除该文件。
子进程测试 launcher 会把 `DSH_HOME` 指向逐测试隔离的目录,确保开发者的个人 overlay 不会泄漏到 fixture测试前置数据中。

View File

@@ -160,16 +160,46 @@ export interface FailLoudProcess {
exit(code: number): void
}
// Loader rc.5 derives and drops a rejected promise after a fiber fails. Keep
// exact reasons already folded into the boot diagnostic visible through the
// next process rejection checkpoint so the process guard can coalesce them.
const assembledActivationRejections = new Map<unknown, number>()
function retainAssembledRejection(reason: unknown): void {
assembledActivationRejections.set(reason, (assembledActivationRejections.get(reason) ?? 0) + 1)
}
function releaseAssembledRejection(reason: unknown): void {
const count = assembledActivationRejections.get(reason)
if (count === undefined || count === 1) {
assembledActivationRejections.delete(reason)
} else {
assembledActivationRejections.set(reason, count - 1)
}
}
async function observeLoaderRejectionCheckpoint(reasons: readonly unknown[]): Promise<void> {
for (const reason of reasons) retainAssembledRejection(reason)
try {
await new Promise<void>(resolve => setImmediate(resolve))
} finally {
for (const reason of reasons) releaseAssembledRejection(reason)
}
}
/**
* Install before boot to turn a late unhandled plugin-init rejection into one
* labelled stderr diagnostic and `exit(1)`. Stdout remains untouched for ACP;
* the returned function removes the handler.
* 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.
* @param binName - the diagnostic prefix on the fatal-failure line.
* @param proc - the process slice to register on; tests inject a fake.
* @returns the uninstaller that removes the rejection handler.
*/
export function installFailLoud(binName: string, proc: FailLoudProcess = process): () => void {
const handler = (err: unknown): void => {
if (assembledActivationRejections.has(err)) return
proc.stderr.write(`${binName}: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
proc.exit(1)
}
@@ -192,28 +222,64 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
}
}
/** Runtime mirrors for Cordis's erased const-enum fiber states. */
const FIBER_ACTIVE = 2 as FiberState.ACTIVE
/**
* Value mirrors used because Cordis's const enum has no runtime object to import.
* Keep aligned with `packages/cordis/tool-cordis/src/fiber-state.ts` and
* `packages/client/web/src/loader-status.ts`.
*/
const FIBER_PENDING = 0 as FiberState.PENDING
const FIBER_ACTIVE = 2 as FiberState.ACTIVE
const FIBER_FAILED = 3 as FiberState.FAILED
/** Render a thrown plugin value without discarding an Error's original stack. */
function formatActivationError(error: unknown): string {
return error instanceof Error ? error.stack ?? error.message : String(error)
}
/**
* Reject enabled Loader entries whose fibers did not reach ACTIVE after settle.
* @param ctx - The settled application root.
* @param binName - Diagnostic prefix.
* Reject a settled Loader tree when an enabled entry failed or remains inactive.
* Plugin failures include the original thrown stack; pending entries name their
* unresolved services because no plugin error exists for that state. Active
* entries require no further wait; only failed fibers are awaited to recover
* their private rejection reason.
* @param ctx - the settled context whose Loader entries to audit.
* @param binName - the diagnostic prefix on the thrown error.
* @returns nothing when every enabled entry is active.
* @throws after one process rejection checkpoint when an entry failed to
* import, rejected during activation, or did not become active.
*/
export function assertEntriesActive(ctx: Context, binName: string): void {
export async function assertEntriesActivated(ctx: Context, binName: string): Promise<void> {
assertEntriesLoaded(ctx, binName)
const failures: string[] = []
const rejectionReasons: unknown[] = []
for (const entry of ctx.loader.entries()) {
if (entry.fiber === undefined || entry.disabled || entry.fiber.state === FIBER_ACTIVE) continue
if (entry.fiber.state === FIBER_PENDING) {
const missing = Object.keys(entry.fiber.inject).filter(service => ctx.get(service) === undefined)
failures.push(`${entry.options.name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
const fiber = entry.fiber
if (fiber === undefined || entry.disabled) continue
const state = fiber.state
if (state === FIBER_ACTIVE) continue
if (state === FIBER_FAILED) {
try {
await fiber.await()
} catch (error) {
rejectionReasons.push(error)
failures.push(`${entry.options.name}: ${formatActivationError(error)}`)
}
continue
}
if (state === FIBER_PENDING) {
const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined)
const subject = missing.length === 1 ? 'service' : 'services'
failures.push(`${entry.options.name}: pending (waiting for ${subject}: ${missing.join(', ') || 'unknown'})`)
} else {
failures.push(`${entry.options.name}: fiber state ${String(entry.fiber.state)}`)
failures.push(`${entry.options.name}: fiber state ${String(state)}`)
}
}
if (failures.length > 0) {
throw new Error(`${binName}: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
if (rejectionReasons.length > 0) {
await observeLoaderRejectionCheckpoint(rejectionReasons)
}
const noun = failures.length === 1 ? 'entry' : 'entries'
throw new Error(`${binName}: ${String(failures.length)} ${noun} did not activate\n${failures.join('\n')}`)
}
}
@@ -225,16 +291,21 @@ export function assertEntriesActive(ctx: Context, binName: string): void {
* bootstrap include is therefore statically imported and mounted as the
* `cordis:include` builtin, loading through the ambient module pipeline
* (vite/tsx/plain ESM) while the included tree's own specifiers stay
* config-relative. A missing fiber rejects here; a later init rejection is
* handled by {@link installFailLoud}. Built bins need the Loader's native
* helper for bare plugin specifiers; relative specifiers do not.
* 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.
* @param binName - the diagnostic prefix for load-failure errors.
* @param absoluteConfigPath - the config to include; must already be absolute
* (see {@link resolveConfigPath}).
* @param patches - optional overlay patches applied over the included tree
* (see {@link loadPersonalPatches}); an empty list mounts none.
* @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.
* @returns the root context once every entry has started, or as soon as a
* surface disposed the tree while startup was still in flight.
*/
export async function boot(
binName: string,
@@ -255,8 +326,14 @@ export async function boot(
},
})
await ctx.loader.await()
assertEntriesLoaded(ctx, binName)
assertEntriesActive(ctx, binName)
// 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
}

View File

@@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import {
addHarnessSourceSection, assertEntriesActive, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
installFailLoud, loadEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
} from '../src/index.ts'
@@ -135,6 +135,33 @@ describe('installFailLoud', () => {
uninstallReal()
expect(process.listenerCount('unhandledRejection')).toBe(before)
})
it('does not report an activation rejection shared by entries in the boot audit', async () => {
const proc = fakeProc()
installFailLoud(NAME, proc)
const error = new Error('assembled activation failure')
const audit = assertEntriesActivated({
loader: {
entries: () => ['broken-a', 'broken-b'].map(name => ({
options: { name },
fiber: {
state: 3,
inject: {},
ctx: { get: () => undefined },
await: async () => { throw error },
},
})),
},
} as unknown as Context, NAME)
await Promise.resolve()
await Promise.resolve()
proc.handlers[0]!(error)
expect(proc.written).toEqual([])
expect(proc.exits).toEqual([])
await expect(audit).rejects.toThrow('assembled activation failure')
proc.handlers[0]!(error)
expect(proc.exits).toEqual([1])
})
})
describe('assertEntriesLoaded', () => {
@@ -157,6 +184,97 @@ describe('assertEntriesLoaded', () => {
})
})
describe('assertEntriesActivated', () => {
interface FakeFiber {
state: number
inject: Record<string, unknown>
ctx: { get(name: string): unknown }
await(): Promise<unknown>
}
const ctxWith = (entries: Array<{ fiber?: FakeFiber; disabled?: boolean; options: { name: string } }>): Context => ({
loader: { entries: () => entries },
}) as unknown as Context
const fiber = (
state: number,
error?: unknown,
inject: Record<string, unknown> = {},
services: string[] = [],
): FakeFiber => ({
state,
inject,
ctx: { get: name => services.includes(name) ? {} : undefined },
await: error === undefined ? async () => undefined : async () => { throw error },
})
it('passes active entries and ignores disabled entries', async () => {
let awaitCalls = 0
const active = fiber(2)
active.await = async () => {
awaitCalls++
return undefined
}
const disabled = fiber(3, new Error('disabled failure'))
disabled.await = async () => {
awaitCalls++
throw new Error('disabled failure')
}
await expect(assertEntriesActivated(ctxWith([
{ fiber: active, options: { name: 'active' } },
{ fiber: disabled, disabled: true, options: { name: 'disabled' } },
]), NAME)).resolves.toBeUndefined()
expect(awaitCalls).toBe(0)
})
it('reports the plugin name and original activation stack instead of fiber state 3', async () => {
const original = new Error('actual plugin failure')
await expect(assertEntriesActivated(ctxWith([
{ fiber: fiber(3, original), options: { name: 'broken-plugin' } },
]), NAME)).rejects.toThrow(`${NAME}: 1 entry did not activate\nbroken-plugin: ${original.stack!}`)
})
it('formats stackless and non-Error activation failures', async () => {
const stackless = new Error('stackless failure')
delete (stackless as { stack?: string }).stack
await expect(assertEntriesActivated(ctxWith([
{ fiber: fiber(3, stackless), options: { name: 'stackless' } },
{ fiber: fiber(3, 'plain failure'), options: { name: 'plain' } },
]), NAME)).rejects.toThrow(`${NAME}: 2 entries did not activate\nstackless: stackless failure\nplain: plain failure`)
})
it('reports unresolved services for pending entries', async () => {
let awaitCalls = 0
const expected = [
`${NAME}: 3 entries did not activate`,
'waiting: pending (waiting for services: missingA, missingB)',
'single-wait: pending (waiting for service: missing)',
'unknown-wait: pending (waiting for services: unknown)',
].join('\n')
const waiting = fiber(0, undefined, { ready: {}, missingA: {}, missingB: {} }, ['ready'])
const singleWait = fiber(0, undefined, { missing: {} })
const unknownWait = fiber(0)
for (const item of [waiting, singleWait, unknownWait]) {
item.await = async () => {
awaitCalls++
return undefined
}
}
await expect(assertEntriesActivated(ctxWith([
{ fiber: waiting, options: { name: 'waiting' } },
{ fiber: singleWait, options: { name: 'single-wait' } },
{ fiber: unknownWait, options: { name: 'unknown-wait' } },
]), NAME)).rejects.toThrow(expected)
expect(awaitCalls).toBe(0)
})
it('retains the numeric diagnostic for a settled unexpected state', async () => {
await expect(assertEntriesActivated(ctxWith([
{ fiber: fiber(4), options: { name: 'disposed' } },
]), NAME)).rejects.toThrow('disposed: fiber state 4')
})
})
describe('loadOverlayPatches', () => {
it('loads expressions and rejects missing, malformed, non-array, and non-mapping overlays', () => {
const dir = tmp()
@@ -207,37 +325,39 @@ describe('boot', () => {
}
})
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
// surface renders before the last entry settles. The Loader service goes
// with the tree, so reading it for the post-boot assertions would crash an
// app that exited exactly as the user asked.
const dir = tmp()
writeFileSync(join(dir, 'exiting.mjs'), [
'export const name = "exiting"',
'export function apply(ctx) {',
' void ctx.root.fiber.dispose()',
'}',
'',
].join('\n'))
writeFileSync(join(dir, 'cordis.yml'), '- id: exiting\n name: ./exiting.mjs\n')
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
expect(ctx.get('loader')).toBeUndefined()
})
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`)
})
it('rejects a settled tree with a pending inject and names every missing service', async () => {
it('reports a pending real Loader fiber and the service unresolved in its own context', async () => {
const dir = tmp()
writeFileSync(join(dir, 'waiting.mjs'), "export const inject = ['alpha', 'beta']\nexport function apply() {}\n")
writeFileSync(join(dir, 'waiting.mjs'), 'export const inject = ["neverProvided"]\nexport function apply() {}\n')
writeFileSync(join(dir, 'cordis.yml'), '- id: waiting\n name: ./waiting.mjs\n')
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow('./waiting.mjs: pending (waiting for services: alpha, beta)')
})
it('uses singular diagnostics for one missing pending dependency', () => {
const ctx = {
loader: { entries: () => [{ disabled: false, options: { name: 'waiting' }, fiber: { state: 0, inject: { alpha: {} } } }] },
get: () => undefined,
} as unknown as Context
expect(() =>{ assertEntriesActive(ctx, NAME) }).toThrow('waiting: pending (waiting for service: alpha)')
})
it('reports unknown pending dependencies and unexpected fiber states', () => {
const entries = [
{ disabled: false, options: { name: 'unknown' }, fiber: { state: 0, inject: {} } },
{ disabled: false, options: { name: 'failed' }, fiber: { state: 3, inject: {} } },
]
const ctx = {
loader: { entries: () => entries },
get: () => undefined,
} as unknown as Context
expect(() =>{ assertEntriesActive(ctx, NAME) }).toThrow(`${NAME}: 2 entries did not activate\nunknown: pending (waiting for services: unknown)\nfailed: fiber state 3`)
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow([
`${NAME}: 1 entry did not activate`,
'./waiting.mjs: pending (waiting for service: neverProvided)',
].join('\n'))
})
})

View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'tsdown'
/**
* Embed Include while keeping Loader external so the built include tree and
* app host bind to one Loader peer.
*/
export default defineConfig({
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
deps: {
alwaysBundle: ['@cordisjs/plugin-include'],
},
})

View File

@@ -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/jsonrpc/README.md
README.md: b1219ba10269fc7d046da22c280ff1b91424a5ae
README.zh.md: 1c27f5edf2f1f172aa6303697b17e2e77a65842a
README.md: ac47af28e69e647ba44a7718478db163d406f5dc
README.zh.md: 2ab600dce52f471d8eef63848e6283217008dcf6

View File

@@ -6,7 +6,7 @@ The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-proc
## Wiring
`inject: ['agents']`. The server gets or creates one agent per `sessionId`. It forwards subagent completions only when the service-snapshotted lifecycle `local` flag is true; provider names, child ids, and durable lineage never establish locality. A registered adapter wins, an unowned `deepseek` route mounts `dsh-llm-deepseek`, and any other unowned provider fails initialization. Other capabilities come from the surrounding `cordis.yml`.
`inject: ['agents']`. The server gets or creates one agent per `sessionId`. It forwards subagent completions only when the service-snapshotted lifecycle `local` flag is true; provider names, child ids, and durable lineage never establish locality. A registered adapter wins, an unowned `deepseek-official` route mounts `dsh-llm-deepseek`, and any other unowned provider fails initialization. Other capabilities come from the surrounding `cordis.yml`.
## Config
@@ -22,7 +22,7 @@ The plugin answers `shutdown`, disposes SDK-owned agents and subscriptions to qu
## Wire notes
`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. An optional positive `initialize.maxTokens` becomes the request output cap of each SDK-created agent and its in-process descendants; invalid values reject initialization, while omission sends no cap and preserves provider defaults. A session accepts one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. `session.finished` reports that prompt's message-triggered turn outcome; later between-turn records still stream as `session.event` notifications but cannot replace the prompt status. Persistence roots and persona come from `cordis.yml`.
`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. An optional positive `initialize.maxTokens` becomes the request output cap of each SDK-created agent and its in-process descendants; invalid values reject initialization, while omission sends no SDK cap and allows the selected adapter or provider route default to apply. A session accepts one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. `session.finished` reports that prompt's message-triggered turn outcome; later between-turn records still stream as `session.event` notifications but cannot replace the prompt status. Persistence roots and persona come from `cordis.yml`.
## Model Experience

View File

@@ -6,7 +6,7 @@
## 组装
`inject: ['agents']`。服务器按 `sessionId` 获取或创建一个 agent。只有服务对生命周期建立快照时记录的 `local` 标志为 true服务器才会转发 subagent 完成事件;提供方名称、子级 id 和持久化谱系均不能证明本地性。已注册的适配器优先;尚无适配器负责的 `deepseek` 路由会挂载 `dsh-llm-deepseek`,任何其他尚无适配器负责的提供方都会导致初始化失败。其他功能由外围 `cordis.yml` 提供。
`inject: ['agents']`。服务器按 `sessionId` 获取或创建一个 agent。只有服务对生命周期建立快照时记录的 `local` 标志为 true服务器才会转发 subagent 完成事件;提供方名称、子级 id 和持久化谱系均不能证明本地性。已注册的适配器优先;尚无适配器负责的 `deepseek-official` 路由会挂载 `dsh-llm-deepseek`,任何其他尚无适配器负责的提供方都会导致初始化失败。其他功能由外围 `cordis.yml` 提供。
## 配置
@@ -22,7 +22,7 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger诊断应写
## 协议说明
`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。可选的正整数 `initialize.maxTokens` 会成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限;非法值会使初始化失败,省略时则不发送上限并保留提供方默认值。一个会话只接受一个进行中的提示词;重叠请求会立即失败,其他会话保持独立,当前请求结算后该会话可再次使用。`session.finished` 报告由该提示词消息触发的轮次结果;后续轮次间记录仍会作为 `session.event` 通知流式发出,但不能替换该提示词的状态。持久化根目录和 persona 由 `cordis.yml` 提供。
`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。可选的正整数 `initialize.maxTokens` 会成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限;非法值会使初始化失败,省略时则不发送 SDK 上限,并应用所选适配器或提供方路由的默认值。一个会话只接受一个进行中的提示词;重叠请求会立即失败,其他会话保持独立,当前请求结算后该会话可再次使用。`session.finished` 报告由该提示词消息触发的轮次结果;后续轮次间记录仍会作为 `session.event` 通知流式发出,但不能替换该提示词的状态。持久化根目录和 persona 由 `cordis.yml` 提供。
## 模型体验

View File

@@ -55,8 +55,8 @@ function successStatus(reason: string, options: HarnessSdkServerOptions): 'ok' |
*/
export class HarnessSdkServer {
private cwd = process.cwd()
private provider = 'deepseek'
private model = 'deepseek'
private provider = 'deepseek-official'
private model = 'deepseek-official'
private maxTokens: number | undefined
private llmFiber: { dispose(): Promise<void> } | undefined
private readonly sessions = new Map<string, SessionRecord>()
@@ -124,7 +124,7 @@ export class HarnessSdkServer {
this.model = params.model
this.maxTokens = params.maxTokens
if (!this.hasAdapterFor(this.provider)) {
if (this.provider !== 'deepseek') throw new Error(`no adapter registered for provider "${this.provider}"`)
if (this.provider !== 'deepseek-official') throw new Error(`no adapter registered for provider "${this.provider}"`)
this.llmFiber = await this.ctx.plugin(LlmDeepSeek, {})
}
return { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } }

View File

@@ -153,7 +153,7 @@ describe('dsh-jsonrpc plugin apply', () => {
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
const harness = await mountPlugin(storageDir)
try {
harness.send({ jsonrpc: '2.0', id: 'init-1', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek', model: 'apply-model' } })
harness.send({ jsonrpc: '2.0', id: 'init-1', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek-official', model: 'apply-model' } })
const response = await harness.waitForFrame(frame => frame.id === 'init-1', 'initialize response')
expect(response).toEqual({
@@ -175,7 +175,7 @@ describe('dsh-jsonrpc plugin apply', () => {
vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url)
const harness = await mountPlugin(storageDir)
try {
harness.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { cwd: storageDir, provider: 'deepseek', model: 'dsagent-model' } })
harness.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { cwd: storageDir, provider: 'deepseek-official', model: 'dsagent-model' } })
await harness.waitForFrame(frame => frame.id === 1, 'initialize response')
harness.send({
@@ -236,7 +236,7 @@ describe('dsh-jsonrpc plugin apply', () => {
expect(harness.exits()).toEqual([0])
const before = harness.frames().length
harness.send({ jsonrpc: '2.0', id: 'after-exit', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek', model: 'x' } })
harness.send({ jsonrpc: '2.0', id: 'after-exit', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek-official', model: 'x' } })
await settle()
expect(harness.frames().length).toBe(before)
} finally {
@@ -257,7 +257,7 @@ describe('dsh-jsonrpc plugin apply', () => {
expect(harness.outputErrors.map(error => error.message)).toEqual(['flush callback failed'])
const before = harness.frames().length
harness.send({ jsonrpc: '2.0', id: 'after-flush-failure', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek', model: 'x' } })
harness.send({ jsonrpc: '2.0', id: 'after-flush-failure', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek-official', model: 'x' } })
await settle()
expect(harness.frames().length).toBe(before)
} finally {
@@ -281,7 +281,7 @@ describe('dsh-jsonrpc plugin apply', () => {
await harness.fiber.dispose()
const before = harness.frames().length
harness.send({ jsonrpc: '2.0', id: 'probe-2', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek', model: 'x' } })
harness.send({ jsonrpc: '2.0', id: 'probe-2', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek-official', model: 'x' } })
await settle()
expect(harness.frames().length).toBe(before)
expect(harness.exits()).toEqual([])

View File

@@ -121,7 +121,7 @@ describe('HarnessSdkServer', () => {
const init = await server.handleRequest('initialize', {
cwd: storageDir,
provider: 'deepseek',
provider: 'deepseek-official',
model: 'dsagent-model',
maxTokens: 321,
}) as { serverInfo: { name: string } }
@@ -154,7 +154,7 @@ describe('HarnessSdkServer', () => {
const orphanHandle = await ctx.agents.create({
sessionId: SessionId('orphan-session'),
meta: { cwd: storageDir },
agentOptions: { provider: 'deepseek', model: 'dsagent-model' },
agentOptions: { provider: 'deepseek-official', model: 'dsagent-model' },
})
orphanHandle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'outside the sdk session map' }], source: { kind: 'user' } }))
await orphanHandle.agent.whenIdle()
@@ -352,7 +352,7 @@ describe('HarnessSdkServer', () => {
try {
const server = new HarnessSdkServer(ctx, new FakeTransport())
await server.initialize({ cwd: storageDir, provider: 'deepseek', model: 'plain-model' })
await server.initialize({ cwd: storageDir, provider: 'deepseek-official', model: 'plain-model' })
await server.prompt({
sessionId: 'plain',
contentBlocks: [{ type: 'text', text: 'hello' }],
@@ -376,20 +376,20 @@ describe('HarnessSdkServer', () => {
const parentHandle = await ctx.agents.create({
sessionId: SessionId('main'),
meta: { cwd: storageDir },
agentOptions: { provider: 'deepseek', model: 'deepseek' },
agentOptions: { provider: 'deepseek-official', model: 'deepseek-official' },
})
// A custom in-process provider may own its child at the provider/root
// scope while preserving durable parent lineage.
const handle = await ctx.agents.create({
sessionId: SessionId('child-session'),
meta: { cwd: storageDir, parentSession: SessionId('main') },
agentOptions: { provider: 'deepseek', model: 'deepseek' },
agentOptions: { provider: 'deepseek-official', model: 'deepseek-official' },
})
expect(ctx.agents.roots()).toContain(handle.agent)
const parentlessHandle = await parentHandle.agent.ctx.agents.create({
sessionId: SessionId('parentless-child-session'),
meta: { cwd: storageDir },
agentOptions: { model: 'deepseek' },
agentOptions: { model: 'deepseek-official' },
})
await settleSubagent(ctx, parentHandle.agent, {
provider: 'spawn',
@@ -446,12 +446,12 @@ describe('HarnessSdkServer', () => {
const parentHandle = await ctx.agents.create({
sessionId: SessionId('collision-parent'),
meta: { cwd: storageDir },
agentOptions: { model: 'deepseek' },
agentOptions: { model: 'deepseek-official' },
})
const collidingChild = await parentHandle.agent.ctx.agents.create({
sessionId: SessionId('remote-run-id'),
meta: { cwd: storageDir, parentSession: SessionId('collision-parent') },
agentOptions: { model: 'deepseek' },
agentOptions: { model: 'deepseek-official' },
})
await settleSubagent(ctx, parentHandle.agent, {
@@ -485,12 +485,12 @@ describe('HarnessSdkServer', () => {
const parentHandle = await ctx.agents.create({
sessionId: SessionId('continuation-parent'),
meta: { cwd: storageDir },
agentOptions: { model: 'deepseek' },
agentOptions: { model: 'deepseek-official' },
})
const childHandle = await parentHandle.agent.ctx.agents.create({
sessionId: SessionId('continuation-child'),
meta: { cwd: storageDir, parentSession: SessionId('continuation-parent') },
agentOptions: { model: 'deepseek' },
agentOptions: { model: 'deepseek-official' },
})
await settleSubagent(ctx, parentHandle.agent, {
@@ -530,12 +530,12 @@ describe('HarnessSdkServer', () => {
const oldParent = await ctx.agents.create({
sessionId: SessionId('old-parent'),
meta: { cwd: storageDir },
agentOptions: { model: 'deepseek' },
agentOptions: { model: 'deepseek-official' },
})
const oldChild = await oldParent.agent.ctx.agents.create({
sessionId: SessionId('reused-child'),
meta: { cwd: storageDir, parentSession: SessionId('old-parent') },
agentOptions: { model: 'deepseek' },
agentOptions: { model: 'deepseek-official' },
})
const first = Promise.withResolvers<SubagentResult>()
const sameLifetime = Promise.withResolvers<SubagentResult>()
@@ -571,12 +571,12 @@ describe('HarnessSdkServer', () => {
const newParent = await ctx.agents.create({
sessionId: SessionId('new-parent'),
meta: { cwd: storageDir },
agentOptions: { model: 'deepseek' },
agentOptions: { model: 'deepseek-official' },
})
const newChild = await newParent.agent.ctx.agents.create({
sessionId: SessionId('reused-child'),
meta: { cwd: storageDir, parentSession: SessionId('new-parent') },
agentOptions: { model: 'deepseek' },
agentOptions: { model: 'deepseek-official' },
})
currentLocalAgent = newChild.agent
const secondRun = await ctx.subagents.start('reused', {
@@ -629,12 +629,12 @@ describe('HarnessSdkServer', () => {
const parent = await ctx.agents.create({
sessionId: SessionId('provider-reuse-parent'),
meta: { cwd: storageDir },
agentOptions: { model: 'deepseek' },
agentOptions: { model: 'deepseek-official' },
})
const child = await parent.agent.ctx.agents.create({
sessionId: SessionId('provider-reuse-child'),
meta: { cwd: storageDir, parentSession: SessionId('provider-reuse-parent') },
agentOptions: { model: 'deepseek' },
agentOptions: { model: 'deepseek-official' },
})
const localResult = Promise.withResolvers<SubagentResult>()
const remoteResult = Promise.withResolvers<SubagentResult>()
@@ -722,18 +722,18 @@ describe('HarnessSdkServer', () => {
parentHandle = await ctx.agents.create({
sessionId: SessionId('fallback-parent'),
meta: { cwd: storageDir },
agentOptions: { provider: 'deepseek', model: 'deepseek' },
agentOptions: { provider: 'deepseek-official', model: 'deepseek-official' },
})
handle = await parentHandle.agent.ctx.agents.create({
sessionId: SessionId('fallback-child-session'),
meta: { cwd: storageDir, parentSession: SessionId('fallback-parent') },
agentOptions: { provider: 'deepseek', model: 'deepseek' },
agentOptions: { provider: 'deepseek-official', model: 'deepseek-official' },
})
const fallbackChild = handle.agent
failedHandle = await parentHandle.agent.ctx.agents.create({
sessionId: SessionId('failed-child-session'),
meta: { cwd: storageDir },
agentOptions: { provider: 'deepseek', model: 'deepseek' },
agentOptions: { provider: 'deepseek-official', model: 'deepseek-official' },
})
const missedStartResult = Promise.withResolvers<SubagentResult>()
const disposeMissedStartProvider = ctx.subagents.registerProvider({
@@ -831,11 +831,11 @@ describe('HarnessSdkServer', () => {
const server = new HarnessSdkServer(ctx, new FakeTransport())
const inspect = server as unknown as { hasAdapterFor(provider: string): boolean }
expect(inspect.hasAdapterFor('deepseek')).toBe(true)
expect(inspect.hasAdapterFor('deepseek-official')).toBe(true)
expect(inspect.hasAdapterFor('missing-provider')).toBe(false)
await server.initialize({ cwd: storageDir, provider: 'deepseek', model: 'preinstalled-model' })
await server.initialize({ cwd: storageDir, provider: 'deepseek-official', model: 'preinstalled-model' })
expect(ctx.get('llm')?.listProviders().filter(provider => provider.id === 'deepseek')).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
expect(ctx.get('llm')?.listProviders().filter(provider => provider.id === 'deepseek-official')).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
await server.shutdown()
} finally {
await ctx.fiber.dispose()
@@ -854,7 +854,7 @@ describe('HarnessSdkServer', () => {
await expect(server.initialize({ cwd: storageDir, provider: 'private', model: 'new-model' }))
.rejects.toThrow('no adapter registered for provider "private"')
expect(ctx.get('llm')?.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
expect(ctx.get('llm')?.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
await server.shutdown()
} finally {
await ctx.fiber.dispose()
@@ -871,7 +871,7 @@ describe('HarnessSdkServer', () => {
const server = new HarnessSdkServer(ctx, new FakeTransport())
await expect(server.initialize({
cwd: storageDir,
provider: 'deepseek',
provider: 'deepseek-official',
model: 'model',
maxTokens,
})).rejects.toThrow('initialize maxTokens must be a positive safe integer')

View File

@@ -211,16 +211,19 @@ export class PermissionService extends Service {
name: 'permission',
description: 'Switch the permission preset (sandbox mode + approval policy)',
input: { hint: '<preset>' },
// No settlement text labels its value with this command's own name: a
// surface that renders `name · text` (the web command row) would
// otherwise read `permission · Permission preset: workspace-write.`
handler: ({ agent, rawInput }) => {
const name = rawInput.trim()
if (name === '') {
return { kind: 'success', text: `Current permission preset: ${this.current(agent.session.events)}. Available: ${this.names.join(', ')}.` }
return { kind: 'success', text: `current preset ${this.current(agent.session.events)} (available: ${this.names.join(', ')})` }
}
if (!this.names.includes(name)) {
return { kind: 'error', text: `unknown permission preset "${name}" (available: ${this.names.join(', ')})` }
return { kind: 'error', text: `unknown preset "${name}" (available: ${this.names.join(', ')})` }
}
this.set(agent.session, name)
return { kind: 'success', text: `Permission preset: ${name}.` }
return { kind: 'success', text: `preset ${name}` }
},
})
})

View File

@@ -89,7 +89,7 @@ describe('/permission command', () => {
const { ctx, session } = await harness()
const agent = await agentFor(ctx, session)
const execution = await ctx.commands.execute(agent, '/permission danger-full-access', new AbortController().signal)
expect(execution?.result).toEqual({ kind: 'success', text: 'Permission preset: danger-full-access.' })
expect(execution?.result).toEqual({ kind: 'success', text: 'preset danger-full-access' })
expect(ctx.permission.current(session.events)).toBe('danger-full-access')
const run = session.events.find(event => event.type === 'command/run')
expect(run?.data).toMatchObject({ name: 'permission', args: ' danger-full-access' })
@@ -101,7 +101,7 @@ describe('/permission command', () => {
const execution = await ctx.commands.execute(agent, '/permission', new AbortController().signal)
expect(execution?.result).toEqual({
kind: 'success',
text: 'Current permission preset: workspace-write. Available: workspace-write, danger-full-access.',
text: 'current preset workspace-write (available: workspace-write, danger-full-access)',
})
expect(session.events.filter(event => event.type === 'permission/preset')).toHaveLength(0)
})
@@ -110,7 +110,13 @@ describe('/permission command', () => {
const { ctx, session } = await harness()
const agent = await agentFor(ctx, session)
const execution = await ctx.commands.execute(agent, '/permission yolo', new AbortController().signal)
expect(execution?.result).toMatchObject({ kind: 'error' })
// The error text carries the same no-self-labelling rule as the success
// texts: `permission · unknown preset "yolo" (…)`, not `unknown permission
// preset`, which the row's own title already says.
expect(execution?.result).toEqual({
kind: 'error',
text: 'unknown preset "yolo" (available: workspace-write, danger-full-access)',
})
expect(session.events.filter(event => event.type !== 'command/run' && event.type !== 'command/done')).toHaveLength(0)
})
})

View File

@@ -389,18 +389,27 @@ export class ToolCardComponent implements Component {
const glyph = this.result === undefined ? '○' : '●'
const rawBody = this.renderBody()
const view = this.resultView ?? this.callView
// A search card (grep/glob results) carries no dedicated TUI rendering and no
// result text of its own: it falls back to the same dim Markdown body as a
// generic card, reading the model-facing text from the raw result content.
// Its structured shape is consumed by capable UIs; the TUI stays
// byte-identical to the pre-search-card generic fallback. Terminal and diff
// cards keep their own body branches.
const genericContent = view.card === 'generic'
// A generic card's own content, or a search/web card's fallback to the raw
// result content (neither the `search` nor the `web` view carries a `content`
// copy), all render as one dim Markdown block below, so links/lists/headings
// keep the unified dim styling rather than reading as bare text. A search card
// thus stays byte-identical to the pre-search-card generic fallback. Terminal
// and diff cards own their body styling, so they are excluded (mirrors
// renderBody's post-terminal/diff fallback).
const markdownContent = view.card === 'generic'
? view.content ?? this.result?.content
: view.card === 'search' ? this.result?.content : undefined
const unknownXml = this.definition === undefined && genericContent !== undefined
: view.card === 'search'
? this.result?.content
: view.card === 'web'
// A web resultView is only assigned alongside this.result (the result
// handler sets both) and the pending callView is never a web card, so
// the optional-chain undefined side is unreachable here.
/* v8 ignore next */
? this.result?.content
: undefined
const unknownXml = this.definition === undefined && markdownContent !== undefined
? renderUnknownXml(
displayText(contentText(genericContent)),
displayText(contentText(markdownContent)),
this.maxOutputLines,
this.visibility === 'expanded',
displayText,
@@ -413,7 +422,7 @@ export class ToolCardComponent implements Component {
// A generic card renders title and result as one Markdown document, so the
// document's own block spacing is preserved, then dims every row — the whole
// card body reads as one dim block under the status-colored header.
const body = unknownXml ?? (genericContent !== undefined && rawBody.lines.length > 0
const body = unknownXml ?? (markdownContent !== undefined && rawBody.lines.length > 0
? this.dimBody(rawBody, width)
: [...rawBody.prelude, ...rawBody.lines])
const visibleBody = unknownXml !== undefined || this.visibility === 'expanded'
@@ -510,10 +519,11 @@ export class ToolCardComponent implements Component {
// rather than under the dim result-output color.
return { prelude: [...hunks, footer], lines: [] }
}
// A search card carries no result text of its own; only a generic view
// supplies `content`. Both fall back to the raw result content below.
const viewContent = view.card === 'generic' ? view.content : undefined
const content = viewContent ?? this.result?.content
// Neither a search card nor a web card carries a `content` copy, so those
// result views fall back to the raw result content here (`view.card ===
// 'generic'` narrows the generic union arm; a search or web card takes the
// same fallback, mirroring the `markdownContent` selection in render()).
const content = (view.card === 'generic' ? view.content : undefined) ?? this.result?.content
const prelude: string[] = []
const lines: string[] = []
// The presenter title headlines the body now that the header is a fixed

View File

@@ -105,10 +105,10 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
await ctx.plugin(UserInteractionService)
await ctx.plugin(TuiPromptService)
const catalog = options.catalog ?? {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
providers: [{ id: 'deepseek-official', name: 'DeepSeek' }],
models: [
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' },
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro' },
{ provider: 'deepseek-official', id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' },
{ provider: 'deepseek-official', id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro' },
],
}
ctx.provide('tokenMeter', {
@@ -194,7 +194,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
const cancelled: AgentCancelCause[] = []
const agent: FakeAgent = {
id: sessionId,
options: options.agentOptions ?? { provider: 'deepseek', model: 'deepseek-v4-flash' },
options: options.agentOptions ?? { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
session,
status: options.status ?? 'idle',
get acceptsNextStep() {

View File

@@ -152,8 +152,8 @@ describe('TUI prompt values', () => {
describe('TUI prompt templates', () => {
it('interpolates values and removes separators around unavailable values', () => {
const tokens = parseTuiPromptTemplate('${cwd} ${git/worktree} :: ${missing} ${model}')
const values = new Map([['cwd', '/work'], ['model', 'deepseek']])
expect(renderTuiPromptTemplate(tokens, name => values.get(name))).toBe('/work :: deepseek')
const values = new Map([['cwd', '/work'], ['model', 'deepseek-official']])
expect(renderTuiPromptTemplate(tokens, name => values.get(name))).toBe('/work :: deepseek-official')
})
it('keeps a trailing literal after the last value', () => {

View File

@@ -36,9 +36,9 @@ buffer
14| " │ │ "
style 8-8 fg=bright-magenta
style 83-83 fg=bright-magenta
15| " │ → deepseek/deepseek-v4-pro DeepSeek V4 Pro │ "
15| " │ → deepseek-official/deepseek-v4- DeepSeek V4 Pro │ "
style 8-8 fg=bright-magenta
style 10-58 fg=bright-magenta inverse
style 10-41 fg=bright-magenta inverse
style 83-83 fg=bright-magenta
16| " │ │ "
style 8-8 fg=bright-magenta

View File

@@ -36,13 +36,13 @@ buffer
14| " │ │ "
style 8-8 fg=bright-magenta
style 83-83 fg=bright-magenta
15| " │ → deepseek/deepseek-v4-flash DeepSeek V4 Flash — current │ "
15| " │ → deepseek-official/deepseek-v4- DeepSeek V4 Flash — current │ "
style 8-8 fg=bright-magenta
style 10-70 fg=bright-magenta inverse
style 10-41 fg=bright-magenta inverse
style 83-83 fg=bright-magenta
16| " │ deepseek/deepseek-v4-pro DeepSeek V4 Pro │ "
16| " │ deepseek-official/deepseek-v4- DeepSeek V4 Pro │ "
style 8-8 fg=bright-magenta
style 36-58 dim
style 42-58 dim
style 83-83 fg=bright-magenta
17| " │ │ "
style 8-8 fg=bright-magenta

View File

@@ -16,8 +16,8 @@ buffer
5| "Model wait 0.0s "
style 0-14 dim
6| <blank>
7| "Model selected: deepseek/deepseek-v4-pro. New steps will use it. "
style 0-63 dim
7| "Model selected: deepseek-official/deepseek-v4-pro. New steps will use it. "
style 0-72 dim
8| <blank>
9| "/workspace/project (tui-staging) deepseek-v4-pro ↑0 ↓0 0% context"
style 0-17 fg=bright-magenta bold

View File

@@ -31,15 +31,15 @@ buffer
13| " unavailable: current session "
style 2-31 fg=yellow
14| " Other workspace work "
15| " 2024-02-02T00:00:08.000Z · turn 1: completed · deepseek/deepseek-v4-pro "
style 2-74 dim
15| " 2024-02-02T00:00:08.000Z · turn 1: completed · deepseek-official/deepseek-v4-pro "
style 2-83 dim
16| " persisted · elsewhere-session "
style 2-32 dim
17| " workspace /workspace/other "
style 2-29 dim
18| " Resume selector design "
19| " 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek/deepseek-v4-pro "
style 2-74 dim
19| " 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek-official/deepseek-v4-pro "
style 2-83 dim
20| " persisted · earlier-session "
style 2-30 dim
21| " workspace /workspace/project "

View File

@@ -29,8 +29,8 @@ buffer
12| " unavailable: current session "
style 2-31 fg=yellow
13| " Resume selector design "
14| " 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek/deepseek-v4-pro "
style 2-74 dim
14| " 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek-official/deepseek-v4-pro "
style 2-83 dim
15| " persisted · earlier-session "
style 2-30 dim
16| " "

View File

@@ -1,7 +1,7 @@
terminal 56x36 buffer=normal length=44 base=8 viewport=8
terminal 56x36 buffer=normal length=45 base=9 viewport=9
lifecycle started=1 stopped=0 progress=inactive
title "Inspect session diagnostics — DSH snapshot"
cursor hidden column=7 viewportRow=35 bufferRow=43
cursor hidden column=7 viewportRow=35 bufferRow=44
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-magenta bold
@@ -37,84 +37,87 @@ buffer
style 0-0 dim
style 3-12 dim
style 55-55 dim
15| "│ Model: deepseek/deepseek-v4-pro (effort │"
style 0-0 dim
style 3-12 dim
style 40-55 dim
16| "│ default; reasoning blocks shown) │"
style 0-0 dim
style 15-46 dim
style 55-55 dim
17| "│ │"
style 0-0 dim
style 55-55 dim
18| "│ Agent: idle · 8 events · 1 turn · 1 step · 1 │"
15| "│ Model: deepseek-official/deepseek-v4-pro │"
style 0-0 dim
style 3-12 dim
style 55-55 dim
19| "│ tool call │"
16| "│ (effort default; reasoning blocks │"
style 0-0 dim
style 15-55 dim
17| "│ shown) │"
style 0-0 dim
style 15-20 dim
style 55-55 dim
18| "│ │"
style 0-0 dim
style 55-55 dim
20| "│ │"
style 0-0 dim
style 55-55 dim
21| "│ Tokens: 1,250 input + 340 output │"
19| "│ Agent: idle · 8 events · 1 turn · 1 step · 1 │"
style 0-0 dim
style 3-12 dim
style 55-55 dim
22| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read │"
20| "│ tool call │"
style 0-0 dim
style 55-55 dim
21| "│ │"
style 0-0 dim
style 55-55 dim
22| "│ Tokens: 1,250 input + 340 output │"
style 0-0 dim
style 3-12 dim
style 55-55 dim
23| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read │"
style 0-0 dim
style 3-12 dim
style 15-15 dim
style 16-26 fg=bright-magenta
style 27-32 dim
style 55-55 dim
23| "│ + 250 write) │"
24| "│ + 250 write) │"
style 0-0 dim
style 55-55 dim
24| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / │"
25| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / │"
style 0-0 dim
style 3-12 dim
style 15-15 dim
style 16-20 fg=bright-magenta
style 21-32 dim
style 55-55 dim
25| "│ 128,000) │"
26| "│ 128,000) │"
style 0-0 dim
style 55-55 dim
26| "│ │"
27| "│ │"
style 0-0 dim
style 55-55 dim
27| "│ Created: 2026-07-22 09:10:11 UTC │"
28| "│ Created: 2026-07-22 09:10:11 UTC │"
style 0-0 dim
style 3-12 dim
style 55-55 dim
28| "│ Active: 2026-07-22 09:10:11 UTC │"
29| "│ Active: 2026-07-22 09:10:11 UTC │"
style 0-0 dim
style 3-12 dim
style 55-55 dim
29| "╰──────────────────────────────────────────────────────╯"
30| "╰──────────────────────────────────────────────────────╯"
style 0-55 dim
30| <blank>
31| "System prompt "
31| <blank>
32| "System prompt "
style 0-12 fg=bright-magenta bold
32| "You are an AI agent powered by the DeepSeek Harness SDK."
33| " "
34| "Paths prefixed with @ are files explicitly referenced by"
35| "the user. Use the read tool when their contents are "
36| "needed; do not claim to have inspected a file before "
37| "reading it. "
38| <blank>
39| "Registered tools "
33| "You are an AI agent powered by the DeepSeek Harness SDK."
34| " "
35| "Paths prefixed with @ are files explicitly referenced by"
36| "the user. Use the read tool when their contents are "
37| "needed; do not claim to have inspected a file before "
38| "reading it. "
39| <blank>
40| "Registered tools "
style 0-15 fg=bright-magenta bold
40| "read, write "
41| <blank>
42| "/workspace/project (tui-staging) deepseek-v4-pro ↑1.3k"
41| "read, write "
42| <blank>
43| "/workspace/project (tui-staging) deepseek-v4-pro ↑1.3k"
style 0-17 fg=bright-magenta bold
style 18-31 dim
style 34-48 dim
style 51-55 dim
43| " dsh > "
44| " dsh > "
style 1-3 fg=bright-magenta bold
style 5-6 dim
style 7-7 inverse

View File

@@ -21,68 +21,68 @@ buffer
style 0-2 fg=bright-magenta bold underline
9| "inspect this session "
10| <blank>
11| "╭─ Session status ───────────────────────────────────────────────────────────────╮"
11| "╭─ Session status ────────────────────────────────────────────────────────────────────────╮"
style 0-2 dim
style 3-16 fg=bright-magenta bold
style 17-81 dim
12| "│ Session: main-session │"
style 17-90 dim
12| "│ Session: main-session │"
style 0-0 dim
style 3-12 dim
style 81-81 dim
13| "│ Title: Inspect session diagnostics │"
style 90-90 dim
13| "│ Title: Inspect session diagnostics │"
style 0-0 dim
style 3-12 dim
style 81-81 dim
14| "│ Directory: /workspace/project │"
style 90-90 dim
14| "│ Directory: /workspace/project │"
style 0-0 dim
style 3-12 dim
style 81-81 dim
15| "│ Model: deepseek/deepseek-v4-pro (effort default; reasoning blocks shown) │"
style 90-90 dim
15| "│ Model: deepseek-official/deepseek-v4-pro (effort default; reasoning blocks shown) │"
style 0-0 dim
style 3-12 dim
style 40-79 dim
style 81-81 dim
16| "│ │"
style 49-88 dim
style 90-90 dim
16| "│ │"
style 0-0 dim
style 81-81 dim
17| "│ Agent: idle · 8 events · 1 turn · 1 step · 1 tool call │"
style 90-90 dim
17| "│ Agent: idle · 8 events · 1 turn · 1 step · 1 tool call │"
style 0-0 dim
style 3-12 dim
style 81-81 dim
18| "│ │"
style 90-90 dim
18| "│ │"
style 0-0 dim
style 81-81 dim
19| "│ Tokens: 1,250 input + 340 output │"
style 90-90 dim
19| "│ Tokens: 1,250 input + 340 output │"
style 0-0 dim
style 3-12 dim
style 81-81 dim
20| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read + 250 write) │"
style 90-90 dim
20| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read + 250 write) │"
style 0-0 dim
style 3-12 dim
style 15-15 dim
style 16-26 fg=bright-magenta
style 27-32 dim
style 81-81 dim
21| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / 128,000) │"
style 90-90 dim
21| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / 128,000) │"
style 0-0 dim
style 3-12 dim
style 15-15 dim
style 16-20 fg=bright-magenta
style 21-32 dim
style 81-81 dim
22| "│ │"
style 90-90 dim
22| "│ │"
style 0-0 dim
style 81-81 dim
23| "│ Created: 2026-07-22 09:10:11 UTC │"
style 90-90 dim
23| "│ Created: 2026-07-22 09:10:11 UTC │"
style 0-0 dim
style 3-12 dim
style 81-81 dim
24| "│ Active: 2026-07-22 09:10:11 UTC │"
style 90-90 dim
24| "│ Active: 2026-07-22 09:10:11 UTC │"
style 0-0 dim
style 3-12 dim
style 81-81 dim
25| "╰────────────────────────────────────────────────────────────────────────────────╯"
style 0-81 dim
style 90-90 dim
25| "╰─────────────────────────────────────────────────────────────────────────────────────────╯"
style 0-90 dim
26| <blank>
27| "System prompt "
style 0-12 fg=bright-magenta bold

View File

@@ -554,7 +554,7 @@ describe('TUI terminal-state snapshots', () => {
description: 'Audit terminal states from independent angles',
phases: [
{ title: 'Inspect', detail: 'Map renderer branches' },
{ title: 'Verify', detail: 'Challenge missing states', provider: 'deepseek', model: 'deepseek-v4-flash' },
{ title: 'Verify', detail: 'Challenge missing states', provider: 'deepseek-official', model: 'deepseek-v4-flash' },
],
},
args: { packages: ['ui/tui', 'workflow/tool-workflow'] },
@@ -866,13 +866,13 @@ describe('TUI terminal-state snapshots', () => {
{ type: 'turn/start', seq: 0, time: Date.parse(`${day}T00:00:01Z`), data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'user/message', seq: 1, time: Date.parse(`${day}T00:00:02Z`), data: createUserMessage({ content: [{ type: 'text', text: 'restore the selector' }], source: { kind: 'user' } }), surfaceOp: 'append' },
{ type: 'step/start', seq: 2, time: Date.parse(`${day}T00:00:03Z`), data: { turn: 1, step: 1 } },
{ type: 'request/header', seq: 3, time: Date.parse(`${day}T00:00:04Z`), data: { header: { config: { provider: 'deepseek', model: 'deepseek-v4-pro' } }, reason: 'initial' } },
{ type: 'request/header', seq: 3, time: Date.parse(`${day}T00:00:04Z`), data: { header: { config: { provider: 'deepseek-official', model: 'deepseek-v4-pro' } }, reason: 'initial' } },
{ type: 'assistant/message', seq: 4, time: Date.parse(`${day}T00:00:05Z`), data: {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'ready' }],
source: { kind: 'model', provider: 'deepseek', model: 'deepseek-v4-pro' },
source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-pro' },
}),
}, surfaceOp: 'append' },
{ type: 'step/end', seq: 5, time: Date.parse(`${day}T00:00:06Z`), data: { turn: 1, step: 1 } },
@@ -913,7 +913,7 @@ describe('TUI terminal-state snapshots', () => {
const harness = await setupSnapshot({
contextWindow: 128_000,
contextTokens: 42_000,
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-pro' },
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-pro' },
tools: {
read: {
name: 'read',

View File

@@ -247,7 +247,7 @@ describe('goodbye message and /resume', () => {
({ version: 0, id: SessionId(id), createdAt, cwd })
const resumeEvents = (
title: string,
provider = 'deepseek',
provider = 'deepseek-official',
time = 100,
reason: TurnEndReason = { kind: 'completed' },
): SessionEvent[] => [
@@ -319,8 +319,8 @@ describe('goodbye message and /resume', () => {
sessionPersistence: {
list: async () => [older, newer, header('foreign-session', 3000, '/elsewhere')],
load: async id => id === newer.id
? { meta: newer, events: resumeEvents('Newer product work', 'deepseek', 300) }
: { meta: older, events: resumeEvents('Older investigation', 'deepseek', 100) },
? { meta: newer, events: resumeEvents('Newer product work', 'deepseek-official', 300) }
: { meta: older, events: resumeEvents('Older investigation', 'deepseek-official', 100) },
},
})
result.terminal.send('/resume')
@@ -419,7 +419,7 @@ describe('goodbye message and /resume', () => {
list: async () => targets,
load: async id => ({
meta: targets.find(target => target.id === id)!,
events: resumeEvents(`Paged ${id.slice('paged-'.length)}`, 'deepseek', 1000 - Number(id.slice('paged-'.length)) * 10),
events: resumeEvents(`Paged ${id.slice('paged-'.length)}`, 'deepseek-official', 1000 - Number(id.slice('paged-'.length)) * 10),
}),
},
})
@@ -475,7 +475,7 @@ describe('goodbye message and /resume', () => {
cwd: '/workspace',
sessionPersistence: {
list: async () => [target],
load: async () => ({ meta: target, events: resumeEvents(`Turn ${label}`, 'deepseek', 100, reason) }),
load: async () => ({ meta: target, events: resumeEvents(`Turn ${label}`, 'deepseek-official', 100, reason) }),
},
})
result.terminal.send('/resume')
@@ -711,7 +711,7 @@ describe('goodbye message and /resume', () => {
it('falls back to assistant provenance and header creation time for sparse logs', async () => {
const assistantOnly = header('assistant-route', 20, '/workspace')
const empty = header('empty-log', 10, '/workspace')
const events = resumeEvents('Assistant route', 'deepseek')
const events = resumeEvents('Assistant route', 'deepseek-official')
.filter(event => event.type !== 'request/header')
.map((event, seq) => ({ ...event, seq })) as SessionEvent[]
const result = await setup({
@@ -726,7 +726,7 @@ describe('goodbye message and /resume', () => {
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
expect(result.terminal.output).toContain('deepseek/model-1')
expect(result.terminal.output).toContain('deepseek-official/model-1')
expect(result.terminal.output).toContain(new Date(empty.createdAt).toISOString())
await dispose(result)
})
@@ -2397,7 +2397,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
contextWindow: 128_000,
contextTokens: 42_000,
config: { showReasoning: false },
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-pro' },
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-pro' },
tools: {
read: {
name: 'read', description: 'Read a file', parameters: {},
@@ -2445,7 +2445,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('main-session')
expect(result.terminal.output).toContain('Inspect status \\x1b]2;unsafe\\x07')
expect(result.terminal.output).toContain('/workspace/status')
expect(result.terminal.output).toContain('deepseek/deepseek-v4-pro (effort default; reasoning blocks')
expect(result.terminal.output).toContain('deepseek-official/deepseek-v4-pro (effort default; reasoning blocks')
expect(result.terminal.output).toContain('hidden)')
// 6 domain events + the /status invocation's own command/run (open turn: joined directly).
expect(result.terminal.output).toContain('running · 7 events · 1 turn · 1 step · 2 tool calls')
@@ -3601,7 +3601,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
const failed = await setup({
catalog: {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
providers: [{ id: 'deepseek-official', name: 'DeepSeek' }],
models: [],
listModels: () => Promise.reject(new Error('catalog offline')),
resolveModelInfo: () => Promise.reject(new Error('capacity offline')),
@@ -3617,8 +3617,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
const reasoningFailed = await setup({
catalog: {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
models: [{ provider: 'deepseek', id: 'model-1', name: 'Model One' }],
providers: [{ id: 'deepseek-official', name: 'DeepSeek' }],
models: [{ provider: 'deepseek-official', id: 'model-1', name: 'Model One' }],
resolveModelInfo: () => Promise.reject(new Error('reasoning metadata offline')),
},
})
@@ -3634,7 +3634,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
const deferred = Promise.withResolvers<never[]>()
const result = await setup({
catalog: {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
providers: [{ id: 'deepseek-official', name: 'DeepSeek' }],
models: [],
listModels: () => deferred.promise,
},
@@ -3650,7 +3650,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
const rejected = Promise.withResolvers<never[]>()
const rejectedResult = await setup({
catalog: {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
providers: [{ id: 'deepseek-official', name: 'DeepSeek' }],
models: [],
listModels: () => rejected.promise,
},
@@ -3667,7 +3667,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
const contextResult = await setup({
contextTokens: 99,
catalog: {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
providers: [{ id: 'deepseek-official', name: 'DeepSeek' }],
models: [],
resolveModelInfo: () => context.promise.then(value => ({ context: value })),
},
@@ -4390,6 +4390,14 @@ describe('tool cards and surface replay', () => {
name: 'knownXml', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'Known XML' }),
},
// A web card carries no `content` copy, so it falls back to the raw result
// content, which must still render through the dim Markdown path (bold
// markers stripped) rather than as bare text.
webCard: {
name: 'webCard', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'Fetch page', kind: 'fetch' }),
presentResult: () => ({ card: 'web', kind: 'fetch', title: 'https://a.test', url: 'https://a.test', statusCode: 200, truncated: false }),
},
}
it('uses terminal, diff, generic, fallback, and collapsed tool presentations', async () => {
@@ -4410,7 +4418,8 @@ describe('tool cards and surface replay', () => {
['c11', 'terminalResult', '{}'],
['c12', 'symbolic', '{}'],
['c13', 'knownXml', '{}'],
['c16', 'search', '{"pattern":"todo"}'],
['c16', 'webCard', '{}'],
['c17', 'search', '{"pattern":"todo"}'],
] as const
appendAssistant(result.session, [
{ type: 'text', text: 'Calling tools' },
@@ -4508,6 +4517,14 @@ describe('tool cards and surface replay', () => {
turn: 1, step: 1,
message: createToolResultMessage({
callId: 'c16' as never,
content: [{ type: 'text', text: 'Fetched **body** text' }],
isError: false,
}),
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: 'c17' as never,
content: [{ type: 'text', text: 'Found 1 match\n\na.ts\nLine 1: todo one' }],
isError: false,
}),
@@ -4566,6 +4583,11 @@ describe('tool cards and surface replay', () => {
expect(output).toContain('Empty card')
expect(output).toContain('converted terminal')
expect(output).toContain('<known><value>literal</value></known>')
// A web card carries no `content` copy, so it falls back to the raw result
// content, which still renders through the dim Markdown path: the bold
// markers are stripped rather than shown literally.
expect(output).toContain('Fetched body text')
expect(output).not.toContain('Fetched **body** text')
expect(output).toContain('path: /tmp/a.txt')
expect(output).toContain('line (number="1"): hello')
expect(output).not.toContain('<result>')

View File

@@ -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-interaction/README.md
README.md: d234d6677bdd772f1bbd2c979c0d41f90aef5c32
README.zh.md: c89210b6955a661313ca9e0e82e43da5a4d1db79
README.md: d62e75d110b8be339c5f9449b0834320f695ac99
README.zh.md: 55258e85e56df2375ed8f195fa0b3b731a9cb816

View File

@@ -13,14 +13,19 @@ Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a mod
### Key Types
- `AskUserQuestionRequest``{ questions: [{ id, question, detail?, header?, options?, multiSelect? }], agent?, signal? }`; `detail` supplies supporting text that providers render with the question without turning it into an option label.
- `AskUserQuestionRequest``{ questions: [{ id, question, detail?, header?, options?, multiSelect?, intent? }], agent?, signal? }`; `detail` supplies supporting text that providers render with the question without turning it into an option label.
- `AskUserQuestionOption``{ label, description? }`.
- `AskUserQuestionIntent``{ kind: 'plan-review', approve }`; the tagged presentation intent below.
- `AskUserQuestionAnswer``{ answers: [{ id, selected, custom? }] }`.
- `UserInteractionProvider` — UI implementation with `ask(request)`.
- `UserInteractionError``HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`.
- `UserInteractionError``HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `BAD_INTENT`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`.
When an answer includes `custom`, `selected` is empty; custom text is an override rather than a supplement to selected choices. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch.
### Presentation intent
`intent` declares that a question IS a decision of a known shape, so a UI that recognises the tag may present it as such — `plan-review` says `detail` is a plan under review, and `dsh-plan-mode` sets it on the `exit_plan_mode` question. An intent shapes presentation only: a UI honouring it answers with the same option labels a generic UI would send, and a UI that does not know the tag renders the generic option list, so callers read one answer shape either way. `approve` names the label that approves rather than relying on option order. `ask()` rejects with `BAD_INTENT` the two assertions no type can carry: an `approve` naming none of that question's own options, and an intent on a question with no `detail` — the thing it declares itself a review of.
## Role
This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; `dsh-tui` and the host runtime provide interactive implementations. The loop stays unchanged: a tool call awaits a promise, and the tool result resumes the normal agent loop.

View File

@@ -13,14 +13,19 @@
### 关键类型
- `AskUserQuestionRequest``{ questions: [{ id, question, detail?, header?, options?, multiSelect? }], agent?, signal? }``detail` 提供辅助文本,提供方会将其随问题一起渲染,而不会将其变成选项标签。
- `AskUserQuestionRequest``{ questions: [{ id, question, detail?, header?, options?, multiSelect?, intent? }], agent?, signal? }``detail` 提供辅助文本,提供方会将其随问题一起渲染,而不会将其变成选项标签。
- `AskUserQuestionOption``{ label, description? }`
- `AskUserQuestionIntent``{ kind: 'plan-review', approve }`;即下文的带标签呈现意图。
- `AskUserQuestionAnswer``{ answers: [{ id, selected, custom? }] }`
- `UserInteractionProvider`:包含 `ask(request)` 的 UI 实现。
- `UserInteractionError``HarnessError` 的子类,包含 `EMPTY_QUESTIONS``NO_PROVIDER``DUPLICATE_PROVIDER``ASK_ABORTED` 等代码。
- `UserInteractionError``HarnessError` 的子类,包含 `EMPTY_QUESTIONS``BAD_INTENT``NO_PROVIDER``DUPLICATE_PROVIDER``ASK_ABORTED` 等代码。
当回答包含 `custom` 时,`selected` 为空自定义文本是所选选项的替代而不是补充。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。
### 呈现意图
`intent` 声明某个问题本身就是一次已知形状的决定,因此认识该标签的 UI 可以照此呈现 —— `plan-review` 表示 `detail` 是一份待审阅的计划,`dsh-plan-mode` 会在 `exit_plan_mode` 的问题上设置它。意图只塑造呈现:遵循它的 UI 回答的仍是通用 UI 会发送的那些选项标签,不认识该标签的 UI 渲染通用选项列表,因此调用方两种情况下读到的都是同一种回答形态。`approve` 指名表示批准的标签,而不依赖选项顺序。有两项断言是任何类型都承载不了的,`ask()` 会以 `BAD_INTENT` 拒绝它们:`approve` 未命中该问题自身的任一选项,以及意图落在没有 `detail` 的问题上 —— 而 `detail` 正是它自称在审阅的东西。
## 职责
这是接口包package`@deepseek-ai/dsh-tool-ask-user` 等面向模型的消费方依赖此 seam`dsh-tui` 和宿主运行时提供交互式实现。循环保持不变:工具调用等待 Promise工具结果随后恢复正常的 agent loop智能体循环

View File

@@ -20,7 +20,8 @@ declare module 'cordis' {
import type { AskUserQuestionAnswer, AskUserQuestionItem } from './types.ts'
export type {
AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionItem, AskUserQuestionOption,
AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionIntent, AskUserQuestionItem,
AskUserQuestionOption,
} from './types.ts'
/** Request for a human answer. */
@@ -86,6 +87,28 @@ export class UserInteractionService extends Service {
if (request.questions.length === 0) {
throw new UserInteractionError('ask_user_question requires at least one question', 'EMPTY_QUESTIONS')
}
// A presentation intent asserts two things the types cannot: that the
// named approve label is one of this question's own options, and that a
// plan-review carries the plan it is a review of. A UI honouring the
// intent answers with that label, and shows that detail as the plan, so
// either gap would put a choice the asker never offered — or an approval of
// something invisible — in front of the user. Caught at the asker, where
// the mistake is, rather than in each UI.
for (const question of request.questions) {
const intent = question.intent
if (intent === undefined) continue
if (!(question.options ?? []).some(option => option.label === intent.approve)) {
throw new UserInteractionError(
`question ${question.id} declares intent ${intent.kind} whose approve label `
+ `${JSON.stringify(intent.approve)} names none of its options`,
'BAD_INTENT')
}
if (question.detail === undefined) {
throw new UserInteractionError(
`question ${question.id} declares intent ${intent.kind} without the detail it reviews`,
'BAD_INTENT')
}
}
if (this.provider === undefined) {
throw new UserInteractionError('no user-interaction provider is registered', 'NO_PROVIDER')
}

View File

@@ -13,6 +13,24 @@ export interface AskUserQuestionOption {
description?: string
}
/**
* A caller-declared presentation intent: the question IS a decision of this
* shape, so a UI that recognises the tag may present it as such instead of as a
* generic option list. Tagged so further intents can be added; a UI that does
* not know a tag renders the generic flow, and the answer encoding is identical
* either way — an intent shapes presentation only, never the protocol.
*/
export type AskUserQuestionIntent = {
/** A plan submitted for review: `detail` is the plan markdown `ask()` requires, and the decision approves or declines it. */
kind: 'plan-review'
/**
* The option label that approves the plan; every other option declines it.
* Named rather than positional so no UI infers the verdict from option order.
* An `approve` naming no option of its own question is rejected at `ask()`.
*/
approve: string
}
/** One question in a user-interaction request. */
export interface AskUserQuestionItem {
/** Stable caller-provided question id, echoed in the answer. */
@@ -27,6 +45,8 @@ export interface AskUserQuestionItem {
options?: AskUserQuestionOption[]
/** Whether more than one option may be selected. Defaults to single-select. */
multiSelect?: boolean
/** Optional presentation intent for capable UIs; absent asks for the generic option list. */
intent?: AskUserQuestionIntent
}
/** Answer to one question. */

View File

@@ -83,4 +83,63 @@ describe('UserInteractionService', () => {
.rejects.toMatchObject({ name: 'UserInteractionError', code: 'EMPTY_QUESTIONS' })
expect(p.ask).not.toHaveBeenCalled()
})
it('rejects an intent whose approve label names none of its own options', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
const p = { ask: vi.fn(async () => ({ answers: [] })) }
ctx.userInteraction.registerProvider(p)
const question = { id: 'plan-review', question: 'Approve?', detail: '# Plan' }
// A wrong label among offered options, and no options offered at all.
for (const options of [[{ label: 'Approve' }], undefined]) {
await expect(ctx.userInteraction.ask({
questions: [{
...question,
...(options === undefined ? {} : { options }),
intent: { kind: 'plan-review', approve: 'Ship it' },
}],
})).rejects.toMatchObject({ name: 'UserInteractionError', code: 'BAD_INTENT' })
}
expect(p.ask).not.toHaveBeenCalled()
})
it('rejects a plan-review intent on a question carrying no plan to review', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
const p = { ask: vi.fn(async () => ({ answers: [] })) }
ctx.userInteraction.registerProvider(p)
// Detail IS the plan for this intent, so a UI honouring it would ask the
// user to approve something they cannot see.
await expect(ctx.userInteraction.ask({
questions: [{
id: 'plan-review', question: 'Approve?',
options: [{ label: 'Approve' }, { label: 'Keep planning' }],
intent: { kind: 'plan-review', approve: 'Approve' },
}],
})).rejects.toMatchObject({ name: 'UserInteractionError', code: 'BAD_INTENT' })
expect(p.ask).not.toHaveBeenCalled()
})
it('passes an intent through once its approve label names an offered option', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
const p = provider('Approve')
ctx.userInteraction.registerProvider(p)
const intent = { kind: 'plan-review', approve: 'Approve' } as const
const result = await ctx.userInteraction.ask({
questions: [
{ id: 'plain', question: 'Proceed?' },
{
id: 'plan-review', question: 'Approve?', detail: '# Plan',
options: [{ label: 'Approve' }, { label: 'Keep planning' }], intent,
},
],
})
expect(result.answers).toEqual([{ id: 'plain', selected: ['Approve'] }])
expect(p.seen[0]?.questions[1]?.intent).toEqual(intent)
})
})