* refactor(agent-core-v2): register agent tools as DI services with profile-aware activation - replace module-level registerTool + Eager AgentBuiltinToolsRegistrar with registerAgentTool double registration (Agent-scope DI service + contribution table) - add toolActivation domain: AgentToolActivationService filters contributions by the bound Profile's tool policy using declared names, resolves instances lazily via accessor.get, and re-activates on agent.status.updated - rename BuiltinTool to AgentTool service interface; tools become Agent-scope services with decorator-injected dependencies (e.g. AgentTool -> SubagentTool/ISubagentTool) - AgentLifecycleService.create runs one activation pass after restore and profile binding so tools reflect the Profile before the first turn - update tool registrations, scripts, and tests; add toolActivationService tests * refactor(agent-core-v2): centralize builtin tools under agent/tools - move builtin tools from scattered domain folders (plan/tools, goal/tools, os/backends/node-local/tools, task/tools, etc.) into a unified agent/tools/ directory - split each tool into a kebab-case definition file (bash.ts) and a registration file (bashTool.ts) pairing with its prompt markdown - update imports across src, tests, kap-server, and TUI comments * fix(agent-core-v2): keep agent tools out of scope-creation instantiation Main's instantiateAll constructs every registered service at scope creation, but agent tool constructors may legitimately throw when their host capability is absent (e.g. WebSearchTool without a configured provider), and profile-aware activation must stay the only resolution path so the runtime registry holds real instances, never proxies. - add SyncDescriptor.instantiateWithScope (default true) and let registerScopedService opt registrations out of the instantiateAll sweep - registerAgentTool passes the opt-out, restoring lazy activation-driven construction on top of the eager-scope semantics - regenerate docs/state-manifest.d.ts * refactor(agent-core-v2): replace delayed DI with scope activation - add explicit OnScopeCreated and OnDemand activation modes - remove delayed proxy and idle initialization support - migrate service registrations, tests, and DI guidance
15 KiB
DI testing
Conventions for testing services built on the DI × Scope architecture.
The goal of these rules is that a test exercises the same path production uses: a service is reached by its interface through the container, its
@IServicedependencies are resolved from the container, and — where the scope layer matters — through the scope tree. Tests thatnewa service and paper over its constructor with hand-rolled objects bypass that path and let theregisterScopedService(IX → Impl)binding rot untested.
@IService parameter decorators run under vitest (the build uses
experimentalDecorators), so fixtures declare dependencies exactly like
production code. There is no param() helper, no manual
(Id as …)(Ctor, '', 0), and no capturing accessor inside a constructor to
synchronously .get() a peer — those are workarounds for a decorator
transform we already have.
The one rule
Resolve the system under test by its interface, through the container. Never
call new on a production service whose constructor carries @IService
dependencies.
// ✅ resolve by interface — the IX → Sut binding is exercised
ix.set(IMessageService, new SyncDescriptor(MessageService));
const svc = ix.get(IMessageService);
// ❌ construct the implementation directly — the registration is never run
const svc = new MessageService(stubContext);
Resolving by interface is what makes registerScopedService(ISut, Sut, …) part
of the test. Constructing the class directly (or via
ix.createInstance(Sut)) tests the class in isolation but leaves the binding,
the scope layer, and its ScopeActivation mode unverified.
Pure functions, value objects, and services with no @IService
dependencies may be constructed directly.
The only other exception is a test that genuinely needs two independent
instances of the same service with different dependencies (for example,
test/turn/turn.test.ts constructs two
TurnServices with different ILoopRunners). A singleton-per-container
resolution cannot produce both, so ix.createInstance(Impl) is acceptable
there — annotate it with a comment explaining why.
Two harnesses
Pick the harness by whether the scope layer is part of what you are testing.
| Under test | Harness | Resolve the SUT with |
|---|---|---|
| A single service's behavior (unit) | TestInstantiationService (flat) |
ix.get(ISut) after ix.set(ISut, new SyncDescriptor(Sut)) |
| Cross-scope wiring, or which layer a service lives in | createScopedTestHost (scope tree) |
host.<scope>.accessor.get(ISut) |
Unit harness — TestInstantiationService
Default for domain service unit tests. It is an InstantiationService that
also implements ServicesAccessor (so you can ix.get(...) directly) and
owns sinon (so dispose() restores stubs).
Reference: test/message/message.test.ts.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DisposableStore } from '#/_base/di/lifecycle';
import { createServices } from '#/_base/di/test';
import type { TestInstantiationService } from '#/_base/di/test';
import { registerRecordsServices } from '../records/stubs';
describe('XxxService', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
beforeEach(() => {
disposables = new DisposableStore();
ix = createServices(disposables, {
base: [registerRecordsServices],
additionalServices: (reg) => {
// 1. Real collaborator, registered by interface.
reg.define(IContextService, ContextService);
// 2. System under test, registered by interface.
reg.define(IXxxService, XxxService);
},
});
});
afterEach(() => disposables.dispose());
it('does the thing', () => {
// 3. Resolve by interface.
const svc = ix.get(IXxxService);
expect(svc.thing()).toBe('…');
});
});
createServices builds the container from domain service groups plus
per-test overrides (see Service groups). Reach for
ix.stub(...) / ix.set(...) directly only inside an it when a single test
needs to swap a registration (for example, to inject a spy or a second
instance). Stubbing:
- whole service, partial object:
ix.stub(IId, { method() { return … } }); - single method:
ix.stub(IId, 'method', value)returns a sinon stub;ix.spy(IId, 'method')returns a spy; - a prebuilt instance or descriptor:
ix.set(IId, instance)/ix.set(IId, new SyncDescriptor(Impl)); - when a collaborator's behavior must vary per test, model it as a
Test*Servicesubclass whose methods read suite-scopedletvariables (theconfigurationValue/updateArgspattern) rather than rebuilding the container each test.
Scope harness — createScopedTestHost
Reach for this only when which layer a service lives in is itself the thing
being asserted, or when the SUT reads from parent/child scopes. It builds the
real Scope tree and resolves through it.
Reference:
test/environment/environmentService.test.ts.
import { beforeEach, describe, expect, it } from 'vitest';
import {
LifecycleScope,
_clearScopedRegistryForTests,
registerScopedService,
ScopeActivation,
} from '#/_base/di/scope';
import { createScopedTestHost, stubPair } from '#/_base/di/test';
describe('XxxService (scoped)', () => {
beforeEach(() => {
_clearScopedRegistryForTests();
registerScopedService(
LifecycleScope.Agent,
IXxxService,
XxxService,
ScopeActivation.OnDemand,
'xxx',
);
});
it('resolves from the Agent scope with ancestor deps injected', () => {
const host = createScopedTestHost([stubPair(ILogService, stubLog())]);
const agent = host.child(LifecycleScope.Agent, 'main');
const svc = agent.accessor.get(IXxxService); // by interface
expect(svc.thing()).toBe('…');
host.dispose();
});
});
Always _clearScopedRegistryForTests() and re-register explicitly in
beforeEach. Do not rely on a production module's top-level
registerScopedService(...) side-effect: import order then becomes part of the
test, and another suite's _clearScopedRegistryForTests() can wipe it.
The scoped registration signature is
registerScopedService(scope, id, ctor, activation = ScopeActivation.OnScopeCreated, domain?).
The fourth argument is activation and the fifth is domain.
ScopeActivation.OnScopeCreated is 0 and constructs the real instance during
scope creation; it is the default. ScopeActivation.OnDemand is 1 and
constructs the real instance on the first get().
Register the SUT by interface
Whichever harness you use, the SUT is registered under its interface
(ix.set(IX, new SyncDescriptor(Impl)) or registerScopedService(scope, IX, Impl, …))
and resolved by that interface. This is non-negotiable: it is the only thing
that keeps the production registration honest.
A test that does ix.createInstance(Impl) is testing the class, not the
service. Convert those (see Migration).
Shared stubs
Hand-rolled stubs (noopLog, noneEvent, unusedRecords, …) must not be
copied between test files. Each domain that owns a frequently-stubbed
interface exports a stub from a stubs.ts in the test/ tree, never from
src/:
test/log/stubs.ts → stubLog() / stubLogger()
test/turn/stubs.ts → stubTurn()
test/records/stubs.ts → stubAgentRecords()
test/environment/stubs.ts → stubEnvironment()
Reference: test/records/stubs.ts.
All test support lives under the test/ tree so test-only code stays out of
the production source tree. Because tsdown builds from src/index.ts,
anything under test/ is unreachable from the entry and is never bundled into
dist/.
Conventions:
- export a factory (
stubXxx()), not a shared singleton, so tests cannot leak state through a stub; - name it
stub<Interface>— e.g.stubAgentRecords; - the stub satisfies the full interface so the compiler, not a cast, guarantees it stays in sync;
- import it with a relative path —
./stubsfrom the same domain's tests,../<domain>/stubsfrom another domain. Never import stubs from#/…(that alias is for productionsrc/) and never import one test file from another; - a
stubs.tsmay import its domain's production types via#/<domain>/….
If a stub is needed by two test files, it belongs in that domain's
test/<domain>/stubs.ts.
Service groups
Most unit tests stub the same handful of collaborators (ILogService,
IAgentRecords, IConfigService, ITelemetryService, …). Rather than repeat
ix.stub(...) lines in every beforeEach, each domain exports a
register*Services function from its stubs.ts that registers the default test
doubles for that domain:
// test/log/stubs.ts
export function registerLogServices(reg: ServiceRegistration): void {
reg.defineInstance(ILogService, stubLog());
}
createServices(disposables, { base, additionalServices }) composes them:
base— an ordered list of service groups. Each group's registrations are deduped (first writer wins), so groups supply safe defaults without clobbering each other.additionalServices— applied afterbase. Registrations here overwrite any base default, so a test can swap a stub for a spy, register the system under test, or supply a one-off collaborator.
ix = createServices(disposables, {
base: [registerLogServices, registerConfigServices, registerRecordsServices],
additionalServices: (reg) => {
reg.definePartialInstance(IAgentKaos, {}); // one-off collaborator
reg.define(IAgentRecords, spyRecords); // override a base default
reg.define(IXxxService, XxxService); // system under test
},
});
ServiceRegistration offers three verbs:
define(id, Ctor)— descriptor-backed registration; the real service is instantiated on first resolve. Use for real collaborators and the system under test.defineInstance(id, instance)— a fully-built instance (a fake such asstubLog(), ornew ConfigRegistry()).definePartialInstance(id, { ... })— a partial mock; only the supplied members are provided. Use for collaborators the test does not exercise.
Conventions:
- a group registers the domain's services as dependencies (a fake, or a
{}partial when no fake exists yet). When a service is the system under test, the test registers the real implementation viaadditionalServicesand does not rely on the group's default for it; - keep groups small and domain-local. A service that is almost always the
system under test, or that every consumer configures differently, should not
have a group — register it inline via
additionalServices; - import groups with a relative path (
../<domain>/stubs), never from#/….
createServices defaults to strict: false (missing dependencies warn rather
than throw), matching new TestInstantiationService(). Pass strict: true to
surface unregistered @IService dependencies.
Declaring dependencies
Always use @IService constructor decorators — in fixtures and in production
services alike.
// ✅
class Consumer {
constructor(@IGreeter private readonly greeter: IGreeter) {}
}
// ❌ no param() helper, no inline cast
class Consumer {
constructor(private readonly greeter: IGreeter) {}
}
param(IGreeter, Consumer, 0);
This holds for cycle tests too. Declare the loop with real constructor
dependencies (ServiceLoop1(@IService2) ↔ ServiceLoop2(@IService1)); do not
capture accessor inside a constructor and call .get(peer) to force an edge.
Because the decorator runs when the class is defined, the createDecorator
identifier must be initialized before the class that uses it. Declare the
identifier, then the class:
const IDep = createDecorator<IDep>('dep');
class Consumer {
constructor(@IDep private readonly dep: IDep) {}
}
For two services that depend on each other (a cycle), declare both identifiers first, then both classes, so neither class references an uninitialized binding.
Declare fixtures at module top, interface + decorator + implementation
co-located, and keep _serviceBrand on the interface when it represents a
real service — GetLeadingNonServiceArgs relies on the brand to tell service
parameters apart from static ones:
const IGreeter = createDecorator<IGreeter>('greeter');
interface IGreeter {
readonly _serviceBrand: undefined;
greet(): string;
}
class Greeter implements IGreeter {
declare readonly _serviceBrand: undefined;
greet(): string { return 'hi'; }
}
Pure throwaway fixtures may omit _serviceBrand.
Lifecycle / teardown
One DisposableStore per suite. Add the container and any event
subscriptions to it; dispose in afterEach.
beforeEach(() => { disposables = new DisposableStore(); /* … */ });
afterEach(() => disposables.dispose());
Do not add the system-under-test itself to the store.
TestInstantiationService disposes every service it creates when the container
is disposed, so ix.get(IX) instances are cleaned up automatically via
disposables.add(ix). Wrapping the SUT in disposables.add(...) would
double-dispose it. For the same reason, do not call svc.dispose() at the end
of a test unless you are asserting something about disposal itself.
Scope-host tests call host.dispose() in afterEach (or at the end of the
it). Do not scatter bare ix.dispose() / core.dispose() calls through test
bodies — route teardown through the store so ordering is deterministic and
nothing leaks when a test fails mid-way.
Assertions and naming
- One behavior per
it; describe observable behavior (child shadows parent registration), not implementation (calls _getOrCreateServiceInstance). - For cycles, assert
CyclicDependencyErrorand itspatharray (e.g.['A', 'B', 'A']), not merelytoThrow. - For disposal order, capture events in an array and assert the sequence
(
['C', 'B', 'A']— children before parents).
Migrating existing tests
Most legacy tests build the SUT with ix.createInstance(Impl). Converting one
is mechanical:
- import the interface (
IX) and the descriptor; - register the SUT by interface —
reg.define(IX, Impl)insideadditionalServices(orix.set(IX, new SyncDescriptor(Impl))); - replace
ix.createInstance(Impl)withix.get(IX); - drop the
disposables.add(...)wrapper around the SUT and any trailingsvc.dispose()— the container disposes it; - replace any hand-rolled collaborator object with the domain's shared stub
or service group (or add one to
test/<domain>/stubs.tsif it does not exist); - delete now-unused imports.
Before / after:
// before
const svc = ix.createInstance(MessageService);
// after — registration in beforeEach additionalServices
reg.define(IMessageService, MessageService);
// after — resolution in the test body
const svc = ix.get(IMessageService);