diff --git a/.gitignore b/.gitignore
index 8d331c7ca..b02fd5c7e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,3 +24,4 @@ docker-compose.yml
docs/superpowers/
reports/
.superpowers/
+plan/
diff --git a/packages/agent-core-v2/AGENTS.md b/packages/agent-core-v2/AGENTS.md
index a0adaaf0b..a44f90464 100644
--- a/packages/agent-core-v2/AGENTS.md
+++ b/packages/agent-core-v2/AGENTS.md
@@ -37,6 +37,9 @@ Barrel (`src/session/index.ts`):
Per-domain references live in `docs/`.
+- [`docs/di.md`](docs/di.md) — Read **before adding any business capability**: a scenario-driven walkthrough of the DI × Scope black box, from "add a global service" through dependency injection, scope selection, disposal, delayed/eager instantiation, `invokeFunction`, `createInstance`, child scopes, and cycles — introducing each concept only as the scenario needs it.
+- [`docs/service-design.md`](docs/service-design.md) — Read **before designing a new Service**: first-principles rules for choosing a scope, splitting a domain Multi-Scope, picking a calling style (direct call vs event vs hook), and directing dependencies — the design companion to `docs/di.md`.
- [`docs/flag.md`](docs/flag.md) — Read **before gating behavior behind a feature flag**: defining/registering a flag in `FLAG_DEFINITIONS`, checking `IFlagService.enabled(id)`, wiring the `[experimental]` config section, or deciding whether a flag is Core-scope vs. per-session.
- [`docs/errors.md`](docs/errors.md) — Read **before raising errors from a domain**: defining a co-located `XxxError`, registering a code in `ErrorCodes`/`ERROR_INFO`, translating external errors (provider/HTTP, fs, MCP) at the boundary, or (de)serializing errors across RPC/SDK with `toErrorPayload`/`fromErrorPayload`.
- [`docs/di-testing.md`](docs/di-testing.md) — Read **before writing or touching any DI/Scope test**: picking the right harness (`InstantiationService` vs `TestInstantiationService` vs `createScopedTestHost`), declaring deps with `@IService`, stubbing collaborators, and teardown via `DisposableStore`.
+- [`docs/di-scope-domains.puml`](docs/di-scope-domains.puml) — DI Scope × Domain dependency map (node color = `LifecycleScope`; solid edges = constructor DI injection, dashed edges = `wireRecord` / event-driven). **When adding a Service or changing the dependency relationships between Services, update this puml and regenerate `docs/di-scope-domains.svg`**.
diff --git a/packages/agent-core-v2/docs/di-scope-domains.puml b/packages/agent-core-v2/docs/di-scope-domains.puml
new file mode 100644
index 000000000..6303528a9
--- /dev/null
+++ b/packages/agent-core-v2/docs/di-scope-domains.puml
@@ -0,0 +1,310 @@
+@startuml agent-core-v2-domains
+top to bottom direction
+skinparam shadowing false
+skinparam nodesep 28
+skinparam ranksep 55
+
+legend right
+ Scope = node color
+ Core (process-wide, L0)
+ Session (per session, L1)
+ Agent (per agent, L2)
+ Turn (per turn, L3)
+ Multi-scope (spans layers)
+ WIP: contract only
+ == Edges ==
+ solid : DI injection (ctor @IX)
+ dashed : event-driven (subscribe/emit)
+ direction: consumer ---> provider
+endlegend
+
+package "Core scope (process-wide)" #EAF3FB {
+ rectangle "environment\nCore\n IEnvironmentService" as environment #D6EAF8
+ rectangle "log\nCore\n ILogService\n ILogSink" as log #D6EAF8
+ rectangle "telemetry\nCore\n ITelemetryService" as telemetry #D6EAF8
+ rectangle "event\nCore\n IEventService" as event #D6EAF8
+ rectangle "gateway\nCore\n IScopeRegistry\n IRestGateway\n IWSGateway\n IWSBroadcastService" as gateway #D6EAF8
+ rectangle "workspace\nCore\n IWorkspaceRegistry\n IWorkspaceFsService" as workspace #D6EAF8
+ rectangle "filestore\nCore\n IFileStore" as filestore #D6EAF8
+ rectangle "auth\nCore\n IOAuthService\n IAuthSummaryService" as auth #D6EAF8
+ rectangle "flag\nCore\n IFlagService" as flag #D6EAF8
+}
+
+package "Session scope (per session)" #EAFAF1 {
+ rectangle "session\nSession\n ISessionService" as session #D5F5E3
+ rectangle "fs\nSession\n IFsService\n IFsSearchService\n IFsGitService\n IFsWatcher" as fs #D5F5E3
+ rectangle "approval\nSession\n IApprovalService" as approval #D5F5E3
+ rectangle "question\nSession\n IQuestionService" as question #D5F5E3
+ rectangle "session-activity\nSession\n ISessionActivity" as session_activity #D5F5E3
+ rectangle "subagentHost\nSession\n ISubagentHost" as subagentHost #D5F5E3
+ rectangle "terminal\nSession\n ITerminalService" as terminal #D5F5E3
+ rectangle "agent-lifecycle\nSession\n IAgentLifecycleService" as agent_lifecycle #D5F5E3
+ rectangle "session-context\nSession\n ISessionContext (seed)" as session_context #D5F5E3
+}
+
+package "Agent scope (per agent)" #FDF5E6 {
+ rectangle "permissionMode\nAgent\n IPermissionModeService" as permissionMode #FDEBD0
+ rectangle "prompt\nAgent\n IPromptService" as prompt #FDEBD0
+ rectangle "userTool\nAgent\n IUserToolService" as userTool #FDEBD0
+ rectangle "llmRequestLog\nAgent\n ILLMRequestLogService" as llmRequestLog #FDEBD0
+ rectangle "dynamicInjector\nAgent\n IDynamicInjector" as dynamicInjector #FDEBD0
+ rectangle "replayBuilder\nAgent\n IReplayBuilderService" as replayBuilder #FDEBD0
+ rectangle "profile\nAgent\n IProfileService" as profile #FDEBD0
+ rectangle "rpc\nAgent\n IAgentRPCService" as rpc #FDEBD0
+ rectangle "contextMemory\nAgent\n IContextMemory" as contextMemory #FDEBD0
+ rectangle "toolExecutor\nAgent\n IToolExecutor" as toolExecutor #FDEBD0
+ rectangle "permission\nAgent\n IPermissionService" as permission #FDEBD0
+ rectangle "cron\nAgent\n ICronService" as cron #FDEBD0
+ rectangle "llmRequester\nAgent\n ILLMRequester" as llmRequester #FDEBD0
+ rectangle "fullCompaction\nAgent\n IFullCompaction" as fullCompaction #FDEBD0
+ rectangle "permissionRules\nAgent\n IPermissionRulesService" as permissionRules #FDEBD0
+ rectangle "skill\nAgent\n IAgentSkillService" as skill #FDEBD0
+ rectangle "goal\nAgent\n IGoalService" as goal #FDEBD0
+ rectangle "microCompaction\nAgent\n IMicroCompactionService" as microCompaction #FDEBD0
+ rectangle "externalHooks\nAgent\n IExternalHooksService" as externalHooks #FDEBD0
+ rectangle "swarm\nAgent\n ISwarmService" as swarm #FDEBD0
+ rectangle "background\nAgent\n IBackgroundService" as background #FDEBD0
+ rectangle "toolStore\nAgent\n IToolStoreService" as toolStore #FDEBD0
+ rectangle "wireRecord\nAgent\n IWireRecord (event hub)" as wireRecord #FDEBD0
+ rectangle "eventBus\nAgent\n IEventBus" as eventBus #FDEBD0
+ rectangle "toolRegistry\nAgent\n IToolRegistry" as toolRegistry #FDEBD0
+ rectangle "usage\nAgent\n IUsageService" as usage #FDEBD0
+ rectangle "message\nAgent\n IMessageService" as message #FDEBD0
+ rectangle "mcp\nAgent\n IMcpService" as mcp #FDEBD0
+ rectangle "contextSize\nAgent\n IContextSizeService" as contextSize #FDEBD0
+ rectangle "permissionPolicy\nAgent\n IPermissionPolicyService" as permissionPolicy #FDEBD0
+ rectangle "contextProjector\nAgent\n IContextProjector" as contextProjector #FDEBD0
+ rectangle "todoList\nAgent\n ITodoListService" as todoList #FDEBD0
+ rectangle "loop\nAgent\n ILoopService" as loop #FDEBD0
+ rectangle "turn\nAgent\n ITurnService\n ITurnEvents\n ILoopRunner(T)\n IToolCallExecutor(T)" as turn #FDEBD0
+ rectangle "plan\nAgent\n IPlanService" as plan #FDEBD0
+ rectangle "blobStore\nAgent\n IBlobStoreService" as blobStore #FDEBD0
+}
+
+package "Turn scope (per turn)" #FDEDEC {
+ rectangle "tooldedup\nTurn\n IToolDedupService" as tooldedup #FADBD8
+}
+
+package "Multi-scope" #F5EEF8 {
+ rectangle "config\nMulti\n IConfigRegistry(C)\n IConfigService(C)\n IAgentConfigService(A)" as config #E8DAEF
+ rectangle "kosong\nMulti\n IModelCatalogService(C)\n IProviderManager(S)\n ILLMService(A)" as kosong #E8DAEF
+ rectangle "records\nMulti\n ISessionStore(C)\n ISessionMetaStore(S)\n IAgentRecords(A)" as records #E8DAEF
+ rectangle "tool\nMulti\n IToolDefinitionRegistry(C)\n IToolService(A)" as tool #E8DAEF
+ rectangle "kaos\nMulti\n IKaosFactory(C)\n ISessionKaosService(S)\n IKaosService / IAgentKaos(A)" as kaos #E8DAEF
+}
+
+package "WIP (contract only)" #FBFCFC {
+ rectangle "context\nWIP\n IContextService (WIP)" as context #F4F6F7
+ rectangle "injection\nWIP\n IInjectionService (WIP)" as injection #F4F6F7
+ rectangle "turnRunner\nWIP\n ITurnRunner (WIP)" as turnRunner #F4F6F7
+}
+
+' ---- DI injection (solid) ----
+gateway --> event #34495E
+workspace --> kaos #34495E
+workspace --> log #34495E
+filestore --> kaos #34495E
+auth --> config #34495E
+auth --> environment #34495E
+auth --> telemetry #34495E
+flag --> config #34495E
+config --> environment #34495E
+config --> log #34495E
+config --> records #34495E
+config --> kaos #34495E
+kosong --> config #34495E
+kosong --> environment #34495E
+records --> kaos #34495E
+records --> log #34495E
+tool --> config #34495E
+tool --> records #34495E
+tool --> kaos #34495E
+tool --> permission #34495E
+tool --> kosong #34495E
+kaos --> environment #34495E
+kaos --> log #34495E
+session --> records #34495E
+session --> agent_lifecycle #34495E
+session --> session_activity #34495E
+session --> event #34495E
+fs --> kaos #34495E
+fs --> log #34495E
+session_activity --> agent_lifecycle #34495E
+terminal --> log #34495E
+terminal --> kaos #34495E
+agent_lifecycle --> session_context #34495E
+agent_lifecycle --> records #34495E
+permissionMode --> wireRecord #34495E
+permissionMode --> eventBus #34495E
+permissionMode --> replayBuilder #34495E
+permissionMode --> dynamicInjector #34495E
+prompt --> contextMemory #34495E
+prompt --> turn #34495E
+prompt --> wireRecord #34495E
+prompt --> eventBus #34495E
+userTool --> toolRegistry #34495E
+userTool --> profile #34495E
+userTool --> wireRecord #34495E
+llmRequestLog --> log #34495E
+dynamicInjector --> contextMemory #34495E
+dynamicInjector --> turn #34495E
+replayBuilder --> wireRecord #34495E
+profile --> wireRecord #34495E
+profile --> eventBus #34495E
+profile --> replayBuilder #34495E
+profile --> telemetry #34495E
+rpc --> prompt #34495E
+rpc --> turn #34495E
+rpc --> profile #34495E
+rpc --> permissionMode #34495E
+rpc --> permission #34495E
+rpc --> plan #34495E
+rpc --> swarm #34495E
+rpc --> fullCompaction #34495E
+rpc --> userTool #34495E
+rpc --> toolRegistry #34495E
+rpc --> background #34495E
+rpc --> contextMemory #34495E
+rpc --> contextSize #34495E
+rpc --> skill #34495E
+rpc --> subagentHost #34495E
+rpc --> usage #34495E
+rpc --> telemetry #34495E
+rpc --> goal #34495E
+contextMemory --> wireRecord #34495E
+contextMemory --> replayBuilder #34495E
+permission --> permissionMode #34495E
+permission --> permissionRules #34495E
+permission --> permissionPolicy #34495E
+permission --> externalHooks #34495E
+permission --> telemetry #34495E
+cron --> prompt #34495E
+cron --> eventBus #34495E
+cron --> wireRecord #34495E
+cron --> turnRunner #34495E
+cron --> telemetry #34495E
+cron --> toolRegistry #34495E
+llmRequester --> contextMemory #34495E
+llmRequester --> contextProjector #34495E
+llmRequester --> toolRegistry #34495E
+llmRequester --> profile #34495E
+llmRequester --> llmRequestLog #34495E
+fullCompaction --> contextMemory #34495E
+fullCompaction --> contextProjector #34495E
+fullCompaction --> contextSize #34495E
+fullCompaction --> llmRequester #34495E
+fullCompaction --> profile #34495E
+fullCompaction --> toolStore #34495E
+fullCompaction --> telemetry #34495E
+fullCompaction --> usage #34495E
+fullCompaction --> wireRecord #34495E
+fullCompaction --> eventBus #34495E
+fullCompaction --> replayBuilder #34495E
+fullCompaction --> externalHooks #34495E
+fullCompaction --> turnRunner #34495E
+permissionRules --> wireRecord #34495E
+permissionRules --> replayBuilder #34495E
+skill --> prompt #34495E
+skill --> eventBus #34495E
+skill --> wireRecord #34495E
+skill --> telemetry #34495E
+goal --> wireRecord #34495E
+goal --> eventBus #34495E
+goal --> contextMemory #34495E
+goal --> replayBuilder #34495E
+goal --> telemetry #34495E
+goal --> dynamicInjector #34495E
+microCompaction --> contextMemory #34495E
+microCompaction --> contextSize #34495E
+microCompaction --> wireRecord #34495E
+microCompaction --> profile #34495E
+microCompaction --> telemetry #34495E
+microCompaction --> turn #34495E
+swarm --> contextMemory #34495E
+swarm --> wireRecord #34495E
+swarm --> eventBus #34495E
+swarm --> turn #34495E
+swarm --> toolRegistry #34495E
+swarm --> subagentHost #34495E
+background --> eventBus #34495E
+background --> wireRecord #34495E
+background --> telemetry #34495E
+background --> prompt #34495E
+background --> externalHooks #34495E
+background --> contextMemory #34495E
+toolStore --> wireRecord #34495E
+wireRecord --> blobStore #34495E
+eventBus --> wireRecord #34495E
+usage --> wireRecord #34495E
+usage --> eventBus #34495E
+message --> context #34495E
+mcp --> toolRegistry #34495E
+mcp --> eventBus #34495E
+contextSize --> contextMemory #34495E
+contextSize --> eventBus #34495E
+contextSize --> profile #34495E
+contextSize --> wireRecord #34495E
+todoList --> contextMemory #34495E
+todoList --> profile #34495E
+todoList --> toolStore #34495E
+todoList --> toolRegistry #34495E
+todoList --> dynamicInjector #34495E
+loop --> contextMemory #34495E
+loop --> contextProjector #34495E
+loop --> contextSize #34495E
+loop --> llmRequester #34495E
+loop --> eventBus #34495E
+loop --> toolRegistry #34495E
+loop --> toolExecutor #34495E
+loop --> usage #34495E
+loop --> profile #34495E
+loop --> telemetry #34495E
+loop --> wireRecord #34495E
+loop --> mcp #34495E
+loop --> externalHooks #34495E
+turn --> context #34495E
+turn --> kosong #34495E
+turn --> injection #34495E
+turn --> usage #34495E
+turn --> telemetry #34495E
+turn --> log #34495E
+turn --> agent_lifecycle #34495E
+turn --> tool #34495E
+plan --> contextMemory #34495E
+plan --> wireRecord #34495E
+plan --> eventBus #34495E
+plan --> kaos #34495E
+plan --> profile #34495E
+plan --> replayBuilder #34495E
+plan --> toolRegistry #34495E
+plan --> dynamicInjector #34495E
+plan --> telemetry #34495E
+tooldedup --> telemetry #34495E
+
+' ---- event-driven (dashed) ----
+gateway ..> event #16A085 : subscribe
+flag ..> config #16A085 : onDidChange
+agent_lifecycle ..> turn #16A085 : onWillExecuteTool
+permissionMode ..> wireRecord #16A085 : permission.set_mode
+userTool ..> wireRecord #16A085 : tools.register_/unregister_user_tool
+profile ..> wireRecord #16A085 : config.update / tools.set_active_tools
+contextMemory ..> wireRecord #16A085 : context.splice
+cron ..> wireRecord #16A085 : cron.add / delete / cursor
+fullCompaction ..> wireRecord #16A085 : full_compaction.begin/cancel/complete
+permissionRules ..> wireRecord #16A085 : permission.rules.add / record_approval_result
+skill ..> wireRecord #16A085 : skill.activate
+goal ..> wireRecord #16A085 : goal.create/update/clear
+microCompaction ..> wireRecord #16A085 : micro_compaction.apply / full_compaction.complete
+swarm ..> wireRecord #16A085 : swarm_mode.enter/exit
+swarm ..> turn #16A085 : hooks.onEnded
+background ..> wireRecord #16A085 : background.task.started/terminated
+background ..> contextMemory #16A085 : hooks.onSpliced
+toolStore ..> wireRecord #16A085 : tools.update_store
+usage ..> wireRecord #16A085 : usage.record
+contextSize ..> contextMemory #16A085 : hooks.onSpliced
+contextSize ..> wireRecord #16A085 : context_size.measured
+loop ..> contextMemory #16A085 : hooks.onSpliced
+loop ..> wireRecord #16A085 : hooks.onResumeEnded
+plan ..> wireRecord #16A085 : plan_mode.enter/cancel/exit
+
+@enduml
diff --git a/packages/agent-core-v2/docs/di-scope-domains.svg b/packages/agent-core-v2/docs/di-scope-domains.svg
new file mode 100644
index 000000000..2a3d324be
--- /dev/null
+++ b/packages/agent-core-v2/docs/di-scope-domains.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/agent-core-v2/docs/di-testing.md b/packages/agent-core-v2/docs/di-testing.md
index 3dbfdc625..8c28e1829 100644
--- a/packages/agent-core-v2/docs/di-testing.md
+++ b/packages/agent-core-v2/docs/di-testing.md
@@ -65,9 +65,10 @@ Reference: [`test/message/message.test.ts`](../test/message/message.test.ts).
```ts
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
-import { TestInstantiationService } from '#/_base/di/test';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
+import { registerRecordsServices } from '../records/stubs';
describe('XxxService', () => {
let disposables: DisposableStore;
@@ -75,14 +76,15 @@ describe('XxxService', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
-
- // 1. Stub the collaborators, by interface.
- ix.stub(IAgentRecords, stubAgentRecords());
- ix.set(IContextService, new SyncDescriptor(ContextService)); // real dep
-
- // 2. Register the system under test, by interface.
- ix.set(IXxxService, new SyncDescriptor(XxxService));
+ 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());
@@ -94,7 +96,11 @@ describe('XxxService', () => {
});
```
-Stubbing:
+`createServices` builds the container from domain **service groups** plus
+per-test overrides (see [Service groups](#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;
@@ -198,6 +204,66 @@ Conventions:
If a stub is needed by two test files, it belongs in that domain's
`test//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:
+
+```ts
+// 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 after `base`. 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.
+
+```ts
+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)` — lazy `SyncDescriptor`; the 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 as
+ `stubLog()`, or `new 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 via `additionalServices` and 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** (`..//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
@@ -291,12 +357,14 @@ Most legacy tests build the SUT with `ix.createInstance(Impl)`. Converting one
is mechanical:
1. import the interface (`IX`) and the descriptor;
-2. add `ix.set(IX, new SyncDescriptor(Impl))` to `beforeEach`;
+2. register the SUT by interface — `reg.define(IX, Impl)` inside
+ `additionalServices` (or `ix.set(IX, new SyncDescriptor(Impl))`);
3. replace `ix.createInstance(Impl)` with `ix.get(IX)`;
4. drop the `disposables.add(...)` wrapper around the SUT and any trailing
`svc.dispose()` — the container disposes it;
5. replace any hand-rolled collaborator object with the domain's shared stub
- (or add one to `test//stubs.ts` if it does not exist);
+ or service group (or add one to `test//stubs.ts` if it does not
+ exist);
6. delete now-unused imports.
Before / after:
@@ -305,7 +373,8 @@ Before / after:
// before
const svc = ix.createInstance(MessageService);
-// after
-ix.set(IMessageService, new SyncDescriptor(MessageService)); // in beforeEach
+// after — registration in beforeEach additionalServices
+reg.define(IMessageService, MessageService);
+// after — resolution in the test body
const svc = ix.get(IMessageService);
```
diff --git a/packages/agent-core-v2/docs/di.md b/packages/agent-core-v2/docs/di.md
new file mode 100644
index 000000000..0cf928deb
--- /dev/null
+++ b/packages/agent-core-v2/docs/di.md
@@ -0,0 +1,391 @@
+# DI(依赖注入)与 Scope — 场景化指南
+
+> 本文按「给 agent-core-v2 加业务功能」会遇到的场景,从最简单到最复杂,逐个引入 DI 的概念。
+> 源码位于 [`src/_base/di/`](../src/_base/di/);测试约定见 [`docs/di-testing.md`](di-testing.md)。
+
+---
+
+## 0. 先把 DI 当成黑盒子
+
+写业务代码时,你只需要向这个黑盒子声明三件事:
+
+- **我是谁** —— 一个能当 key 又能当类型的「身份」。
+- **我需要谁** —— 我的依赖由谁提供。
+- **我活多久** —— 我属于哪一层生命周期。
+
+剩下的事(何时创建、是不是同一份、谁先谁后、何时销毁)都由容器负责。类只跟接口打交道,从不关心实现怎么 new。
+
+下面每个场景只引入它所需要的那一块 DI。跟着场景走,概念会逐步叠加。
+
+---
+
+## 场景 1:加一个全局服务(不依赖任何人)
+
+> 你要做的:进程级只有一个、谁都能用的基础能力,比如日志、遥测。参考 [`log`](../src/log/log.ts)。
+
+这一步引入四块:**接口 / 身份 / 实现 / 注册**。
+
+### 1.1 写接口,带上 `_serviceBrand`
+
+```ts
+// greet/greet.ts
+import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
+
+export interface IGreeter {
+ readonly _serviceBrand: undefined; // 类型记号:告诉 DI「这是一个服务」
+ hello(): string;
+}
+
+export const IGreeter: ServiceIdentifier = createDecorator('greeter');
+```
+
+`createDecorator(name)` 造出的 `ServiceIdentifier` 一身二任:运行时是 key 和参数装饰器,编译时携带 `IGreeter` 类型。
+
+> ⚠️ **约束:身份名字全局唯一。** `createDecorator` 按 `name` 缓存,同名返回同一个身份。两个域用了同一个字符串就会碰撞、共享一个身份。
+
+### 1.2 写实现类
+
+```ts
+// greet/greetService.ts
+import { InstantiationType } from '#/_base/di/extensions';
+import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
+import { IGreeter } from './greet';
+
+export class Greeter implements IGreeter {
+ declare readonly _serviceBrand: undefined; // 与接口的 _serviceBrand 对应
+ hello(): string { return 'hi'; }
+}
+```
+
+实现类用 `declare readonly _serviceBrand: undefined;` 对应接口上的类型记号。
+
+### 1.3 注册到一层生命周期
+
+```ts
+// greet/greetService.ts(文件顶层,import 时执行)
+registerScopedService(
+ LifecycleScope.Core, // 活多久:进程级
+ IGreeter, // 身份
+ Greeter, // 实现
+ InstantiationType.Eager, // 创建时机:立刻
+ 'greet', // 域名(用于排错)
+);
+```
+
+绑定在哪一层是这个类的**固有属性**,在注册点决定,不在调用点决定。
+
+### 1.4 通过 barrel 导出,让注册生效
+
+```ts
+// greet/index.ts
+export * from './greet';
+export * from './greetService'; // import 这一行即触发上面的 registerScopedService
+```
+
+再在包入口 [`src/index.ts`](../src/index.ts) 加一行:
+
+```ts
+export * from './greet/index';
+```
+
+于是「import 这个包」=「加载全部注册」。**没有中心装配文件**:绑定散落在各自域的实现文件里,靠 import 副作用收集。
+
+至此,任何人都能 `accessor.get(IGreeter)` 拿到这个全局唯一的服务。
+
+---
+
+## 场景 2:你的服务要用别人的服务
+
+> 你要做的:你的服务需要调用别的域的能力。参考 [`sessionService.ts`](../src/session/sessionService.ts)。
+
+这一步引入:**构造器注入** 与 **按接口解析**。
+
+### 2.1 用 `@IX` 在构造器上声明依赖
+
+```ts
+export class SessionService implements ISessionService {
+ declare readonly _serviceBrand: undefined;
+
+ constructor(
+ @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService,
+ @IEventService private readonly event: IEventService,
+ ) {}
+}
+```
+
+`@IAgentLifecycleService` 只做一件事:把「第 0 个参数需要 `IAgentLifecycleService`」记到类的元数据上。容器 new 这个类时读元数据,把依赖填好。
+
+### 2.2 三条不可破的约束
+
+1. **不要 `new` 带 `@IService` 依赖的类。** `new` 会绕过容器:绕过注册、绕过 scope、绕过单例缓存。要用就 `@IX` 注入,或 `accessor.get(IX)`。
+2. **`@IX` 只能装饰构造器参数。** 装饰到字段/方法上会在运行时抛错。
+3. **服务参数排在静态参数之后**(静态参数见场景 7)。
+
+### 2.3 消费方按接口取,看不到实现
+
+```ts
+const session = accessor.get(ISessionService); // 类型是 ISessionService
+```
+
+消费方只 import **接口** 和 **`IX` 身份**,从不 import 实现类。这是 DI 把「接口 → 实现」的替换权完全握在容器手里的关键。
+
+> 如果你需要的不是「一个服务」而是「一份配置」,通常做法是把它也做成一个服务注入进来(如 `IConfigService`);如果是「每轮一个、带参数的非单例对象」,见场景 7。
+
+---
+
+## 场景 3:你的服务不是全局一份
+
+> 你要做的:每个会话一份、每个 agent 一份、或每轮对话一份。参考 [`turn`](../src/turn/turn.ts)、[`session`](../src/session/session.ts)。
+
+这一步引入:**`LifecycleScope` 四层生命周期** 与 **父子 scope 的可见性**。
+
+### 3.1 四层,按寿命从长到短
+
+```ts
+export enum LifecycleScope {
+ Core = 0, // 进程级,全局一份
+ Session = 1, // 一次会话
+ Agent = 2, // 一个 agent
+ Turn = 3, // 一轮对话
+}
+```
+
+数值越大,寿命越短、越靠叶子。注册时把 `scope` 换成对应层即可:
+
+```ts
+registerScopedService(LifecycleScope.Session, ISessionService, SessionService, InstantiationType.Delayed, 'session');
+```
+
+「单例」的粒度是**每个 scope 一份**:Core 的 `ILogService` 全局只有一份;每个 Session scope 各有自己的 `ISessionService`。
+
+### 3.2 子 scope 看得见父 scope,反之不行
+
+Scope 是一棵树,`kind` 必须沿父子方向**严格递增**:
+
+```
+Core (0)
+ └── Session (1)
+ └── Agent (2)
+ └── Turn (3)
+```
+
+解析服务时,容器先看自己这一层,没有就**递归问父 scope**。所以一条铁律:
+
+> **短寿命的服务可以注入长寿命的服务,反过来不行。**
+
+- ✅ Turn 服务注入 Session / Core 服务(往上找,找得到)。
+- ❌ Core 服务注入 Session 服务(Core 创建时 Session 还不存在,且父不会往下找)。
+
+这条规则由树的结构强制保证,不靠纪律维持。
+
+---
+
+## 场景 4:你的服务要释放资源
+
+> 你要做的:服务里订阅了事件、开了定时器、持有了句柄,scope 销毁时要释放。参考 `WSBroadcastService`([`gatewayService.ts`](../src/gateway/gatewayService.ts))。
+
+这一步引入:**`Disposable` / `IDisposable` 生命周期**。
+
+```ts
+import { Disposable } from '#/_base/di/lifecycle';
+
+export class WSBroadcastService extends Disposable implements IWSBroadcastService {
+ declare readonly _serviceBrand: undefined;
+
+ constructor(@IEventService event: IEventService) {
+ super();
+ this._register(event.subscribe(() => { /* … */ })); // 收集子资源
+ }
+}
+```
+
+- 继承 `Disposable`,用 `this._register(d)` 收集任何 `IDisposable`(事件订阅、`toDisposable(fn)` 等)。
+- 容器在销毁这个服务时会自动调它的 `dispose()`,它注册过的子资源随之释放。
+
+销毁顺序是确定的(见场景 3 的树):**子 scope 先死,同 scope 内按构造逆序释放**(后 new 的先释放)。业务代码只声明「我活在哪一层」,从不手动释放。
+
+---
+
+## 场景 5:你的服务很重,想延迟初始化
+
+> 你要做的:服务依赖多、创建贵,不想在 scope 创建时就 new。
+
+这一步引入:**`InstantiationType.Eager` vs `Delayed`**。
+
+```ts
+// Eager:scope 创建时立刻 new
+registerScopedService(LifecycleScope.Core, ILogService, LogService, InstantiationType.Eager, 'log');
+
+// Delayed:第一次被 get 时才 new
+registerScopedService(LifecycleScope.Core, IScopeRegistry, ScopeRegistry, InstantiationType.Delayed, 'gateway');
+```
+
+Delayed 服务返回的是一个 **Proxy**:在首次访问任意属性时才真正构造。即便还没构造好,别人提前订阅它的 `onDid…` / `onWill…` 事件也不会丢——容器会先记下监听器,实例真正出来后再回放订阅。
+
+> 经验:无依赖、被频繁使用、或有「尽早初始化副作用」的服务用 `Eager`(如 `ILogService`);其余默认 `Delayed`。
+
+---
+
+## 场景 6:在普通函数里临时用服务
+
+> 你要做的:你不想写一个新类,只是在一个函数里临时拿一个服务用一下。或你要给外部提供一个 `ServicesAccessor`。参考 [`gatewayService.ts`](../src/gateway/gatewayService.ts)。
+
+这一步引入:**`IInstantiationService.invokeFunction`** 与 **`ServicesAccessor`**。
+
+```ts
+const accessor: ServicesAccessor = {
+ get: (id: ServiceIdentifier): T => instantiation.invokeFunction((a) => a.get(id)),
+};
+```
+
+`invokeFunction(fn)` 会给 `fn` 一个**只在这次调用期间有效**的 `ServicesAccessor`。
+
+> ⚠️ **约束:accessor 只在调用期间有效。** `invokeFunction` 返回后再 `accessor.get()` 会抛 `"service accessor is only valid during the invocation"`。不要把 accessor 存起来异步用——要长期持有服务,就在构造器里注入(场景 2)。
+
+---
+
+## 场景 7:创建带依赖、但不是单例的对象
+
+> 你要做的:每轮对话都要 new 一个新对象,但它也有 `@IService` 依赖。比如一个 per-turn 的执行器。
+
+这一步引入:**`IInstantiationService.createInstance`** 与 **静态参数**。
+
+```ts
+class TurnRunner {
+ constructor(
+ private readonly input: string, // 静态参数:调用时传
+ private readonly turn: number, // 静态参数:调用时传
+ @ILogService private readonly log: ILogService, // 服务参数:容器注入
+ ) {}
+}
+
+// 调用时:静态参数你传,服务参数容器填
+const runner = instantiation.createInstance(TurnRunner, 'hello', 1);
+```
+
+容器把静态参数放前面、服务参数接在后面,再 `Reflect.construct` 出实例。这个对象**不会**被放进任何 scope 的单例缓存——每次都是新实例。
+
+> 这就是「服务参数必须排在静态参数之后」的原因:容器按 `@IX` 记录的参数位置排序后依次注入。`_serviceBrand` 让编译器能在类型上区分这两类参数。
+
+---
+
+## 场景 8:你的服务要派生子容器 / 子 scope
+
+> 你要做的:你的服务负责「拉起一个新会话 / 新 agent」,需要为它造一个子 scope。参考 `ScopeRegistry`([`gatewayService.ts`](../src/gateway/gatewayService.ts))。
+
+这一步引入:**注入 `IInstantiationService` 本身** 与 **`createChild`**。
+
+每个容器都把自己绑定成 `IInstantiationService`,所以你可以像注入别的服务一样注入它:
+
+```ts
+export class ScopeRegistry implements IScopeRegistry {
+ declare readonly _serviceBrand: undefined;
+
+ constructor(@IInstantiationService private readonly instantiation: IInstantiationService) {}
+
+ createSession(opts: CreateSessionOptions): Promise {
+ const collection = new ServiceCollection();
+ for (const entry of getScopedServiceDescriptors(LifecycleScope.Session)) {
+ collection.set(entry.id, entry.descriptor); // 收集 Session 这一层的描述符
+ }
+ const child = this.instantiation.createChild(collection); // 派生子容器
+ const accessor: ServicesAccessor = {
+ get: (id: ServiceIdentifier): T => child.invokeFunction((a) => a.get(id)),
+ };
+ const handle: IScopeHandle = { id: opts.sessionId, kind: LifecycleScope.Session, accessor };
+ this.sessions.set(opts.sessionId, handle);
+ return Promise.resolve(handle);
+ }
+}
+```
+
+关键点:
+
+- `getScopedServiceDescriptors(scope)` 能拿回注册在某一层的所有描述符,装进一个 `ServiceCollection`。
+- `instantiation.createChild(collection)` 造一个子容器,它的父指针指向当前容器——于是子容器能向上解析到 Core 的服务(场景 3 的可见性规则)。
+- 给外部暴露时,用 `invokeFunction` 把子容器包成 `ServicesAccessor`(场景 6)。
+
+> 更高层通常直接用 [`Scope.createChild(kind, id)`](../src/_base/di/scope.ts)(它帮你做了「筛描述符 + 建子容器」);只有需要手动控制 `ServiceCollection` 时才像上面这样写。
+
+---
+
+## 场景 9:撞上循环依赖(不允许,要重构)
+
+> 业务规则:**不允许循环依赖。** 容器会拒绝它;撞上时的正确处理是重构,不是让它跑通。
+
+### 9.1 容器会拒绝同步成环
+
+A 创建中要 B,B 创建中又要 A——容器会抛 `CyclicDependencyError`,`path` 形如 `['A', 'B', 'A']`。自环(A 依赖自己)同样会被拒绝。这不是 bug,是保护机制:它在告诉你「这两个服务的职责划错了」。
+
+### 9.2 为什么不允许
+
+- scope 分层让正常依赖天然是 DAG(Turn → Agent → Session → Core 向上找),一个环几乎总是设计味道。
+- 靠「让环刚好能跑」会把构造顺序变成隐式约定,难调试、难排错。
+
+所以 v2 的立场是:**依赖图必须是无环的。**
+
+### 9.3 撞上时怎么重构
+
+按优先级考虑:
+
+1. **抽出第三个服务 C。** 把 A、B 互相需要的那部分逻辑提到 C,让 A、B 都依赖 C,而不是互相依赖。这是最常见的解。
+2. **用事件解耦。** 如果 A 只是想知道 B 的某个变化,让 B 通过 `IEventService` 发事件、A 订阅,而不是 A 直接持有 B 的引用。
+3. **重新划分 scope。** 也许其中一个本不该在这一层——它其实该更短或更长寿命,移动后环自然消失。
+
+### 9.4 关于 Delayed 破环(遗留逃生舱,禁用)
+
+容器里有一个遗留机制:当环里的某一边注册为 `Delayed`(场景 5)时,Proxy 能让这个「软循环」不同步炸开。**业务上禁止使用它来绕过循环依赖**——它存在是为了兼容历史代码,不是给你的设计兜底的。撞上 `CyclicDependencyError` 时,按 9.3 重构。
+
+---
+
+## 场景 10:给服务写测试
+
+> 你要做的:让测试走和生产一样的路径——按接口解析、依赖由容器注入。
+
+这一步引入:**两个测试 harness**。详见 [`docs/di-testing.md`](di-testing.md),这里只给选择标准:
+
+| 测什么 | 用哪个 harness | 怎么取 SUT |
+|---|---|---|
+| 单个服务的行为(单元) | `TestInstantiationService`(扁平容器) | `ix.set(ISut, new SyncDescriptor(Sut))` 后 `ix.get(ISut)` |
+| 跨 scope 接线 / 服务活在哪一层 | `createScopedTestHost`(scope 树) | `host..accessor.get(ISut)` |
+
+核心规则:**按接口解析被测对象,绝不 `new` 带 `@IService` 依赖的实现类**——否则 `registerScopedService(IX → Impl)` 这条绑定在测试里根本没跑过。
+
+---
+
+## 附录 A:接口速查
+
+| 接口 | 出现场景 | 作用 |
+|---|---|---|
+| `createDecorator(name)` → `ServiceIdentifier` | 1 | 造身份(运行时 key + 编译时类型 + 参数装饰器) |
+| `@IService` | 2, 7 | 在构造器参数上声明依赖 |
+| `registerScopedService(scope, id, ctor, type, domain)` | 1, 3, 5 | 把实现绑定到一层生命周期 |
+| `ServicesAccessor.get(IX)` | 2, 6 | 按接口解析实例 |
+| `IInstantiationService.invokeFunction(fn, …)` | 6, 8 | 在函数里临时拿到 accessor |
+| `IInstantiationService.createInstance(ctor, …args)` | 7 | 创建非单例对象并注入依赖 |
+| `IInstantiationService.createChild(collection)` | 8 | 派生子容器 |
+| `getScopedServiceDescriptors(scope)` | 8 | 取回注册在某一层的所有描述符 |
+| `Disposable` / `DisposableStore` / `IDisposable` | 4 | 资源管理与销毁 |
+| `Scope` / `LifecycleScope` | 3, 8 | 生命周期树 |
+| `SyncDescriptor` | (测试/底层) | 把「构造器 + 静态参数」打包成待 new 描述符 |
+
+> 遗留导出(v2 不用,知道即可):`registerSingleton` / `getSingletonServiceDescriptors` / `refineServiceDecorator` 是 VS Code 遗留的全局单例注册器,v2 的 src/test 零引用,统一走 `registerScopedService`。
+
+## 附录 B:红线汇总
+
+1. 不 `new` 带 `@IService` 依赖的类——用 `@IX` 注入或 `accessor.get(IX)`。
+2. `@IX` 只能装饰构造器参数;服务参数排在静态参数之后。
+3. 接口和实现都带 `_serviceBrand`。
+4. 身份名字全局唯一。
+5. 父 scope 的服务不依赖子 scope 的服务(运行时也解析不到)。
+6. **不写循环依赖**——容器会抛 `CyclicDependencyError`;撞上时按场景 9 重构,不用 Delayed 绕过。
+7. `ServicesAccessor` 只在 `invokeFunction` 调用期间有效,不存起来异步用。
+8. 注册写在实现文件顶层;测试里用 `_clearScopedRegistryForTests()` 后显式重注册,不依赖生产 import 顺序。
+
+## 附录 C:新增一个服务的标准动作
+
+1. **契约**:`src//.ts` 写接口(带 `_serviceBrand`)+ `createDecorator` 身份。
+2. **实现**:`src//Service.ts` 写类,`@IX` 声明依赖,文件顶层 `registerScopedService(scope, IX, Impl, type, '')`。
+3. **barrel**:`src//index.ts` re-export 契约和实现。
+4. **入口**:`src/index.ts` 加一行 `export * from './/index';`。
+5. **测试**:`test//` 用 `TestInstantiationService` 或 `createScopedTestHost`,按接口解析。
diff --git a/packages/agent-core-v2/docs/service-design.md b/packages/agent-core-v2/docs/service-design.md
new file mode 100644
index 000000000..1afe1f36a
--- /dev/null
+++ b/packages/agent-core-v2/docs/service-design.md
@@ -0,0 +1,283 @@
+# Service Design Principles
+
+> First-principles guide for designing a new Service in agent-core-v2: how to pick its
+> **scope**, when to **split it across scopes**, how to **call** other Services, and which
+> direction dependencies should point.
+>
+> This complements [`docs/di.md`](di.md). `di.md` explains the DI/Scope machinery
+> ("how the container works"); this doc explains the **design rules** ("where to put things
+> and why"). Read `di.md` first if you have not.
+
+---
+
+## 1. What a Service is
+
+Before discussing scope or calling style, define the object.
+
+**A Service = a bundle of state + a set of behaviors, bound to a lifetime.**
+
+Of these three:
+
+- **Behavior** is almost *free* — the same logic runs anywhere, so it does not by itself
+ decide a scope.
+- **State** is what pins a Service to a scope. State has an **identity** (what it is keyed
+ by) and a **lifetime** (when it is born, when it dies).
+- **Dependencies / calling style** answer a different question: **who controls whom, and who
+ knows whom**.
+
+Every principle below derives from two root questions:
+
+1. **What is the identity of the state it owns?** → decides the **Scope**.
+2. **Who owns the decision, and who needs the result?** → decides the **calling style** and
+ the **dependency direction**.
+
+---
+
+## 2. Choosing a Scope
+
+**First principle: Scope = the identity + lifetime of the owned state.**
+
+`Core` / `Session` / `Agent` / `Turn` are four tiers of identity + lifetime:
+
+| Scope | State identity (keyed by) | Lifetime |
+|---|---|---|
+| `Core` | none (single global instance) | the process |
+| `Session` | `sessionId` | one session |
+| `Agent` | `agentId` | one agent |
+| `Turn` | `turnId` | one turn |
+
+### Decision tree
+
+**Q1. Does it own mutable state?**
+
+- **No (pure behavior)** → jump to Q3.
+- **Yes** → Q2.
+
+**Q2. What is the identity of that state?**
+
+- one global instance → **`Core`**
+- one per session → **`Session`**
+- one per agent → **`Agent`**
+- one per turn → **`Turn`**
+- a mix (a global registry *and* per-instance state) → **do not put it in one Service;
+ split it** (see §3 Multi-Scope).
+
+**Q3 (stateless). What is the shortest-lived dependency it must inject?**
+
+A stateless Service is pulled *down* by its shortest-lived dependency: if it injects an
+`Agent`-scoped Service, it cannot be `Core`. Among the scopes that still satisfy every
+dependency, **default to the longest-lived one** (usually `Core`) to maximize reuse and
+singleton sharing. Push it down only when:
+
+1. it must inject a shorter-lived Service (enforced by the container); or
+2. you want to limit its visibility (it conceptually belongs to one agent and should not be
+ globally exposed).
+
+### The core anti-pattern (a litmus test)
+
+> **Do not store per-session state in a `Map` inside a `Core` Service.**
+
+This is the tell-tale sign of "this should have been `Session`-scoped but was lazily parked
+at `Core`". Consequences:
+
+- nobody cleans the entry up when the session ends → **leak**;
+- every consumer threads `sessionId` around → **loss of type safety**;
+- it cannot inject `Session`/`Agent`-scoped collaborators.
+
+### One-sentence self-check
+
+> **"When this scope is disposed, should this state disappear with it?"**
+>
+> - Yes → the scope is right.
+> - It must outlive the scope → the scope is too short; move up one tier.
+> - It should be one-per-unit but is being shared → the scope is too long; move down one tier.
+
+---
+
+## 3. Multi-Scope splitting
+
+**First principle: one Service owns state at exactly one identity / lifetime. If a domain
+owns state at several lifetimes, split it along those lifetime boundaries — one Service per
+lifetime.**
+
+This is not layered-architecture aesthetics; it is forced by state identity. A class that
+holds both "a global registry" and "per-session instances" will either leak (the global part
+keeps per-session entries alive) or get pinned to an awkward scope where it can do neither
+job well.
+
+### The standard split: "global registry / factory" + "per-instance"
+
+| Tier | Role | Naming tends to |
+|---|---|---|
+| `Core` | **global registry / catalog / factory** — knows "all of them" and how to create one | `XxxStore` / `XxxRegistry` / `XxxCatalog` |
+| `Session` / `Agent` | **one instance** — only the state of "this one" | `XxxService` / `ISessionXxx` / `IAgentXxx` |
+
+This pattern recurs throughout the codebase and confirms the rule:
+
+- **`records`** — `ISessionStore` (`Core`, index of all sessions) + `ISessionMetaStore`
+ (`Session`, this session's metadata) + `IAgentRecords` (`Agent`, this agent's record
+ stream).
+- **`config`** — `IConfigRegistry` / `IConfigService` (`Core`, global config) +
+ `IAgentConfigService` (`Agent`, this agent's config view).
+- **`kosong`** — `IModelCatalogService` (`Core`, model catalog) + `IProviderManager`
+ (`Session`) + `ILLMService` (`Agent`, this agent's generation).
+- **`tool`** — `IToolDefinitionRegistry` (`Core`, tool-definition registry) + `IToolService`
+ (`Agent`, this agent's execution).
+
+### When to split and when not to
+
+- **Split** when the domain genuinely has both a global view and per-instance state.
+- **Do not split** when the domain has state at only one lifetime (e.g. purely `Core` like
+ `log` / `telemetry`; purely `Agent` like `prompt`). **Do not pre-split for symmetry.**
+
+### Dependency direction after the split
+
+The `Core` Service usually plays the **factory**: it knows how to create or locate the
+per-instance one. Most consumers inject the **per-instance** Service, because it serves the
+current session/agent directly without threading an id. Inject the `Core` factory only when
+you genuinely need cross-instance management.
+
+---
+
+## 4. Choosing a calling style
+
+There are three ways for one Service to make another act: a **direct call** (DI injection),
+an **event**, or a **hook**. From first principles, they answer three different questions.
+
+**First principle: the choice depends on "who owns the decision" + "is a result needed" +
+"how many consumers".**
+
+### What the three mechanisms mean
+
+| Mechanism | Nature | Coupling | Returns a value? | Consumers |
+|---|---|---|---|---|
+| **Direct call** | command: A tells B to do | A → B | yes | one (known) |
+| **Event** | fact: A announces "X happened" | both depend only on the bus | no | zero / one / many (unknown) |
+| **Hook** (`onWill` / `onDid`, `OrderedHookSlot`) | participation: observers step into an operation, in order | both depend only on the bus | can observe / veto | many, but ordered |
+
+### Decision tree
+
+**Q1. Does A need a return value from B?**
+
+- Yes → **direct call**. Events cannot return a value (doing request/reply over events is an
+ anti-pattern).
+
+**Q2. Is B's reaction part of A's responsibility, or B's own concern?**
+
+- A's responsibility *includes* B's behavior (A orchestrates B) → **direct call**. E.g.
+ `session` drives `agent-lifecycle`; `loop` drives `llmRequester` / `toolExecutor` — that
+ *is* their job.
+- B's reaction is B's own concern, and A is merely **stating a fact** → **event**. E.g.
+ `flag` reacts to `config.onDidChange`; `config` does not know who is listening.
+
+**Q3. How many consumers?**
+
+- exactly one, and known → **direct call**.
+- zero / one / many, and the producer should not know how many → **event**.
+
+**Q4. Would a direct A→B call create a cycle or violate the scope direction?**
+
+- This is a **consequence check**, not a primary reason. Decide by Q1–Q3 first; if the
+ semantics already call for an event, the decoupling comes for free. Do not turn a genuine
+ direct call into an event just to break a cycle.
+
+**Q5. Is this fact part of the durable record / replay / cross-agent projection?**
+
+- Yes → **emit it on the wire** (`wireRecord`). This is a system-specific but strong reason:
+ state changes that must be recorded, replayed, or synchronized across agents have to be
+ projected onto the wire, not handled by a direct call alone. `permission.set_mode`,
+ `goal.create/update/clear`, and `plan_mode.enter/exit` are all in this category.
+ Note that the wire is the *durable record*, not the live notification channel: a live
+ `spliceHistory(...)` call appends a `context.splice` record to the wire *and* applies it,
+ and `contextMemory` then fires `hooks.onSpliced`, which `contextSize` / `loop` /
+ `background` / `microCompaction` / `dynamicInjector` actually subscribe to. Those listeners
+ react to the **hook**, not the wire — the wire is what makes the splice replayable.
+
+### One-sentence rule
+
+> **"I am telling you to do this, and I may need the result" → direct call.**
+> **"I am announcing that something happened; react if you care" → event.**
+> **"I am announcing something, and you may step in, in order, possibly to veto" → hook.**
+
+---
+
+## 5. Dependency direction
+
+Two distinct layers are involved, and they differ in *hardness*:
+
+- **Scope direction**: short-lived → long-lived, **enforced by the container** (already
+ covered in [`docs/di.md`](di.md)).
+- **Domain direction**: which domain may depend on which, **a matter of judgment** — the
+ container does not enforce it.
+
+### First principle: dependency direction = the direction of "needs to know"
+
+> **A depends on B iff A needs B's data or behavior to do its own job.**
+
+That is the whole rule. `prompt` depending on `turn` (as it does today) is legitimate —
+the prompt needs the turn's information to be built. `loop` depending on many capabilities
+is legitimate — orchestration *is* its job.
+
+This rule alone is not enough; add one anti-rot heuristic to keep the graph from collapsing
+into a clique:
+
+> **Do not let a more foundational / more-reused Service come to know a more specific /
+> more-upstream one.**
+
+Reason: reuse gets inverted — once a foundational component knows about an upstream
+scenario, it can no longer be reused by other scenarios, and it will almost always create a
+cycle.
+
+### The natural layers of this repo
+
+Derived from "what is more foundational", roughly (lower is depended on by higher, never the
+reverse):
+
+1. **Root (depend on no business domain)**: `_base`, `log`, `environment`, `event`,
+ `telemetry`, `kaos`.
+2. **Data / state**: `records`, `filestore`, `workspace`, `blobStore`, `config`.
+3. **Capabilities**: `tool`, `permission`, `prompt`, `contextMemory`, `kosong`, `skill`, …
+4. **Orchestrators**: `session`, `agent-lifecycle`, `loop`, `turn`, `swarm`.
+5. **Edge**: `gateway`, `rpc`.
+
+**Red lines:**
+
+- Layer 1 (root) **never** depends on any business domain.
+- Business logic does **not** depend on layer 5 (edge) — business code should not know REST /
+ WebSocket exist.
+- A cycle means knowledge was placed the wrong way around. Fix it (consistent with `di.md`
+ scenario 9): extract a third, more foundational Service, or invert the "notification" half
+ into an event.
+
+> Note: capability → orchestrator (e.g. `prompt → turn`) is **allowed and present** in this
+> repo; do not treat it as a red line. The real red line is *inverted reuse* — a
+> foundational / lower Service depending on a specific / upper one.
+
+---
+
+## 6. Putting it together
+
+The complete checklist for a new `IXxxService`:
+
+1. **What does it remember, and what is the state's identity?** → pick the scope (§2).
+2. **What is the shortest-lived dependency it must inject?** → the scope cannot be longer
+ than that.
+3. **Does it own state at both a global and a per-instance lifetime?** → if yes, split it
+ Multi-Scope (§3).
+4. **For each collaborator: am I commanding it, notifying it, or letting it participate?**
+ → pick the calling style (§4).
+5. **Does each dependency arrow make a more foundational thing know a more specific thing?**
+ → if yes, invert it (§5).
+
+---
+
+## 7. Summary
+
+- **Scope**: the **identity** of the state fixes the scope; do not fake per-instance state
+ at `Core` with a `Map`.
+- **Multi-Scope**: a domain with state at several lifetimes → split into "a `Core` registry
+ + per-instance Services".
+- **Calling style**: need a result / I orchestrate → direct call; stating a fact / react if
+ you care → event; ordered participation / may veto → hook.
+- **Dependency direction**: arrows follow "needs to know", but never let a foundational layer
+ know an upstream one; a cycle means knowledge is placed backwards.
diff --git a/packages/agent-core-v2/src/_base/di/test.ts b/packages/agent-core-v2/src/_base/di/test.ts
index 769417637..86cadc305 100644
--- a/packages/agent-core-v2/src/_base/di/test.ts
+++ b/packages/agent-core-v2/src/_base/di/test.ts
@@ -6,7 +6,11 @@ export {
createServices,
TestInstantiationService,
} from './testInstantiationService';
-export type { ServiceIdCtorPair } from './testInstantiationService';
+export type {
+ CreateServicesOptions,
+ ServiceGroup,
+ ServiceRegistration,
+} from './testInstantiationService';
import { type ServiceIdentifier } from './instantiation';
import { createCoreScope, LifecycleScope, Scope, type ScopeSeed } from './scope';
diff --git a/packages/agent-core-v2/src/_base/di/testInstantiationService.ts b/packages/agent-core-v2/src/_base/di/testInstantiationService.ts
index 15c4747f1..5b6a9d4cd 100644
--- a/packages/agent-core-v2/src/_base/di/testInstantiationService.ts
+++ b/packages/agent-core-v2/src/_base/di/testInstantiationService.ts
@@ -292,45 +292,110 @@ interface SinonOptions {
stub?: boolean;
}
-export type ServiceIdCtorPair = [
- id: ServiceIdentifier,
- ctorOrInstance: T | AnyConstructor,
-];
+/**
+ * Registration surface handed to a {@link ServiceGroup} or to
+ * `CreateServicesOptions.additionalServices`. Mirrors the three ways a test
+ * supplies a service: a lazy constructor, a full instance, or a partial mock.
+ */
+export interface ServiceRegistration {
+ /**
+ * Register a lazy `SyncDescriptor` for a service constructor. The service is
+ * instantiated only when first resolved from the container.
+ */
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ define(id: ServiceIdentifier, ctor: new (...args: any[]) => T): void;
+ /** Register a fully-constructed instance. */
+ defineInstance(id: ServiceIdentifier, instance: T): void;
+ /**
+ * Register a partial instance (a mock). Only the supplied members need to be
+ * provided; the container returns it typed as `T`.
+ */
+ definePartialInstance(id: ServiceIdentifier, instance: Partial): void;
+}
+/** A bundle of service registrations, typically one per domain. */
+export type ServiceGroup = (reg: ServiceRegistration) => void;
+
+export interface CreateServicesOptions {
+ /**
+ * Base service groups applied first, in order. Registrations are deduped
+ * (first writer wins) so groups can supply safe defaults without clobbering
+ * each other.
+ */
+ readonly base?: readonly ServiceGroup[];
+ /**
+ * Applied after `base`. 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.
+ */
+ readonly additionalServices?: (reg: ServiceRegistration) => void;
+ /**
+ * When `true`, resolving an unregistered service throws. Defaults to `false`
+ * to match `new TestInstantiationService()` (missing deps only warn), keeping
+ * migrated tests behavior-preserving.
+ */
+ readonly strict?: boolean;
+}
+
+/**
+ * Build a `TestInstantiationService` from domain service groups plus per-test
+ * overrides. The container is added to `disposables`; directly-registered
+ * instances are disposed with it.
+ */
export function createServices(
disposables: DisposableStore,
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- services: ServiceIdCtorPair[],
+ options: CreateServicesOptions = {},
): TestInstantiationService {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const serviceIdentifiers: ServiceIdentifier[] = [];
const serviceCollection = new ServiceCollection();
+ // Directly-registered instances are not constructed by the container, so the
+ // container will not dispose them — track their ids and dispose them below.
+ // Descriptor-created services are disposed by the container itself and are
+ // intentionally not tracked here (disposing them again would double-dispose).
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const instanceIds = new Set>();
- const define = (
+ const register = (
id: ServiceIdentifier,
- ctorOrInstance: T | AnyConstructor,
+ value: T | Partial | SyncDescriptor,
+ isInstance: boolean,
+ overwrite: boolean,
): void => {
- if (!serviceCollection.has(id)) {
- if (typeof ctorOrInstance === 'function') {
- serviceCollection.set(id, new SyncDescriptor(ctorOrInstance as AnyConstructor));
- } else {
- serviceCollection.set(id, ctorOrInstance);
- }
+ if (overwrite || !serviceCollection.has(id)) {
+ serviceCollection.set(id, value as T | SyncDescriptor);
+ }
+ if (isInstance) {
+ instanceIds.add(id);
}
- serviceIdentifiers.push(id);
};
- for (const [id, ctorOrInstance] of services) {
- define(id, ctorOrInstance);
+ const baseReg: ServiceRegistration = {
+ define: (id, ctor) => register(id, new SyncDescriptor(ctor), false, false),
+ defineInstance: (id, instance) => register(id, instance, true, false),
+ definePartialInstance: (id, instance) => register(id, instance, true, false),
+ };
+
+ for (const group of options.base ?? []) {
+ group(baseReg);
}
- const instantiationService = disposables.add(new TestInstantiationService(serviceCollection, true));
+ if (options.additionalServices) {
+ const overrideReg: ServiceRegistration = {
+ define: (id, ctor) => register(id, new SyncDescriptor(ctor), false, true),
+ defineInstance: (id, instance) => register(id, instance, true, true),
+ definePartialInstance: (id, instance) => register(id, instance, true, true),
+ };
+ options.additionalServices(overrideReg);
+ }
+
+ const instantiationService = disposables.add(
+ new TestInstantiationService(serviceCollection, options.strict ?? false),
+ );
disposables.add(toDisposable(() => {
const serviceDisposables: IDisposable[] = [];
- for (const id of serviceIdentifiers) {
- const instanceOrDescriptor = serviceCollection.get(id);
- if (isDisposable(instanceOrDescriptor)) {
- serviceDisposables.push(instanceOrDescriptor);
+ for (const id of instanceIds) {
+ const instance = serviceCollection.get(id);
+ if (isDisposable(instance)) {
+ serviceDisposables.push(instance);
}
}
dispose(serviceDisposables);
diff --git a/packages/agent-core-v2/src/_base/di/util/linkedList.ts b/packages/agent-core-v2/src/_base/di/util/linkedList.ts
index b520a715f..b281ce527 100644
--- a/packages/agent-core-v2/src/_base/di/util/linkedList.ts
+++ b/packages/agent-core-v2/src/_base/di/util/linkedList.ts
@@ -51,6 +51,15 @@ export class LinkedList {
};
}
+ shift(): E | undefined {
+ if (this._first === Node.Undefined) {
+ return undefined;
+ }
+ const node = this._first as Node;
+ this._remove(node);
+ return node.element;
+ }
+
private _remove(node: Node): void {
if (node.prev !== Node.Undefined && node.next !== Node.Undefined) {
const anchor = node.prev as Node;
diff --git a/packages/agent-core-v2/src/_base/event.ts b/packages/agent-core-v2/src/_base/event.ts
index 8a7e8bc82..89b6d5aec 100644
--- a/packages/agent-core-v2/src/_base/event.ts
+++ b/packages/agent-core-v2/src/_base/event.ts
@@ -1,5 +1,10 @@
/**
- * `event` domain (L0) — `Event` / `Emitter` primitives and event combinators (`once` / `map` / `filter` / `any`).
+ * `event` domain (L0) — `Event` / `Emitter` primitives, the async
+ * `AsyncEmitter` / `IWaitUntil` participation primitive (for interceptable
+ * `onWill` events whose listeners register work via `waitUntil`), the
+ * `handleVetos` helper (for `onBefore*` veto events whose listeners answer
+ * with `veto(value, id)`), and event combinators (`once` / `map` / `filter`
+ * / `any`).
*/
import { onUnexpectedError, safelyCallListener } from './errors/unexpectedError';
@@ -9,6 +14,7 @@ import {
combinedDisposable,
type IDisposable,
} from './di/lifecycle';
+import { LinkedList } from './di/util/linkedList';
export interface Event {
(
@@ -24,7 +30,7 @@ interface ListenerEntry {
}
export class Emitter {
- private _listeners: Set> | undefined;
+ protected _listeners: Set> | undefined;
private _disposed = false;
private _event: Event | undefined;
@@ -85,6 +91,100 @@ export class Emitter {
}
}
+export interface IWaitUntil {
+ readonly signal: AbortSignal;
+ waitUntil(thenable: Promise): void;
+}
+
+export type IWaitUntilData = Omit;
+
+export class AsyncEmitter extends Emitter {
+ private _asyncDeliveryQueue?: LinkedList<[(event: T) => void, IWaitUntilData]>;
+
+ async fireAsync(data: IWaitUntilData, signal: AbortSignal): Promise {
+ if (this.isDisposed || this._listeners === undefined) {
+ return;
+ }
+
+ this._asyncDeliveryQueue ??= new LinkedList();
+ for (const entry of this._listeners) {
+ this._asyncDeliveryQueue.push([
+ (event) => {
+ entry.listener.call(entry.thisArg, event);
+ },
+ data,
+ ]);
+ }
+
+ while (this._asyncDeliveryQueue.size > 0 && !signal.aborted) {
+ const [deliver, eventData] = this._asyncDeliveryQueue.shift()!;
+ const thenables: Promise[] = [];
+
+ const event = {
+ ...eventData,
+ signal,
+ waitUntil: (p: Promise): void => {
+ if (Object.isFrozen(thenables)) {
+ throw new Error('waitUntil can NOT be called asynchronously');
+ }
+ thenables.push(p);
+ },
+ } as T;
+
+ try {
+ deliver(event);
+ } catch (error) {
+ onUnexpectedError(error);
+ continue;
+ }
+
+ Object.freeze(thenables);
+ const settled = await Promise.allSettled(thenables);
+ for (const result of settled) {
+ if (result.status === 'rejected') {
+ onUnexpectedError(result.reason);
+ }
+ }
+ }
+ }
+}
+
+export function handleVetos(
+ vetos: (boolean | Promise)[],
+ onError: (error: unknown) => void,
+): Promise {
+ if (vetos.length === 0) {
+ return Promise.resolve(false);
+ }
+
+ const promises: Promise[] = [];
+ let lazyValue = false;
+
+ for (const valueOrPromise of vetos) {
+ if (valueOrPromise === true) {
+ return Promise.resolve(true);
+ }
+ if (typeof valueOrPromise === 'boolean') {
+ continue;
+ }
+ promises.push(
+ valueOrPromise.then(
+ (value) => {
+ if (value) {
+ lazyValue = true;
+ }
+ },
+ (error) => {
+ onError(error);
+ lazyValue = true;
+ },
+ ),
+ );
+ }
+
+ return Promise.allSettled(promises).then(() => lazyValue);
+}
+
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace Event {
export const None: Event = () => Disposable.None;
diff --git a/packages/agent-core-v2/src/agent-lifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/agent-lifecycle/agentLifecycleService.ts
index cf96f89c2..690010445 100644
--- a/packages/agent-core-v2/src/agent-lifecycle/agentLifecycleService.ts
+++ b/packages/agent-core-v2/src/agent-lifecycle/agentLifecycleService.ts
@@ -1,22 +1,30 @@
/**
* `agent-lifecycle` domain (L6) — `IAgentLifecycleService` implementation.
*
- * Creates and tracks the session's agents as child scopes; persists records
- * through `records` and reads session context through `session-context`. Bound
- * at Session scope.
+ * Creates and tracks the session's agents as child scopes, acting as the agent
+ * composition root: wires the tool-call permission veto through `turn` and
+ * `permission`, persists records through `records`, and reads session context
+ * through `session-context`. Bound at Session scope.
*/
import { Disposable } from '#/_base/di/lifecycle';
import { InstantiationType } from '#/_base/di/extensions';
import {
- createScopedChildHandle,
type IScopeHandle,
LifecycleScope,
+ getScopedServiceDescriptors,
registerScopedService,
} from '#/_base/di/scope';
-import { IInstantiationService } from '#/_base/di/instantiation';
-import { ISessionMetaStore } from '#/records';
+import {
+ IInstantiationService,
+ type ServiceIdentifier,
+ type ServicesAccessor,
+} from '#/_base/di/instantiation';
+import { ServiceCollection } from '#/_base/di/serviceCollection';
+import { IPermissionService } from '#/permission/permission';
+import { ISessionMetaStore } from '#/records/records';
import { ISessionContext } from '#/session-context/sessionContext';
+import { ITurnEvents } from '#/turn/turn';
import { type CreateAgentOptions, IAgentLifecycleService } from './agentLifecycle';
@@ -36,15 +44,35 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
create(opts: CreateAgentOptions): Promise {
const agentId = opts.agentId ?? `agent-${nextAgentId++}`;
- const handle = createScopedChildHandle(
- this.instantiation,
- LifecycleScope.Agent,
- agentId,
- );
+ const collection = new ServiceCollection();
+ for (const entry of getScopedServiceDescriptors(LifecycleScope.Agent)) {
+ collection.set(entry.id, entry.descriptor);
+ }
+ const child = this.instantiation.createChild(collection);
+ this.wireToolCallPermissionVeto(child);
+ const accessor: ServicesAccessor = {
+ get: (id: ServiceIdentifier): T => child.invokeFunction((a) => a.get(id)),
+ };
+ const handle: IScopeHandle = { id: agentId, kind: LifecycleScope.Agent, accessor };
this.handles.set(agentId, handle);
return Promise.resolve(handle);
}
+ private wireToolCallPermissionVeto(instantiation: IInstantiationService): void {
+ instantiation.invokeFunction((accessor) => {
+ const turnEvents = accessor.get(ITurnEvents);
+ const permission = accessor.get(IPermissionService);
+ turnEvents.onWillExecuteTool((event) => {
+ event.veto(
+ Promise.resolve(
+ permission.beforeToolCall({ toolName: event.toolName, args: event.args }),
+ ).then((decision) => decision === 'deny'),
+ 'permission',
+ );
+ });
+ });
+ }
+
createMain(): Promise {
return this.create({ agentId: 'main' });
}
diff --git a/packages/agent-core-v2/src/kaos/kaos.ts b/packages/agent-core-v2/src/kaos/kaos.ts
index b14f6b428..9e1657e23 100644
--- a/packages/agent-core-v2/src/kaos/kaos.ts
+++ b/packages/agent-core-v2/src/kaos/kaos.ts
@@ -1,6 +1,6 @@
import type { Kaos } from '@moonshot-ai/kaos';
-import { createDecorator } from "#/_base/di";
+import { createDecorator } from '#/_base/di/instantiation';
export interface IKaosService {
readonly _serviceBrand: undefined;
@@ -9,3 +9,30 @@ export interface IKaosService {
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const IKaosService = createDecorator('agentKaosService');
+
+export type KaosFactoryOptions =
+ | { readonly kind: 'local'; readonly cwd?: string }
+ | { readonly kind: 'ssh'; readonly host: string; readonly cwd?: string };
+
+export interface IKaosFactory {
+ readonly _serviceBrand: undefined;
+ create(options: KaosFactoryOptions): Promise;
+}
+
+// eslint-disable-next-line @typescript-eslint/no-redeclare
+export const IKaosFactory = createDecorator('kaosFactory');
+
+export interface ISessionKaosService {
+ readonly _serviceBrand: undefined;
+ readonly toolKaos: Kaos;
+ readonly persistenceKaos: Kaos;
+ readonly systemContextKaos: Kaos;
+ readonly additionalDirs: readonly string[];
+ setToolKaos(kaos: Kaos): void;
+ setPersistenceKaos(kaos: Kaos): void;
+ addAdditionalDir(dir: string): void;
+ removeAdditionalDir(dir: string): void;
+}
+
+// eslint-disable-next-line @typescript-eslint/no-redeclare
+export const ISessionKaosService = createDecorator('sessionKaosService');
diff --git a/packages/agent-core-v2/src/kaos/kaosFactory.ts b/packages/agent-core-v2/src/kaos/kaosFactory.ts
index e52c76698..43d30ae8c 100644
--- a/packages/agent-core-v2/src/kaos/kaosFactory.ts
+++ b/packages/agent-core-v2/src/kaos/kaosFactory.ts
@@ -9,8 +9,8 @@ import { type Kaos, LocalKaos } from '@moonshot-ai/kaos';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
-import { IEnvironmentService } from '#/environment';
-import { ILogService } from '#/log';
+import { IEnvironmentService } from '#/environment/environment';
+import { ILogService } from '#/log/log';
import { type KaosFactoryOptions, IKaosFactory } from './kaos';
diff --git a/packages/agent-core-v2/src/kaos/sessionKaosService.ts b/packages/agent-core-v2/src/kaos/sessionKaosService.ts
index 6855c4966..6c5571f14 100644
--- a/packages/agent-core-v2/src/kaos/sessionKaosService.ts
+++ b/packages/agent-core-v2/src/kaos/sessionKaosService.ts
@@ -11,7 +11,7 @@ import type { Kaos } from '@moonshot-ai/kaos';
import { Disposable } from '#/_base/di/lifecycle';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
-import { ILogService } from '#/log';
+import { ILogService } from '#/log/log';
import { ISessionKaosService } from './kaos';
diff --git a/packages/agent-core-v2/src/turn/index.ts b/packages/agent-core-v2/src/turn/index.ts
index d40945113..58f3767dd 100644
--- a/packages/agent-core-v2/src/turn/index.ts
+++ b/packages/agent-core-v2/src/turn/index.ts
@@ -1,6 +1,12 @@
/**
- * `turnRunner` domain barrel - re-exports the turnRunner service contract and implementation.
+ * `turn` domain barrel — re-exports the turn contract (`turn`) and its scoped
+ * services (`turnService`, `turnEvents`, `loopRunner`, `toolCallExecutor`).
+ * Importing this barrel registers the `ITurnService`, `ITurnEvents`,
+ * `ILoopRunner`, and `IToolCallExecutor` bindings into the scope registry.
*/
export * from './turn';
export * from './turnService';
+export * from './turnEvents';
+export * from './loopRunner';
+export * from './toolCallExecutor';
diff --git a/packages/agent-core-v2/src/turn/loopRunner.ts b/packages/agent-core-v2/src/turn/loopRunner.ts
new file mode 100644
index 000000000..043f01a79
--- /dev/null
+++ b/packages/agent-core-v2/src/turn/loopRunner.ts
@@ -0,0 +1,19 @@
+/**
+ * `turn` domain (L4) — `ILoopRunner` implementation.
+ *
+ * Runs the per-turn loop. Bound at Turn scope.
+ */
+
+import { InstantiationType } from '#/_base/di/extensions';
+import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
+
+import { ILoopRunner } from './turn';
+
+export class LoopRunner implements ILoopRunner {
+ declare readonly _serviceBrand: undefined;
+ run(): Promise {
+ return Promise.resolve();
+ }
+}
+
+registerScopedService(LifecycleScope.Turn, ILoopRunner, LoopRunner, InstantiationType.Delayed, 'turn');
diff --git a/packages/agent-core-v2/src/turn/toolCallExecutor.ts b/packages/agent-core-v2/src/turn/toolCallExecutor.ts
new file mode 100644
index 000000000..625d6bcdf
--- /dev/null
+++ b/packages/agent-core-v2/src/turn/toolCallExecutor.ts
@@ -0,0 +1,70 @@
+/**
+ * `turn` domain (L4) — `IToolCallExecutor` implementation.
+ *
+ * Runs a single tool call through its generic veto gate and fires the
+ * surrounding turn events; executes tools through `tool`. The veto gate is
+ * policy-agnostic — participants such as permission are subscribed by the
+ * composition root, not hard-wired here. Bound at Turn scope.
+ */
+
+import { Disposable } from '#/_base/di/lifecycle';
+import { handleVetos } from '#/_base/event';
+import { onUnexpectedError } from '#/_base/errors/unexpectedError';
+import { InstantiationType } from '#/_base/di/extensions';
+import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
+import { IToolService } from '#/tool/tool';
+
+import {
+ type ToolCallOutcome,
+ IToolCallExecutor,
+ ITurnContext,
+ ITurnEvents,
+} from './turn';
+
+type VetoEntry = { readonly value: boolean | Promise; readonly id?: string };
+
+export class ToolCallExecutor extends Disposable implements IToolCallExecutor {
+ declare readonly _serviceBrand: undefined;
+
+ constructor(
+ @ITurnEvents private readonly turnEvents: ITurnEvents,
+ @IToolService private readonly tool: IToolService,
+ @ITurnContext private readonly turnContext: ITurnContext,
+ ) {
+ super();
+ }
+
+ async execute(toolCallId: string, toolName: string, args: unknown): Promise {
+ const vetos: VetoEntry[] = [];
+ const { turnId } = this.turnContext;
+
+ this.turnEvents.fireWillExecuteTool({
+ turnId,
+ toolCallId,
+ toolName,
+ args,
+ veto: (value, id) => {
+ vetos.push({ value, id });
+ },
+ });
+
+ if (await handleVetos(vetos.map((entry) => entry.value), onUnexpectedError)) {
+ return { vetoed: true, reason: await this.resolveVetoReason(vetos) };
+ }
+
+ const result = await this.tool.execute(toolName, args);
+ this.turnEvents.fireDidFinalizeTool({ turnId, toolCallId, toolName });
+ return { vetoed: false, result };
+ }
+
+ private async resolveVetoReason(vetos: readonly VetoEntry[]): Promise {
+ for (const { value, id } of vetos) {
+ if (await Promise.resolve(value)) {
+ return id ?? 'vetoed';
+ }
+ }
+ return 'vetoed';
+ }
+}
+
+registerScopedService(LifecycleScope.Turn, IToolCallExecutor, ToolCallExecutor, InstantiationType.Delayed, 'turn');
diff --git a/packages/agent-core-v2/src/turn/turn.ts b/packages/agent-core-v2/src/turn/turn.ts
index fcacb98a1..314947ded 100644
--- a/packages/agent-core-v2/src/turn/turn.ts
+++ b/packages/agent-core-v2/src/turn/turn.ts
@@ -1,50 +1,102 @@
-import { createDecorator } from "#/_base/di";
-import type { ContextMessage, PromptOrigin } from '#/contextMemory';
-import type { Hooks } from '#/hooks';
+/**
+ * `turn` domain (L4) — drives the turn lifecycle.
+ *
+ * Defines the public contract of a turn: the `ITurnService` used by upper layers
+ * to start, steer, retry, and cancel a turn and to observe its events, the
+ * `ITurnEvents` dispatcher that owns the turn's event emitters (subscribed to at
+ * Agent scope, fired from both the Agent-scope controller and the per-turn
+ * loop), the `IToolCallExecutor` that runs a single tool call through its
+ * veto/permission gate, the per-turn `ITurnContext`, and the `ILoopRunner` that
+ * runs the turn loop. `ITurnService` / `ITurnEvents` are Agent-scoped;
+ * `IToolCallExecutor` / `ILoopRunner` / `ITurnContext` are Turn-scoped.
+ */
+import type { Event } from '#/_base/event';
+import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
+import type { ToolCallResult } from '#/tool/tool';
-export interface TurnResult {
- readonly reason: 'completed' | 'cancelled' | 'failed' | 'filtered';
- readonly error?: unknown;
+export interface TurnStartEvent {
+ readonly turnId: string;
}
-
-export interface Turn {
- readonly id: number;
- readonly abortController: AbortController;
- readonly ready: Promise;
- readonly result: Promise;
+export interface TurnWillExecuteToolEvent {
+ readonly turnId: string;
+ readonly toolCallId: string;
+ readonly toolName: string;
+ readonly args: unknown;
+ veto(value: boolean | Promise, id?: string): void;
}
-
-export interface TurnStepContext {
- readonly turn: Turn;
- continueTurn: boolean;
+export interface TurnToolEvent {
+ readonly turnId: string;
+ readonly toolCallId: string;
+ readonly toolName: string;
}
-
-export interface TurnRunContext {
- readonly turn: Turn;
- readonly origin: PromptOrigin;
- readonly promptMessage?: ContextMessage;
- result?: TurnResult;
+export type ToolCallOutcome =
+ | { readonly vetoed: true; readonly reason: string }
+ | { readonly vetoed: false; readonly result: ToolCallResult };
+export interface TurnStepEvent {
+ readonly turnId: string;
+ readonly step: number;
}
-
-export interface TurnEndedContext {
- readonly turn: Turn;
- readonly result: TurnResult;
+export interface TurnEndEvent {
+ readonly turnId: string;
+ readonly reason: string;
}
-
export interface ITurnService {
- launch(origin: PromptOrigin): Turn;
- getActiveTurn(): Turn | undefined;
- cancel(turnId?: number, reason?: unknown): void;
-
- readonly hooks: Hooks<{
- onLaunched: { turn: Turn };
- onEnded: TurnEndedContext;
- beforeStep: TurnStepContext;
- afterStep: TurnStepContext;
- }>;
+ readonly _serviceBrand: undefined;
+ readonly onWillStartTurn: Event;
+ readonly onWillExecuteTool: Event;
+ readonly onDidFinalizeTool: Event;
+ readonly onDidEndStep: Event;
+ readonly onDidEndTurn: Event;
+ readonly hasActiveTurn: boolean;
+ readonly currentId: string | undefined;
+ prompt(input: string): Promise;
+ steer(content: string, origin?: string): void;
+ retry(): Promise;
+ cancel(reason?: string): void;
}
-// eslint-disable-next-line @typescript-eslint/no-redeclare
-export const ITurnService = createDecorator('turnService');
+export const ITurnService: ServiceIdentifier =
+ createDecorator('turnService');
+
+export interface ITurnEvents {
+ readonly _serviceBrand: undefined;
+ readonly onWillStartTurn: Event;
+ readonly onWillExecuteTool: Event;
+ readonly onDidFinalizeTool: Event;
+ readonly onDidEndStep: Event;
+ readonly onDidEndTurn: Event;
+ fireWillStartTurn(event: TurnStartEvent): void;
+ fireWillExecuteTool(event: TurnWillExecuteToolEvent): void;
+ fireDidFinalizeTool(event: TurnToolEvent): void;
+ fireDidEndStep(event: TurnStepEvent): void;
+ fireDidEndTurn(event: TurnEndEvent): void;
+}
+
+export const ITurnEvents: ServiceIdentifier =
+ createDecorator('turnEvents');
+
+export interface IToolCallExecutor {
+ readonly _serviceBrand: undefined;
+ execute(toolCallId: string, toolName: string, args: unknown): Promise;
+}
+
+export const IToolCallExecutor: ServiceIdentifier =
+ createDecorator('toolCallExecutor');
+
+export interface ITurnContext {
+ readonly _serviceBrand: undefined;
+ readonly turnId: string;
+}
+
+export const ITurnContext: ServiceIdentifier =
+ createDecorator('turnContext');
+
+export interface ILoopRunner {
+ readonly _serviceBrand: undefined;
+ run(): Promise;
+}
+
+export const ILoopRunner: ServiceIdentifier =
+ createDecorator('loopRunner');
diff --git a/packages/agent-core-v2/src/turn/turnEvents.ts b/packages/agent-core-v2/src/turn/turnEvents.ts
new file mode 100644
index 000000000..e112dabb8
--- /dev/null
+++ b/packages/agent-core-v2/src/turn/turnEvents.ts
@@ -0,0 +1,54 @@
+/**
+ * `turn` domain (L4) — `ITurnEvents` implementation.
+ *
+ * Owns the turn's event emitters and exposes both the subscribe surfaces and
+ * the fire methods, so the Agent-scope lifecycle controller and the per-turn
+ * loop / tool executor can drive the same event stream. Bound at Agent scope.
+ */
+
+import { Disposable } from '#/_base/di/lifecycle';
+import { Emitter, type Event } from '#/_base/event';
+import { InstantiationType } from '#/_base/di/extensions';
+import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
+
+import {
+ type TurnEndEvent,
+ type TurnStartEvent,
+ type TurnStepEvent,
+ type TurnToolEvent,
+ type TurnWillExecuteToolEvent,
+ ITurnEvents,
+} from './turn';
+
+export class TurnEvents extends Disposable implements ITurnEvents {
+ declare readonly _serviceBrand: undefined;
+
+ private readonly _onWillStartTurn = this._register(new Emitter());
+ readonly onWillStartTurn: Event = this._onWillStartTurn.event;
+ private readonly _onWillExecuteTool = this._register(new Emitter());
+ readonly onWillExecuteTool: Event = this._onWillExecuteTool.event;
+ private readonly _onDidFinalizeTool = this._register(new Emitter());
+ readonly onDidFinalizeTool: Event = this._onDidFinalizeTool.event;
+ private readonly _onDidEndStep = this._register(new Emitter());
+ readonly onDidEndStep: Event = this._onDidEndStep.event;
+ private readonly _onDidEndTurn = this._register(new Emitter());
+ readonly onDidEndTurn: Event = this._onDidEndTurn.event;
+
+ fireWillStartTurn(event: TurnStartEvent): void {
+ this._onWillStartTurn.fire(event);
+ }
+ fireWillExecuteTool(event: TurnWillExecuteToolEvent): void {
+ this._onWillExecuteTool.fire(event);
+ }
+ fireDidFinalizeTool(event: TurnToolEvent): void {
+ this._onDidFinalizeTool.fire(event);
+ }
+ fireDidEndStep(event: TurnStepEvent): void {
+ this._onDidEndStep.fire(event);
+ }
+ fireDidEndTurn(event: TurnEndEvent): void {
+ this._onDidEndTurn.fire(event);
+ }
+}
+
+registerScopedService(LifecycleScope.Agent, ITurnEvents, TurnEvents, InstantiationType.Delayed, 'turn');
diff --git a/packages/agent-core-v2/src/turn/turnService.ts b/packages/agent-core-v2/src/turn/turnService.ts
index 83a086161..68e3537a6 100644
--- a/packages/agent-core-v2/src/turn/turnService.ts
+++ b/packages/agent-core-v2/src/turn/turnService.ts
@@ -1,329 +1,117 @@
-import {
- IInstantiationService,
-} from "#/_base/di";
+/**
+ * `turn` domain (L4) — `ITurnService` implementation.
+ *
+ * Drives the turn lifecycle and emits its events through the turn event
+ * dispatcher; runs the turn loop through `loopRunner`, drives agent lifecycle
+ * through `agent-lifecycle`, reads history through `context`, enqueues
+ * follow-up through `injection`, drives LLM generation through `kosong`, logs
+ * through `log`, reports telemetry through `telemetry`, and checks usage
+ * through `usage`. Bound at Agent scope.
+ */
+
+import { Disposable } from '#/_base/di/lifecycle';
+import { type Event } from '#/_base/event';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
-import { toKimiErrorPayload, type KimiErrorPayload } from "#/_base/errors";
-import { isUserCancellation, userCancellationReason } from "#/_base/utils/abort";
-import type { ContextMessage, PromptOrigin } from '#/contextMemory';
-import { IContextMemory, USER_PROMPT_ORIGIN } from '#/contextMemory';
-import { IEventBus } from '#/eventBus';
-import { IExternalHooksService } from '#/externalHooks';
-import { OrderedHookSlot } from '#/hooks';
-import { ILoopService } from '#/loop';
-import { IPlanService } from '#/plan';
-import { ITelemetryService } from '#/telemetry';
-import { IUsageService } from '#/usage';
-import { IWireRecord } from '#/wireRecord';
-import type {
- Turn,
- TurnEndedContext,
- TurnResult,
- TurnStepContext,
+import { IAgentLifecycleService } from '#/agent-lifecycle/agentLifecycle';
+import { IContextService } from '#/context/context';
+import { IInjectionService } from '#/injection/injection';
+import { ILLMService } from '#/kosong/kosong';
+import { ILogService } from '#/log/log';
+import { ITelemetryService } from '#/telemetry/telemetry';
+import { IUsageService } from '#/usage/usage';
+
+import {
+ type TurnEndEvent,
+ type TurnStartEvent,
+ type TurnStepEvent,
+ type TurnToolEvent,
+ type TurnWillExecuteToolEvent,
+ ILoopRunner,
+ ITurnEvents,
+ ITurnService,
} from './turn';
-import { ITurnService } from './turn';
-declare module '#/wireRecord' {
- interface WireRecordMap {
- 'turn.launch': {
- turnId: number;
- origin: PromptOrigin;
- };
- }
-}
+let nextTurnId = 0;
-export class TurnRunnerService implements ITurnService {
- private nextTurnId = 0;
- private activeTurn: Turn | undefined;
- private readonly readyControllers = new WeakMap>();
- private readonly readySettled = new WeakSet();
- private readonly currentStepByTurn = new Map();
- private readonly interruptedTelemetryTurnIds = new Set();
- private readonly telemetryModeByTurn = new Map();
+export class TurnService extends Disposable implements ITurnService {
+ declare readonly _serviceBrand: undefined;
- readonly hooks = {
- onLaunched: new OrderedHookSlot<{ turn: Turn }>(),
- onEnded: new OrderedHookSlot(),
- beforeStep: new OrderedHookSlot(),
- afterStep: new OrderedHookSlot(),
- };
+ readonly onWillStartTurn: Event;
+ readonly onWillExecuteTool: Event;
+ readonly onDidFinalizeTool: Event;
+ readonly onDidEndStep: Event;
+ readonly onDidEndTurn: Event;
+
+ private active: { readonly turnId: string; cancelled: boolean } | undefined;
+ private readonly steerBuffer: { content: string; origin?: string }[] = [];
constructor(
- @ILoopService private readonly loop: ILoopService,
- @IUsageService private readonly usage: IUsageService,
- @IEventBus private readonly events: IEventBus,
- @IWireRecord private readonly wireRecord: IWireRecord,
- @IContextMemory private readonly context: IContextMemory,
- @IExternalHooksService private readonly externalHooks: IExternalHooksService,
- @IInstantiationService private readonly instantiation: IInstantiationService,
- @ITelemetryService private readonly telemetry: ITelemetryService,
+ @IContextService _context: IContextService,
+ @ILLMService _llm: ILLMService,
+ @IInjectionService _injection: IInjectionService,
+ @IUsageService _usage: IUsageService,
+ @ITelemetryService _telemetry: ITelemetryService,
+ @ILogService _log: ILogService,
+ @IAgentLifecycleService _agentLifecycle: IAgentLifecycleService,
+ @ILoopRunner private readonly loopRunner: ILoopRunner,
+ @ITurnEvents private readonly turnEvents: ITurnEvents,
) {
- wireRecord.register('turn.launch', (record) => {
- this.restoreLaunch(record.turnId);
- });
- this.hooks.beforeStep.register('turn-before-step-event', async (ctx, next) => {
- await next();
- this.resolveReady(ctx.turn);
- });
- this.events.on((event) => {
- if (event.type === 'turn.step.started') {
- this.currentStepByTurn.set(event.turnId, event.step);
- return;
- }
- if (event.type === 'turn.step.interrupted') {
- this.trackTurnInterrupted(event.turnId, event.step);
- }
- });
+ super();
+
+ this.onWillStartTurn = this.turnEvents.onWillStartTurn;
+ this.onWillExecuteTool = this.turnEvents.onWillExecuteTool;
+ this.onDidFinalizeTool = this.turnEvents.onDidFinalizeTool;
+ this.onDidEndStep = this.turnEvents.onDidEndStep;
+ this.onDidEndTurn = this.turnEvents.onDidEndTurn;
}
- launch(origin: PromptOrigin): Turn {
- if (this.activeTurn !== undefined) {
- throw new Error(`Cannot launch a new turn while turn ${this.activeTurn.id} is active`);
+ get hasActiveTurn(): boolean {
+ return this.active !== undefined;
+ }
+ get currentId(): string | undefined {
+ return this.active?.turnId;
+ }
+
+ async prompt(input: string): Promise {
+ if (this.active !== undefined) {
+ this.steer(input);
+ return;
}
-
- const turnId = this.nextTurnId;
- this.wireRecord.append({ type: 'turn.launch', turnId, origin });
- this.restoreLaunch(turnId);
- const abortController = new AbortController();
- const ready = createControlledPromise();
- const turn: MutableTurn = {
- id: turnId,
- abortController,
- ready: ready.promise,
- result: Promise.resolve({ reason: 'failed' }),
- };
- this.readyControllers.set(turn, ready);
- void ready.promise.catch(() => undefined);
- this.activeTurn = turn;
- turn.result = this.runTurn(turn, origin);
- void this.hooks.onLaunched.run({ turn });
- return turn;
+ await this.launch(input);
}
- getActiveTurn(): Turn | undefined {
- return this.activeTurn;
+ steer(content: string, origin?: string): void {
+ this.steerBuffer.push({ content, origin });
}
- cancel(turnId?: number, reason?: unknown): void {
- const turn = this.activeTurn;
- if (turn === undefined) return;
- if (turnId !== undefined && turn.id !== turnId) return;
- turn.abortController.abort(reason ?? userCancellationReason());
+ retry(): Promise {
+ throw new Error('TODO: TurnService.retry');
}
- private async runTurn(turn: Turn, origin: PromptOrigin): Promise {
- const startedAt = Date.now();
- const telemetryMode = this.telemetryMode();
- this.telemetryModeByTurn.set(turn.id, telemetryMode);
- let result: TurnResult | undefined;
+ cancel(reason?: string): void {
+ if (this.active === undefined) return;
+ this.active.cancelled = true;
+ const turnId = this.active.turnId;
+ this.active = undefined;
+ this.turnEvents.fireDidEndTurn({ turnId, reason: reason ?? 'cancelled' });
+ }
+
+ private async launch(input: string): Promise {
+ const turnId = `turn-${nextTurnId++}`;
+ this.active = { turnId, cancelled: false };
+ this.turnEvents.fireWillStartTurn({ turnId });
try {
- this.usage.beginTurn();
- this.telemetry.track('turn_started', { mode: telemetryMode });
- this.events.emit({ type: 'turn.started', turnId: turn.id, origin });
- const promptHookResult = await this.applyUserPromptHook(turn, origin);
- if (promptHookResult !== undefined) {
- result = promptHookResult;
- return result;
- }
- result = await this.loop.runTurn(turn, {
- beforeStep: this.hooks.beforeStep,
- afterStep: this.hooks.afterStep,
- });
- return result;
- } catch (error) {
- if (turn.abortController.signal.aborted) {
- result = { reason: 'cancelled', error: turn.abortController.signal.reason };
- this.rejectReady(turn, turn.abortController.signal.reason);
- return result;
- }
- this.externalHooks.triggerStopFailure(error, turn.abortController.signal);
- this.rejectReady(turn, error);
- result = { reason: 'failed', error };
- return result;
+ await this.loopRunner.run();
+ this.turnEvents.fireDidEndStep({ turnId, step: 0 });
} finally {
- if (result !== undefined) {
- this.rejectReady(turn, result);
+ if (this.active?.turnId === turnId) {
+ this.active = undefined;
+ this.turnEvents.fireDidEndTurn({ turnId, reason: 'completed' });
}
- this.usage.endTurn();
- if (this.activeTurn === turn) {
- this.activeTurn = undefined;
- }
- if (result !== undefined) {
- const ended = toTurnEndedEvent(turn, result, Date.now() - startedAt);
- if (
- ended.reason === 'cancelled' &&
- isUserCancellation(turn.abortController.signal.reason)
- ) {
- this.externalHooks.triggerInterrupt({ turnId: turn.id, reason: 'cancelled' });
- }
- this.events.emit(ended);
- if (ended.error !== undefined) {
- this.events.emit({ type: 'error', ...ended.error });
- }
- if (ended.reason !== 'completed') {
- this.trackTurnInterrupted(turn.id, this.currentStepByTurn.get(turn.id) ?? 0);
- }
- }
- if (result !== undefined) {
- await this.hooks.onEnded.run({ turn, result });
- }
- this.currentStepByTurn.delete(turn.id);
- this.interruptedTelemetryTurnIds.delete(turn.id);
- this.telemetryModeByTurn.delete(turn.id);
}
- }
-
- private resolveReady(turn: Turn): void {
- if (this.readySettled.has(turn)) return;
- this.readySettled.add(turn);
- this.readyControllers.get(turn)?.resolve();
- }
-
- private restoreLaunch(turnId: number): void {
- if (Number.isInteger(turnId) && turnId >= this.nextTurnId) {
- this.nextTurnId = turnId + 1;
- }
- }
-
- private async applyUserPromptHook(
- turn: Turn,
- origin: PromptOrigin,
- ): Promise {
- if (origin.kind !== 'user') return undefined;
- const promptMessage = this.context.getHistory().at(-1);
- if (!shouldRunUserPromptHook(promptMessage)) return undefined;
-
- const hookResult = await this.externalHooks.triggerUserPromptSubmit(
- promptMessage.content,
- turn.abortController.signal,
- );
- if (hookResult?.action === 'block') {
- this.append({
- role: 'assistant',
- content: [{ type: 'text', text: hookResult.text }],
- toolCalls: [],
- origin: { kind: 'hook_result', event: hookResult.event, blocked: true },
- });
- this.events.emit({
- type: 'hook.result',
- turnId: turn.id,
- hookEvent: hookResult.event,
- content: hookResult.message,
- blocked: true,
- });
- return { reason: 'completed' };
- }
-
- if (hookResult?.action === 'append') {
- this.append({
- role: 'user',
- content: [{ type: 'text', text: hookResult.text }],
- toolCalls: [],
- origin: { kind: 'hook_result', event: hookResult.event },
- });
- this.events.emit({
- type: 'hook.result',
- turnId: turn.id,
- hookEvent: hookResult.event,
- content: hookResult.message,
- });
- }
- return undefined;
- }
-
- private append(...messages: ContextMessage[]): void {
- if (messages.length === 0) return;
- this.context.spliceHistory(this.context.getHistory().length, 0, messages);
- }
-
- private rejectReady(turn: Turn, reason: unknown): void {
- if (this.readySettled.has(turn)) return;
- this.readySettled.add(turn);
- this.readyControllers.get(turn)?.reject(reason);
- }
-
- private trackTurnInterrupted(turnId: number, atStep: number): void {
- if (this.interruptedTelemetryTurnIds.has(turnId)) return;
- this.interruptedTelemetryTurnIds.add(turnId);
- this.telemetry.track('turn_interrupted', {
- mode: this.telemetryModeByTurn.get(turnId) ?? this.telemetryMode(),
- at_step: atStep,
- });
- }
-
- private telemetryMode(): 'agent' | 'plan' {
- const planMode = this.instantiation.invokeFunction((accessor) =>
- accessor.get(IPlanService),
- );
- return planMode.isActive ? 'plan' : 'agent';
+ void input;
}
}
-function shouldRunUserPromptHook(message: ContextMessage | undefined): message is ContextMessage {
- if (message === undefined || message.role !== 'user') return false;
- return (message.origin ?? USER_PROMPT_ORIGIN).kind === 'user';
-}
-
-function toTurnEndedEvent(
- turn: Turn,
- result: TurnResult,
- durationMs: number,
-): {
- type: 'turn.ended';
- turnId: number;
- reason: TurnResult['reason'];
- error?: KimiErrorPayload;
- durationMs: number;
-} {
- if (result.reason !== 'failed' || result.error === undefined) {
- return { type: 'turn.ended', turnId: turn.id, reason: result.reason, durationMs };
- }
- return {
- type: 'turn.ended',
- turnId: turn.id,
- reason: result.reason,
- error: summarizeTurnError(result.error, turn.id),
- durationMs,
- };
-}
-
-const LLM_NOT_SET_MESSAGE = 'LLM not set, send "/login" to login';
-
-function summarizeTurnError(error: unknown, turnId: number): KimiErrorPayload {
- const payload = toKimiErrorPayload(error);
- const details = { ...payload.details, turnId };
- // Substitute a friendlier, login-aware message for model-not-configured. The
- // raw "Model not set" / "Provider not set" text is not actionable.
- if (payload.code === 'model.not_configured') {
- return { ...payload, message: LLM_NOT_SET_MESSAGE, details };
- }
- return { ...payload, details };
-}
-
-interface ControlledPromise {
- readonly promise: Promise;
- resolve(value: T | PromiseLike): void;
- reject(reason?: unknown): void;
-}
-
-type MutableTurn = {
- -readonly [K in keyof Turn]: Turn[K];
-};
-
-function createControlledPromise(): ControlledPromise {
- let resolve!: (value: T | PromiseLike) => void;
- let reject!: (reason?: unknown) => void;
- const promise = new Promise((res, rej) => {
- resolve = res;
- reject = rej;
- });
- return { promise, resolve, reject };
-}
-
-registerScopedService(
- LifecycleScope.Agent,
- ITurnService,
- TurnRunnerService,
- InstantiationType.Delayed,
- 'turn',
-);
+registerScopedService(LifecycleScope.Agent, ITurnService, TurnService, InstantiationType.Delayed, 'turn');
diff --git a/packages/agent-core-v2/test/agent-lifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/agent-lifecycle/agentLifecycle.test.ts
index 96701799a..c16e45197 100644
--- a/packages/agent-core-v2/test/agent-lifecycle/agentLifecycle.test.ts
+++ b/packages/agent-core-v2/test/agent-lifecycle/agentLifecycle.test.ts
@@ -1,12 +1,12 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
-import { TestInstantiationService } from '#/_base/di/test';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
import { IAgentLifecycleService } from '#/agent-lifecycle/agentLifecycle';
import { AgentLifecycleService } from '#/agent-lifecycle/agentLifecycleService';
-import { ISessionMetaStore } from '#/records';
-import { ISessionContext } from '#/session-context/sessionContext';
+import { registerRecordsServices } from '../records/stubs';
+import { registerSessionContextServices } from '../session-context/stubs';
describe('AgentLifecycleService', () => {
let disposables: DisposableStore;
@@ -14,10 +14,12 @@ describe('AgentLifecycleService', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(ISessionContext, {});
- ix.stub(ISessionMetaStore, {});
- ix.set(IAgentLifecycleService, new SyncDescriptor(AgentLifecycleService));
+ ix = createServices(disposables, {
+ base: [registerSessionContextServices, registerRecordsServices],
+ additionalServices: (reg) => {
+ reg.define(IAgentLifecycleService, AgentLifecycleService);
+ },
+ });
});
afterEach(() => disposables.dispose());
diff --git a/packages/agent-core-v2/test/agent-lifecycle/stubs.ts b/packages/agent-core-v2/test/agent-lifecycle/stubs.ts
new file mode 100644
index 000000000..c7ec6bb52
--- /dev/null
+++ b/packages/agent-core-v2/test/agent-lifecycle/stubs.ts
@@ -0,0 +1,19 @@
+/**
+ * `agent-lifecycle` test stubs — shared `IAgentLifecycleService` placeholder.
+ *
+ * Lives under `test/` (not `src/`) so test-support code stays out of the
+ * production tree. Import from a relative path (`./stubs` or
+ * `../agent-lifecycle/stubs`).
+ */
+
+import type { ServiceRegistration } from '#/_base/di/test';
+import { IAgentLifecycleService } from '#/agent-lifecycle/agentLifecycle';
+
+/**
+ * Register an empty `IAgentLifecycleService` placeholder. Tests that need a
+ * lifecycle service with handles should register a custom fake via
+ * `additionalServices` instead.
+ */
+export function registerAgentLifecycleServices(reg: ServiceRegistration): void {
+ reg.definePartialInstance(IAgentLifecycleService, {});
+}
diff --git a/packages/agent-core-v2/test/approval/approval.test.ts b/packages/agent-core-v2/test/approval/approval.test.ts
index d9b11b8c5..8206f5aa7 100644
--- a/packages/agent-core-v2/test/approval/approval.test.ts
+++ b/packages/agent-core-v2/test/approval/approval.test.ts
@@ -1,9 +1,9 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
-import { TestInstantiationService } from '#/_base/di/test';
-import { IApprovalService } from '#/approval';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
+import { IApprovalService } from '#/approval/approval';
import { ApprovalService } from '#/approval/approvalService';
describe('ApprovalService', () => {
@@ -12,8 +12,11 @@ describe('ApprovalService', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.set(IApprovalService, new SyncDescriptor(ApprovalService));
+ ix = createServices(disposables, {
+ additionalServices: (reg) => {
+ reg.define(IApprovalService, ApprovalService);
+ },
+ });
});
afterEach(() => disposables.dispose());
diff --git a/packages/agent-core-v2/test/auth/auth.test.ts b/packages/agent-core-v2/test/auth/auth.test.ts
index 60d75fc81..33e0c791e 100644
--- a/packages/agent-core-v2/test/auth/auth.test.ts
+++ b/packages/agent-core-v2/test/auth/auth.test.ts
@@ -1,14 +1,14 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
-import { TestInstantiationService } from '#/_base/di/test';
-import { IOAuthService } from '#/auth';
-import { IConfigService } from '#/config';
-import { IEnvironmentService } from '#/environment';
-import { ITelemetryService } from '#/telemetry';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
+import { IOAuthService } from '#/auth/auth';
import { OAuthService } from '#/auth/authService';
+import { registerConfigServices } from '../config/stubs';
+import { registerEnvironmentServices } from '../environment/stubs';
+import { registerTelemetryServices } from '../telemetry/stubs';
describe('OAuthService', () => {
let disposables: DisposableStore;
@@ -16,11 +16,16 @@ describe('OAuthService', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(IConfigService, {});
- ix.stub(IEnvironmentService, {});
- ix.stub(ITelemetryService, {});
- ix.set(IOAuthService, new SyncDescriptor(OAuthService));
+ ix = createServices(disposables, {
+ base: [
+ registerConfigServices,
+ registerEnvironmentServices,
+ registerTelemetryServices,
+ ],
+ additionalServices: (reg) => {
+ reg.define(IOAuthService, OAuthService);
+ },
+ });
});
afterEach(() => disposables.dispose());
diff --git a/packages/agent-core-v2/test/background/background.test.ts b/packages/agent-core-v2/test/background/background.test.ts
index 11ea79c9a..a9c3e4fc5 100644
--- a/packages/agent-core-v2/test/background/background.test.ts
+++ b/packages/agent-core-v2/test/background/background.test.ts
@@ -1,16 +1,16 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
-import { TestInstantiationService } from '#/_base/di/test';
-import { IAgentLifecycleService } from '#/agent-lifecycle/agentLifecycle';
-import { IBackgroundService } from '#/background';
-import { IKaosService } from '#/kaos';
-import { ILogService } from '#/log';
-import { IAgentRecords } from '#/records';
-import { ITelemetryService } from '#/telemetry';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
+import { IBackgroundService } from '#/background/background';
+import { IKaosService } from '#/kaos/kaos';
import { BackgroundService } from '#/background/backgroundService';
+import { registerAgentLifecycleServices } from '../agent-lifecycle/stubs';
+import { registerLogServices } from '../log/stubs';
+import { registerRecordsServices } from '../records/stubs';
+import { registerTelemetryServices } from '../telemetry/stubs';
describe('BackgroundService', () => {
let disposables: DisposableStore;
@@ -18,13 +18,18 @@ describe('BackgroundService', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(IKaosService, {});
- ix.stub(IAgentRecords, {});
- ix.stub(ILogService, {});
- ix.stub(ITelemetryService, {});
- ix.stub(IAgentLifecycleService, {});
- ix.set(IBackgroundService, new SyncDescriptor(BackgroundService));
+ ix = createServices(disposables, {
+ base: [
+ registerLogServices,
+ registerTelemetryServices,
+ registerRecordsServices,
+ registerAgentLifecycleServices,
+ ],
+ additionalServices: (reg) => {
+ reg.definePartialInstance(IKaosService, {});
+ reg.define(IBackgroundService, BackgroundService);
+ },
+ });
});
afterEach(() => disposables.dispose());
diff --git a/packages/agent-core-v2/test/compaction/compaction.test.ts b/packages/agent-core-v2/test/compaction/compaction.test.ts
index 7b176776b..bafa8dee6 100644
--- a/packages/agent-core-v2/test/compaction/compaction.test.ts
+++ b/packages/agent-core-v2/test/compaction/compaction.test.ts
@@ -1,19 +1,18 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
-import { TestInstantiationService } from '#/_base/di/test';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
import { CompactionService } from '#/compaction/compactionService';
-import { IAgentConfigService } from '#/config';
-import { IContextService } from '#/context';
+import { IContextService } from '#/context/context';
import { ContextService } from '#/context/contextService';
-import { IInjectionService } from '#/injection';
+import { IInjectionService } from '#/injection/injection';
import { InjectionService } from '#/injection/injectionService';
-import { IAgentRecords } from '#/records';
-import { stubAgentRecords } from '../records/stubs';
-import { ITelemetryService } from '#/telemetry';
-import { ITurnService } from '#/turn';
-import { stubTurn } from '../turn/stubs';
+import { ITurnService } from '#/turn/turn';
+import { registerConfigServices } from '../config/stubs';
+import { registerRecordsServices } from '../records/stubs';
+import { registerTelemetryServices } from '../telemetry/stubs';
+import { registerTurnServices } from '../turn/stubs';
describe('CompactionService', () => {
let disposables: DisposableStore;
@@ -21,13 +20,18 @@ describe('CompactionService', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(IAgentRecords, stubAgentRecords());
- ix.stub(IAgentConfigService, {});
- ix.stub(ITelemetryService, {});
- ix.stub(ITurnService, stubTurn());
- ix.set(IContextService, new SyncDescriptor(ContextService));
- ix.set(IInjectionService, new SyncDescriptor(InjectionService));
+ ix = createServices(disposables, {
+ base: [
+ registerRecordsServices,
+ registerConfigServices,
+ registerTelemetryServices,
+ registerTurnServices,
+ ],
+ additionalServices: (reg) => {
+ reg.define(IContextService, ContextService);
+ reg.define(IInjectionService, InjectionService);
+ },
+ });
});
afterEach(() => disposables.dispose());
diff --git a/packages/agent-core-v2/test/config/config.test.ts b/packages/agent-core-v2/test/config/config.test.ts
index 14fd2d263..293bf2478 100644
--- a/packages/agent-core-v2/test/config/config.test.ts
+++ b/packages/agent-core-v2/test/config/config.test.ts
@@ -1,19 +1,17 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { IKaosService } from '#/kaos';
+import { IKaosService } from '#/kaos/kaos';
-import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
-import { TestInstantiationService } from '#/_base/di/test';
-import { IEnvironmentService } from '#/environment';
-import { stubEnvironment } from '../environment/stubs';
-import { ILogService } from '#/log';
-import { stubLog } from '../log/stubs';
-import { IAgentRecords } from '#/records';
-import { stubAgentRecords } from '../records/stubs';
-import { IAgentConfigService, IConfigRegistry, IConfigService } from '#/config';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
+import { IAgentConfigService, IConfigService } from '#/config/config';
import { AgentConfigService, ConfigRegistry, ConfigService } from '#/config/configService';
+import { registerConfigServices } from '../config/stubs';
+import { registerEnvironmentServices } from '../environment/stubs';
+import { registerLogServices } from '../log/stubs';
+import { registerRecordsServices } from '../records/stubs';
describe('ConfigRegistry', () => {
it('registers and retrieves a section', () => {
@@ -45,11 +43,16 @@ describe('ConfigService', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(IConfigRegistry, new ConfigRegistry());
- ix.stub(IEnvironmentService, stubEnvironment());
- ix.stub(ILogService, stubLog());
- ix.set(IConfigService, new SyncDescriptor(ConfigService));
+ ix = createServices(disposables, {
+ base: [
+ registerConfigServices,
+ registerEnvironmentServices,
+ registerLogServices,
+ ],
+ additionalServices: (reg) => {
+ reg.define(IConfigService, ConfigService);
+ },
+ });
});
afterEach(() => disposables.dispose());
@@ -86,12 +89,15 @@ describe('AgentConfigService', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
agentSection = {};
- ix.stub(IConfigService, { get: () => agentSection as T });
- ix.stub(IAgentRecords, stubAgentRecords());
- ix.stub(IKaosService, agentKaos);
- ix.set(IAgentConfigService, new SyncDescriptor(AgentConfigService));
+ ix = createServices(disposables, {
+ base: [registerRecordsServices],
+ additionalServices: (reg) => {
+ reg.definePartialInstance(IConfigService, { get: () => agentSection as T });
+ reg.defineInstance(IKaosService, agentKaos);
+ reg.define(IAgentConfigService, AgentConfigService);
+ },
+ });
});
afterEach(() => disposables.dispose());
diff --git a/packages/agent-core-v2/test/config/stubs.ts b/packages/agent-core-v2/test/config/stubs.ts
new file mode 100644
index 000000000..3df3a6e27
--- /dev/null
+++ b/packages/agent-core-v2/test/config/stubs.ts
@@ -0,0 +1,22 @@
+/**
+ * `config` test stubs — shared config collaborators for unit tests.
+ *
+ * Lives under `test/` (not `src/`) so test-support code stays out of the
+ * production tree. Import from a relative path (`./stubs` or `../config/stubs`).
+ */
+
+import type { ServiceRegistration } from '#/_base/di/test';
+import { IAgentConfigService, IConfigRegistry, IConfigService } from '#/config/config';
+import { ConfigRegistry } from '#/config/configService';
+
+/**
+ * Register the default config collaborators: a real `ConfigRegistry` plus empty
+ * `IConfigService` / `IAgentConfigService` placeholders. Tests exercising the
+ * real `ConfigService` / `AgentConfigService` should override the placeholder
+ * via `additionalServices`.
+ */
+export function registerConfigServices(reg: ServiceRegistration): void {
+ reg.defineInstance(IConfigRegistry, new ConfigRegistry());
+ reg.definePartialInstance(IConfigService, {});
+ reg.definePartialInstance(IAgentConfigService, {});
+}
diff --git a/packages/agent-core-v2/test/context/context.test.ts b/packages/agent-core-v2/test/context/context.test.ts
index 2e36166ff..518206723 100644
--- a/packages/agent-core-v2/test/context/context.test.ts
+++ b/packages/agent-core-v2/test/context/context.test.ts
@@ -1,11 +1,11 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DisposableStore } from '#/_base/di/lifecycle';
-import { SyncDescriptor } from '#/_base/di/descriptors';
-import { TestInstantiationService } from '#/_base/di/test';
-import { IContextService } from '#/context';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
+import { IContextService } from '#/context/context';
import { ContextService } from '#/context/contextService';
-import { IAgentRecords } from '#/records';
+import { registerRecordsServices } from '../records/stubs';
describe('ContextService', () => {
let disposables: DisposableStore;
@@ -13,9 +13,12 @@ describe('ContextService', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(IAgentRecords, { _serviceBrand: undefined });
- ix.set(IContextService, new SyncDescriptor(ContextService));
+ ix = createServices(disposables, {
+ base: [registerRecordsServices],
+ additionalServices: (reg) => {
+ reg.define(IContextService, ContextService);
+ },
+ });
});
afterEach(() => disposables.dispose());
diff --git a/packages/agent-core-v2/test/context/stubs.ts b/packages/agent-core-v2/test/context/stubs.ts
new file mode 100644
index 000000000..8f29d1696
--- /dev/null
+++ b/packages/agent-core-v2/test/context/stubs.ts
@@ -0,0 +1,17 @@
+/**
+ * `context` test stubs — shared `IContextService` placeholder.
+ *
+ * Lives under `test/` (not `src/`) so test-support code stays out of the
+ * production tree. Import from a relative path (`./stubs` or `../context/stubs`).
+ */
+
+import type { ServiceRegistration } from '#/_base/di/test';
+import { IContextService } from '#/context/context';
+
+/**
+ * Register an empty `IContextService` placeholder. Tests exercising the real
+ * `ContextService` should override it via `additionalServices`.
+ */
+export function registerContextServices(reg: ServiceRegistration): void {
+ reg.definePartialInstance(IContextService, {});
+}
diff --git a/packages/agent-core-v2/test/cron/cron.test.ts b/packages/agent-core-v2/test/cron/cron.test.ts
index 3e3040120..5099e9dbf 100644
--- a/packages/agent-core-v2/test/cron/cron.test.ts
+++ b/packages/agent-core-v2/test/cron/cron.test.ts
@@ -1,21 +1,19 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { SyncDescriptor } from '#/_base/di/descriptors';
import type { ServicesAccessor } from '#/_base/di/instantiation';
import { DisposableStore } from '#/_base/di/lifecycle';
import { type IScopeHandle, LifecycleScope } from '#/_base/di/scope';
-import { TestInstantiationService } from '#/_base/di/test';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
import { IAgentLifecycleService } from '#/agent-lifecycle/agentLifecycle';
-import { ICronFireCoordinator, ICronService } from '#/cron';
+import { ICronFireCoordinator, ICronService } from '#/cron/cron';
import { CronFireCoordinator, CronService } from '#/cron/cronService';
-import { IEnvironmentService } from '#/environment';
-import { stubEnvironment } from '../environment/stubs';
-import { ILogService } from '#/log';
-import { stubLog } from '../log/stubs';
-import { ISessionMetaStore } from '#/records';
import { ISessionActivity } from '#/session-activity/sessionActivity';
-import { ISessionContext } from '#/session-context/sessionContext';
-import { ITelemetryService } from '#/telemetry';
+import { registerEnvironmentServices } from '../environment/stubs';
+import { registerLogServices } from '../log/stubs';
+import { registerRecordsServices } from '../records/stubs';
+import { registerSessionContextServices } from '../session-context/stubs';
+import { registerTelemetryServices } from '../telemetry/stubs';
import { stubTurn } from '../turn/stubs';
function activity(idle: boolean): ISessionActivity {
@@ -28,13 +26,18 @@ describe('CronService', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(ISessionContext, {});
- ix.stub(ITelemetryService, {});
- ix.stub(ILogService, stubLog());
- ix.stub(IEnvironmentService, stubEnvironment());
- ix.stub(ISessionMetaStore, {});
- ix.set(ICronService, new SyncDescriptor(CronService));
+ ix = createServices(disposables, {
+ base: [
+ registerSessionContextServices,
+ registerTelemetryServices,
+ registerLogServices,
+ registerEnvironmentServices,
+ registerRecordsServices,
+ ],
+ additionalServices: (reg) => {
+ reg.define(ICronService, CronService);
+ },
+ });
});
afterEach(() => disposables.dispose());
@@ -76,15 +79,20 @@ describe('CronService', () => {
describe('CronFireCoordinator', () => {
it('steers the main agent on fire', async () => {
const disposables = new DisposableStore();
- const ix = disposables.add(new TestInstantiationService());
- ix.stub(ISessionContext, {});
- ix.stub(ITelemetryService, {});
- ix.stub(ILogService, stubLog());
- ix.stub(IEnvironmentService, stubEnvironment());
- ix.stub(ISessionMetaStore, {});
- ix.stub(ISessionActivity, activity(true));
- ix.set(ICronService, new SyncDescriptor(CronService));
- ix.set(ICronFireCoordinator, new SyncDescriptor(CronFireCoordinator));
+ const ix = createServices(disposables, {
+ base: [
+ registerSessionContextServices,
+ registerTelemetryServices,
+ registerLogServices,
+ registerEnvironmentServices,
+ registerRecordsServices,
+ ],
+ additionalServices: (reg) => {
+ reg.defineInstance(ISessionActivity, activity(true));
+ reg.define(ICronService, CronService);
+ reg.define(ICronFireCoordinator, CronFireCoordinator);
+ },
+ });
const turn = stubTurn();
const handle: IScopeHandle = {
diff --git a/packages/agent-core-v2/test/environment/stubs.ts b/packages/agent-core-v2/test/environment/stubs.ts
index 8c317a94e..de8f40437 100644
--- a/packages/agent-core-v2/test/environment/stubs.ts
+++ b/packages/agent-core-v2/test/environment/stubs.ts
@@ -6,7 +6,8 @@
* `../environment/stubs`).
*/
-import type { IEnvironmentService } from '#/environment';
+import type { ServiceRegistration } from '#/_base/di/test';
+import { IEnvironmentService } from '#/environment/environment';
/**
* An `IEnvironmentService` rooted at the given home dir. `detect()` rejects
@@ -21,3 +22,8 @@ export function stubEnvironment(homeDir = '/tmp/kimi-home'): IEnvironmentService
detect: () => Promise.reject(new Error('unused in test')),
};
}
+
+/** Register the default `IEnvironmentService` rooted at `/tmp/kimi-home`. */
+export function registerEnvironmentServices(reg: ServiceRegistration): void {
+ reg.defineInstance(IEnvironmentService, stubEnvironment());
+}
diff --git a/packages/agent-core-v2/test/event/emitter.test.ts b/packages/agent-core-v2/test/event/emitter.test.ts
new file mode 100644
index 000000000..14b8ca15d
--- /dev/null
+++ b/packages/agent-core-v2/test/event/emitter.test.ts
@@ -0,0 +1,140 @@
+import { describe, expect, it } from 'vitest';
+
+import { AsyncEmitter, handleVetos, type IWaitUntil, type IWaitUntilData } from '#/_base/event';
+
+interface TestEvent extends IWaitUntil {
+ readonly value: number;
+}
+
+function fire(
+ emitter: AsyncEmitter,
+ data: IWaitUntilData,
+ signal = new AbortController().signal,
+): Promise {
+ return emitter.fireAsync(data, signal);
+}
+
+describe('AsyncEmitter', () => {
+ it('resolves without delivering when there are no listeners', async () => {
+ const emitter = new AsyncEmitter();
+ await expect(fire(emitter, { value: 1 })).resolves.toBeUndefined();
+ });
+
+ it('awaits every waitUntil promise before resolving', async () => {
+ const emitter = new AsyncEmitter();
+ const order: string[] = [];
+ emitter.event((e) => {
+ e.waitUntil(
+ new Promise((resolve) => setTimeout(resolve, 10)).then(() => {
+ order.push('a');
+ }),
+ );
+ e.waitUntil(
+ Promise.resolve().then(() => {
+ order.push('b');
+ }),
+ );
+ });
+ await fire(emitter, { value: 1 });
+ expect(order.sort()).toEqual(['a', 'b']);
+ });
+
+ it('delivers to listeners sequentially in registration order', async () => {
+ const emitter = new AsyncEmitter();
+ const order: string[] = [];
+ emitter.event((e) => {
+ e.waitUntil(
+ new Promise((resolve) => setTimeout(resolve, 10)).then(() => {
+ order.push('first-done');
+ }),
+ );
+ order.push('first');
+ });
+ emitter.event(() => {
+ order.push('second');
+ });
+ await fire(emitter, { value: 1 });
+ expect(order).toEqual(['first', 'first-done', 'second']);
+ });
+
+ it('exposes the abort signal and data fields on the event', async () => {
+ const emitter = new AsyncEmitter();
+ const ac = new AbortController();
+ let seen: { value: number; aborted: boolean } | undefined;
+ emitter.event((e) => {
+ seen = { value: e.value, aborted: e.signal.aborted };
+ });
+ await fire(emitter, { value: 42 }, ac.signal);
+ expect(seen).toEqual({ value: 42, aborted: false });
+ });
+
+ it('rejects waitUntil calls made after the synchronous delivery phase', async () => {
+ const emitter = new AsyncEmitter();
+ let captured: TestEvent | undefined;
+ emitter.event((e) => {
+ captured = e;
+ });
+ await fire(emitter, { value: 1 });
+ expect(captured).toBeDefined();
+ expect(() => captured!.waitUntil(Promise.resolve())).toThrow(/asynchronously/);
+ });
+
+ it('stops delivering once the signal is aborted', async () => {
+ const emitter = new AsyncEmitter();
+ const ac = new AbortController();
+ const seen: string[] = [];
+ emitter.event((e) => {
+ seen.push('first');
+ e.waitUntil(
+ Promise.resolve().then(() => {
+ ac.abort();
+ }),
+ );
+ });
+ emitter.event(() => {
+ seen.push('second');
+ });
+ await fire(emitter, { value: 1 }, ac.signal);
+ expect(seen).toEqual(['first']);
+ });
+
+ it('isolates a throwing listener and still delivers to the rest', async () => {
+ const emitter = new AsyncEmitter();
+ const seen: string[] = [];
+ emitter.event(() => {
+ throw new Error('boom');
+ });
+ emitter.event(() => {
+ seen.push('ok');
+ });
+ await fire(emitter, { value: 1 });
+ expect(seen).toEqual(['ok']);
+ });
+});
+
+describe('handleVetos', () => {
+ const noop = (): void => {};
+
+ it('returns false when there are no vetos', async () => {
+ await expect(handleVetos([], noop)).resolves.toBe(false);
+ });
+
+ it('short-circuits to true on a synchronous veto', async () => {
+ await expect(handleVetos([false, true, Promise.resolve(true)], noop)).resolves.toBe(true);
+ });
+
+ it('returns false when all vetos are false', async () => {
+ await expect(handleVetos([false, Promise.resolve(false)], noop)).resolves.toBe(false);
+ });
+
+ it('returns true when a promise veto resolves to true', async () => {
+ await expect(handleVetos([false, Promise.resolve(true)], noop)).resolves.toBe(true);
+ });
+
+ it('treats a rejected promise veto as a veto and reports the error', async () => {
+ const errors: unknown[] = [];
+ const result = await handleVetos([Promise.reject(new Error('boom'))], (e) => errors.push(e));
+ expect(result).toBe(true);
+ expect(errors).toHaveLength(1);
+ });
+});
diff --git a/packages/agent-core-v2/test/filestore/filestore.test.ts b/packages/agent-core-v2/test/filestore/filestore.test.ts
index 52af07c86..8942074ef 100644
--- a/packages/agent-core-v2/test/filestore/filestore.test.ts
+++ b/packages/agent-core-v2/test/filestore/filestore.test.ts
@@ -1,11 +1,11 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
-import { TestInstantiationService } from '#/_base/di/test';
-import { IFileStore } from '#/filestore';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
+import { IFileStore } from '#/filestore/filestore';
import { FileStore } from '#/filestore/fileStoreService';
-import { IKaosFactory } from '#/kaos';
+import { IKaosFactory } from '#/kaos/kaos';
describe('FileStore', () => {
let disposables: DisposableStore;
@@ -13,9 +13,12 @@ describe('FileStore', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(IKaosFactory, { _serviceBrand: undefined });
- ix.set(IFileStore, new SyncDescriptor(FileStore));
+ ix = createServices(disposables, {
+ additionalServices: (reg) => {
+ reg.definePartialInstance(IKaosFactory, {});
+ reg.define(IFileStore, FileStore);
+ },
+ });
});
afterEach(() => disposables.dispose());
diff --git a/packages/agent-core-v2/test/flag/flag.test.ts b/packages/agent-core-v2/test/flag/flag.test.ts
index 7a84d7e77..1022943ee 100644
--- a/packages/agent-core-v2/test/flag/flag.test.ts
+++ b/packages/agent-core-v2/test/flag/flag.test.ts
@@ -1,21 +1,20 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
-import { TestInstantiationService } from '#/_base/di/test';
-import { IConfigRegistry, IConfigService } from '#/config';
-import { ConfigRegistry, ConfigService } from '#/config/configService';
-import { IEnvironmentService } from '#/environment';
-import { stubEnvironment } from '../environment/stubs';
-import { IFlagService } from '#/flag';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
+import { IConfigRegistry, IConfigService } from '#/config/config';
+import { ConfigService } from '#/config/configService';
+import { IFlagService } from '#/flag/flag';
import {
EXPERIMENTAL_SECTION,
FlagService,
MASTER_ENV,
} from '#/flag/flagService';
import { FlagRegistry } from '#/flag/registry';
-import { ILogService } from '#/log';
-import { stubLog } from '../log/stubs';
+import { registerConfigServices } from '../config/stubs';
+import { registerEnvironmentServices } from '../environment/stubs';
+import { registerLogServices } from '../log/stubs';
describe('FlagRegistry', () => {
it('lists registered definitions and resolves by id', () => {
@@ -37,12 +36,17 @@ describe('FlagService', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(IEnvironmentService, stubEnvironment());
- ix.stub(ILogService, stubLog());
- ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry));
- ix.set(IConfigService, new SyncDescriptor(ConfigService));
- ix.set(IFlagService, new SyncDescriptor(FlagService));
+ ix = createServices(disposables, {
+ base: [
+ registerConfigServices,
+ registerEnvironmentServices,
+ registerLogServices,
+ ],
+ additionalServices: (reg) => {
+ reg.define(IConfigService, ConfigService);
+ reg.define(IFlagService, FlagService);
+ },
+ });
});
afterEach(() => disposables.dispose());
diff --git a/packages/agent-core-v2/test/fs/fs.test.ts b/packages/agent-core-v2/test/fs/fs.test.ts
index 89fe3893b..33234ba63 100644
--- a/packages/agent-core-v2/test/fs/fs.test.ts
+++ b/packages/agent-core-v2/test/fs/fs.test.ts
@@ -6,15 +6,14 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { LocalKaos } from '@moonshot-ai/kaos';
-import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
-import { TestInstantiationService } from '#/_base/di/test';
-import { IFsService } from '#/fs';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
+import { IFsService } from '#/fs/fs';
import { FsService } from '#/fs/fsService';
-import { ISessionKaosService } from '#/kaos';
+import { ISessionKaosService } from '#/kaos/kaos';
import { SessionKaosService } from '#/kaos/sessionKaosService';
-import { ILogService } from '#/log';
-import { stubLog } from '../log/stubs';
+import { registerLogServices } from '../log/stubs';
describe('FsService', () => {
let dir: string;
@@ -26,10 +25,13 @@ describe('FsService', () => {
dir = await mkdtemp(join(tmpdir(), 'fs-test-'));
const base = await LocalKaos.create();
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(ILogService, stubLog());
- ix.set(ISessionKaosService, new SyncDescriptor(SessionKaosService));
- ix.set(IFsService, new SyncDescriptor(FsService));
+ ix = createServices(disposables, {
+ base: [registerLogServices],
+ additionalServices: (reg) => {
+ reg.define(ISessionKaosService, SessionKaosService);
+ reg.define(IFsService, FsService);
+ },
+ });
const sessionKaos = ix.get(ISessionKaosService);
sessionKaos.setToolKaos(base.withCwd(dir));
fs = ix.get(IFsService);
diff --git a/packages/agent-core-v2/test/gateway/gateway.test.ts b/packages/agent-core-v2/test/gateway/gateway.test.ts
index a0536e5e1..1f3270d5c 100644
--- a/packages/agent-core-v2/test/gateway/gateway.test.ts
+++ b/packages/agent-core-v2/test/gateway/gateway.test.ts
@@ -1,10 +1,10 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { SyncDescriptor } from '#/_base/di/descriptors';
import type { ServicesAccessor } from '#/_base/di/instantiation';
import { DisposableStore } from '#/_base/di/lifecycle';
import { type IScopeHandle, LifecycleScope } from '#/_base/di/scope';
-import { TestInstantiationService } from '#/_base/di/test';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
import { IAgentLifecycleService } from '#/agent-lifecycle/agentLifecycle';
import { IRestGateway, IScopeRegistry } from '#/gateway';
import { RestGateway, ScopeRegistry } from '#/gateway/gatewayService';
@@ -16,8 +16,11 @@ describe('ScopeRegistry', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.set(IScopeRegistry, new SyncDescriptor(ScopeRegistry));
+ ix = createServices(disposables, {
+ additionalServices: (reg) => {
+ reg.define(IScopeRegistry, ScopeRegistry);
+ },
+ });
});
afterEach(() => disposables.dispose());
@@ -34,7 +37,11 @@ describe('ScopeRegistry', () => {
describe('RestGateway', () => {
it('routes prompt to the agent turn service', async () => {
const disposables = new DisposableStore();
- const ix = disposables.add(new TestInstantiationService());
+ const ix = createServices(disposables, {
+ additionalServices: (reg) => {
+ reg.define(IRestGateway, RestGateway);
+ },
+ });
const turn = stubTurn();
const agentHandle: IScopeHandle = {
@@ -61,7 +68,6 @@ describe('RestGateway', () => {
get: (id) => (id === 's1' ? sessionHandle : undefined),
close: () => Promise.resolve(),
});
- ix.set(IRestGateway, new SyncDescriptor(RestGateway));
const gw = ix.get(IRestGateway);
await gw.prompt('s1', 'main', 'hello');
diff --git a/packages/agent-core-v2/test/goal/goal.test.ts b/packages/agent-core-v2/test/goal/goal.test.ts
index 906fa1223..8fbad5218 100644
--- a/packages/agent-core-v2/test/goal/goal.test.ts
+++ b/packages/agent-core-v2/test/goal/goal.test.ts
@@ -1,15 +1,14 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
-import { TestInstantiationService } from '#/_base/di/test';
-import { IGoalService } from '#/goal';
-import { IInjectionService } from '#/injection';
-import { IAgentRecords } from '#/records';
-import { ITurnService } from '#/turn';
-import { stubTurn } from '../turn/stubs';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
+import { IGoalService } from '#/goal/goal';
import { GoalService } from '#/goal/goalService';
+import { registerInjectionServices } from '../injection/stubs';
+import { registerRecordsServices } from '../records/stubs';
+import { registerTurnServices } from '../turn/stubs';
describe('GoalService', () => {
let disposables: DisposableStore;
@@ -17,11 +16,12 @@ describe('GoalService', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(IAgentRecords, {});
- ix.stub(ITurnService, stubTurn());
- ix.stub(IInjectionService, {});
- ix.set(IGoalService, new SyncDescriptor(GoalService));
+ ix = createServices(disposables, {
+ base: [registerRecordsServices, registerTurnServices, registerInjectionServices],
+ additionalServices: (reg) => {
+ reg.define(IGoalService, GoalService);
+ },
+ });
});
afterEach(() => disposables.dispose());
diff --git a/packages/agent-core-v2/test/hooks/hooks.test.ts b/packages/agent-core-v2/test/hooks/hooks.test.ts
index 83cd6ae53..f465b0dd0 100644
--- a/packages/agent-core-v2/test/hooks/hooks.test.ts
+++ b/packages/agent-core-v2/test/hooks/hooks.test.ts
@@ -1,12 +1,12 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { SyncDescriptor } from '#/_base/di/descriptors';
-import { IConfigService } from '#/config';
import { DisposableStore } from '#/_base/di/lifecycle';
-import { TestInstantiationService } from '#/_base/di/test';
-import { IHookEngine } from '#/hooks';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
+import { IHookEngine } from '#/hooks/hooks';
import { HookEngine } from '#/hooks/hookEngine';
-import { ILogService } from '#/log';
+import { registerConfigServices } from '../config/stubs';
+import { registerLogServices } from '../log/stubs';
describe('HookEngine', () => {
let disposables: DisposableStore;
@@ -14,10 +14,12 @@ describe('HookEngine', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(IConfigService, { _serviceBrand: undefined });
- ix.stub(ILogService, { _serviceBrand: undefined });
- ix.set(IHookEngine, new SyncDescriptor(HookEngine));
+ ix = createServices(disposables, {
+ base: [registerConfigServices, registerLogServices],
+ additionalServices: (reg) => {
+ reg.define(IHookEngine, HookEngine);
+ },
+ });
});
afterEach(() => disposables.dispose());
diff --git a/packages/agent-core-v2/test/injection/injection.test.ts b/packages/agent-core-v2/test/injection/injection.test.ts
index 491b7acda..bf3658df9 100644
--- a/packages/agent-core-v2/test/injection/injection.test.ts
+++ b/packages/agent-core-v2/test/injection/injection.test.ts
@@ -1,11 +1,11 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { IContextService } from '#/context';
-import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
-import { TestInstantiationService } from '#/_base/di/test';
-import { IInjectionQueue, IInjectionService } from '#/injection';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
+import { IInjectionQueue, IInjectionService } from '#/injection/injection';
import { InjectionQueue, InjectionService } from '#/injection/injectionService';
+import { registerContextServices } from '../context/stubs';
describe('InjectionService', () => {
let disposables: DisposableStore;
@@ -13,9 +13,12 @@ describe('InjectionService', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(IContextService, { _serviceBrand: undefined });
- ix.set(IInjectionService, new SyncDescriptor(InjectionService));
+ ix = createServices(disposables, {
+ base: [registerContextServices],
+ additionalServices: (reg) => {
+ reg.define(IInjectionService, InjectionService);
+ },
+ });
});
afterEach(() => disposables.dispose());
@@ -37,8 +40,11 @@ describe('InjectionQueue', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.set(IInjectionQueue, new SyncDescriptor(InjectionQueue));
+ ix = createServices(disposables, {
+ additionalServices: (reg) => {
+ reg.define(IInjectionQueue, InjectionQueue);
+ },
+ });
});
afterEach(() => disposables.dispose());
diff --git a/packages/agent-core-v2/test/injection/stubs.ts b/packages/agent-core-v2/test/injection/stubs.ts
new file mode 100644
index 000000000..3d609c5cf
--- /dev/null
+++ b/packages/agent-core-v2/test/injection/stubs.ts
@@ -0,0 +1,18 @@
+/**
+ * `injection` test stubs — shared `IInjectionService` placeholder.
+ *
+ * Lives under `test/` (not `src/`) so test-support code stays out of the
+ * production tree. Import from a relative path (`./stubs` or
+ * `../injection/stubs`).
+ */
+
+import type { ServiceRegistration } from '#/_base/di/test';
+import { IInjectionService } from '#/injection/injection';
+
+/**
+ * Register an empty `IInjectionService` placeholder. Tests exercising the real
+ * `InjectionService` should override it via `additionalServices`.
+ */
+export function registerInjectionServices(reg: ServiceRegistration): void {
+ reg.definePartialInstance(IInjectionService, {});
+}
diff --git a/packages/agent-core-v2/test/kaos/kaos.test.ts b/packages/agent-core-v2/test/kaos/kaos.test.ts
index 4989cd459..6c5b17cce 100644
--- a/packages/agent-core-v2/test/kaos/kaos.test.ts
+++ b/packages/agent-core-v2/test/kaos/kaos.test.ts
@@ -2,17 +2,16 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { LocalKaos } from '@moonshot-ai/kaos';
-import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
-import { TestInstantiationService } from '#/_base/di/test';
-import { IEnvironmentService } from '#/environment';
-import { ILogService } from '#/log';
-import { stubLog } from '../log/stubs';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
-import { IKaosService, IKaosFactory, ISessionKaosService } from '#/kaos';
+import { IKaosService, IKaosFactory, ISessionKaosService } from '#/kaos/kaos';
import { AgentKaos } from '#/kaos/agentKaos';
import { KaosFactory } from '#/kaos/kaosFactory';
import { SessionKaosService } from '#/kaos/sessionKaosService';
+import { registerEnvironmentServices } from '../environment/stubs';
+import { registerLogServices } from '../log/stubs';
describe('KaosFactory', () => {
let disposables: DisposableStore;
@@ -20,10 +19,12 @@ describe('KaosFactory', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(IEnvironmentService, {});
- ix.stub(ILogService, stubLog());
- ix.set(IKaosFactory, new SyncDescriptor(KaosFactory));
+ ix = createServices(disposables, {
+ base: [registerEnvironmentServices, registerLogServices],
+ additionalServices: (reg) => {
+ reg.define(IKaosFactory, KaosFactory);
+ },
+ });
});
afterEach(() => disposables.dispose());
@@ -53,9 +54,12 @@ describe('SessionKaosService', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(ILogService, stubLog());
- ix.set(ISessionKaosService, new SyncDescriptor(SessionKaosService));
+ ix = createServices(disposables, {
+ base: [registerLogServices],
+ additionalServices: (reg) => {
+ reg.define(ISessionKaosService, SessionKaosService);
+ },
+ });
});
afterEach(() => disposables.dispose());
@@ -107,10 +111,13 @@ describe('AgentKaos', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(ILogService, stubLog());
- ix.set(ISessionKaosService, new SyncDescriptor(SessionKaosService));
- ix.set(IKaosService, new SyncDescriptor(AgentKaos));
+ ix = createServices(disposables, {
+ base: [registerLogServices],
+ additionalServices: (reg) => {
+ reg.define(ISessionKaosService, SessionKaosService);
+ reg.define(IKaosService, AgentKaos);
+ },
+ });
});
afterEach(() => disposables.dispose());
diff --git a/packages/agent-core-v2/test/kosong/kosong.test.ts b/packages/agent-core-v2/test/kosong/kosong.test.ts
index e43b6ebc2..7d5948f6b 100644
--- a/packages/agent-core-v2/test/kosong/kosong.test.ts
+++ b/packages/agent-core-v2/test/kosong/kosong.test.ts
@@ -1,17 +1,16 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
-import { TestInstantiationService } from '#/_base/di/test';
-import { IConfigRegistry, IConfigService } from '#/config';
-import { IEnvironmentService } from '#/environment';
-import { stubEnvironment } from '../environment/stubs';
-import { IModelCatalogService, IProviderManager } from '#/kosong';
-import { ILogService } from '#/log';
-import { stubLog } from '../log/stubs';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
+import { IConfigService } from '#/config/config';
+import { IModelCatalogService, IProviderManager } from '#/kosong/kosong';
-import { ConfigRegistry, ConfigService } from '#/config/configService';
+import { ConfigService } from '#/config/configService';
import { ModelCatalogService, ProviderManager } from '#/kosong/kosongService';
+import { registerConfigServices } from '../config/stubs';
+import { registerEnvironmentServices } from '../environment/stubs';
+import { registerLogServices } from '../log/stubs';
describe('ModelCatalogService', () => {
let disposables: DisposableStore;
@@ -20,12 +19,17 @@ describe('ModelCatalogService', () => {
beforeEach(async () => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(IConfigRegistry, new ConfigRegistry());
- ix.stub(IEnvironmentService, stubEnvironment());
- ix.stub(ILogService, stubLog());
- ix.set(IConfigService, new SyncDescriptor(ConfigService));
- ix.set(IModelCatalogService, new SyncDescriptor(ModelCatalogService));
+ ix = createServices(disposables, {
+ base: [
+ registerConfigServices,
+ registerEnvironmentServices,
+ registerLogServices,
+ ],
+ additionalServices: (reg) => {
+ reg.define(IConfigService, ConfigService);
+ reg.define(IModelCatalogService, ModelCatalogService);
+ },
+ });
const config = ix.get(IConfigService);
await config.set('kosong', {
providers: [
@@ -63,13 +67,18 @@ describe('ProviderManager', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(IConfigRegistry, new ConfigRegistry());
- ix.stub(IEnvironmentService, stubEnvironment());
- ix.stub(ILogService, stubLog());
- ix.set(IConfigService, new SyncDescriptor(ConfigService));
- ix.set(IModelCatalogService, new SyncDescriptor(ModelCatalogService));
- ix.set(IProviderManager, new SyncDescriptor(ProviderManager));
+ ix = createServices(disposables, {
+ base: [
+ registerConfigServices,
+ registerEnvironmentServices,
+ registerLogServices,
+ ],
+ additionalServices: (reg) => {
+ reg.define(IConfigService, ConfigService);
+ reg.define(IModelCatalogService, ModelCatalogService);
+ reg.define(IProviderManager, ProviderManager);
+ },
+ });
config = ix.get(IConfigService);
});
afterEach(() => disposables.dispose());
diff --git a/packages/agent-core-v2/test/kosong/stubs.ts b/packages/agent-core-v2/test/kosong/stubs.ts
new file mode 100644
index 000000000..e7150dc0b
--- /dev/null
+++ b/packages/agent-core-v2/test/kosong/stubs.ts
@@ -0,0 +1,14 @@
+/**
+ * `kosong` test stubs — shared kosong collaborators for unit tests.
+ *
+ * Lives under `test/` (not `src/`) so test-support code stays out of the
+ * production tree. Import from a relative path (`./stubs` or `../kosong/stubs`).
+ */
+
+import type { ServiceRegistration } from '#/_base/di/test';
+import { ILLMService } from '#/kosong/kosong';
+
+/** Register an empty `ILLMService` placeholder. */
+export function registerKosongServices(reg: ServiceRegistration): void {
+ reg.definePartialInstance(ILLMService, {});
+}
diff --git a/packages/agent-core-v2/test/log/stubs.ts b/packages/agent-core-v2/test/log/stubs.ts
index f1bd0a07b..90ca1487a 100644
--- a/packages/agent-core-v2/test/log/stubs.ts
+++ b/packages/agent-core-v2/test/log/stubs.ts
@@ -5,7 +5,9 @@
* production tree. Import from a relative path (`./stubs` or `../log/stubs`).
*/
-import type { ILogger, ILogService } from '#/log';
+import type { ServiceRegistration } from '#/_base/di/test';
+import { ILogService } from '#/log/log';
+import type { ILogger } from '#/log/log';
/** A no-op `ILogger`: every method is a no-op, `child()` returns itself. */
export function stubLogger(): ILogger {
@@ -28,3 +30,8 @@ export function stubLog(): ILogService {
setLevel: () => {},
};
}
+
+/** Register the default no-op `ILogService`. */
+export function registerLogServices(reg: ServiceRegistration): void {
+ reg.defineInstance(ILogService, stubLog());
+}
diff --git a/packages/agent-core-v2/test/mcp/mcp.test.ts b/packages/agent-core-v2/test/mcp/mcp.test.ts
index 909d410fd..7599233e4 100644
--- a/packages/agent-core-v2/test/mcp/mcp.test.ts
+++ b/packages/agent-core-v2/test/mcp/mcp.test.ts
@@ -1,15 +1,15 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
-import { TestInstantiationService } from '#/_base/di/test';
-import { IOAuthService } from '#/auth';
-import { IConfigService } from '#/config';
-import { ILogService } from '#/log';
-import { IMcpService } from '#/mcp';
-import { ITelemetryService } from '#/telemetry';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
+import { IOAuthService } from '#/auth/auth';
+import { IMcpService } from '#/mcp/mcp';
import { McpService } from '#/mcp/mcpService';
+import { registerConfigServices } from '../config/stubs';
+import { registerLogServices } from '../log/stubs';
+import { registerTelemetryServices } from '../telemetry/stubs';
describe('McpService', () => {
let disposables: DisposableStore;
@@ -17,12 +17,17 @@ describe('McpService', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(IConfigService, {});
- ix.stub(ILogService, {});
- ix.stub(ITelemetryService, {});
- ix.stub(IOAuthService, {});
- ix.set(IMcpService, new SyncDescriptor(McpService));
+ ix = createServices(disposables, {
+ base: [
+ registerConfigServices,
+ registerLogServices,
+ registerTelemetryServices,
+ ],
+ additionalServices: (reg) => {
+ reg.definePartialInstance(IOAuthService, {});
+ reg.define(IMcpService, McpService);
+ },
+ });
});
afterEach(() => disposables.dispose());
diff --git a/packages/agent-core-v2/test/message/message.test.ts b/packages/agent-core-v2/test/message/message.test.ts
index d766a044f..dcfe361f6 100644
--- a/packages/agent-core-v2/test/message/message.test.ts
+++ b/packages/agent-core-v2/test/message/message.test.ts
@@ -1,14 +1,13 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
-import { TestInstantiationService } from '#/_base/di/test';
-import { IContextService } from '#/context';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
+import { IContextService } from '#/context/context';
import { ContextService } from '#/context/contextService';
-import { IMessageService } from '#/message';
+import { IMessageService } from '#/message/message';
import { MessageService } from '#/message/messageService';
-import { IAgentRecords } from '#/records';
-import { stubAgentRecords } from '../records/stubs';
+import { registerRecordsServices } from '../records/stubs';
describe('MessageService', () => {
let disposables: DisposableStore;
@@ -16,12 +15,15 @@ describe('MessageService', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- // Dependencies: real ContextService (itself backed by a stubbed IAgentRecords).
- ix.stub(IAgentRecords, stubAgentRecords());
- ix.set(IContextService, new SyncDescriptor(ContextService));
- // System under test, registered by interface so the binding is exercised.
- ix.set(IMessageService, new SyncDescriptor(MessageService));
+ ix = createServices(disposables, {
+ base: [registerRecordsServices],
+ additionalServices: (reg) => {
+ // Dependencies: real ContextService (itself backed by a stubbed IAgentRecords).
+ reg.define(IContextService, ContextService);
+ // System under test, registered by interface so the binding is exercised.
+ reg.define(IMessageService, MessageService);
+ },
+ });
});
afterEach(() => disposables.dispose());
diff --git a/packages/agent-core-v2/test/permission/permission.test.ts b/packages/agent-core-v2/test/permission/permission.test.ts
index 933839d06..0ea1ea798 100644
--- a/packages/agent-core-v2/test/permission/permission.test.ts
+++ b/packages/agent-core-v2/test/permission/permission.test.ts
@@ -1,23 +1,21 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
-import { TestInstantiationService } from '#/_base/di/test';
-import { IApprovalService } from '#/approval';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
+import { IApprovalService } from '#/approval/approval';
import { ApprovalService } from '#/approval/approvalService';
-import { IAgentConfigService } from '#/config';
-import { ILogService } from '#/log';
-import { stubLog } from '../log/stubs';
import {
IPermissionPolicyRegistry,
IPermissionService,
-} from '#/permission';
+} from '#/permission/permission';
import {
PermissionPolicyRegistry,
PermissionService,
} from '#/permission/permissionService';
-import { IAgentRecords } from '#/records';
-import { stubAgentRecords } from '../records/stubs';
+import { registerConfigServices } from '../config/stubs';
+import { registerLogServices } from '../log/stubs';
+import { registerRecordsServices } from '../records/stubs';
describe('PermissionPolicyRegistry', () => {
it('returns the first non-undefined decision', () => {
@@ -40,13 +38,14 @@ describe('PermissionService', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(IAgentConfigService, {});
- ix.stub(IAgentRecords, stubAgentRecords());
- ix.stub(ILogService, stubLog());
- ix.set(IPermissionPolicyRegistry, new SyncDescriptor(PermissionPolicyRegistry));
- ix.set(IApprovalService, new SyncDescriptor(ApprovalService));
- ix.set(IPermissionService, new SyncDescriptor(PermissionService));
+ ix = createServices(disposables, {
+ base: [registerConfigServices, registerRecordsServices, registerLogServices],
+ additionalServices: (reg) => {
+ reg.define(IPermissionPolicyRegistry, PermissionPolicyRegistry);
+ reg.define(IApprovalService, ApprovalService);
+ reg.define(IPermissionService, PermissionService);
+ },
+ });
});
afterEach(() => disposables.dispose());
diff --git a/packages/agent-core-v2/test/permission/stubs.ts b/packages/agent-core-v2/test/permission/stubs.ts
new file mode 100644
index 000000000..d30dd86f1
--- /dev/null
+++ b/packages/agent-core-v2/test/permission/stubs.ts
@@ -0,0 +1,18 @@
+/**
+ * `permission` test stubs — shared `IPermissionService` placeholder.
+ *
+ * Lives under `test/` (not `src/`) so test-support code stays out of the
+ * production tree. Import from a relative path (`./stubs` or
+ * `../permission/stubs`).
+ */
+
+import type { ServiceRegistration } from '#/_base/di/test';
+import { IPermissionService } from '#/permission/permission';
+
+/**
+ * Register an empty `IPermissionService` placeholder. Tests exercising the real
+ * `PermissionService` should override it via `additionalServices`.
+ */
+export function registerPermissionServices(reg: ServiceRegistration): void {
+ reg.definePartialInstance(IPermissionService, {});
+}
diff --git a/packages/agent-core-v2/test/plan/plan.test.ts b/packages/agent-core-v2/test/plan/plan.test.ts
index ba78477e0..52a340337 100644
--- a/packages/agent-core-v2/test/plan/plan.test.ts
+++ b/packages/agent-core-v2/test/plan/plan.test.ts
@@ -1,20 +1,19 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
-import { TestInstantiationService } from '#/_base/di/test';
-import { IAgentConfigService } from '#/config';
-import { IContextService } from '#/context';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
+import { IContextService } from '#/context/context';
import { ContextService } from '#/context/contextService';
-import { IInjectionService } from '#/injection';
+import { IInjectionService } from '#/injection/injection';
import { InjectionService } from '#/injection/injectionService';
-import { IKaosService } from '#/kaos';
-import { IPlanService } from '#/plan';
+import { IAgentKaos } from '#/kaos/kaos';
+import { IPlanService } from '#/plan/plan';
import { PlanService } from '#/plan/planService';
-import { IAgentRecords } from '#/records';
-import { stubAgentRecords } from '../records/stubs';
-import { ITurnService } from '#/turn';
-import { stubTurn } from '../turn/stubs';
+import { ITurnService } from '#/turn/turn';
+import { registerConfigServices } from '../config/stubs';
+import { registerRecordsServices } from '../records/stubs';
+import { registerTurnServices } from '../turn/stubs';
describe('PlanService', () => {
let disposables: DisposableStore;
@@ -22,14 +21,15 @@ describe('PlanService', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(IAgentRecords, stubAgentRecords());
- ix.stub(IKaosService, {});
- ix.stub(IAgentConfigService, {});
- ix.stub(ITurnService, stubTurn());
- ix.set(IContextService, new SyncDescriptor(ContextService));
- ix.set(IInjectionService, new SyncDescriptor(InjectionService));
- ix.set(IPlanService, new SyncDescriptor(PlanService));
+ ix = createServices(disposables, {
+ base: [registerRecordsServices, registerConfigServices, registerTurnServices],
+ additionalServices: (reg) => {
+ reg.definePartialInstance(IAgentKaos, {});
+ reg.define(IContextService, ContextService);
+ reg.define(IInjectionService, InjectionService);
+ reg.define(IPlanService, PlanService);
+ },
+ });
});
afterEach(() => disposables.dispose());
diff --git a/packages/agent-core-v2/test/question/question.test.ts b/packages/agent-core-v2/test/question/question.test.ts
index e45b12d8e..f37a6919b 100644
--- a/packages/agent-core-v2/test/question/question.test.ts
+++ b/packages/agent-core-v2/test/question/question.test.ts
@@ -1,9 +1,9 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
-import { TestInstantiationService } from '#/_base/di/test';
-import { IQuestionService } from '#/question';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
+import { IQuestionService } from '#/question/question';
import { QuestionService } from '#/question/questionService';
describe('QuestionService', () => {
@@ -12,8 +12,11 @@ describe('QuestionService', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.set(IQuestionService, new SyncDescriptor(QuestionService));
+ ix = createServices(disposables, {
+ additionalServices: (reg) => {
+ reg.define(IQuestionService, QuestionService);
+ },
+ });
});
afterEach(() => disposables.dispose());
diff --git a/packages/agent-core-v2/test/records/records.test.ts b/packages/agent-core-v2/test/records/records.test.ts
index e15bd290c..1fbb22c55 100644
--- a/packages/agent-core-v2/test/records/records.test.ts
+++ b/packages/agent-core-v2/test/records/records.test.ts
@@ -6,23 +6,22 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { LocalKaos } from '@moonshot-ai/kaos';
-import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
-import { TestInstantiationService } from '#/_base/di/test';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
import { AgentKaos } from '#/kaos/agentKaos';
-import { IKaosService, ISessionKaosService } from '#/kaos';
+import { IKaosService, ISessionKaosService } from '#/kaos/kaos';
import { SessionKaosService } from '#/kaos/sessionKaosService';
-import { ILogService } from '#/log';
-import { stubLog } from '../log/stubs';
import {
IAgentRecords,
ISessionMetaStore,
-} from '#/records';
+} from '#/records/records';
import {
AgentRecords,
SessionMetaStore,
encodeWorkDirKey,
} from '#/records/recordsService';
+import { registerLogServices } from '../log/stubs';
describe('encodeWorkDirKey', () => {
it('is deterministic and path-sensitive', () => {
@@ -44,10 +43,13 @@ describe('SessionMetaStore', () => {
dir = await mkdtemp(join(tmpdir(), 'records-test-'));
const base = await LocalKaos.create();
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(ILogService, stubLog());
- ix.set(ISessionKaosService, new SyncDescriptor(SessionKaosService));
- ix.set(ISessionMetaStore, new SyncDescriptor(SessionMetaStore));
+ ix = createServices(disposables, {
+ base: [registerLogServices],
+ additionalServices: (reg) => {
+ reg.define(ISessionKaosService, SessionKaosService);
+ reg.define(ISessionMetaStore, SessionMetaStore);
+ },
+ });
const sessionKaos = ix.get(ISessionKaosService);
sessionKaos.setToolKaos(base.withCwd(dir));
});
@@ -84,11 +86,14 @@ describe('AgentRecords', () => {
dir = await mkdtemp(join(tmpdir(), 'records-test-'));
const base = await LocalKaos.create();
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(ILogService, stubLog());
- ix.set(ISessionKaosService, new SyncDescriptor(SessionKaosService));
- ix.set(IKaosService, new SyncDescriptor(AgentKaos));
- ix.set(IAgentRecords, new SyncDescriptor(AgentRecords));
+ ix = createServices(disposables, {
+ base: [registerLogServices],
+ additionalServices: (reg) => {
+ reg.define(ISessionKaosService, SessionKaosService);
+ reg.define(IKaosService, AgentKaos);
+ reg.define(IAgentRecords, AgentRecords);
+ },
+ });
const sessionKaos = ix.get(ISessionKaosService);
sessionKaos.setToolKaos(base.withCwd(dir));
records = ix.get(IAgentRecords);
diff --git a/packages/agent-core-v2/test/records/stubs.ts b/packages/agent-core-v2/test/records/stubs.ts
index 08e8f6d00..4941ec72f 100644
--- a/packages/agent-core-v2/test/records/stubs.ts
+++ b/packages/agent-core-v2/test/records/stubs.ts
@@ -5,7 +5,8 @@
* production tree. Import from a relative path (`./stubs` or `../records/stubs`).
*/
-import type { IAgentRecords } from '#/records';
+import type { ServiceRegistration } from '#/_base/di/test';
+import { IAgentRecords, ISessionMetaStore } from '#/records/records';
/**
* A no-op `IAgentRecords`: writes vanish, replay yields nothing, restore is a
@@ -23,3 +24,12 @@ export function stubAgentRecords(): IAgentRecords {
restore: () => Promise.resolve(),
};
}
+
+/**
+ * Register the default records collaborators: a no-op `IAgentRecords` and an
+ * empty `ISessionMetaStore` placeholder.
+ */
+export function registerRecordsServices(reg: ServiceRegistration): void {
+ reg.defineInstance(IAgentRecords, stubAgentRecords());
+ reg.definePartialInstance(ISessionMetaStore, {});
+}
diff --git a/packages/agent-core-v2/test/session-activity/sessionActivity.test.ts b/packages/agent-core-v2/test/session-activity/sessionActivity.test.ts
index 06350c62a..1df766d95 100644
--- a/packages/agent-core-v2/test/session-activity/sessionActivity.test.ts
+++ b/packages/agent-core-v2/test/session-activity/sessionActivity.test.ts
@@ -1,10 +1,10 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { SyncDescriptor } from '#/_base/di/descriptors';
import type { ServicesAccessor } from '#/_base/di/instantiation';
import { DisposableStore } from '#/_base/di/lifecycle';
import { type IScopeHandle, LifecycleScope } from '#/_base/di/scope';
-import { TestInstantiationService } from '#/_base/di/test';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
import { IAgentLifecycleService } from '#/agent-lifecycle/agentLifecycle';
import { ISessionActivity } from '#/session-activity/sessionActivity';
import { SessionActivity } from '#/session-activity/sessionActivityService';
@@ -33,8 +33,11 @@ describe('SessionActivity', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.set(ISessionActivity, new SyncDescriptor(SessionActivity));
+ ix = createServices(disposables, {
+ additionalServices: (reg) => {
+ reg.define(ISessionActivity, SessionActivity);
+ },
+ });
});
afterEach(() => disposables.dispose());
diff --git a/packages/agent-core-v2/test/session-context/stubs.ts b/packages/agent-core-v2/test/session-context/stubs.ts
new file mode 100644
index 000000000..3e7c1b3e1
--- /dev/null
+++ b/packages/agent-core-v2/test/session-context/stubs.ts
@@ -0,0 +1,15 @@
+/**
+ * `session-context` test stubs — shared `ISessionContext` placeholder.
+ *
+ * Lives under `test/` (not `src/`) so test-support code stays out of the
+ * production tree. Import from a relative path (`./stubs` or
+ * `../session-context/stubs`).
+ */
+
+import type { ServiceRegistration } from '#/_base/di/test';
+import { ISessionContext } from '#/session-context/sessionContext';
+
+/** Register an empty `ISessionContext` placeholder. */
+export function registerSessionContextServices(reg: ServiceRegistration): void {
+ reg.definePartialInstance(ISessionContext, {});
+}
diff --git a/packages/agent-core-v2/test/session/session.test.ts b/packages/agent-core-v2/test/session/session.test.ts
index b07b65b3a..5019d426d 100644
--- a/packages/agent-core-v2/test/session/session.test.ts
+++ b/packages/agent-core-v2/test/session/session.test.ts
@@ -1,16 +1,16 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { SyncDescriptor } from '#/_base/di/descriptors';
import type { ServicesAccessor } from '#/_base/di/instantiation';
import { DisposableStore } from '#/_base/di/lifecycle';
import { type IScopeHandle, LifecycleScope } from '#/_base/di/scope';
-import { TestInstantiationService } from '#/_base/di/test';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
import { IAgentLifecycleService } from '#/agent-lifecycle/agentLifecycle';
-import { IEventService } from '#/event';
-import { ISessionMetaStore } from '#/records';
+import { IEventService } from '#/event/event';
import { ISessionActivity } from '#/session-activity/sessionActivity';
-import { ISessionService } from '#/session';
+import { ISessionService } from '#/session/session';
import { SessionService } from '#/session/sessionService';
+import { registerRecordsServices } from '../records/stubs';
const handle: IScopeHandle = {
id: 'main',
@@ -24,10 +24,13 @@ describe('SessionService', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(ISessionMetaStore, {});
- ix.stub(IEventService, {});
- ix.set(ISessionService, new SyncDescriptor(SessionService));
+ ix = createServices(disposables, {
+ base: [registerRecordsServices],
+ additionalServices: (reg) => {
+ reg.definePartialInstance(IEventService, {});
+ reg.define(ISessionService, SessionService);
+ },
+ });
});
afterEach(() => disposables.dispose());
diff --git a/packages/agent-core-v2/test/skill/skill.test.ts b/packages/agent-core-v2/test/skill/skill.test.ts
index 5b120c832..ec7bdc1d2 100644
--- a/packages/agent-core-v2/test/skill/skill.test.ts
+++ b/packages/agent-core-v2/test/skill/skill.test.ts
@@ -1,16 +1,16 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
-import { TestInstantiationService } from '#/_base/di/test';
-import { IConfigService } from '#/config';
-import { ILogService } from '#/log';
-import { IAgentRecords } from '#/records';
-import { ISkillRegistry, ISkillService } from '#/skill';
-import { ITurnService } from '#/turn';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
+import { ISkillRegistry, ISkillService } from '#/skill/skill';
+import { ITurnService } from '#/turn/turn';
import { stubTurn } from '../turn/stubs';
import { SkillRegistry, SkillService } from '#/skill/skillService';
+import { registerConfigServices } from '../config/stubs';
+import { registerLogServices } from '../log/stubs';
+import { registerRecordsServices } from '../records/stubs';
describe('SkillRegistry', () => {
let disposables: DisposableStore;
@@ -18,10 +18,12 @@ describe('SkillRegistry', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(IConfigService, {});
- ix.stub(ILogService, {});
- ix.set(ISkillRegistry, new SyncDescriptor(SkillRegistry));
+ ix = createServices(disposables, {
+ base: [registerConfigServices, registerLogServices],
+ additionalServices: (reg) => {
+ reg.define(ISkillRegistry, SkillRegistry);
+ },
+ });
});
afterEach(() => disposables.dispose());
@@ -40,12 +42,17 @@ describe('SkillService', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(IConfigService, {});
- ix.stub(ILogService, {});
- ix.stub(IAgentRecords, {});
- ix.set(ISkillRegistry, new SyncDescriptor(SkillRegistry));
- ix.set(ISkillService, new SyncDescriptor(SkillService));
+ ix = createServices(disposables, {
+ base: [
+ registerConfigServices,
+ registerLogServices,
+ registerRecordsServices,
+ ],
+ additionalServices: (reg) => {
+ reg.define(ISkillRegistry, SkillRegistry);
+ reg.define(ISkillService, SkillService);
+ },
+ });
});
afterEach(() => disposables.dispose());
diff --git a/packages/agent-core-v2/test/swarm/swarm.test.ts b/packages/agent-core-v2/test/swarm/swarm.test.ts
index 425c8e1fd..329d84039 100644
--- a/packages/agent-core-v2/test/swarm/swarm.test.ts
+++ b/packages/agent-core-v2/test/swarm/swarm.test.ts
@@ -1,13 +1,13 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
-import { TestInstantiationService } from '#/_base/di/test';
-import { IAgentLifecycleService } from '#/agent-lifecycle/agentLifecycle';
-import { IPermissionService } from '#/permission';
-import { IAgentRecords } from '#/records';
-import { ISwarmService } from '#/swarm';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
+import { ISwarmService } from '#/swarm/swarm';
import { SwarmService } from '#/swarm/swarmService';
+import { registerAgentLifecycleServices } from '../agent-lifecycle/stubs';
+import { registerPermissionServices } from '../permission/stubs';
+import { registerRecordsServices } from '../records/stubs';
describe('SwarmService', () => {
let disposables: DisposableStore;
@@ -15,11 +15,16 @@ describe('SwarmService', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(IAgentRecords, {});
- ix.stub(IAgentLifecycleService, {});
- ix.stub(IPermissionService, {});
- ix.set(ISwarmService, new SyncDescriptor(SwarmService));
+ ix = createServices(disposables, {
+ base: [
+ registerRecordsServices,
+ registerAgentLifecycleServices,
+ registerPermissionServices,
+ ],
+ additionalServices: (reg) => {
+ reg.define(ISwarmService, SwarmService);
+ },
+ });
});
afterEach(() => disposables.dispose());
diff --git a/packages/agent-core-v2/test/telemetry/stubs.ts b/packages/agent-core-v2/test/telemetry/stubs.ts
new file mode 100644
index 000000000..4e4a7f6c8
--- /dev/null
+++ b/packages/agent-core-v2/test/telemetry/stubs.ts
@@ -0,0 +1,18 @@
+/**
+ * `telemetry` test stubs — shared `ITelemetryService` placeholder for unit tests.
+ *
+ * Lives under `test/` (not `src/`) so test-support code stays out of the
+ * production tree. Import from a relative path (`./stubs` or
+ * `../telemetry/stubs`).
+ */
+
+import type { ServiceRegistration } from '#/_base/di/test';
+import { ITelemetryService } from '#/telemetry/telemetry';
+
+/**
+ * Register an empty `ITelemetryService` placeholder. Tests that assert on
+ * telemetry should register a spy via `additionalServices` instead.
+ */
+export function registerTelemetryServices(reg: ServiceRegistration): void {
+ reg.definePartialInstance(ITelemetryService, {});
+}
diff --git a/packages/agent-core-v2/test/terminal/terminal.test.ts b/packages/agent-core-v2/test/terminal/terminal.test.ts
index b1c97a072..57ba8cc36 100644
--- a/packages/agent-core-v2/test/terminal/terminal.test.ts
+++ b/packages/agent-core-v2/test/terminal/terminal.test.ts
@@ -6,15 +6,14 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { LocalKaos } from '@moonshot-ai/kaos';
-import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
-import { TestInstantiationService } from '#/_base/di/test';
-import { ISessionKaosService } from '#/kaos';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
+import { ISessionKaosService } from '#/kaos/kaos';
import { SessionKaosService } from '#/kaos/sessionKaosService';
-import { ILogService } from '#/log';
-import { stubLog } from '../log/stubs';
-import { ITerminalService } from '#/terminal';
+import { ITerminalService } from '#/terminal/terminal';
import { TerminalService } from '#/terminal/terminalService';
+import { registerLogServices } from '../log/stubs';
describe('TerminalService', () => {
let dir: string;
@@ -26,10 +25,13 @@ describe('TerminalService', () => {
dir = await mkdtemp(join(tmpdir(), 'term-test-'));
const base = await LocalKaos.create();
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(ILogService, stubLog());
- ix.set(ISessionKaosService, new SyncDescriptor(SessionKaosService));
- ix.set(ITerminalService, new SyncDescriptor(TerminalService));
+ ix = createServices(disposables, {
+ base: [registerLogServices],
+ additionalServices: (reg) => {
+ reg.define(ISessionKaosService, SessionKaosService);
+ reg.define(ITerminalService, TerminalService);
+ },
+ });
const sessionKaos = ix.get(ISessionKaosService);
sessionKaos.setToolKaos(base.withCwd(dir));
terminal = ix.get(ITerminalService);
diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts
index 5aa22ea74..9d761af8a 100644
--- a/packages/agent-core-v2/test/tool/tool.test.ts
+++ b/packages/agent-core-v2/test/tool/tool.test.ts
@@ -1,20 +1,20 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
-import { TestInstantiationService } from '#/_base/di/test';
-import { IAgentConfigService } from '#/config';
-import { IKaosService } from '#/kaos';
-import { ILLMService } from '#/kosong';
-import { IPermissionService } from '#/permission';
-import { IAgentRecords } from '#/records';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
+import { IKaosService } from '#/kaos/kaos';
import {
IToolDefinitionRegistry,
IToolService,
type ToolCallResult,
type ToolDefinition,
-} from '#/tool';
+} from '#/tool/tool';
import { ToolDefinitionRegistry, ToolService } from '#/tool/toolService';
+import { registerConfigServices } from '../config/stubs';
+import { registerKosongServices } from '../kosong/stubs';
+import { registerPermissionServices } from '../permission/stubs';
+import { registerRecordsServices } from '../records/stubs';
const echoDef: ToolDefinition = {
name: 'echo',
@@ -41,16 +41,21 @@ describe('ToolService', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
reg = new ToolDefinitionRegistry();
reg.register(echoDef);
- ix.set(IToolDefinitionRegistry, reg);
- ix.stub(IAgentConfigService, {});
- ix.stub(IAgentRecords, {});
- ix.stub(IKaosService, {});
- ix.stub(IPermissionService, {});
- ix.stub(ILLMService, {});
- ix.set(IToolService, new SyncDescriptor(ToolService));
+ ix = createServices(disposables, {
+ base: [
+ registerConfigServices,
+ registerRecordsServices,
+ registerPermissionServices,
+ registerKosongServices,
+ ],
+ additionalServices: (r) => {
+ r.defineInstance(IToolDefinitionRegistry, reg);
+ r.definePartialInstance(IKaosService, {});
+ r.define(IToolService, ToolService);
+ },
+ });
});
afterEach(() => disposables.dispose());
diff --git a/packages/agent-core-v2/test/turn/stubs.ts b/packages/agent-core-v2/test/turn/stubs.ts
index f77a0b622..a2efb88e6 100644
--- a/packages/agent-core-v2/test/turn/stubs.ts
+++ b/packages/agent-core-v2/test/turn/stubs.ts
@@ -6,13 +6,15 @@
*/
import { Emitter, type Event } from '#/_base/event';
+import type { ServiceRegistration } from '#/_base/di/test';
+import { ITurnService } from '#/turn/turn';
import type {
- ITurnService,
TurnEndEvent,
TurnStartEvent,
TurnStepEvent,
TurnToolEvent,
-} from '#/turn';
+ TurnWillExecuteToolEvent,
+} from '#/turn/turn';
const noneEvent = ((): Event => () => ({ dispose: () => {} }))();
@@ -43,7 +45,7 @@ export function stubTurn(options: StubTurnOptions = {}): StubTurn {
return {
_serviceBrand: undefined,
onWillStartTurn: noneEvent as Event,
- onWillExecuteTool: noneEvent as Event,
+ onWillExecuteTool: noneEvent as Event,
onDidFinalizeTool: noneEvent as Event,
onDidEndStep: endStep.event,
onDidEndTurn: endTurn.event,
@@ -70,3 +72,12 @@ export function stubTurn(options: StubTurnOptions = {}): StubTurn {
steered,
};
}
+
+/**
+ * Register the default `ITurnService` stub. Tests that need to inspect
+ * `prompts` / `steered` should create their own `stubTurn()` and register it
+ * via `additionalServices` instead.
+ */
+export function registerTurnServices(reg: ServiceRegistration): void {
+ reg.defineInstance(ITurnService, stubTurn());
+}
diff --git a/packages/agent-core-v2/test/turn/toolCallExecutor.test.ts b/packages/agent-core-v2/test/turn/toolCallExecutor.test.ts
new file mode 100644
index 000000000..f7d4327ea
--- /dev/null
+++ b/packages/agent-core-v2/test/turn/toolCallExecutor.test.ts
@@ -0,0 +1,134 @@
+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 { type Decision, IPermissionService, type PermissionContext } from '#/permission/permission';
+import { type ToolCallResult, type ToolDefinition, IToolService } from '#/tool/tool';
+import { ITurnContext, ITurnEvents } from '#/turn/turn';
+
+import { TurnEvents } from '#/turn/turnEvents';
+import { ToolCallExecutor } from '#/turn/toolCallExecutor';
+import { registerLogServices } from '../log/stubs';
+
+describe('ToolCallExecutor', () => {
+ let disposables: DisposableStore;
+ let ix: TestInstantiationService;
+ let turnEvents: TurnEvents;
+ let executed: string[];
+ let decision: Decision;
+ let permission: IPermissionService;
+
+ function toolService(): IToolService {
+ return {
+ _serviceBrand: undefined,
+ execute: (name: string): Promise => {
+ executed.push(name);
+ return Promise.resolve({ output: `ran:${name}` });
+ },
+ list: (): readonly ToolDefinition[] => [],
+ registerUserTool: () => {},
+ registerMcpTools: () => {},
+ };
+ }
+
+ function permissionService(): IPermissionService {
+ return {
+ _serviceBrand: undefined,
+ beforeToolCall: (_ctx: PermissionContext): Promise => Promise.resolve(decision),
+ };
+ }
+
+ beforeEach(() => {
+ disposables = new DisposableStore();
+ executed = [];
+ decision = 'allow';
+ permission = permissionService();
+ turnEvents = new TurnEvents();
+ ix = createServices(disposables, {
+ base: [registerLogServices],
+ additionalServices: (reg) => {
+ reg.defineInstance(ITurnEvents, turnEvents);
+ reg.defineInstance(IToolService, toolService());
+ reg.definePartialInstance(ITurnContext, { turnId: 'turn-0' });
+ },
+ });
+
+ turnEvents.onWillExecuteTool((event) => {
+ event.veto(
+ Promise.resolve(
+ permission.beforeToolCall({ toolName: event.toolName, args: event.args }),
+ ).then((value) => value === 'deny'),
+ 'permission',
+ );
+ });
+ });
+ afterEach(() => disposables.dispose());
+
+ it('runs the tool and fires onDidFinalizeTool when permission allows', async () => {
+ const executor = ix.createInstance(ToolCallExecutor);
+ const finalized: string[] = [];
+ turnEvents.onDidFinalizeTool((e) => finalized.push(e.toolName));
+
+ const outcome = await executor.execute('call-1', 'echo', { text: 'hi' });
+
+ expect(outcome).toEqual({ vetoed: false, result: { output: 'ran:echo' } });
+ expect(executed).toEqual(['echo']);
+ expect(finalized).toEqual(['echo']);
+ });
+
+ it('vetoes the tool call when permission denies, skipping execution', async () => {
+ decision = 'deny';
+ permission = permissionService();
+ const executor = ix.createInstance(ToolCallExecutor);
+ const finalized: string[] = [];
+ turnEvents.onDidFinalizeTool((e) => finalized.push(e.toolName));
+
+ const outcome = await executor.execute('call-1', 'rm', {});
+
+ expect(outcome.vetoed).toBe(true);
+ if (outcome.vetoed) {
+ expect(outcome.reason).toBe('permission');
+ }
+ expect(executed).toEqual([]);
+ expect(finalized).toEqual([]);
+ });
+
+ it('awaits an asynchronous permission decision before vetoing', async () => {
+ permission = {
+ _serviceBrand: undefined,
+ beforeToolCall: (): Promise =>
+ new Promise((resolve) => setTimeout(() => resolve('deny'), 10)),
+ };
+ const executor = ix.createInstance(ToolCallExecutor);
+
+ const outcome = await executor.execute('call-1', 'rm', {});
+
+ expect(outcome.vetoed).toBe(true);
+ expect(executed).toEqual([]);
+ });
+
+ it('lets an external onWillExecuteTool listener veto the call', async () => {
+ const executor = ix.createInstance(ToolCallExecutor);
+ turnEvents.onWillExecuteTool((e) => {
+ e.veto(true, 'manual block');
+ });
+
+ const outcome = await executor.execute('call-1', 'echo', {});
+
+ expect(outcome).toEqual({ vetoed: true, reason: 'manual block' });
+ expect(executed).toEqual([]);
+ });
+
+ it('delivers tool name and args to onWillExecuteTool listeners', async () => {
+ const executor = ix.createInstance(ToolCallExecutor);
+ const seen: { toolName: string; args: unknown }[] = [];
+ turnEvents.onWillExecuteTool((e) => {
+ seen.push({ toolName: e.toolName, args: e.args });
+ });
+
+ await executor.execute('call-1', 'echo', { text: 'hi' });
+
+ expect(seen).toEqual([{ toolName: 'echo', args: { text: 'hi' } }]);
+ });
+});
diff --git a/packages/agent-core-v2/test/turn/turn.test.ts b/packages/agent-core-v2/test/turn/turn.test.ts
index 8e62a2f06..a1354d4f7 100644
--- a/packages/agent-core-v2/test/turn/turn.test.ts
+++ b/packages/agent-core-v2/test/turn/turn.test.ts
@@ -1,20 +1,21 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DisposableStore } from '#/_base/di/lifecycle';
-import { TestInstantiationService } from '#/_base/di/test';
-import { IAgentLifecycleService } from '#/agent-lifecycle/agentLifecycle';
-import { IContextService } from '#/context';
-import { IInjectionService } from '#/injection';
-import { ILLMService } from '#/kosong';
-import { ILogService } from '#/log';
-import { IPermissionService } from '#/permission';
-import { ITelemetryService } from '#/telemetry';
-import { IToolService } from '#/tool';
-import { ILoopRunner } from '#/turn';
-import { IUsageService } from '#/usage';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
+import { ILoopRunner, ITurnEvents } from '#/turn/turn';
+import { IUsageService } from '#/usage/usage';
import { LoopRunner } from '#/turn/loopRunner';
+import { TurnEvents } from '#/turn/turnEvents';
import { TurnService } from '#/turn/turnService';
+import { registerAgentLifecycleServices } from '../agent-lifecycle/stubs';
+import { registerContextServices } from '../context/stubs';
+import { registerInjectionServices } from '../injection/stubs';
+import { registerKosongServices } from '../kosong/stubs';
+import { registerLogServices } from '../log/stubs';
+import { registerPermissionServices } from '../permission/stubs';
+import { registerTelemetryServices } from '../telemetry/stubs';
describe('TurnService', () => {
let disposables: DisposableStore;
@@ -22,17 +23,22 @@ describe('TurnService', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(IContextService, {});
- ix.stub(IToolService, {});
- ix.stub(IPermissionService, {});
- ix.stub(ILLMService, {});
- ix.stub(IInjectionService, {});
- ix.stub(IUsageService, {});
- ix.stub(ITelemetryService, {});
- ix.stub(ILogService, {});
- ix.stub(IAgentLifecycleService, {});
- ix.set(ILoopRunner, new LoopRunner());
+ ix = createServices(disposables, {
+ base: [
+ registerLogServices,
+ registerTelemetryServices,
+ registerAgentLifecycleServices,
+ registerPermissionServices,
+ registerContextServices,
+ registerInjectionServices,
+ registerKosongServices,
+ ],
+ additionalServices: (reg) => {
+ reg.defineInstance(ITurnEvents, new TurnEvents());
+ reg.definePartialInstance(IUsageService, {});
+ reg.defineInstance(ILoopRunner, new LoopRunner());
+ },
+ });
});
afterEach(() => disposables.dispose());
diff --git a/packages/agent-core-v2/test/usage/usage.test.ts b/packages/agent-core-v2/test/usage/usage.test.ts
index 602fc74e8..21cafe8ee 100644
--- a/packages/agent-core-v2/test/usage/usage.test.ts
+++ b/packages/agent-core-v2/test/usage/usage.test.ts
@@ -1,12 +1,12 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
-import { TestInstantiationService } from '#/_base/di/test';
-import { IAgentRecords } from '#/records';
-import { ITelemetryService } from '#/telemetry';
-import { IUsageService } from '#/usage';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
+import { IUsageService } from '#/usage/usage';
import { UsageService } from '#/usage/usageService';
+import { registerRecordsServices } from '../records/stubs';
+import { registerTelemetryServices } from '../telemetry/stubs';
describe('UsageService', () => {
let disposables: DisposableStore;
@@ -14,10 +14,12 @@ describe('UsageService', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(IAgentRecords, { _serviceBrand: undefined });
- ix.stub(ITelemetryService, { _serviceBrand: undefined });
- ix.set(IUsageService, new SyncDescriptor(UsageService));
+ ix = createServices(disposables, {
+ base: [registerTelemetryServices, registerRecordsServices],
+ additionalServices: (reg) => {
+ reg.define(IUsageService, UsageService);
+ },
+ });
});
afterEach(() => disposables.dispose());
diff --git a/packages/agent-core-v2/test/workspace/workspace.test.ts b/packages/agent-core-v2/test/workspace/workspace.test.ts
index 3de01b9c7..db6b1baa3 100644
--- a/packages/agent-core-v2/test/workspace/workspace.test.ts
+++ b/packages/agent-core-v2/test/workspace/workspace.test.ts
@@ -1,13 +1,12 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
-import { TestInstantiationService } from '#/_base/di/test';
-import { IKaosFactory } from '#/kaos';
-import { ILogService } from '#/log';
-import { stubLog } from '../log/stubs';
-import { IWorkspaceFsService, IWorkspaceRegistry } from '#/workspace';
+import { createServices } from '#/_base/di/test';
+import type { TestInstantiationService } from '#/_base/di/test';
+import { IKaosFactory } from '#/kaos/kaos';
+import { IWorkspaceFsService, IWorkspaceRegistry } from '#/workspace/workspace';
import { WorkspaceFsService, WorkspaceRegistry } from '#/workspace/workspaceService';
+import { registerLogServices } from '../log/stubs';
describe('WorkspaceRegistry', () => {
let disposables: DisposableStore;
@@ -15,10 +14,13 @@ describe('WorkspaceRegistry', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(IKaosFactory, {});
- ix.stub(ILogService, stubLog());
- ix.set(IWorkspaceRegistry, new SyncDescriptor(WorkspaceRegistry));
+ ix = createServices(disposables, {
+ base: [registerLogServices],
+ additionalServices: (reg) => {
+ reg.definePartialInstance(IKaosFactory, {});
+ reg.define(IWorkspaceRegistry, WorkspaceRegistry);
+ },
+ });
});
afterEach(() => disposables.dispose());
@@ -37,11 +39,14 @@ describe('WorkspaceFsService', () => {
beforeEach(() => {
disposables = new DisposableStore();
- ix = disposables.add(new TestInstantiationService());
- ix.stub(IKaosFactory, {});
- ix.stub(ILogService, stubLog());
- ix.set(IWorkspaceRegistry, new SyncDescriptor(WorkspaceRegistry));
- ix.set(IWorkspaceFsService, new SyncDescriptor(WorkspaceFsService));
+ ix = createServices(disposables, {
+ base: [registerLogServices],
+ additionalServices: (reg) => {
+ reg.definePartialInstance(IKaosFactory, {});
+ reg.define(IWorkspaceRegistry, WorkspaceRegistry);
+ reg.define(IWorkspaceFsService, WorkspaceFsService);
+ },
+ });
});
afterEach(() => disposables.dispose());