Просмотр исходного кода

refactor(commands): use shared scoped layers

Tianyi Cui 1 месяц назад
Родитель
Сommit
e56bcb4d5c
2 измененных файлов с 45 добавлено и 35 удалено
  1. 32 35
      packages/ui/commands/src/index.ts
  2. 13 0
      packages/ui/commands/tests/commands.spec.ts

+ 32 - 35
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<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' {
   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<string, RegisteredCommand>()
-  private readonly scoped = new Map<ScopeKey, Map<string, RegisteredCommand>>()
+  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<string, RegisteredCommand> {
-    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<string, RegisteredCommand> {
-    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. */

+ 13 - 0
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')