refactor(commands): use shared scoped layers

This commit is contained in:
Tianyi Cui
2026-07-21 22:16:06 +08:00
parent 1d209932d6
commit e56bcb4d5c
2 changed files with 45 additions and 35 deletions

View File

@@ -5,8 +5,8 @@
import { Context, Service } from 'cordis' import { Context, Service } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent'
import { scopeOf } from '@deepseek-ai/dsh-scope' import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope'
import type { ScopeKey } from '@deepseek-ai/dsh-scope' import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope'
export const name = 'commands' export const name = 'commands'
@@ -68,6 +68,26 @@ interface RegisteredCommand {
readonly descriptor: CommandDescriptor readonly descriptor: CommandDescriptor
} }
/** All command registrations owned by one global or scoped layer. */
class CommandLayer implements ScopeLayer {
readonly commands: NamedEntries<RegisteredCommand>
/**
* 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' { declare module 'cordis' {
interface Context { interface Context {
commands: CommandService commands: CommandService
@@ -205,8 +225,10 @@ function normalizeResult(command: string, value: unknown): CommandResult {
* globals for that agent. * globals for that agent.
*/ */
export class CommandService extends Service { export class CommandService extends Service {
private readonly global = new Map<string, RegisteredCommand>() private readonly layers = new ScopedLayers(
private readonly scoped = new Map<ScopeKey, Map<string, RegisteredCommand>>() scope => new CommandLayer(scope),
() => { this.notifyChange() },
)
constructor(ctx: Context) { constructor(ctx: Context) {
super(ctx, 'commands') super(ctx, 'commands')
@@ -218,25 +240,12 @@ export class CommandService extends Service {
* @returns the exact effect disposer that unregisters this definition. * @returns the exact effect disposer that unregisters this definition.
*/ */
register(definition: CommandDefinition): () => void { register(definition: CommandDefinition): () => void {
const scope = scopeOf(this.ctx)
const registered = normalizeDefinition(definition) const registered = normalizeDefinition(definition)
const dispose = this.ctx.effect(function* (this: CommandService) { return this.layers.effect(
const layer = scope === undefined ? this.global : this.layerFor(scope) this.ctx,
if (layer.has(registered.definition.name)) { layer => layer.commands.insert(registered.definition.name, registered),
throw new Error(scope === undefined { label: 'commands.register()' },
? `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
} }
/** /**
@@ -285,19 +294,7 @@ export class CommandService extends Service {
/** Resolve global definitions followed by exact scoped shadows. */ /** Resolve global definitions followed by exact scoped shadows. */
private view(agent: Agent): Map<string, RegisteredCommand> { private view(agent: Agent): Map<string, RegisteredCommand> {
const visible = new Map(this.global) return this.layers.merge(agent, layer => layer.commands)
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<string, RegisteredCommand> {
let layer = this.scoped.get(scope)
if (layer === undefined) {
layer = new Map()
this.scoped.set(scope, layer)
}
return layer
} }
/** Notify every registry observer without making UI refresh load-bearing. */ /** Notify every registry observer without making UI refresh load-bearing. */

View File

@@ -94,6 +94,19 @@ describe('CommandService', () => {
expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.text).toBe('global') 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 () => { it('rejects duplicates within one layer while allowing a scoped shadow', async () => {
const ctx = await mount() const ctx = await mount()
const { scope } = await mintAgentScope(ctx, 'a') const { scope } = await mintAgentScope(ctx, 'a')