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

View File

@@ -204,19 +204,19 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
methods: [
{
signature: 'register(definition: CommandDefinition): () => void',
jsDoc: '/**\n * Register a global or calling-agent-scoped command.\n * @param definition - discovery metadata, surface mask, and direct UI handler.\n * @returns the exact effect disposer that unregisters this definition.\n */',
jsDoc: '/**\n * Register a global or calling-agent-scoped command.\n * @param definition - discovery metadata and direct UI handler.\n * @returns the exact effect disposer that unregisters this definition.\n */',
},
{
signature: 'list(agent: Agent, surface: CommandSurface): readonly CommandDescriptor[]',
jsDoc: '/**\n * List the effective immutable command descriptors for one agent and surface.\n * @param agent - exact receiving agent and scoped-layer key.\n * @param surface - UI adapter requesting discovery metadata.\n * @returns name-sorted descriptors after scoped shadowing and surface filtering.\n */',
signature: 'list(agent: Agent): readonly CommandDescriptor[]',
jsDoc: '/**\n * List the effective immutable command descriptors for one agent.\n * @param agent - exact receiving agent and scoped-layer key.\n * @returns name-sorted descriptors after scoped shadowing.\n */',
},
{
signature: 'find(agent: Agent, surface: CommandSurface, name: string): CommandDefinition | undefined',
jsDoc: '/**\n * Resolve one effective command definition.\n * @param agent - exact receiving agent and scoped-layer key.\n * @param surface - UI adapter performing the lookup.\n * @param name - command name without a slash.\n * @returns the scoped shadow or global definition when visible on the surface.\n */',
signature: 'find(agent: Agent, name: string): CommandDefinition | undefined',
jsDoc: '/**\n * Resolve one effective command definition.\n * @param agent - exact receiving agent and scoped-layer key.\n * @param name - command name without a slash.\n * @returns the scoped shadow or global definition.\n */',
},
{
signature: 'async execute( agent: Agent, surface: CommandSurface, line: string, signal: AbortSignal, ): Promise<CommandResult | undefined>',
jsDoc: '/**\n * Parse and execute a known command without sending it to the model.\n * @param agent - exact receiving agent.\n * @param surface - dispatching UI adapter.\n * @param line - complete slash-command line.\n * @param signal - cancellation signal owned by the UI request.\n * @returns a detached result, or `undefined` when syntax/name/surface does not resolve.\n */',
signature: 'async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise<CommandResult | undefined>',
jsDoc: '/**\n * Parse and execute a known command without sending it to the model.\n * @param agent - exact receiving agent.\n * @param line - complete slash-command line.\n * @param signal - cancellation signal owned by the UI request.\n * @returns a detached result, or `undefined` when syntax or name does not resolve.\n */',
},
],
},
@@ -1127,11 +1127,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'CommandDefinition',
declaration: 'export interface CommandDefinition {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly surfaces?: readonly CommandSurface[];\n readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>;\n}',
declaration: 'export interface CommandDefinition {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>;\n}',
},
{
name: 'CommandDescriptor',
declaration: 'export interface CommandDescriptor {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly surfaces: readonly CommandSurface[];\n}',
declaration: 'export interface CommandDescriptor {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n}',
},
{
name: 'CommandInputDescriptor',
@@ -1139,16 +1139,12 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'CommandInvocation',
declaration: 'export interface CommandInvocation {\n readonly agent: Agent;\n readonly surface: CommandSurface;\n readonly rawInput: string;\n readonly signal: AbortSignal;\n}',
declaration: 'export interface CommandInvocation {\n readonly agent: Agent;\n readonly rawInput: string;\n readonly signal: AbortSignal;\n}',
},
{
name: 'CommandResult',
declaration: 'export type CommandResult = {\n readonly kind: \'success\';\n readonly text?: string;\n} | {\n readonly kind: \'error\';\n readonly text: string;\n};',
},
{
name: 'CommandSurface',
declaration: 'export type CommandSurface = \'tui\' | \'acp\' | (string & {});',
},
{
name: 'CompactAgentContext',
declaration: 'export interface CompactAgentContext {\n session: Session;\n options: {\n provider?: string;\n model?: string;\n };\n}',

View File

@@ -514,7 +514,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
}
/** Project the effective registry view onto ACP discovery metadata. */
const availableCommands = (agent: Agent): AvailableCommand[] => commands.list(agent, 'acp').map(command => ({
const availableCommands = (agent: Agent): AvailableCommand[] => commands.list(agent).map(command => ({
name: command.name,
description: command.description,
...command.input === undefined ? {} : { input: { hint: command.input.hint } },
@@ -905,7 +905,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
const controller = new AbortController()
rec.commandAbort = controller
try {
const result = await commands.execute(rec.agent, 'acp', commandLine, controller.signal)
const result = await commands.execute(rec.agent, commandLine, controller.signal)
if (result !== undefined && result.text !== undefined && result.text !== '') {
notify({
sessionId: rec.agent.session.id,

View File

@@ -55,7 +55,6 @@ describe('ACP plugin commands', () => {
const dispose = harness.ctx.commands.register({
name: 'alpha',
description: 'Alpha command',
surfaces: ['acp'],
handler: () => ({ kind: 'success' }),
})
await vi.waitFor(() => {
@@ -126,7 +125,7 @@ describe('ACP plugin commands', () => {
})
expect(response.stopReason).toBe('end_turn')
expect(seen).toHaveBeenCalledWith(expect.objectContaining({ surface: 'acp', rawInput: ' raw args ' }))
expect(seen).toHaveBeenCalledWith(expect.objectContaining({ rawInput: ' raw args ' }))
expect(messageText(harness, sessionId)).toContain('DIRECT RESULT')
const updatesAfterText = harness.sessionUpdates.length
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/silent' }] })
@@ -260,7 +259,7 @@ describe('ACP plugin commands', () => {
if (agentA === undefined) throw new Error('session A has no agent')
await agentA.ctx.inject(['commands'], (commandCtx) => {
commandCtx.commands.register({
name: 'private', description: 'Only session A', surfaces: ['acp'],
name: 'private', description: 'Only session A',
handler: () => ({ kind: 'success', text: 'A ONLY' }),
})
})

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.

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))
}

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)
})
})

View File

@@ -1144,7 +1144,7 @@ export function createTuiChat(
}
const showHelp = (): void => {
const commandLines = ctx.commands.list(agent, 'tui').map((command) => {
const commandLines = ctx.commands.list(agent).map((command) => {
const input = command.input === undefined ? '' : ` ${command.input.hint}`
return `/${command.name}${input}${command.description}`
})
@@ -1162,7 +1162,7 @@ export function createTuiChat(
const refreshCommandAutocomplete = (): void => {
editor.setAutocompleteProvider(new CombinedAutocompleteProvider(
ctx.commands.list(agent, 'tui').map(command => ({
ctx.commands.list(agent).map(command => ({
name: command.name,
description: command.description,
})),
@@ -1179,19 +1179,16 @@ export function createTuiChat(
commandCtx.commands.register({
name: 'help',
description: 'Show keyboard shortcuts and commands',
surfaces: ['tui'],
handler: () => { showHelp(); return { kind: 'success' } },
})
commandCtx.commands.register({
name: 'clear',
description: 'Clear the transcript view (session history is unchanged)',
surfaces: ['tui'],
handler: () => { chat.clear(); requestRender(); return { kind: 'success' } },
})
commandCtx.commands.register({
name: 'cancel',
description: 'Cancel the active turn',
surfaces: ['tui'],
handler: () => {
if (agent.status !== 'running') return { kind: 'error', text: 'The agent is already idle.' }
agent.cancel('cancelled from terminal')
@@ -1201,25 +1198,21 @@ export function createTuiChat(
commandCtx.commands.register({
name: 'reasoning',
description: 'Toggle reasoning blocks',
surfaces: ['tui'],
handler: () => { toggleReasoning(); return { kind: 'success' } },
})
commandCtx.commands.register({
name: 'tools',
description: 'Expand or collapse all tool cards',
surfaces: ['tui'],
handler: () => { toggleTools(); return { kind: 'success' } },
})
commandCtx.commands.register({
name: 'redraw',
description: 'Invalidate components and redraw the terminal',
surfaces: ['tui'],
handler: () => { ui.invalidate(); ui.requestRender(true); return { kind: 'success' } },
})
commandCtx.commands.register({
name: 'exit',
description: 'Exit after the active turn reaches idle',
surfaces: ['tui'],
handler: () => { requestExit(); return { kind: 'success' } },
})
})
@@ -1227,7 +1220,7 @@ export function createTuiChat(
const runCommand = (text: string): void => {
const controller = new AbortController()
commandControllers.add(controller)
void ctx.commands.execute(agent, 'tui', text, controller.signal).then(
void ctx.commands.execute(agent, text, controller.signal).then(
(result) => {
if (disposed) return
if (result === undefined) {

View File

@@ -442,13 +442,11 @@ describe('pi-tui chat lifecycle and transcript', () => {
name: 'plugin-check',
description: 'Run a plugin command',
input: { hint: '<value>' },
surfaces: ['tui'],
handler,
})
result.ctx.commands.register({
name: 'plugin-fail',
description: 'Fail a plugin command',
surfaces: ['tui'],
handler: () => { throw new Error('plugin command exploded') },
})
@@ -459,7 +457,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(handler).toHaveBeenCalledTimes(1)
const invocation = handler.mock.calls[0]?.[0]
expect(invocation?.agent).toBe(result.agent)
expect(invocation?.surface).toBe('tui')
// pi-tui's Editor owns terminal-line normalization and removes trailing
// spaces before onSubmit; the registry preserves the adapter-delivered line.
expect(invocation?.rawInput).toBe(' value')
@@ -472,10 +469,10 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('/plugin-check <value> — Run a plugin command')
expect(result.ctx.commands.list(result.agent, 'tui').map(command => command.name)).toContain('help')
expect(result.ctx.commands.list(result.agent).map(command => command.name)).toContain('help')
await result.controller.dispose()
expect(result.ctx.commands.list(result.agent, 'tui').map(command => command.name)).toEqual([
expect(result.ctx.commands.list(result.agent).map(command => command.name)).toEqual([
'plugin-check',
'plugin-fail',
])
@@ -490,7 +487,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.ctx.commands.register({
name: 'wait-plugin',
description: 'Wait until disposal',
surfaces: ['tui'],
handler: ({ signal }) => {
commandSignal = signal
started()
@@ -518,7 +514,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.ctx.commands.register({
name: 'late-success',
description: 'Resolve while the TUI closes',
surfaces: ['tui'],
handler: () => new Promise((resolve) => {
resolveCommand = resolve
started()
@@ -1027,7 +1022,7 @@ describe('terminal mounting', () => {
expect(() => createTuiChat(ctx, { sessionId: 'failed-start-session', color: false }, { terminal, exit: vi.fn() }))
.toThrow('terminal startup failed')
await tick()
expect(ctx.commands.list(ctx.agents.get(SessionId('failed-start-session'))!, 'tui')).toEqual([])
expect(ctx.commands.list(ctx.agents.get(SessionId('failed-start-session'))!)).toEqual([])
expect(terminal.stopped).toBe(1)
expect(terminal.progress).toEqual([false, true, false])
await expect(ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Late?' }] }))