Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md
#	.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md
#	.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md
#	.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md
#	.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md
#	.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md
#	docs/architecture.i18n.yaml
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.i18n.yaml
#	docs/core-data-structures/core.md
#	docs/core-data-structures/core.zh.md
#	docs/defensive-patterns.i18n.yaml
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/runtime/tests/queue-store.spec.ts
#	packages/context/time-context/tests/time-context.spec.ts
#	packages/context/workspace-context/tests/workspace-context.spec.ts
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/agent-loop/README.i18n.yaml
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/README.zh.md
#	packages/core/agent-loop/src/agent.ts
#	packages/core/agent/README.i18n.yaml
#	packages/core/agent/README.md
#	packages/core/agent/README.zh.md
#	packages/core/agent/src/types.ts
#	packages/core/agent/tests/agent.spec.ts
#	packages/core/scope/src/scoped-events.generated.ts
#	packages/goal/command-goal/tests/command-goal.spec.ts
#	packages/goal/goal-session/src/index.ts
#	packages/goal/goal-session/tests/goal-session.spec.ts
#	packages/goal/goal/tests/goal.spec.ts
#	packages/goal/goal/tests/projection.spec.ts
#	packages/goal/tool-goal/tests/tool-goal.spec.ts
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/events.schema.ts
#	packages/host/apiproxy/src/api/events.ts
#	packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
#	packages/llm/llm/README.i18n.yaml
#	packages/llm/llm/README.zh.md
#	packages/llm/llm/src/index.ts
#	packages/pty/pty-local/tests/index.spec.ts
#	packages/pty/pty-local/tests/local.spec.ts
#	packages/pty/pty/tests/service.spec.ts
#	packages/pty/tool-pty/tests/loader-composition.spec.ts
#	packages/pty/tool-pty/tests/tools.spec.ts
#	packages/skill/tool-skill/tests/tool-skill.spec.ts
#	packages/tasks/tasks-local/tests/tasks.spec.ts
#	packages/ui/tui/src/index.ts
#	packages/ui/tui/tests/harness.ts
#	packages/ui/tui/tests/tui.spec.ts
#	scripts/gen-cordis-catalog.ts
#	scripts/type-equiv.manifest.json
This commit is contained in:
_Kerman
2026-07-30 14:04:53 +08:00
1175 changed files with 49683 additions and 8452 deletions

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
# pnpm run verify-translation-pairing --write packages/ui/README.md
README.md: f08157d411a018141cdc21c487f81ae198f4de56
README.zh.md: d321ee9a181eb27ccecb143d3733ee46e417b27f
README.zh.md: ed4fbf576224a61e680fca337ac5e60829f8a90e

View File

