fix(commands): remove adapter surface filtering

This commit is contained in:
Tianyi Cui
2026-07-20 17:41:22 +08:00
parent a87d3e7e55
commit 4e7a2c732f
19 changed files with 112 additions and 192 deletions
+2 -2
View File
@@ -4,9 +4,9 @@ Plugin-owned human-command registry shared by the TUI and ACP adapters. The [plu
## Service contract
`ctx.commands.register(definition)` registers one lowercase command name, description, optional ACP-compatible unstructured-input hint, optional surface list, and abortable handler. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers.
`ctx.commands.register(definition)` registers one lowercase command name, description, optional ACP-compatible unstructured-input hint, and abortable handler. A registered command is available to every composed command adapter; a plugin that is incompatible with a deployment does not register there. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers.
`list(agent, surface)` returns immutable, name-sorted descriptors after scoped shadowing and surface filtering. `find(agent, surface, name)` returns the corresponding definition. `execute(agent, surface, line, signal)` uses `parseCommand()` and runs only a known command, returning `undefined` for invalid syntax, unknown names, or commands hidden from that surface.
`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning `undefined` for invalid syntax or unknown names.
`parseCommand()` recognizes a slash at byte zero, a lowercase name containing letters, digits, `_`, or `-`, and either end-of-input or whitespace. It returns every byte after the name as `rawInput`, including separator whitespace; consumers own their command-specific grammar and may normalize only what that grammar permits.
+11 -45
View File
@@ -11,11 +11,6 @@ import type { ScopeKey } from '@deepseek-ai/dsh-scope'
export const name = 'commands'
const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u
const SURFACE_NAME = /^[a-z][a-z0-9-]*$/u
const DEFAULT_SURFACES = ['tui', 'acp'] as const
/** A UI adapter capable of listing and executing human commands. */
export type CommandSurface = 'tui' | 'acp' | (string & {})
/** Immutable command input metadata compatible with ACP unstructured input. */
export interface CommandInputDescriptor {
@@ -27,8 +22,6 @@ export interface CommandInputDescriptor {
export interface CommandInvocation {
/** Exact agent whose human-facing surface received the command. */
readonly agent: Agent
/** UI adapter that dispatched the command. */
readonly surface: CommandSurface
/** Exact text following the registered command name, including separator whitespace. */
readonly rawInput: string
/** Cancellation signal owned by the dispatching UI request. */
@@ -48,8 +41,6 @@ export interface CommandDefinition {
readonly description: string
/** Optional free-form input hint advertised to capable clients. */
readonly input?: CommandInputDescriptor
/** Surfaces exposing this command; omission means both shipped surfaces. */
readonly surfaces?: readonly CommandSurface[]
/** Execute against the receiving agent without sending the command to the model. */
readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>
}
@@ -62,8 +53,6 @@ export interface CommandDescriptor {
readonly description: string
/** Optional free-form input hint advertised to capable clients. */
readonly input?: CommandInputDescriptor
/** Surfaces on which this definition is visible. */
readonly surfaces: readonly CommandSurface[]
}
/** Syntactically valid slash command before registry resolution. */
@@ -75,7 +64,7 @@ export interface ParsedCommand {
}
interface RegisteredCommand {
readonly definition: CommandDefinition & { readonly surfaces: readonly CommandSurface[] }
readonly definition: CommandDefinition
readonly descriptor: CommandDescriptor
}
@@ -175,33 +164,16 @@ function normalizeDefinition(definition: CommandDefinition): RegisteredCommand {
}
input = Object.freeze({ hint: rawInput.hint })
}
const surfaces = [...(definition.surfaces ?? DEFAULT_SURFACES)]
if (surfaces.length === 0) {
throw new TypeError(`command "${definition.name}" must expose at least one surface`)
}
const unique = new Set<CommandSurface>()
for (const surface of surfaces) {
if (!SURFACE_NAME.test(surface)) {
throw new TypeError(`command "${definition.name}" surface "${surface}" must match ${String(SURFACE_NAME)}`)
}
if (unique.has(surface)) {
throw new TypeError(`command "${definition.name}" surface "${surface}" is duplicated`)
}
unique.add(surface)
}
const frozenSurfaces = Object.freeze(surfaces)
const normalized = Object.freeze({
name: definition.name,
description: definition.description,
...input === undefined ? {} : { input },
surfaces: frozenSurfaces,
handler: definition.handler,
})
const descriptor = Object.freeze({
name: normalized.name,
description: normalized.description,
...normalized.input === undefined ? {} : { input: normalized.input },
surfaces: normalized.surfaces,
})
return { definition: normalized, descriptor }
}
@@ -242,7 +214,7 @@ export class CommandService extends Service {
/**
* Register a global or calling-agent-scoped command.
* @param definition - discovery metadata, surface mask, and direct UI handler.
* @param definition - discovery metadata and direct UI handler.
* @returns the exact effect disposer that unregisters this definition.
*/
register(definition: CommandDefinition): () => void {
@@ -268,14 +240,12 @@ export class CommandService extends Service {
}
/**
* List the effective immutable command descriptors for one agent and surface.
* List the effective immutable command descriptors for one agent.
* @param agent - exact receiving agent and scoped-layer key.
* @param surface - UI adapter requesting discovery metadata.
* @returns name-sorted descriptors after scoped shadowing and surface filtering.
* @returns name-sorted descriptors after scoped shadowing.
*/
list(agent: Agent, surface: CommandSurface): readonly CommandDescriptor[] {
list(agent: Agent): readonly CommandDescriptor[] {
return Object.freeze([...this.view(agent).values()]
.filter(command => command.definition.surfaces.includes(surface))
.map(command => command.descriptor)
// Names are unique in the effective view, so equality is impossible.
.sort((left, right) => left.name < right.name ? -1 : 1))
@@ -284,35 +254,31 @@ export class CommandService extends Service {
/**
* Resolve one effective command definition.
* @param agent - exact receiving agent and scoped-layer key.
* @param surface - UI adapter performing the lookup.
* @param name - command name without a slash.
* @returns the scoped shadow or global definition when visible on the surface.
* @returns the scoped shadow or global definition.
*/
find(agent: Agent, surface: CommandSurface, name: string): CommandDefinition | undefined {
const command = this.view(agent).get(name)
return command?.definition.surfaces.includes(surface) === true ? command.definition : undefined
find(agent: Agent, name: string): CommandDefinition | undefined {
return this.view(agent).get(name)?.definition
}
/**
* Parse and execute a known command without sending it to the model.
* @param agent - exact receiving agent.
* @param surface - dispatching UI adapter.
* @param line - complete slash-command line.
* @param signal - cancellation signal owned by the UI request.
* @returns a detached result, or `undefined` when syntax/name/surface does not resolve.
* @returns a detached result, or `undefined` when syntax or name does not resolve.
*/
async execute(
agent: Agent,
surface: CommandSurface,
line: string,
signal: AbortSignal,
): Promise<CommandResult | undefined> {
const parsed = parseCommand(line)
if (parsed === undefined) return undefined
const command = this.view(agent).get(parsed.name)
if (command === undefined || !command.definition.surfaces.includes(surface)) return undefined
if (command === undefined) return undefined
if (signal.aborted) throw abortError(signal)
const invocation = Object.freeze({ agent, surface, rawInput: parsed.rawInput, signal })
const invocation = Object.freeze({ agent, rawInput: parsed.rawInput, signal })
const output = command.definition.handler(invocation)
return normalizeResult(parsed.name, await withAbort(Promise.resolve(output), signal))
}
+27 -36
View File
@@ -44,7 +44,7 @@ describe('parseCommand()', () => {
})
describe('CommandService', () => {
it('lists immutable global descriptors with default surfaces and ACP input metadata', async () => {
it('lists immutable global descriptors with input metadata', async () => {
const ctx = await mount()
const { agent } = await mintAgentScope(ctx, 'a')
const definition: CommandDefinition = {
@@ -55,20 +55,17 @@ describe('CommandService', () => {
}
ctx.commands.register(definition)
const listed = ctx.commands.list(agent, 'acp')
const listed = ctx.commands.list(agent)
expect(listed).toEqual([{
name: 'inspect',
description: 'Inspect state',
input: { hint: '<target>' },
surfaces: ['tui', 'acp'],
}])
expect(Object.isFrozen(listed)).toBe(true)
expect(Object.isFrozen(listed[0])).toBe(true)
expect(Object.isFrozen(listed[0]?.input)).toBe(true)
expect(Object.isFrozen(listed[0]?.surfaces)).toBe(true)
expect(ctx.commands.find(agent, 'tui', 'inspect')).toMatchObject({ name: 'inspect' })
expect(ctx.commands.find(agent, 'other', 'inspect')).toBeUndefined()
expect(ctx.commands.find(agent, 'tui', 'missing')).toBeUndefined()
expect(ctx.commands.find(agent, 'inspect')).toMatchObject({ name: 'inspect' })
expect(ctx.commands.find(agent, 'missing')).toBeUndefined()
})
it('sorts distinct effective command names', async () => {
@@ -77,7 +74,7 @@ describe('CommandService', () => {
ctx.commands.register(command('zeta'))
ctx.commands.register(command('alpha'))
ctx.commands.register(command('middle'))
expect(ctx.commands.list(agent, 'tui').map(item => item.name)).toEqual(['alpha', 'middle', 'zeta'])
expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['alpha', 'middle', 'zeta'])
})
it('uses agent-scoped shadows and removes them with their scope', async () => {
@@ -85,17 +82,16 @@ describe('CommandService', () => {
const { scope, agent } = await mintAgentScope(ctx, 'a')
const other = { id: 'other' as SessionId } as Agent
ctx.commands.register(command('shared', 'global'))
scope.ctx.commands.register({ ...command('shared', 'scoped'), surfaces: ['tui'] })
scope.ctx.commands.register(command('shared', 'scoped'))
expect(ctx.commands.list(agent, 'tui').map(item => item.name)).toEqual(['shared'])
expect(ctx.commands.list(agent, 'acp')).toEqual([])
expect(ctx.commands.find(agent, 'tui', 'shared')?.handler).toBeDefined()
expect(ctx.commands.list(other, 'acp').map(item => item.name)).toEqual(['shared'])
expect(await ctx.commands.execute(agent, 'tui', '/shared', new AbortController().signal))
expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['shared'])
expect(ctx.commands.find(agent, 'shared')?.handler).toBeDefined()
expect(ctx.commands.list(other).map(item => item.name)).toEqual(['shared'])
expect(await ctx.commands.execute(agent, '/shared', new AbortController().signal))
.toEqual({ kind: 'success', text: 'scoped' })
await scope.dispose()
expect((await ctx.commands.execute(agent, 'tui', '/shared', new AbortController().signal))?.text).toBe('global')
expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.text).toBe('global')
})
it('rejects duplicates within one layer while allowing a scoped shadow', async () => {
@@ -124,14 +120,14 @@ describe('CommandService', () => {
ctx.on('commands/change', afterFailures)
const removeContained = ctx.commands.register(command('contained'))
const { agent } = await mintAgentScope(ctx, 'a')
expect(ctx.commands.find(agent, 'tui', 'contained')).toBeDefined()
expect(ctx.commands.find(agent, 'contained')).toBeDefined()
expect(afterFailures).toHaveBeenCalledTimes(1)
await vi.waitFor(() => {
expect(warn).toHaveBeenCalledWith('commands/change listener threw: Error: observer threw')
expect(warn).toHaveBeenCalledWith('commands/change listener rejected: Error: observer rejected')
})
removeContained()
expect(ctx.commands.find(agent, 'tui', 'contained')).toBeUndefined()
expect(ctx.commands.find(agent, 'contained')).toBeUndefined()
expect(afterFailures).toHaveBeenCalledTimes(2)
})
@@ -155,22 +151,20 @@ describe('CommandService', () => {
const ctx = await mount()
const { agent } = await mintAgentScope(ctx, 'a')
const seen = vi.fn(() => ({ kind: 'success' as const, text: 'ok' }))
ctx.commands.register({ name: 'run', description: 'Run it', surfaces: ['acp'], handler: seen })
ctx.commands.register({ name: 'run', description: 'Run it', handler: seen })
const controller = new AbortController()
const result = await ctx.commands.execute(agent, 'acp', '/run untouched ', controller.signal)
const result = await ctx.commands.execute(agent, '/run untouched ', controller.signal)
expect(result).toEqual({ kind: 'success', text: 'ok' })
expect(Object.isFrozen(result)).toBe(true)
expect(seen).toHaveBeenCalledWith(expect.objectContaining({
agent,
surface: 'acp',
rawInput: ' untouched ',
signal: controller.signal,
}))
await expect(ctx.commands.execute(agent, 'tui', '/run', controller.signal)).resolves.toBeUndefined()
await expect(ctx.commands.execute(agent, 'acp', 'run', controller.signal)).resolves.toBeUndefined()
await expect(ctx.commands.execute(agent, 'acp', '/missing', controller.signal)).resolves.toBeUndefined()
await expect(ctx.commands.execute(agent, 'run', controller.signal)).resolves.toBeUndefined()
await expect(ctx.commands.execute(agent, '/missing', controller.signal)).resolves.toBeUndefined()
})
it('stops awaiting an aborted handler and handles an already-aborted signal', async () => {
@@ -183,18 +177,18 @@ describe('CommandService', () => {
handler: () => new Promise((resolve) => { release = resolve }),
})
const running = new AbortController()
const promise = ctx.commands.execute(agent, 'tui', '/wait', running.signal)
const promise = ctx.commands.execute(agent, '/wait', running.signal)
running.abort('operator cancelled command')
await expect(promise).rejects.toThrow('operator cancelled command')
release({ kind: 'success', text: 'late' })
const already = new AbortController()
already.abort(new Error('already gone'))
await expect(ctx.commands.execute(agent, 'tui', '/wait', already.signal)).rejects.toThrow('already gone')
await expect(ctx.commands.execute(agent, '/wait', already.signal)).rejects.toThrow('already gone')
const defaultReason = new AbortController()
defaultReason.abort({ source: 'test' })
await expect(ctx.commands.execute(agent, 'tui', '/wait', defaultReason.signal)).rejects.toThrow('command aborted')
await expect(ctx.commands.execute(agent, '/wait', defaultReason.signal)).rejects.toThrow('command aborted')
})
it('propagates an asynchronously rejected handler', async () => {
@@ -205,7 +199,7 @@ describe('CommandService', () => {
description: 'Reject',
handler: () => Promise.reject(new Error('handler rejected')),
})
await expect(ctx.commands.execute(agent, 'tui', '/reject', new AbortController().signal))
await expect(ctx.commands.execute(agent, '/reject', new AbortController().signal))
.rejects.toThrow('handler rejected')
ctx.commands.register({
@@ -214,7 +208,7 @@ describe('CommandService', () => {
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise untyped plugin normalization
handler: () => Promise.reject('not an Error'),
})
await expect(ctx.commands.execute(agent, 'tui', '/reject-value', new AbortController().signal))
await expect(ctx.commands.execute(agent, '/reject-value', new AbortController().signal))
.rejects.toThrow('command handler rejected with a non-Error value: not an Error')
const hostile = { toString(): string { throw new Error('cannot render') } }
@@ -224,7 +218,7 @@ describe('CommandService', () => {
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise hostile plugin normalization
handler: () => Promise.reject(hostile),
})
await expect(ctx.commands.execute(agent, 'tui', '/reject-hostile', new AbortController().signal))
await expect(ctx.commands.execute(agent, '/reject-hostile', new AbortController().signal))
.rejects.toMatchObject({
message: 'command handler rejected with a non-Error value: <unrenderable thrown value>',
cause: hostile,
@@ -243,7 +237,7 @@ describe('CommandService', () => {
return { kind: 'success' }
},
})
await expect(ctx.commands.execute(agent, 'tui', '/self-abort', controller.signal))
await expect(ctx.commands.execute(agent, '/self-abort', controller.signal))
.rejects.toThrow('aborted in handler')
})
@@ -255,7 +249,7 @@ describe('CommandService', () => {
description: 'Denied',
handler: () => ({ kind: 'error', text: 'not now' }),
})
const result = await ctx.commands.execute(agent, 'tui', '/denied', new AbortController().signal)
const result = await ctx.commands.execute(agent, '/denied', new AbortController().signal)
expect(result).toEqual({ kind: 'error', text: 'not now' })
expect(Object.isFrozen(result)).toBe(true)
@@ -264,7 +258,7 @@ describe('CommandService', () => {
description: 'No output',
handler: () => ({ kind: 'success' }),
})
const silent = await ctx.commands.execute(agent, 'tui', '/silent', new AbortController().signal)
const silent = await ctx.commands.execute(agent, '/silent', new AbortController().signal)
expect(silent).toEqual({ kind: 'success' })
expect(Object.isFrozen(silent)).toBe(true)
})
@@ -273,9 +267,6 @@ describe('CommandService', () => {
[{ ...command('Bad') }, /command name/],
[{ ...command('empty-description'), description: ' ' }, /description/],
[{ ...command('empty-hint'), input: { hint: '' } }, /input hint/],
[{ ...command('no-surface'), surfaces: [] }, /at least one surface/],
[{ ...command('bad-surface'), surfaces: ['ACP'] }, /surface/],
[{ ...command('duplicate-surface'), surfaces: ['tui', 'tui'] }, /duplicated/],
[{ ...command('bad-handler'), handler: undefined }, /handler/],
] as const)('rejects invalid definition %#', async (definition, expected) => {
const ctx = await mount()
@@ -298,6 +289,6 @@ describe('CommandService', () => {
description: 'Broken',
handler: () => output as never,
})
await expect(ctx.commands.execute(agent, 'tui', '/broken', new AbortController().signal)).rejects.toThrow(expected)
await expect(ctx.commands.execute(agent, '/broken', new AbortController().signal)).rejects.toThrow(expected)
})
})