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