fix(commands): harden registry and UI ordering
This commit is contained in:
@@ -4,7 +4,7 @@ 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 emits `commands/change` so live adapters can refresh discovery.
|
||||
`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.
|
||||
|
||||
`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.
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ declare module 'cordis' {
|
||||
/**
|
||||
* 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
|
||||
@@ -115,6 +116,15 @@ function abortError(signal: AbortSignal): Error {
|
||||
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))
|
||||
@@ -133,7 +143,7 @@ function withAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
reject(error instanceof Error
|
||||
? error
|
||||
: new Error('command handler rejected with a non-Error value'))
|
||||
: new Error(`command handler rejected with a non-Error value: ${renderThrown(error)}`, { cause: error }))
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -144,17 +154,26 @@ 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 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 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 surfaces = [...(definition.surfaces ?? DEFAULT_SURFACES)]
|
||||
if (surfaces.length === 0) {
|
||||
@@ -240,9 +259,9 @@ export class CommandService extends Service {
|
||||
yield () => {
|
||||
layer.delete(registered.definition.name)
|
||||
if (scope !== undefined && layer.size === 0) this.scoped.delete(scope)
|
||||
this.ctx.emit('commands/change')
|
||||
this.notifyChange()
|
||||
}
|
||||
this.ctx.emit('commands/change')
|
||||
this.notifyChange()
|
||||
}.bind(this), 'commands.register()')
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact synchronous disposer preserves composite teardown order
|
||||
return dispose
|
||||
@@ -314,6 +333,23 @@ export class CommandService extends Service {
|
||||
}
|
||||
return layer
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
||||
@@ -107,7 +107,7 @@ describe('CommandService', () => {
|
||||
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 () => {
|
||||
it('notifies on registration and disposal while containing broken observers', async () => {
|
||||
const ctx = await mount()
|
||||
const changed = vi.fn()
|
||||
ctx.on('commands/change', changed)
|
||||
@@ -116,11 +116,39 @@ describe('CommandService', () => {
|
||||
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 warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
ctx.on('commands/change', () => { throw new Error('observer threw') })
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- exercises rejected-listener containment
|
||||
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, 'tui', 'rollback')).toBeUndefined()
|
||||
expect(ctx.commands.find(agent, 'tui', '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(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 () => {
|
||||
@@ -187,7 +215,20 @@ describe('CommandService', () => {
|
||||
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')
|
||||
.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',
|
||||
// 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))
|
||||
.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 () => {
|
||||
|
||||
Reference in New Issue
Block a user