diff --git a/apps/kimi-inspect/AGENTS.md b/apps/kimi-inspect/AGENTS.md index 23e74caf956..2cafed50110 100644 --- a/apps/kimi-inspect/AGENTS.md +++ b/apps/kimi-inspect/AGENTS.md @@ -10,7 +10,7 @@ A left icon rail (`src/components/NavRail.tsx`) switches top-level views: - **Global message search** (`src/components/SearchView.tsx`) — cross-session full-text search over `POST /api/v1/search`, cursor-paged via a manual Load more; an exact-match checkbox maps to the API's `mode: 'literal'` substring search, which ignores sort and orders newest-first; a `live`/`index` badge on the results shows which server route served them (in-memory session transcript vs the persisted index). - **Model Catalog** (`src/components/ModelCatalogView.tsx`) — every Provider with its Models and the default marker, via `IModelCatalog` / `IModelService` channel proxies. Expanding a Model opens the model inspector inside that view: provider/model config layers plus the resolved runtime view with per-value provenance (config / override / builtin / env / synthesized), served on demand by `IModelCatalog.inspect` — the same resolution pass the runtime's `get` serves, traced via `ResolutionTraceCollector` and assembled by `kosong/model/inspection.ts`. - **App Services** (`src/components/AppServicesView.tsx`) — the app-scope Service reflection, full width, joined by the **Workspace Services** view (`src/components/WorkspaceServicesView.tsx`) — the workspace-scope counterpart with a left sidebar directory browser (`src/components/WorkspaceDirBrowser.tsx` — server-side fs browsing over the App-scope `IHostFolderBrowser`, marking entries that are registered workspaces with their `IWorkspaceTrust` trust state, and registering a picked folder on demand via `IWorkspaceService.createOrTouch`), its proxies riding the `/workspace/:id` route, which materializes the handler on demand via `IWorkspaceLifecycleService.handlerFor`. -- **DI view** (`src/components/DiInspectionView.tsx`) — the engine's Service × Effect × DI debug surface over the App-scope `IDebugLedgerService` / `IDebugGraphService` / `IDebugCascadeService`: the unit tree = ledger tree with unprovide / update / dispose triggers, the dependency DAG as a hand-rolled SVG, the cascade history, and the waiting area; the four panels poll on a short interval and refresh eagerly off the global `event.di.unit_changed` WS frame via `src/activity/di.ts`, which invalidates the `['di']` react-query prefix. +- **DI view** (`src/components/DiInspectionView.tsx`) — the engine's Service × Effect × DI debug surface over the App-scope `IDebugLedgerService` / `IDebugGraphService` / `IDebugEventsService` / `IDebugCascadeService`: the unit tree = ledger tree with unprovide / update / dispose triggers, the dependency DAG as Miller columns (`di/DiGraphPanel.tsx`), the event-subscription ledger (unit-book `on:` entries + per-bus listener counts, `di/DiEventsPanel.tsx`), the cascade history, and the waiting area; the five panels poll on a short interval and refresh eagerly off the global `event.di.unit_changed` WS frame via `src/activity/di.ts`, which invalidates the `['di']` react-query prefix. The **Agent scope** stays in the Chat view's right dock (`src/components/RightPanel.tsx`) across two tabs: diff --git a/apps/kimi-inspect/src/components/DiInspectionView.tsx b/apps/kimi-inspect/src/components/DiInspectionView.tsx index bd1f78476e4..93001b1b708 100644 --- a/apps/kimi-inspect/src/components/DiInspectionView.tsx +++ b/apps/kimi-inspect/src/components/DiInspectionView.tsx @@ -12,12 +12,16 @@ * with that service's direct dependencies; rows carry path-scoped * relation bars (one per path ancestor with a direct edge) and a * path-root background highlight; + * - Events: event subscriptions (`IDebugEventsService.subscriptions`) — + * unit-book ledger entries labeled `on:` / + * `disposable:EventSubscription` per scope, plus per-bus listener counts + * as the fallback side (`di/DiEventsPanel.tsx`); * - Cascade: the cross-scope cascade history rings * (`IDebugCascadeService.history`), newest first; * - Pending: the waiting area + sticky failures per scope * (`IDebugCascadeService.pending`), with an `update` retry per failure. * - * All four panels poll on a short interval and refresh eagerly when the + * All five panels poll on a short interval and refresh eagerly when the * global `event.di.unit_changed` WS frame fires (`useDiQueryInvalidation` * invalidates the `['di']` query prefix). */ @@ -30,6 +34,10 @@ import { type DebugPendingGroup, } from '@moonshot-ai/agent-core-v2/debug/debugCascade'; import { IDebugGraphService, type DebugGraph } from '@moonshot-ai/agent-core-v2/debug/debugGraph'; +import { + IDebugEventsService, + type DebugEventSubscriptions, +} from '@moonshot-ai/agent-core-v2/features/debugEvents/debugEvents'; import { IDebugLedgerService, type DebugLedgerNode, @@ -42,13 +50,15 @@ import { useDiQueryInvalidation } from '../activity/di'; import type { InspectClient } from '../channel'; import { useConnection } from '../connection'; import { ActionButton, Badge, ErrorLine } from '../ui'; +import { DiEventsPanel } from './di/DiEventsPanel'; import { DiGraphPanel } from './di/DiGraphPanel'; -type DiPanel = 'units' | 'graph' | 'cascade' | 'pending'; +type DiPanel = 'units' | 'graph' | 'events' | 'cascade' | 'pending'; const PANELS: readonly { id: DiPanel; title: string }[] = [ { id: 'units', title: 'Units' }, { id: 'graph', title: 'Deps' }, + { id: 'events', title: 'Events' }, { id: 'cascade', title: 'Cascade' }, { id: 'pending', title: 'Pending' }, ]; @@ -86,6 +96,8 @@ export function DiInspectionView() { ) : panel === 'graph' ? ( + ) : panel === 'events' ? ( + ) : panel === 'cascade' ? ( ) : ( @@ -384,6 +396,19 @@ function GraphPanel() { return ; } +// --------------------------------------------------------------------------- +// Events panel — event subscriptions; rendering lives in di/DiEventsPanel.tsx +// --------------------------------------------------------------------------- + +function EventsPanel() { + const query = useDiQuery('events', (klient) => + klient.core(IDebugEventsService).subscriptions(), + ); + const gate = panelGate(query); + if (gate !== null) return gate; + return ; +} + // --------------------------------------------------------------------------- // Cascade panel — the cross-scope cascade history rings, newest first // --------------------------------------------------------------------------- diff --git a/apps/kimi-inspect/src/components/di/DiEventsPanel.tsx b/apps/kimi-inspect/src/components/di/DiEventsPanel.tsx new file mode 100644 index 00000000000..790dc6f389a --- /dev/null +++ b/apps/kimi-inspect/src/components/di/DiEventsPanel.tsx @@ -0,0 +1,146 @@ +/** + * DI Events panel — event-subscription introspection + * (`IDebugEventsService.subscriptions`), two merged sides: + * + * - Subscriptions: the unit-book side — every materialized unit's ledger + * entries labeled as an event subscription (`on:` from a named + * Emitter or the fiber `on` capability, `disposable:EventSubscription` + * from an unnamed one), grouped by scope path; + * - Bus listeners: the emitter-side fallback — per-`IEventBus` listener + * counts (`*` = the full stream) plus the global `IEventService` count, + * which also cover subscriptions never registered on a unit book. + * + * Pure React + Tailwind. + */ +import type { + DebugEventBusSnapshot, + DebugEventSubscription, + DebugEventSubscriptions, +} from '@moonshot-ai/agent-core-v2/features/debugEvents/debugEvents'; + +import { Badge } from '../../ui'; + +const KIND_TONES: Record = { + disposer: 'neutral', + effect: 'sky', + ledger: 'violet', +}; + +export function DiEventsPanel({ data }: { data: DebugEventSubscriptions }) { + const groups = groupByScope(data.subscriptions); + return ( +
+
+ subscriptions ({data.subscriptions.length}) +
+ {groups.length === 0 ? ( +
+ no event subscriptions on any unit book +
+ ) : ( + groups.map(([scopePath, subs]) => ( +
+
+ {scopePath} + {subs.length} +
+
+ {subs.map((sub, i) => ( +
+ + {sub.unit} + + {sub.uid !== undefined ? ( + #{sub.uid} + ) : null} + + {sub.label} + + + {sub.kind} + +
+ ))} +
+
+ )) + )} +
+ bus listeners +
+ {data.buses.length === 0 && data.globalListeners === undefined ? ( +
no materialized event buses
+ ) : ( +
+ {data.globalListeners !== undefined ? ( + + ) : null} + {data.buses.flatMap((bus) => busRows(bus))} +
+ )} +
+ ); +} + +function groupByScope( + subs: readonly DebugEventSubscription[], +): [string, DebugEventSubscription[]][] { + const map = new Map(); + for (const sub of subs) { + const group = map.get(sub.scopePath) ?? []; + group.push(sub); + map.set(sub.scopePath, group); + } + return [...map.entries()]; +} + +function busRows(bus: DebugEventBusSnapshot) { + const rows = [ + , + ]; + for (const type of Object.keys(bus.perType).toSorted()) { + rows.push( + , + ); + } + return rows; +} + +function BusRow({ + scopePath, + type, + count, +}: { + scopePath: string; + type: string; + count: number; +}) { + return ( +
+ + {scopePath} + + + {type} + + {count} +
+ ); +} diff --git a/packages/agent-core-v2/src/_base/di/instantiation.ts b/packages/agent-core-v2/src/_base/di/instantiation.ts index f059ea7a5fc..370dad62ff3 100644 --- a/packages/agent-core-v2/src/_base/di/instantiation.ts +++ b/packages/agent-core-v2/src/_base/di/instantiation.ts @@ -130,6 +130,10 @@ export function createDecorator(name: string): ServiceIdentifier { return id; } +export function lookupServiceDecorator(name: string): ServiceIdentifier | undefined { + return _util.serviceIds.get(name); +} + const SERVICE_IDENTIFIER_MARK = Symbol('serviceIdentifier'); export function isServiceIdentifier(thing: unknown): thing is ServiceIdentifier { diff --git a/packages/agent-core-v2/src/_base/di/lifecycle.ts b/packages/agent-core-v2/src/_base/di/lifecycle.ts index 5ae86e22e3c..4cb5dc5d147 100644 --- a/packages/agent-core-v2/src/_base/di/lifecycle.ts +++ b/packages/agent-core-v2/src/_base/di/lifecycle.ts @@ -5,7 +5,15 @@ import { onUnexpectedError } from '../errors/unexpectedError'; import { Ledger, type LedgerEntry } from '../lifecycle/ledger'; +export interface IDisposableDebugLabel { + readonly debugLabel?: string; +} + function disposableLabel(d: IDisposable): string { + const debugLabel = (d as IDisposableDebugLabel).debugLabel; + if (typeof debugLabel === 'string' && debugLabel.length > 0) { + return debugLabel; + } return `disposable:${d.constructor?.name ?? 'anonymous'}`; } diff --git a/packages/agent-core-v2/src/_base/event.ts b/packages/agent-core-v2/src/_base/event.ts index 802fe467416..35b0559ccd0 100644 --- a/packages/agent-core-v2/src/_base/event.ts +++ b/packages/agent-core-v2/src/_base/event.ts @@ -4,7 +4,9 @@ * `onWill` events whose listeners register work via `waitUntil`), the * `handleVetos` helper (for `onBefore*` veto events whose listeners answer * with `veto(value, id)`), and event combinators (`once` / `map` / `filter` - * / `any`). + * / `any`). `Emitter` accepts an optional debug name that its + * `EventSubscription` carries as an `on:` ledger label, so event + * subscriptions stay identifiable in unit-book introspection. */ import { onUnexpectedError, safelyCallListener } from './errors/unexpectedError'; @@ -13,6 +15,7 @@ import { DisposableStore, combinedDisposable, type IDisposable, + type IDisposableDebugLabel, } from './di/lifecycle'; import { LinkedList } from './di/util/linkedList'; @@ -29,11 +32,31 @@ interface ListenerEntry { thisArg: unknown; } +export class EventSubscription implements IDisposable, IDisposableDebugLabel { + readonly debugLabel: string | undefined; + private _removed = false; + + constructor( + debugName: string | undefined, + private readonly _remove: () => void, + ) { + this.debugLabel = debugName === undefined ? undefined : `on:${debugName}`; + } + + dispose(): void { + if (this._removed) return; + this._removed = true; + this._remove(); + } +} + export class Emitter { protected _listeners: Set> | undefined; private _disposed = false; private _event: Event | undefined; + constructor(public readonly debugName?: string) {} + get event(): Event { this._event ??= (listener, thisArg, disposables) => { if (this._disposed) { @@ -43,17 +66,12 @@ export class Emitter { const entry: ListenerEntry = { listener, thisArg }; this._listeners.add(entry); - let removed = false; - const subscription: IDisposable = { - dispose: () => { - if (removed) return; - removed = true; - if (this._disposed) { - return; - } - this._listeners?.delete(entry); - }, - }; + const subscription = new EventSubscription(this.debugName, () => { + if (this._disposed) { + return; + } + this._listeners?.delete(entry); + }); if (disposables !== undefined) { if (disposables instanceof DisposableStore) { @@ -67,6 +85,10 @@ export class Emitter { return this._event; } + get listenerCount(): number { + return this._listeners?.size ?? 0; + } + fire(value: T): void { if (this._disposed || this._listeners === undefined) { return; diff --git a/packages/agent-core-v2/src/app/event/eventBusService.ts b/packages/agent-core-v2/src/app/event/eventBusService.ts index 959d31fdd2d..a9e6b26d1f1 100644 --- a/packages/agent-core-v2/src/app/event/eventBusService.ts +++ b/packages/agent-core-v2/src/app/event/eventBusService.ts @@ -21,7 +21,7 @@ import { type DomainEvent, type DomainEventMap, IEventBus } from './eventBus'; export class EventBusService extends Service implements IEventBus { declare readonly _serviceBrand: undefined; - private readonly allEmitter = this._register(new Emitter()); + private readonly allEmitter = this._register(new Emitter('*')); private readonly perType = new Map>(); publish(event: DomainEvent): void { @@ -29,6 +29,14 @@ export class EventBusService extends Service implements IEventBus { this.perType.get(event.type)?.fire(event); } + listenerCounts(): { all: number; perType: Record } { + const perType: Record = {}; + for (const [type, emitter] of this.perType) { + perType[String(type)] = emitter.listenerCount; + } + return { all: this.allEmitter.listenerCount, perType }; + } + subscribe(handler: (event: DomainEvent) => void): IDisposable; subscribe( type: K, @@ -44,7 +52,7 @@ export class EventBusService extends Service implements IEventBus { const type = typeOrHandler; let emitter = this.perType.get(type); if (emitter === undefined) { - emitter = this._register(new Emitter()); + emitter = this._register(new Emitter(String(type))); this.perType.set(type, emitter); } return emitter.event(handler as unknown as (event: DomainEvent) => void); diff --git a/packages/agent-core-v2/src/app/event/eventService.ts b/packages/agent-core-v2/src/app/event/eventService.ts index beafdbc8ca9..062a51e3e7a 100644 --- a/packages/agent-core-v2/src/app/event/eventService.ts +++ b/packages/agent-core-v2/src/app/event/eventService.ts @@ -16,9 +16,13 @@ import { type DomainEvent, IEventService } from './event'; export class EventService extends Service implements IEventService { declare readonly _serviceBrand: undefined; - private readonly emitter = this._register(new Emitter()); + private readonly emitter = this._register(new Emitter('publish')); readonly onDidPublish: Event = this.emitter.event; + get listenerCount(): number { + return this.emitter.listenerCount; + } + publish(event: DomainEvent): void { this.emitter.fire(event); } diff --git a/packages/agent-core-v2/src/features/debugEvents/debugEvents.ts b/packages/agent-core-v2/src/features/debugEvents/debugEvents.ts new file mode 100644 index 00000000000..698c42b3552 --- /dev/null +++ b/packages/agent-core-v2/src/features/debugEvents/debugEvents.ts @@ -0,0 +1,48 @@ +/** + * `debugEvents` domain — `IDebugEventsService`: event-subscription + * introspection. + * + * Public contract. `subscriptions()` merges two sides: the precise unit-book + * side (every materialized unit's ledger entries whose label marks an event + * subscription — `on:` from a named `Emitter` or the fiber `on` + * capability, `disposable:EventSubscription` from an unnamed emitter) and the + * emitter-side fallback (listener counts of every materialized `IEventBus` + * instance and the global `IEventService`), which also covers subscriptions + * the caller never registered on a unit book. Unmaterialized on-demand units + * and anonymous fiber units are not enumerable and are simply absent. + * Contributed at App scope through `DebugEventsFeature` — reachable over the + * debug RPC surface by decorator name, but absent from the static scoped + * registry (`GET /api/v1/debug/channels`). All payloads are JSON-serializable + * wire data. + */ + +import { createDecorator } from '#/_base/di/instantiation'; +import type { LedgerEntryInfo } from '#/_base/lifecycle/ledger'; + +export interface DebugEventSubscription { + readonly scopePath: string; + readonly unit: string; + readonly uid?: number; + readonly label: string; + readonly kind: LedgerEntryInfo['kind']; +} + +export interface DebugEventBusSnapshot { + readonly scopePath: string; + readonly all: number; + readonly perType: Record; +} + +export interface DebugEventSubscriptions { + readonly subscriptions: DebugEventSubscription[]; + readonly buses: DebugEventBusSnapshot[]; + readonly globalListeners?: number; +} + +export interface IDebugEventsService { + readonly _serviceBrand: undefined; + + subscriptions(): DebugEventSubscriptions; +} + +export const IDebugEventsService = createDecorator('debugEventsService'); diff --git a/packages/agent-core-v2/src/features/debugEvents/debugEventsFeature.ts b/packages/agent-core-v2/src/features/debugEvents/debugEventsFeature.ts new file mode 100644 index 00000000000..f5ab880bb3f --- /dev/null +++ b/packages/agent-core-v2/src/features/debugEvents/debugEventsFeature.ts @@ -0,0 +1,31 @@ +/** + * `debugEvents` domain — `DebugEventsFeature`: the event-subscription + * introspection capability assembled as one App-scope Feature unit. + * + * Contributes the App-scope `IDebugEventsService` (OnDemand) through the + * `features` base-class seam; retracting the unit withdraws the service + * across the scope tree. The service is intentionally absent from the static + * scoped registry — the debug RPC dispatcher reaches it by decorator-name + * fallback. Registered into the feature table at import. + */ + +import { ScopeActivation } from '#/_base/di/instantiation'; +import { LifecycleScope } from '#/app/scopes'; +import { Feature } from '#/features/feature'; +import { registerFeature } from '#/features/featureRegistry'; + +import { IDebugEventsService } from './debugEvents'; +import { DebugEventsService } from './debugEventsService'; + +export class DebugEventsFeature extends Feature { + static override readonly name = 'debugEvents'; + + constructor() { + super(); + this.contributeService(LifecycleScope.App, IDebugEventsService, DebugEventsService, { + activation: ScopeActivation.OnDemand, + }); + } +} + +registerFeature(DebugEventsFeature); diff --git a/packages/agent-core-v2/src/features/debugEvents/debugEventsService.ts b/packages/agent-core-v2/src/features/debugEvents/debugEventsService.ts new file mode 100644 index 00000000000..08bdc74a3aa --- /dev/null +++ b/packages/agent-core-v2/src/features/debugEvents/debugEventsService.ts @@ -0,0 +1,120 @@ +/** + * `debugEvents` domain — `IDebugEventsService` implementation. + * + * Read-only introspection over the kernel's debug accessors (`children` / + * `servicesSnapshot` / `fiberHost.materializedInstance` / unit-book + * `ledger.entries`), plus listener counters on the `event` domain's bus + * implementations; no kernel state is mutated. Instances resolve up the parent + * chain, so each is attributed to the first container that reaches it and + * deduplicated by identity; unmaterialized on-demand units read as `undefined` + * and are skipped. Contributed at App scope through `DebugEventsFeature`; the + * injected container is the tree root. + */ + +import { IInstantiationService } from '#/_base/di/instantiation'; +import type { InstantiationService } from '#/_base/di/instantiationService'; +import type { LedgerEntryInfo } from '#/_base/lifecycle/ledger'; +import { IEventService } from '#/app/event/event'; +import { IEventBus } from '#/app/event/eventBus'; +import { walkScopeContainers } from '#/debug/scopeTree'; + +import { + IDebugEventsService, + type DebugEventBusSnapshot, + type DebugEventSubscription, + type DebugEventSubscriptions, +} from './debugEvents'; + +interface UnitBookOwner { + readonly unitBook: { entries(): LedgerEntryInfo[] }; +} + +interface BusCountSource { + listenerCounts(): { all: number; perType: Record }; +} + +interface GlobalCountSource { + readonly listenerCount: number; +} + +export class DebugEventsService implements IDebugEventsService { + declare readonly _serviceBrand: undefined; + + private readonly root: InstantiationService; + + constructor(@IInstantiationService instantiation: IInstantiationService) { + this.root = instantiation as InstantiationService; + } + + subscriptions(): DebugEventSubscriptions { + const subscriptions: DebugEventSubscription[] = []; + const buses: DebugEventBusSnapshot[] = []; + const seenUnits = new Set(); + const seenBuses = new Set(); + for (const info of walkScopeContainers(this.root)) { + for (const registration of info.container.servicesSnapshot()) { + const id = info.container.findIdentifier(registration.token); + if (id === undefined) { + continue; + } + const instance: unknown = info.container.fiberHost.materializedInstance(id); + if (typeof instance !== 'object' || instance === null || seenUnits.has(instance)) { + continue; + } + seenUnits.add(instance); + if ('unitBook' in instance) { + collectEventEntries((instance as UnitBookOwner).unitBook.entries(), subscriptions, { + scopePath: info.path, + unit: registration.token, + uid: registration.uid, + }); + } + } + const bus: unknown = info.container.fiberHost.materializedInstance(IEventBus); + if (isBusCountSource(bus) && !seenBuses.has(bus)) { + seenBuses.add(bus); + buses.push({ scopePath: info.path, ...bus.listenerCounts() }); + } + } + const globalEvents: unknown = this.root.fiberHost.materializedInstance(IEventService); + const globalListeners = isGlobalCountSource(globalEvents) + ? globalEvents.listenerCount + : undefined; + return { subscriptions, buses, globalListeners }; + } +} + +function collectEventEntries( + entries: readonly LedgerEntryInfo[], + out: DebugEventSubscription[], + base: { scopePath: string; unit: string; uid?: number }, +): void { + for (const entry of entries) { + if (isEventSubscriptionLabel(entry.label)) { + out.push({ ...base, label: entry.label, kind: entry.kind }); + } + if (entry.children !== undefined) { + collectEventEntries(entry.children, out, base); + } + } +} + +function isEventSubscriptionLabel(label: string): boolean { + return label.startsWith('on:') || label === 'disposable:EventSubscription'; +} + +function isBusCountSource(value: unknown): value is BusCountSource { + return ( + typeof value === 'object' && + value !== null && + typeof (value as BusCountSource).listenerCounts === 'function' + ); +} + +function isGlobalCountSource(value: unknown): value is GlobalCountSource { + return ( + typeof value === 'object' && + value !== null && + typeof (value as GlobalCountSource).listenerCount === 'number' + ); +} diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 6d910fcaba4..e41a76d8f31 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -302,6 +302,9 @@ export * from '#/features/plan/plan'; export * from '#/features/plan/planOps'; export * from '#/features/plan/planService'; import '#/features/plan/planFeature'; +export * from '#/features/debugEvents/debugEvents'; +export * from '#/features/debugEvents/debugEventsService'; +import '#/features/debugEvents/debugEventsFeature'; export * from '#/agent/tools/goal/create-goal/create-goal'; import '#/agent/tools/goal/create-goal/createGoalTool'; export * from '#/agent/tools/goal/get-goal/get-goal'; diff --git a/packages/agent-core-v2/test/_base/event.test.ts b/packages/agent-core-v2/test/_base/event.test.ts index 08d175c254b..8078a2e7be3 100644 --- a/packages/agent-core-v2/test/_base/event.test.ts +++ b/packages/agent-core-v2/test/_base/event.test.ts @@ -168,6 +168,44 @@ describe('Event.None', () => { }); }); +describe('Emitter debug name / EventSubscription ledger labels', () => { + it('named emitter subscriptions land on the store ledger as on:', () => { + const emitter = new Emitter('test.event'); + const store = new DisposableStore(); + + emitter.event(() => undefined, undefined, store); + + expect(store.ledger.entries().map((entry) => entry.label)).toContain('on:test.event'); + store.dispose(); + emitter.dispose(); + }); + + it('unnamed emitter subscriptions fall back to disposable:EventSubscription', () => { + const emitter = new Emitter(); + const store = new DisposableStore(); + + emitter.event(() => undefined, undefined, store); + + expect(store.ledger.entries().map((entry) => entry.label)).toContain( + 'disposable:EventSubscription', + ); + store.dispose(); + emitter.dispose(); + }); + + it('listenerCount tracks subscribe and dispose', () => { + const emitter = new Emitter(); + expect(emitter.listenerCount).toBe(0); + + const subscription = emitter.event(() => undefined); + expect(emitter.listenerCount).toBe(1); + + subscription.dispose(); + expect(emitter.listenerCount).toBe(0); + emitter.dispose(); + }); +}); + describe('Event.once', () => { it('delivers exactly once then auto-disposes', () => { const emitter = new Emitter(); diff --git a/packages/agent-core-v2/test/debug/debug.test.ts b/packages/agent-core-v2/test/debug/debug.test.ts index 1af5c8de357..89a2414a413 100644 --- a/packages/agent-core-v2/test/debug/debug.test.ts +++ b/packages/agent-core-v2/test/debug/debug.test.ts @@ -7,11 +7,21 @@ import { InstantiationService } from '#/_base/di/instantiationService'; import { Service } from '#/_base/di/service'; import { ServiceCollection } from '#/_base/di/serviceCollection'; import { Emitter } from '#/_base/event'; -import type { DomainEvent, IEventService } from '#/app/event/event'; +import { type DomainEvent, IEventService } from '#/app/event/event'; +import { EventService } from '#/app/event/eventService'; +import { IEventBus } from '#/app/event/eventBus'; +import { EventBusService } from '#/app/event/eventBusService'; import { DI_UNIT_CHANGED_EVENT } from '#/debug/debugCascade'; import { DebugCascadeService } from '#/debug/debugCascadeService'; import { DebugGraphService } from '#/debug/debugGraphService'; import { DebugLedgerService } from '#/debug/debugLedgerService'; +import { DebugEventsService } from '#/features/debugEvents/debugEventsService'; + +declare module '#/app/event/eventBus' { + interface DomainEventMap { + 'debug.test': { v: number }; + } +} interface IRoot { @@ -74,6 +84,14 @@ class FakeEventService implements IEventService { } } +class BusSubscriber extends Service { + constructor(@IEventBus bus: IEventBus) { + super(); + this._register(bus.subscribe('debug.test', () => undefined)); + } +} +const IBusSubscriber = createDecorator('debug-bus-subscriber'); + function makeTree(): { app: InstantiationService; ws: InstantiationService } { const app = new InstantiationService(new ServiceCollection(), true); app.debugLabel = 'app'; @@ -272,3 +290,44 @@ describe('debug domain — IDebugCascadeService', () => { app.dispose(); }); }); + +describe('debug domain — IDebugEventsService', () => { + it('subscriptions() merges unit-book labels and bus listener counts, deduped across containers', () => { + const { app } = makeTree(); + app.provide(IEventBus, new SyncDescriptor(EventBusService)); + app.provide(IBusSubscriber, new SyncDescriptor(BusSubscriber)); + app.invokeFunction((a) => a.get(IBusSubscriber)); + const bus = app.invokeFunction((a) => a.get(IEventBus)); + bus.subscribe('debug.test', () => undefined); + bus.subscribe(() => undefined); + + const result = new DebugEventsService(app).subscriptions(); + + const entry = result.subscriptions.find((s) => s.unit === 'debug-bus-subscriber'); + expect(entry).toMatchObject({ + scopePath: 'app', + label: 'on:debug.test', + kind: 'disposer', + uid: expect.any(Number), + }); + expect(result.buses).toEqual([ + { scopePath: 'app', all: 1, perType: { 'debug.test': 2 } }, + ]); + expect(() => JSON.stringify(result)).not.toThrow(); + app.dispose(); + }); + + it('skips unmaterialized units and reports the global event service listener count', () => { + const { app } = makeTree(); + app.provide(IBusSubscriber, new SyncDescriptor(BusSubscriber)); + app.provide(IEventService, new SyncDescriptor(EventService)); + const events = app.invokeFunction((a) => a.get(IEventService)); + events.subscribe(() => undefined); + + const result = new DebugEventsService(app).subscriptions(); + + expect(result.subscriptions.find((s) => s.unit === 'debug-bus-subscriber')).toBeUndefined(); + expect(result.globalListeners).toBe(1); + app.dispose(); + }); +}); diff --git a/packages/agent-core-v2/test/features/debugEvents/debugEvents.test.ts b/packages/agent-core-v2/test/features/debugEvents/debugEvents.test.ts new file mode 100644 index 00000000000..e4899897871 --- /dev/null +++ b/packages/agent-core-v2/test/features/debugEvents/debugEvents.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { ScopeActivation } from '#/_base/di/instantiation'; +import { + _clearScopedRegistryForTests, + getScopedServiceDescriptors, + registerScopedService, +} from '#/_base/di/scope'; +import { createScopedTestHost } from '#/_base/di/test'; +import { IFeatureManager } from '#/app/feature/featureManager'; +import { FeatureManagerService } from '#/app/feature/featureManagerService'; +import { LifecycleScope } from '#/app/scopes'; +import { IFeatureAssemblyService } from '#/features/featureAssembly'; +import { FeatureAssemblyService } from '#/features/featureAssemblyService'; +import { _clearFeatureRecipesForTests, registerFeature } from '#/features/featureRegistry'; + +import { IDebugEventsService } from '#/features/debugEvents/debugEvents'; +import { DebugEventsFeature } from '#/features/debugEvents/debugEventsFeature'; + +describe('DebugEventsFeature — App-scope introspection service', () => { + beforeEach(() => { + _clearScopedRegistryForTests(); + _clearFeatureRecipesForTests(); + registerScopedService( + LifecycleScope.App, + IFeatureManager, + FeatureManagerService, + ScopeActivation.OnScopeCreated, + 'feature', + ); + registerScopedService( + LifecycleScope.App, + IFeatureAssemblyService, + FeatureAssemblyService, + ScopeActivation.OnScopeCreated, + 'features', + ); + registerFeature(DebugEventsFeature); + }); + + it('contributes IDebugEventsService at App scope outside the static scoped registry', async () => { + expect( + getScopedServiceDescriptors(LifecycleScope.App).some( + (entry) => entry.id.toString() === 'debugEventsService', + ), + ).toBe(false); + + const host = createScopedTestHost(); + const manager = host.app.accessor.get(IFeatureManager); + expect(manager.units().map((unit) => unit.name)).toContain('debugEvents'); + + const result = host.app.accessor.get(IDebugEventsService).subscriptions(); + expect(result).toMatchObject({ + subscriptions: expect.any(Array), + buses: expect.any(Array), + }); + + await manager.unprovideUnit('debugEvents'); + await host.app.instantiation.cascade.whenIdle(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(() => host.app.accessor.get(IDebugEventsService)).toThrow(); + host.dispose(); + }); +}); diff --git a/packages/kap-server/AGENTS.md b/packages/kap-server/AGENTS.md index 131c15f2c10..c743d7b06f3 100644 --- a/packages/kap-server/AGENTS.md +++ b/packages/kap-server/AGENTS.md @@ -5,7 +5,7 @@ The Kimi Code server, backed by the DI × Scope agent engine (`@moonshot-ai/agen ## Routes - Session create/resume/fork routes compose `ISessionIndex` → `IWorkspaceLifecycleService.handlerFor` → the handler's `ISessionLifecycleService`, and the fs routes resolve session → handler → the Workspace-scope fs services. One exception: `fs:search` also accepts a workspace reference (registered id or absolute root) in the `{session_id}` slot, so a not-yet-created draft session's `@` file mention resolves the workspace handler directly; the first-class session-less form is `POST /api/v1/workspace/fs:search` (the workspace reference travels in the body). -- The RPC surface is `/api/v1/debug/*` — a reflection dispatcher over the ENTIRE scoped DI registry (every Service callable, no whitelist, Workspace scope addressable alongside App/Session/Agent; `src/transport/registerDebugRoutes.ts` + `serviceDispatcherRoutes.ts`), mounted only with `--debug-endpoints` on a loopback bind and gated by the global bearer auth; repo dev scripts pass the flag. +- The RPC surface is `/api/v1/debug/*` — a reflection dispatcher over the ENTIRE scoped DI registry (every Service callable, no whitelist, Workspace scope addressable alongside App/Session/Agent; `src/transport/registerDebugRoutes.ts` + `serviceDispatcherRoutes.ts`), mounted only with `--debug-endpoints` on a loopback bind and gated by the global bearer auth; repo dev scripts pass the flag. Lookup falls back to the global decorator registry, so runtime-contributed Services that bypass the static scoped registry (e.g. a Feature's `contributeService`) stay callable even though `GET /channels` does not list them. ## `/api/v2` surface diff --git a/packages/kap-server/src/transport/channelRegistry.ts b/packages/kap-server/src/transport/channelRegistry.ts index 2bf20314cdc..48f84530c7e 100644 --- a/packages/kap-server/src/transport/channelRegistry.ts +++ b/packages/kap-server/src/transport/channelRegistry.ts @@ -1,6 +1,9 @@ /** * `/api/v1/debug` channel registry — the set of Services exposed over the - * wire, which is simply the ENTIRE scoped DI registry (no whitelist). + * wire: the ENTIRE scoped DI registry (no whitelist), plus any Service + * resolvable by decorator name as a fallback, so runtime-contributed units + * (Feature `contributeService`, which bypasses the static scoped registry) + * stay callable. * * In VS Code's `registerChannel` model a Service is registered once, keyed by * its decorator id (the public channel name), and from then on all of its @@ -13,6 +16,7 @@ import { Disposable, getScopedServiceDescriptors, LifecycleScope, + lookupServiceDecorator, } from '@moonshot-ai/agent-core-v2'; import type { ScopedEntry, ServiceIdentifier } from '@moonshot-ai/agent-core-v2'; @@ -83,7 +87,7 @@ function scopedServiceNameIndex(): Map> { /** Resolve a wire name to its `ServiceIdentifier` anywhere in the DI registry. */ export function resolveAnyScopedServiceId(name: string): ServiceIdentifier | undefined { - return scopedServiceNameIndex().get(name); + return scopedServiceNameIndex().get(name) ?? lookupServiceDecorator(name); } /** diff --git a/packages/kap-server/src/transport/serviceDispatcherRoutes.ts b/packages/kap-server/src/transport/serviceDispatcherRoutes.ts index 81163cb7a65..4a17799131b 100644 --- a/packages/kap-server/src/transport/serviceDispatcherRoutes.ts +++ b/packages/kap-server/src/transport/serviceDispatcherRoutes.ts @@ -3,7 +3,8 @@ * * Mounts the reflection dispatcher under `basePath`: the routes mirror the * scope tree; all share one handler. `:service` is a decorator id (channel - * name) resolved against the scoped DI registry; `:method` is invoked by + * name) resolved against the scoped DI registry, then the global decorator + * registry (runtime-contributed Services); `:method` is invoked by * reflection. Reads use `GET`, writes use `POST`. * * GET|POST {basePath}/:service/:method diff --git a/packages/kap-server/test/rpc.test.ts b/packages/kap-server/test/rpc.test.ts index 88f6107afd6..b0154f31888 100644 --- a/packages/kap-server/test/rpc.test.ts +++ b/packages/kap-server/test/rpc.test.ts @@ -9,6 +9,7 @@ import { IAgentRPCService, IAgentShellCommandService, IAppendLogStore, + IDebugEventsService, IEventService, IPluginService, ISessionIndex, @@ -187,6 +188,28 @@ describe('server-v2 /api/v1/debug RPC', () => { expect(meta?.methods.map((m) => m.name)).not.toContain('dispose'); }); + it('reaches a runtime-contributed Service absent from /channels (decorator-name fallback)', async () => { + // IDebugEventsService comes from DebugEventsFeature's contributeService, + // which bypasses the static scoped registry: /channels omits it, but the + // dispatcher still resolves it through the global decorator registry. + const channels = await call( + 'GET', + '/api/v1/debug/channels', + ); + expect(channels.body.data.some((c) => c.name === String(IDebugEventsService))).toBe(false); + + const { status, body } = await call<{ + subscriptions: unknown[]; + buses: unknown[]; + globalListeners?: number; + }>('GET', rpc('core', IDebugEventsService, 'subscriptions')); + expect(status).toBe(200); + expect(body.code).toBe(0); + expect(Array.isArray(body.data.subscriptions)).toBe(true); + expect(Array.isArray(body.data.buses)).toBe(true); + expect(typeof body.data.globalListeners).toBe('number'); + }); + it('lists sessions via GET', async () => { const { body } = await call<{ items: unknown[]; has_more: boolean }>( 'GET',