refactor(packages): dissolve ui/ and rename sdk/ to scaffold/
git mv per the regrouping RFC: the five human-collaboration seams and tui join packages/interaction/, app-boot becomes packages/boot/, and jsonrpc joins the renamed scaffold/ (formerly sdk/) as its server half beside client/protocol/create-sdk/helper/scripts/telemetry, whose folders drop the legacy sdk- prefix. Three new group README triplets replace the ui/ and sdk/ ones; tsconfig references/paths/globs, knip keys, vitest globs, gate scripts, catalogs, docs, and the lockfile follow. Adds the four settled FIXME rename markers (dsh-sdk-server, dsh-sdk-telemetry, dsh-sdk-helper, dsh-sdk-scripts). The scaffold folders diverge from their npm names until those renames land, so tsconfig.base.json maps the three affected names explicitly beside the group wildcard. Also repairs two pre-existing stale-path classes the strengthened sweep surfaced: docs/web-styling.md's retired web-ui host package and type-model spec fixture-literal joins. app-boot's three Loader-composition specs time out at the default 5s under full-suite parallel load on this filesystem (pre-existing; pass isolated with --testTimeout=30000); interaction/scaffold/boot suites otherwise green (687 passed).
This commit is contained in:
6
packages/interaction/commands/README.i18n.yaml
Normal file
6
packages/interaction/commands/README.i18n.yaml
Normal file
@@ -0,0 +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 packages/interaction/commands/README.md
|
||||
README.md: 1709bdcdce4e43d98cfea5ff3972ab95bfd3c33b
|
||||
README.zh.md: 569f2aa8293793b26d63ee16e3ea7600e04a8397
|
||||
40
packages/interaction/commands/README.md
Normal file
40
packages/interaction/commands/README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# @deepseek-ai/dsh-commands
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Plugin-owned human-command registry consumed by interactive UI adapters. The [plugin command registration Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) owns the boundary and dispatch contract.
|
||||
|
||||
## Service contract
|
||||
|
||||
`ctx.commands.register(definition)` registers one lowercase command name, description, optional unstructured-input hint, optional `recordInput` policy, and abortable handler. `recordInput` defaults to true; a command whose authoritative domain event owns the payload sets it to false so `command/run` omits `args` instead of duplicating the input. 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)` 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 the settled `CommandExecution` (the normalized result plus the lifecycle pairing `commandId`) or `undefined` for invalid syntax or unknown names. A resolved command's lifecycle is logged on the receiving agent's session as the log-only pair `command/run` (before the handler, with a minted `commandId`, the parser's structured name, the issuing `CommandSource`, and `args` unless `recordInput` is false) and `command/done` (at settlement, with the outcome kind and verbatim text; a successful result may also name an earlier non-command authoritative domain event through `sourceEventSeq`; a thrown or aborted handler settles as `kind: 'error'`). Admission misses log nothing. Both are direct standalone appends on the receiving agent's session: no turn wraps them, and persistence drains them through ordinary checkpoints and teardown.
|
||||
|
||||
`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.
|
||||
|
||||
Handlers return `success` or `error` plus optional UI text. A successful handler may also return `sourceEventSeq` when an earlier domain event owns a richer presentation; the lifecycle invariant requires that reference to be a prior non-command event in the same session. Results are rendered directly by the adapter and never enter model history. The registry never submits `rawInput` to the agent implicitly; a command producer may explicitly schedule model-visible work through the receiving `Agent`, in which case that producer owns the resulting message contract. The registry races handler completion against the supplied abort signal, but an uncooperative handler may continue its own external side effects after the caller stops awaiting it.
|
||||
|
||||
## Composition
|
||||
|
||||
The shipped `dsh` base mounts this service and the Web client dispatches through it. UI-less demo spines and ACP automation do not provide a command adapter. Custom interactive compositions and command producers mount `@deepseek-ai/dsh-commands` explicitly.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Direct human commands
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The registry itself submits nothing. Known slash commands execute in the UI command plane, and their `CommandResult` text is not submitted as a user message. Unknown slash-command input is rejected by shipped adapters instead of becoming a model prompt. A command producer may explicitly use the receiving `Agent`; for example, [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) submits the optional message in `/plan [message]` after selecting plan mode.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Command discovery, execution, and UI output add no model tokens. Explicit agent work scheduled by a command producer has the same token effect as the corresponding agent input.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Registry metadata, command input, and direct output never enter a model request and do not affect its cache. A mutated domain owns any later cache effect.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Only unstructured text input** — forms, completion schemas, and typed arguments remain command-owned parsing concerns.
|
||||
- **Cooperative side-effect cancellation** — dispatch stops awaiting on abort; handlers must honor the signal to stop work that has already escaped into external systems.
|
||||
40
packages/interaction/commands/README.zh.md
Normal file
40
packages/interaction/commands/README.zh.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# @deepseek-ai/dsh-commands
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
由插件负责、供交互式 UI 适配器使用的面向用户命令注册表。[插件命令注册 Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md)定义了其边界与分发契约。
|
||||
|
||||
## 服务契约
|
||||
|
||||
`ctx.commands.register(definition)` 注册一个小写命令名称、描述、可选的非结构化输入提示、可选的 `recordInput` 策略,以及可中止的处理器。`recordInput` 默认为 true;若载荷由命令的权威领域事件持有,该命令会将 `recordInput` 设为 false,让 `command/run` 省略 `args`,避免重复记录输入。每个已注册命令都可供所有已组合的命令适配器使用;与某项部署不兼容的插件不会在此注册。普通上下文中的注册全局生效。在 `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`、解析器的结构化名称、发起方 `CommandSource`,以及 `args`(`recordInput` 为 false 时省略))与 `command/done`(结算时记录,携带结果类型与原样文本;成功结果还可通过 `sourceEventSeq` 指向更早的一条非命令权威领域事件;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。两者都直接独立追加到接收 agent 的会话中:没有轮次包裹它们,持久化机制会在常规检查点和销毁期间排空这些事件。
|
||||
|
||||
`parseCommand()` 识别位于第 0 字节的斜杠、由小写字母、数字、`_` 或 `-` 构成的名称,以及名称后紧接输入末尾或空白的形式。它将名称后的每个字节作为 `rawInput` 返回,其中包括分隔空白;消费方负责各命令专用的语法,只能执行该语法允许的规范化。
|
||||
|
||||
处理器返回 `success` 或 `error`,并可附带 UI 文本。若更丰富的呈现由一条更早的领域事件持有,成功的处理器还可返回 `sourceEventSeq`;生命周期不变量要求该引用指向同一会话中更早的一条非命令事件。适配器直接渲染结果,结果绝不进入模型历史。注册表绝不会隐式地把 `rawInput` 提交给 agent;命令生产方可以通过接收命令的 `Agent` 显式安排模型可见工作,此时该生产方负责由此产生的消息契约。注册表会同时等待处理器完成和所提供的中止信号,以先发生者为准,但不响应中止的处理器可能在调用方停止等待后继续产生自身的外部副作用。
|
||||
|
||||
## 组合
|
||||
|
||||
随产品交付的 `dsh` 基础组合会挂载此服务,Web 客户端通过它分派命令。无 UI 的演示主干和 ACP(Agent Client Protocol)自动化不提供命令适配器。自定义交互式组合与命令生产方会显式挂载 `@deepseek-ai/dsh-commands`。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 直接面向用户的命令
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
注册表自身不会提交任何内容。已知斜杠命令在 UI 命令平面执行,其 `CommandResult` 文本不会作为用户消息提交。已交付的适配器会拒绝未知斜杠命令输入,而不是将其变成模型提示词。命令生产方可以显式使用接收命令的 `Agent`;例如,[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces)在选择 plan mode 后,会提交 `/plan [message]` 中的可选消息。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
命令发现、执行和 UI 输出不会增加模型 token。命令生产方显式安排的 agent 工作与相应 agent 输入具有相同的 token 影响。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
注册表元数据、命令输入和直接输出绝不会进入模型请求,也不会影响其缓存。发生变更的领域负责之后产生的所有缓存影响。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **仅支持非结构化文本输入**:表单、补全 schema 和类型化参数仍由各命令自行解析。
|
||||
- **副作用采用协作式取消**:中止后,分发会停止等待;处理器必须遵循信号,才能停止已经进入外部系统的工作。
|
||||
48
packages/interaction/commands/package.json
Normal file
48
packages/interaction/commands/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-commands",
|
||||
"description": "Plugin-owned human command registry for DeepSeek Harness UI surfaces",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./brand": {
|
||||
"types": "./lib/types/brand.d.ts",
|
||||
"default": "./lib/types/brand.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
29
packages/interaction/commands/src/brand.ts
Normal file
29
packages/interaction/commands/src/brand.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* dsh-commands' owned branded id: command lifecycle pairing across the
|
||||
* session log, the wire admission response, and client-side flow pairing.
|
||||
*
|
||||
* The `Branded<B>` primitive lives in `@deepseek-ai/dsh-brand`; this module
|
||||
* is a pure type/constructor outlet (no cordis imports, no module
|
||||
* augmentation) so wire and client programs can name the brand without
|
||||
* loading the host plugin's Context merges — the `dsh-llm/brand` shape.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-commands/brand
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/**
|
||||
* Pairs one command execution's `command/run`/`command/done` lifecycle
|
||||
* records with each other and with the `command.execute` admission response.
|
||||
* Minted by the executor, monotonic per service instance.
|
||||
*/
|
||||
export type CommandId = Branded<'CommandId'>
|
||||
|
||||
/**
|
||||
* Brand a string as a {@link CommandId}.
|
||||
* @param id - the executor-minted pairing id.
|
||||
* @returns the same string, branded; no validation is performed.
|
||||
*/
|
||||
export function CommandId(id: string): CommandId {
|
||||
return id as CommandId
|
||||
}
|
||||
465
packages/interaction/commands/src/index.ts
Normal file
465
packages/interaction/commands/src/index.ts
Normal file
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* Plugin-owned human-command registry shared by interactive UI adapters.
|
||||
* @module @deepseek-ai/dsh-commands
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope'
|
||||
import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope'
|
||||
import type { Session, SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session'
|
||||
import { CommandId } from './brand.ts'
|
||||
|
||||
export { CommandId } from './brand.ts'
|
||||
|
||||
export const name = 'commands'
|
||||
|
||||
const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u
|
||||
|
||||
/**
|
||||
* Producer record for one command invocation (the `command/run` event's
|
||||
* provenance slot). Merge-extensible sum type mirroring `MessageSourceMap`'s
|
||||
* shape; minimal today because every executor caller is a human-facing UI
|
||||
* surface dispatching a human-typed line, so the sole variant is `user`.
|
||||
*/
|
||||
export interface CommandSourceMap {
|
||||
user: { kind: 'user' }
|
||||
}
|
||||
|
||||
/** The union over {@link CommandSourceMap} — who issued a command line. */
|
||||
export type CommandSource = CommandSourceMap[keyof CommandSourceMap]
|
||||
|
||||
/** Immutable metadata for a command's optional unstructured input. */
|
||||
export interface CommandInputDescriptor {
|
||||
/** Placeholder shown before the user supplies free-form input. */
|
||||
readonly hint: string
|
||||
}
|
||||
|
||||
/** Invocation passed to one registered command handler. */
|
||||
export interface CommandInvocation {
|
||||
/** Exact agent whose human-facing surface received the command. */
|
||||
readonly agent: Agent
|
||||
/** Exact text following the registered command name, including separator whitespace. */
|
||||
readonly rawInput: string
|
||||
/** Cancellation signal owned by the dispatching UI request. */
|
||||
readonly signal: AbortSignal
|
||||
}
|
||||
|
||||
/** Expected command outcome rendered directly by the dispatching UI. */
|
||||
export type CommandResult =
|
||||
| {
|
||||
readonly kind: 'success'
|
||||
readonly text?: string
|
||||
/** Earlier authoritative domain event that owns a richer presentation. */
|
||||
readonly sourceEventSeq?: number
|
||||
}
|
||||
| { readonly kind: 'error'; readonly text: string }
|
||||
|
||||
/**
|
||||
* One settled command execution: the handler's normalized result plus the
|
||||
* lifecycle pairing id minted for its `command/run`/`command/done` records,
|
||||
* so a dispatching surface can correlate the RPC-level acknowledgment with
|
||||
* the flow node those events produce.
|
||||
*/
|
||||
export interface CommandExecution {
|
||||
/** Pairing id carried by this execution's lifecycle events. */
|
||||
readonly commandId: CommandId
|
||||
/** The handler's normalized outcome. */
|
||||
readonly result: CommandResult
|
||||
}
|
||||
|
||||
/** Plugin-owned command registration. */
|
||||
export interface CommandDefinition {
|
||||
/** Lowercase command name without the leading slash. */
|
||||
readonly name: string
|
||||
/** Human-readable summary used in discovery UI. */
|
||||
readonly description: string
|
||||
/** Optional free-form input hint advertised to capable clients. */
|
||||
readonly input?: CommandInputDescriptor
|
||||
/**
|
||||
* Whether `command/run` records `rawInput`. Defaults to true. A command
|
||||
* whose domain event owns the payload sets this false to avoid duplicating
|
||||
* that payload in the session log.
|
||||
*/
|
||||
readonly recordInput?: boolean
|
||||
/** Execute against the receiving agent without sending the command to the model. */
|
||||
readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>
|
||||
}
|
||||
|
||||
/** Handler-free immutable command view returned to UI adapters. */
|
||||
export interface CommandDescriptor {
|
||||
/** Lowercase command name without the leading slash. */
|
||||
readonly name: string
|
||||
/** Human-readable summary used in discovery UI. */
|
||||
readonly description: string
|
||||
/** Optional free-form input hint advertised to capable clients. */
|
||||
readonly input?: CommandInputDescriptor
|
||||
}
|
||||
|
||||
/** Syntactically valid slash command before registry resolution. */
|
||||
export interface ParsedCommand {
|
||||
/** Lowercase command name without the leading slash. */
|
||||
readonly name: string
|
||||
/** Exact text following the command name. */
|
||||
readonly rawInput: string
|
||||
}
|
||||
|
||||
interface RegisteredCommand {
|
||||
readonly definition: CommandDefinition
|
||||
readonly descriptor: CommandDescriptor
|
||||
}
|
||||
|
||||
/** All command registrations owned by one global or scoped layer. */
|
||||
class CommandLayer implements ScopeLayer {
|
||||
readonly commands: NamedEntries<RegisteredCommand>
|
||||
|
||||
/**
|
||||
* Create one command layer with diagnostics specific to its ownership scope.
|
||||
* @param scope - the scoped owner, or `undefined` for global registrations.
|
||||
*/
|
||||
constructor(scope: ScopeKey | undefined) {
|
||||
this.commands = new NamedEntries(name => new Error(scope === undefined
|
||||
? `command "${name}" is already registered (for a per-agent variant, mount a command-injected plugin under that agent's \`agent.ctx\`)`
|
||||
: `command "${name}" is already registered in this scope`))
|
||||
}
|
||||
|
||||
/** @returns whether this layer owns no command registrations. */
|
||||
isEmpty(): boolean {
|
||||
return this.commands.isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/**
|
||||
* A resolved slash command entered its handler. Log-only (never model
|
||||
* surface); paired with `command/done` by `commandId`, mirroring the
|
||||
* `tool/call`↔`tool/result` pairing. The payload is structured — `name`
|
||||
* and `args` are `parseCommand`'s own split (name and verbatim rawInput,
|
||||
* separator whitespace included), so a consumer (a projection unit
|
||||
* folding its own command records, a rich command card) never re-parses
|
||||
* a line. `args` is absent when the definition sets `recordInput: false`
|
||||
* because an authoritative domain event owns the input payload.
|
||||
*/
|
||||
'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource }
|
||||
/**
|
||||
* The paired command settled. `kind`/`text` carry the handler's verbatim
|
||||
* outcome (a thrown/aborted handler settles as `kind: 'error'` with the
|
||||
* rendered failure). A successful command may identify the earlier
|
||||
* authoritative domain event for a richer client-computed presentation.
|
||||
*/
|
||||
'command/done': {
|
||||
commandId: CommandId
|
||||
kind: 'success' | 'error'
|
||||
text?: string
|
||||
sourceEventSeq?: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
commands: CommandService
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* 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.
|
||||
* @mode emit
|
||||
*/
|
||||
'commands/change'(): void
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an exact slash command without normalizing its trailing input.
|
||||
*
|
||||
* @param line - Complete candidate command line.
|
||||
* @returns The parsed command, or `undefined` when the line is not a command.
|
||||
*/
|
||||
export function parseCommand(line: string): ParsedCommand | undefined {
|
||||
const match = /^\/([a-z][a-z0-9_-]*)(?=$|[\t\n\r ])/u.exec(line)
|
||||
if (match === null) return undefined
|
||||
const name = match[1]
|
||||
/* v8 ignore next -- the first capture is required whenever the regular expression matches */
|
||||
if (name === undefined) return undefined
|
||||
return Object.freeze({ name, rawInput: line.slice(match[0].length) })
|
||||
}
|
||||
|
||||
/** Convert arbitrary abort reasons to one stable rejected Error. */
|
||||
function abortError(signal: AbortSignal): Error {
|
||||
if (signal.reason instanceof Error) return signal.reason
|
||||
return new Error(typeof signal.reason === 'string' ? signal.reason : 'command aborted')
|
||||
}
|
||||
|
||||
/** Render arbitrary thrown values without trusting their string coercion. */
|
||||
function renderThrown(value: unknown): string {
|
||||
try {
|
||||
return String(value)
|
||||
} catch {
|
||||
return '<unrenderable thrown value>'
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop awaiting an uncooperative handler once its owning UI request aborts. */
|
||||
function withAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
if (signal.aborted) return Promise.reject(abortError(signal))
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const onAbort = (): void => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
reject(abortError(signal))
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
promise.then(
|
||||
(value) => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve(value)
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
reject(error instanceof Error
|
||||
? error
|
||||
: new Error(`command handler rejected with a non-Error value: ${renderThrown(error)}`, { cause: error }))
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/** Reject invalid command metadata before it can reach a UI protocol. */
|
||||
function normalizeDefinition(definition: CommandDefinition): RegisteredCommand {
|
||||
if (!COMMAND_NAME.test(definition.name)) {
|
||||
throw new TypeError(`command name "${definition.name}" must match ${String(COMMAND_NAME)}`)
|
||||
}
|
||||
if (typeof definition.description !== 'string') {
|
||||
throw new TypeError(`command "${definition.name}" description must be a string`)
|
||||
}
|
||||
if (definition.description.trim().length === 0) {
|
||||
throw new TypeError(`command "${definition.name}" description must not be empty`)
|
||||
}
|
||||
if (typeof definition.handler !== 'function') {
|
||||
throw new TypeError(`command "${definition.name}" handler must be a function`)
|
||||
}
|
||||
const rawInput: unknown = definition.input
|
||||
let input: CommandInputDescriptor | undefined
|
||||
if (rawInput !== undefined) {
|
||||
if (typeof rawInput !== 'object' || rawInput === null || !('hint' in rawInput)
|
||||
|| typeof rawInput.hint !== 'string') {
|
||||
throw new TypeError(`command "${definition.name}" input hint must be a string`)
|
||||
}
|
||||
if (rawInput.hint.trim().length === 0) {
|
||||
throw new TypeError(`command "${definition.name}" input hint must not be empty`)
|
||||
}
|
||||
input = Object.freeze({ hint: rawInput.hint })
|
||||
}
|
||||
const normalized = Object.freeze({
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
...input === undefined ? {} : { input },
|
||||
...definition.recordInput === undefined ? {} : { recordInput: definition.recordInput },
|
||||
handler: definition.handler,
|
||||
})
|
||||
const descriptor = Object.freeze({
|
||||
name: normalized.name,
|
||||
description: normalized.description,
|
||||
...normalized.input === undefined ? {} : { input: normalized.input },
|
||||
})
|
||||
return { definition: normalized, descriptor }
|
||||
}
|
||||
|
||||
/** Validate and detach an untrusted handler result at the registry boundary. */
|
||||
function normalizeResult(command: string, value: unknown): CommandResult {
|
||||
if (typeof value !== 'object' || value === null || !('kind' in value)) {
|
||||
throw new TypeError(`command "${command}" handler must return a CommandResult`)
|
||||
}
|
||||
const result = value as { kind?: unknown; text?: unknown; sourceEventSeq?: unknown }
|
||||
if (result.kind === 'success') {
|
||||
if (result.text !== undefined && typeof result.text !== 'string') {
|
||||
throw new TypeError(`command "${command}" success text must be a string when supplied`)
|
||||
}
|
||||
if (result.sourceEventSeq !== undefined
|
||||
&& (!Number.isSafeInteger(result.sourceEventSeq) || (result.sourceEventSeq as number) < 0)) {
|
||||
throw new TypeError(`command "${command}" success sourceEventSeq must be a non-negative safe integer when supplied`)
|
||||
}
|
||||
return Object.freeze({
|
||||
kind: 'success',
|
||||
...result.text === undefined ? {} : { text: result.text },
|
||||
...result.sourceEventSeq === undefined ? {} : { sourceEventSeq: result.sourceEventSeq as number },
|
||||
})
|
||||
}
|
||||
if (result.kind === 'error') {
|
||||
if (typeof result.text !== 'string' || result.text.trim().length === 0) {
|
||||
throw new TypeError(`command "${command}" error text must be a non-empty string`)
|
||||
}
|
||||
return Object.freeze({ kind: 'error', text: result.text })
|
||||
}
|
||||
throw new TypeError(`command "${command}" returned unknown result kind "${String(result.kind)}"`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-command registry. Plain-context definitions are global; definitions
|
||||
* registered through a command-injected child of an agent context shadow
|
||||
* globals for that agent.
|
||||
*/
|
||||
export class CommandService extends Service {
|
||||
private readonly layers = new ScopedLayers(
|
||||
scope => new CommandLayer(scope),
|
||||
() => { this.notifyChange() },
|
||||
)
|
||||
|
||||
/** Monotonic per-instance counter behind {@link mintCommandId}. */
|
||||
private commandSeq = 0
|
||||
/** Instance token keeping minted ids unique across process restarts over one resumed log. */
|
||||
private readonly instanceToken = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'commands')
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a global or calling-agent-scoped command.
|
||||
* @param definition - discovery metadata and direct UI handler.
|
||||
* @returns the exact effect disposer that unregisters this definition.
|
||||
*/
|
||||
register(definition: CommandDefinition): () => void {
|
||||
const registered = normalizeDefinition(definition)
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
layer => layer.commands.insert(registered.definition.name, registered),
|
||||
{ label: 'commands.register()' },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* List the effective immutable command descriptors for one agent.
|
||||
* @param agent - exact receiving agent and scoped-layer key.
|
||||
* @returns name-sorted descriptors after scoped shadowing.
|
||||
*/
|
||||
list(agent: Agent): readonly CommandDescriptor[] {
|
||||
return Object.freeze([...this.view(agent).values()]
|
||||
.map(command => command.descriptor)
|
||||
// Names are unique in the effective view, so equality is impossible.
|
||||
.sort((left, right) => left.name < right.name ? -1 : 1))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one effective command definition.
|
||||
* @param agent - exact receiving agent and scoped-layer key.
|
||||
* @param name - command name without a slash.
|
||||
* @returns the scoped shadow or global definition.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* A resolved command's lifecycle is logged: `command/run` is appended
|
||||
* before the handler is invoked and `command/done` after settlement (a
|
||||
* thrown or aborted handler settles as `kind: 'error'`). Both are direct
|
||||
* log-only appends — no turn wraps them, and persistence drains them at
|
||||
* ordinary checkpoints. Admission misses (syntax or unknown name) log
|
||||
* nothing — they never entered a handler. A `command/run` append failure
|
||||
* fails the execution loud; a `command/done` append failure on the
|
||||
* handler-failure path is contained so the handler's own error stays the
|
||||
* reported failure.
|
||||
*
|
||||
* @param agent - exact receiving agent.
|
||||
* @param line - complete slash-command line.
|
||||
* @param signal - cancellation signal owned by the UI request.
|
||||
* @returns the settled execution (result + lifecycle pairing id), or
|
||||
* `undefined` when syntax or name does not resolve.
|
||||
*/
|
||||
async execute(
|
||||
agent: Agent,
|
||||
line: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<CommandExecution | undefined> {
|
||||
const parsed = parseCommand(line)
|
||||
if (parsed === undefined) return undefined
|
||||
const command = this.view(agent).get(parsed.name)
|
||||
if (command === undefined) return undefined
|
||||
if (signal.aborted) throw abortError(signal)
|
||||
const commandId = this.mintCommandId()
|
||||
this.appendLifecycle(agent.session, 'command/run', {
|
||||
commandId,
|
||||
name: parsed.name,
|
||||
...command.definition.recordInput === false ? {} : { args: parsed.rawInput },
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
const invocation = Object.freeze({ agent, rawInput: parsed.rawInput, signal })
|
||||
let result: CommandResult
|
||||
try {
|
||||
const output = command.definition.handler(invocation)
|
||||
result = normalizeResult(parsed.name, await withAbort(Promise.resolve(output), signal))
|
||||
} catch (error: unknown) {
|
||||
try {
|
||||
this.appendLifecycle(agent.session, 'command/done', {
|
||||
commandId, kind: 'error',
|
||||
text: error instanceof Error ? error.message : renderThrown(error),
|
||||
})
|
||||
} catch (appendError: unknown) {
|
||||
this.ctx.logger.warn(`command "${parsed.name}": command/done append failed: ${renderThrown(appendError)}`)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
this.appendLifecycle(agent.session, 'command/done', {
|
||||
commandId, kind: result.kind,
|
||||
...result.text === undefined ? {} : { text: result.text },
|
||||
...result.kind === 'success' && result.sourceEventSeq !== undefined
|
||||
? { sourceEventSeq: result.sourceEventSeq }
|
||||
: {},
|
||||
})
|
||||
return Object.freeze({ commandId, result })
|
||||
}
|
||||
|
||||
/** Mint the next pairing id (monotonic; instance-token-prefixed so a resumed log never repeats one). */
|
||||
private mintCommandId(): CommandId {
|
||||
this.commandSeq += 1
|
||||
return CommandId(`cmd-${this.instanceToken}-${this.commandSeq}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one log-only lifecycle event directly: no turn is opened for it and
|
||||
* no flush is forced — persistence observes the eager `session/event` path
|
||||
* and drains at ordinary checkpoints and teardown, like every other
|
||||
* standalone plugin event.
|
||||
*/
|
||||
private appendLifecycle<T extends 'command/run' | 'command/done'>(
|
||||
session: Session,
|
||||
type: T,
|
||||
data: SessionEventMap[T],
|
||||
): SessionEvent<T> {
|
||||
// Both admitted types are log-only (non-surface), but TypeScript does not
|
||||
// reduce Session.append's conditional rest parameter through a generic
|
||||
// type parameter. Preserve the proven two-argument call shape.
|
||||
const appendLogOnly = session.append.bind(session) as (eventType: T, eventData: SessionEventMap[T]) => SessionEvent<T>
|
||||
return appendLogOnly(type, data)
|
||||
}
|
||||
|
||||
/** Resolve global definitions followed by exact scoped shadows. */
|
||||
private view(agent: Agent): Map<string, RegisteredCommand> {
|
||||
return this.layers.merge(agent, layer => layer.commands)
|
||||
}
|
||||
|
||||
/** Notify every registry observer without making UI refresh load-bearing. */
|
||||
private notifyChange(): void {
|
||||
// Cordis emit uses Array.map: one synchronous throw starves later listeners,
|
||||
// and returned promises are discarded. Registry notifications are
|
||||
// non-vetoing, so contain each callback independently.
|
||||
for (const callback of this.ctx.events.dispatch('emit', ['commands/change'])) {
|
||||
try {
|
||||
const returned: unknown = callback()
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
this.ctx.logger.warn(`commands/change listener rejected: ${renderThrown(error)}`)
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`commands/change listener threw: ${renderThrown(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default CommandService
|
||||
65
packages/interaction/commands/src/invariant.ts
Normal file
65
packages/interaction/commands/src/invariant.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-commands`:
|
||||
* command lifecycle events pair by commandId within one session log.
|
||||
* @module @deepseek-ai/dsh-commands/invariant
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-commands'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'commands-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
|
||||
/** Install pairing validation over loaded logs and newly appended lifecycle events. */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
// Install-scoped so a dispose/re-register cycle re-sweeps from a clean slate.
|
||||
const runIds = new WeakMap<Session, Set<string>>()
|
||||
const validateEvent = (session: Session, event: SessionEvent): void => {
|
||||
if (event.type === 'command/run') {
|
||||
const ids = runIds.get(session) ?? new Set<string>()
|
||||
if (ids.has(event.data.commandId)) {
|
||||
fail(`command/run repeats commandId ${JSON.stringify(event.data.commandId)}`)
|
||||
}
|
||||
ids.add(event.data.commandId)
|
||||
runIds.set(session, ids)
|
||||
return
|
||||
}
|
||||
if (event.type !== 'command/done') return
|
||||
if (runIds.get(session)?.has(event.data.commandId) !== true) {
|
||||
fail(`command/done ${JSON.stringify(event.data.commandId)} pairs no prior command/run in this log`)
|
||||
}
|
||||
const source = event.data.sourceEventSeq
|
||||
const sourceEvent = source === undefined ? undefined : session.events[source]
|
||||
if (source !== undefined
|
||||
&& (event.data.kind !== 'success'
|
||||
|| !Number.isSafeInteger(source) || source < 0 || source >= event.seq
|
||||
|| sourceEvent?.seq !== source
|
||||
|| sourceEvent.type === 'command/run'
|
||||
|| sourceEvent.type === 'command/done')) {
|
||||
fail(`command/done ${JSON.stringify(event.data.commandId)} has invalid sourceEventSeq ${String(source)}`)
|
||||
}
|
||||
}
|
||||
for (const session of ctx.sessions.list()) {
|
||||
for (const event of session.events) validateEvent(session, event)
|
||||
}
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const [session, event] = args as [Session, SessionEvent]
|
||||
validateEvent(session, event)
|
||||
}, { global: true })
|
||||
}, { inject: ['sessions'] })
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
465
packages/interaction/commands/tests/commands.spec.ts
Normal file
465
packages/interaction/commands/tests/commands.spec.ts
Normal file
@@ -0,0 +1,465 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import CommandService, { parseCommand, type CommandDefinition } from '@deepseek-ai/dsh-commands'
|
||||
|
||||
function command(name: string, text = `ran:${name}`): CommandDefinition {
|
||||
return {
|
||||
name,
|
||||
description: `command ${name}`,
|
||||
handler: () => ({ kind: 'success', text }),
|
||||
}
|
||||
}
|
||||
|
||||
async function mount(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(CommandService)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Mint a scope whose key is a live agent (real session: the executor logs lifecycle events on it). */
|
||||
async function mintAgentScope(ctx: Context, name: string): Promise<{ scope: Scope; agent: Agent }> {
|
||||
const session = ctx.sessions.create(SessionId(name))
|
||||
const agent = { id: session.id, session } as Agent
|
||||
let scope!: Scope
|
||||
await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, { inject: ['commands'] }))
|
||||
return { scope, agent }
|
||||
}
|
||||
|
||||
/** The lifecycle slice of one agent's log (boundary markers stripped). */
|
||||
function lifecycleOf(agent: Agent): Array<{ type: string; data: unknown }> {
|
||||
return agent.session.events
|
||||
.filter(event => event.type === 'command/run' || event.type === 'command/done')
|
||||
.map(event => ({ type: event.type, data: event.data }))
|
||||
}
|
||||
|
||||
describe('parseCommand()', () => {
|
||||
it.each([
|
||||
['/goal', { name: 'goal', rawInput: '' }],
|
||||
['/goal create the thing', { name: 'goal', rawInput: ' create the thing' }],
|
||||
['/goal\ncreate the thing', { name: 'goal', rawInput: '\ncreate the thing' }],
|
||||
['/goal_name-2\t x ', { name: 'goal_name-2', rawInput: '\t x ' }],
|
||||
] as const)('parses %j without normalizing trailing input', (line, expected) => {
|
||||
expect(parseCommand(line)).toEqual(expected)
|
||||
})
|
||||
|
||||
it.each(['goal', ' /goal', '/', '/Goal', '/goal/path', '/goal🔥'])('rejects non-command boundary %j', (line) => {
|
||||
expect(parseCommand(line)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CommandService', () => {
|
||||
it('lists immutable global descriptors with input metadata', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
const definition: CommandDefinition = {
|
||||
name: 'inspect',
|
||||
description: 'Inspect state',
|
||||
input: { hint: '<target>' },
|
||||
handler: () => ({ kind: 'success' }),
|
||||
}
|
||||
ctx.commands.register(definition)
|
||||
|
||||
const listed = ctx.commands.list(agent)
|
||||
expect(listed).toEqual([{
|
||||
name: 'inspect',
|
||||
description: 'Inspect state',
|
||||
input: { hint: '<target>' },
|
||||
}])
|
||||
expect(Object.isFrozen(listed)).toBe(true)
|
||||
expect(Object.isFrozen(listed[0])).toBe(true)
|
||||
expect(Object.isFrozen(listed[0]?.input)).toBe(true)
|
||||
expect(ctx.commands.find(agent, 'inspect')).toMatchObject({ name: 'inspect' })
|
||||
expect(ctx.commands.find(agent, 'missing')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('sorts distinct effective command names', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register(command('zeta'))
|
||||
ctx.commands.register(command('alpha'))
|
||||
ctx.commands.register(command('middle'))
|
||||
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 () => {
|
||||
const ctx = await mount()
|
||||
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'))
|
||||
|
||||
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))?.result)
|
||||
.toEqual({ kind: 'success', text: 'scoped' })
|
||||
|
||||
await scope.dispose()
|
||||
expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.result.text).toBe('global')
|
||||
})
|
||||
|
||||
it('removes a registration when its contributing plugin fiber is disposed', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.commands.register(command('temporary'))
|
||||
}, { inject: ['commands'] }))
|
||||
expect(ctx.commands.find(agent, 'temporary')).toBeDefined()
|
||||
|
||||
await fiber.dispose()
|
||||
|
||||
expect(ctx.commands.find(agent, 'temporary')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects duplicates within one layer while allowing a scoped shadow', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register(command('same'))
|
||||
expect(() => ctx.commands.register(command('same'))).toThrow(/agent\.ctx/)
|
||||
scope.ctx.commands.register(command('same'))
|
||||
expect(() => scope.ctx.commands.register(command('same'))).toThrow(/already registered in this scope/)
|
||||
})
|
||||
|
||||
it('notifies on registration and disposal while containing broken observers', async () => {
|
||||
const ctx = await mount()
|
||||
const changed = vi.fn()
|
||||
ctx.on('commands/change', changed)
|
||||
const dispose = ctx.commands.register(command('live'))
|
||||
dispose()
|
||||
dispose()
|
||||
expect(changed).toHaveBeenCalledTimes(2)
|
||||
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
ctx.on('commands/change', () => { throw new Error('observer threw') })
|
||||
// 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)
|
||||
const removeContained = ctx.commands.register(command('contained'))
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
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, 'contained')).toBeUndefined()
|
||||
expect(afterFailures).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('rejects non-string descriptions and input hints with boundary diagnostics', async () => {
|
||||
const ctx = await mount()
|
||||
expect(() => ctx.commands.register({
|
||||
...command('description-type'),
|
||||
description: undefined,
|
||||
} as unknown as CommandDefinition)).toThrow('command "description-type" description must be a string')
|
||||
expect(() => ctx.commands.register({
|
||||
...command('hint-type'),
|
||||
input: { hint: 42 },
|
||||
} as unknown as CommandDefinition)).toThrow('command "hint-type" input hint must be a string')
|
||||
expect(() => ctx.commands.register({
|
||||
...command('input-type'),
|
||||
input: null,
|
||||
} as unknown as CommandDefinition)).toThrow('command "input-type" input hint must be a string')
|
||||
})
|
||||
|
||||
it('passes exact invocation context and detaches valid handler results', async () => {
|
||||
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', handler: seen })
|
||||
const controller = new AbortController()
|
||||
|
||||
const execution = await ctx.commands.execute(agent, '/run untouched ', controller.signal)
|
||||
|
||||
expect(execution?.result).toEqual({ kind: 'success', text: 'ok' })
|
||||
expect(execution?.commandId).toBeTruthy()
|
||||
expect(Object.isFrozen(execution)).toBe(true)
|
||||
expect(Object.isFrozen(execution?.result)).toBe(true)
|
||||
expect(seen).toHaveBeenCalledWith(expect.objectContaining({
|
||||
agent,
|
||||
rawInput: ' untouched ',
|
||||
signal: controller.signal,
|
||||
}))
|
||||
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 () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
let release!: (result: { kind: 'success'; text: string }) => void
|
||||
ctx.commands.register({
|
||||
name: 'wait',
|
||||
description: 'Wait',
|
||||
handler: () => new Promise((resolve) => { release = resolve }),
|
||||
})
|
||||
const running = new AbortController()
|
||||
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, '/wait', already.signal)).rejects.toThrow('already gone')
|
||||
|
||||
const defaultReason = new AbortController()
|
||||
defaultReason.abort({ source: 'test' })
|
||||
await expect(ctx.commands.execute(agent, '/wait', defaultReason.signal)).rejects.toThrow('command aborted')
|
||||
})
|
||||
|
||||
it('propagates an asynchronously rejected handler', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register({
|
||||
name: 'reject',
|
||||
description: 'Reject',
|
||||
handler: () => Promise.reject(new Error('handler rejected')),
|
||||
})
|
||||
await expect(ctx.commands.execute(agent, '/reject', new AbortController().signal))
|
||||
.rejects.toThrow('handler rejected')
|
||||
|
||||
ctx.commands.register({
|
||||
name: 'reject-value',
|
||||
description: 'Reject a non-Error value',
|
||||
// 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))
|
||||
.rejects.toThrow('command handler rejected with a non-Error value: not an Error')
|
||||
|
||||
const hostile = { toString(): string { throw new Error('cannot render') } }
|
||||
ctx.commands.register({
|
||||
name: 'reject-hostile',
|
||||
description: 'Reject an unrenderable value',
|
||||
// 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))
|
||||
.rejects.toMatchObject({
|
||||
message: 'command handler rejected with a non-Error value: <unrenderable thrown value>',
|
||||
cause: hostile,
|
||||
})
|
||||
})
|
||||
|
||||
it('observes an abort triggered synchronously inside the handler', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
const controller = new AbortController()
|
||||
ctx.commands.register({
|
||||
name: 'self-abort',
|
||||
description: 'Abort before returning',
|
||||
handler: () => {
|
||||
controller.abort('aborted in handler')
|
||||
return { kind: 'success' }
|
||||
},
|
||||
})
|
||||
await expect(ctx.commands.execute(agent, '/self-abort', controller.signal))
|
||||
.rejects.toThrow('aborted in handler')
|
||||
})
|
||||
|
||||
it('returns a detached expected-error result', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register({
|
||||
name: 'denied',
|
||||
description: 'Denied',
|
||||
handler: () => ({ kind: 'error', text: 'not now' }),
|
||||
})
|
||||
const execution = await ctx.commands.execute(agent, '/denied', new AbortController().signal)
|
||||
expect(execution?.result).toEqual({ kind: 'error', text: 'not now' })
|
||||
expect(Object.isFrozen(execution?.result)).toBe(true)
|
||||
|
||||
ctx.commands.register({
|
||||
name: 'silent',
|
||||
description: 'No output',
|
||||
handler: () => ({ kind: 'success' }),
|
||||
})
|
||||
const silent = await ctx.commands.execute(agent, '/silent', new AbortController().signal)
|
||||
expect(silent?.result).toEqual({ kind: 'success' })
|
||||
expect(Object.isFrozen(silent?.result)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ ...command('Bad') }, /command name/],
|
||||
[{ ...command('empty-description'), description: ' ' }, /description/],
|
||||
[{ ...command('empty-hint'), input: { hint: '' } }, /input hint/],
|
||||
[{ ...command('bad-handler'), handler: undefined }, /handler/],
|
||||
] as const)('rejects invalid definition %#', async (definition, expected) => {
|
||||
const ctx = await mount()
|
||||
expect(() => ctx.commands.register(definition as unknown as CommandDefinition)).toThrow(expected)
|
||||
})
|
||||
|
||||
it('logs a paired command/run + command/done around a successful handler', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register(command('deploy', 'deployed'))
|
||||
|
||||
const execution = await ctx.commands.execute(agent, '/deploy now', new AbortController().signal)
|
||||
|
||||
const lifecycle = lifecycleOf(agent)
|
||||
expect(lifecycle).toMatchObject([
|
||||
{ type: 'command/run', data: { name: 'deploy', args: ' now', source: { kind: 'user' } } },
|
||||
{ type: 'command/done', data: { kind: 'success', text: 'deployed' } },
|
||||
])
|
||||
const ids = lifecycle.map(event => (event.data as { commandId: string }).commandId)
|
||||
expect(ids[0]).toBeTruthy()
|
||||
expect(ids[0]).toBe(ids[1])
|
||||
// The execution's pairing id is the logged one (RPC-level correlation).
|
||||
expect(execution?.commandId).toBe(ids[0])
|
||||
// Direct log-only appends: no turn is opened for the pair on an idle log.
|
||||
expect(agent.session.events.map(event => event.type)).toEqual([
|
||||
'command/run', 'command/done',
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves an earlier authoritative domain-event reference on successful settlement', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
const source = agent.session.append('turn/start', { turn: 1 })
|
||||
ctx.commands.register({
|
||||
name: 'linked',
|
||||
description: 'Link outcome',
|
||||
handler: () => ({ kind: 'success', text: 'linked', sourceEventSeq: source.seq }),
|
||||
})
|
||||
|
||||
const execution = await ctx.commands.execute(agent, '/linked', new AbortController().signal)
|
||||
|
||||
expect(execution?.result).toEqual({ kind: 'success', text: 'linked', sourceEventSeq: source.seq })
|
||||
expect(lifecycleOf(agent)).toMatchObject([
|
||||
{ type: 'command/run', data: { name: 'linked' } },
|
||||
{ type: 'command/done', data: { kind: 'success', text: 'linked', sourceEventSeq: source.seq } },
|
||||
])
|
||||
})
|
||||
|
||||
it('omits raw input from command/run when an authoritative domain event owns it', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
const seen = vi.fn(() => ({ kind: 'success' as const }))
|
||||
ctx.commands.register({
|
||||
name: 'private',
|
||||
description: 'Record privately',
|
||||
recordInput: false,
|
||||
handler: seen,
|
||||
})
|
||||
|
||||
await ctx.commands.execute(agent, '/private keep this once', new AbortController().signal)
|
||||
|
||||
expect(seen).toHaveBeenCalledWith(expect.objectContaining({ rawInput: ' keep this once' }))
|
||||
const run = agent.session.events.find(event => event.type === 'command/run')
|
||||
expect(run?.type).toBe('command/run')
|
||||
expect(run?.type === 'command/run' && Object.hasOwn(run.data, 'args')).toBe(false)
|
||||
})
|
||||
|
||||
it('mints distinct monotonic commandIds across executions', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register(command('first'))
|
||||
ctx.commands.register(command('second'))
|
||||
await ctx.commands.execute(agent, '/first', new AbortController().signal)
|
||||
await ctx.commands.execute(agent, '/second', new AbortController().signal)
|
||||
const ids = lifecycleOf(agent)
|
||||
.filter(event => event.type === 'command/run')
|
||||
.map(event => (event.data as { commandId: string }).commandId)
|
||||
expect(new Set(ids).size).toBe(2)
|
||||
})
|
||||
|
||||
it('logs command/done kind error for an expected error result', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register({ name: 'denied', description: 'Denied', handler: () => ({ kind: 'error', text: 'not now' }) })
|
||||
await ctx.commands.execute(agent, '/denied', new AbortController().signal)
|
||||
expect(lifecycleOf(agent)).toMatchObject([
|
||||
{ type: 'command/run', data: { name: 'denied' } },
|
||||
{ type: 'command/done', data: { kind: 'error', text: 'not now' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('logs command/done kind error when the handler throws, and preserves the throw', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register({
|
||||
name: 'boom',
|
||||
description: 'Throw',
|
||||
handler: () => { throw new Error('handler exploded') },
|
||||
})
|
||||
await expect(ctx.commands.execute(agent, '/boom', new AbortController().signal))
|
||||
.rejects.toThrow('handler exploded')
|
||||
expect(lifecycleOf(agent)).toMatchObject([
|
||||
{ type: 'command/run', data: { name: 'boom' } },
|
||||
{ type: 'command/done', data: { kind: 'error', text: 'handler exploded' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('logs command/done kind error when the signal aborts a hanging handler', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register({
|
||||
name: 'hang',
|
||||
description: 'Hang',
|
||||
handler: () => new Promise(() => undefined),
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const pending = ctx.commands.execute(agent, '/hang', controller.signal)
|
||||
// The run append must land before the abort so the pair stays complete.
|
||||
await vi.waitFor(() => { expect(lifecycleOf(agent)).toHaveLength(1) })
|
||||
controller.abort('operator cancelled command')
|
||||
await expect(pending).rejects.toThrow('operator cancelled command')
|
||||
await vi.waitFor(() => {
|
||||
expect(lifecycleOf(agent)).toMatchObject([
|
||||
{ type: 'command/run', data: { name: 'hang' } },
|
||||
{ type: 'command/done', data: { kind: 'error', text: 'operator cancelled command' } },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('logs nothing for admission misses (syntax or unknown name)', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register(command('real'))
|
||||
const signal = new AbortController().signal
|
||||
await ctx.commands.execute(agent, 'not a command', signal)
|
||||
await ctx.commands.execute(agent, '/missing', signal)
|
||||
expect(agent.session.events).toEqual([])
|
||||
})
|
||||
|
||||
it('joins an open turn without wrapping the lifecycle pair in synthetic turns', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register(command('mid'))
|
||||
agent.session.append('turn/start', { turn: 1 })
|
||||
await ctx.commands.execute(agent, '/mid', new AbortController().signal)
|
||||
expect(agent.session.events.map(event => event.type)).toEqual([
|
||||
'turn/start', 'command/run', 'command/done',
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[undefined, /CommandResult/],
|
||||
[null, /CommandResult/],
|
||||
[{}, /CommandResult/],
|
||||
[{ kind: 'success', text: 1 }, /success text/],
|
||||
[{ kind: 'success', sourceEventSeq: -1 }, /sourceEventSeq/],
|
||||
[{ kind: 'success', sourceEventSeq: 1.5 }, /sourceEventSeq/],
|
||||
[{ kind: 'success', sourceEventSeq: '1' }, /sourceEventSeq/],
|
||||
[{ kind: 'error', text: '' }, /error text/],
|
||||
[{ kind: 'error', text: 1 }, /error text/],
|
||||
[{ kind: 'future', text: 'x' }, /unknown result kind/],
|
||||
] as const)('rejects malformed handler result %j', async (output, expected) => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register({
|
||||
name: 'broken',
|
||||
description: 'Broken',
|
||||
handler: () => output as never,
|
||||
})
|
||||
await expect(ctx.commands.execute(agent, '/broken', new AbortController().signal)).rejects.toThrow(expected)
|
||||
})
|
||||
})
|
||||
89
packages/interaction/commands/tests/invariant.spec.ts
Normal file
89
packages/interaction/commands/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import * as CommandInvariant from '@deepseek-ai/dsh-commands/invariant'
|
||||
import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
|
||||
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
|
||||
import { CommandId } from '@deepseek-ai/dsh-commands'
|
||||
|
||||
async function mount(installCompanion = true): Promise<{ ctx: Context; session: Session }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('commands-invariant'))
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
if (installCompanion) await ctx.plugin(CommandInvariant)
|
||||
return { ctx, session }
|
||||
}
|
||||
|
||||
function appendRun(session: Session, id: string): void {
|
||||
session.append('command/run', {
|
||||
commandId: CommandId(id),
|
||||
name: 'linked',
|
||||
args: '',
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('command lifecycle invariants', () => {
|
||||
it('accepts a success outcome linked to an earlier non-command domain event', async () => {
|
||||
const { session } = await mount()
|
||||
const source = session.append('turn/start', { turn: 1 })
|
||||
appendRun(session, 'cmd-valid')
|
||||
|
||||
expect(() => {
|
||||
session.append('command/done', {
|
||||
commandId: CommandId('cmd-valid'),
|
||||
kind: 'success',
|
||||
sourceEventSeq: source.seq,
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it.each([-1, 1.5, 1])('rejects invalid or non-prior sourceEventSeq %s', async (sourceEventSeq) => {
|
||||
const { session } = await mount()
|
||||
appendRun(session, 'cmd-invalid')
|
||||
|
||||
expect(() => {
|
||||
session.append('command/done', {
|
||||
commandId: CommandId('cmd-invalid'),
|
||||
kind: 'success',
|
||||
sourceEventSeq,
|
||||
})
|
||||
}).toThrow(expect.objectContaining<Partial<InvariantError>>({
|
||||
code: 'INVARIANT',
|
||||
packageName: '@deepseek-ai/dsh-commands',
|
||||
}))
|
||||
})
|
||||
|
||||
it('rejects an error settlement carrying a success-only source reference', async () => {
|
||||
const { session } = await mount()
|
||||
const source = session.append('turn/start', { turn: 1 })
|
||||
appendRun(session, 'cmd-error-source')
|
||||
|
||||
expect(() => {
|
||||
session.append('command/done', {
|
||||
commandId: CommandId('cmd-error-source'),
|
||||
kind: 'error',
|
||||
text: 'failed',
|
||||
sourceEventSeq: source.seq,
|
||||
})
|
||||
}).toThrow(expect.objectContaining<Partial<InvariantError>>({
|
||||
code: 'INVARIANT',
|
||||
packageName: '@deepseek-ai/dsh-commands',
|
||||
}))
|
||||
})
|
||||
|
||||
it('attributes an invalid durable prefix during late companion loading', async () => {
|
||||
const { ctx, session } = await mount(false)
|
||||
appendRun(session, 'cmd-late')
|
||||
session.append('command/done', {
|
||||
commandId: CommandId('cmd-late'),
|
||||
kind: 'success',
|
||||
sourceEventSeq: 0,
|
||||
})
|
||||
|
||||
await expect(ctx.plugin(CommandInvariant)).rejects.toMatchObject({
|
||||
code: 'INVARIANT',
|
||||
packageName: '@deepseek-ai/dsh-commands',
|
||||
})
|
||||
})
|
||||
})
|
||||
33
packages/interaction/commands/tsconfig.json
Normal file
33
packages/interaction/commands/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user