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:
@@ -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
|
||||
|
||||
@@ -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` 粘合层;仅供自动化使用的 ACP(Agent Client Protocol)传输层位于 [`acp/`](../acp/README.md)。每个入口都负责自己的 stdout 策略,叶子 `cordis.yml` 则提供后端与可选工具。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
由插件拥有、供交互式 UI 适配器使用的面向用户命令注册表。[插件命令注册 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md)定义了其边界与分发契约。
|
||||
由插件负责、供交互式 UI 适配器使用的面向用户命令注册表。[插件命令注册 Agent Note(agent 决策记录)](../../../.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 和类型化参数仍由各命令自行解析。
|
||||
- **副作用采用协作式取消**:中止后,分发会停止等待;处理器必须遵循信号,才能停止已经进入外部系统的工作。
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 必须重新加载插件。
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
10
packages/ui/permission/src/client.ts
Normal file
10
packages/ui/permission/src/client.ts
Normal 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'
|
||||
@@ -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.
|
||||
|
||||
44
packages/ui/permission/src/types.ts
Normal file
44
packages/ui/permission/src/types.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
116
packages/ui/permission/tests/projection.spec.ts
Normal file
116
packages/ui/permission/tests/projection.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -34,6 +34,12 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../session-projection/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../commands"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,而非更丰富的内容块词汇。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 切换 reasoning,Ctrl+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 影响
|
||||
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
29
packages/ui/tui/tests/chat-helpers.spec.ts
Normal file
29
packages/ui/tui/tests/chat-helpers.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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') }
|
||||
|
||||
@@ -56,6 +56,9 @@
|
||||
{
|
||||
"path": "../../skill/skill"
|
||||
},
|
||||
{
|
||||
"path": "../../subprocess/subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../user-interaction"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 的请求;每项部署应当组合一个终端应答者,因为同级监听器的顺序不是策略优先级机制。ACP(Agent Client Protocol)自动化桥接层为其拥有的会话提供一次性机器决定。
|
||||
应答者是 `approval/request` waterfall(瀑布式事件)监听器。要回答其负责的 agent 请求,请返回一个结果;否则调用 `next()` 委托。限定到 agent 的监听器只接收该 agent 的请求;每项部署应当组合一个最终应答者,因为同级监听器的顺序不是策略优先级机制。ACP(Agent 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`。
|
||||
- **请求不携带工具参数**:应答者会看到工具名称、原因和可选调用 id;ACP 机器通道要求调用 id,并会委托不含 id 的请求。
|
||||
- **没有内置应答者**:无头或组合不完整的部署会 resolve 为 `unavailable` 并以拒绝方式关闭;服务自身绝不会提示人类。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 词汇。
|
||||
|
||||
Reference in New Issue
Block a user