@@ -2,21 +2,21 @@
[English](README.md) | 中文
面向用户的交互通道和进程外 SDK 服务器。这些是**产品** package由用户或 SDK 客户端直接操作的真实接口。
面向用户的交互通道和进程外 SDK 服务器。这些是**产品**包package由用户或 SDK 客户端直接操作的真实接口。
| 包 | 职责 | ctx 键 |
|---|---|---|
| `commands/` | 用户命令注册表:共享发现元数据、作用域遮蔽、取消以及 UI 直接分派 | `ctx.commands` |
| `user-approval/` | 一次性用户审批机制、封闭的结果词汇、审计事件和逐会话审批策略 | `ctx.approval` |
| `permission/` | 面向用户的权限预设(`workspace-write`/`danger-full-access`用一个产品级选择组合沙箱模式与审批策略两个调节项,并写入各自的会话事件 | `ctx.permission` |
| `permission/` | 面向用户的权限预设(`workspace-write`/`danger-full-access`通过一项产品级选择组合沙箱模式与审批策略两个可调参数,并写入各自的会话事件 | `ctx.permission` |
| `user-interaction/` | UI 支持的确认工具所使用的抽象用户问答 seam | `ctx.userInteraction` |
| `tool-ask-user/` | 模型侧 `ask_user_question` 工具,基于 `ctx.userInteraction` 实现 | (注册到 `ctx.tools` |
| `tui/` | 交互式 pi-tui 终端通道:渲染会话标题、事件和工具意图,响应 `ctx.userInteraction`,并托管由 effect 持有的插件浮层 | `ctx.tui`(驱动 `ctx.agents` |
| `jsonrpc/` | 面向进程外 SDK 客户端的 stdio JSON-RPC 服务器 | (驱动 `ctx.agents` |
| `app-boot/` | app bin 的共享启动粘合层:加载 `.env`、Loader 快速失败保护、感知快照的配置解析,以及等待整棵树停稳的启动序列 | (供各 bin 使用的库) |
| `app-boot/` | app bin 的共享启动粘合层:加载 `.env`会明确报错的 Loader 保护机制、感知快照的配置解析,以及等待整棵树停稳的启动序列 | (供各 bin 使用的库) |
UI 集成属于客户端驱动插件,而非对循环的修改:它使用现有的 `agent/*` 事件分类和 `dsh-agent` 工厂。[`tui`](tui/README.md) 是交互式终端入口,并提供终端本地的 `ctx.tui` 扩展服务;[`jsonrpc`](jsonrpc/README.md) 为进程外 SDK 客户端提供服务,非交互式单次任务使用 `cli-demo`。[`commands`](commands/README.md) 是 TUI 使用的用户发现与分派通道;命令输入和输出不会成为模型消息。
UI 集成属于客户端驱动插件,而非对循环的修改:它使用现有的 `agent/*` 事件分类和 `dsh-agent` 工厂。[`tui`](tui/README.md) 是交互式终端入口,并提供终端本地的 `ctx.tui` 扩展服务;[`jsonrpc`](jsonrpc/README.md) 为进程外 SDK 客户端提供服务,非交互式的一次性任务使用 `cli-demo`。[`commands`](commands/README.md) 是 TUI 使用的仅面向用户发现与分派通道;命令输入和输出不会成为模型消息。
`user-approval``user-interaction``tool-ask-user` 位于此处,因为向用户提问是由 UI 支持的产品功能,并不属于提供方无关的核心主干。`user-approval` 持有一次性的 `ctx.approval` 决策机制及其策略层级;应答方仍归拥有 agent智能体的通道或自动化传输层所有`user-interaction` 保持提供方无关(`ctx.userInteraction``tool-ask-user` 是其模型侧消费方,而交互式 app 包提供具体实现
`user-approval``user-interaction``tool-ask-user` 位于此处,因为向用户提问是由 UI 支持的产品功能,并不属于提供方的核心主干。`user-approval` 负责一次性的 `ctx.approval` 决策机制及其策略层级;应答逻辑仍由负责 agent智能体的通道或自动化传输层提供`user-interaction` 保持提供方无关(`ctx.userInteraction``tool-ask-user` 是其模型侧消费方,而交互式 app 包提供具体的提供方
基于 [`agent-spine-demo`](../examples/agent-spine-demo/README.md) 组合的可运行 app bundle 位于 [`examples/`](../examples/README.md)`tui-demo``acp-demo``jsonrpc-demo`)。`acp-demo``jsonrpc-demo` 持有启动 bin`tui-demo` bundle 则由产品 [`dsh`](../../apps/cli/README.md) CLI 启动。`ui/` 保留可复用的用户SDK 通道插件和共享 `app-boot` 粘合层;仅供自动化使用的 ACP 传输层位于 [`acp/`](../acp/README.md)。每个入口都持有自己的 stdout 策略,叶子 `cordis.yml` 则提供后端与可选工具。
基于 [`agent-spine-demo`](../examples/agent-spine-demo/README.md) 组合的可运行 app bundle 位于 [`examples/`](../examples/README.md)`tui-demo``acp-demo``jsonrpc-demo`)。`acp-demo``jsonrpc-demo` 各自提供启动 bin`tui-demo` bundle 则由产品 [`dsh`](../../apps/cli/README.md) CLI(命令行界面)启动。`ui/` 保留可复用的用户SDK 通道插件和共享 `app-boot` 粘合层;仅供自动化使用的 ACPAgent Client Protocol传输层位于 [`acp/`](../acp/README.md)。每个入口都负责自己的 stdout 策略,叶子 `cordis.yml` 则提供后端与可选工具。

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/commands/README.md
README.md: 4ad72cf9e232c8d41e525f42eecde5637032a391
README.zh.md: bace8f6346ac737a838d802dfc5c6ffe52c56edd
README.zh.md: 9cc6a3f31e55da5d56c5b49ba78fbd66381ed680

View File

@@ -2,17 +2,17 @@
[English](README.md) | 中文
由插件拥有、供交互式 UI 适配器使用的面向用户命令注册表。[插件命令注册 Agent Noteagent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md)定义了其边界与分发契约。
由插件负责、供交互式 UI 适配器使用的面向用户命令注册表。[插件命令注册 Agent Noteagent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md)定义了其边界与分发契约。
## 服务契约
`ctx.commands.register(definition)` 注册一个小写命令名称、描述、可选的非结构化输入提示,以及可中止的处理器。每个已注册命令都可供所有已组合的命令适配器使用;与某项部署不兼容的插件不会在此注册。普通上下文中的注册全局生效。在 `agent.ctx` 下挂载的命令生产插件会声明自身的 `commands` 注入,并创建精确限定到该 agent 的定义;该定义会遮蔽同名的全局定义。这种子级注入形态保留了 agent 作用域,同时不会让核心 agent loop 依赖 UI 服务。同一层中的名称重复会在注册时失败。每个 disposer 都是 Cordis effect 返回的确切 disposer注册或移除命令时系统会通知每个 `commands/change` 观察者,使实时适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。
`ctx.commands.register(definition)` 注册一个小写命令名称、描述、可选的非结构化输入提示,以及可中止的处理器。每个已注册命令都可供所有已组合的命令适配器使用;与某项部署不兼容的插件不会在此注册。普通上下文中的注册全局生效。在 `agent.ctx` 下挂载的命令生产插件会声明自身的 `commands` 注入,并创建精确限定到该 agent(智能体)的定义;该定义会遮蔽同名的全局定义。这种子级注入形态保留了 agent 作用域,同时不会让核心 agent loop(智能体循环)依赖 UI 服务。同一层中的名称重复会在注册时失败。每个 disposer 都是 Cordis effect 返回的确切 disposer注册或移除命令时系统会通知每个 `commands/change` 观察者,使运行中的适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。
`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令,返回已结算的 `CommandExecution`(规范化结果加生命周期配对 `commandId`);语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带铸造`commandId`、解析器的结构化 `name`/`args` 切分和发起方 `CommandSource`)与 `command/done`(结算时记录,携带结局种类与原样文本;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。两者都直接独立追加:没有轮次包裹它们,持久化在常规检查点与 teardown 时排空它们
`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令,返回已结算的 `CommandExecution`(规范化结果加生命周期配对 `commandId`);语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带新生成`commandId`、解析器的结构化 `name`/`args` 切分和发起方 `CommandSource`)与 `command/done`(结算时记录,携带结果类型与原样文本;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。两者都直接独立追加到接收 agent 的会话中:没有轮次包裹它们,持久化机制会在常规检查点和销毁期间排空这些事件
`parseCommand()` 识别位于字节零位置的斜杠、由小写字母、数字、`_``-` 构成的名称,以及名称后紧接输入末尾或空白的形式。它将名称后的每个字节作为 `rawInput` 返回,其中包括分隔空白;消费方拥有各命令专用的语法,只能执行该语法允许的规范化。
`parseCommand()` 识别位于第 0 字节的斜杠、由小写字母、数字、`_``-` 构成的名称,以及名称后紧接输入末尾或空白的形式。它将名称后的每个字节作为 `rawInput` 返回,其中包括分隔空白;消费方负责各命令专用的语法,只能执行该语法允许的规范化。
处理器返回 `success``error`,并可附带 UI 文本。适配器直接渲染结果,结果绝不进入模型历史。注册表绝不会隐式地把 `rawInput` 提交给 agent命令生产方可以通过接收命令的 `Agent` 显式安排模型可见工作,此时该生产方拥有由此产生的消息契约。注册表会处理器完成所提供的中止信号竞速,但不协作的处理器可能在调用方停止等待后继续产生自身的外部副作用。
处理器返回 `success``error`,并可附带 UI 文本。适配器直接渲染结果,结果绝不进入模型历史。注册表绝不会隐式地把 `rawInput` 提交给 agent命令生产方可以通过接收命令的 `Agent` 显式安排模型可见工作,此时该生产方负责由此产生的消息契约。注册表会同时等待处理器完成所提供的中止信号,以先发生者为准,但不响应中止的处理器可能在调用方停止等待后继续产生自身的外部副作用。
## 组合
@@ -32,9 +32,9 @@
#### KV Cache 影响
注册表元数据、命令输入和直接输出绝不会进入模型请求,也不会影响其缓存。被命令变更的领域拥有之后产生的所有缓存影响。
注册表元数据、命令输入和直接输出绝不会进入模型请求,也不会影响其缓存。发生变更的领域负责之后产生的所有缓存影响。
## 已知限制与延期工作
## 已知限制与暂缓事项
- **仅支持非结构化文本输入**:表单、补全 schema 和类型化参数仍由各命令自行解析。
- **副作用采用协作式取消**:中止后,分发会停止等待;处理器必须遵循信号,才能停止已经进入外部系统的工作。

View File

@@ -136,7 +136,7 @@ describe('CommandService', () => {
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
ctx.on('commands/change', () => { throw new Error('observer threw') })
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- exercises rejected-listener containment
// oxlint-disable-next-line typescript/no-misused-promises -- exercises rejected-listener containment
ctx.on('commands/change', () => Promise.reject(new Error('observer rejected')))
const afterFailures = vi.fn()
ctx.on('commands/change', afterFailures)
@@ -229,7 +229,7 @@ describe('CommandService', () => {
ctx.commands.register({
name: 'reject-value',
description: 'Reject a non-Error value',
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise untyped plugin normalization
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercise untyped plugin normalization
handler: () => Promise.reject('not an Error'),
})
await expect(ctx.commands.execute(agent, '/reject-value', new AbortController().signal))
@@ -239,7 +239,7 @@ describe('CommandService', () => {
ctx.commands.register({
name: 'reject-hostile',
description: 'Reject an unrenderable value',
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise hostile plugin normalization
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercise hostile plugin normalization
handler: () => Promise.reject(hostile),
})
await expect(ctx.commands.execute(agent, '/reject-hostile', new AbortController().signal))

View File

@@ -3,4 +3,4 @@
# 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: 63615654769bf4ed7a69c09dc818af034c3a3c3c
README.zh.md: 1c27f5edf2f1f172aa6303697b17e2e77a65842a

View File

@@ -2,11 +2,11 @@
[English](README.md) | 中文
`jsonrpc` 插件通过 stdio 提供以换行符分隔的 JSON-RPC使进程外 SDK 客户端能够驱动 harness agent智能体。[`HarnessSdkServer`](src/server.ts) 持有协议方法和通知;传输与具名线类型位于 [`dsh-sdk-protocol`](../../sdk/sdk-protocol/README.md),与客户端 SDK 共享;[`jsonrpc-demo`](../../examples/jsonrpc-demo/README.md) 提供外围的 `cordis.yml` 应用。
`jsonrpc` 插件通过 stdio 提供以换行符分隔的 JSON-RPC使进程外 SDK 客户端能够驱动 harness agent智能体。[`HarnessSdkServer`](src/server.ts) 负责协议方法和通知;传输与具名协议类型位于 [`dsh-sdk-protocol`](../../sdk/sdk-protocol/README.md),与客户端 SDK 共享;[`jsonrpc-demo`](../../examples/jsonrpc-demo/README.md) 提供外围的 `cordis.yml` 应用。
## 组装
`inject: ['agents']`。服务器按 `sessionId` 获取或创建一个 agent。只有服务建立快照时的生命周期 `local` 标志为 true服务器才会转发 subagent 完成事件;提供方名称、子级 id 和持久化谱系均不能证明本地性。已注册的适配器优先;未被持有`deepseek` 路由会挂载 `dsh-llm-deepseek`,任何其他未被持有的提供方都会导致初始化失败。其他功能由外围 `cordis.yml` 提供。
`inject: ['agents']`。服务器按 `sessionId` 获取或创建一个 agent。只有服务对生命周期建立快照时记录的 `local` 标志为 true服务器才会转发 subagent 完成事件;提供方名称、子级 id 和持久化谱系均不能证明本地性。已注册的适配器优先;尚无适配器负责`deepseek` 路由会挂载 `dsh-llm-deepseek`,任何其他尚无适配器负责的提供方都会导致初始化失败。其他功能由外围 `cordis.yml` 提供。
## 配置
@@ -40,7 +40,7 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger诊断应写
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
## 已知限制与延期工作
## 已知限制与暂缓事项
- **协议没有逐会话关闭或提示词取消方法**SDK 创建的 agent 会一直存活到进程关闭;一条已接受的提示词必须运行到 agent 空闲,该会话才能接受下一条。
- **stdout 纯净性由部署保证**:外围配置仍可能加载 stdout logger 并破坏 JSON-RPC 通道;此插件不会检查或否决同级 logger。

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 6a59ad9425bf5bfeb89e9798304a2eb90ee55bfa
README.zh.md: 0e7db1bd41a15ac4be18d33db7b9011a5bc24e7e
# pnpm run verify-translation-pairing --write packages/ui/permission/README.md
README.md: 814085ed6f2c9650854f377e1c97e442fc4211a4
README.zh.md: 36880d6b8c3f0b39b88db1abb02534f30e3355fa

View File

@@ -8,6 +8,8 @@ User-facing permission presets through `ctx.permission` ([`PermissionService`](s
The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load; composition defaults outside the table instead make a zero-event session derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
Two optional children ship the product surfaces over the same service: a `permissions` session-projection unit (`src/types.ts` declares the key; the unit folds the three whole-value knob events and views the select — table options plus a current-only `custom` — over the composition defaults) and the `/permission` command (bare invocation reports the current preset and the table; a preset argument switches through `set`). Each child activates only when its registry (`ctx.sessionProjections` / `ctx.commands`) is composed.
## Model Experience
Indirectly, through `dsh-user-approval` and `dsh-tool-bash`, which render the approval-policy prompt, switch notice, and sandboxed tool outcomes selected by this service's knob events; `permission/preset` itself is log-only.
@@ -18,7 +20,6 @@ No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **No shipped composition currently mounts the service** — the ACP bridge was its only selector before [ACP became automation-only](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md); the preset table is kept for the interactive front door that next exposes a runtime policy switch.
- **Only two mechanism knobs are bundled** — presets select sandbox mode and approval policy; an agent/profile choice is not part of `PresetSpec` yet.
- **`custom` is derived-only** — callers can switch away from an unmatched knob combination but cannot target or persist a named custom preset through this service.
- **The preset table is process-level** — configuration is fixed for the plugin lifetime; changing available presets requires reloading the plugin.

View File

@@ -8,6 +8,8 @@
该服务要求存在具有约束能力的 `ctx.bash` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常;如果组合在表外指定默认值,则零事件会话会推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
两个可选子件在同一服务之上交付产品界面:`permissions` 会话投影单元(`src/types.ts` 声明该 key单元折叠三个全量值旋钮事件在组合默认值之上视图出 select——表内选项加仅作当前值的 `custom`)与 `/permission` 命令(裸调用报告当前预设与表;预设参数经 `set` 切换)。每个子件仅在其注册表(`ctx.sessionProjections` / `ctx.commands`)被组合时激活。
## 模型体验
间接地,通过 `dsh-user-approval``dsh-tool-bash`:二者会渲染由此服务的调节项事件所选择的审批策略提示词、切换通知和沙箱工具结果;`permission/preset` 本身只写入日志。
@@ -18,7 +20,6 @@
## 已知限制与延期工作
- **当前没有已交付的组合挂载此服务**:在 [ACP 变为仅用于自动化](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md)之前ACP 桥接层是唯一的选择器preset 表为下一个公开运行时策略切换的交互式入口保留。
- **只组合两个机制调节项**preset 选择沙箱模式和审批策略agent智能体profile 选择尚未纳入 `PresetSpec`
- **`custom` 只能推导得出**:调用方可以从不匹配的调节项组合切换出去,但无法通过此服务选中或持久化一个具名 custom preset。
- **preset 表位于进程级别**:配置在插件生命周期内固定;更改可用 preset 必须重新加载插件。

View File

@@ -15,12 +15,21 @@
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./types": {
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
"./client": {
"types": "./lib/types/client.d.ts",
"default": "./lib/types/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -28,22 +37,27 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-projection": "^0.0.1",
"@deepseek-ai/dsh-user-approval": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
"schemastery": "^3.18.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"cordis": "^4.0.0-rc.7"
}

View File

@@ -0,0 +1,10 @@
/**
* Client-namespace projection of the permission domain: a pure re-export of
* the package's types outlet. Client code imports ONLY the client namespace
* (repo discipline), so `./client` projects the same single-source content
* `./types` serves to host consumers — zero duplication.
*
* @module @deepseek-ai/dsh-permission/client
*/
export type * from './types.ts'

View File

@@ -3,13 +3,16 @@
* approval-policy knobs. A switch records the selected preset, then writes
* changed knobs through their canonical setters. Execution, prompt narration,
* and replay keep reading their knob folds. The preset event preserves user
* intent when two presets share a bundle.
* intent when two presets share a bundle. The read side ships as the
* `permissions` session projection; the write side ships as the
* `/permission` command — both optional children over the same service.
*
* @module dsh-permission
*/
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { z as zod } from 'zod'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
@@ -18,6 +21,16 @@ import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-a
import type {} from '@deepseek-ai/dsh-bash'
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
// Type-only: resolves ctx.sessionProjections / ctx.commands for the optional children.
import type {} from '@deepseek-ai/dsh-session-projection'
import type {} from '@deepseek-ai/dsh-commands'
import type { PermissionSelect, PresetOption } from './types.ts'
// The `permissions` projection-key declaration lives in src/types.ts (its one
// home); this re-export projects the type face onto the package root AND
// keeps the module edge in the emitted index.d.ts, so aggregate programs
// consuming the declarations still receive the SessionProjectionMap merge.
export type * from './types.ts'
declare module 'cordis' {
interface Context {
@@ -49,16 +62,6 @@ export interface PresetSpec {
description?: string
}
/** The select-option shape a presentation layer advertises for one preset (or for the derived `custom` state). */
export interface PresetOption {
/** Stable option value: the table key, or `custom`. */
value: string
/** The display label. */
name: string
/** One user-facing sentence on what the value means. */
description?: string
}
/**
* Returned when effective knob values match no table entry. Clients may show
* it as the current value, but it is never a switch target or event payload.
@@ -79,6 +82,50 @@ export function effectivePermissionPreset(events: readonly SessionEvent[]): stri
return undefined
}
/**
* The projection unit's state: the last seen value of each knob event, null
* before an override (composition defaults apply at view time). Plain JSON
* (persisted-cache precondition).
*/
export interface KnobState {
/** Last `permission/preset` payload, or null. */
preset: string | null
/** Last `sandbox/mode` payload, or null. */
sandbox: SandboxMode | null
/** Last `approval/policy` payload, or null. */
approval: ApprovalPolicy | null
}
/** State for the empty log: every knob at its composition default. */
const EMPTY_KNOBS: KnobState = { preset: null, sandbox: null, approval: null }
/**
* One-event knob transition (the projection unit's `apply`). Uninterested
* events return the same reference — the registry's change gate.
* @param state - the folded knob state before `event`.
* @param event - one committed session event.
* @returns the next state; the same reference when the event is not a knob.
*/
export function applyKnobEvent(state: KnobState, event: SessionEvent): KnobState {
switch (event.type) {
case 'permission/preset':
return { ...state, preset: event.data.preset }
case 'sandbox/mode':
return { ...state, sandbox: event.data.mode }
case 'approval/policy':
return { ...state, approval: event.data.policy }
default:
return state
}
}
/** Whole-log knob fold (the cold-read parallel of {@link applyKnobEvent}). */
function foldKnobs(events: readonly SessionEvent[]): KnobState {
let state = EMPTY_KNOBS
for (const event of events) state = applyKnobEvent(state, event)
return state
}
/** The {@link PermissionService} config: the deployment's preset table. */
export interface Config {
/**
@@ -128,6 +175,55 @@ export class PermissionService extends Service {
if (ctx.bash.sandboxMode === undefined) {
throw new Error('permission: the mounted bash executor does not confine (no sandboxMode) — presets bundle a sandbox mode, so composing this plugin over an unconfined executor is a misconfiguration')
}
// The permissions projection unit: fold the three whole-value knob
// events; view derives the select over the composition defaults this
// service already owns. The unit child activates only when a projection
// registry is composed (headless assemblies stay unaffected).
// zod `.optional()` types the key `string | undefined` while the domain
// says `description?: string`; on the JSON wire the two serialize
// identically (absent), so the cast records exactly that
// exactOptionalPropertyTypes widening (the Wire<T> precedent).
const selectSchema = zod.object({
options: zod.array(zod.object({
value: zod.string().min(1),
name: zod.string().min(1),
description: zod.string().optional(),
})),
currentValue: zod.string().min(1),
}) as unknown as zod.ZodType<PermissionSelect>
ctx.inject(['sessionProjections'], (projectionCtx) => {
projectionCtx.sessionProjections.register<'permissions', KnobState>({
key: 'permissions',
schema: selectSchema,
init: () => EMPTY_KNOBS,
apply: applyKnobEvent,
view: state => this.selectFor(state),
stateVersion: 1,
})
})
// The /permission command: the one write path a web client uses (the
// popup contribution submits the picked preset as this line). The child
// activates only when a command registry is composed.
ctx.inject(['commands'], (commandCtx) => {
commandCtx.commands.register({
name: 'permission',
description: 'Switch the permission preset (sandbox mode + approval policy)',
input: { hint: '<preset>' },
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(', ')}.` }
}
if (!this.names.includes(name)) {
return { kind: 'error', text: `unknown permission preset "${name}" (available: ${this.names.join(', ')})` }
}
this.set(agent.session, name)
return { kind: 'success', text: `Permission preset: ${name}.` }
},
})
})
}
/**
@@ -146,13 +242,17 @@ export class PermissionService extends Service {
* @returns the effective preset name, or `custom` when nothing matches.
*/
current(events: readonly SessionEvent[]): string {
const sandbox = effectiveSandboxMode(events) ?? this.ctx.bash.sandboxMode
const approval = effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask'
return this.derive(foldKnobs(events))
}
/** Resolve the preset for one folded knob state (the shared mathematics of `current` and the projection unit). */
private derive(state: KnobState): string {
const sandbox = state.sandbox ?? this.ctx.bash.sandboxMode
const approval = state.approval ?? this.ctx.approval.config.policy ?? 'ask'
const matches = (spec: PresetSpec): boolean => spec.sandbox === sandbox && spec.approval === approval
const folded = effectivePermissionPreset(events)
if (folded !== undefined) {
const spec = this.presets[folded]
if (spec !== undefined && matches(spec)) return folded
if (state.preset !== null) {
const spec = this.presets[state.preset]
if (spec !== undefined && matches(spec)) return state.preset
}
for (const [name, spec] of Object.entries(this.presets)) {
if (matches(spec)) return name
@@ -160,6 +260,23 @@ export class PermissionService extends Service {
return CUSTOM_PRESET
}
/**
* Build the whole select value for one folded knob state: every table
* option in declaration order, `custom` appended exactly while derived.
* @param state - the folded knob overrides.
* @returns the `permissions` projection payload.
*/
selectFor(state: KnobState): PermissionSelect {
const currentValue = this.derive(state)
return {
options: [
...this.names.map(name => this.optionOf(name)),
...currentValue === CUSTOM_PRESET ? [this.optionOf(CUSTOM_PRESET)] : [],
],
currentValue,
}
}
/**
* Resolve a preset's knob bundle.
* @param name - the preset name to resolve.

View File

@@ -0,0 +1,44 @@
/**
* Pure types of the permission domain: the ONE home of the `permissions`
* projection-key declaration plus its payload types, free of this package's
* host-side value imports (cordis, schemastery). Two namespace projections
* serve it — the package root re-export for host consumers, `./client` (the
* browser half-entry's re-export) for client aggregates — with zero content
* duplication.
*
* @module @deepseek-ai/dsh-permission/types
*/
/** The select-option shape a presentation layer advertises for one preset (or for the derived `custom` state). */
export interface PresetOption {
/** Stable option value: the table key, or `custom`. */
value: string
/** The display label. */
name: string
/** One user-facing sentence on what the value means; omitted when not configured. */
description?: string
}
/**
* Whole `permissions` projection value: every switchable preset in table
* order (plus the derived current-only `custom` when the knobs match no
* entry) and the effective current value.
*/
export interface PermissionSelect {
/** Switchable presets, plus `custom` appended exactly while it is current. */
options: PresetOption[]
/** The effective current value: a preset table key, or `custom`. */
currentValue: string
}
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionMap {
/**
* The session's permission select, folded from the three whole-value
* knob events (`permission/preset`, `sandbox/mode`, `approval/policy`)
* over the composition defaults. Key absence means no permission service
* is composed — clients hide the control.
*/
permissions: PermissionSelect
}
}

View File

@@ -34,6 +34,9 @@ describe('effectivePermissionPreset', () => {
session.append('permission/preset', { preset: 'danger-full-access' })
session.append('permission/preset', { preset: 'workspace-write' })
expect(effectivePermissionPreset(session.events)).toBe('workspace-write')
// The backward scan steps over non-preset events to the latest selection.
session.append('sandbox/mode', { mode: 'read-only' })
expect(effectivePermissionPreset(session.events)).toBe('workspace-write')
})
})

View File

@@ -0,0 +1,116 @@
/**
* The `permissions` projection unit and the `/permission` command: mounting
* the permission service beside the projection registry serves the whole
* select (table options + effective current value, `custom` appended exactly
* while derived) folded from the three knob events over the composition
* defaults; the command child registers `/permission` whose handler switches
* through `permission.set` (bare invocation reports, unknown names error);
* compositions without either registry are unaffected; unmounting the
* service removes the key (HMR safety).
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createScope } from '@deepseek-ai/dsh-scope'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import CommandService from '@deepseek-ai/dsh-commands'
import PermissionService from '@deepseek-ai/dsh-permission'
import type { Config } from '@deepseek-ai/dsh-permission'
async function harness(options: { withPermission?: boolean; config?: Config } = {}): Promise<{ ctx: Context; session: Session }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(CommandService)
ctx.provide('bash', {
sandboxMode: 'workspace-write',
resolve() { throw new Error('permission tests do not execute bash') },
run() { throw new Error('permission tests do not execute bash') },
start() { throw new Error('permission tests do not execute bash') },
})
ctx.provide('approval', { config: { policy: 'ask' } })
if (options.withPermission !== false) await ctx.plugin(PermissionService, options.config ?? {})
return { ctx, session: ctx.sessions.create(SessionId('perm-projected')) }
}
/** Mint a scoped agent over a live session (the command executor's addressing shape). */
async function agentFor(ctx: Context, session: Session): Promise<Agent> {
const agent = { id: session.id, session } as Agent
await ctx.plugin(Object.assign((inner: Context) => { createScope(inner, agent) }, { inject: ['commands'] }))
return agent
}
describe('permissions projection unit', () => {
it('serves the composition-default select at zero events', async () => {
const { ctx, session } = await harness()
const value = ctx.sessionProjections.snapshot(session).values.permissions
expect(value).toMatchObject({ currentValue: 'workspace-write' })
expect(value?.options.map(option => option.value)).toEqual(['workspace-write', 'danger-full-access'])
})
it('folds the knob events and notifies the change feed per knob append', async () => {
const { ctx, session } = await harness()
const changes: { key: string; value: unknown; seq: number }[] = []
ctx.sessionProjections.onChanged((_session, key, value, seq) => {
changes.push({ key, value, seq })
})
ctx.permission.set(session, 'danger-full-access')
// set() appends preset + sandbox/mode + approval/policy: three knob transitions.
expect(changes).toHaveLength(3)
expect(changes.at(-1)).toMatchObject({ key: 'permissions', value: { currentValue: 'danger-full-access' } })
// Unrelated event: same-reference apply, no notification.
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(changes).toHaveLength(3)
})
it('appends custom as a current-only option when the knobs match no preset', async () => {
const { ctx, session } = await harness()
session.append('sandbox/mode', { mode: 'read-only' })
const value = ctx.sessionProjections.snapshot(session).values.permissions
expect(value?.currentValue).toBe('custom')
expect(value?.options.at(-1)).toMatchObject({ value: 'custom', name: 'Custom' })
})
it('has no permissions key without the service, and drops it on unload (HMR safety)', async () => {
const { ctx, session } = await harness({ withPermission: false })
expect('permissions' in ctx.sessionProjections.snapshot(session).values).toBe(false)
const fiber = await ctx.plugin(PermissionService, {})
expect(ctx.sessionProjections.snapshot(session).values.permissions).toMatchObject({ currentValue: 'workspace-write' })
await fiber.dispose()
expect('permissions' in ctx.sessionProjections.snapshot(session).values).toBe(false)
})
})
describe('/permission command', () => {
it('switches through permission.set and logs the lifecycle pair', async () => {
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(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' })
})
it('reports the current preset and the table on bare invocation', async () => {
const { ctx, session } = await harness()
const agent = await agentFor(ctx, session)
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.',
})
expect(session.events.filter(event => event.type === 'permission/preset')).toHaveLength(0)
})
it('rejects an unknown preset without touching the log', async () => {
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' })
expect(session.events.filter(event => event.type !== 'command/run' && event.type !== 'command/done')).toHaveLength(0)
})
})

View File

@@ -34,6 +34,12 @@
},
{
"path": "../../support/invariants"
},
{
"path": "../../session-projection/session-projection"
},
{
"path": "../commands"
}
]
}

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
# pnpm run verify-translation-pairing --write packages/ui/tool-ask-user/README.md
README.md: 8e779f4025c20cd200344efb7cb8cd6bc09ba64d
README.zh.md: fe1dc5559882532c4f44e705cc6daa2c7f4f8905
README.zh.md: acaffec0764404a0e0e842ffc2b4efdee8869c4f

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
模型侧 `ask_user_question` 工具,基于 `ctx.userInteraction` 实现。当模型需要确认、选择或缺失信息才能继续时,它可以借此向用户提出简明问题。
模型侧 `ask_user_question` 工具,基于 `ctx.userInteraction` 实现。当模型需要确认、选择结果或缺失信息才能继续时,它可以借此向用户提出简明问题。
## 工具
@@ -15,7 +15,7 @@
- `options`:可选选项,包含 `label``description`。如需推荐某个选项,请将其置于首位,并在该标签末尾追加 `(Recommended)`
- `multi_select`:该问题是否可以返回多个选中的选项。
工具调用 `ctx.userInteraction.ask()`,并返回规范的 `{ answers: [{ id, selected, custom? }] }``selected` 包含选项标签;仅当用户自由填写回答时才会出现 `custom`并覆盖选中的选项。Native renderer 会保留紧凑的 JSON 文本形式 `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`
工具调用 `ctx.userInteraction.ask()`,并返回规范的 `{ answers: [{ id, selected, custom? }] }``selected` 包含选项标签;仅当用户自由填写回答时才会出现 `custom`并覆盖选中的选项。Native 渲染器会保留紧凑的 JSON 文本形式 `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`
## 职责
@@ -31,17 +31,17 @@
#### Token 影响
工具可见每个请求都会产生固定的 schema 开销。
工具可见时,每个请求都会产生固定的 schema token 开销。
#### KV Cache 影响
只要定义和可见性保持不变,前缀即可稳定复用。插件生命周期变化或作用域限制可能从此 schema 开始使复用失效。
只要定义和可见性保持不变,前缀即可稳定复用。插件生命周期变化或作用域限制可能会使从此 schema 起的缓存复用失效。
### 工具调用历史与结果
#### 模型看到的内容
模型提出的完整问题保留在 assistant 工具调用参数中。用户回答后,下一步会看到精确采用 `{"answers":[{"id":"<id>","selected":["<label>"],"custom":"<text>"}]}` 形式的紧凑 JSON不使用 `custom` 时会省略该字段,`selected` 可以包含零个、一个或多个标签。调用等待期间的 UI 交互不属于模型上下文。
模型提出的完整问题保留在 assistant 工具调用参数中。用户回答后,下一步会看到精确采用 `{"answers":[{"id":"<id>","selected":["<label>"],"custom":"<text>"}]}` 形式的紧凑 JSON不使用 `custom` 时会省略该字段,`selected` 可以包含零个、一个或多个标签。调用等待期间的 UI 交互不属于模型上下文。
#### Token 影响
@@ -51,7 +51,7 @@
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
## 已知限制与延期工作
## 已知限制与暂缓事项
- **待处理问题会阻塞工具调用,直至用户作答**:该工具未声明 `timeout-policy` 预算;取消仅沿用当前轮次的 `exec.signal`
- **Native 回答渲染为 JSON 文本**:规范值仍为结构化数据,但模型侧结果使用紧凑 JSON而非更丰富的内容块词汇。

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/tui/README.md
README.md: 0b358520b863f0b9ee7a128cf4807f582fc46d8d
README.zh.md: 7e89197bd82d16dfbabeb715e953275e2f6dd68b
README.md: 99d76d21828bc6b1eb0220e11362885652b3cefd
README.zh.md: 71b3b0546fed70a89f34a2ca4eee423be5909afd

View File

@@ -24,9 +24,9 @@ When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers
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`, `/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. 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.
`/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 `provider default`, which clears an explicit selection; 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.
`/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.
`/reload` (EXPERIMENTAL, dev-only) re-reads every file-backed loader config tree and applies the diff to the running app — the HMR watcher's config path, invoked manually; it needs the cordis Loader in the context and degrades to a warning without one, runs only while the agent is idle, and refuses re-entry while a reload is in flight. Module-source hot reload remains watcher-owned. When a `skills` service is mounted, `/skill:<name> [instructions]` loads that skill's instructions into the conversation as a user turn; autocomplete lists the model-invocable skills, and any skill (including a model-disabled one) is loadable by its exact name.
`/reload` (EXPERIMENTAL, dev-only) re-reads every file-backed loader config tree and applies the diff to the running app — the HMR watcher's config path, invoked manually; it needs the cordis Loader in the context and degrades to a warning without one, runs only while the agent is idle, and refuses re-entry while a reload is in flight. Module-source hot reload remains watcher-owned. When a `skills` service is mounted, `/skill:<name> [instructions]` loads that skill's instructions into the conversation as a user turn; autocomplete lists user-invocable skills, and exact invocation rejects a skill whose user policy disables it.
The footer sums the session's reported usage as `↑<uncached input> ↓<output>`, followed by `cache <rate>%` once any input has been billed — the share of billed prompt tokens (uncached input plus cache reads and writes) served from the provider cache, rounded to a percent. It also compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route (omitting the context share when the adapter has no capacity metadata) and shows the current model and tool-card mode; the right side clips first when the footer is narrow.
@@ -139,7 +139,7 @@ Changing provider or model enters that target's cache domain; no cache reuse acr
#### What the model sees
A `/skill:<name> [instructions]` submission loads the named skill and delivers one text block: a `<skill name="…">` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same followup-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill; model-disabled skills are omitted from autocomplete but stay loadable by exact name.
A `/skill:<name> [instructions]` submission loads the named skill and delivers one text block: a `<skill name="…">` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same followup-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill: autocomplete and exact invocation apply `invocation.userInvocable`, while `invocation.modelInvocable` does not restrict this surface. User-disabled skills are omitted from autocomplete and rejected before exact-name loading; the loaded definition is rechecked for a policy race. Autocomplete retains its last complete skill snapshot and refetches after `skills/change`; an incomplete observation preserves the prior menu, a complete empty observation clears it, and a catalog arriving while a slash-name draft is open immediately re-queries that draft. The skill service is an optional peer; this policy check uses its type contract without introducing a runtime package dependency.
#### Token effect

View File

@@ -24,9 +24,9 @@ TUI 从活跃会话表层重建已恢复历史,渲染 Markdown 响应与 reaso
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``/palette``/reload``/resume``/status``/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help``/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具它显示该阶段已经过时间和运行中的步骤总数每秒刷新并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉而上下文卡片保持预览因为注入的指令不属于工具流量。注入上下文卡片把消息渲染为文本并去掉生产方的外层提醒外框因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoningCtrl+L 重绘Ctrl+D 在空闲时退出。
`/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:列表上方设有一个过滤框,按对每行 `provider/model` 标签、模型名称和描述的大小写不敏感子串匹配来缩小行集并在高亮行仍通过过滤时保持其选中状态Up/Down 移动Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度Enter 选择模型和推理强度Escape 会先清除非空过滤内容,再次按下才关闭选择器。适配器未公布默认推理强度时,循环还会包含 `provider default`,该项会清除显式选择;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model <model>` 仍可直接选择无歧义的模型 id`/model <provider>/<model>` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}``{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。
`/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 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。
`/reload`(实验性,仅开发环境)会重新读取所有基于文件的 loader 配置树,并把 diff 应用到运行中 app它手动调用 HMR热模块替换watcher 的配置路径;上下文中必须有 cordis Loader否则退化为警告。它只在 agent 空闲时运行,并拒绝 reload 进行期间的再次进入。模块源代码热重载仍由 watcher 持有。挂载 `skills` 服务后,`/skill:<name> [instructions]` 会把该 skill 的指令作为一个 user 轮次加载到会话中;自动补全列出模型可调用的 skill任何 skill包括模型禁用的 skill都可通过精确名称加载
`/reload`(实验性,仅开发环境)会重新读取所有基于文件的 loader 配置树,并把 diff 应用到运行中 app它手动调用 HMR热模块替换watcher 的配置路径;上下文中必须有 cordis Loader否则退化为警告。它只在 agent 空闲时运行,并拒绝 reload 进行期间的再次进入。模块源代码热重载仍由 watcher 持有。挂载 `skills` 服务后,`/skill:<name> [instructions]` 会把该 skill 的指令作为一个 user 轮次加载到会话中;自动补全列出用户可调用的 skill按精确名称调用时也会拒绝用户策略禁用的 skill
Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任何输入计费后,后面会显示 `cache <rate>%`,表示提供方缓存服务的已计费提示词 token 占比(未缓存输入加缓存读写),并四舍五入为百分比。它还会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较适配器没有容量元数据时省略上下文占比并显示当前模型和工具卡片模式footer 过窄时,右侧会优先裁剪。
@@ -139,7 +139,7 @@ Paths prefixed with @ are files explicitly referenced by the user. Use the read
#### 模型看到的内容
提交 `/skill:<name> [instructions]` 会加载具名 skill并交付一个文本块`<skill name="…">` 元素包装 skill 指令;提供方公开资源基准时,会先添加一行定位 skill 相对资源;最后附上用户输入的尾随指令。交付遵循普通输入同样的空闲时 followup、运行时 steer 规则。选择 skill 的是命令而非模型;模型禁用的 skill 不出现在自动补全中,但仍可按精确名称加载
提交 `/skill:<name> [instructions]` 会加载具名 skill并交付一个文本块`<skill name="…">` 元素包装 skill 指令;提供方公开资源基准时,会先添加一行定位 skill 相对资源;最后附上用户输入的尾随指令。交付遵循普通输入同样的空闲时 followup、运行时 steer 规则。选择 skill 的是命令而非模型:自动补全和按精确名称调用都应用 `invocation.userInvocable``invocation.modelInvocable` 不限制这个接口。用户禁用的 skill 不出现在自动补全中,按精确名称调用时也会在加载前被拒绝;为防止策略竞态,加载后的定义还会再次接受检查。自动补全会保留最后一份完整 skill 快照,并在 `skills/change` 后重新获取。观测不完整时保留先前菜单完整的空观测会将其清空如果目录在斜杠命令名称草稿打开期间到达则会立即根据该草稿重新查询。skill 服务是可选 peer这项策略检查仅使用其类型契约不引入运行时包依赖
#### Token 影响

View File

@@ -45,6 +45,7 @@
"@deepseek-ai/dsh-session-reference": "^0.0.1",
"@deepseek-ai/dsh-session-title": "^0.0.1",
"@deepseek-ai/dsh-skill": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-token-meter": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
@@ -82,6 +83,7 @@
"@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tool-cordis": "workspace:^",

View File

@@ -16,6 +16,7 @@ import {
visibleWidth,
} from '@earendil-works/pi-tui'
import type { Session } from '@deepseek-ai/dsh-session'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
/** Editor that shows a placeholder without making it editable content. */
export class HintEditor extends Editor {
@@ -65,13 +66,10 @@ export function formatCwd(cwd: string | undefined): string {
*/
export function gitBranch(cwd: string): string | undefined {
try {
const env = Object.fromEntries(
Object.entries(process.env).filter(([name]) => !/(?:KEY|SECRET|TOKEN)/iu.test(name)),
)
const branch = execFileSync('git', ['branch', '--show-current'], {
cwd,
encoding: 'utf8',
env,
env: scrubbedParentEnv(),
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 1_000,
}).trim()

View File

@@ -75,10 +75,10 @@ export function compactTargetLabel(target: AgentLlmTarget): string {
* Resolve the display label for a choice's reasoning effort.
* @param choice - The model choice carrying advertised reasoning metadata.
* @param effort - The selected effort, or `undefined` for provider default.
* @returns The effort's display name, `provider default`, or `undefined` when the model has no reasoning metadata.
* @returns The effort's display name, `Default`, or `undefined` when the model has no reasoning metadata.
*/
export function targetReasoningLabel(choice: ModelChoice, effort: ReasoningEffortId | undefined): string | undefined {
if (effort === undefined) return choice.reasoning === undefined ? undefined : 'provider default'
if (effort === undefined) return choice.reasoning === undefined ? undefined : 'Default'
return choice.reasoning?.efforts.find(candidate => candidate.id === effort)?.name ?? effort
}

View File

@@ -354,7 +354,7 @@ export function createTuiChat(
// the controller needs `appendNotice`/`overlayManager`, defined after that
// closure. Declare here, assign once after those exist, and defer the first
// `updatePromptValues()` call until after the assignment so no read precedes it.
// eslint-disable-next-line prefer-const -- single assignment is a forward-reference, not a const.
// oxlint-disable-next-line prefer-const -- single assignment is a forward-reference, not a const.
let modelController!: ModelController
const now = (): number => runtime.now?.() ?? Date.now()
const agentStatus = (): AgentStatus => agent.status
@@ -1034,11 +1034,12 @@ export function createTuiChat(
requestRender()
}
// Skill listing is async while `createTuiChat` is synchronous, so the
// completions rebuild once the catalog resolves. Disabled-for-model skills
// are absent from `list()`, so they never appear as completions; a user can
// still invoke one by typing its exact name.
// Skill listing is async while `createTuiChat` is synchronous, so the TUI
// retains the last complete invocation-neutral catalog for synchronous
// editor completion, filters it for user invocation, and refreshes it after
// registry invalidation.
let skillCommands: SlashCommand[] = []
let skillCommandScan = 0
const refreshCommandAutocomplete = (): void => {
const base = new CombinedAutocompleteProvider(
[
@@ -1059,24 +1060,37 @@ export function createTuiChat(
agent,
))
}
const refreshVisibleSlashAutocomplete = (): void => {
const cursor = editor.getCursor()
const textBeforeCursor = editor.getLines().slice(cursor.line, cursor.line + 1).join('').slice(0, cursor.col)
if (cursor.line === 0 && textBeforeCursor.startsWith('/') && !textBeforeCursor.includes(' ')) {
// pi-tui's provider setter closes an existing menu but does not query
// the replacement for the current draft. Tab in a slash-name context
// only requests suggestions, so it refreshes without editing the text.
editor.handleInput('\t')
}
}
const disposeCommandChanges = ctx.on('commands/change', refreshCommandAutocomplete)
refreshCommandAutocomplete()
const loadSkillCommands = (service: SkillService): void => {
service.list({ cwd, signal: skillAbort.signal }).then(
(summaries) => {
if (disposed || summaries.length === 0) return
const refreshSkillCommands = (service: SkillService): void => {
const scan = ++skillCommandScan
service.snapshot({ cwd, signal: skillAbort.signal }).then(
(snapshot) => {
if (disposed || scan !== skillCommandScan || !snapshot.complete) return
const invocable = snapshot.skills.filter(skill => skill.invocation.userInvocable)
// The argument-hint slot shows in the menu but is never inserted on
// selection, so it carries the skill's scope instead of an
// instructions placeholder. `SkillSource` is open-ended; every
// non-project source (user, custom, bundled, runtime, …) collapses
// to `(user)`.
skillCommands = summaries.map(skill => ({
skillCommands = invocable.map(skill => ({
name: `skill:${skill.name}`,
description: skill.description,
argumentHint: skill.source.startsWith('project-') ? '(project)' : '(user)',
}))
refreshCommandAutocomplete()
refreshVisibleSlashAutocomplete()
requestRender()
},
() => {
@@ -1085,7 +1099,10 @@ export function createTuiChat(
},
)
}
if (skills !== undefined) loadSkillCommands(skills)
const disposeSkillChanges = skills === undefined
? () => {}
: ctx.on('skills/change', () => { refreshSkillCommands(skills) })
if (skills !== undefined) refreshSkillCommands(skills)
// The agent scope is minted by agent-loop and intentionally inherits only
// that core plugin's dependencies. A child command producer declares its own
@@ -1181,11 +1198,64 @@ export function createTuiChat(
appendNotice(`Agent "${agent.id}" is disposed.`, 'error')
return
}
if (attachedContext !== undefined) agent.inject(attachedContext)
const message = createUserMessage({ content, source: { kind: 'user' } })
agent.steer(message)
pendingSteering.add(message.id)
refreshStatus()
if (agent.acceptsNextStep) {
// Steering is never subject to prompt admission; an attached snapshot
// drains beside it at the same step boundary through the outbox.
if (attachedContext !== undefined) {
agent.inject(attachedContext)
}
const message = createUserMessage({ content, source: { kind: 'user' } })
agent.steer(message)
pendingSteering.add(message.id)
refreshStatus()
return
}
if (attachedContext === undefined) {
agent.followup(createUserMessage({ content, source: { kind: 'user' } }))
return
}
// Idle: the snapshot rides the prompt's admission transaction so a
// blocking hook discards both together.
let cleanedUp = false
const message: UserMessage = createUserMessage({ content, source: { kind: 'user' } })
const acceptedId = message.id
const discarded = new Set<MessageId>()
const cleanup = (): void => {
// Every completion path detaches both listeners. Keep this
// idempotent so later cleanup paths cannot double-release them.
/* v8 ignore next -- unreachable idempotence guard, see above */
if (cleanedUp) return
cleanedUp = true
detachSubmit()
detachDiscard()
}
// Prepended so this wrapper is outermost: it observes the exact accepted
// message identity whether a downstream hook allows or blocks, then detaches.
const detachSubmit = ctx.on('agent/prompt-submit', async (subject, submitted, _signal, next) => {
if (subject !== agent || !submitted.some(item => item.id === message.id)) return next()
cleanup()
const decision = await next()
if (decision.kind !== 'allow') return decision
return { ...decision, messages: [...decision.messages, attachedContext] }
}, { prepend: true })
// Installed before followup(): an enqueue listener can synchronously
// cancel and discard before followup() returns its id.
const detachDiscard = ctx.on('agent/inbox/discard', (subject, items) => {
if (subject !== agent) return
for (const item of items) discarded.add(item.message.id)
if (discarded.has(acceptedId)) cleanup()
})
// followup() accepts any typed input and contains listener failures;
// this guards a future synchronous throw so the wrapper cannot leak.
/* v8 ignore start -- future-proofing guard, see above */
try {
agent.followup(message)
if (discarded.has(acceptedId)) cleanup()
} catch (error: unknown) {
cleanup()
throw error
}
/* v8 ignore stop */
}
/** Deliver user input to the nearest step, or report a disposed agent. */
@@ -1199,19 +1269,40 @@ export function createTuiChat(
appendNotice('Skills are not available in this session.', 'warning')
return
}
skills.get(name, { cwd, signal: skillAbort.signal }).then(
(skill) => {
const lookup = { cwd, signal: skillAbort.signal }
const reportFailure = (error: unknown): void => {
if (disposed) return
appendNotice(`Skill "${name}" failed to load: ${errorChain(error)}`, 'error')
}
skills.list(lookup).then(
(summaries) => {
if (disposed) return
if (skill === undefined) {
const summary = summaries.find(skill => skill.name === name)
if (summary === undefined) {
appendNotice(`Unknown skill: ${name}`, 'warning')
return
}
deliver(renderSkillInvocation(skill, instructions))
},
(error: unknown) => {
if (disposed) return
appendNotice(`Skill "${name}" failed to load: ${errorChain(error)}`, 'error')
if (!summary.invocation.userInvocable) {
appendNotice(`Skill "${name}" is not available for user invocation.`, 'warning')
return
}
skills.get(name, lookup).then(
(skill) => {
if (disposed) return
if (skill === undefined) {
appendNotice(`Unknown skill: ${name}`, 'warning')
return
}
if (!skill.invocation.userInvocable) {
appendNotice(`Skill "${name}" is not available for user invocation.`, 'warning')
return
}
deliver(renderSkillInvocation(skill, instructions))
},
reportFailure,
)
},
reportFailure,
)
}
@@ -1386,11 +1477,14 @@ export function createTuiChat(
const settlePendingSteering = (id: MessageId): void => {
if (pendingSteering.delete(id)) refreshStatus()
}
const disposeDequeued = ctx.on('agent/inbox/admitted', (subject, message) => {
if (subject === agent) settlePendingSteering(message.id)
const disposeDequeued = ctx.on('agent/inbox/dequeue', (subject, item) => {
if (subject === agent) settlePendingSteering(item.message.id)
})
const disposeDiscarded = ctx.on('agent/inbox/canceled', (subject, message) => {
if (subject === agent && pendingSteering.delete(message.id)) refreshStatus()
const disposeDiscarded = ctx.on('agent/inbox/discard', (subject, items) => {
if (subject !== agent) return
let changed = false
for (const item of items) changed = pendingSteering.delete(item.message.id) || changed
if (changed) refreshStatus()
})
const disposeStatus = ctx.on('agent/status', (subject, status) => {
if (subject !== agent) return
@@ -1427,6 +1521,7 @@ export function createTuiChat(
fileSearch.dispose()
removeInputListener()
disposeCommandChanges()
disposeSkillChanges()
disposePromptChanges()
for (const value of promptValues) value.dispose()
stopBannerReveal()

View File

@@ -0,0 +1,29 @@
import { execFileSync } from 'node:child_process'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { gitBranch } from '../src/chat/helpers.ts'
vi.mock('node:child_process', () => ({
execFileSync: vi.fn(() => 'main\n'),
}))
afterEach(() => {
vi.unstubAllEnvs()
vi.clearAllMocks()
})
describe('chat helpers', () => {
it('scrubs ambient credentials and DSH names from the Git child', () => {
vi.stubEnv('TUI_TEST_PASSWORD', 'ambient-password')
vi.stubEnv('DSH_TUI_TEST_FLAG', 'ambient-harness-state')
expect(gitBranch('/workspace')).toBe('main')
const call = vi.mocked(execFileSync).mock.calls[0] as unknown as [
string,
string[],
{ env: NodeJS.ProcessEnv },
]
expect(call[0]).toBe('git')
expect(call[1]).toEqual(['branch', '--show-current'])
expect(call[2].env).not.toHaveProperty('TUI_TEST_PASSWORD')
expect(call[2].env).not.toHaveProperty('DSH_TUI_TEST_FLAG')
})
})

View File

@@ -205,6 +205,13 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
injected,
injectedOptions,
cancelled,
send(input, options) {
sent.push(input.content)
sentMessages.push(input)
sentOptions.push(options)
return input.id
},
updateInbox: () => 'not-found',
followup(input) {
sent.push(input.content)
sentMessages.push(input)

View File

@@ -4,7 +4,10 @@ import { join, resolve } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { CombinedAutocompleteProvider, visibleWidth, type Terminal } from '@earendil-works/pi-tui'
import AgentRegistry, { agentEvents, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
import AgentRegistry, {
agentEvents, assembleContextFor, InboxItemId, type Agent, type InboxItem,
type InboxPlacement,
} from '@deepseek-ai/dsh-agent'
import { createUserMessage,
createToolResultMessage,
ReasoningEffortId,
@@ -18,7 +21,7 @@ import { GOAL_CHANGE_VERSION, GoalId, renderGoalChange, type GoalSnapshotChangeM
import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
import SessionStore, { SessionId, type JsonValue, type SessionEvent, type SessionHeader, type TurnEndReason } from '@deepseek-ai/dsh-session'
import type { SessionRecord } from '@deepseek-ai/dsh-session-query'
import SkillService, { type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill'
import SkillService, { type SkillCatalogSnapshot, type SkillDefinition, type SkillProvider, type SkillSummary } from '@deepseek-ai/dsh-skill'
import type {} from '@deepseek-ai/dsh-session-title'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
@@ -51,6 +54,13 @@ const UNUSED_TOOL_OUTPUT: ToolDefinition['output'] = {
render: () => [],
}
let nextInboxItem = 0
/** Wrap one test message in the production inbox occurrence envelope. */
function inboxItem(message: InboxItem['message'], placement: InboxPlacement): InboxItem {
return { id: InboxItemId(`tui-item-${nextInboxItem++}`), message, placement }
}
class FakeTerminal implements Terminal {
columns = 88
rows = 32
@@ -1654,12 +1664,12 @@ describe('pi-tui chat lifecycle and transcript', () => {
const drainSteering = (text: string): void => {
const id = result.agent.steeredIds.shift()
if (id !== undefined) {
result.ctx.emit('agent/inbox/dequeue', result.agent, freezeMessage({
result.ctx.emit('agent/inbox/dequeue', result.agent, inboxItem(freezeMessage({
id,
role: 'user',
content: [{ type: 'text', text }],
source: { kind: 'user' },
}), 'steering')
}), 'steering'))
}
result.session.append('steering/message', {
turn: 1,
@@ -1673,12 +1683,12 @@ describe('pi-tui chat lifecycle and transcript', () => {
// A steering queue for a different agent never touches this status line.
const other = { ...result.agent, id: SessionId('other') } as Agent
result.terminal.output = ''
result.ctx.emit('agent/inbox/enqueue', other, freezeMessage({
result.ctx.emit('agent/inbox/enqueue', other, inboxItem(freezeMessage({
id: MessageId('stub'),
role: 'user',
content: [{ type: 'text', text: 'elsewhere' }],
source: { kind: 'user' },
}), 'queued')
}), 'queued'))
await tick()
expect(result.terminal.output).not.toContain('queued')
@@ -1751,26 +1761,26 @@ describe('pi-tui chat lifecycle and transcript', () => {
}))
// Another agent's dequeue/discard, and ones naming no pending id, leave
// the badge alone.
result.ctx.emit('agent/inbox/dequeue', other, discarded[0]!, 'steering')
result.ctx.emit('agent/inbox/dequeue', result.agent, freezeMessage({
result.ctx.emit('agent/inbox/dequeue', other, inboxItem(discarded[0]!, 'steering'))
result.ctx.emit('agent/inbox/dequeue', result.agent, inboxItem(freezeMessage({
id: MessageId('never-queued'),
role: 'user',
content: [{ type: 'text', text: 'x' }],
source: { kind: 'user' },
}), 'steering')
result.ctx.emit('agent/inbox/discard', other, discarded)
}), 'steering'))
result.ctx.emit('agent/inbox/discard', other, discarded.map(message => inboxItem(message, 'steering')))
result.ctx.emit('agent/inbox/discard', result.agent, [
freezeMessage({
inboxItem(freezeMessage({
id: MessageId('never-queued'),
role: 'user',
content: [{ type: 'text', text: 'x' }],
source: { kind: 'user' },
}),
}), 'steering'),
])
await tick()
expect(result.terminal.output).toContain('2 queued')
result.terminal.output = ''
result.ctx.emit('agent/inbox/discard', result.agent, discarded)
result.ctx.emit('agent/inbox/discard', result.agent, discarded.map(message => inboxItem(message, 'steering')))
await tick()
expect(result.terminal.output).not.toContain('queued')
@@ -2086,12 +2096,12 @@ describe('pi-tui chat lifecycle and transcript', () => {
it('tracks steering drains without a running status line', async () => {
const result = await setup()
const source = { kind: 'user' as const }
result.ctx.emit('agent/inbox/enqueue', result.agent, freezeMessage({
result.ctx.emit('agent/inbox/enqueue', result.agent, inboxItem(freezeMessage({
id: MessageId('stub'),
role: 'user',
content: [{ type: 'text', text: 'early' }],
source,
}), 'steering')
}), 'steering'))
result.session.append('steering/message', {
turn: 1,
message: createUserMessage({
@@ -2699,7 +2709,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
// no armed listener, and an unrelated admission is untouched. The leak
// regression: a listener installed after its cleanup already ran would
// survive every future cleanup.
result.ctx.emit('agent/inbox/discard', result.agent, [result.agent.sentMessages[0]!])
result.ctx.emit('agent/inbox/discard', result.agent, [inboxItem(result.agent.sentMessages[0]!, 'queued')])
const unrelated = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', createUserMessage({
content: [{ type: 'text', text: 'unrelated' }],
@@ -2743,9 +2753,9 @@ describe('pi-tui chat lifecycle and transcript', () => {
content: structuredClone(input.content),
source: structuredClone(input.source),
})
result.ctx.emit('agent/inbox/enqueue', foreign, message, 'queued')
result.ctx.emit('agent/inbox/enqueue', result.agent, message, 'queued')
result.ctx.emit('agent/inbox/discard', result.agent, [message])
result.ctx.emit('agent/inbox/enqueue', foreign, inboxItem(message, 'queued'))
result.ctx.emit('agent/inbox/enqueue', result.agent, inboxItem(message, 'queued'))
result.ctx.emit('agent/inbox/discard', result.agent, [inboxItem(message, 'queued')])
return message.id
}
@@ -2827,16 +2837,16 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(passthrough.kind === 'allow' && passthrough.additionalContexts).toBeUndefined()
// A foreign agent's discard leaves the wrapper armed.
const foreign = { ...result.agent, id: SessionId('foreign') } as unknown as Agent
result.ctx.emit('agent/inbox/discard', foreign, [result.agent.sentMessages.at(-1)!])
result.ctx.emit('agent/inbox/discard', foreign, [inboxItem(result.agent.sentMessages.at(-1)!, 'queued')])
// An unrelated discard for this agent also leaves the wrapper armed.
result.ctx.emit('agent/inbox/discard', result.agent, [createUserMessage({
result.ctx.emit('agent/inbox/discard', result.agent, [inboxItem(createUserMessage({
content: [{ type: 'text', text: 'unrelated discard' }],
source: { kind: 'user' },
})])
result.ctx.emit('agent/inbox/discard', result.agent, [result.agent.sentMessages.at(-1)!])
}), 'queued')])
result.ctx.emit('agent/inbox/discard', result.agent, [inboxItem(result.agent.sentMessages.at(-1)!, 'queued')])
await tick()
// Idempotent: a repeat discard after cleanup is a no-op.
result.ctx.emit('agent/inbox/discard', result.agent, [result.agent.sentMessages.at(-1)!])
result.ctx.emit('agent/inbox/discard', result.agent, [inboxItem(result.agent.sentMessages.at(-1)!, 'queued')])
const afterDiscard = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', result.agent.sentMessages.at(-1)!,
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
@@ -3339,14 +3349,14 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.terminal.send('/model alpha/shared')
result.terminal.send('\r')
await vi.waitFor(() => {
expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Reasoning effort: provider default.')
expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Reasoning effort: Default.')
})
result.terminal.send('/model')
result.terminal.send('\r')
await vi.waitFor(() => {
expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Select model')
})
expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Alpha Shared — provider default')
expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Alpha Shared — Default')
result.terminal.send('\x1b[Z')
await tick()
expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Alpha Shared — Standard')
@@ -3365,10 +3375,10 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output.slice(resetDefaultOutput)).toContain('Alpha Shared — Ultra — current')
result.terminal.send('\x1b[Z')
await tick()
expect(result.terminal.output.slice(resetDefaultOutput)).toContain('Alpha Shared — provider default')
expect(result.terminal.output.slice(resetDefaultOutput)).toContain('Alpha Shared — Default')
result.terminal.send('\r')
await tick()
expect(result.terminal.output.slice(resetDefaultOutput)).toContain('Reasoning effort: provider default.')
expect(result.terminal.output.slice(resetDefaultOutput)).toContain('Reasoning effort: Default.')
const explicitResetSeed: LlmCallConfig = {
provider: 'beta',
model: 'b1',
@@ -3828,10 +3838,30 @@ describe('skill slash command', () => {
if (skills === undefined) throw new Error('skills service not mounted')
skills.register({ name: 'demo-skill', description: 'Demo skill for tests', source: 'runtime', provider: 'runtime', content: 'Demo instructions body.' })
skills.register({ name: 'project-skill', description: 'Project skill for tests', source: 'project-dsh', provider: 'runtime', content: 'Project instructions body.' })
skills.register({ name: 'hidden-skill', description: 'Model-hidden skill', source: 'runtime', provider: 'runtime', content: 'Hidden instructions body.', disableModelInvocation: true })
skills.register({
name: 'user-only-skill',
description: 'User-only skill',
invocation: { modelInvocable: false, userInvocable: true },
source: 'runtime',
content: 'User-only instructions body.',
})
skills.register({
name: 'model-only-skill',
description: 'Model-only skill',
invocation: { modelInvocable: true, userInvocable: false },
source: 'runtime',
content: 'Model-only instructions body.',
})
skills.register({
name: 'trusted-only-skill',
description: 'Trusted-only skill',
invocation: { modelInvocable: false, userInvocable: false },
source: 'runtime',
content: 'Trusted-only instructions body.',
})
}
it('labels slash completions by scope and hides model-disabled skills', async () => {
it('labels slash completions by scope and applies user invocation policy', async () => {
const result = await setup({ configureContext: withSkills })
result.terminal.send('/skill')
await tick()
@@ -3839,8 +3869,139 @@ describe('skill slash command', () => {
expect(result.terminal.output).toContain('(user)')
expect(result.terminal.output).toContain('project-skill')
expect(result.terminal.output).toContain('(project)')
expect(result.terminal.output).toContain('user-only-skill')
expect(result.terminal.output).not.toContain('[instructions]')
expect(result.terminal.output).not.toContain('hidden-skill')
expect(result.terminal.output).not.toContain('model-only-skill')
expect(result.terminal.output).not.toContain('trusted-only-skill')
await dispose(result)
})
it('refreshes slash completions after runtime skill additions and complete removals', async () => {
let skills: SkillService | undefined
const result = await setup({
configureContext: async (ctx) => {
ctx.provide('tools', { get() { return undefined } } as never)
await ctx.plugin(SkillService)
skills = ctx.get('skills')
},
})
if (skills === undefined) throw new Error('skills service not mounted')
result.terminal.send('/skill:dynamic')
await tick()
result.terminal.output = ''
const disposeSkill = skills.register({
name: 'dynamic-skill',
description: 'DYNAMIC_COMPLETION_MARKER',
source: 'runtime',
content: 'Dynamic body.',
})
await tick()
expect(result.terminal.output).toContain('DYNAMIC_COMPLETION_MARKER')
result.terminal.send('\x03')
disposeSkill()
await tick()
result.terminal.output = ''
result.terminal.send('/skill:dynamic')
await tick()
expect(result.terminal.output).not.toContain('DYNAMIC_COMPLETION_MARKER')
await dispose(result)
})
it('retains last-good slash completions across incomplete snapshots', async () => {
let skills: SkillService | undefined
let provider: SkillProvider | undefined
let invalidate = (): void => {}
let fail = false
const result = await setup({
configureContext: async (ctx) => {
ctx.provide('tools', { get() { return undefined } } as never)
await ctx.plugin(SkillService)
skills = ctx.get('skills')
provider = {
name: 'flaky-completion',
async list() {
if (fail) throw new Error('transient completion failure')
return [{
name: 'stable-skill',
description: 'STABLE_COMPLETION_MARKER',
invocation: { modelInvocable: true, userInvocable: true },
source: 'test',
provider: 'flaky-completion',
rank: 1,
locator: 'stable',
}]
},
async get() {
return undefined
},
}
skills?.registerProvider((control) => {
invalidate = control.invalidate
return provider as SkillProvider
})
},
})
if (skills === undefined || provider === undefined) throw new Error('skills provider not mounted')
fail = true
invalidate()
await tick()
result.terminal.output = ''
result.terminal.send('/skill:stable')
await tick()
expect(result.terminal.output).toContain('STABLE_COMPLETION_MARKER')
await dispose(result)
})
it('keeps the latest slash catalog when asynchronous refreshes settle out of order', async () => {
const pendingSnapshots: Array<PromiseWithResolvers<SkillCatalogSnapshot>> = []
const result = await setup({
configureContext: async (ctx) => {
ctx.provide('tools', { get() { return undefined } } as never)
ctx.provide('skills', {
snapshot: () => {
const pending = Promise.withResolvers<SkillCatalogSnapshot>()
pendingSnapshots.push(pending)
return pending.promise
},
get: () => Promise.resolve(undefined),
} as never)
},
})
expect(pendingSnapshots).toHaveLength(1)
result.ctx.emit('skills/change')
result.ctx.emit('skills/change')
expect(pendingSnapshots).toHaveLength(3)
pendingSnapshots[2]?.resolve({
skills: [{
name: 'latest-skill',
description: 'LATEST_COMPLETION_MARKER',
invocation: { modelInvocable: true, userInvocable: true },
source: 'runtime',
provider: 'runtime',
}],
complete: true,
})
await tick()
pendingSnapshots[0]?.resolve({
skills: [{ name: 'stale-first', description: 'STALE_FIRST', invocation: { modelInvocable: true, userInvocable: true }, source: 'runtime', provider: 'runtime' }],
complete: true,
})
pendingSnapshots[1]?.resolve({
skills: [{ name: 'stale-second', description: 'STALE_SECOND', invocation: { modelInvocable: true, userInvocable: true }, source: 'runtime', provider: 'runtime' }],
complete: true,
})
await tick()
result.terminal.output = ''
result.terminal.send('/skill:latest')
await tick()
expect(result.terminal.output).toContain('LATEST_COMPLETION_MARKER')
expect(result.terminal.output).not.toContain('STALE_FIRST')
expect(result.terminal.output).not.toContain('STALE_SECOND')
await dispose(result)
})
@@ -3859,12 +4020,62 @@ describe('skill slash command', () => {
await dispose(result)
})
it('invokes a model-disabled skill by its exact name', async () => {
it('invokes a user-only skill by its exact name', async () => {
const result = await setup({ configureContext: withSkills })
result.terminal.send('/skill:hidden-skill')
result.terminal.send('/skill:user-only-skill')
result.terminal.send('\r')
await tick()
expect(result.agent.sent).toEqual([[{ type: 'text', text: '<skill name="hidden-skill">\nHidden instructions body.\n</skill>' }]])
expect(result.agent.sent).toEqual([[{ type: 'text', text: '<skill name="user-only-skill">\nUser-only instructions body.\n</skill>' }]])
await dispose(result)
})
it('checks user policy before loading and rechecks the loaded definition', async () => {
const summaries: SkillSummary[] = [
{
name: 'model-only-skill',
description: 'Model-only skill',
invocation: { modelInvocable: true, userInvocable: false },
source: 'runtime',
provider: 'runtime',
},
{
name: 'policy-race-skill',
description: 'Policy race skill',
invocation: { modelInvocable: true, userInvocable: true },
source: 'runtime',
provider: 'runtime',
},
]
const get = vi.fn((name: string) => Promise.resolve<SkillDefinition | undefined>({
name,
description: 'Policy race skill',
invocation: { modelInvocable: true, userInvocable: false },
source: 'runtime',
provider: 'runtime',
content: 'Instructions must not be delivered.',
}))
const result = await setup({
configureContext: async (ctx) => {
ctx.provide('tools', { get() { return undefined } } as never)
ctx.provide('skills', {
snapshot: () => Promise.resolve({ skills: summaries, complete: true }),
list: () => Promise.resolve(summaries),
get,
} as never)
},
})
result.terminal.send('/skill:model-only-skill')
result.terminal.send('\r')
await tick()
result.terminal.send('/skill:policy-race-skill')
result.terminal.send('\r')
await tick()
expect(result.agent.sent).toEqual([])
expect(get).toHaveBeenCalledTimes(1)
expect(get).toHaveBeenCalledWith('policy-race-skill', expect.objectContaining({ cwd: '/workspace' }))
expect(result.terminal.output).toContain('Skill "model-only-skill" is not available for user invocation.')
expect(result.terminal.output).toContain('Skill "policy-race-skill" is not available for user invocation.')
expect(result.terminal.output).not.toContain('Instructions must not be delivered.')
await dispose(result)
})
@@ -3919,6 +4130,7 @@ describe('skill slash command', () => {
configureContext: async (ctx) => {
ctx.provide('tools', { get() { return undefined } } as never)
ctx.provide('skills', {
snapshot: () => Promise.reject(new Error('list boom')),
list: () => Promise.reject(new Error('list boom')),
get: () => Promise.reject(new Error('get boom')),
} as never)
@@ -3928,37 +4140,84 @@ describe('skill slash command', () => {
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('failed to load')
expect(result.terminal.output).toContain('get boom')
expect(result.terminal.output).toContain('list boom')
await dispose(result)
})
it('drops skill list and lookup results that settle after disposal', async () => {
const pendingList: Array<(value: SkillSummary[]) => void> = []
let listCalls = 0
let resolvePendingList: ((value: SkillSummary[]) => void) | undefined
const pendingSnapshots: Array<(value: SkillCatalogSnapshot) => void> = []
const pendingGet: Array<{ resolve: (value: SkillDefinition | undefined) => void; reject: (error: unknown) => void }> = []
const result = await setup({
configureContext: async (ctx) => {
ctx.provide('tools', { get() { return undefined } } as never)
ctx.provide('skills', {
list: () => new Promise<SkillSummary[]>((resolve) => { pendingList.push(resolve) }),
snapshot: () => new Promise<SkillCatalogSnapshot>((resolve) => { pendingSnapshots.push(resolve) }),
list: () => {
listCalls += 1
if (listCalls === 1 || listCalls === 2) {
const name = listCalls === 1 ? 'demo-skill' : 'error-skill'
return Promise.resolve<SkillSummary[]>([{
name,
description: 'demo',
invocation: { modelInvocable: true, userInvocable: true },
source: 'runtime',
provider: 'runtime',
}])
}
return new Promise<SkillSummary[]>((resolve) => { resolvePendingList = resolve })
},
get: () => new Promise<SkillDefinition | undefined>((resolve, reject) => { pendingGet.push({ resolve, reject }) }),
} as never)
},
})
await tick()
result.terminal.send('/skill:demo-skill')
result.terminal.send('\r')
await tick()
result.terminal.send('/skill:error-skill')
result.terminal.send('\r')
await tick()
result.terminal.send('/skill:other-skill')
result.terminal.send('\r')
await tick()
await dispose(result)
for (const resolve of pendingList) resolve([{ name: 'late', description: 'late', source: 'runtime', provider: 'runtime' }])
pendingGet[0]?.resolve({ name: 'demo-skill', description: 'late', source: 'runtime', provider: 'runtime', content: 'late body' })
result.ctx.emit('skills/change')
expect(pendingSnapshots).toHaveLength(1)
for (const resolve of pendingSnapshots) {
resolve({
skills: [{
name: 'late',
description: 'late',
invocation: { modelInvocable: true, userInvocable: true },
source: 'runtime',
provider: 'runtime',
}],
complete: true,
})
}
resolvePendingList?.([{
name: 'other-skill',
description: 'late',
invocation: { modelInvocable: true, userInvocable: true },
source: 'runtime',
provider: 'runtime',
}])
pendingGet[0]?.resolve({
name: 'demo-skill',
description: 'late',
invocation: { modelInvocable: true, userInvocable: true },
source: 'runtime',
provider: 'runtime',
content: 'late body',
})
pendingGet[1]?.reject(new Error('late failure'))
await tick()
expect(result.agent.sent).toEqual([])
expect(result.terminal.output).not.toContain('late failure')
expect(result.terminal.output).not.toContain('late body')
expect(result.terminal.output).not.toContain('late failure')
})
})
@@ -3966,6 +4225,7 @@ describe('renderSkillInvocation', () => {
const skill: SkillDefinition = {
name: 'demo-skill',
description: 'Demo skill',
invocation: { modelInvocable: true, userInvocable: true },
source: 'runtime',
provider: 'runtime',
content: 'Body text.',
@@ -4715,7 +4975,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: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
mountTui(ctx, { theme: { color: false } }, { terminal, exit: vi.fn() })
@@ -4740,7 +5000,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: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
// Mirror dsh-tui's own inject (minus loader, the absence under test).
@@ -4775,14 +5035,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: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', 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: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(),
} as Agent
ctx.agents.register(agent)
await tick()
@@ -4813,7 +5073,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: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(),
})
await tick()
expect(terminal.started).toBe(0)
@@ -4857,7 +5117,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: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
terminal.start = () => { throw new Error('terminal startup failed') }

View File

@@ -56,6 +56,9 @@
{
"path": "../../skill/skill"
},
{
"path": "../../subprocess/subprocess"
},
{
"path": "../user-interaction"
},

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
# pnpm run verify-translation-pairing --write packages/ui/user-approval/README.md
README.md: 38bcfbfe81c3ff5f16d1835259bd4c35a06dcb64
README.zh.md: 2a3a6d08d66c70a22b3a23a2341efc0452b8a782
README.zh.md: 7f2678d8572b191ec88a326374420dde7deed3dc

View File

@@ -4,9 +4,9 @@
与通道无关的一次性审批 seam。`ctx.approval.request(req)` 返回 `allowed-once``rejected``cancelled``unavailable`;应答者缺失或失败时会以拒绝方式关闭,授权也只适用于所请求的操作。确切事件签名见生成的 [Cordis 目录](../../../docs/cordis-catalog/events.md)。
每个请求都必须属于一个打开的 agent智能体轮次。服务会追加一对 `approval/asked``approval/decided` 审计记录,而模型只会看到由此产生且已写入日志的工具结果。已中止的请求会 resolve `cancelled`;如果审计追加在提交前失败,请求会被拒绝,而不会返回一项未记录的决定。
每个请求都必须属于一个尚未结束的 agent智能体轮次。服务会追加一对 `approval/asked``approval/decided` 审计记录,而模型只会看到由此产生且已写入日志的工具结果。已中止的请求会解析`cancelled`;如果审计记录的追加在提交前失败,Promise 会被拒绝,而不会返回一项未记录的决定。
应答者是 `approval/request` waterfall瀑布式事件监听器。要回答所拥有 agent 请求,请返回一个结果;否则调用 `next()` 委托。限定到 agent 的监听器只接收该 agent 的请求;每项部署应当组合一个终应答者因为同级监听器的顺序不是策略优先级机制。ACPAgent Client Protocol自动化桥接层为其拥有的会话提供一次性机器决定。
应答者是 `approval/request` waterfall瀑布式事件监听器。要回答其负责的 agent 请求,请返回一个结果;否则调用 `next()` 委托。限定到 agent 的监听器只接收该 agent 的请求;每项部署应当组合一个终应答者因为同级监听器的顺序不是策略优先级机制。ACPAgent Client Protocol自动化桥接层为其负责的会话提供一次性机器决定。
`ApprovalPolicy``'ask'``'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求,也是提示词中唯一声明的策略。切换最多产生一条合并通知:如果覆盖发生在最后一个 `request/header` 之后,则归因于用户;否则归因于操作方/配置。
@@ -49,15 +49,15 @@ Approval prompts are disabled in this session: actions that require approval are
#### Token 影响
不会产生重复的审计 token。拒绝可能以一条少量且保留的错误替换正常工具结果,而允许会保留消费方的普通结果。
不会产生重复的审计 token。拒绝可能以一条简短且会保留的错误信息替换正常工具结果,而允许会保留消费方的普通结果。
#### KV Cache 影响
仅追加;新出现的可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
## 已知限制与延期工作
## 已知限制与暂缓事项
- **请求只在打开的轮次内有效**:空闲时或轮次之间调用会在审计前抛出异常;持久化的轮次外审批工作流仍属延期事项。
- **仅存在一次性授权**:结果词汇包含 `allowed-once`,但不含 `allow-always`记忆规则、撤销或授权存储;会话策略只有 `ask``never`
- **请求只在尚未结束的轮次内有效**空闲时或轮次之间发起调用会在审计前抛出异常;持久化的轮次外审批工作流仍属暂缓事项。
- **仅存在一次性授权**:结果词汇包含 `allowed-once`,但不含 `allow-always`已记住的规则、撤销或授权存储;会话策略只有 `ask``never`
- **请求不携带工具参数**:应答者会看到工具名称、原因和可选调用 idACP 机器通道要求调用 id并会委托不含 id 的请求。
- **没有内置应答者**:无头或组合不完整的部署会 resolve 为 `unavailable` 并以拒绝方式关闭;服务自身绝不会提示人类。

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
# pnpm run verify-translation-pairing --write packages/ui/user-interaction/README.md
README.md: d234d6677bdd772f1bbd2c979c0d41f90aef5c32
README.zh.md: b70a61d6491e0bb0e52215cdeaeea3d728f7f153
README.zh.md: c89210b6955a661313ca9e0e82e43da5a4d1db79

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
抽象用户交互 seam。它拥有 `ctx.userInteraction`:当面向模型的工具或权限插件需要暂停工作并询问人类决定时使用的服务
抽象用户交互 seam。它定义 `ctx.userInteraction`,供面向模型的工具或权限插件需要暂停工作并询问人类决定时使用。
## 服务:`UserInteractionService`ctx 键:`userInteraction`
@@ -19,7 +19,7 @@
- `UserInteractionProvider`:包含 `ask(request)` 的 UI 实现。
- `UserInteractionError``HarnessError` 的子类,包含 `EMPTY_QUESTIONS``NO_PROVIDER``DUPLICATE_PROVIDER``ASK_ABORTED` 等代码。
当回答包含 `custom` 时,`selected` 为空;自定义文本会覆盖所选选项,而不是补充它们。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。
当回答包含 `custom` 时,`selected` 为空;自定义文本所选选项的替代而不是补充。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。
## 职责
@@ -31,9 +31,9 @@
#### KV Cache 影响
不会直接使缓存失效;具名消费方拥有所有请求前缀变更
不会直接使 KV Cache 失效;请求前缀的任何变更均由上述消费方负责
## 已知限制与延期工作
## 已知限制与暂缓事项
- **每个上下文只能有一个提供方**:不支持路由或扇出到多个 UI第二次注册会抛出 `DUPLICATE_PROVIDER`,未注册任何提供方时,`ask()` 会抛出 `NO_PROVIDER`,而不会降级。
- **词汇仅包含问题表单形态**:可选项加可选自定义文本更丰富的交互形态文件选择器、diff 预览确认)尚无 seam 词汇。
- **词汇仅包含问题表单形态**:可供选择的选项加可选自定义文本更丰富的交互形态文件选择器、diff 预览确认)尚无 seam 词汇。