diff --git a/packages/ui/commands/src/index.ts b/packages/ui/commands/src/index.ts index 16e665e71f..56f88a9f20 100644 --- a/packages/ui/commands/src/index.ts +++ b/packages/ui/commands/src/index.ts @@ -5,8 +5,8 @@ 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' +import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope' +import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope' export const name = 'commands' @@ -68,6 +68,26 @@ interface RegisteredCommand { readonly descriptor: CommandDescriptor } +/** All command registrations owned by one global or scoped layer. */ +class CommandLayer implements ScopeLayer { + readonly commands: NamedEntries + + /** + * Create one command layer with diagnostics specific to its ownership scope. + * @param scope - the scoped owner, or `undefined` for global registrations. + */ + constructor(scope: ScopeKey | undefined) { + this.commands = new NamedEntries(name => new Error(scope === undefined + ? `command "${name}" is already registered (for a per-agent variant, mount a command-injected plugin under that agent's \`agent.ctx\`)` + : `command "${name}" is already registered in this scope`)) + } + + /** @returns whether this layer owns no command registrations. */ + isEmpty(): boolean { + return this.commands.isEmpty() + } +} + declare module 'cordis' { interface Context { commands: CommandService @@ -205,8 +225,10 @@ function normalizeResult(command: string, value: unknown): CommandResult { * globals for that agent. */ export class CommandService extends Service { - private readonly global = new Map() - private readonly scoped = new Map>() + private readonly layers = new ScopedLayers( + scope => new CommandLayer(scope), + () => { this.notifyChange() }, + ) constructor(ctx: Context) { super(ctx, 'commands') @@ -218,25 +240,12 @@ export class CommandService extends Service { * @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.notifyChange() - } - this.notifyChange() - }.bind(this), 'commands.register()') - // eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact synchronous disposer preserves composite teardown order - return dispose + return this.layers.effect( + this.ctx, + layer => layer.commands.insert(registered.definition.name, registered), + { label: 'commands.register()' }, + ) } /** @@ -285,19 +294,7 @@ export class CommandService extends Service { /** Resolve global definitions followed by exact scoped shadows. */ private view(agent: Agent): Map { - 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 { - let layer = this.scoped.get(scope) - if (layer === undefined) { - layer = new Map() - this.scoped.set(scope, layer) - } - return layer + return this.layers.merge(agent, layer => layer.commands) } /** Notify every registry observer without making UI refresh load-bearing. */ diff --git a/packages/ui/commands/tests/commands.spec.ts b/packages/ui/commands/tests/commands.spec.ts index 839ccb0297..7b6fefb2d2 100644 --- a/packages/ui/commands/tests/commands.spec.ts +++ b/packages/ui/commands/tests/commands.spec.ts @@ -94,6 +94,19 @@ describe('CommandService', () => { expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.text).toBe('global') }) + it('removes a registration when its contributing plugin fiber is disposed', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + inner.commands.register(command('temporary')) + }, { inject: ['commands'] })) + expect(ctx.commands.find(agent, 'temporary')).toBeDefined() + + await fiber.dispose() + + expect(ctx.commands.find(agent, 'temporary')).toBeUndefined() + }) + it('rejects duplicates within one layer while allowing a scoped shadow', async () => { const ctx = await mount() const { scope } = await mintAgentScope(ctx, 'a')