fix(commands): remove adapter surface filtering
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-19-plugin-command-registration.md: 821f22405fd6a4bc0b8bfd3c5edf773be844899d
|
||||
2026-07-19-plugin-command-registration.zh.md: 7a2ed82eb1a8f55d4d701a68c4053e5d412b7d04
|
||||
2026-07-19-plugin-command-registration.md: c414f8183e100f712a552828992d108193fc33cf
|
||||
2026-07-19-plugin-command-registration.zh.md: 3cd820c55ec30f3b2f6cf471376abfd8edd8ac5e
|
||||
@@ -16,9 +16,9 @@ A shared mechanism must remain a UI concern rather than a model tool or agent-lo
|
||||
|
||||
### Registry contract
|
||||
|
||||
A `CommandDefinition` contains a lowercase name without `/`, a non-empty description, an optional unstructured-input hint, an optional non-empty surface list, and an abortable handler. Omitted surfaces resolve to `tui` plus `acp`. Registration validates and detaches the metadata, freezes the effective definition, and returns the exact Cordis effect disposer. Duplicate names fail within one layer.
|
||||
A `CommandDefinition` contains a lowercase name without `/`, a non-empty description, an optional unstructured-input hint, and an abortable handler. Registration validates and detaches the metadata, freezes the effective definition, and returns the exact Cordis effect disposer. Duplicate names fail within one layer. Every adapter consuming the registry sees every effective definition; a command plugin that cannot operate in a deployment omits its registration there instead of encoding adapter identities in the shared domain.
|
||||
|
||||
`list(agent, surface)` returns immutable name-sorted descriptors after surface filtering and scoped shadowing. `find(agent, surface, name)` resolves the effective definition. `execute(agent, surface, line, signal)` parses and runs a visible definition, returning a detached `success` or `error` result; invalid syntax, unknown names, and hidden definitions return `undefined` so the adapter owns its direct error text.
|
||||
`list(agent)` returns immutable name-sorted descriptors after scoped shadowing. `find(agent, name)` resolves the effective definition. `execute(agent, line, signal)` parses and runs a known definition, returning a detached `success` or `error` result; invalid syntax and unknown names return `undefined` so the adapter owns its direct error text.
|
||||
|
||||
`parseCommand(line)` requires `/` at byte zero, a lowercase ASCII name containing letters, digits, `_`, or `-`, then whitespace or end-of-input. It preserves the complete adapter-delivered suffix as `rawInput`, including separator whitespace. Command-specific plugins own every further grammar decision.
|
||||
|
||||
@@ -30,13 +30,13 @@ Registration and removal emit the unfiltered, non-vetoing `commands/change` regi
|
||||
|
||||
### Direct dispatch and cancellation
|
||||
|
||||
Commands run in a human-only command plane. Their input does not become `user/message`, their output does not become a session event, and neither is sent to the model. A handler receives the exact target agent, surface, raw input, and request-owned `AbortSignal`. The registry stops awaiting an uncooperative handler when the signal aborts; the handler remains responsible for stopping external side effects already started.
|
||||
Commands run in a human-only command plane. Their input does not become `user/message`, their output does not become a session event, and neither is sent to the model. A handler receives the exact target agent, raw input, and request-owned `AbortSignal`. The registry stops awaiting an uncooperative handler when the signal aborts; the handler remains responsible for stopping external side effects already started.
|
||||
|
||||
Expected handler failures return `CommandResult.error`. Thrown or malformed results remain adapter-visible command failures, not model messages. This boundary deliberately separates UI output from durable domain mutation: a goal command may change `ctx.goals`, for example, but the goal service owns that persisted state.
|
||||
|
||||
### TUI mapping
|
||||
|
||||
The TUI registers `help`, `clear`, `cancel`, `reasoning`, `tools`, `redraw`, and `exit` as agent-scoped command definitions instead of switching on strings. Its autocomplete and help view read the live `tui` catalog, so plugin commands appear and disappear with their effects. Any submitted line beginning with `/` stays in the command plane; unknown input produces a terminal warning rather than falling through to `Agent.send()` or `Agent.steer()`.
|
||||
The TUI registers `help`, `clear`, `cancel`, `reasoning`, `tools`, `redraw`, and `exit` as agent-scoped command definitions instead of switching on strings. Its autocomplete and help view read the live catalog, so plugin commands appear and disappear with their effects. Any submitted line beginning with `/` stays in the command plane; unknown input produces a terminal warning rather than falling through to `Agent.send()` or `Agent.steer()`.
|
||||
|
||||
Each submitted command owns an `AbortController`. TUI disposal aborts outstanding dispatches, removes the local definitions, and waits for the command-producing fiber before completing teardown.
|
||||
|
||||
@@ -50,7 +50,7 @@ One model prompt or direct command may be in flight per ACP session, independent
|
||||
|
||||
## Testing
|
||||
|
||||
The registry suite covers syntax boundaries, immutable normalization, runtime metadata validation, default and explicit surfaces, deterministic sorting, global and scoped shadowing, duplicate rejection, exact disposal, contained change-notification failures, direct invocation, expected and malformed results, synchronous and asynchronous failure, and every abort timing edge at per-file 100% statement, branch, function, and line coverage.
|
||||
The registry suite covers syntax boundaries, immutable normalization, runtime metadata validation, deterministic sorting, global and scoped shadowing, duplicate rejection, exact disposal, contained change-notification failures, direct invocation, expected and malformed results, synchronous and asynchronous failure, and every abort timing edge at per-file 100% statement, branch, function, and line coverage.
|
||||
|
||||
TUI tests exercise all migrated built-ins, live plugin discovery, help/autocomplete refresh, direct results, unknown-command rejection, raw-input delivery, definition removal, startup rollback, and disposal cancellation. ACP tests use the real SDK connection, agent factory, loop, and JSONL persistence to verify create/load snapshots, dynamic updates, scoped multi-session catalogs, supported-block flattening, direct success/error/failure, unknown-command isolation, cancellation, and the absence of model requests or session messages. The SDK helper suite pins direct-ACP composition. Keyless ACP and terminal snapshots pin the new protocol and rendered transcript shapes.
|
||||
|
||||
@@ -60,6 +60,7 @@ TUI tests exercise all migrated built-ins, live plugin discovery, help/autocompl
|
||||
- **Represent human commands as model tools** — rejected because discovery and direct invocation are human UI behavior; routing through the model adds latency, token cost, and reinterpretation.
|
||||
- **Put the registry in the core agent spine** — rejected because headless and JSON-RPC agents do not consume it, while the two UI app bundles can compose it explicitly.
|
||||
- **Make `dsh-agent-loop` inject commands** — rejected because the loop does not execute or discover human commands. Agent-scoped producers declare the UI dependency in a child plugin instead.
|
||||
- **Attach adapter masks to each definition** — rejected because support is a composition fact, not command-domain state. Every composed adapter exposes a registered command; an incompatible plugin omits registration in that deployment.
|
||||
- **Send unknown slash input to the model** — rejected because typoed or unavailable direct actions must fail predictably rather than change execution planes.
|
||||
- **Persist generic command input and output** — rejected because adapter notices are not model-visible state. A handler that changes durable behavior calls the owning domain API, which records its own events.
|
||||
- **Restrict ACP commands to one text block** — rejected because ACP v1 permits accompanying content; the bridge already has a lossless accepted-block translation.
|
||||
|
||||
@@ -16,9 +16,9 @@ TUI 拥有七个斜杠命令,而 ACP 定义了标准命令目录与调用形
|
||||
|
||||
### 注册表契约
|
||||
|
||||
`CommandDefinition` 包含不带 `/` 的小写名称、非空描述、可选的非结构化输入提示、可选的非空界面列表,以及可取消处理器。省略界面时解析为 `tui` 与 `acp`。注册会校验并分离元数据、冻结有效定义,并返回准确的 Cordis effect disposer(副作用释放器)。同一层中的重复名称会失败。
|
||||
`CommandDefinition` 包含不带 `/` 的小写名称、非空描述、可选的非结构化输入提示,以及可取消处理器。注册会校验并分离元数据、冻结有效定义,并返回准确的 Cordis effect disposer(副作用释放器)。同一层中的重复名称会失败。每个消费该注册表的适配器都能看到所有有效定义;若命令插件无法在某种部署中运行,它就不在该部署中注册,而不是把适配器身份编码进共享领域。
|
||||
|
||||
`list(agent, surface)` 在界面过滤与作用域遮蔽后返回不可变、按名称排序的描述符。`find(agent, surface, name)` 解析有效定义。`execute(agent, surface, line, signal)` 解析并运行可见定义,返回分离后的 `success` 或 `error` 结果;无效语法、未知名称和对该界面隐藏的定义返回 `undefined`,由适配器拥有直接错误文本。
|
||||
`list(agent)` 在作用域遮蔽后返回不可变、按名称排序的描述符。`find(agent, name)` 解析有效定义。`execute(agent, line, signal)` 解析并运行已知定义,返回分离后的 `success` 或 `error` 结果;无效语法和未知名称返回 `undefined`,由适配器拥有直接错误文本。
|
||||
|
||||
`parseCommand(line)` 要求 `/` 位于第零字节,后接由字母、数字、`_` 或 `-` 组成的小写 ASCII 名称,并以空白或输入末尾结束。它把适配器交付的完整后缀保留为 `rawInput`,包括分隔空白。每个命令插件自行拥有后续语法决策。
|
||||
|
||||
@@ -30,13 +30,13 @@ TUI 拥有七个斜杠命令,而 ACP 定义了标准命令目录与调用形
|
||||
|
||||
### 直接分派与取消
|
||||
|
||||
命令在仅面向人类的命令平面中运行。输入不会成为 `user/message`,输出不会成为会话事件,两者都不会发送给模型。处理器接收准确的目标智能体、界面、原始输入和请求拥有的 `AbortSignal`。信号中止时,注册表不再等待不合作的处理器;处理器仍负责停止已经启动的外部副作用。
|
||||
命令在仅面向人类的命令平面中运行。输入不会成为 `user/message`,输出不会成为会话事件,两者都不会发送给模型。处理器接收准确的目标智能体、原始输入和请求拥有的 `AbortSignal`。信号中止时,注册表不再等待不合作的处理器;处理器仍负责停止已经启动的外部副作用。
|
||||
|
||||
预期的处理器失败返回 `CommandResult.error`。抛出的异常或格式错误的结果仍是适配器可见的命令失败,而不是模型消息。该边界有意分离 UI 输出与持久领域变更:例如目标命令可以改变 `ctx.goals`,但持久状态由目标服务拥有。
|
||||
|
||||
### TUI 映射
|
||||
|
||||
TUI 把 `help`、`clear`、`cancel`、`reasoning`、`tools`、`redraw` 和 `exit` 注册为智能体作用域命令定义,不再对字符串执行 switch。自动补全与帮助视图读取实时 `tui` 目录,因此插件命令会随其副作用出现和消失。任何以 `/` 开头的提交行都留在命令平面;未知输入产生终端警告,不会落入 `Agent.send()` 或 `Agent.steer()`。
|
||||
TUI 把 `help`、`clear`、`cancel`、`reasoning`、`tools`、`redraw` 和 `exit` 注册为智能体作用域命令定义,不再对字符串执行 switch。自动补全与帮助视图读取实时目录,因此插件命令会随其副作用出现和消失。任何以 `/` 开头的提交行都留在命令平面;未知输入产生终端警告,不会落入 `Agent.send()` 或 `Agent.steer()`。
|
||||
|
||||
每个提交的命令拥有一个 `AbortController`。TUI 释放会中止未完成的分派、移除本地定义,并等待命令生产者 fiber(纤程)后再完成清理。
|
||||
|
||||
@@ -50,7 +50,7 @@ ACP 允许命令提示携带额外的受支持内容块。桥接应用普通的
|
||||
|
||||
## 测试
|
||||
|
||||
注册表测试覆盖语法边界、不可变规范化、运行时元数据校验、默认和显式界面、确定性排序、全局与作用域遮蔽、重复拒绝、准确释放、变更通知失败隔离、直接调用、预期和格式错误结果、同步与异步失败,以及每种中止时序边沿;该源文件达到逐文件 100% 语句、分支、函数和行覆盖率。
|
||||
注册表测试覆盖语法边界、不可变规范化、运行时元数据校验、确定性排序、全局与作用域遮蔽、重复拒绝、准确释放、变更通知失败隔离、直接调用、预期和格式错误结果、同步与异步失败,以及每种中止时序边沿;该源文件达到逐文件 100% 语句、分支、函数和行覆盖率。
|
||||
|
||||
TUI 测试覆盖全部迁移后的内置命令、实时插件发现、帮助与自动补全刷新、直接结果、未知命令拒绝、原始输入交付、定义移除、启动回滚和释放取消。ACP 测试使用真实 SDK 连接、智能体工厂、循环与 JSONL 持久化,验证创建/加载快照、动态更新、作用域多会话目录、受支持块扁平化、直接成功/错误/失败、未知命令隔离、取消,以及不存在模型请求或会话消息。SDK helper 测试固定直接 ACP 组合。无密钥 ACP 与终端快照固定新的协议和渲染记录形态。
|
||||
|
||||
@@ -60,6 +60,7 @@ TUI 测试覆盖全部迁移后的内置命令、实时插件发现、帮助与
|
||||
- **把人类命令表示为模型工具**——不予采纳,因为发现与直接调用属于人类 UI 行为;经由模型路由会增加延迟、token 成本和重新解释。
|
||||
- **把注册表放入核心智能体主干**——不予采纳,因为无头和 JSON-RPC 智能体不消费它,而两个 UI 应用组合包可以显式组合它。
|
||||
- **让 `dsh-agent-loop` 注入 commands**——不予采纳,因为循环不执行也不发现人类命令。智能体作用域生产者改为在子插件中声明 UI 依赖。
|
||||
- **为每个定义附加适配器掩码**——不予采纳,因为支持能力是组合事实,而不是命令领域状态。每个已组合适配器都暴露已注册命令;不兼容插件不会在该部署中注册。
|
||||
- **把未知斜杠输入发送给模型**——不予采纳,因为输入错误或不可用的直接操作必须可预测地失败,而不能改变执行平面。
|
||||
- **持久化通用命令输入与输出**——不予采纳,因为适配器提示不是模型可见状态。改变持久行为的处理器会调用拥有该状态的领域 API,由后者记录自己的事件。
|
||||
- **把 ACP 命令限制为单个文本块**——不予采纳,因为 ACP v1 允许附带内容,而桥接已有无损的已接纳块转换。
|
||||
|
||||
@@ -426,7 +426,7 @@ A command was registered or unregistered. This is an unfiltered registry notific
|
||||
'commands/change'(): void
|
||||
```
|
||||
|
||||
Source: [`packages/ui/commands/src/index.ts:94`](../../packages/ui/commands/src/index.ts)
|
||||
Source: [`packages/ui/commands/src/index.ts:83`](../../packages/ui/commands/src/index.ts)
|
||||
|
||||
## `fs/*`
|
||||
|
||||
|
||||
@@ -347,42 +347,39 @@ Human-command registry. Plain-context definitions are global; definitions regist
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Register a global or calling-agent-scoped command.
|
||||
* @param definition - discovery metadata, surface mask, and direct UI handler.
|
||||
* @param definition - discovery metadata and direct UI handler.
|
||||
* @returns the exact effect disposer that unregisters this definition.
|
||||
*/
|
||||
register(definition: CommandDefinition): () => void
|
||||
|
||||
/**
|
||||
* List the effective immutable command descriptors for one agent and surface.
|
||||
* List the effective immutable command descriptors for one agent.
|
||||
* @param agent - exact receiving agent and scoped-layer key.
|
||||
* @param surface - UI adapter requesting discovery metadata.
|
||||
* @returns name-sorted descriptors after scoped shadowing and surface filtering.
|
||||
* @returns name-sorted descriptors after scoped shadowing.
|
||||
*/
|
||||
list(agent: Agent, surface: CommandSurface): readonly CommandDescriptor[]
|
||||
list(agent: Agent): readonly CommandDescriptor[]
|
||||
|
||||
/**
|
||||
* Resolve one effective command definition.
|
||||
* @param agent - exact receiving agent and scoped-layer key.
|
||||
* @param surface - UI adapter performing the lookup.
|
||||
* @param name - command name without a slash.
|
||||
* @returns the scoped shadow or global definition when visible on the surface.
|
||||
* @returns the scoped shadow or global definition.
|
||||
*/
|
||||
find(agent: Agent, surface: CommandSurface, name: string): CommandDefinition | undefined
|
||||
find(agent: Agent, name: string): CommandDefinition | undefined
|
||||
|
||||
/**
|
||||
* Parse and execute a known command without sending it to the model.
|
||||
* @param agent - exact receiving agent.
|
||||
* @param surface - dispatching UI adapter.
|
||||
* @param line - complete slash-command line.
|
||||
* @param signal - cancellation signal owned by the UI request.
|
||||
* @returns a detached result, or `undefined` when syntax/name/surface does not resolve.
|
||||
* @returns a detached result, or `undefined` when syntax or name does not resolve.
|
||||
*/
|
||||
async execute( agent: Agent, surface: CommandSurface, line: string, signal: AbortSignal, ): Promise<CommandResult | undefined>
|
||||
async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise<CommandResult | undefined>
|
||||
```
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [CommandDefinition](../core-data-structures/commands.md) · [CommandDescriptor](../core-data-structures/commands.md) · [CommandResult](../core-data-structures/commands.md) · [CommandSurface](../core-data-structures/commands.md)
|
||||
Types: [Agent](../core-data-structures/core.md) · [CommandDefinition](../core-data-structures/commands.md) · [CommandDescriptor](../core-data-structures/commands.md) · [CommandResult](../core-data-structures/commands.md)
|
||||
|
||||
Source: [`packages/ui/commands/src/index.ts:235`](../../packages/ui/commands/src/index.ts)
|
||||
Source: [`packages/ui/commands/src/index.ts:207`](../../packages/ui/commands/src/index.ts)
|
||||
|
||||
## `ctx.compact` — `CompactService` (abstract seam)
|
||||
|
||||
|
||||
@@ -4,14 +4,9 @@ The human-command seam of [`dsh-commands`](../../packages/ui/commands). TUI and
|
||||
|
||||
Source: [`packages/ui/commands/src/index.ts`](../../packages/ui/commands/src/index.ts)
|
||||
|
||||
## Surface and input metadata
|
||||
## Input metadata
|
||||
|
||||
A definition selects one or more adapter identities. The shipped identities are `tui` and `acp`; the string intersection keeps the registry extensible without widening editor autocomplete to plain `string`. ACP currently exposes one unstructured-input hint.
|
||||
|
||||
```ts type-equiv
|
||||
/** A UI adapter capable of listing and executing human commands. */
|
||||
type CommandSurface = 'tui' | 'acp' | (string & {})
|
||||
```
|
||||
ACP currently exposes one unstructured-input hint. Command availability follows plugin composition: every adapter consuming the registry sees every effective definition.
|
||||
|
||||
```ts type-equiv
|
||||
/** Immutable command input metadata compatible with ACP unstructured input. */
|
||||
@@ -23,7 +18,7 @@ interface CommandInputDescriptor {
|
||||
|
||||
## Definition
|
||||
|
||||
`CommandDefinition` is the plugin-authored registration. Omitted surfaces resolve to both shipped adapters; the registry validates and freezes a detached effective definition.
|
||||
`CommandDefinition` is the plugin-authored registration. The registry validates and freezes a detached effective definition.
|
||||
|
||||
```ts type-equiv
|
||||
/** Plugin-owned command registration. */
|
||||
@@ -34,8 +29,6 @@ interface CommandDefinition {
|
||||
readonly description: string
|
||||
/** Optional free-form input hint advertised to capable clients. */
|
||||
readonly input?: CommandInputDescriptor
|
||||
/** Surfaces exposing this command; omission means both shipped surfaces. */
|
||||
readonly surfaces?: readonly CommandSurface[]
|
||||
/** Execute against the receiving agent without sending the command to the model. */
|
||||
readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>
|
||||
}
|
||||
@@ -50,8 +43,6 @@ The adapter owns cancellation and passes the exact target agent. `rawInput` begi
|
||||
interface CommandInvocation {
|
||||
/** Exact agent whose human-facing surface received the command. */
|
||||
readonly agent: Agent
|
||||
/** UI adapter that dispatched the command. */
|
||||
readonly surface: CommandSurface
|
||||
/** Exact text following the registered command name, including separator whitespace. */
|
||||
readonly rawInput: string
|
||||
/** Cancellation signal owned by the dispatching UI request. */
|
||||
@@ -68,7 +59,7 @@ type CommandResult =
|
||||
|
||||
## Discovery and parsing views
|
||||
|
||||
Adapters receive handler-free immutable descriptors after scope resolution and surface filtering. `parseCommand()` returns `ParsedCommand` before registry resolution; syntax-valid input can still name an unavailable command.
|
||||
Adapters receive handler-free immutable descriptors after scope resolution. `parseCommand()` returns `ParsedCommand` before registry resolution; syntax-valid input can still name an unavailable command.
|
||||
|
||||
```ts type-equiv
|
||||
/** Handler-free immutable command view returned to UI adapters. */
|
||||
@@ -79,8 +70,6 @@ interface CommandDescriptor {
|
||||
readonly description: string
|
||||
/** Optional free-form input hint advertised to capable clients. */
|
||||
readonly input?: CommandInputDescriptor
|
||||
/** Surfaces on which this definition is visible. */
|
||||
readonly surfaces: readonly CommandSurface[]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:312`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) |
|
||||
| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) |
|
||||
| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:94`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) |
|
||||
| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:83`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) |
|
||||
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:53`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
|
||||
@@ -26,7 +26,6 @@ FIXME(glossary-completeness): Expand this glossary before the first release so i
|
||||
|
||||
- **human command** — a slash-prefixed instruction interpreted and executed by a human-facing adapter through `ctx.commands`, without becoming a model message. It is distinct from a model-facing tool and from shell command execution through `ctx.bash`.
|
||||
- **command plane** — discovery, parsing, dispatch, cancellation, and result rendering owned by UI adapters and command plugins. Command output is UI state unless the handler separately mutates a durable domain.
|
||||
- **command surface** — the adapter identity used to filter definitions, such as `tui` or `acp`; one scoped definition may shadow a same-named global command for its exact agent.
|
||||
|
||||
## loop hierarchy
|
||||
|
||||
|
||||
@@ -204,19 +204,19 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
methods: [
|
||||
{
|
||||
signature: 'register(definition: CommandDefinition): () => void',
|
||||
jsDoc: '/**\n * Register a global or calling-agent-scoped command.\n * @param definition - discovery metadata, surface mask, and direct UI handler.\n * @returns the exact effect disposer that unregisters this definition.\n */',
|
||||
jsDoc: '/**\n * Register a global or calling-agent-scoped command.\n * @param definition - discovery metadata and direct UI handler.\n * @returns the exact effect disposer that unregisters this definition.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'list(agent: Agent, surface: CommandSurface): readonly CommandDescriptor[]',
|
||||
jsDoc: '/**\n * List the effective immutable command descriptors for one agent and surface.\n * @param agent - exact receiving agent and scoped-layer key.\n * @param surface - UI adapter requesting discovery metadata.\n * @returns name-sorted descriptors after scoped shadowing and surface filtering.\n */',
|
||||
signature: 'list(agent: Agent): readonly CommandDescriptor[]',
|
||||
jsDoc: '/**\n * List the effective immutable command descriptors for one agent.\n * @param agent - exact receiving agent and scoped-layer key.\n * @returns name-sorted descriptors after scoped shadowing.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'find(agent: Agent, surface: CommandSurface, name: string): CommandDefinition | undefined',
|
||||
jsDoc: '/**\n * Resolve one effective command definition.\n * @param agent - exact receiving agent and scoped-layer key.\n * @param surface - UI adapter performing the lookup.\n * @param name - command name without a slash.\n * @returns the scoped shadow or global definition when visible on the surface.\n */',
|
||||
signature: 'find(agent: Agent, name: string): CommandDefinition | undefined',
|
||||
jsDoc: '/**\n * Resolve one effective command definition.\n * @param agent - exact receiving agent and scoped-layer key.\n * @param name - command name without a slash.\n * @returns the scoped shadow or global definition.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async execute( agent: Agent, surface: CommandSurface, line: string, signal: AbortSignal, ): Promise<CommandResult | undefined>',
|
||||
jsDoc: '/**\n * Parse and execute a known command without sending it to the model.\n * @param agent - exact receiving agent.\n * @param surface - dispatching UI adapter.\n * @param line - complete slash-command line.\n * @param signal - cancellation signal owned by the UI request.\n * @returns a detached result, or `undefined` when syntax/name/surface does not resolve.\n */',
|
||||
signature: 'async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise<CommandResult | undefined>',
|
||||
jsDoc: '/**\n * Parse and execute a known command without sending it to the model.\n * @param agent - exact receiving agent.\n * @param line - complete slash-command line.\n * @param signal - cancellation signal owned by the UI request.\n * @returns a detached result, or `undefined` when syntax or name does not resolve.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1127,11 +1127,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'CommandDefinition',
|
||||
declaration: 'export interface CommandDefinition {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly surfaces?: readonly CommandSurface[];\n readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>;\n}',
|
||||
declaration: 'export interface CommandDefinition {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CommandDescriptor',
|
||||
declaration: 'export interface CommandDescriptor {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly surfaces: readonly CommandSurface[];\n}',
|
||||
declaration: 'export interface CommandDescriptor {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CommandInputDescriptor',
|
||||
@@ -1139,16 +1139,12 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'CommandInvocation',
|
||||
declaration: 'export interface CommandInvocation {\n readonly agent: Agent;\n readonly surface: CommandSurface;\n readonly rawInput: string;\n readonly signal: AbortSignal;\n}',
|
||||
declaration: 'export interface CommandInvocation {\n readonly agent: Agent;\n readonly rawInput: string;\n readonly signal: AbortSignal;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CommandResult',
|
||||
declaration: 'export type CommandResult = {\n readonly kind: \'success\';\n readonly text?: string;\n} | {\n readonly kind: \'error\';\n readonly text: string;\n};',
|
||||
},
|
||||
{
|
||||
name: 'CommandSurface',
|
||||
declaration: 'export type CommandSurface = \'tui\' | \'acp\' | (string & {});',
|
||||
},
|
||||
{
|
||||
name: 'CompactAgentContext',
|
||||
declaration: 'export interface CompactAgentContext {\n session: Session;\n options: {\n provider?: string;\n model?: string;\n };\n}',
|
||||
|
||||
@@ -514,7 +514,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
}
|
||||
|
||||
/** Project the effective registry view onto ACP discovery metadata. */
|
||||
const availableCommands = (agent: Agent): AvailableCommand[] => commands.list(agent, 'acp').map(command => ({
|
||||
const availableCommands = (agent: Agent): AvailableCommand[] => commands.list(agent).map(command => ({
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
...command.input === undefined ? {} : { input: { hint: command.input.hint } },
|
||||
@@ -905,7 +905,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
const controller = new AbortController()
|
||||
rec.commandAbort = controller
|
||||
try {
|
||||
const result = await commands.execute(rec.agent, 'acp', commandLine, controller.signal)
|
||||
const result = await commands.execute(rec.agent, commandLine, controller.signal)
|
||||
if (result !== undefined && result.text !== undefined && result.text !== '') {
|
||||
notify({
|
||||
sessionId: rec.agent.session.id,
|
||||
|
||||
@@ -55,7 +55,6 @@ describe('ACP plugin commands', () => {
|
||||
const dispose = harness.ctx.commands.register({
|
||||
name: 'alpha',
|
||||
description: 'Alpha command',
|
||||
surfaces: ['acp'],
|
||||
handler: () => ({ kind: 'success' }),
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
@@ -126,7 +125,7 @@ describe('ACP plugin commands', () => {
|
||||
})
|
||||
|
||||
expect(response.stopReason).toBe('end_turn')
|
||||
expect(seen).toHaveBeenCalledWith(expect.objectContaining({ surface: 'acp', rawInput: ' raw args ' }))
|
||||
expect(seen).toHaveBeenCalledWith(expect.objectContaining({ rawInput: ' raw args ' }))
|
||||
expect(messageText(harness, sessionId)).toContain('DIRECT RESULT')
|
||||
const updatesAfterText = harness.sessionUpdates.length
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/silent' }] })
|
||||
@@ -260,7 +259,7 @@ describe('ACP plugin commands', () => {
|
||||
if (agentA === undefined) throw new Error('session A has no agent')
|
||||
await agentA.ctx.inject(['commands'], (commandCtx) => {
|
||||
commandCtx.commands.register({
|
||||
name: 'private', description: 'Only session A', surfaces: ['acp'],
|
||||
name: 'private', description: 'Only session A',
|
||||
handler: () => ({ kind: 'success', text: 'A ONLY' }),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,9 +4,9 @@ Plugin-owned human-command registry shared by the TUI and ACP adapters. The [plu
|
||||
|
||||
## Service contract
|
||||
|
||||
`ctx.commands.register(definition)` registers one lowercase command name, description, optional ACP-compatible unstructured-input hint, optional surface list, and abortable handler. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers.
|
||||
`ctx.commands.register(definition)` registers one lowercase command name, description, optional ACP-compatible unstructured-input hint, and abortable handler. A registered command is available to every composed command adapter; a plugin that is incompatible with a deployment does not register there. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers.
|
||||
|
||||
`list(agent, surface)` returns immutable, name-sorted descriptors after scoped shadowing and surface filtering. `find(agent, surface, name)` returns the corresponding definition. `execute(agent, surface, line, signal)` uses `parseCommand()` and runs only a known command, returning `undefined` for invalid syntax, unknown names, or commands hidden from that surface.
|
||||
`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning `undefined` for invalid syntax or unknown names.
|
||||
|
||||
`parseCommand()` recognizes a slash at byte zero, a lowercase name containing letters, digits, `_`, or `-`, and either end-of-input or whitespace. It returns every byte after the name as `rawInput`, including separator whitespace; consumers own their command-specific grammar and may normalize only what that grammar permits.
|
||||
|
||||
|
||||
@@ -11,11 +11,6 @@ import type { ScopeKey } from '@deepseek-ai/dsh-scope'
|
||||
export const name = 'commands'
|
||||
|
||||
const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u
|
||||
const SURFACE_NAME = /^[a-z][a-z0-9-]*$/u
|
||||
const DEFAULT_SURFACES = ['tui', 'acp'] as const
|
||||
|
||||
/** A UI adapter capable of listing and executing human commands. */
|
||||
export type CommandSurface = 'tui' | 'acp' | (string & {})
|
||||
|
||||
/** Immutable command input metadata compatible with ACP unstructured input. */
|
||||
export interface CommandInputDescriptor {
|
||||
@@ -27,8 +22,6 @@ export interface CommandInputDescriptor {
|
||||
export interface CommandInvocation {
|
||||
/** Exact agent whose human-facing surface received the command. */
|
||||
readonly agent: Agent
|
||||
/** UI adapter that dispatched the command. */
|
||||
readonly surface: CommandSurface
|
||||
/** Exact text following the registered command name, including separator whitespace. */
|
||||
readonly rawInput: string
|
||||
/** Cancellation signal owned by the dispatching UI request. */
|
||||
@@ -48,8 +41,6 @@ export interface CommandDefinition {
|
||||
readonly description: string
|
||||
/** Optional free-form input hint advertised to capable clients. */
|
||||
readonly input?: CommandInputDescriptor
|
||||
/** Surfaces exposing this command; omission means both shipped surfaces. */
|
||||
readonly surfaces?: readonly CommandSurface[]
|
||||
/** Execute against the receiving agent without sending the command to the model. */
|
||||
readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>
|
||||
}
|
||||
@@ -62,8 +53,6 @@ export interface CommandDescriptor {
|
||||
readonly description: string
|
||||
/** Optional free-form input hint advertised to capable clients. */
|
||||
readonly input?: CommandInputDescriptor
|
||||
/** Surfaces on which this definition is visible. */
|
||||
readonly surfaces: readonly CommandSurface[]
|
||||
}
|
||||
|
||||
/** Syntactically valid slash command before registry resolution. */
|
||||
@@ -75,7 +64,7 @@ export interface ParsedCommand {
|
||||
}
|
||||
|
||||
interface RegisteredCommand {
|
||||
readonly definition: CommandDefinition & { readonly surfaces: readonly CommandSurface[] }
|
||||
readonly definition: CommandDefinition
|
||||
readonly descriptor: CommandDescriptor
|
||||
}
|
||||
|
||||
@@ -175,33 +164,16 @@ function normalizeDefinition(definition: CommandDefinition): RegisteredCommand {
|
||||
}
|
||||
input = Object.freeze({ hint: rawInput.hint })
|
||||
}
|
||||
const surfaces = [...(definition.surfaces ?? DEFAULT_SURFACES)]
|
||||
if (surfaces.length === 0) {
|
||||
throw new TypeError(`command "${definition.name}" must expose at least one surface`)
|
||||
}
|
||||
const unique = new Set<CommandSurface>()
|
||||
for (const surface of surfaces) {
|
||||
if (!SURFACE_NAME.test(surface)) {
|
||||
throw new TypeError(`command "${definition.name}" surface "${surface}" must match ${String(SURFACE_NAME)}`)
|
||||
}
|
||||
if (unique.has(surface)) {
|
||||
throw new TypeError(`command "${definition.name}" surface "${surface}" is duplicated`)
|
||||
}
|
||||
unique.add(surface)
|
||||
}
|
||||
const frozenSurfaces = Object.freeze(surfaces)
|
||||
const normalized = Object.freeze({
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
...input === undefined ? {} : { input },
|
||||
surfaces: frozenSurfaces,
|
||||
handler: definition.handler,
|
||||
})
|
||||
const descriptor = Object.freeze({
|
||||
name: normalized.name,
|
||||
description: normalized.description,
|
||||
...normalized.input === undefined ? {} : { input: normalized.input },
|
||||
surfaces: normalized.surfaces,
|
||||
})
|
||||
return { definition: normalized, descriptor }
|
||||
}
|
||||
@@ -242,7 +214,7 @@ export class CommandService extends Service {
|
||||
|
||||
/**
|
||||
* Register a global or calling-agent-scoped command.
|
||||
* @param definition - discovery metadata, surface mask, and direct UI handler.
|
||||
* @param definition - discovery metadata and direct UI handler.
|
||||
* @returns the exact effect disposer that unregisters this definition.
|
||||
*/
|
||||
register(definition: CommandDefinition): () => void {
|
||||
@@ -268,14 +240,12 @@ export class CommandService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* List the effective immutable command descriptors for one agent and surface.
|
||||
* List the effective immutable command descriptors for one agent.
|
||||
* @param agent - exact receiving agent and scoped-layer key.
|
||||
* @param surface - UI adapter requesting discovery metadata.
|
||||
* @returns name-sorted descriptors after scoped shadowing and surface filtering.
|
||||
* @returns name-sorted descriptors after scoped shadowing.
|
||||
*/
|
||||
list(agent: Agent, surface: CommandSurface): readonly CommandDescriptor[] {
|
||||
list(agent: Agent): readonly CommandDescriptor[] {
|
||||
return Object.freeze([...this.view(agent).values()]
|
||||
.filter(command => command.definition.surfaces.includes(surface))
|
||||
.map(command => command.descriptor)
|
||||
// Names are unique in the effective view, so equality is impossible.
|
||||
.sort((left, right) => left.name < right.name ? -1 : 1))
|
||||
@@ -284,35 +254,31 @@ export class CommandService extends Service {
|
||||
/**
|
||||
* Resolve one effective command definition.
|
||||
* @param agent - exact receiving agent and scoped-layer key.
|
||||
* @param surface - UI adapter performing the lookup.
|
||||
* @param name - command name without a slash.
|
||||
* @returns the scoped shadow or global definition when visible on the surface.
|
||||
* @returns the scoped shadow or global definition.
|
||||
*/
|
||||
find(agent: Agent, surface: CommandSurface, name: string): CommandDefinition | undefined {
|
||||
const command = this.view(agent).get(name)
|
||||
return command?.definition.surfaces.includes(surface) === true ? command.definition : undefined
|
||||
find(agent: Agent, name: string): CommandDefinition | undefined {
|
||||
return this.view(agent).get(name)?.definition
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and execute a known command without sending it to the model.
|
||||
* @param agent - exact receiving agent.
|
||||
* @param surface - dispatching UI adapter.
|
||||
* @param line - complete slash-command line.
|
||||
* @param signal - cancellation signal owned by the UI request.
|
||||
* @returns a detached result, or `undefined` when syntax/name/surface does not resolve.
|
||||
* @returns a detached result, or `undefined` when syntax or name does not resolve.
|
||||
*/
|
||||
async execute(
|
||||
agent: Agent,
|
||||
surface: CommandSurface,
|
||||
line: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<CommandResult | undefined> {
|
||||
const parsed = parseCommand(line)
|
||||
if (parsed === undefined) return undefined
|
||||
const command = this.view(agent).get(parsed.name)
|
||||
if (command === undefined || !command.definition.surfaces.includes(surface)) return undefined
|
||||
if (command === undefined) return undefined
|
||||
if (signal.aborted) throw abortError(signal)
|
||||
const invocation = Object.freeze({ agent, surface, rawInput: parsed.rawInput, signal })
|
||||
const invocation = Object.freeze({ agent, rawInput: parsed.rawInput, signal })
|
||||
const output = command.definition.handler(invocation)
|
||||
return normalizeResult(parsed.name, await withAbort(Promise.resolve(output), signal))
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('parseCommand()', () => {
|
||||
})
|
||||
|
||||
describe('CommandService', () => {
|
||||
it('lists immutable global descriptors with default surfaces and ACP input metadata', async () => {
|
||||
it('lists immutable global descriptors with input metadata', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
const definition: CommandDefinition = {
|
||||
@@ -55,20 +55,17 @@ describe('CommandService', () => {
|
||||
}
|
||||
ctx.commands.register(definition)
|
||||
|
||||
const listed = ctx.commands.list(agent, 'acp')
|
||||
const listed = ctx.commands.list(agent)
|
||||
expect(listed).toEqual([{
|
||||
name: 'inspect',
|
||||
description: 'Inspect state',
|
||||
input: { hint: '<target>' },
|
||||
surfaces: ['tui', 'acp'],
|
||||
}])
|
||||
expect(Object.isFrozen(listed)).toBe(true)
|
||||
expect(Object.isFrozen(listed[0])).toBe(true)
|
||||
expect(Object.isFrozen(listed[0]?.input)).toBe(true)
|
||||
expect(Object.isFrozen(listed[0]?.surfaces)).toBe(true)
|
||||
expect(ctx.commands.find(agent, 'tui', 'inspect')).toMatchObject({ name: 'inspect' })
|
||||
expect(ctx.commands.find(agent, 'other', 'inspect')).toBeUndefined()
|
||||
expect(ctx.commands.find(agent, 'tui', 'missing')).toBeUndefined()
|
||||
expect(ctx.commands.find(agent, 'inspect')).toMatchObject({ name: 'inspect' })
|
||||
expect(ctx.commands.find(agent, 'missing')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('sorts distinct effective command names', async () => {
|
||||
@@ -77,7 +74,7 @@ describe('CommandService', () => {
|
||||
ctx.commands.register(command('zeta'))
|
||||
ctx.commands.register(command('alpha'))
|
||||
ctx.commands.register(command('middle'))
|
||||
expect(ctx.commands.list(agent, 'tui').map(item => item.name)).toEqual(['alpha', 'middle', 'zeta'])
|
||||
expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['alpha', 'middle', 'zeta'])
|
||||
})
|
||||
|
||||
it('uses agent-scoped shadows and removes them with their scope', async () => {
|
||||
@@ -85,17 +82,16 @@ describe('CommandService', () => {
|
||||
const { scope, agent } = await mintAgentScope(ctx, 'a')
|
||||
const other = { id: 'other' as SessionId } as Agent
|
||||
ctx.commands.register(command('shared', 'global'))
|
||||
scope.ctx.commands.register({ ...command('shared', 'scoped'), surfaces: ['tui'] })
|
||||
scope.ctx.commands.register(command('shared', 'scoped'))
|
||||
|
||||
expect(ctx.commands.list(agent, 'tui').map(item => item.name)).toEqual(['shared'])
|
||||
expect(ctx.commands.list(agent, 'acp')).toEqual([])
|
||||
expect(ctx.commands.find(agent, 'tui', 'shared')?.handler).toBeDefined()
|
||||
expect(ctx.commands.list(other, 'acp').map(item => item.name)).toEqual(['shared'])
|
||||
expect(await ctx.commands.execute(agent, 'tui', '/shared', new AbortController().signal))
|
||||
expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['shared'])
|
||||
expect(ctx.commands.find(agent, 'shared')?.handler).toBeDefined()
|
||||
expect(ctx.commands.list(other).map(item => item.name)).toEqual(['shared'])
|
||||
expect(await ctx.commands.execute(agent, '/shared', new AbortController().signal))
|
||||
.toEqual({ kind: 'success', text: 'scoped' })
|
||||
|
||||
await scope.dispose()
|
||||
expect((await ctx.commands.execute(agent, 'tui', '/shared', new AbortController().signal))?.text).toBe('global')
|
||||
expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.text).toBe('global')
|
||||
})
|
||||
|
||||
it('rejects duplicates within one layer while allowing a scoped shadow', async () => {
|
||||
@@ -124,14 +120,14 @@ describe('CommandService', () => {
|
||||
ctx.on('commands/change', afterFailures)
|
||||
const removeContained = ctx.commands.register(command('contained'))
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
expect(ctx.commands.find(agent, 'tui', 'contained')).toBeDefined()
|
||||
expect(ctx.commands.find(agent, 'contained')).toBeDefined()
|
||||
expect(afterFailures).toHaveBeenCalledTimes(1)
|
||||
await vi.waitFor(() => {
|
||||
expect(warn).toHaveBeenCalledWith('commands/change listener threw: Error: observer threw')
|
||||
expect(warn).toHaveBeenCalledWith('commands/change listener rejected: Error: observer rejected')
|
||||
})
|
||||
removeContained()
|
||||
expect(ctx.commands.find(agent, 'tui', 'contained')).toBeUndefined()
|
||||
expect(ctx.commands.find(agent, 'contained')).toBeUndefined()
|
||||
expect(afterFailures).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
@@ -155,22 +151,20 @@ describe('CommandService', () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
const seen = vi.fn(() => ({ kind: 'success' as const, text: 'ok' }))
|
||||
ctx.commands.register({ name: 'run', description: 'Run it', surfaces: ['acp'], handler: seen })
|
||||
ctx.commands.register({ name: 'run', description: 'Run it', handler: seen })
|
||||
const controller = new AbortController()
|
||||
|
||||
const result = await ctx.commands.execute(agent, 'acp', '/run untouched ', controller.signal)
|
||||
const result = await ctx.commands.execute(agent, '/run untouched ', controller.signal)
|
||||
|
||||
expect(result).toEqual({ kind: 'success', text: 'ok' })
|
||||
expect(Object.isFrozen(result)).toBe(true)
|
||||
expect(seen).toHaveBeenCalledWith(expect.objectContaining({
|
||||
agent,
|
||||
surface: 'acp',
|
||||
rawInput: ' untouched ',
|
||||
signal: controller.signal,
|
||||
}))
|
||||
await expect(ctx.commands.execute(agent, 'tui', '/run', controller.signal)).resolves.toBeUndefined()
|
||||
await expect(ctx.commands.execute(agent, 'acp', 'run', controller.signal)).resolves.toBeUndefined()
|
||||
await expect(ctx.commands.execute(agent, 'acp', '/missing', controller.signal)).resolves.toBeUndefined()
|
||||
await expect(ctx.commands.execute(agent, 'run', controller.signal)).resolves.toBeUndefined()
|
||||
await expect(ctx.commands.execute(agent, '/missing', controller.signal)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('stops awaiting an aborted handler and handles an already-aborted signal', async () => {
|
||||
@@ -183,18 +177,18 @@ describe('CommandService', () => {
|
||||
handler: () => new Promise((resolve) => { release = resolve }),
|
||||
})
|
||||
const running = new AbortController()
|
||||
const promise = ctx.commands.execute(agent, 'tui', '/wait', running.signal)
|
||||
const promise = ctx.commands.execute(agent, '/wait', running.signal)
|
||||
running.abort('operator cancelled command')
|
||||
await expect(promise).rejects.toThrow('operator cancelled command')
|
||||
release({ kind: 'success', text: 'late' })
|
||||
|
||||
const already = new AbortController()
|
||||
already.abort(new Error('already gone'))
|
||||
await expect(ctx.commands.execute(agent, 'tui', '/wait', already.signal)).rejects.toThrow('already gone')
|
||||
await expect(ctx.commands.execute(agent, '/wait', already.signal)).rejects.toThrow('already gone')
|
||||
|
||||
const defaultReason = new AbortController()
|
||||
defaultReason.abort({ source: 'test' })
|
||||
await expect(ctx.commands.execute(agent, 'tui', '/wait', defaultReason.signal)).rejects.toThrow('command aborted')
|
||||
await expect(ctx.commands.execute(agent, '/wait', defaultReason.signal)).rejects.toThrow('command aborted')
|
||||
})
|
||||
|
||||
it('propagates an asynchronously rejected handler', async () => {
|
||||
@@ -205,7 +199,7 @@ describe('CommandService', () => {
|
||||
description: 'Reject',
|
||||
handler: () => Promise.reject(new Error('handler rejected')),
|
||||
})
|
||||
await expect(ctx.commands.execute(agent, 'tui', '/reject', new AbortController().signal))
|
||||
await expect(ctx.commands.execute(agent, '/reject', new AbortController().signal))
|
||||
.rejects.toThrow('handler rejected')
|
||||
|
||||
ctx.commands.register({
|
||||
@@ -214,7 +208,7 @@ describe('CommandService', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise untyped plugin normalization
|
||||
handler: () => Promise.reject('not an Error'),
|
||||
})
|
||||
await expect(ctx.commands.execute(agent, 'tui', '/reject-value', new AbortController().signal))
|
||||
await expect(ctx.commands.execute(agent, '/reject-value', new AbortController().signal))
|
||||
.rejects.toThrow('command handler rejected with a non-Error value: not an Error')
|
||||
|
||||
const hostile = { toString(): string { throw new Error('cannot render') } }
|
||||
@@ -224,7 +218,7 @@ describe('CommandService', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise hostile plugin normalization
|
||||
handler: () => Promise.reject(hostile),
|
||||
})
|
||||
await expect(ctx.commands.execute(agent, 'tui', '/reject-hostile', new AbortController().signal))
|
||||
await expect(ctx.commands.execute(agent, '/reject-hostile', new AbortController().signal))
|
||||
.rejects.toMatchObject({
|
||||
message: 'command handler rejected with a non-Error value: <unrenderable thrown value>',
|
||||
cause: hostile,
|
||||
@@ -243,7 +237,7 @@ describe('CommandService', () => {
|
||||
return { kind: 'success' }
|
||||
},
|
||||
})
|
||||
await expect(ctx.commands.execute(agent, 'tui', '/self-abort', controller.signal))
|
||||
await expect(ctx.commands.execute(agent, '/self-abort', controller.signal))
|
||||
.rejects.toThrow('aborted in handler')
|
||||
})
|
||||
|
||||
@@ -255,7 +249,7 @@ describe('CommandService', () => {
|
||||
description: 'Denied',
|
||||
handler: () => ({ kind: 'error', text: 'not now' }),
|
||||
})
|
||||
const result = await ctx.commands.execute(agent, 'tui', '/denied', new AbortController().signal)
|
||||
const result = await ctx.commands.execute(agent, '/denied', new AbortController().signal)
|
||||
expect(result).toEqual({ kind: 'error', text: 'not now' })
|
||||
expect(Object.isFrozen(result)).toBe(true)
|
||||
|
||||
@@ -264,7 +258,7 @@ describe('CommandService', () => {
|
||||
description: 'No output',
|
||||
handler: () => ({ kind: 'success' }),
|
||||
})
|
||||
const silent = await ctx.commands.execute(agent, 'tui', '/silent', new AbortController().signal)
|
||||
const silent = await ctx.commands.execute(agent, '/silent', new AbortController().signal)
|
||||
expect(silent).toEqual({ kind: 'success' })
|
||||
expect(Object.isFrozen(silent)).toBe(true)
|
||||
})
|
||||
@@ -273,9 +267,6 @@ describe('CommandService', () => {
|
||||
[{ ...command('Bad') }, /command name/],
|
||||
[{ ...command('empty-description'), description: ' ' }, /description/],
|
||||
[{ ...command('empty-hint'), input: { hint: '' } }, /input hint/],
|
||||
[{ ...command('no-surface'), surfaces: [] }, /at least one surface/],
|
||||
[{ ...command('bad-surface'), surfaces: ['ACP'] }, /surface/],
|
||||
[{ ...command('duplicate-surface'), surfaces: ['tui', 'tui'] }, /duplicated/],
|
||||
[{ ...command('bad-handler'), handler: undefined }, /handler/],
|
||||
] as const)('rejects invalid definition %#', async (definition, expected) => {
|
||||
const ctx = await mount()
|
||||
@@ -298,6 +289,6 @@ describe('CommandService', () => {
|
||||
description: 'Broken',
|
||||
handler: () => output as never,
|
||||
})
|
||||
await expect(ctx.commands.execute(agent, 'tui', '/broken', new AbortController().signal)).rejects.toThrow(expected)
|
||||
await expect(ctx.commands.execute(agent, '/broken', new AbortController().signal)).rejects.toThrow(expected)
|
||||
})
|
||||
})
|
||||
@@ -1144,7 +1144,7 @@ export function createTuiChat(
|
||||
}
|
||||
|
||||
const showHelp = (): void => {
|
||||
const commandLines = ctx.commands.list(agent, 'tui').map((command) => {
|
||||
const commandLines = ctx.commands.list(agent).map((command) => {
|
||||
const input = command.input === undefined ? '' : ` ${command.input.hint}`
|
||||
return `/${command.name}${input} — ${command.description}`
|
||||
})
|
||||
@@ -1162,7 +1162,7 @@ export function createTuiChat(
|
||||
|
||||
const refreshCommandAutocomplete = (): void => {
|
||||
editor.setAutocompleteProvider(new CombinedAutocompleteProvider(
|
||||
ctx.commands.list(agent, 'tui').map(command => ({
|
||||
ctx.commands.list(agent).map(command => ({
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
})),
|
||||
@@ -1179,19 +1179,16 @@ export function createTuiChat(
|
||||
commandCtx.commands.register({
|
||||
name: 'help',
|
||||
description: 'Show keyboard shortcuts and commands',
|
||||
surfaces: ['tui'],
|
||||
handler: () => { showHelp(); return { kind: 'success' } },
|
||||
})
|
||||
commandCtx.commands.register({
|
||||
name: 'clear',
|
||||
description: 'Clear the transcript view (session history is unchanged)',
|
||||
surfaces: ['tui'],
|
||||
handler: () => { chat.clear(); requestRender(); return { kind: 'success' } },
|
||||
})
|
||||
commandCtx.commands.register({
|
||||
name: 'cancel',
|
||||
description: 'Cancel the active turn',
|
||||
surfaces: ['tui'],
|
||||
handler: () => {
|
||||
if (agent.status !== 'running') return { kind: 'error', text: 'The agent is already idle.' }
|
||||
agent.cancel('cancelled from terminal')
|
||||
@@ -1201,25 +1198,21 @@ export function createTuiChat(
|
||||
commandCtx.commands.register({
|
||||
name: 'reasoning',
|
||||
description: 'Toggle reasoning blocks',
|
||||
surfaces: ['tui'],
|
||||
handler: () => { toggleReasoning(); return { kind: 'success' } },
|
||||
})
|
||||
commandCtx.commands.register({
|
||||
name: 'tools',
|
||||
description: 'Expand or collapse all tool cards',
|
||||
surfaces: ['tui'],
|
||||
handler: () => { toggleTools(); return { kind: 'success' } },
|
||||
})
|
||||
commandCtx.commands.register({
|
||||
name: 'redraw',
|
||||
description: 'Invalidate components and redraw the terminal',
|
||||
surfaces: ['tui'],
|
||||
handler: () => { ui.invalidate(); ui.requestRender(true); return { kind: 'success' } },
|
||||
})
|
||||
commandCtx.commands.register({
|
||||
name: 'exit',
|
||||
description: 'Exit after the active turn reaches idle',
|
||||
surfaces: ['tui'],
|
||||
handler: () => { requestExit(); return { kind: 'success' } },
|
||||
})
|
||||
})
|
||||
@@ -1227,7 +1220,7 @@ export function createTuiChat(
|
||||
const runCommand = (text: string): void => {
|
||||
const controller = new AbortController()
|
||||
commandControllers.add(controller)
|
||||
void ctx.commands.execute(agent, 'tui', text, controller.signal).then(
|
||||
void ctx.commands.execute(agent, text, controller.signal).then(
|
||||
(result) => {
|
||||
if (disposed) return
|
||||
if (result === undefined) {
|
||||
|
||||
@@ -442,13 +442,11 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
name: 'plugin-check',
|
||||
description: 'Run a plugin command',
|
||||
input: { hint: '<value>' },
|
||||
surfaces: ['tui'],
|
||||
handler,
|
||||
})
|
||||
result.ctx.commands.register({
|
||||
name: 'plugin-fail',
|
||||
description: 'Fail a plugin command',
|
||||
surfaces: ['tui'],
|
||||
handler: () => { throw new Error('plugin command exploded') },
|
||||
})
|
||||
|
||||
@@ -459,7 +457,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(handler).toHaveBeenCalledTimes(1)
|
||||
const invocation = handler.mock.calls[0]?.[0]
|
||||
expect(invocation?.agent).toBe(result.agent)
|
||||
expect(invocation?.surface).toBe('tui')
|
||||
// pi-tui's Editor owns terminal-line normalization and removes trailing
|
||||
// spaces before onSubmit; the registry preserves the adapter-delivered line.
|
||||
expect(invocation?.rawInput).toBe(' value')
|
||||
@@ -472,10 +469,10 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('/plugin-check <value> — Run a plugin command')
|
||||
expect(result.ctx.commands.list(result.agent, 'tui').map(command => command.name)).toContain('help')
|
||||
expect(result.ctx.commands.list(result.agent).map(command => command.name)).toContain('help')
|
||||
|
||||
await result.controller.dispose()
|
||||
expect(result.ctx.commands.list(result.agent, 'tui').map(command => command.name)).toEqual([
|
||||
expect(result.ctx.commands.list(result.agent).map(command => command.name)).toEqual([
|
||||
'plugin-check',
|
||||
'plugin-fail',
|
||||
])
|
||||
@@ -490,7 +487,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
result.ctx.commands.register({
|
||||
name: 'wait-plugin',
|
||||
description: 'Wait until disposal',
|
||||
surfaces: ['tui'],
|
||||
handler: ({ signal }) => {
|
||||
commandSignal = signal
|
||||
started()
|
||||
@@ -518,7 +514,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
result.ctx.commands.register({
|
||||
name: 'late-success',
|
||||
description: 'Resolve while the TUI closes',
|
||||
surfaces: ['tui'],
|
||||
handler: () => new Promise((resolve) => {
|
||||
resolveCommand = resolve
|
||||
started()
|
||||
@@ -1027,7 +1022,7 @@ describe('terminal mounting', () => {
|
||||
expect(() => createTuiChat(ctx, { sessionId: 'failed-start-session', color: false }, { terminal, exit: vi.fn() }))
|
||||
.toThrow('terminal startup failed')
|
||||
await tick()
|
||||
expect(ctx.commands.list(ctx.agents.get(SessionId('failed-start-session'))!, 'tui')).toEqual([])
|
||||
expect(ctx.commands.list(ctx.agents.get(SessionId('failed-start-session'))!)).toEqual([])
|
||||
expect(terminal.stopped).toBe(1)
|
||||
expect(terminal.progress).toEqual([false, true, false])
|
||||
await expect(ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Late?' }] }))
|
||||
|
||||
@@ -39,7 +39,6 @@
|
||||
{ "doc": "docs/core-data-structures/goal.md", "symbol": "EditGoalRequest", "source": "packages/goal/goal/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/goal.md", "symbol": "GoalChanged", "source": "packages/goal/goal/src/types.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/commands.md", "symbol": "CommandSurface", "source": "packages/ui/commands/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/commands.md", "symbol": "CommandInputDescriptor", "source": "packages/ui/commands/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/commands.md", "symbol": "CommandDefinition", "source": "packages/ui/commands/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/commands.md", "symbol": "CommandInvocation", "source": "packages/ui/commands/src/index.ts" },
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
|
||||
Human-command registry. Plain-context definitions are global; definitions registered through a command-injected child of an agent context shadow globals for that agent.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L235)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L207)
|
||||
|
||||
### ctx.commands.register(definition)
|
||||
|
||||
```ts website-api
|
||||
/**
|
||||
* Register a global or calling-agent-scoped command.
|
||||
* @param definition - discovery metadata, surface mask, and direct UI handler.
|
||||
* @param definition - discovery metadata and direct UI handler.
|
||||
* @returns the exact effect disposer that unregisters this definition.
|
||||
*/
|
||||
register(definition: CommandDefinition): () => void
|
||||
@@ -21,77 +21,71 @@ register(definition: CommandDefinition): () => void
|
||||
|
||||
Register a global or calling-agent-scoped command.
|
||||
|
||||
- `definition` — discovery metadata, surface mask, and direct UI handler.
|
||||
- `definition` — discovery metadata and direct UI handler.
|
||||
|
||||
**Returns** the exact effect disposer that unregisters this definition.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L248)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L220)
|
||||
|
||||
### ctx.commands.list(agent, surface)
|
||||
### ctx.commands.list(agent)
|
||||
|
||||
```ts website-api
|
||||
/**
|
||||
* List the effective immutable command descriptors for one agent and surface.
|
||||
* List the effective immutable command descriptors for one agent.
|
||||
* @param agent - exact receiving agent and scoped-layer key.
|
||||
* @param surface - UI adapter requesting discovery metadata.
|
||||
* @returns name-sorted descriptors after scoped shadowing and surface filtering.
|
||||
* @returns name-sorted descriptors after scoped shadowing.
|
||||
*/
|
||||
list(agent: Agent, surface: CommandSurface): readonly CommandDescriptor[]
|
||||
list(agent: Agent): readonly CommandDescriptor[]
|
||||
```
|
||||
|
||||
List the effective immutable command descriptors for one agent and surface.
|
||||
List the effective immutable command descriptors for one agent.
|
||||
|
||||
- `agent` — exact receiving agent and scoped-layer key.
|
||||
- `surface` — UI adapter requesting discovery metadata.
|
||||
|
||||
**Returns** name-sorted descriptors after scoped shadowing and surface filtering.
|
||||
**Returns** name-sorted descriptors after scoped shadowing.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L276)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L247)
|
||||
|
||||
### ctx.commands.find(agent, surface, name)
|
||||
### ctx.commands.find(agent, name)
|
||||
|
||||
```ts website-api
|
||||
/**
|
||||
* Resolve one effective command definition.
|
||||
* @param agent - exact receiving agent and scoped-layer key.
|
||||
* @param surface - UI adapter performing the lookup.
|
||||
* @param name - command name without a slash.
|
||||
* @returns the scoped shadow or global definition when visible on the surface.
|
||||
* @returns the scoped shadow or global definition.
|
||||
*/
|
||||
find(agent: Agent, surface: CommandSurface, name: string): CommandDefinition | undefined
|
||||
find(agent: Agent, name: string): CommandDefinition | undefined
|
||||
```
|
||||
|
||||
Resolve one effective command definition.
|
||||
|
||||
- `agent` — exact receiving agent and scoped-layer key.
|
||||
- `surface` — UI adapter performing the lookup.
|
||||
- `name` — command name without a slash.
|
||||
|
||||
**Returns** the scoped shadow or global definition when visible on the surface.
|
||||
**Returns** the scoped shadow or global definition.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L291)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L260)
|
||||
|
||||
### ctx.commands.execute(agent, surface, line, signal)
|
||||
### ctx.commands.execute(agent, line, signal)
|
||||
|
||||
```ts website-api
|
||||
/**
|
||||
* Parse and execute a known command without sending it to the model.
|
||||
* @param agent - exact receiving agent.
|
||||
* @param surface - dispatching UI adapter.
|
||||
* @param line - complete slash-command line.
|
||||
* @param signal - cancellation signal owned by the UI request.
|
||||
* @returns a detached result, or `undefined` when syntax/name/surface does not resolve.
|
||||
* @returns a detached result, or `undefined` when syntax or name does not resolve.
|
||||
*/
|
||||
async execute( agent: Agent, surface: CommandSurface, line: string, signal: AbortSignal, ): Promise<CommandResult | undefined>
|
||||
async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise<CommandResult | undefined>
|
||||
```
|
||||
|
||||
Parse and execute a known command without sending it to the model.
|
||||
|
||||
- `agent` — exact receiving agent.
|
||||
- `surface` — dispatching UI adapter.
|
||||
- `line` — complete slash-command line.
|
||||
- `signal` — cancellation signal owned by the UI request.
|
||||
|
||||
**Returns** a detached result, or `undefined` when syntax/name/surface does not resolve.
|
||||
**Returns** a detached result, or `undefined` when syntax or name does not resolve.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L304)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L271)
|
||||
@@ -490,7 +490,7 @@ Ask composed answerers for one decision. Return an outcome to claim the request
|
||||
|
||||
A command was registered or unregistered. This is an unfiltered registry notification because a global or scoped change may affect any UI view. Observer failures are contained and cannot veto the registry mutation.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L94)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L83)
|
||||
|
||||
## fs/*
|
||||
|
||||
|
||||
Reference in New Issue
Block a user