refactor(agent-core-v2): rework DI test services and decompose turn domain

- rework createServices around ServiceRegistration groups (base + additionalServices) and add per-domain register*Services stubs
- extract loopRunner, toolCallExecutor and turnEvents out of turnService; switch turn lifecycle to typed events
- add AsyncEmitter / IWaitUntil / handleVetos to _base/event for interceptable onWill and veto events
- document DI x scope (docs/di.md, docs/service-design.md, scope-domain diagram) and refresh di-testing.md
- ignore plan/
This commit is contained in:
haozhe.yang 2026-06-25 23:10:24 +08:00
parent b21d8820d1
commit 67810a822f
67 changed files with 2672 additions and 816 deletions

1
.gitignore vendored
View file

@ -24,3 +24,4 @@ docker-compose.yml
docs/superpowers/
reports/
.superpowers/
plan/

View file

@ -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`**.

View file

@ -0,0 +1,310 @@
@startuml agent-core-v2-domains
top to bottom direction
skinparam shadowing false
skinparam nodesep 28
skinparam ranksep 55
legend right
<b>Scope = node color</b>
<back:#D6EAF8> </back> Core (process-wide, L0)
<back:#D5F5E3> </back> Session (per session, L1)
<back:#FDEBD0> </back> Agent (per agent, L2)
<back:#FADBD8> </back> Turn (per turn, L3)
<back:#E8DAEF> </back> Multi-scope (spans layers)
<back:#F4F6F7> </back> WIP: contract only
== Edges ==
<color:#34495E>solid</color> : DI injection (ctor @IX)
<color:#16A085>dashed</color> : event-driven (subscribe/emit)
direction: consumer ---> provider
endlegend
package "Core scope (process-wide)" #EAF3FB {
rectangle "<b>environment</b>\n<size:9><i>Core</i></size>\n IEnvironmentService" as environment #D6EAF8
rectangle "<b>log</b>\n<size:9><i>Core</i></size>\n ILogService\n ILogSink" as log #D6EAF8
rectangle "<b>telemetry</b>\n<size:9><i>Core</i></size>\n ITelemetryService" as telemetry #D6EAF8
rectangle "<b>event</b>\n<size:9><i>Core</i></size>\n IEventService" as event #D6EAF8
rectangle "<b>gateway</b>\n<size:9><i>Core</i></size>\n IScopeRegistry\n IRestGateway\n IWSGateway\n IWSBroadcastService" as gateway #D6EAF8
rectangle "<b>workspace</b>\n<size:9><i>Core</i></size>\n IWorkspaceRegistry\n IWorkspaceFsService" as workspace #D6EAF8
rectangle "<b>filestore</b>\n<size:9><i>Core</i></size>\n IFileStore" as filestore #D6EAF8
rectangle "<b>auth</b>\n<size:9><i>Core</i></size>\n IOAuthService\n IAuthSummaryService" as auth #D6EAF8
rectangle "<b>flag</b>\n<size:9><i>Core</i></size>\n IFlagService" as flag #D6EAF8
}
package "Session scope (per session)" #EAFAF1 {
rectangle "<b>session</b>\n<size:9><i>Session</i></size>\n ISessionService" as session #D5F5E3
rectangle "<b>fs</b>\n<size:9><i>Session</i></size>\n IFsService\n IFsSearchService\n IFsGitService\n IFsWatcher" as fs #D5F5E3
rectangle "<b>approval</b>\n<size:9><i>Session</i></size>\n IApprovalService" as approval #D5F5E3
rectangle "<b>question</b>\n<size:9><i>Session</i></size>\n IQuestionService" as question #D5F5E3
rectangle "<b>session-activity</b>\n<size:9><i>Session</i></size>\n ISessionActivity" as session_activity #D5F5E3
rectangle "<b>subagentHost</b>\n<size:9><i>Session</i></size>\n ISubagentHost" as subagentHost #D5F5E3
rectangle "<b>terminal</b>\n<size:9><i>Session</i></size>\n ITerminalService" as terminal #D5F5E3
rectangle "<b>agent-lifecycle</b>\n<size:9><i>Session</i></size>\n IAgentLifecycleService" as agent_lifecycle #D5F5E3
rectangle "<b>session-context</b>\n<size:9><i>Session</i></size>\n ISessionContext (seed)" as session_context #D5F5E3
}
package "Agent scope (per agent)" #FDF5E6 {
rectangle "<b>permissionMode</b>\n<size:9><i>Agent</i></size>\n IPermissionModeService" as permissionMode #FDEBD0
rectangle "<b>prompt</b>\n<size:9><i>Agent</i></size>\n IPromptService" as prompt #FDEBD0
rectangle "<b>userTool</b>\n<size:9><i>Agent</i></size>\n IUserToolService" as userTool #FDEBD0
rectangle "<b>llmRequestLog</b>\n<size:9><i>Agent</i></size>\n ILLMRequestLogService" as llmRequestLog #FDEBD0
rectangle "<b>dynamicInjector</b>\n<size:9><i>Agent</i></size>\n IDynamicInjector" as dynamicInjector #FDEBD0
rectangle "<b>replayBuilder</b>\n<size:9><i>Agent</i></size>\n IReplayBuilderService" as replayBuilder #FDEBD0
rectangle "<b>profile</b>\n<size:9><i>Agent</i></size>\n IProfileService" as profile #FDEBD0
rectangle "<b>rpc</b>\n<size:9><i>Agent</i></size>\n IAgentRPCService" as rpc #FDEBD0
rectangle "<b>contextMemory</b>\n<size:9><i>Agent</i></size>\n IContextMemory" as contextMemory #FDEBD0
rectangle "<b>toolExecutor</b>\n<size:9><i>Agent</i></size>\n IToolExecutor" as toolExecutor #FDEBD0
rectangle "<b>permission</b>\n<size:9><i>Agent</i></size>\n IPermissionService" as permission #FDEBD0
rectangle "<b>cron</b>\n<size:9><i>Agent</i></size>\n ICronService" as cron #FDEBD0
rectangle "<b>llmRequester</b>\n<size:9><i>Agent</i></size>\n ILLMRequester" as llmRequester #FDEBD0
rectangle "<b>fullCompaction</b>\n<size:9><i>Agent</i></size>\n IFullCompaction" as fullCompaction #FDEBD0
rectangle "<b>permissionRules</b>\n<size:9><i>Agent</i></size>\n IPermissionRulesService" as permissionRules #FDEBD0
rectangle "<b>skill</b>\n<size:9><i>Agent</i></size>\n IAgentSkillService" as skill #FDEBD0
rectangle "<b>goal</b>\n<size:9><i>Agent</i></size>\n IGoalService" as goal #FDEBD0
rectangle "<b>microCompaction</b>\n<size:9><i>Agent</i></size>\n IMicroCompactionService" as microCompaction #FDEBD0
rectangle "<b>externalHooks</b>\n<size:9><i>Agent</i></size>\n IExternalHooksService" as externalHooks #FDEBD0
rectangle "<b>swarm</b>\n<size:9><i>Agent</i></size>\n ISwarmService" as swarm #FDEBD0
rectangle "<b>background</b>\n<size:9><i>Agent</i></size>\n IBackgroundService" as background #FDEBD0
rectangle "<b>toolStore</b>\n<size:9><i>Agent</i></size>\n IToolStoreService" as toolStore #FDEBD0
rectangle "<b>wireRecord</b>\n<size:9><i>Agent</i></size>\n IWireRecord (event hub)" as wireRecord #FDEBD0
rectangle "<b>eventBus</b>\n<size:9><i>Agent</i></size>\n IEventBus" as eventBus #FDEBD0
rectangle "<b>toolRegistry</b>\n<size:9><i>Agent</i></size>\n IToolRegistry" as toolRegistry #FDEBD0
rectangle "<b>usage</b>\n<size:9><i>Agent</i></size>\n IUsageService" as usage #FDEBD0
rectangle "<b>message</b>\n<size:9><i>Agent</i></size>\n IMessageService" as message #FDEBD0
rectangle "<b>mcp</b>\n<size:9><i>Agent</i></size>\n IMcpService" as mcp #FDEBD0
rectangle "<b>contextSize</b>\n<size:9><i>Agent</i></size>\n IContextSizeService" as contextSize #FDEBD0
rectangle "<b>permissionPolicy</b>\n<size:9><i>Agent</i></size>\n IPermissionPolicyService" as permissionPolicy #FDEBD0
rectangle "<b>contextProjector</b>\n<size:9><i>Agent</i></size>\n IContextProjector" as contextProjector #FDEBD0
rectangle "<b>todoList</b>\n<size:9><i>Agent</i></size>\n ITodoListService" as todoList #FDEBD0
rectangle "<b>loop</b>\n<size:9><i>Agent</i></size>\n ILoopService" as loop #FDEBD0
rectangle "<b>turn</b>\n<size:9><i>Agent</i></size>\n ITurnService\n ITurnEvents\n ILoopRunner(T)\n IToolCallExecutor(T)" as turn #FDEBD0
rectangle "<b>plan</b>\n<size:9><i>Agent</i></size>\n IPlanService" as plan #FDEBD0
rectangle "<b>blobStore</b>\n<size:9><i>Agent</i></size>\n IBlobStoreService" as blobStore #FDEBD0
}
package "Turn scope (per turn)" #FDEDEC {
rectangle "<b>tooldedup</b>\n<size:9><i>Turn</i></size>\n IToolDedupService" as tooldedup #FADBD8
}
package "Multi-scope" #F5EEF8 {
rectangle "<b>config</b>\n<size:9><i>Multi</i></size>\n IConfigRegistry(C)\n IConfigService(C)\n IAgentConfigService(A)" as config #E8DAEF
rectangle "<b>kosong</b>\n<size:9><i>Multi</i></size>\n IModelCatalogService(C)\n IProviderManager(S)\n ILLMService(A)" as kosong #E8DAEF
rectangle "<b>records</b>\n<size:9><i>Multi</i></size>\n ISessionStore(C)\n ISessionMetaStore(S)\n IAgentRecords(A)" as records #E8DAEF
rectangle "<b>tool</b>\n<size:9><i>Multi</i></size>\n IToolDefinitionRegistry(C)\n IToolService(A)" as tool #E8DAEF
rectangle "<b>kaos</b>\n<size:9><i>Multi</i></size>\n IKaosFactory(C)\n ISessionKaosService(S)\n IKaosService / IAgentKaos(A)" as kaos #E8DAEF
}
package "WIP (contract only)" #FBFCFC {
rectangle "<b>context</b>\n<size:9><i>WIP</i></size>\n IContextService (WIP)" as context #F4F6F7
rectangle "<b>injection</b>\n<size:9><i>WIP</i></size>\n IInjectionService (WIP)" as injection #F4F6F7
rectangle "<b>turnRunner</b>\n<size:9><i>WIP</i></size>\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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 197 KiB

View file

@ -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/<domain>/stubs.ts`.
## Service groups
Most unit tests stub the same handful of collaborators (`ILogService`,
`IAgentRecords`, `IConfigService`, `ITelemetryService`, …). Rather than repeat
`ix.stub(...)` lines in every `beforeEach`, each domain exports a
`register*Services` function from its `stubs.ts` that registers the default test
doubles for that domain:
```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** (`../<domain>/stubs`), never from
`#/…`.
`createServices` defaults to `strict: false` (missing dependencies warn rather
than throw), matching `new TestInstantiationService()`. Pass `strict: true` to
surface unregistered `@IService` dependencies.
## Declaring dependencies
Always use `@IService` constructor decorators — in fixtures and in production
@ -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/<domain>/stubs.ts` if it does not exist);
or service group (or add one to `test/<domain>/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);
```

View file

@ -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<IGreeter> = createDecorator<IGreeter>('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
// Eagerscope 创建时立刻 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: <T>(id: ServiceIdentifier<T>): 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<IScopeHandle> {
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: <T>(id: ServiceIdentifier<T>): 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 创建中要 BB 创建中又要 A——容器会抛 `CyclicDependencyError``path` 形如 `['A', 'B', 'A']`。自环A 依赖自己)同样会被拒绝。这不是 bug是保护机制它在告诉你「这两个服务的职责划错了」。
### 9.2 为什么不允许
- scope 分层让正常依赖天然是 DAGTurn → 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`(场景 5Proxy 能让这个「软循环」不同步炸开。**业务上禁止使用它来绕过循环依赖**——它存在是为了兼容历史代码,不是给你的设计兜底的。撞上 `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.<scope>.accessor.get(ISut)` |
核心规则:**按接口解析被测对象,绝不 `new``@IService` 依赖的实现类**——否则 `registerScopedService(IX → Impl)` 这条绑定在测试里根本没跑过。
---
## 附录 A接口速查
| 接口 | 出现场景 | 作用 |
|---|---|---|
| `createDecorator<T>(name)``ServiceIdentifier<T>` | 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/<domain>/<domain>.ts` 写接口(带 `_serviceBrand`+ `createDecorator` 身份。
2. **实现**`src/<domain>/<domain>Service.ts` 写类,`@IX` 声明依赖,文件顶层 `registerScopedService(scope, IX, Impl, type, '<domain>')`
3. **barrel**`src/<domain>/index.ts` re-export 契约和实现。
4. **入口**`src/index.ts` 加一行 `export * from './<domain>/index';`
5. **测试**`test/<domain>/``TestInstantiationService``createScopedTestHost`,按接口解析。

View file

@ -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<sessionId, …>` 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 Q1Q3 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<id, …>`.
- **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.

View file

@ -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';

View file

@ -292,45 +292,110 @@ interface SinonOptions {
stub?: boolean;
}
export type ServiceIdCtorPair<T> = [
id: ServiceIdentifier<T>,
ctorOrInstance: T | AnyConstructor<T>,
];
/**
* 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<T>(id: ServiceIdentifier<T>, ctor: new (...args: any[]) => T): void;
/** Register a fully-constructed instance. */
defineInstance<T>(id: ServiceIdentifier<T>, 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<T>(id: ServiceIdentifier<T>, instance: Partial<T>): 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<any>[],
options: CreateServicesOptions = {},
): TestInstantiationService {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const serviceIdentifiers: ServiceIdentifier<any>[] = [];
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<ServiceIdentifier<any>>();
const define = <T>(
const register = <T>(
id: ServiceIdentifier<T>,
ctorOrInstance: T | AnyConstructor<T>,
value: T | Partial<T> | SyncDescriptor<T>,
isInstance: boolean,
overwrite: boolean,
): void => {
if (!serviceCollection.has(id)) {
if (typeof ctorOrInstance === 'function') {
serviceCollection.set(id, new SyncDescriptor(ctorOrInstance as AnyConstructor<T>));
} else {
serviceCollection.set(id, ctorOrInstance);
}
if (overwrite || !serviceCollection.has(id)) {
serviceCollection.set(id, value as T | SyncDescriptor<T>);
}
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);

View file

@ -51,6 +51,15 @@ export class LinkedList<E> {
};
}
shift(): E | undefined {
if (this._first === Node.Undefined) {
return undefined;
}
const node = this._first as Node<E>;
this._remove(node);
return node.element;
}
private _remove(node: Node<E>): void {
if (node.prev !== Node.Undefined && node.next !== Node.Undefined) {
const anchor = node.prev as Node<E>;

View file

@ -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<T> {
(
@ -24,7 +30,7 @@ interface ListenerEntry<T> {
}
export class Emitter<T> {
private _listeners: Set<ListenerEntry<T>> | undefined;
protected _listeners: Set<ListenerEntry<T>> | undefined;
private _disposed = false;
private _event: Event<T> | undefined;
@ -85,6 +91,100 @@ export class Emitter<T> {
}
}
export interface IWaitUntil {
readonly signal: AbortSignal;
waitUntil(thenable: Promise<unknown>): void;
}
export type IWaitUntilData<T> = Omit<T, 'waitUntil' | 'signal'>;
export class AsyncEmitter<T extends IWaitUntil> extends Emitter<T> {
private _asyncDeliveryQueue?: LinkedList<[(event: T) => void, IWaitUntilData<T>]>;
async fireAsync(data: IWaitUntilData<T>, signal: AbortSignal): Promise<void> {
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<unknown>[] = [];
const event = {
...eventData,
signal,
waitUntil: (p: Promise<unknown>): 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<boolean>)[],
onError: (error: unknown) => void,
): Promise<boolean> {
if (vetos.length === 0) {
return Promise.resolve(false);
}
const promises: Promise<void>[] = [];
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<unknown> = () => Disposable.None;

View file

@ -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<IScopeHandle> {
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: <T>(id: ServiceIdentifier<T>): 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<IScopeHandle> {
return this.create({ agentId: 'main' });
}

View file

@ -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<IKaosService>('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<Kaos>;
}
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const IKaosFactory = createDecorator<IKaosFactory>('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<ISessionKaosService>('sessionKaosService');

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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<void> {
return Promise.resolve();
}
}
registerScopedService(LifecycleScope.Turn, ILoopRunner, LoopRunner, InstantiationType.Delayed, 'turn');

View file

@ -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<boolean>; 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<ToolCallOutcome> {
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<string> {
for (const { value, id } of vetos) {
if (await Promise.resolve(value)) {
return id ?? 'vetoed';
}
}
return 'vetoed';
}
}
registerScopedService(LifecycleScope.Turn, IToolCallExecutor, ToolCallExecutor, InstantiationType.Delayed, 'turn');

View file

@ -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<void>;
readonly result: Promise<TurnResult>;
export interface TurnWillExecuteToolEvent {
readonly turnId: string;
readonly toolCallId: string;
readonly toolName: string;
readonly args: unknown;
veto(value: boolean | Promise<boolean>, 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<TurnStartEvent>;
readonly onWillExecuteTool: Event<TurnWillExecuteToolEvent>;
readonly onDidFinalizeTool: Event<TurnToolEvent>;
readonly onDidEndStep: Event<TurnStepEvent>;
readonly onDidEndTurn: Event<TurnEndEvent>;
readonly hasActiveTurn: boolean;
readonly currentId: string | undefined;
prompt(input: string): Promise<void>;
steer(content: string, origin?: string): void;
retry(): Promise<void>;
cancel(reason?: string): void;
}
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const ITurnService = createDecorator<ITurnService>('turnService');
export const ITurnService: ServiceIdentifier<ITurnService> =
createDecorator<ITurnService>('turnService');
export interface ITurnEvents {
readonly _serviceBrand: undefined;
readonly onWillStartTurn: Event<TurnStartEvent>;
readonly onWillExecuteTool: Event<TurnWillExecuteToolEvent>;
readonly onDidFinalizeTool: Event<TurnToolEvent>;
readonly onDidEndStep: Event<TurnStepEvent>;
readonly onDidEndTurn: Event<TurnEndEvent>;
fireWillStartTurn(event: TurnStartEvent): void;
fireWillExecuteTool(event: TurnWillExecuteToolEvent): void;
fireDidFinalizeTool(event: TurnToolEvent): void;
fireDidEndStep(event: TurnStepEvent): void;
fireDidEndTurn(event: TurnEndEvent): void;
}
export const ITurnEvents: ServiceIdentifier<ITurnEvents> =
createDecorator<ITurnEvents>('turnEvents');
export interface IToolCallExecutor {
readonly _serviceBrand: undefined;
execute(toolCallId: string, toolName: string, args: unknown): Promise<ToolCallOutcome>;
}
export const IToolCallExecutor: ServiceIdentifier<IToolCallExecutor> =
createDecorator<IToolCallExecutor>('toolCallExecutor');
export interface ITurnContext {
readonly _serviceBrand: undefined;
readonly turnId: string;
}
export const ITurnContext: ServiceIdentifier<ITurnContext> =
createDecorator<ITurnContext>('turnContext');
export interface ILoopRunner {
readonly _serviceBrand: undefined;
run(): Promise<void>;
}
export const ILoopRunner: ServiceIdentifier<ILoopRunner> =
createDecorator<ILoopRunner>('loopRunner');

View file

@ -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<TurnStartEvent>());
readonly onWillStartTurn: Event<TurnStartEvent> = this._onWillStartTurn.event;
private readonly _onWillExecuteTool = this._register(new Emitter<TurnWillExecuteToolEvent>());
readonly onWillExecuteTool: Event<TurnWillExecuteToolEvent> = this._onWillExecuteTool.event;
private readonly _onDidFinalizeTool = this._register(new Emitter<TurnToolEvent>());
readonly onDidFinalizeTool: Event<TurnToolEvent> = this._onDidFinalizeTool.event;
private readonly _onDidEndStep = this._register(new Emitter<TurnStepEvent>());
readonly onDidEndStep: Event<TurnStepEvent> = this._onDidEndStep.event;
private readonly _onDidEndTurn = this._register(new Emitter<TurnEndEvent>());
readonly onDidEndTurn: Event<TurnEndEvent> = 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');

View file

@ -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<Turn, ControlledPromise<void>>();
private readonly readySettled = new WeakSet<Turn>();
private readonly currentStepByTurn = new Map<number, number>();
private readonly interruptedTelemetryTurnIds = new Set<number>();
private readonly telemetryModeByTurn = new Map<number, 'agent' | 'plan'>();
export class TurnService extends Disposable implements ITurnService {
declare readonly _serviceBrand: undefined;
readonly hooks = {
onLaunched: new OrderedHookSlot<{ turn: Turn }>(),
onEnded: new OrderedHookSlot<TurnEndedContext>(),
beforeStep: new OrderedHookSlot<TurnStepContext>(),
afterStep: new OrderedHookSlot<TurnStepContext>(),
};
readonly onWillStartTurn: Event<TurnStartEvent>;
readonly onWillExecuteTool: Event<TurnWillExecuteToolEvent>;
readonly onDidFinalizeTool: Event<TurnToolEvent>;
readonly onDidEndStep: Event<TurnStepEvent>;
readonly onDidEndTurn: Event<TurnEndEvent>;
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<void> {
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<void>();
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<void> {
throw new Error('TODO: TurnService.retry');
}
private async runTurn(turn: Turn, origin: PromptOrigin): Promise<TurnResult> {
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<void> {
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<TurnResult | undefined> {
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<T> {
readonly promise: Promise<T>;
resolve(value: T | PromiseLike<T>): void;
reject(reason?: unknown): void;
}
type MutableTurn = {
-readonly [K in keyof Turn]: Turn[K];
};
function createControlledPromise<T>(): ControlledPromise<T> {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((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');

View file

@ -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());

View file

@ -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, {});
}

View file

@ -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());

View file

@ -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());

View file

@ -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());

View file

@ -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());

View file

@ -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: <T>() => 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: <T>() => agentSection as T });
reg.defineInstance(IKaosService, agentKaos);
reg.define(IAgentConfigService, AgentConfigService);
},
});
});
afterEach(() => disposables.dispose());

View file

@ -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, {});
}

View file

@ -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());

View file

@ -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, {});
}

View file

@ -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 = {

View file

@ -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());
}

View file

@ -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<TestEvent>,
data: IWaitUntilData<TestEvent>,
signal = new AbortController().signal,
): Promise<void> {
return emitter.fireAsync(data, signal);
}
describe('AsyncEmitter', () => {
it('resolves without delivering when there are no listeners', async () => {
const emitter = new AsyncEmitter<TestEvent>();
await expect(fire(emitter, { value: 1 })).resolves.toBeUndefined();
});
it('awaits every waitUntil promise before resolving', async () => {
const emitter = new AsyncEmitter<TestEvent>();
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<TestEvent>();
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<TestEvent>();
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<TestEvent>();
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<TestEvent>();
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<TestEvent>();
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);
});
});

View file

@ -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());

View file

@ -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());

View file

@ -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);

View file

@ -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');

View file

@ -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());

View file

@ -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());

View file

@ -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());

View file

@ -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, {});
}

View file

@ -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());

View file

@ -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());

View file

@ -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, {});
}

View file

@ -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());
}

View file

@ -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());

View file

@ -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());

View file

@ -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());

View file

@ -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, {});
}

View file

@ -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());

View file

@ -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());

View file

@ -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);

View file

@ -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, {});
}

View file

@ -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());

View file

@ -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, {});
}

View file

@ -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());

View file

@ -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());

View file

@ -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());

View file

@ -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, {});
}

View file

@ -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);

View file

@ -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());

View file

@ -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 = (<T>(): Event<T> => () => ({ dispose: () => {} }))();
@ -43,7 +45,7 @@ export function stubTurn(options: StubTurnOptions = {}): StubTurn {
return {
_serviceBrand: undefined,
onWillStartTurn: noneEvent as Event<TurnStartEvent>,
onWillExecuteTool: noneEvent as Event<TurnToolEvent>,
onWillExecuteTool: noneEvent as Event<TurnWillExecuteToolEvent>,
onDidFinalizeTool: noneEvent as Event<TurnToolEvent>,
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());
}

View file

@ -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<ToolCallResult> => {
executed.push(name);
return Promise.resolve({ output: `ran:${name}` });
},
list: (): readonly ToolDefinition[] => [],
registerUserTool: () => {},
registerMcpTools: () => {},
};
}
function permissionService(): IPermissionService {
return {
_serviceBrand: undefined,
beforeToolCall: (_ctx: PermissionContext): Promise<Decision> => 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<Decision> =>
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' } }]);
});
});

View file

@ -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());

View file

@ -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());

View file

@ -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());