fix(commands): harden registry and UI ordering

This commit is contained in:
Tianyi Cui
2026-07-20 11:24:02 +08:00
parent 38abe7cac6
commit 2dd3b1dfba
67 changed files with 284 additions and 101 deletions
+47 -6
View File
@@ -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 () => {