feat(ui): add plugin command registry
This commit is contained in:
31
packages/ui/commands/README.md
Normal file
31
packages/ui/commands/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# @deepseek-ai/dsh-commands
|
||||
|
||||
Plugin-owned human-command registry shared by the TUI and ACP adapters. The [plugin command registration RFC](../../../docs/rfc/implemented/feature/2026-07-19-plugin-command-registration.md) owns the boundary and protocol mapping.
|
||||
|
||||
## 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 emits `commands/change` so live adapters can refresh discovery.
|
||||
|
||||
`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.
|
||||
|
||||
`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. Results are rendered directly by the adapter and never enter model history. 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 terminal and ACP app bundles mount this service with their consuming front door; the UI-less agent spine does not. Custom compositions that use `dsh-tui`, `dsh-acp`, or a command producer mount `@deepseek-ai/dsh-commands` explicitly.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Direct human commands
|
||||
|
||||
**What the model sees**: 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.
|
||||
|
||||
**Token effect**: Command discovery, execution, and UI output add no model tokens. A command plugin may separately mutate a model-visible domain through that domain's durable APIs.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Only unstructured text input** — the descriptor intentionally matches ACP's current unstructured command input; forms, completion schemas, and typed arguments remain command-owned parsing concerns.
|
||||
- **No persisted command output** — adapters display results live, but the generic registry does not add them to the session log or reconstruct them after reconnect.
|
||||
- **Cooperative side-effect cancellation** — dispatch stops awaiting on abort; handlers must honor the signal to stop work that has already escaped into external systems.
|
||||
35
packages/ui/commands/package.json
Normal file
35
packages/ui/commands/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
319
packages/ui/commands/src/index.ts
Normal file
319
packages/ui/commands/src/index.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* 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 { scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
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 {
|
||||
/** 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
|
||||
/** 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. */
|
||||
readonly signal: AbortSignal
|
||||
}
|
||||
|
||||
/** Expected command outcome rendered directly by the dispatching UI. */
|
||||
export type CommandResult =
|
||||
| { readonly kind: 'success'; readonly text?: string }
|
||||
| { readonly kind: 'error'; readonly text: string }
|
||||
|
||||
/** 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
|
||||
/** 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>
|
||||
}
|
||||
|
||||
/** 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
|
||||
/** Surfaces on which this definition is visible. */
|
||||
readonly surfaces: readonly CommandSurface[]
|
||||
}
|
||||
|
||||
/** 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 surfaces: readonly CommandSurface[] }
|
||||
readonly descriptor: CommandDescriptor
|
||||
}
|
||||
|
||||
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.
|
||||
* @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')
|
||||
}
|
||||
|
||||
/** 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'))
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/** 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 (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 input = definition.input === undefined
|
||||
? undefined
|
||||
: Object.freeze({ hint: definition.input.hint })
|
||||
if (input !== undefined && input.hint.trim().length === 0) {
|
||||
throw new TypeError(`command "${definition.name}" input hint must not be empty`)
|
||||
}
|
||||
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 }
|
||||
}
|
||||
|
||||
/** 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 }
|
||||
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`)
|
||||
}
|
||||
return Object.freeze(result.text === undefined ? { kind: 'success' } : { kind: 'success', text: result.text })
|
||||
}
|
||||
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 global = new Map<string, RegisteredCommand>()
|
||||
private readonly scoped = new Map<ScopeKey, Map<string, RegisteredCommand>>()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'commands')
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a global or calling-agent-scoped command.
|
||||
* @param definition - discovery metadata, surface mask, and direct UI handler.
|
||||
* @returns the exact effect disposer that unregisters this definition.
|
||||
*/
|
||||
register(definition: CommandDefinition): () => void {
|
||||
const scope = scopeOf(this.ctx)
|
||||
const registered = normalizeDefinition(definition)
|
||||
const dispose = this.ctx.effect(function* (this: CommandService) {
|
||||
const layer = scope === undefined ? this.global : this.layerFor(scope)
|
||||
if (layer.has(registered.definition.name)) {
|
||||
throw new Error(scope === undefined
|
||||
? `command "${registered.definition.name}" is already registered (for a per-agent variant, mount a command-injected plugin under that agent's \`agent.ctx\`)`
|
||||
: `command "${registered.definition.name}" is already registered in this scope`)
|
||||
}
|
||||
layer.set(registered.definition.name, registered)
|
||||
yield () => {
|
||||
layer.delete(registered.definition.name)
|
||||
if (scope !== undefined && layer.size === 0) this.scoped.delete(scope)
|
||||
this.ctx.emit('commands/change')
|
||||
}
|
||||
this.ctx.emit('commands/change')
|
||||
}.bind(this), 'commands.register()')
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact synchronous disposer preserves composite teardown order
|
||||
return dispose
|
||||
}
|
||||
|
||||
/**
|
||||
* List the effective immutable command descriptors for one agent and surface.
|
||||
* @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.
|
||||
*/
|
||||
list(agent: Agent, surface: CommandSurface): 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))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 (signal.aborted) throw abortError(signal)
|
||||
const invocation = Object.freeze({ agent, surface, rawInput: parsed.rawInput, signal })
|
||||
const output = command.definition.handler(invocation)
|
||||
return normalizeResult(parsed.name, await withAbort(Promise.resolve(output), signal))
|
||||
}
|
||||
|
||||
/** Resolve global definitions followed by exact scoped shadows. */
|
||||
private view(agent: Agent): Map<string, RegisteredCommand> {
|
||||
const visible = new Map(this.global)
|
||||
for (const [name, command] of this.scoped.get(agent) ?? []) visible.set(name, command)
|
||||
return visible
|
||||
}
|
||||
|
||||
/** Create the registration layer for one agent scope on demand. */
|
||||
private layerFor(scope: ScopeKey): Map<string, RegisteredCommand> {
|
||||
let layer = this.scoped.get(scope)
|
||||
if (layer === undefined) {
|
||||
layer = new Map()
|
||||
this.scoped.set(scope, layer)
|
||||
}
|
||||
return layer
|
||||
}
|
||||
}
|
||||
|
||||
export default CommandService
|
||||
262
packages/ui/commands/tests/commands.spec.ts
Normal file
262
packages/ui/commands/tests/commands.spec.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
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 type { 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(CommandService)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Mint a scope whose key is sufficient for registry lookup and invocation. */
|
||||
async function mintAgentScope(ctx: Context, name: string): Promise<{ scope: Scope; agent: Agent }> {
|
||||
const agent = { id: name as SessionId } as Agent
|
||||
let scope!: Scope
|
||||
await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, { inject: ['commands'] }))
|
||||
return { scope, agent }
|
||||
}
|
||||
|
||||
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 default surfaces and ACP 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, 'acp')
|
||||
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()
|
||||
})
|
||||
|
||||
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, 'tui').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'), surfaces: ['tui'] })
|
||||
|
||||
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))
|
||||
.toEqual({ kind: 'success', text: 'scoped' })
|
||||
|
||||
await scope.dispose()
|
||||
expect((await ctx.commands.execute(agent, 'tui', '/shared', new AbortController().signal))?.text).toBe('global')
|
||||
})
|
||||
|
||||
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('emits on registration and disposal and rolls back when notification fails', 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 explode = ctx.on('commands/change', () => { throw new Error('observer failed') })
|
||||
expect(() => ctx.commands.register(command('rollback'))).toThrow('observer failed')
|
||||
explode()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
expect(ctx.commands.find(agent, 'tui', 'rollback')).toBeUndefined()
|
||||
})
|
||||
|
||||
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', surfaces: ['acp'], handler: seen })
|
||||
const controller = new AbortController()
|
||||
|
||||
const result = await ctx.commands.execute(agent, 'acp', '/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()
|
||||
})
|
||||
|
||||
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, 'tui', '/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')
|
||||
|
||||
const defaultReason = new AbortController()
|
||||
defaultReason.abort({ source: 'test' })
|
||||
await expect(ctx.commands.execute(agent, 'tui', '/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, 'tui', '/reject', new AbortController().signal))
|
||||
.rejects.toThrow('handler rejected')
|
||||
|
||||
ctx.commands.register({
|
||||
name: 'reject-value',
|
||||
description: 'Reject a non-Error value',
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise untyped plugin normalization
|
||||
handler: () => Promise.reject('not an Error'),
|
||||
})
|
||||
await expect(ctx.commands.execute(agent, 'tui', '/reject-value', new AbortController().signal))
|
||||
.rejects.toThrow('command handler rejected with a non-Error value')
|
||||
})
|
||||
|
||||
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, 'tui', '/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 result = await ctx.commands.execute(agent, 'tui', '/denied', new AbortController().signal)
|
||||
expect(result).toEqual({ kind: 'error', text: 'not now' })
|
||||
expect(Object.isFrozen(result)).toBe(true)
|
||||
|
||||
ctx.commands.register({
|
||||
name: 'silent',
|
||||
description: 'No output',
|
||||
handler: () => ({ kind: 'success' }),
|
||||
})
|
||||
const silent = await ctx.commands.execute(agent, 'tui', '/silent', new AbortController().signal)
|
||||
expect(silent).toEqual({ kind: 'success' })
|
||||
expect(Object.isFrozen(silent)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ ...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()
|
||||
expect(() => ctx.commands.register(definition as unknown as CommandDefinition)).toThrow(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[undefined, /CommandResult/],
|
||||
[null, /CommandResult/],
|
||||
[{}, /CommandResult/],
|
||||
[{ kind: 'success', text: 1 }, /success text/],
|
||||
[{ 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, 'tui', '/broken', new AbortController().signal)).rejects.toThrow(expected)
|
||||
})
|
||||
})
|
||||
24
packages/ui/commands/tsconfig.json
Normal file
24
packages/ui/commands/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user