refactor(services,daemon,agent-core): align service registry bootstrap and expand DI lifecycle primitives

- remove defaultServicesModule() and services/src/module.ts; consume getSingletonServiceDescriptors() directly\n- update daemon service registrations and bootstrap to use registry descriptors\n- add DisposableMap, DisposableSet, disposable tracking, and disposeOnReturn to agent-core DI\n- update AGENTS.md with new registration patterns
This commit is contained in:
haozhe.yang 2026-06-09 20:09:29 +08:00
parent d4eacc7e7d
commit 18224d47ff
31 changed files with 1059 additions and 1049 deletions

View file

@ -0,0 +1,6 @@
---
"@moonshot-ai/services": patch
"@moonshot-ai/daemon": patch
---
Use singleton service descriptors directly for daemon service bootstrap.

View file

@ -29,11 +29,22 @@ export { InstantiationService } from './instantiationService';
export {
Disposable,
DisposableStore,
DisposableMap,
DisposableSet,
MutableDisposable,
DisposableTracker,
combinedDisposable,
toDisposable,
dispose,
disposeIfDisposable,
disposeOnReturn,
isDisposable,
markAsSingleton,
setDisposableTracker,
trackDisposable,
markAsDisposed,
} from './lifecycle';
export type { IDisposable } from './lifecycle';
export type { IDisposable, IDisposableTracker } from './lifecycle';
export { CyclicDependencyError } from './errors';
export {
InstantiationType,

View file

@ -1,68 +1,435 @@
/**
* Lifecycle primitives for DI-managed services: `IDisposable` interface, a
* `Disposable` base class, plus the helper primitives (`DisposableStore`,
* `MutableDisposable`, `toDisposable`, `combinedDisposable`, `Disposable.None`)
* that `Event<T>` / `Emitter<T>` (in `base/common/event.ts`) build on top of.
* `MutableDisposable`, `DisposableMap`, `DisposableSet`, `toDisposable`,
* `combinedDisposable`, `dispose`, `disposeOnReturn`, `Disposable.None`) that
* `Event<T>` / `Emitter<T>` (in `base/common/event.ts`) build on top of.
*
* Modelled after VSCode's `base/common/lifecycle.ts`. Children disposed by
* `Disposable` / `DisposableStore` are torn down in reverse register order
* (LIFO) and each `dispose()` is wrapped in try/catch so one failing child
* does not skip its siblings.
* Modelled after VSCode's `base/common/lifecycle.ts`. Two intentional
* deviations from upstream:
*
* 1. **Error policy** VSCode's iterable `dispose(...)` collects per-child
* errors and throws an `AggregateError` at the end. Here every dispose
* path routes failures through `onUnexpectedError` and continues, so a
* single misbehaving child cannot abort sibling teardown. Same policy
* `Emitter.fire()` uses; keep it consistent.
* 2. **Disposable tracker** `IDisposableTracker` interface and the
* plumbing (`trackDisposable` / `markAsDisposed` / `setParentOfDisposable`)
* are present, but `disposableTracker` defaults to `null` so there is
* zero overhead. Install a `DisposableTracker` in test setup (or a custom
* one in a debug build) when leak hunting. We do not ship the
* `GCBasedDisposableTracker` variant `FinalizationRegistry` timing is
* non-deterministic enough to produce noisy reports.
*
* Sibling teardown order is **insertion order** (`Set` iteration). Subclasses
* that need a specific order must sequence teardown explicitly inside
* `override dispose()` before calling `super.dispose()`.
*/
import { onUnexpectedError } from '../errors/unexpectedError';
// #region Disposable Tracking
/**
* Hook surface for tracking living disposables, parentage, and lifecycle
* transitions. Install via `setDisposableTracker`. Defaults to `null`
* (zero overhead).
*/
export interface IDisposableTracker {
/** Called on construction of every disposable. */
trackDisposable(disposable: IDisposable): void;
/**
* Called when a disposable is registered as child of another. If `parent`
* is `null`, the disposable was detached from its former parent (e.g. by
* `DisposableStore.deleteAndLeak`).
*/
setParent(child: IDisposable, parent: IDisposable | null): void;
/** Called after a disposable's `dispose()` runs. */
markAsDisposed(disposable: IDisposable): void;
/**
* Mark a disposable as a singleton (lives for the lifetime of the process)
* so it isn't reported as a leak.
*/
markAsSingleton(disposable: IDisposable): void;
}
interface DisposableInfo {
value: IDisposable;
source: string | null;
parent: IDisposable | null;
isSingleton: boolean;
idx: number;
}
/**
* Default tracker for test / dev use. Records a constructor stack on each
* `trackDisposable` call and removes the entry on `markAsDisposed`. After a
* suspected leak window (e.g. an `afterAll` hook), call
* `getTrackedDisposables()` to inspect what's still alive.
*
* Roots whose ancestor was marked as a singleton are filtered out (they
* intentionally live for the process).
*/
export class DisposableTracker implements IDisposableTracker {
private static idx = 0;
private readonly livingDisposables = new Map<IDisposable, DisposableInfo>();
private getDisposableData(d: IDisposable): DisposableInfo {
let val = this.livingDisposables.get(d);
if (!val) {
val = {
parent: null,
source: null,
isSingleton: false,
value: d,
idx: DisposableTracker.idx++,
};
this.livingDisposables.set(d, val);
}
return val;
}
trackDisposable(d: IDisposable): void {
const data = this.getDisposableData(d);
if (!data.source) {
data.source = new Error().stack ?? null;
}
}
setParent(child: IDisposable, parent: IDisposable | null): void {
this.getDisposableData(child).parent = parent;
}
markAsDisposed(x: IDisposable): void {
this.livingDisposables.delete(x);
}
markAsSingleton(d: IDisposable): void {
this.getDisposableData(d).isSingleton = true;
}
private getRootParent(
data: DisposableInfo,
cache: Map<DisposableInfo, DisposableInfo>,
): DisposableInfo {
const cached = cache.get(data);
if (cached) return cached;
const result = data.parent
? this.getRootParent(this.getDisposableData(data.parent), cache)
: data;
cache.set(data, result);
return result;
}
/**
* All currently-living disposables whose root ancestor is NOT a singleton.
* Use as the post-condition assertion in test teardown.
*/
getTrackedDisposables(): IDisposable[] {
const cache = new Map<DisposableInfo, DisposableInfo>();
return [...this.livingDisposables.entries()]
.filter(
([, v]) => v.source !== null && !this.getRootParent(v, cache).isSingleton,
)
.map(([k]) => k);
}
}
let disposableTracker: IDisposableTracker | null = null;
export function setDisposableTracker(tracker: IDisposableTracker | null): void {
disposableTracker = tracker;
}
export function trackDisposable<T extends IDisposable>(x: T): T {
disposableTracker?.trackDisposable(x);
return x;
}
export function markAsDisposed(disposable: IDisposable): void {
disposableTracker?.markAsDisposed(disposable);
}
function setParentOfDisposable(
child: IDisposable,
parent: IDisposable | null,
): void {
disposableTracker?.setParent(child, parent);
}
function setParentOfDisposables(
children: IDisposable[],
parent: IDisposable | null,
): void {
if (!disposableTracker) return;
for (const child of children) {
disposableTracker.setParent(child, parent);
}
}
/**
* Indicates that the given object lives for the lifetime of the process so
* the tracker should not report it as a leak.
*/
export function markAsSingleton<T extends IDisposable>(singleton: T): T {
disposableTracker?.markAsSingleton(singleton);
return singleton;
}
// #endregion
export interface IDisposable {
dispose(): void;
}
/**
* Base class for services that own other disposables. Subclasses call
* `this._register(child)` to take ownership; `dispose()` tears children down
* in reverse register order (LIFO) and is idempotent.
* Type guard for heterogeneous collections. Matches VSCode `isDisposable`:
* accepts any object with a zero-arg `dispose()` method.
*/
export abstract class Disposable implements IDisposable {
private _disposed = false;
protected _toDispose: IDisposable[] = [];
export function isDisposable<E>(thing: E): thing is E & IDisposable {
return (
typeof thing === 'object' &&
thing !== null &&
typeof (thing as unknown as IDisposable).dispose === 'function' &&
(thing as unknown as IDisposable).dispose.length === 0
);
}
/**
* Take ownership of a child disposable. Returns the child for ergonomic
* one-liner chaining (`const x = this._register(new Foo())`).
*/
protected _register<T extends IDisposable>(d: T): T {
if (this._disposed) {
// Don't silently hold a reference after disposal; tear down immediately
// so we don't leak the child if someone calls `_register` post-dispose.
/**
* Dispose one or many `IDisposable`s. Per-child errors are routed through
* `onUnexpectedError` and do not abort the loop (kimi-code policy VSCode
* collects errors into an `AggregateError` and throws; we do not, to keep
* a single misbehaving child from breaking sibling teardown).
*
* Overloads mirror VSCode for ergonomic call sites.
*/
export function dispose<T extends IDisposable>(disposable: T): T;
export function dispose<T extends IDisposable>(
disposable: T | undefined,
): T | undefined;
export function dispose<T extends IDisposable, A extends Iterable<T> = Iterable<T>>(
disposables: A,
): A;
export function dispose<T extends IDisposable>(disposables: Array<T>): Array<T>;
export function dispose<T extends IDisposable>(
disposables: ReadonlyArray<T>,
): ReadonlyArray<T>;
export function dispose<T extends IDisposable>(
arg: T | Iterable<T> | undefined,
): unknown {
if (arg === undefined || arg === null) return arg;
if (isIterable<T>(arg)) {
for (const d of arg) {
if (d) {
try {
d.dispose();
} catch (err) {
onUnexpectedError(err);
}
}
}
return Array.isArray(arg) ? [] : arg;
}
try {
(arg as T).dispose();
} catch (err) {
onUnexpectedError(err);
}
return arg;
}
function isIterable<T>(arg: unknown): arg is Iterable<T> {
return (
typeof arg === 'object' &&
arg !== null &&
typeof (arg as { [Symbol.iterator]?: unknown })[Symbol.iterator] === 'function'
);
}
/**
* Dispose only the entries in `disposables` that pass the `isDisposable`
* type guard. Mirrors VSCode helper of the same name; useful when holding
* mixed collections (e.g. legacy code that may or may not implement the
* interface).
*/
export function disposeIfDisposable<T extends IDisposable | object>(
disposables: Array<T>,
): Array<T> {
for (const d of disposables) {
if (isDisposable(d)) {
try {
d.dispose();
} catch {
// Swallow: dispose() must be idempotent / forgiving.
} catch (err) {
onUnexpectedError(err);
}
return d;
}
this._toDispose.push(d);
return d;
}
return [];
}
/**
* Wrap a function as an `IDisposable`. The returned object's `dispose()`
* invokes `fn` at most once repeated calls are a no-op (idempotent).
*
* Implemented as a class so the returned object has a stable shape for
* debuggers / V8 hidden-class optimisation, and so the tracker can record
* a construction stack.
*/
class FunctionDisposable implements IDisposable {
private _isDisposed = false;
private readonly _fn: () => void;
constructor(fn: () => void) {
this._fn = fn;
trackDisposable(this);
}
dispose(): void {
if (this._disposed) {
return;
if (this._isDisposed) return;
this._isDisposed = true;
markAsDisposed(this);
try {
this._fn();
} catch (err) {
onUnexpectedError(err);
}
this._disposed = true;
// Reverse order: most-recently-registered tears down first (LIFO).
while (this._toDispose.length > 0) {
const child = this._toDispose.pop();
if (!child) continue;
}
}
export function toDisposable(fn: () => void): IDisposable {
return new FunctionDisposable(fn);
}
/**
* Aggregate multiple disposables into a single `IDisposable`. Children are
* disposed in insertion order via the iterable `dispose(...)` helper, so
* one throwing child does not skip its siblings.
*/
export function combinedDisposable(...disposables: IDisposable[]): IDisposable {
const parent = toDisposable(() => dispose(disposables));
setParentOfDisposables(disposables, parent);
return parent;
}
/**
* Container that owns multiple `IDisposable`s. Iteration / disposal order is
* **insertion order** (`Set` semantics). Mirrors VSCode
* `base/common/lifecycle.ts DisposableStore`.
*/
export class DisposableStore implements IDisposable {
private readonly _toDispose = new Set<IDisposable>();
private _isDisposed = false;
constructor() {
trackDisposable(this);
}
/**
* Take ownership of `d`. Returns `d` for ergonomic chaining
* (`const x = store.add(new Foo())`). After the store has been disposed,
* `add` disposes the incoming child immediately and still returns it.
* Adding the store to itself throws.
*/
add<T extends IDisposable>(d: T): T {
if ((d as unknown as DisposableStore) === this) {
throw new Error('Cannot register a disposable on itself!');
}
setParentOfDisposable(d, this);
if (this._isDisposed) {
try {
child.dispose();
} catch {
// Continue tearing down siblings even if one throws.
d.dispose();
} catch (err) {
onUnexpectedError(err);
}
return d;
}
this._toDispose.add(d);
return d;
}
/**
* Remove `d` from the store AND dispose it. Matches VSCode
* `DisposableStore.delete`. Use `deleteAndLeak` to detach without
* disposing.
*/
delete<T extends IDisposable>(d: T): void {
if (this._isDisposed) return;
if ((d as unknown as DisposableStore) === this) {
throw new Error('Cannot dispose a disposable on itself!');
}
this._toDispose.delete(d);
try {
d.dispose();
} catch (err) {
onUnexpectedError(err);
}
}
protected get _isDisposed(): boolean {
return this._disposed;
/**
* Remove `d` from the store WITHOUT disposing. Caller takes ownership of
* `d`'s lifetime. Matches VSCode `DisposableStore.deleteAndLeak`.
*/
deleteAndLeak<T extends IDisposable>(d: T): void {
if (this._isDisposed) return;
if (this._toDispose.delete(d)) {
setParentOfDisposable(d, null);
}
}
/**
* Dispose every currently-held child but keep the store usable.
*/
clear(): void {
if (this._isDisposed) return;
if (this._toDispose.size === 0) return;
const items = Array.from(this._toDispose);
this._toDispose.clear();
dispose(items);
}
/**
* Dispose every currently-held child and mark the store as disposed.
* Idempotent.
*/
dispose(): void {
if (this._isDisposed) return;
this._isDisposed = true;
markAsDisposed(this);
const items = Array.from(this._toDispose);
this._toDispose.clear();
dispose(items);
}
get isDisposed(): boolean {
return this._isDisposed;
}
}
/**
* Base class for services that own other disposables. Subclasses call
* `this._register(child)` to take ownership; `dispose()` tears children down
* in **insertion order** (matching VSCode) and is idempotent.
*
* Subclasses inspect "have I been disposed yet?" via `this._store.isDisposed`.
*/
export abstract class Disposable implements IDisposable {
protected readonly _store = new DisposableStore();
constructor() {
trackDisposable(this);
setParentOfDisposable(this._store, this);
}
protected _register<T extends IDisposable>(d: T): T {
if ((d as unknown as Disposable) === this) {
throw new Error('Cannot register a disposable on itself!');
}
return this._store.add(d);
}
dispose(): void {
markAsDisposed(this);
this._store.dispose();
}
}
@ -70,6 +437,10 @@ export abstract class Disposable implements IDisposable {
* Static zero-value disposable. `Disposable.None.dispose()` is a no-op and
* is safe to call repeatedly. The object is frozen so callers can't mutate
* the shared instance. Modelled after VSCode `base/common/lifecycle.ts`.
*
* Declared as a namespace merger rather than a static class property so we
* don't pull `DisposableStore` allocation into module load just to read
* `Disposable.None`.
*/
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace Disposable {
@ -80,52 +451,11 @@ export namespace Disposable {
});
}
/**
* Wrap a function as an `IDisposable`. The returned object's `dispose()`
* invokes `fn` at most once repeated calls are a no-op (idempotent).
*/
export function toDisposable(fn: () => void): IDisposable {
let called = false;
return {
dispose(): void {
if (called) return;
called = true;
fn();
},
};
}
/**
* Aggregate multiple disposables into a single `IDisposable`. The returned
* object's `dispose()` invokes each child's `dispose()` each call wrapped
* in try/catch so one throwing child does not skip its siblings (mirrors
* `Disposable.dispose()` semantics above). Idempotent: a second `dispose()`
* is a no-op.
*/
export function combinedDisposable(...disposables: IDisposable[]): IDisposable {
let disposed = false;
return {
dispose(): void {
if (disposed) return;
disposed = true;
for (const child of disposables) {
try {
child.dispose();
} catch (err) {
// Route to onUnexpectedError so the failure is visible, but keep
// iterating siblings (consistent with Disposable.dispose()).
onUnexpectedError(err);
}
}
},
};
}
/**
* Mutable slot that owns a single `IDisposable`. Assigning a new value
* disposes the previous one; assigning `undefined` disposes the current
* value. After this store has itself been disposed any subsequent value
* is disposed immediately on assignment (mirrors `Disposable._register`).
* is disposed immediately on assignment.
*
* Mirrors VSCode `base/common/lifecycle.ts MutableDisposable`.
*/
@ -133,14 +463,16 @@ export class MutableDisposable<T extends IDisposable> implements IDisposable {
private _value: T | undefined;
private _isDisposed = false;
constructor() {
trackDisposable(this);
}
get value(): T | undefined {
return this._isDisposed ? undefined : this._value;
}
set value(value: T | undefined) {
if (this._isDisposed) {
// Once disposed, the slot can no longer hold a reference — tear
// down the new value immediately and keep the slot empty.
if (value !== undefined) {
try {
value.dispose();
@ -150,11 +482,10 @@ export class MutableDisposable<T extends IDisposable> implements IDisposable {
}
return;
}
if (this._value === value) {
return;
}
if (this._value === value) return;
const prev = this._value;
this._value = value;
if (value) setParentOfDisposable(value, this);
if (prev !== undefined) {
try {
prev.dispose();
@ -164,14 +495,10 @@ export class MutableDisposable<T extends IDisposable> implements IDisposable {
}
}
/**
* Dispose the held value (if any) and mark the slot as disposed.
* Subsequent `value = ...` assignments dispose the incoming value
* immediately. Idempotent.
*/
dispose(): void {
if (this._isDisposed) return;
this._isDisposed = true;
markAsDisposed(this);
const prev = this._value;
this._value = undefined;
if (prev !== undefined) {
@ -199,87 +526,240 @@ export class MutableDisposable<T extends IDisposable> implements IDisposable {
}
}
}
/**
* Clear the slot WITHOUT disposing the current value; returns the old
* value. Caller takes ownership of its lifetime.
*/
clearAndLeak(): T | undefined {
if (this._isDisposed) return undefined;
const prev = this._value;
this._value = undefined;
if (prev !== undefined) setParentOfDisposable(prev, null);
return prev;
}
}
/**
* Container that owns multiple `IDisposable`s. `add` returns the child for
* chaining; `delete` removes (without disposing); `clear` disposes every
* currently-held child; `dispose` disposes every child and marks the store
* as disposed. Once disposed, subsequent `add(child)` calls dispose the
* incoming child immediately (mirrors `Disposable._register`).
* Map whose values are `IDisposable`. Overwriting a key disposes the previous
* value; `deleteAndDispose(key)` removes and disposes; `dispose()` disposes
* every value and marks the map as disposed. Mirrors VSCode
* `base/common/lifecycle.ts DisposableMap`.
*
* Mirrors VSCode `base/common/lifecycle.ts DisposableStore`.
* Use this to collapse the "Map of per-entity state + manual teardown loop in
* `override dispose()`" pattern that recurs across daemon services.
*/
export class DisposableStore implements IDisposable {
private _toDispose = new Set<IDisposable>();
export class DisposableMap<K, V extends IDisposable = IDisposable>
implements IDisposable
{
private readonly _store: Map<K, V>;
private _isDisposed = false;
/**
* Take ownership of `d`. Returns `d` for ergonomic chaining
* (`const x = store.add(new Foo())`). After the store has been disposed,
* `add` disposes the incoming child immediately and still returns it.
*/
add<T extends IDisposable>(d: T): T {
if (this._isDisposed) {
try {
d.dispose();
} catch (err) {
onUnexpectedError(err);
}
return d;
}
this._toDispose.add(d);
return d;
constructor(store: Map<K, V> = new Map<K, V>()) {
this._store = store;
trackDisposable(this);
}
/**
* Remove `d` from the store WITHOUT disposing it. No-op if `d` is not
* currently tracked.
*/
delete<T extends IDisposable>(d: T): void {
if (this._isDisposed) return;
this._toDispose.delete(d);
}
/**
* Dispose every currently-held child but keep the store usable
* (subsequent `add` calls still work).
*/
clear(): void {
if (this._isDisposed) return;
if (this._toDispose.size === 0) return;
const items = Array.from(this._toDispose);
this._toDispose.clear();
// Iterate in reverse so most-recently-added tears down first (LIFO),
// matching `Disposable.dispose()` semantics.
for (let i = items.length - 1; i >= 0; i--) {
try {
items[i]!.dispose();
} catch (err) {
onUnexpectedError(err);
}
}
}
/**
* Dispose every currently-held child and mark the store as disposed.
* Idempotent.
* Dispose every stored value and mark this object as disposed. Subsequent
* mutation (`set`) is a no-op + warning.
*/
dispose(): void {
if (this._isDisposed) return;
this._isDisposed = true;
const items = Array.from(this._toDispose);
this._toDispose.clear();
for (let i = items.length - 1; i >= 0; i--) {
markAsDisposed(this);
this.clearAndDisposeAll();
}
/**
* Dispose every stored value and clear the map, but DO NOT mark the map
* itself as disposed (subsequent `set` calls still work).
*/
clearAndDisposeAll(): void {
if (this._store.size === 0) return;
try {
dispose(this._store.values());
} finally {
this._store.clear();
}
}
has(key: K): boolean {
return this._store.has(key);
}
get size(): number {
return this._store.size;
}
get(key: K): V | undefined {
return this._store.get(key);
}
/**
* Insert `value` at `key`. If `key` already has a value, that previous
* value is disposed unless `skipDisposeOnOverwrite` is set.
*/
set(key: K, value: V, skipDisposeOnOverwrite = false): void {
if (this._isDisposed) {
// eslint-disable-next-line no-console
console.warn(
new Error(
'Trying to add a disposable to a DisposableMap that has already been disposed of. The added object will be leaked!',
).stack,
);
return;
}
if (!skipDisposeOnOverwrite) {
const prev = this._store.get(key);
if (prev !== undefined && prev !== value) {
try {
prev.dispose();
} catch (err) {
onUnexpectedError(err);
}
}
}
this._store.set(key, value);
setParentOfDisposable(value, this);
}
/**
* Remove the value stored for `key` AND dispose it.
*/
deleteAndDispose(key: K): void {
const value = this._store.get(key);
if (value !== undefined) {
try {
items[i]!.dispose();
value.dispose();
} catch (err) {
onUnexpectedError(err);
}
}
this._store.delete(key);
}
/**
* Remove the value stored for `key` and return it. Caller takes
* ownership of the lifetime.
*/
deleteAndLeak(key: K): V | undefined {
const value = this._store.get(key);
if (value !== undefined) setParentOfDisposable(value, null);
this._store.delete(key);
return value;
}
keys(): IterableIterator<K> {
return this._store.keys();
}
values(): IterableIterator<V> {
return this._store.values();
}
[Symbol.iterator](): IterableIterator<[K, V]> {
return this._store[Symbol.iterator]();
}
}
/**
* Set whose values are `IDisposable`. `add(v)` takes ownership;
* `deleteAndDispose(v)` removes and disposes; `dispose()` disposes every
* value. Mirrors VSCode `base/common/lifecycle.ts DisposableSet`.
*/
export class DisposableSet<V extends IDisposable = IDisposable>
implements IDisposable
{
private readonly _store: Set<V>;
private _isDisposed = false;
constructor(store: Set<V> = new Set<V>()) {
this._store = store;
trackDisposable(this);
}
dispose(): void {
if (this._isDisposed) return;
this._isDisposed = true;
markAsDisposed(this);
this.clearAndDisposeAll();
}
clearAndDisposeAll(): void {
if (this._store.size === 0) return;
try {
dispose(this._store.values());
} finally {
this._store.clear();
}
}
has(value: V): boolean {
return this._store.has(value);
}
get size(): number {
return this._store.size;
}
add(value: V): void {
if (this._isDisposed) {
// eslint-disable-next-line no-console
console.warn(
new Error(
'Trying to add a disposable to a DisposableSet that has already been disposed of. The added object will be leaked!',
).stack,
);
return;
}
this._store.add(value);
setParentOfDisposable(value, this);
}
deleteAndDispose(value: V): void {
if (this._store.delete(value)) {
try {
value.dispose();
} catch (err) {
onUnexpectedError(err);
}
}
}
get isDisposed(): boolean {
return this._isDisposed;
deleteAndLeak(value: V): V | undefined {
if (this._store.delete(value)) {
setParentOfDisposable(value, null);
return value;
}
return undefined;
}
values(): IterableIterator<V> {
return this._store.values();
}
[Symbol.iterator](): IterableIterator<V> {
return this._store[Symbol.iterator]();
}
}
/**
* Scoped `using` helper: construct a `DisposableStore`, run `fn(store)`, then
* tear the store down in a `finally`. Lets callers register transient
* disposables for the duration of a function without writing
* `try { ... } finally { store.dispose(); }` by hand.
*
* disposeOnReturn(store => {
* const a = store.add(new Foo());
* doStuff(a);
* });
*/
export function disposeOnReturn(fn: (store: DisposableStore) => void): void {
const store = new DisposableStore();
try {
fn(store);
} finally {
store.dispose();
}
}

View file

@ -292,7 +292,7 @@ describe('InstantiationService.createChild', () => {
});
describe('Disposable base class', () => {
it('reverse register order on dispose', () => {
it('insertion order on dispose', () => {
const events: string[] = [];
class Child implements IDisposable {
constructor(public readonly label: string) {}
@ -310,7 +310,7 @@ describe('Disposable base class', () => {
}
const o = new Owner();
o.dispose();
expect(events).toEqual(['disposed third', 'disposed second', 'disposed first']);
expect(events).toEqual(['disposed first', 'disposed second', 'disposed third']);
});
it('idempotent dispose on the base class', () => {
@ -372,7 +372,8 @@ describe('Disposable base class', () => {
}
const o = new Owner();
expect(() => o.dispose()).not.toThrow();
// BadChild is registered last so it tears down first (LIFO).
expect(events).toEqual(['bad-attempted', 'good']);
// GoodChild is registered first, so it tears down first (insertion order);
// BadChild throws but the loop continues.
expect(events).toEqual(['good', 'bad-attempted']);
});
});

View file

@ -41,7 +41,7 @@ describe('toDisposable', () => {
});
describe('combinedDisposable', () => {
it('disposes all children', () => {
it('disposes all children in insertion order', () => {
const order: string[] = [];
const d = combinedDisposable(
makeRecorder('a', order),
@ -142,14 +142,14 @@ describe('DisposableStore', () => {
store.dispose();
});
it('dispose tears down children in LIFO order', () => {
it('dispose tears down children in insertion order', () => {
const order: string[] = [];
const store = new DisposableStore();
store.add(makeRecorder('a', order));
store.add(makeRecorder('b', order));
store.add(makeRecorder('c', order));
store.dispose();
expect(order).toEqual(['c', 'b', 'a']);
expect(order).toEqual(['a', 'b', 'c']);
});
it('clear disposes children but keeps the store usable', () => {
@ -158,18 +158,30 @@ describe('DisposableStore', () => {
store.add(makeRecorder('a', order));
store.add(makeRecorder('b', order));
store.clear();
expect(order).toEqual(['b', 'a']);
expect(order).toEqual(['a', 'b']);
store.add(makeRecorder('c', order));
store.dispose();
expect(order).toEqual(['b', 'a', 'c']);
expect(order).toEqual(['a', 'b', 'c']);
});
it('delete removes a child without disposing it', () => {
it('delete removes a child AND disposes it', () => {
const order: string[] = [];
const store = new DisposableStore();
const rec = makeRecorder('a', order);
store.add(rec);
store.delete(rec);
expect(order).toEqual(['a']);
// No second dispose when the store itself is later torn down.
store.dispose();
expect(order).toEqual(['a']);
});
it('deleteAndLeak removes a child WITHOUT disposing it', () => {
const order: string[] = [];
const store = new DisposableStore();
const rec = makeRecorder('a', order);
store.add(rec);
store.deleteAndLeak(rec);
store.dispose();
expect(order).toEqual([]);
});
@ -205,16 +217,16 @@ describe('DisposableStore', () => {
});
store.add(makeRecorder('c', order));
store.dispose();
// LIFO: c, throwing-child, a
expect(order).toEqual(['c', 'a']);
// Insertion order: a, throwing-child, c
expect(order).toEqual(['a', 'c']);
expect(captured).toHaveLength(1);
expect((captured[0] as Error).message).toBe('store-child-boom');
resetUnexpectedErrorHandler();
});
});
describe('Disposable base class (unchanged behaviour)', () => {
it('LIFO teardown still holds after the lifecycle expansion', () => {
describe('Disposable base class', () => {
it('insertion-order teardown', () => {
const order: string[] = [];
class Owner extends Disposable {
add(label: string): void {
@ -226,6 +238,19 @@ describe('Disposable base class (unchanged behaviour)', () => {
owner.add('b');
owner.add('c');
owner.dispose();
expect(order).toEqual(['c', 'b', 'a']);
expect(order).toEqual(['a', 'b', 'c']);
});
it('registering self throws', () => {
class Owner extends Disposable {
registerSelf(): void {
this._register(this);
}
}
const owner = new Owner();
expect(() => owner.registerSelf()).toThrow(
/Cannot register a disposable on itself/,
);
owner.dispose();
});
});

View file

@ -48,7 +48,7 @@
import { ulid } from 'ulid';
import { Disposable } from '@moonshot-ai/agent-core';
import { Disposable, DisposableMap, type IDisposable } from '@moonshot-ai/agent-core';
import type {
ApprovalRequest as ProtocolApprovalRequest,
Event,
@ -86,23 +86,64 @@ export class ApprovalExpiredError extends Error {
}
}
interface PendingApproval {
readonly approvalId: string;
readonly sessionId: string;
readonly toolCallId: string;
readonly createdAt: string;
readonly expiresAt: string;
readonly protocolRequest: ProtocolApprovalRequest;
resolve: (r: ApprovalResponse) => void;
reject: (e: Error) => void;
timer: NodeJS.Timeout;
/**
* One pending approval. Owns the 60s timer + promise callbacks. Two ways out:
* - `markSettled()` the happy path called by resolve/expire AFTER they've
* decided the outcome; just stops the timer.
* - `dispose()` the shutdown fallback. If the entry hasn't been settled
* yet, clears the timer AND rejects with "daemon shutting down" so the
* awaiter doesn't dangle.
*
* Stored in `ApprovalService._pending` (`DisposableMap`) so service-wide
* teardown via `super.dispose()` triggers each entry's `dispose()`
* automatically.
*/
class PendingApproval implements IDisposable {
private _settled = false;
constructor(
readonly approvalId: string,
readonly sessionId: string,
readonly toolCallId: string,
readonly createdAt: string,
readonly expiresAt: string,
readonly protocolRequest: ProtocolApprovalRequest,
private readonly _resolveFn: (r: ApprovalResponse) => void,
private readonly _rejectFn: (e: Error) => void,
private readonly _timer: NodeJS.Timeout,
) {}
markSettled(): void {
if (this._settled) return;
this._settled = true;
clearTimeout(this._timer);
}
resolve(r: ApprovalResponse): void {
this._resolveFn(r);
}
reject(e: Error): void {
this._rejectFn(e);
}
dispose(): void {
if (this._settled) return;
this._settled = true;
clearTimeout(this._timer);
try {
this._rejectFn(new Error('daemon shutting down'));
} catch {
// awaiter may not have a catch handler attached yet
}
}
}
export class ApprovalService extends Disposable implements IApprovalService {
readonly _serviceBrand: undefined;
/** Indexed by daemon-minted `approval_id` (REST path key). */
private readonly _pending = new Map<string, PendingApproval>();
private readonly _pending: DisposableMap<string, PendingApproval>;
/** Reverse lookup for `toolCallId` (legacy in-process interface compatibility). */
private readonly _byToolCallId = new Map<string, string>();
/**
@ -119,12 +160,13 @@ export class ApprovalService extends Disposable implements IApprovalService {
@IEventService private readonly eventService: IEventService,
) {
super();
this._pending = this._register(new DisposableMap<string, PendingApproval>());
}
async request(
req: ApprovalRequest & { sessionId: string; agentId: string },
): Promise<ApprovalResponse> {
if (this._isDisposed) {
if (this._store.isDisposed) {
throw new Error('approval service disposed');
}
@ -169,17 +211,20 @@ export class ApprovalService extends Disposable implements IApprovalService {
return await new Promise<ApprovalResponse>((resolve, reject) => {
const timer = setTimeout(() => this._expire(approvalId), this._timeoutMs);
timer.unref?.();
this._pending.set(approvalId, {
this._pending.set(
approvalId,
sessionId: req.sessionId,
toolCallId: req.toolCallId,
createdAt,
expiresAt,
protocolRequest,
resolve,
reject,
timer,
});
new PendingApproval(
approvalId,
req.sessionId,
req.toolCallId,
createdAt,
expiresAt,
protocolRequest,
resolve,
reject,
timer,
),
);
this._byToolCallId.set(req.toolCallId, approvalId);
});
}
@ -194,8 +239,8 @@ export class ApprovalService extends Disposable implements IApprovalService {
resolve(id: string, response: ApprovalResponse): void {
const p = this._pending.get(id);
if (!p) return;
clearTimeout(p.timer);
this._pending.delete(id);
p.markSettled();
this._pending.deleteAndLeak(id);
this._byToolCallId.delete(p.toolCallId);
this.markResolved(p.approvalId);
@ -275,7 +320,8 @@ export class ApprovalService extends Disposable implements IApprovalService {
private _expire(approvalId: string): void {
const p = this._pending.get(approvalId);
if (!p) return;
this._pending.delete(approvalId);
p.markSettled();
this._pending.deleteAndLeak(approvalId);
this._byToolCallId.delete(p.toolCallId);
// Mark as resolved-style for idempotency — a late REST resolve on this id
// gets 40902 rather than 40404 (matches "expired ≈ already_resolved" UX).
@ -293,16 +339,7 @@ export class ApprovalService extends Disposable implements IApprovalService {
}
override dispose(): void {
if (this._isDisposed) return;
for (const [, p] of this._pending) {
clearTimeout(p.timer);
try {
p.reject(new Error('daemon shutting down'));
} catch {
// ignore — the awaiter may not have a catch handler attached yet.
}
}
this._pending.clear();
if (this._store.isDisposed) return;
this._byToolCallId.clear();
this._recentlyResolved.clear();
super.dispose();

View file

@ -222,7 +222,7 @@ export class FileStore extends Disposable implements IFileStore {
}
override dispose(): void {
if (this._isDisposed) return;
if (this._store.isDisposed) return;
this.indexCache = undefined;
super.dispose();
}

View file

@ -6,7 +6,7 @@ import nodePath from 'node:path';
import { FSWatcher } from 'chokidar';
import { Disposable } from '@moonshot-ai/agent-core';
import { Disposable, DisposableMap, type IDisposable } from '@moonshot-ai/agent-core';
import { ISessionService } from '@moonshot-ai/services';
import type { FsChangeEntry, FsChangeAction, FsChangeKind } from '@moonshot-ai/protocol';
@ -42,25 +42,51 @@ interface PendingChange {
kind: FsChangeKind;
}
interface SessionEntry {
/** Live chokidar watcher; closed + dropped on last unref. */
watcher: FSWatcher;
/** `sessionId.cwd` (absolute, post-realpath) for relative-path mapping. */
cwd: string;
/**
* Per-session watcher state. Owns the chokidar `FSWatcher` + active debounce
* timer; `dispose()` clears the timer and fire-and-forgets `watcher.close()`.
* Stored in `FsWatcherService.sessions` (a `DisposableMap`) so removal via
* `sessions.deleteAndDispose(sessionId)` and service-wide teardown via
* `super.dispose()` both flow through `dispose()` automatically.
*/
class SessionEntry implements IDisposable {
/** `absPath → refCount` across all connections subscribed to this session. */
pathRefs: Map<string, number>;
readonly pathRefs = new Map<string, number>();
/** `connectionId → Set<absPath>` for overlap filtering on emit. */
connectionPaths: Map<string, Set<string>>;
readonly connectionPaths = new Map<string, Set<string>>();
/** Accumulating changes for the current 200ms window. */
pendingChanges: PendingChange[];
pendingChanges: PendingChange[] = [];
/** Raw event count (used for `truncated.count`). */
pendingRawCount: number;
pendingRawCount = 0;
/** True once `pendingChanges.length > maxChangesPerWindow`. */
truncated: boolean;
truncated = false;
/** Timer for the active debounce window; `undefined` between windows. */
debounceTimer: NodeJS.Timeout | undefined;
debounceTimer: NodeJS.Timeout | undefined = undefined;
/** Per-session seq counter, monotonic, starts at 1. */
seq: number;
seq = 0;
private _disposed = false;
constructor(
public readonly sessionId: string,
public readonly watcher: FSWatcher,
public cwd: string,
private readonly logger: ILogService,
) {}
dispose(): void {
if (this._disposed) return;
this._disposed = true;
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = undefined;
}
void this.watcher.close().catch((err) => {
this.logger.warn(
{ sessionId: this.sessionId, err: String(err) },
'fs-watcher close failed',
);
});
}
}
export class FsWatcherService extends Disposable implements IFsWatcher {
@ -70,7 +96,7 @@ export class FsWatcherService extends Disposable implements IFsWatcher {
private readonly maxChangesPerWindow: number;
private readonly maxPathsPerConnection: number;
private readonly makeWatcher: () => FSWatcher;
private readonly sessions = new Map<string, SessionEntry>();
private readonly sessions: DisposableMap<string, SessionEntry>;
/** `connectionId → Map<sessionId, Set<absPath>>`. */
private readonly connections = new Map<string, Map<string, Set<string>>>();
@ -89,6 +115,7 @@ export class FsWatcherService extends Disposable implements IFsWatcher {
@ISessionService _sessionService: ISessionService,
) {
super();
this.sessions = this._register(new DisposableMap<string, SessionEntry>());
this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
this.maxChangesPerWindow =
options.maxChangesPerWindow ?? DEFAULT_MAX_CHANGES_PER_WINDOW;
@ -111,7 +138,7 @@ export class FsWatcherService extends Disposable implements IFsWatcher {
connectionId: string,
absPaths: readonly string[],
): readonly string[] {
if (this._isDisposed) return [];
if (this._store.isDisposed) return [];
// Project the new total for this connection (assuming all `absPaths` are
// additions). Dedup against existing first.
@ -184,7 +211,7 @@ export class FsWatcherService extends Disposable implements IFsWatcher {
connectionId: string,
absPaths: readonly string[],
): readonly string[] {
if (this._isDisposed) return [];
if (this._store.isDisposed) return [];
const entry = this.sessions.get(sessionId);
if (!entry) return [];
const connSessions = this.connections.get(connectionId);
@ -218,7 +245,7 @@ export class FsWatcherService extends Disposable implements IFsWatcher {
}
// Per-session cleanup: if no path references remain, close the watcher.
if (entry.pathRefs.size === 0) {
this.disposeSessionEntry(sessionId, entry);
this.sessions.deleteAndDispose(sessionId);
}
return connSessionPaths ? Array.from(connSessionPaths) : [];
}
@ -285,17 +312,7 @@ export class FsWatcherService extends Disposable implements IFsWatcher {
private createSessionEntry(sessionId: string, cwd: string): SessionEntry {
const watcher = this.makeWatcher();
const entry: SessionEntry = {
watcher,
cwd,
pathRefs: new Map(),
connectionPaths: new Map(),
pendingChanges: [],
pendingRawCount: 0,
truncated: false,
debounceTimer: undefined,
seq: 0,
};
const entry = new SessionEntry(sessionId, watcher, cwd, this.logger);
watcher.on(
'all',
(eventName: string, absPath: string) => {
@ -311,27 +328,13 @@ export class FsWatcherService extends Disposable implements IFsWatcher {
return entry;
}
private disposeSessionEntry(sessionId: string, entry: SessionEntry): void {
if (entry.debounceTimer) {
clearTimeout(entry.debounceTimer);
entry.debounceTimer = undefined;
}
void entry.watcher.close().catch((err) => {
this.logger.warn(
{ sessionId, err: String(err) },
'fs-watcher close failed',
);
});
this.sessions.delete(sessionId);
}
private onRawChange(
sessionId: string,
entry: SessionEntry,
eventName: string,
absPath: string,
): void {
if (this._isDisposed) return;
if (this._store.isDisposed) return;
const action = mapChokidarEventToAction(eventName);
if (action === undefined) return; // 'ready', 'raw', 'all', 'error'
const kind = mapChokidarEventToKind(eventName);
@ -414,11 +417,7 @@ export class FsWatcherService extends Disposable implements IFsWatcher {
}
override dispose(): void {
if (this._isDisposed) return;
const entries = Array.from(this.sessions.entries());
for (const [sid, e] of entries) {
this.disposeSessionEntry(sid, e);
}
if (this._store.isDisposed) return;
this.connections.clear();
super.dispose();
}

View file

@ -47,7 +47,7 @@ export class ConnectionRegistry extends Disposable implements IConnectionRegistr
}
override dispose(): void {
if (this._isDisposed) return;
if (this._store.isDisposed) return;
// Belt-and-suspenders: WSGateway.dispose() already called closeAll().
// Idempotent.
this.closeAll();

View file

@ -18,7 +18,7 @@ export class FastifyRestGateway extends Disposable implements IRestGateway {
}
override dispose(): void {
if (this._isDisposed) return;
if (this._store.isDisposed) return;
// Fire-and-forget — Fastify's close is async but the DI dispose contract is sync.
// The daemon's RunningDaemon.close() awaits `app.close()` explicitly before
// calling ix.dispose(), so by the time we get here the listener is already

View file

@ -80,7 +80,7 @@ export class SessionClientsService extends Disposable implements ISessionClients
}
override dispose(): void {
if (this._isDisposed) return;
if (this._store.isDisposed) return;
this._bySession.clear();
super.dispose();
}

View file

@ -77,7 +77,7 @@ export class WSBroadcastService extends Disposable implements IWSBroadcastServic
}
private _onEvent(event: Event): void {
if (this._isDisposed) return;
if (this._store.isDisposed) return;
const sid = extractSessionId(event);
const evType = (event as { type?: string }).type ?? '<no-type>';
if (!sid) {
@ -157,7 +157,7 @@ export class WSBroadcastService extends Disposable implements IWSBroadcastServic
}
override dispose(): void {
if (this._isDisposed) return;
if (this._store.isDisposed) return;
this._sessions.clear();
super.dispose();
}

View file

@ -92,7 +92,7 @@ export class WSGateway extends Disposable implements IWSGateway {
}
override dispose(): void {
if (this._isDisposed) return;
if (this._store.isDisposed) return;
// 1. Close every attached connection (WS code 1001 = going away).
try {
this.registry.closeAll('daemon shutting down');

View file

@ -31,7 +31,7 @@
import { ulid } from 'ulid';
import { Disposable } from '@moonshot-ai/agent-core';
import { Disposable, DisposableMap, type IDisposable } from '@moonshot-ai/agent-core';
import type {
Event,
QuestionRequest as ProtocolQuestionRequest,
@ -66,23 +66,67 @@ export class QuestionExpiredError extends Error {
}
}
interface PendingQuestion {
readonly questionId: string;
readonly sessionId: string;
readonly toolCallId: string | undefined;
readonly createdAt: string;
readonly expiresAt: string;
readonly protocolRequest: ProtocolQuestionRequest;
resolve: (r: QuestionResult) => void;
reject: (e: Error) => void;
timer: NodeJS.Timeout;
/**
* One pending question. Owns the 60s timer + promise callbacks. Two ways out:
* - `markSettled()` the happy path called by resolve/dismiss/expire AFTER
* they've decided the outcome; just stops the timer.
* - `dispose()` the shutdown fallback. If the entry hasn't been settled
* yet, clears the timer AND rejects with "daemon shutting down" so the
* awaiter doesn't dangle.
*
* Stored in `QuestionService._pending` (`DisposableMap`) so service-wide
* teardown via `super.dispose()` triggers each entry's `dispose()`
* automatically, replacing the old hand-written for-loop in `override
* dispose()`.
*/
class PendingQuestion implements IDisposable {
private _settled = false;
constructor(
readonly questionId: string,
readonly sessionId: string,
readonly toolCallId: string | undefined,
readonly createdAt: string,
readonly expiresAt: string,
readonly protocolRequest: ProtocolQuestionRequest,
private readonly _resolveFn: (r: QuestionResult) => void,
private readonly _rejectFn: (e: Error) => void,
private readonly _timer: NodeJS.Timeout,
) {}
/** Happy-path settle (resolve/dismiss/expire decided the outcome). */
markSettled(): void {
if (this._settled) return;
this._settled = true;
clearTimeout(this._timer);
}
resolve(r: QuestionResult): void {
this._resolveFn(r);
}
reject(e: Error): void {
this._rejectFn(e);
}
/** Shutdown fallback: reject any unsettled awaiter. Idempotent. */
dispose(): void {
if (this._settled) return;
this._settled = true;
clearTimeout(this._timer);
try {
this._rejectFn(new Error('daemon shutting down'));
} catch {
// swallow — the awaiter side may have already gone away
}
}
}
export class QuestionService extends Disposable implements IQuestionService {
readonly _serviceBrand: undefined;
/** Indexed by daemon-minted `question_id` (REST path key). */
private readonly _pending = new Map<string, PendingQuestion>();
private readonly _pending: DisposableMap<string, PendingQuestion>;
/** Bounded set of resolved/dismissed ids for idempotency. */
private readonly _recentlyResolved = new Set<string>();
private _timeoutMs = QUESTION_DEFAULT_TIMEOUT_MS;
@ -93,12 +137,13 @@ export class QuestionService extends Disposable implements IQuestionService {
@IEventService private readonly eventService: IEventService,
) {
super();
this._pending = this._register(new DisposableMap<string, PendingQuestion>());
}
async request(
req: QuestionRequest & { sessionId: string; agentId: string },
): Promise<QuestionResult> {
if (this._isDisposed) {
if (this._store.isDisposed) {
throw new Error('question service disposed');
}
@ -135,17 +180,20 @@ export class QuestionService extends Disposable implements IQuestionService {
return await new Promise<QuestionResult>((resolve, reject) => {
const timer = setTimeout(() => this._expire(questionId), this._timeoutMs);
timer.unref?.();
this._pending.set(questionId, {
this._pending.set(
questionId,
sessionId: req.sessionId,
toolCallId: req.toolCallId,
createdAt,
expiresAt,
protocolRequest,
resolve,
reject,
timer,
});
new PendingQuestion(
questionId,
req.sessionId,
req.toolCallId,
createdAt,
expiresAt,
protocolRequest,
resolve,
reject,
timer,
),
);
});
}
@ -157,8 +205,8 @@ export class QuestionService extends Disposable implements IQuestionService {
resolve(id: string, response: QuestionResult): void {
const p = this._pending.get(id);
if (!p) return;
clearTimeout(p.timer);
this._pending.delete(id);
p.markSettled();
this._pending.deleteAndLeak(id);
this.markResolved(p.questionId);
const resolvedAt = new Date().toISOString();
@ -187,8 +235,8 @@ export class QuestionService extends Disposable implements IQuestionService {
dismiss(id: string): void {
const p = this._pending.get(id);
if (!p) return;
clearTimeout(p.timer);
this._pending.delete(id);
p.markSettled();
this._pending.deleteAndLeak(id);
this.markResolved(p.questionId);
const dismissedAt = new Date().toISOString();
@ -253,7 +301,8 @@ export class QuestionService extends Disposable implements IQuestionService {
private _expire(questionId: string): void {
const p = this._pending.get(questionId);
if (!p) return;
this._pending.delete(questionId);
p.markSettled();
this._pending.deleteAndLeak(questionId);
this.markResolved(p.questionId);
const expiredEvent: Event = {
@ -268,16 +317,7 @@ export class QuestionService extends Disposable implements IQuestionService {
}
override dispose(): void {
if (this._isDisposed) return;
for (const [, p] of this._pending) {
clearTimeout(p.timer);
try {
p.reject(new Error('daemon shutting down'));
} catch {
// ignore
}
}
this._pending.clear();
if (this._store.isDisposed) return;
this._recentlyResolved.clear();
super.dispose();
}

View file

@ -1,70 +1,9 @@
/**
* `createDaemonServiceCollection` central wiring for the daemon's DI graph.
* Mirrors the VSCode `electron-main/main.ts:162-233`
* pattern: hybrid `ServiceCollection`, with:
*
* - **Prebuilt** `services.set(I, new C(...))` for services that capture
* runtime handles or closures the container can't synthesize
* (`PinoLogger` wraps the Fastify-shared `pino.Logger`;
* `FastifyRestGateway` wraps the `FastifyLike` instance;
* `IEnvironmentService` carries CLI-resolved `homeDir` / `configPath`).
* - **Descriptor** `services.set(I, new SyncDescriptor(C, [], false))` for
* services whose ctor is pure `@I…` injection. The container drives
* construction via `_createAndCacheServiceInstance` so decorators auto-inject.
* A handful of services still take a leading options bag (e.g.
* `CoreProcessService` with `coreProcessOptions`, `WSGateway` with
* `wsGatewayOptions`); those use `new SyncDescriptor(C, [options], false)`.
*
* `supportsDelayedInstantiation = false` for every descriptor here to preserve
* the `_constructionOrder` discipline that powers reverse-dispose order. The
* `a.get(IX)` touch sequence in `start.ts` still pins the ordering.
*
* # Why a helper (not inline in start.ts)?
*
* Centralizing all `services.set(... new SyncDescriptor(...))` in one place
* keeps the wiring shape auditable in a single file while `start.ts` retains
* the construction-order touch list + post-collection adapters
* (`IFsWatcher` closure construction,
* `setUnexpectedErrorHandler`, WS abort + fs-watch handler wiring).
*
* # Why `IFsWatcher` stays in start.ts
*
* `FsWatcherService` ctor takes a `connection-lookup` closure built from
* `IConnectionRegistry.get` at runtime. That closure isn't serializable
* into a `SyncDescriptor` static-arg slot, so we keep its construction
* inside the `ix.invokeFunction` block in `start.ts` post-collection.
*/
import {
getSingletonServiceDescriptors,
ServiceCollection,
SyncDescriptor,
} from '@moonshot-ai/agent-core';
import {
AuthSummaryService,
CoreProcessService,
defaultServicesModule,
EventService,
IApprovalService,
IAuthSummaryService,
IEnvironmentService,
IEventService,
ICoreProcessService,
IMcpService,
IMessageService,
IOAuthService,
IPromptService,
IQuestionService,
ISessionService,
ITaskService,
IToolService,
McpService,
MessageService,
OAuthService,
PromptService,
SessionService,
TaskService,
ToolService,
} from '@moonshot-ai/services';
import * as Services from '@moonshot-ai/services';
import type { Logger as PinoLogger } from 'pino';
import type { FastifyLike } from '#/services/gateway/restGateway';
@ -100,66 +39,43 @@ import { IWSBroadcastService } from '#/services/gateway/wsBroadcast';
import { WSBroadcastService } from '#/services/gateway/wsBroadcastService';
export interface DaemonServiceCollectionOptions {
/** Original `startDaemon` options bag — carries the per-service tunables. */
readonly daemon: DaemonStartOptions;
/** Resolved Fastify instance (`app`) — needed by `FastifyRestGateway`. */
readonly app: FastifyLike;
/** Fastify-shared pino logger — wrapped by `PinoLoggerAdapter`. */
readonly pinoLogger: PinoLogger;
/** Pre-resolved environment paths (homeDir / configPath). */
readonly envService: IEnvironmentService;
readonly envService: Services.IEnvironmentService;
}
/**
* Assemble the daemon's `ServiceCollection`. The returned collection has
* EVERY singleton seeded either as a prebuilt instance (runtime-handle
* services) or as a `SyncDescriptor` (descriptor-first singletons).
*
* One singleton NOT registered here, by design:
* - `IFsWatcher` needs a closure over `IConnectionRegistry.get` at
* construction time; built inline in `start.ts` (see file header).
*/
export function createDaemonServiceCollection(
input: DaemonServiceCollectionOptions,
): ServiceCollection {
const { daemon, app, pinoLogger, envService } = input;
const services = new ServiceCollection(
// Registry entries from `@moonshot-ai/services` (self-registered by each
// impl file at module-load time). These supply the default descriptors
// for services whose ctor is pure `@I…` injection.
...defaultServicesModule(),
// Daemon-only services not shipped by `@moonshot-ai/services`.
...getSingletonServiceDescriptors(),
[IConnectionRegistry, new SyncDescriptor(ConnectionRegistry, [], false)],
[ISessionClientsService, new SyncDescriptor(SessionClientsService, [], false)],
[IWSBroadcastService, new SyncDescriptor(WSBroadcastService, [], false)],
[IApprovalService, new SyncDescriptor(ApprovalService, [], false)],
[IQuestionService, new SyncDescriptor(QuestionService, [], false)],
[Services.IApprovalService, new SyncDescriptor(ApprovalService, [], false)],
[Services.IQuestionService, new SyncDescriptor(QuestionService, [], false)],
[IFsService, new SyncDescriptor(FsService, [], false)],
[IFsSearchService, new SyncDescriptor(FsSearchService, [], false)],
[IFsGitService, new SyncDescriptor(FsGitService, [], false)],
[IWorkspaceFsService, new SyncDescriptor(WorkspaceFsService, [], false)],
);
// -- Prebuilt: services that need runtime handles / external closures ------
services.set(ILogService, new PinoLoggerAdapter(pinoLogger));
services.set(IRestGateway, new FastifyRestGateway(app));
services.set(IEnvironmentService, envService);
services.set(Services.IEnvironmentService, envService);
// -- Override registry entries with runtime static args --------------------
services.set(
IWSGateway,
new SyncDescriptor(WSGateway, [daemon.wsGatewayOptions ?? {}], false),
);
services.set(
ICoreProcessService,
new SyncDescriptor(CoreProcessService, [daemon.coreProcessOptions ?? {}], false),
Services.ICoreProcessService,
new SyncDescriptor(Services.CoreProcessService, [daemon.coreProcessOptions ?? {}], false),
);
// `IFileStore` + `IWorkspaceRegistry` derive their on-disk base from
// `IEnvironmentService.homeDir` (which itself reads
// `opts.coreProcessOptions?.homeDir`). No static args here — the impls
// inject `IEnvironmentService` directly.
services.set(IFileStore, new SyncDescriptor(FileStore, [], false));
services.set(IWorkspaceRegistry, new SyncDescriptor(WorkspaceRegistryService, [], false));

View file

@ -92,7 +92,7 @@ export class WorkspaceFsService extends Disposable implements IWorkspaceFsServic
}
override dispose(): void {
if (this._isDisposed) return;
if (this._store.isDisposed) return;
super.dispose();
}
}

View file

@ -278,7 +278,7 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe
}
override dispose(): void {
if (this._isDisposed) return;
if (this._store.isDisposed) return;
super.dispose();
}
}

View file

@ -96,13 +96,13 @@ no new suffixes get reintroduced.
Adding a new service: create the folder + contracts + impl pair, add a
bottom-of-file `registerSingleton(IXxxService, XxxService,
InstantiationType.Delayed)` in the impl, add the corresponding side-effect
import to `module.ts`, re-export from `index.ts`. The daemon's `start.ts`
consumes `defaultServicesModule()` for descriptor-only services; only override
the registry entry (via `services.set(I, prebuiltInstance)` or
`services.set(I, new SyncDescriptor(C, [runtimeArgs], false))`) when the
service needs an external handle or runtime static args that the registry
can't supply.
InstantiationType.Delayed)` in the impl, then re-export the contracts and impl
from `index.ts` so importing `@moonshot-ai/services` runs the registration
side effect. Daemon bootstrap consumes `getSingletonServiceDescriptors()` for
descriptor-only services; only override the registry entry (via
`services.set(I, prebuiltInstance)` or `services.set(I, new SyncDescriptor(C,
[runtimeArgs], false))`) when the service needs an external handle or runtime
static args that the registry can't supply.
## Service registration (normative)
@ -127,11 +127,9 @@ can't supply.
`CoreProcessService`'s `options`), fall back to the descriptor overload:
`registerSingleton(IXxxService, new SyncDescriptor(XxxService, [optionsBag]))`.
2. **`defaultServicesModule()` is a thin projection** of
`getSingletonServiceDescriptors()`. It does NOT maintain a separate
list — `module.ts`'s only responsibility is the side-effect import
list that populates the registry plus the
`InstantiationType.Delayed | Eager` projection.
2. **Consumers seed from `getSingletonServiceDescriptors()` directly**.
Importing `@moonshot-ai/services` loads the package barrel, whose impl
re-exports run the `registerSingleton(...)` side effects.
3. **Daemon-side `services.set(...)` may override** the registry-derived
entry for services that need runtime static args (e.g.
@ -142,9 +140,9 @@ can't supply.
from `registerSingleton` (plan §158); the later registration wins at
every layer.
The legacy "hand-built array in `module.ts`" pattern that lived here
through Phase 2 is gone. Do NOT reintroduce it — extending the array in
`module.ts` no longer has any effect on what the daemon resolves.
The legacy "hand-built array" and `defaultServicesModule()` wrapper patterns
are gone. Do NOT reintroduce them — the registry is the source of truth, and
bootstrap code should read it with `getSingletonServiceDescriptors()`.
## Comments (normative)
@ -178,4 +176,3 @@ Existing files in this package over-comment by historical accident.
file, prefer leaving the surrounding comments alone — large comment
deletions belong in their own dedicated cleanup pass, not bundled into
behavior changes.

View file

@ -106,7 +106,7 @@ export class AuthSummaryService
}
override dispose(): void {
if (this._isDisposed) return;
if (this._store.isDisposed) return;
super.dispose();
}

View file

@ -142,7 +142,7 @@ export class CoreProcessService extends Disposable implements ICoreProcessServic
}
override dispose(): void {
if (this._isDisposed) return;
if (this._store.isDisposed) return;
// KimiCore does not currently expose a dispose() — when it does, we'll
// await/call it here BEFORE super.dispose(). For now, disposing the
// service flips _disposed, which makes future rpc.* invocations reject
@ -152,7 +152,7 @@ export class CoreProcessService extends Disposable implements ICoreProcessServic
private _buildRpcProxy(): CoreRPC {
const rpcPromise = this._coreRpcPromise;
const isDisposedRef = () => this._isDisposed;
const isDisposedRef = () => this._store.isDisposed;
// We don't know the concrete method set at compile time here (CoreAPI is
// a structural interface; `RPCMethods<CoreAPI>` is a mapped type).

View file

@ -35,7 +35,7 @@ export class EventService extends Disposable implements IEventService {
readonly onDidPublish = this._onDidPublish.event;
publish(event: ProtocolEvent): void {
if (this._isDisposed) return;
if (this._store.isDisposed) return;
this._onDidPublish.fire(event);
}
}

View file

@ -1,42 +1,3 @@
/**
* `@moonshot-ai/services` in-process service container for the kimi-code
* daemon. Houses every `IXxxService` decorator (per-domain folder), the
* `CoreProcessService` that owns the in-process `KimiCore` instance, and the
* adapters that translate `KimiCore` shapes into protocol-shaped data.
*
* Naming convention is encoded in `packages/services/AGENTS.md` every
* injectable uses the `Service` suffix, contracts live in `<domain>.ts`,
* impl lives in `<domain>Service.ts`, folder names are camelCase.
*
* Per-domain layout:
* coreProcess/coreProcess.ts ICoreProcessService + CoreProcessServiceOptions
* coreProcess/coreProcessService.ts CoreProcessService (self-registers via registerSingleton)
* coreProcess/coreProcessClient.ts BridgeClientAPI (SDK-side of the RPC pair)
* event/event.ts IEventService
* event/eventService.ts EventService (pure in-process Emitter wrapper)
* approval/approval.ts IApprovalService + protocol adapter
* question/question.ts IQuestionService + protocol adapter
* environment/environment.ts IEnvironmentService
* session/session.ts ISessionService + toProtocolSession
* session/sessionService.ts SessionService
* message/message.ts IMessageService + toProtocolMessage
* message/messageService.ts MessageService
* prompt/prompt.ts IPromptService + SyntheticPrompt* events
* prompt/promptService.ts PromptService
* tool/tool.ts IToolService + toProtocolTool
* tool/toolService.ts ToolService
* mcp/mcp.ts IMcpService + toProtocolMcpServer
* mcp/mcpService.ts McpService
* task/task.ts ITaskService + toProtocolTask
* task/taskService.ts TaskService
* oauth/oauth.ts IOAuthService
* oauth/oauthService.ts OAuthService
* authSummary/authSummary.ts IAuthSummaryService + sentinel errors
* authSummary/authSummaryService.ts AuthSummaryService
* modelCatalog/modelCatalog.ts IModelCatalogService + config adapters
* modelCatalog/modelCatalogService.ts ModelCatalogService
*/
export { BridgeClientAPI } from './coreProcess/coreProcessClient';
export type { CoreProcessClientDeps } from './coreProcess/coreProcessClient';
export {
@ -44,18 +5,10 @@ export {
type CoreProcessServiceOptions,
} from './coreProcess/coreProcess';
export { CoreProcessService } from './coreProcess/coreProcessService';
export {
defaultServicesModule,
type ServiceModuleEntry,
} from './module';
// --- per-domain exports ---------------------------------------------------
// event service
export { IEventService } from './event/event';
export { EventService } from './event/eventService';
// approval service + adapter
export { IApprovalService } from './approval/approval';
export type { ApprovalRequest, ApprovalResponse } from './approval/approval';
export {
@ -64,7 +17,6 @@ export {
type ToBrokerRequestParams as ApprovalToBrokerRequestParams,
} from './approval/approval';
// question service + adapter
export { IQuestionService } from './question/question';
export type { QuestionRequest, QuestionResult } from './question/question';
export {
@ -74,10 +26,8 @@ export {
type QuestionToBrokerRequestParams,
} from './question/question';
// environment service
export { IEnvironmentService } from './environment/environment';
// authSummary service
export {
IAuthSummaryService,
AuthProvisioningRequiredError,
@ -87,11 +37,9 @@ export {
} from './authSummary/authSummary';
export { AuthSummaryService } from './authSummary/authSummaryService';
// oauth service
export { IOAuthService } from './oauth/oauth';
export { OAuthService } from './oauth/oauthService';
// model catalog service + adapter
export {
IModelCatalogService,
ModelNotFoundError,
@ -103,7 +51,6 @@ export {
export type { ProviderCredentialState } from './modelCatalog/modelCatalog';
export { ModelCatalogService } from './modelCatalog/modelCatalogService';
// session service + adapter
export {
ISessionService,
SessionNotFoundError,
@ -113,7 +60,6 @@ export {
export type { SessionListQuery } from './session/session';
export { SessionService } from './session/sessionService';
// message service + adapter
export {
IMessageService,
MessageNotFoundError,
@ -124,7 +70,6 @@ export {
export type { MessageListQuery } from './message/message';
export { MessageService } from './message/messageService';
// prompt service
export {
IPromptService,
PromptAlreadyCompletedError,
@ -141,7 +86,6 @@ export type {
} from './prompt/prompt';
export { PromptService } from './prompt/promptService';
// tool service + adapter
export {
IToolService,
toProtocolTool,
@ -149,7 +93,6 @@ export {
} from './tool/tool';
export { ToolService } from './tool/toolService';
// mcp service + adapter
export {
IMcpService,
McpServerNotFoundError,
@ -157,7 +100,6 @@ export {
} from './mcp/mcp';
export { McpService } from './mcp/mcpService';
// task service + adapter
export {
ITaskService,
TaskAlreadyFinishedError,
@ -167,10 +109,3 @@ export {
} from './task/task';
export type { TaskListQuery } from './task/task';
export { TaskService } from './task/taskService';
// NOTE: every `<X>Service.ts` impl file self-registers at the bottom via
// `registerSingleton(IXxx, XxxService, InstantiationType.Delayed)` (or the
// descriptor overload when a leading options bag is required, e.g.
// `CoreProcessService`). `defaultServicesModule()` is a thin projection of
// that global registry. Consumers override entries with `services.set(...)`
// for runtime static args or prebuilt instances.

View file

@ -1,66 +0,0 @@
/**
* `defaultServicesModule()` DI entries shipped by `@moonshot-ai/services`.
*
* Thin projection of the global singleton registry maintained by
* `@moonshot-ai/agent-core`. Each service impl file self-registers at
* module-load time via `registerSingleton` (ctor overload for pure `@I…`
* injection, descriptor overload when a leading options bag is required).
* Importing this `module.ts` triggers the side-effect imports below, which
* populate the registry; `defaultServicesModule()` snapshots the registry
* via `getSingletonServiceDescriptors()`.
*
* Usage:
*
* const services = new ServiceCollection(...defaultServicesModule());
*
* Each entry is `[ServiceIdentifier, SyncDescriptor]`. The descriptor carries
* the `supportsDelayedInstantiation` flag; no extra projection is needed.
*
* Consumers (e.g. the daemon) override entries with `services.set(...)` for
* runtime static args (`CoreProcessService` with real `coreProcessOptions`)
* or prebuilt instances (`PinoLogger`). Later registrations win.
*
* Per-domain layout: see `packages/services/AGENTS.md`. Classes live in
* per-domain folders (`session/`, `message/`, ) with one `<domain>.ts`
* contracts file and one `<domain>Service.ts` impl file each.
*/
import {
getSingletonServiceDescriptors,
SyncDescriptor,
type ServiceIdentifier,
} from '@moonshot-ai/agent-core';
// Side-effect imports — each impl file calls `registerSingleton(...)` at
// file bottom. Ordering matters: it determines the order entries surface from
// `getSingletonServiceDescriptors()`, which the daemon's reverse-dispose
// semantics piggy-back on. CoreProcessService MUST register first — the
// existing `defaultServicesModule()` test
// (`packages/services/test/coreProcessService.test.ts:315`) asserts it
// sits at index 0, and downstream `a.get(...)` "touch" ordering in
// `packages/daemon/src/start.ts` assumes the bridge is the first
// service-package entry into the construction-order list.
import './coreProcess/coreProcessService';
import './event/eventService';
import './session/sessionService';
import './message/messageService';
import './prompt/promptService';
import './tool/toolService';
import './mcp/mcpService';
import './task/taskService';
import './authSummary/authSummaryService';
import './oauth/oauthService';
import './modelCatalog/modelCatalogService';
export type ServiceModuleEntry = readonly [
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ServiceIdentifier<any>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
SyncDescriptor<any>,
];
export function defaultServicesModule(): ReadonlyArray<ServiceModuleEntry> {
return getSingletonServiceDescriptors().map(
([id, descriptor]) => [id, descriptor] as const,
);
}

View file

@ -2,7 +2,7 @@
* `OAuthService` implementation of `IOAuthService`.
*/
import { Disposable, InstantiationType, registerSingleton } from '@moonshot-ai/agent-core';
import { Disposable, DisposableMap, InstantiationType, registerSingleton, type IDisposable } from '@moonshot-ai/agent-core';
import {
DeviceCodeTimeoutError,
KIMI_CODE_PROVIDER_NAME,
@ -22,19 +22,52 @@ import { ulid } from 'ulid';
import { IEnvironmentService } from '../environment/environment';
import { IOAuthService } from './oauth';
interface FlowState {
readonly flowId: string;
readonly provider: string;
readonly deviceAuth: DeviceAuthorization;
/** Resolved seconds-until-expiry (may differ from `deviceAuth.expiresIn` if that was null). */
readonly expiresInSec: number;
readonly startedAt: number;
readonly expiresAt: number;
status: OAuthFlowStatus;
readonly controller: AbortController;
/**
* One in-flight (or recently-completed) device-code flow. Stored in
* `OAuthService._flows` (a `DisposableMap`) so:
* - `_flows.set(provider, newState)` auto-disposes the supersedee
* - `_flows.deleteAndDispose(provider)` (called from the GC timer) tears
* down a terminal entry's leftover state
* - service-wide `super.dispose()` walks every entry's `dispose()`
* instead of the old hand-written for-loop in `override dispose()`.
*
* `dispose()` is idempotent and aborts the controller only when the flow is
* still pending terminal flows have already returned from their underlying
* promise, so a second abort would be a noisy no-op.
*/
class FlowState implements IDisposable {
status: OAuthFlowStatus = 'pending';
resolvedAt?: number;
errorMessage?: string;
gcTimer?: NodeJS.Timeout;
private _disposed = false;
constructor(
readonly flowId: string,
readonly provider: string,
readonly deviceAuth: DeviceAuthorization,
/** Resolved seconds-until-expiry (may differ from `deviceAuth.expiresIn` if that was null). */
readonly expiresInSec: number,
readonly startedAt: number,
readonly expiresAt: number,
readonly controller: AbortController,
) {}
dispose(): void {
if (this._disposed) return;
this._disposed = true;
if (this.gcTimer !== undefined) {
clearTimeout(this.gcTimer);
this.gcTimer = undefined;
}
if (this.status === 'pending') {
try {
this.controller.abort();
} catch {
// ignore
}
}
}
}
/** Terminal flows live this long after resolution before GC. */
@ -44,10 +77,11 @@ export class OAuthService extends Disposable implements IOAuthService {
readonly _serviceBrand: undefined;
private readonly _authFacade: KimiAuthFacade;
private readonly _flows = new Map<string, FlowState>();
private readonly _flows: DisposableMap<string, FlowState>;
constructor(@IEnvironmentService private readonly env: IEnvironmentService) {
super();
this._flows = this._register(new DisposableMap<string, FlowState>());
this._authFacade = new KimiAuthFacade({
homeDir: env.homeDir,
configPath: env.configPath,
@ -117,16 +151,15 @@ export class OAuthService extends Disposable implements IOAuthService {
// `OAuthManager.login`, so the `expires_at` we surface to clients is
// never further out than the deadline that's actually being enforced.
const expiresInSec = deviceAuth.expiresIn ?? 15 * 60;
const state: FlowState = {
const state = new FlowState(
flowId,
provider: name,
name,
deviceAuth,
expiresInSec,
startedAt,
expiresAt: startedAt + expiresInSec * 1000,
status: 'pending',
startedAt + expiresInSec * 1000,
controller,
};
);
this._flows.set(name, state);
// Wire the background promise's terminal transition. We branch on error
@ -185,18 +218,7 @@ export class OAuthService extends Disposable implements IOAuthService {
}
override dispose(): void {
if (this._isDisposed) return;
for (const state of this._flows.values()) {
if (state.gcTimer !== undefined) clearTimeout(state.gcTimer);
if (state.status === 'pending') {
try {
state.controller.abort();
} catch {
// ignore
}
}
}
this._flows.clear();
if (this._store.isDisposed) return;
super.dispose();
}
@ -220,14 +242,18 @@ export class OAuthService extends Disposable implements IOAuthService {
if (state.status === status) return;
state.status = status;
state.resolvedAt = Date.now();
// Schedule GC. If a new flow supersedes this entry first, the new flow
// replaces the map entry and this timer just no-ops on the stale state.
// Schedule GC. If a new flow supersedes this entry first, the supersede
// path runs `_flows.set(name, newState)` which auto-disposes this entry —
// its `dispose()` clears `gcTimer` before the timer can fire, so the
// callback can rely on "this state is still the current map entry"
// without the equality check needing a stale-guard.
if (state.gcTimer !== undefined) clearTimeout(state.gcTimer);
state.gcTimer = setTimeout(() => {
const current = this._flows.get(state.provider);
// Only GC if this state IS the current map entry. A newer flow may
// have already overwritten the slot.
if (current === state) this._flows.delete(state.provider);
// Belt-and-suspenders: even though dispose() clears the timer on
// overwrite, keep the identity check in case the timer was queued
// before the clearTimeout took effect.
if (current === state) this._flows.deleteAndDispose(state.provider);
}, TERMINAL_RETENTION_MS);
// Don't keep the process alive solely for GC.
state.gcTimer.unref?.();

View file

@ -819,7 +819,7 @@ export class PromptService
}
override dispose(): void {
if (this._isDisposed) return;
if (this._store.isDisposed) return;
this._active.clear();
this._queued.clear();
this._agentState.clear();

View file

@ -1,39 +1,3 @@
/**
* `ISessionService` daemon-facing session CRUD interface.
*
* Wraps `ICoreProcessService.rpc.{createSession, listSessions, closeSession,
* updateSessionMetadata}` and adapts agent-core's camelCase + number
* timestamps to the protocol's snake_case + ISO 8601 `Z` shape (see SCHEMAS.md
* §2). Other services in `@moonshot-ai/services` (messages, prompts, ...)
* inherit this camelCase snake_case + number ISO pattern.
*
* **Why a service layer**: REST handlers in `@moonshot-ai/daemon` are
* disallowed from importing `@moonshot-ai/kimi-code-sdk` (anti-corruption
* test). Routes call `accessor.get(ISessionService).<method>(...)`; the
* adapter is here.
*
* **CoreAPI shape gap**: agent-core does NOT expose `getSession(id)` returning
* a full `SessionSummary` `getSessionMetadata` returns the smaller
* `SessionMeta` shape. `get(id)` is implemented via `listSessions({})` +
* filter, throwing `SessionNotFoundError` ( 40401) when the id is absent.
* See `SessionService` for details + the gap documentation.
*
* **Adapter helpers**: `toProtocolSession` is co-located here.
*
* **DI wiring**: this class takes `ICoreProcessService` via ctor positional
* arg. `defaultServicesModule()` adds a `SyncDescriptor(SessionService)`
* entry, but the container has no ctor-arg DI, so the daemon's `start.ts`
* wires it via
* `ix.createInstance(SessionService, a.get(ICoreProcessService))` then
* `services.set(ISessionService, instance)` same pattern as
* `CoreProcessService` itself. The descriptor entry is the canonical
* declaration; the daemon's manual wiring is the runtime path.
*
* **Anti-corruption**: this file imports from `@moonshot-ai/agent-core` only
* for type-only `SessionSummary` / `SessionMeta`. Runtime calls go through
* `ICoreProcessService.rpc.<method>`, not direct CoreAPI consumption.
*/
import { createDecorator } from '@moonshot-ai/agent-core';
import { encodeWorkDirKey } from '@moonshot-ai/agent-core/session/store';
import type { Event } from '@moonshot-ai/agent-core/base/common/event';
@ -54,116 +18,41 @@ import {
type UndoSessionResponse,
} from '@moonshot-ai/protocol';
/**
* Listing query `before_id`/`after_id` + `page_size` mutual exclusivity is
* already enforced by `cursorQuerySchema`. The service layer adds an optional
* status filter the daemon layer parses out of the REST query string, and an
* optional `workDir` filter for the `?workspace_id=` fast path (the daemon
* route layer resolves `workspace_id → workspace.root` and sets `workDir`).
*/
export interface SessionListQuery extends CursorQuery {
status?: import('@moonshot-ai/protocol').SessionStatus;
/**
* When set, the underlying `core.rpc.listSessions({workDir})` path uses
* agent-core's `listWorkDir` (readdir-based) instead of a full `listAll`.
* Daemon-route caller is responsible for resolving the workspace_id to its
* registered root before populating this.
*/
workDir?: string;
}
export interface ISessionService {
readonly _serviceBrand: undefined;
/**
* `POST /v1/sessions` create a new session. Requires `metadata.cwd`
* (agent-core's `createSession` calls `requiredWorkDir`; missing cwd throw).
*/
create(input: SessionCreate): Promise<Session>;
/**
* `GET /v1/sessions` list sessions. Cursor pagination is applied
* client-side over `core.rpc.listSessions({})` (the CoreAPI surface
* doesn't take a cursor today). Default
* `page_size = 20` per REST.md §1.6 is applied at the route layer, not here.
*/
list(query: SessionListQuery): Promise<PageResponse<Session>>;
/**
* `GET /v1/sessions/{id}` and `GET /v1/sessions/{id}/profile` single
* session by id. Implemented as `listSessions({}) + .find(id)`; throws
* `SessionNotFoundError` ( 40401) when not found.
*/
get(id: string): Promise<Session>;
/**
* `POST /v1/sessions/{id}/profile` update session mutable properties.
* Backed by `updateSessionMetadata` for metadata changes; `title` writes
* through the same path (mapped onto agent-core's `SessionMeta.title`).
* `agent_config.model` is dispatched to `core.rpc.setModel` when present.
* Returns the post-update Session.
*/
update(id: string, input: SessionUpdate): Promise<Session>;
/**
* `POST /v1/sessions/{id}:fork` create a new persisted session from an
* idle source session and return the fork.
*/
fork(id: string, input: SessionFork): Promise<Session>;
/**
* `GET /v1/sessions/{id}/children` list direct child sessions whose
* metadata points at the parent session.
*/
listChildren(id: string, query: SessionListQuery): Promise<PageResponse<Session>>;
/**
* `POST /v1/sessions/{id}/children` create a persisted child session from
* the parent session and return the child.
*/
createChild(id: string, input: SessionChildCreate): Promise<Session>;
/**
* `DELETE /v1/sessions/{id}` close (= soft-delete in v1) the session.
* Backed by `bridge.rpc.closeSession({sessionId})`. CoreAPI does not
* surface a hard delete; the daemon currently conflates close == delete.
*
* Returns `{ deleted: true }` envelope shape per REST §3.3.
*/
getStatus(id: string): Promise<SessionStatusResponse>;
compact(id: string, input: CompactSessionRequest): Promise<CompactSessionResponse>;
undo(id: string, input: UndoSessionRequest): Promise<UndoSessionResponse>;
/**
* `DELETE /v1/sessions/{id}` close (= soft-delete in v1) the session.
* Backed by `bridge.rpc.closeSession({sessionId})`. CoreAPI does not
* surface a hard delete; the daemon currently conflates close == delete.
*
* Returns `{ deleted: true }` envelope shape per REST §3.3.
*/
delete(id: string): Promise<{ deleted: true }>;
/**
* VSCode-style accessor for session-creation events. The listener fires
* synchronously after the bridge RPC returns a new `Session`.
*
* Subscribing returns an `IDisposable`. Owners stash it via
* `Disposable._register(svc.onDidCreate(handler))` so it tears down
* with the owning service.
*/
readonly onDidCreate: Event<{ session: Session }>;
/**
* VSCode-style accessor for session-close events. The listener fires
* synchronously after `bridge.rpc.closeSession` resolves. Same
* `IDisposable` contract as `onDidCreate`.
*/
readonly onDidClose: Event<{ sessionId: string }>;
}
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const ISessionService = createDecorator<ISessionService>('sessionService');
export class SessionUndoUnavailableError extends Error {
@ -175,11 +64,6 @@ export class SessionUndoUnavailableError extends Error {
}
}
/**
* Sentinel error class daemon's route layer catches this and maps to
* `code: 40401` (session.not_found). Other errors fall through to
* `installErrorHandler` ( 50001 internal).
*/
export class SessionNotFoundError extends Error {
readonly sessionId: string;
constructor(sessionId: string) {
@ -189,27 +73,6 @@ export class SessionNotFoundError extends Error {
}
}
/**
* Convert agent-core's `SessionSummary` + optional `SessionMeta` into the
* protocol-level `Session` shape. The optional `meta` argument is the result
* of `getSessionMetadata` when present, its `title` / `custom` enrich the
* baseline summary; when absent, defaults are used.
*
* `cwd` overrides apply in this priority order:
* 1. `meta.custom.cwd` (set by daemon when update wrote a new cwd).
* 2. `summary.metadata.cwd` (when caller-supplied during create).
* 3. `summary.workDir` (agent-core canonical field).
*
* `workspace_id` is ALWAYS derived from `summary.workDir` via
* `encodeWorkDirKey`, so every session round-trips to a stable workspace
* key. If the daemon has never seen a `POST /workspaces` for that wd-key
* the id simply won't appear in the workspaces list; the session still has
* an id the front-end can group on.
*
* The merged `Session.metadata` keeps `cwd` plus persistent custom metadata
* from the summary and live `meta.custom` when available (excluding
* daemon-internal `goal` plumbing that's not protocol surface).
*/
export function toProtocolSession(
summary: SessionSummary,
meta?: SessionMeta | undefined,
@ -221,8 +84,6 @@ export function toProtocolSession(
(typeof summaryMetadata['cwd'] === 'string' && summaryMetadata['cwd']) ||
summary.workDir;
// Strip the internal "goal" key — that's daemon-side runtime state, not
// protocol surface (SCHEMAS §2 doesn't expose it).
const { goal: _dropSummaryGoal, ...summaryWithoutGoal } = summaryMetadata;
const { goal: _dropCustomGoal, ...customWithoutGoal } = customMetadata;
@ -244,10 +105,6 @@ export function toProtocolSession(
status: 'idle',
metadata: mergedMetadata,
agent_config: {
// CoreAPI doesn't surface a session's effective model on the listSessions
// path; we leave it empty because there is no current source for the
// effective model on this path. Empty string keeps the schema valid for
// consumers that only inspect known keys.
model: '',
},
usage: emptySessionUsage(),

View file

@ -1,7 +1,3 @@
/**
* `SessionService` implementation of `ISessionService`.
*/
import {
Disposable,
Emitter,
@ -50,13 +46,6 @@ const DEFAULT_UNDO_MESSAGE_PAGE_SIZE = 50;
const MAX_UNDO_MESSAGE_PAGE_SIZE = 100;
const CHILD_SESSION_KIND = 'child';
/**
* Treat the incoming `metadata` object schema-validated by zod as
* `{cwd: string}` plus arbitrary `unknown` keys as a JSON-safe object for
* agent-core's `JsonObject` slot. We don't deep-validate here; clients can
* send non-JSON-serializable values and agent-core will reject at the RPC
* boundary. This cast keeps the adapter narrow and the wire stable.
*/
function asJsonObject(value: Record<string, unknown>): JsonObject {
return value as unknown as JsonObject;
}
@ -111,17 +100,8 @@ function pageContextMessages(
export class SessionService extends Disposable implements ISessionService {
readonly _serviceBrand: undefined;
/**
* VSCode-style Emitter for session-creation events. Listener exceptions
* route to `onUnexpectedError` inside `Emitter.fire()`. Owned via
* `_register(...)` so it disposes when the service is torn down.
*/
private readonly _onDidCreate = this._register(new Emitter<{ session: Session }>());
readonly onDidCreate = this._onDidCreate.event;
/**
* VSCode-style Emitter for session-close events. Same ownership +
* exception-routing semantics as `_onDidCreate`.
*/
private readonly _onDidClose = this._register(new Emitter<{ sessionId: string }>());
readonly onDidClose = this._onDidClose.event;
@ -134,13 +114,6 @@ export class SessionService extends Disposable implements ISessionService {
}
async create(input: SessionCreate): Promise<Session> {
// The protocol schema now allows `metadata` to be omitted (so callers can
// send `{ workspace_id }` only). The daemon route layer is responsible
// for resolving workspace_id → workspace.root → metadata.cwd BEFORE
// calling this method; by the time we land here `metadata.cwd` must be
// set. agent-core's `createSession` calls `requiredWorkDir(...)` and
// throws if cwd is missing, so a missing-cwd bug surfaces as a
// descriptive error rather than silently picking the daemon's cwd.
if (input.metadata === undefined || typeof input.metadata.cwd !== 'string') {
throw new Error('SessionService.create: metadata.cwd is required');
}
@ -150,39 +123,25 @@ export class SessionService extends Disposable implements ISessionService {
metadata: metadataForCore,
...(input.agent_config?.model !== undefined ? { model: input.agent_config.model } : {}),
});
// agent-core's createSession ignores any caller-supplied title — newly
// created sessions get the default `SessionMeta.title = 'New Session'`.
// When the caller supplied a title we apply it via `renameSession` so the
// post-create get reflects it.
if (input.title !== undefined) {
try {
await this.core.rpc.renameSession({ sessionId: summary.id, title: input.title });
} catch {
// If rename fails (e.g. session closed/race), continue with the
// default — the response shape is unchanged.
}
}
const meta = await this.tryGetMeta(summary.id);
const session = toProtocolSession(summary, meta);
// Fire onDidCreate listeners after the core RPC resolves.
this._onDidCreate.fire({ session });
return session;
}
async list(query: SessionListQuery): Promise<PageResponse<Session>> {
// Fast path: when caller supplies a workDir (typically from `?workspace_id=`
// resolving to a workspace.root), agent-core's `listSessions({workDir})`
// walks a single wd-key bucket via readdir instead of scanning every
// workdir. Otherwise we list everything and apply downstream filters.
const all =
query.workDir !== undefined
? await this.core.rpc.listSessions({ workDir: query.workDir })
: await this.core.rpc.listSessions({});
// Sort by createdAt desc per REST §1.6 "最近 N 条(按 created_at desc".
const sorted = [...all].sort((a, b) => b.createdAt - a.createdAt);
const sorted = all.toSorted((a, b) => b.createdAt - a.createdAt);
// Cursor: anchor on id. before_id = older than that id; after_id = newer.
// Because the underlying list is desc, "older" = AFTER in the array.
let pivotIndex = -1;
if (query.before_id !== undefined) {
pivotIndex = sorted.findIndex((s) => s.id === query.before_id);
@ -192,10 +151,8 @@ export class SessionService extends Disposable implements ISessionService {
let slice: typeof sorted;
if (query.before_id !== undefined && pivotIndex >= 0) {
// before_id = older entries → tail of the desc array, exclusive of pivot
slice = sorted.slice(pivotIndex + 1);
} else if (query.after_id !== undefined && pivotIndex >= 0) {
// after_id = newer entries → head of the desc array, exclusive of pivot
slice = sorted.slice(0, pivotIndex);
} else {
slice = sorted;
@ -206,16 +163,10 @@ export class SessionService extends Disposable implements ISessionService {
const pageSummaries = slice.slice(0, pageSize);
const hasMore = slice.length > pageSize;
// Hydrate each summary with its metadata. We do these in parallel —
// `getSessionMetadata` is in-memory once the session is loaded, so the
// round-trip count is what matters, not bandwidth.
const items = await Promise.all(
pageSummaries.map(async (s) => toProtocolSession(s, await this.tryGetMeta(s.id))),
);
// Apply post-hydration status filter if requested. Today all sessions
// are mapped to 'idle' (see header note); the filter is wired now so the
// wire contract is stable when agent-core surfaces a real status enum.
const filtered =
query.status !== undefined ? items.filter((s) => s.status === query.status) : items;
@ -233,21 +184,16 @@ export class SessionService extends Disposable implements ISessionService {
}
async update(id: string, input: SessionUpdate): Promise<Session> {
// Existence check first — gives a deterministic 40401 if the id is wrong.
const all = await this.core.rpc.listSessions({});
const summary = all.find((s) => s.id === id);
if (summary === undefined) {
throw new SessionNotFoundError(id);
}
// 1) title goes through renameSession.
if (input.title !== undefined) {
await this.core.rpc.renameSession({ sessionId: id, title: input.title });
}
// 2) metadata patches go through updateSessionMetadata. agent-core's
// SessionMeta has top-level `title` + `custom`; we route protocol's
// `metadata` (catchall) into `custom` so it round-trips on the next get.
const metadataPatch = input.metadata;
if (metadataPatch !== undefined && Object.keys(metadataPatch).length > 0) {
await this.core.rpc.updateSessionMetadata({
@ -256,19 +202,9 @@ export class SessionService extends Disposable implements ISessionService {
});
}
// 3) agent_config runtime controls — route the four fields (model,
// thinking, permission_mode, plan_mode) through the per-session
// shadow on `IPromptService.applyAgentState`. The helper diff-
// dispatches against the shadow and writes a dispatch-log entry
// with `source='meta'` for any setter that actually fires.
// Resolution is lazy via `IInstantiationService` to break the
// ctor cycle (`PromptService` already injects `ISessionService`).
const ac = input.agent_config;
if (ac !== undefined) {
const patch: AgentStatePatch = {};
// SessionService.update has long accepted `agent_config.model` and
// treated empty string as "no change" — we preserve that quirk by
// dropping the empty case before forwarding to the shadow.
if (ac.model !== undefined && ac.model !== '') patch.model = ac.model;
if (ac.thinking !== undefined) patch.thinking = ac.thinking;
if (ac.permission_mode !== undefined) patch.permission_mode = ac.permission_mode;
@ -286,10 +222,6 @@ export class SessionService extends Disposable implements ISessionService {
}
}
// 4) permission_rules: no CoreAPI surface yet — we accept the input
// (schema-validated) but no CoreAPI surface exists to persist them yet.
// Re-fetch to return the post-update Session.
const allAfter = await this.core.rpc.listSessions({});
const summaryAfter = allAfter.find((s) => s.id === id) ?? summary;
const meta = await this.tryGetMeta(id);
@ -374,7 +306,6 @@ export class SessionService extends Disposable implements ISessionService {
}
async getStatus(id: string): Promise<SessionStatusResponse> {
// Existence check — same pattern as get() / update() / delete().
const all = await this.core.rpc.listSessions({});
const summary = all.find((s) => s.id === id);
if (summary === undefined) {
@ -448,14 +379,12 @@ export class SessionService extends Disposable implements ISessionService {
}
async delete(id: string): Promise<{ deleted: true }> {
// Existence check — deterministic 40401 even on close.
const all = await this.core.rpc.listSessions({});
const summary = all.find((s) => s.id === id);
if (summary === undefined) {
throw new SessionNotFoundError(id);
}
await this.core.rpc.closeSession({ sessionId: id });
// Fire onDidClose listeners after the core RPC resolves.
this._onDidClose.fire({ sessionId: id });
return { deleted: true };
}
@ -469,11 +398,6 @@ export class SessionService extends Disposable implements ISessionService {
return summary;
}
/**
* Pull a session's metadata; swallow errors (session may not be loaded into
* the active session map yet, in which case `sessionApi(id)` throws). The
* caller falls back to defaults from the summary alone.
*/
private async tryGetMeta(id: string): Promise<SessionMeta | undefined> {
try {
const meta = await this.core.rpc.getSessionMetadata({ sessionId: id });
@ -483,24 +407,10 @@ export class SessionService extends Disposable implements ISessionService {
}
}
// --- Per-domain event accessors -------------------------------------------
//
// `onDidCreate` / `onDidClose` are declared above as
// `Emitter<T>.event` getters; consumers subscribe via
// `svc.onDidCreate(handler)` (returns IDisposable) and own the
// detach lifetime through `Disposable._register(...)`.
override dispose(): void {
if (this._isDisposed) return;
// `_onDidCreate` and `_onDidClose` are registered via `this._register(...)`,
// so `super.dispose()` flushes their listeners.
if (this._store.isDisposed) return;
super.dispose();
}
}
// Self-register under the global singleton registry. Daemon-side bootstrap
// projects this through `defaultServicesModule()` /
// `getSingletonServiceDescriptors()`. All ctor deps are `@I…`-injected, so
// `staticArguments` is `[]`. `supportsDelayedInstantiation = false` preserves
// current reverse-dispose semantics.
registerSingleton(ISessionService, SessionService, InstantiationType.Delayed);

View file

@ -1,18 +1,3 @@
/**
* Acceptance: `CoreProcessService` wires peer services + KimiCore + RPC pair;
* `ready()` settles; `dispose()` short-circuits RPC; `defaultServicesModule()`
* composes with the DI container.
*
* Hermetic strategy: KimiCore wants a real HOME dir / config / Git Bash. We
* point it at an isolated tmp dir per test so it doesn't touch the user's
* `~/.kimi`. The `rpc` smoke uses a single round-trip (`getCoreInfo`) that
* doesn't require any external state exercises the full RPC plumbing (core
* createRPC BridgeClientAPI binding) without touching session/plugin/MCP
* code paths. createSession() smoke is harder to make hermetic because it
* spins up Kaos, hooks, and plugin discovery we leave that to integration
* suites with daemon-side mocks.
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@ -20,16 +5,16 @@ import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
InstantiationService,
ServiceCollection,
SyncDescriptor,
Emitter,
getSingletonServiceDescriptors,
type ApprovalRequest,
type ApprovalResponse,
type Event,
type QuestionRequest,
type QuestionResult,
} from '@moonshot-ai/agent-core';
import { TestInstantiationService } from '@moonshot-ai/agent-core/di/test';
import {
BridgeClientAPI,
@ -39,11 +24,8 @@ import {
IEventService,
ICoreProcessService,
IQuestionService,
defaultServicesModule,
} from '../src';
// --- Mock peer-service impls (per-test fresh instances) ----------------------
class RecordingEventService implements IEventService {
readonly _serviceBrand: undefined;
@ -92,8 +74,6 @@ class RecordingQuestionService implements IQuestionService {
}
}
// --- Sandbox HOME setup ------------------------------------------------------
let tmpHome: string;
let prevHome: string | undefined;
@ -112,7 +92,6 @@ afterEach(() => {
try {
rmSync(tmpHome, { recursive: true, force: true });
} catch {
// Best-effort cleanup; tmp dirs are auto-pruned.
}
});
@ -132,8 +111,6 @@ function makeEnv(homeDir: string): IEnvironmentService {
};
}
// --- Tests -------------------------------------------------------------------
describe('BridgeClientAPI', () => {
it('routes emitEvent / requestApproval / requestQuestion / toolCall to peer services', async () => {
const { eventService, approvalService, questionService } = makePeers();
@ -191,7 +168,6 @@ describe('CoreProcessService direct construction', () => {
questionService,
);
try {
// ready() resolves once the SDK side of the RPC pair has bound.
await expect(core.ready()).resolves.toBeUndefined();
expect(typeof core.rpc.getCoreInfo).toBe('function');
} finally {
@ -210,9 +186,6 @@ describe('CoreProcessService direct construction', () => {
);
try {
await core.ready();
// getCoreInfo is a pure read on KimiCore (no session/plugin state). It
// round-trips through the full createRPC pair (serialize → core →
// serialize back) — that's the in-process adapter smoke we care about.
const info = await core.rpc.getCoreInfo({});
expect(info).toHaveProperty('version');
expect(typeof info.version).toBe('string');
@ -232,40 +205,19 @@ describe('CoreProcessService direct construction', () => {
);
await core.ready();
core.dispose();
core.dispose(); // second call must be a no-op
core.dispose();
await expect(core.rpc.getCoreInfo({})).rejects.toThrow(/disposed/);
});
// Regression: prior to the BLOCKER fix the in-process adapter never
// forwarded a `resolveOAuthTokenProvider` into KimiCore.
// ProviderManager.resolveAuth then synthesized a closure that ALWAYS
// threw `auth.login_required` even after a successful device-code login.
// The daemon's `/auth` readiness probe (file-existence check) still said
// `ready:true`, so the failure only surfaced inside the prompt turn.
// Lock down that the adapter default-wires a resolver from the same
// home/config paths KimiCore consumes.
it('default-wires a resolveOAuthTokenProvider when caller omits one', () => {
const resolver = CoreProcessService._defaultOAuthTokenResolver(tmpHome, join(tmpHome, 'config.toml'));
expect(typeof resolver).toBe('function');
// Calling the resolver with the managed-kimi-code provider name must
// return an object exposing `getAccessToken`. We don't invoke it —
// there's no token on disk in this hermetic test — but the shape is
// sufficient to prove the adapter wired a real BearerTokenProvider
// factory (not the always-throw sentinel).
const tokenProvider = resolver('managed:kimi-code');
expect(tokenProvider).toBeDefined();
expect(typeof tokenProvider?.getAccessToken).toBe('function');
});
// Regression: prior to the identity-wiring fix the adapter never
// forwarded `kimiRequestHeaders` into KimiCore — the daemon-hosted
// KimiCore made upstream fetches with the Node default User-Agent, and
// the managed Kimi-for-Coding endpoint rejected with 40340 ("only
// available for Coding Agents such as Kimi CLI, …"). The in-process TUI
// path (`createKimiHarness`) was unaffected because `SDKRpcClient`
// already built these headers from `identity`. Lock down that the
// adapter does the same when given an `identity`.
it('default-wires kimiRequestHeaders from identity when caller omits headers', () => {
const headers = CoreProcessService._defaultKimiRequestHeaders(
tmpHome,
@ -275,10 +227,6 @@ describe('CoreProcessService direct construction', () => {
expect(headers!['User-Agent']).toMatch(/^kimi-code-cli\/9\.9\.9/);
expect(headers!['X-Msh-Platform']).toBe('kimi_code_cli');
expect(headers!['X-Msh-Version']).toBe('9.9.9');
// `createKimiDeviceId` mints + caches a per-machine UUID under
// `<homeDir>/device_id`. Assert the header exists (UUID shape, not a
// literal value — we tmp-isolate the home, so the value differs every
// run).
expect(headers!['X-Msh-Device-Id']).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
@ -290,10 +238,6 @@ describe('CoreProcessService direct construction', () => {
});
it('caller-supplied kimiRequestHeaders win over identity-derived defaults', () => {
// Sanity: when both are present the ctor takes `options.kimiRequestHeaders`
// first. We can't observe the KimiCore ctor arg directly without exposing
// it, so we just lock down the helper's precedence contract — the ctor's
// `??` chain depends on it.
const explicit = { 'User-Agent': 'override/1.0' };
const picked =
explicit ?? CoreProcessService._defaultKimiRequestHeaders(
@ -304,33 +248,24 @@ describe('CoreProcessService direct construction', () => {
});
});
describe('defaultServicesModule() composition', () => {
describe('singleton registry composition', () => {
it('returns a CoreProcessService descriptor that composes with the DI container', async () => {
const { eventService, approvalService, questionService } = makePeers();
const moduleEntries = defaultServicesModule();
// ICoreProcessService is the FIRST entry (its position matters because
// the daemon's start.ts uses createInstance + services.set on top of the
// descriptor — the order documents the canonical construction sequence).
const moduleEntries = getSingletonServiceDescriptors();
expect(moduleEntries.length).toBeGreaterThanOrEqual(1);
expect(moduleEntries[0]![0]).toBe(ICoreProcessService);
expect(moduleEntries[0]![1]).toBeInstanceOf(SyncDescriptor);
const services = new ServiceCollection(
// Spread module entries first so the test's explicit per-decorator
// overrides below win (last-write-wins in ServiceCollection). The
// module now self-registers `IEventService` too, so we need the
// fake `eventService` to land AFTER that descriptor entry.
...moduleEntries.map(([id, desc]) => [id, desc] as const),
[IEventService, eventService],
[IApprovalService, approvalService],
[IQuestionService, questionService],
[IEnvironmentService, makeEnv(tmpHome)],
);
const ix = new InstantiationService(services);
const ix = new TestInstantiationService();
for (const [id, desc] of moduleEntries) {
ix.set(id, desc);
}
ix.stub(IEventService, eventService);
ix.stub(IApprovalService, approvalService);
ix.stub(IQuestionService, questionService);
ix.stub(IEnvironmentService, makeEnv(tmpHome));
try {
// createInstance with static options prefix; @IEnvironmentService and
// the three peer services auto-inject from the container.
const core = ix.createInstance(CoreProcessService, {});
try {
await core.ready();

View file

@ -1,18 +1,9 @@
/**
* Acceptance: the three peer-service decorators (`IEventService`,
* `IApprovalService`, `IQuestionService`) are typed correctly, can be
* registered in a `ServiceCollection`, resolved through
* `InstantiationService`, and surface their diagnostic names in
* not-registered errors.
*/
import { describe, expect, it, vi } from 'vitest';
import {
InstantiationService,
ServiceCollection,
Emitter,
} from '@moonshot-ai/agent-core';
import { TestInstantiationService } from '@moonshot-ai/agent-core/di/test';
import type { ApprovalRequest, Event, QuestionRequest } from '@moonshot-ai/agent-core';
import {
@ -72,7 +63,6 @@ class FakeQuestionService implements IQuestionService {
}
function makeFakeEvent(): Event {
// Minimal AgentStatusUpdatedEvent shape — the union narrows by `type`.
return {
type: 'agent_status_updated',
sessionId: 'sess-1',
@ -106,117 +96,81 @@ function makeFakeQuestion(): QuestionRequest & { sessionId: string; agentId: str
}
describe('@moonshot-ai/services · interfaces', () => {
it('registers all three peer services in a ServiceCollection and resolves them through InstantiationService', () => {
it('registers all three peer services in a test instantiation service', () => {
const events = new FakeEventService();
const approvals = new FakeApprovalService();
const questions = new FakeQuestionService();
const services = new ServiceCollection(
[IEventService, events],
[IApprovalService, approvals],
[IQuestionService, questions],
);
const ix = new InstantiationService(services);
const ix = new TestInstantiationService();
ix.stub(IEventService, events);
ix.stub(IApprovalService, approvals);
ix.stub(IQuestionService, questions);
try {
ix.invokeFunction((accessor) => {
expect(accessor.get(IEventService)).toBe(events);
expect(accessor.get(IApprovalService)).toBe(approvals);
expect(accessor.get(IQuestionService)).toBe(questions);
});
} finally {
ix.dispose();
}
expect(ix.get(IEventService)).toBe(events);
expect(ix.get(IApprovalService)).toBe(approvals);
expect(ix.get(IQuestionService)).toBe(questions);
});
it('end-to-end smoke: invokes service methods via the accessor', async () => {
it('end-to-end smoke: invokes service methods through the test container', async () => {
const events = new FakeEventService();
const approvals = new FakeApprovalService();
const questions = new FakeQuestionService();
const services = new ServiceCollection(
[IEventService, events],
[IApprovalService, approvals],
[IQuestionService, questions],
);
const ix = new InstantiationService(services);
const ix = new TestInstantiationService();
ix.stub(IEventService, events);
ix.stub(IApprovalService, approvals);
ix.stub(IQuestionService, questions);
try {
const event = makeFakeEvent();
ix.invokeFunction((a) => a.get(IEventService).publish(event));
expect(events.events).toEqual([event]);
const event = makeFakeEvent();
ix.get(IEventService).publish(event);
expect(events.events).toEqual([event]);
const approval = makeFakeApproval();
const approvalResp = await ix.invokeFunction((a) =>
a.get(IApprovalService).request(approval),
);
expect(approvalResp).toEqual({ decision: 'approved' });
expect(approvals.received).toHaveLength(1);
const approval = makeFakeApproval();
const approvalResp = await ix.get(IApprovalService).request(approval);
expect(approvalResp).toEqual({ decision: 'approved' });
expect(approvals.received).toHaveLength(1);
const question = makeFakeQuestion();
const questionResp = await ix.invokeFunction((a) =>
a.get(IQuestionService).request(question),
);
expect(questionResp).toBeNull();
expect(questions.received).toHaveLength(1);
} finally {
ix.dispose();
}
const question = makeFakeQuestion();
const questionResp = await ix.get(IQuestionService).request(question);
expect(questionResp).toBeNull();
expect(questions.received).toHaveLength(1);
});
it('resolve/dismiss service methods are wired through the same DI value', () => {
const approvals = new FakeApprovalService();
const questions = new FakeQuestionService();
const services = new ServiceCollection(
[IApprovalService, approvals],
[IQuestionService, questions],
);
const ix = new InstantiationService(services);
const ix = new TestInstantiationService();
ix.stub(IApprovalService, approvals);
ix.stub(IQuestionService, questions);
try {
ix.invokeFunction((a) => {
a.get(IApprovalService).resolve('tc-1', { decision: 'rejected', feedback: 'no' });
a.get(IQuestionService).resolve('q-1', { answers: { q_1: 'A' } });
a.get(IQuestionService).dismiss('q-2');
});
ix.get(IApprovalService).resolve('tc-1', { decision: 'rejected', feedback: 'no' });
ix.get(IQuestionService).resolve('q-1', { answers: { q_1: 'A' } });
ix.get(IQuestionService).dismiss('q-2');
expect(approvals.resolveCalls).toEqual([
{ id: 'tc-1', response: { decision: 'rejected', feedback: 'no' } },
]);
expect(questions.resolveCalls).toEqual([
{ id: 'q-1', response: { answers: { q_1: 'A' } } },
]);
expect(questions.dismissCalls).toEqual(['q-2']);
} finally {
ix.dispose();
}
expect(approvals.resolveCalls).toEqual([
{ id: 'tc-1', response: { decision: 'rejected', feedback: 'no' } },
]);
expect(questions.resolveCalls).toEqual([
{ id: 'q-1', response: { answers: { q_1: 'A' } } },
]);
expect(questions.dismissCalls).toEqual(['q-2']);
});
it('looking up an unregistered service returns undefined in non-strict mode', () => {
const ix = new InstantiationService(new ServiceCollection());
try {
expect(ix.invokeFunction((a) => a.get(IEventService))).toBeUndefined();
expect(ix.invokeFunction((a) => a.get(IApprovalService))).toBeUndefined();
expect(ix.invokeFunction((a) => a.get(IQuestionService))).toBeUndefined();
} finally {
ix.dispose();
}
const ix = new TestInstantiationService();
expect(ix.get(IEventService)).toBeUndefined();
expect(ix.get(IApprovalService)).toBeUndefined();
expect(ix.get(IQuestionService)).toBeUndefined();
});
it('IEventService / IApprovalService / IQuestionService are callable ServiceIdentifiers (compile-time guard)', () => {
// The const half of the dual export must be usable as a ServiceCollection
// key and as a `createDecorator` brand value. We exercise both at runtime
// to also catch any accidental swap of the value with the type.
expect(typeof IEventService).toBe('function');
expect(typeof IApprovalService).toBe('function');
expect(typeof IQuestionService).toBe('function');
// Avoid an unused-import warning on the type-only re-export.
const _typeProbe: ApprovalResponse | QuestionResult = null;
void _typeProbe;
// And use vi to keep the import surface (helpful when running with strict
// unused-imports lints in the future).
vi.fn();
});
});

View file

@ -1,19 +1,3 @@
/**
* `SessionService` (Chain 2 / P1.2) unit tests.
*
* Hermetic: we mock `ICoreProcessService` with an in-memory `rpc` proxy whose
* methods return controllable promises. No KimiCore, no agent-core RPC pair
* the adapter is exercised against a fake bridge.
*
* Test cases cover:
* - create toProtocolSession (camelCase snake_case + number ISO)
* - list pagination (default/before_id/after_id/page_size; has_more)
* - get + SessionNotFoundError 40401 mapping at the daemon layer
* - update (title-only / metadata-only / both / empty)
* - delete returning {deleted: true}
* - toProtocolSession field defaults for fields agent-core doesn't surface
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
@ -23,15 +7,13 @@ import {
type CreateSessionPayload,
Emitter,
type ForkSessionPayload,
type IInstantiationService,
type RenameSessionPayload,
type ResumeSessionResult,
type ServiceIdentifier,
type ServicesAccessor,
type SessionMeta,
type SessionSummary,
type UpdateSessionMetadataPayload,
} from '@moonshot-ai/agent-core';
import { TestInstantiationService } from '@moonshot-ai/agent-core/di/test';
import { emptySessionUsage, type Session } from '@moonshot-ai/protocol';
import {
@ -63,11 +45,6 @@ interface FakeBridgeState {
postUndoContexts: Map<string, AgentContextData>;
}
/**
* Build a tiny fake `ICoreProcessService` whose `rpc` proxy implements just the
* five session methods the impl uses. Each method delegates to an in-memory
* state object the test owns.
*/
function makeFakeBridge(state: FakeBridgeState): ICoreProcessService {
const rpc: Partial<CoreRPC> = {
createSession: vi
@ -152,8 +129,6 @@ function makeFakeBridge(state: FakeBridgeState): ICoreProcessService {
.fn()
.mockImplementation(async (payload: WithSessionId<RenameSessionPayload>) => {
state.renamedTitles.set(payload.sessionId, payload.title);
// Reflect into the metadata map so subsequent `getSessionMetadata`
// returns the updated title (mirrors real KimiCore behavior).
const existing = state.metas.get(payload.sessionId);
if (existing !== undefined) {
state.metas.set(payload.sessionId, { ...existing, title: payload.title });
@ -257,15 +232,8 @@ function textMessage(
let state: FakeBridgeState;
let svc: SessionService;
let promptStub: ReturnType<typeof makePromptServiceStub>;
let instantiation: TestInstantiationService;
/**
* Stub `IPromptService` for hermetic SessionService tests. Records every
* `applyAgentState(sid, patch, source)` call so tests can assert that
* `SessionService.update` forwards `agent_config` runtime fields through
* the shared shadow-aware helper rather than dispatching `core.rpc.*`
* directly. The other `IPromptService` methods aren't reachable from
* SessionService and are stubbed to throw on access.
*/
function makePromptServiceStub(): {
promptService: IPromptService;
calls: Array<{ sid: string; patch: Record<string, unknown>; source: string; promptId: string | undefined }>;
@ -290,45 +258,27 @@ function makePromptServiceStub(): {
return { promptService, calls };
}
/**
* Fake `IInstantiationService` that only resolves the one service
* `SessionService.update` reaches for (`IPromptService`). Other lookups
* throw they would indicate an unintended dependency creeping in.
*/
function makeFakeInstantiation(stubs: {
function makeTestInstantiation(stubs: {
promptService: IPromptService;
}): IInstantiationService {
const accessor: ServicesAccessor = {
get: <T,>(id: ServiceIdentifier<T>): T => {
if ((id as unknown) === (IPromptService as unknown)) {
return stubs.promptService as unknown as T;
}
throw new Error(`unexpected service lookup: ${String((id as unknown as { toString(): string }).toString())}`);
},
};
return {
_serviceBrand: undefined,
invokeFunction: <R,>(fn: (a: ServicesAccessor) => R): R => fn(accessor),
createInstance: (() => {
throw new Error('createInstance not supported in this test stub');
}) as IInstantiationService['createInstance'],
createChild: () => {
throw new Error('createChild not supported in this test stub');
},
} as unknown as IInstantiationService;
}): TestInstantiationService {
const ix = new TestInstantiationService(undefined, true);
ix.stub(IPromptService, stubs.promptService);
return ix;
}
beforeEach(() => {
state = freshState();
promptStub = makePromptServiceStub();
instantiation = makeTestInstantiation({ promptService: promptStub.promptService });
svc = new SessionService(
makeFakeBridge(state),
makeFakeInstantiation({ promptService: promptStub.promptService }),
instantiation,
);
});
afterEach(() => {
svc.dispose();
instantiation.dispose();
});
describe('toProtocolSession adapter', () => {
@ -458,7 +408,6 @@ describe('SessionService.create', () => {
expect(state.sessions).toHaveLength(1);
expect(state.sessions[0]!.workDir).toBe('/tmp/foo');
expect(session.metadata.cwd).toBe('/tmp/foo');
// title is echoed back even when CoreAPI doesn't reflect it (gap doc).
expect(session.title).toBe('My session');
expect(session.created_at.endsWith('Z')).toBe(true);
});
@ -468,9 +417,7 @@ describe('SessionService.create', () => {
metadata: { cwd: '/tmp/x' },
agent_config: { model: 'moonshot-v1-128k' },
});
const created = state.sessions[0]!;
expect((state.sessions as SessionSummary[])[0]!.metadata?.['cwd']).toBe('/tmp/x');
void created;
expect(state.sessions[0]!.metadata?.['cwd']).toBe('/tmp/x');
});
it('rejects when metadata.cwd is absent (daemon route must pre-resolve workspace_id → cwd)', async () => {
@ -482,7 +429,6 @@ describe('SessionService.create', () => {
describe('SessionService.list', () => {
beforeEach(async () => {
// Seed 3 sessions in increasing createdAt order.
await svc.create({ metadata: { cwd: '/tmp/a' } });
await svc.create({ metadata: { cwd: '/tmp/b' } });
await svc.create({ metadata: { cwd: '/tmp/c' } });
@ -504,20 +450,19 @@ describe('SessionService.list', () => {
it('before_id returns older sessions only', async () => {
const all = await svc.list({});
const pivotId = all.items[0]!.id; // newest
const pivotId = all.items[0]!.id;
const olderPage = await svc.list({ before_id: pivotId });
expect(olderPage.items.map((s) => s.metadata.cwd)).toEqual(['/tmp/b', '/tmp/a']);
});
it('after_id returns newer sessions only', async () => {
const all = await svc.list({});
const pivotId = all.items[2]!.id; // oldest
const pivotId = all.items[2]!.id;
const newerPage = await svc.list({ after_id: pivotId });
expect(newerPage.items.map((s) => s.metadata.cwd)).toEqual(['/tmp/c', '/tmp/b']);
});
it('status filter applies post-hydration', async () => {
// Today everything maps to 'idle'; non-matching filter returns []
const empty = await svc.list({ status: 'running' });
expect(empty.items).toEqual([]);
const idle = await svc.list({ status: 'idle' });
@ -569,7 +514,6 @@ describe('SessionService.update', () => {
it('routes title through bridge.rpc.renameSession', async () => {
await svc.update(created.id, { title: 'Renamed' });
expect(state.renamedTitles.get(created.id)).toBe('Renamed');
// Title is reflected via the next get (impl re-fetches metadata).
expect(state.metadataPatches.has(created.id)).toBe(false);
});

View file

@ -8,9 +8,6 @@ export default defineConfig({
plugins: [rawTextPlugin()],
resolve: {
alias: [
// Order matters — list MORE specific entries first so prefix matching
// doesn't route them through the bare `@moonshot-ai/agent-core` alias
// (which points at agent-core/src/index.ts, breaking subpath imports).
{
find: /^@moonshot-ai\/agent-core\/session\/store$/,
replacement: fileURLToPath(
@ -23,6 +20,12 @@ export default defineConfig({
new URL('../agent-core/src/base/common/event.ts', import.meta.url),
),
},
{
find: /^@moonshot-ai\/agent-core\/di\/test$/,
replacement: fileURLToPath(
new URL('../agent-core/src/di/test.ts', import.meta.url),
),
},
{
find: '@moonshot-ai/kimi-code-sdk',
replacement: fileURLToPath(