diff --git a/packages/agent-core-v2/AGENTS.md b/packages/agent-core-v2/AGENTS.md index edb4c14c5a..d7509008ce 100644 --- a/packages/agent-core-v2/AGENTS.md +++ b/packages/agent-core-v2/AGENTS.md @@ -4,7 +4,7 @@ ## Scopes -Four `LifecycleScope` tiers — `App` / `Workspace` / `Session` / `Agent` (string-valued, declared in `src/app/scopes.ts` — the DI kernel in `src/_base/di/scope.ts` only knows opaque `ScopeKind` strings plus the order installed by `setScopeTopology`). The `workspace/` domain owns the Workspace tier: the App-scope `workspaceLifecycle` holds the live handler registry (one handler per workspaceId, create-or-get + join, never closed), and each handler's `sessionLifecycle` owns the session lifecycle (create/resume/fork/close/delete) as its child scopes. Workspace-scope services (`workspaceSkillCatalog` / `workspaceAgentProfileLoader` / `workspaceInstructions` / `workspaceMcp` / `workspaceDirs` / `workspaceFs` / `workspaceFsWatch` / `workspaceProcess` / `workspaceGit` / `workspaceToolPolicy` / `workspaceTrust`) hold the handler-shared resources — loaded once at handler materialization, then refreshed by fs watch — and sessions consume them through session-domain seed contracts with change events (`session/mcp`, `session/workspaceInfo`, `session/sessionSkillCatalog` data, …), projected by five seed-adapter units (`src/session/sessionSeed/sessionSeedAdapters.ts`): each adapter `@ref`-observes its workspace upstream, live-reads through getters, re-fires `onDidChange` when the backing generation switches, and provides the seed token synchronously through the session scope's `ScopeOptions.assemble` hook before session services activate (a host without the workspace layer keeps the scope's default `extra` registration; the inline seeds stay plain `extra`). `workspaceMcp` is pure connection orchestration over the scope-agnostic `mcpCore` layer; the effective server set is owned by `workspaceMcpConfig` (mcp.json files + plugin contributions, fs-watch refreshed), and MCP persistence — the `[mcp]` config section plus OAuth credentials — lives in `app/mcpConfig`, the same wrapper shape as `kosongConfig` over kosong. `workspaceDirs` is backed by `.kimi-code/local.toml`; `workspaceToolPolicy` is the os-level tool veto. A session created with `CreateSessionOptions.mcpServers` additionally gets ephemeral per-session MCP servers: `workspaceMcp.sessionOverlay` builds a session-owned manager for them (never persisted, invisible to the handler's other sessions, not gated by `workspaceTrust`), the session's `ISessionMcpHandle` seed carries a `session/mcp` `MergedMcpConnectionView` over the shared manager and the overlay (an ephemeral name shadows a workspace server for that session), and `sessionLifecycle` shuts the overlay down when the session handle disposes (backstopped by the lifecycle service's own dispose for teardown paths that bypass the handle wrapper). Agent profiles follow the Contribution / Registry / Catalog extension point instead of a workspace catalog: the `workspaceAgentProfileLoader` domain owns agent-file discovery end to end (parse / roots / SYSTEM.md / explicit runtime files) and its Workspace-scope loaders (`workspace` / `user` / `plugin` / `extra` / `explicit`) contribute `AgentProfileContribution` records to the collection via `this.provide`, tagged with the handler's `workspaceId`; the App-scope `IAgentProfileRegistry` is a fold over that collection (same-(sourceId, workspaceKey) later records shadow earlier ones, provider death withdraws; the App-scope `builtinAgentProfileLoader` contributes the code-defined profiles through an owned helper unit), and each Session-scope `sessionAgentProfileCatalog` projects the registry into the merged read view directly (name-level dedup + the builtin-override rule in the projection) — its seed carries only the workspace key. `workspaceTrust` records the per-workspace trust marker (persisted under the home, keyed by `encodeWorkDirKey(root)`); while untrusted, `workspaceMcpConfig` skips the project-level MCP config files (`.mcp.json`, `.kimi-code/mcp.json`). The trust state flips through kap-server's `GET|POST /workspaces/{id}/trust` + `POST /workspaces/{id}/untrust` routes. The old App-level session-lifecycle facade and `ISessionMcpService` / `ISessionFsService` are gone — compose `sessionIndex` → `workspaceLifecycle.handlerFor` → the handler instead. +Four `LifecycleScope` tiers — `App` / `Workspace` / `Session` / `Agent` (string-valued, declared in `src/app/scopes.ts` — the DI kernel in `src/_base/di/scope.ts` only knows opaque `ScopeKind` strings plus the order installed by `setScopeTopology`). The `workspace/` domain owns the Workspace tier: the App-scope `workspaceLifecycle` holds the live handler registry (one handler per workspaceId, create-or-get + join, never closed), and each handler's `sessionLifecycle` owns the session lifecycle (create/resume/fork/close/delete) as its child scopes. Workspace-scope services (`workspaceSkillCatalog` / `workspaceAgentProfileLoader` / `workspaceInstructions` / `workspaceMcp` / `workspaceDirs` / `workspaceFs` / `workspaceFsWatch` / `workspaceProcess` / `workspaceGit` / `workspaceToolPolicy` / `workspaceTrust`) hold the handler-shared resources — loaded once at handler materialization, then refreshed by fs watch — and sessions consume them through session-domain seed contracts with change events (`session/mcp`, `session/workspaceInfo`, `session/sessionSkillCatalog` data, …), projected by five seed-adapter units (`src/session/sessionSeed/sessionSeedAdapters.ts`): each adapter `@ref`-observes its workspace upstream, live-reads through getters, re-fires `onDidChange` when the backing generation switches, and provides the seed token synchronously through the session scope's `ScopeOptions.configureContainer` hook before session services activate (a host without the workspace layer keeps the scope's default `extra` registration; the inline seeds stay plain `extra`). The same `configureContainer` window also fires `sessionLifecycle.onWillCreateSession` — a synchronous participation event whose surface speaks the session domain's own vocabulary (`readSeed` / `contributeSeed` / `onSessionDispose`), so Workspace-scope participants contribute session-scoped resources without the lifecycle depending on them or on kernel mechanics: `workspaceMcp` uses it to activate a session's ephemeral-server overlay (the configs travel as the `ISessionEphemeralMcpServers` session seed), contributing the merged `ISessionMcpHandle` over the adapter's workspace projection and attaching the overlay's shutdown to the session's teardown. `workspaceMcp` is pure connection orchestration over the scope-agnostic `mcpCore` layer; the effective server set is owned by `workspaceMcpConfig` (mcp.json files + plugin contributions, fs-watch refreshed), and MCP persistence — the `[mcp]` config section plus OAuth credentials — lives in `app/mcpConfig`, the same wrapper shape as `kosongConfig` over kosong. `workspaceDirs` is backed by `.kimi-code/local.toml`; `workspaceToolPolicy` is the os-level tool veto. A session created with `CreateSessionOptions.mcpServers` additionally gets ephemeral per-session MCP servers: `workspaceMcp.sessionOverlay` builds a session-owned manager for them (never persisted, invisible to the handler's other sessions, not gated by `workspaceTrust`), the session's `ISessionMcpHandle` seed carries a `session/mcp` `MergedMcpConnectionView` over the shared manager and the overlay (an ephemeral name shadows a workspace server for that session), and `sessionLifecycle` shuts the overlay down when the session handle disposes (backstopped by the lifecycle service's own dispose for teardown paths that bypass the handle wrapper). Agent profiles follow the Contribution / Registry / Catalog extension point instead of a workspace catalog: the `workspaceAgentProfileLoader` domain owns agent-file discovery end to end (parse / roots / SYSTEM.md / explicit runtime files) and its Workspace-scope loaders (`workspace` / `user` / `plugin` / `extra` / `explicit`) contribute `AgentProfileContribution` records to the collection via `this.provide`, tagged with the handler's `workspaceId`; the App-scope `IAgentProfileRegistry` is a fold over that collection (same-(sourceId, workspaceKey) later records shadow earlier ones, provider death withdraws; the App-scope `builtinAgentProfileLoader` contributes the code-defined profiles through an owned helper unit), and each Session-scope `sessionAgentProfileCatalog` projects the registry into the merged read view directly (name-level dedup + the builtin-override rule in the projection) — its seed carries only the workspace key. `workspaceTrust` records the per-workspace trust marker (persisted under the home, keyed by `encodeWorkDirKey(root)`); while untrusted, `workspaceMcpConfig` skips the project-level MCP config files (`.mcp.json`, `.kimi-code/mcp.json`). The trust state flips through kap-server's `GET|POST /workspaces/{id}/trust` + `POST /workspaces/{id}/untrust` routes. The old App-level session-lifecycle facade and `ISessionMcpService` / `ISessionFsService` are gone — compose `sessionIndex` → `workspaceLifecycle.handlerFor` → the handler instead. ## Units and contribution points (L3) @@ -13,7 +13,7 @@ The DI kernel (`src/_base/di/`) owns the unit layer on top of the scoped registr - `service.ts` — `Service`: the unit base class (extends `Disposable`). Capabilities live on `this` (`provide` / `effect` / `on` / `get` / `ref`, plus `name` / `state` / `config`). Two-phase construction: inside the ctor `provide`/`on`/`effect` buffer (writes only — `get`/`ref` throw, dependencies are constructor parameters); the kernel binds the runtime after `Reflect.construct` and flushes in writing order; a manually `new`ed instance throws on every capability call. Services whose own members collide with the `Service` vocabulary keep `extends Disposable` with a NOTE comment — still full DI units (cascade/ledger do not require `Service`). - `fiber.ts` — the `Fiber` capability interface (not a DI token), `FiberHandle` (thenable / `state` / `uid` / `update` / `dispose`), `ServiceRecipe` (class / arrow function / `{apply}`), the `FiberState` five-state machine, and `ScopeUnits(kind)` — the materialization collection token, one per scope kind. - `collection.ts` — `collection(name)` contribution tokens. Contribute with `this.provide(token, value)`; a fold declares the token as a constructor parameter and receives a `CollectionView` (`items` / `records` / incremental `onDidChange`). Records are visible to the provider's ancestors and descendants (never sibling subtrees); provider death withdraws. Collection edges enter the graph for introspection but never join a cascade contagion set. -- `scopeUnits.ts` — the kernel fold: every scope-creation point (`createScopedChildHandle` / `Scope.createApp` / `Scope.createChild`) runs `watchScopeUnits(container, kind)` before eager activation, materializing each visible `ScopeUnits(kind)` record's recipe as a unit inside the new scope (disposal hangs on the record provider's book — provider death tears the materialized units down across the tree). `ScopeOptions.assemble` runs at the same point (the session seed adapters use it). +- `scopeUnits.ts` — the kernel fold: every scope-creation point (`createScopedChildHandle` / `Scope.createApp` / `Scope.createChild`) runs `watchScopeUnits(container, kind)` before eager activation, materializing each visible `ScopeUnits(kind)` record's recipe as a unit inside the new scope (disposal hangs on the record provider's book — provider death tears the materialized units down across the tree). `ScopeOptions.configureContainer` runs at the same point (the session seed adapters use it). - `instantiation.ts` — the `@ref(IX)` decorator factory (`LiveRef`: `current` live read + `onDidChange` availability event; observation creates no binding and no graph edge) and `ScopeActivation`. - `src/app/feature/` — `IFeatureManager` (App scope): runtime unit assembly (`provideUnit` / `unprovideUnit` / `updateUnit`) and introspection (`units()` / `onDidChangeUnits`); managed units hang on the manager's own book. External package management stays with `IPluginService`. The `features` assembly (`src/features/featureAssemblyService.ts`) drains the module-level feature table through it. diff --git a/packages/agent-core-v2/src/_base/di/scope.ts b/packages/agent-core-v2/src/_base/di/scope.ts index 6d4d7272f3..ced7a35f99 100644 --- a/packages/agent-core-v2/src/_base/di/scope.ts +++ b/packages/agent-core-v2/src/_base/di/scope.ts @@ -84,8 +84,8 @@ export type ScopeSeed = ReadonlyArray< export interface ScopeOptions { readonly id?: string; - readonly extra?: ScopeSeed; - readonly assemble?: (container: InstantiationService) => void; + readonly seeds?: ScopeSeed; + readonly configureContainer?: (container: InstantiationService) => void; } export interface IScopeHandle { @@ -100,10 +100,10 @@ export type IWorkspaceScopeHandle = IScopeHandle<'workspace'>; export type ISessionScopeHandle = IScopeHandle<'session'>; export type IAgentScopeHandle = IScopeHandle<'agent'>; -function buildCollection(extra?: ScopeSeed): ServiceCollection { +function buildCollection(seeds?: ScopeSeed): ServiceCollection { const collection = new ServiceCollection(); - if (extra) { - for (const [id, value] of extra) { + if (seeds) { + for (const [id, value] of seeds) { collection.set(id, value); } } @@ -137,12 +137,12 @@ export function createScopedChildHandle( id: string, options: ScopeOptions = {}, ): IScopeHandle { - const collection = buildCollection(options.extra); + const collection = buildCollection(options.seeds); const child = parent.createChild(collection); (child as InstantiationService).debugLabel = id; try { watchScopeUnits(child as InstantiationService, kind); - options.assemble?.(child as InstantiationService); + options.configureContainer?.(child as InstantiationService); provideScopeServices(child, kind, collection); } catch (error) { child.dispose(); @@ -189,12 +189,12 @@ export class Scope implements IDisposable { static createApp(options: ScopeOptions = {}): Scope { const kind: ScopeKind = 'app'; - const collection = buildCollection(options.extra); + const collection = buildCollection(options.seeds); const instantiation = new InstantiationService(collection, true); instantiation.debugLabel = options.id ?? 'app'; try { watchScopeUnits(instantiation, kind); - options.assemble?.(instantiation); + options.configureContainer?.(instantiation); provideScopeServices(instantiation, kind, collection); } catch (error) { instantiation.dispose(); @@ -223,12 +223,12 @@ export class Scope implements IDisposable { if (this.children.has(id)) { throw new Error(`Scope '${this.id}' already has a child with id '${id}'`); } - const collection = buildCollection(options.extra); + const collection = buildCollection(options.seeds); const childInstantiation = this.instantiation.createChild(collection); (childInstantiation as InstantiationService).debugLabel = id; try { watchScopeUnits(childInstantiation as InstantiationService, kind); - options.assemble?.(childInstantiation as InstantiationService); + options.configureContainer?.(childInstantiation as InstantiationService); provideScopeServices(childInstantiation, kind, collection); } catch (error) { childInstantiation.dispose(); diff --git a/packages/agent-core-v2/src/_base/di/test.ts b/packages/agent-core-v2/src/_base/di/test.ts index 11b332889d..d861e71151 100644 --- a/packages/agent-core-v2/src/_base/di/test.ts +++ b/packages/agent-core-v2/src/_base/di/test.ts @@ -23,14 +23,14 @@ export interface ScopedTestHost { } export function createScopedTestHost(appStubs: ScopeSeed = []): ScopedTestHost { - const app = createAppScope({ extra: appStubs }); + const app = createAppScope({ seeds: appStubs }); return { app, child(kind, id, stubs = []) { - return app.createChild(kind, id, { extra: stubs }); + return app.createChild(kind, id, { seeds: stubs }); }, childOf(parent, kind, id, stubs = []) { - return parent.createChild(kind, id, { extra: stubs }); + return parent.createChild(kind, id, { seeds: stubs }); }, dispose() { app.dispose(); diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts index b80c1a00a0..f80aff2e1b 100644 --- a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts +++ b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts @@ -155,7 +155,7 @@ export interface BootstrapResult { export function bootstrap(input: BootstrapInput, extraSeeds: ScopeSeed = []): BootstrapResult { const options = resolveBootstrapOptions(input); const app = createAppScope({ - extra: [...bootstrapSeed(input), ...storageSeed(options), ...skillSeed(), ...extraSeeds], + seeds: [...bootstrapSeed(input), ...storageSeed(options), ...skillSeed(), ...extraSeeds], }); return { app }; } diff --git a/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycleService.ts b/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycleService.ts index d9e082d4d2..16f7f72703 100644 --- a/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycleService.ts +++ b/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycleService.ts @@ -134,7 +134,7 @@ export class WorkspaceLifecycleService extends Service implements IWorkspaceLife this.instantiation, LifecycleScope.Workspace, workspaceId, - { extra: workspaceContextSeed(ctx) }, + { seeds: workspaceContextSeed(ctx) }, ) as IWorkspaceScopeHandle; this.live.set(workspaceId, handle); this._onDidMaterializeHandler.fire(handle); diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index 6c7a0d6efe..4ccbbdd6f5 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -151,7 +151,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle LifecycleScope.Agent, agentId, { - extra: [ + seeds: [ [IAgentScopeContext, makeAgentScopeContext({ agentId, agentScope })], [ITelemetryService, this.telemetry.withContext({ agent_id: agentId })], ], diff --git a/packages/agent-core-v2/src/session/mcp/ephemeralMcpServers.ts b/packages/agent-core-v2/src/session/mcp/ephemeralMcpServers.ts new file mode 100644 index 0000000000..018adf7acf --- /dev/null +++ b/packages/agent-core-v2/src/session/mcp/ephemeralMcpServers.ts @@ -0,0 +1,28 @@ +/** + * `mcp` domain — seeded ephemeral per-session MCP server configs. + * + * Defines `ISessionEphemeralMcpServers`, the pure-data injection contract + * carrying the session's ephemeral (caller-injected, never persisted) MCP + * server configs, copied verbatim from the session's creation options + * (`CreateSessionOptions.mcpServers` / `ResumeSessionOptions.mcpServers`). + * Always seeded into the Session scope by the session lifecycle (an empty + * record for ordinary sessions), so consumers can resolve it + * unconditionally. The contract carries no IO of its own — connecting the + * servers and projecting the resulting session handle is the + * Workspace-side MCP domain's concern, activated through the session + * lifecycle's `onWillCreateSession` event. Session-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ScopeSeed } from '#/_base/di/scope'; +import type { McpServerConfig } from '#/mcpCore/config-schema'; + +export const ISessionEphemeralMcpServers: ServiceIdentifier< + Readonly> +> = createDecorator>>('sessionEphemeralMcpServers'); + +export function sessionEphemeralMcpServersSeed( + servers: Readonly>, +): ScopeSeed { + return [[ISessionEphemeralMcpServers as ServiceIdentifier, servers]]; +} diff --git a/packages/agent-core-v2/src/session/sessionSeed/sessionSeedAdapters.ts b/packages/agent-core-v2/src/session/sessionSeed/sessionSeedAdapters.ts index f0b15d7fbc..bd1c7968d5 100644 --- a/packages/agent-core-v2/src/session/sessionSeed/sessionSeedAdapters.ts +++ b/packages/agent-core-v2/src/session/sessionSeed/sessionSeedAdapters.ts @@ -23,14 +23,15 @@ * untouched. * * The units carry no DI token of their own: the session - * assembly point constructs them explicitly (`assembleSessionSeedAdapters`, - * the `assemble` hook of `createScopedChildHandle`) and anchors their + * assembly point constructs them explicitly (`installSessionSeedAdapters`, + * the `configureContainer` hook of `createScopedChildHandle`) and anchors their * disposal into the session container's ledger. Observation (`@ref`) is * data-flow semantics — an upstream rebuild re-fires `onDidChange` instead * of cascading this adapter down. A session created with ephemeral - * `mcpServers` passes its merged overlay handle as `sessionMcpHandle`: the - * MCP adapter is skipped and the overlay handle is provided directly (fixed - * at creation, like the pre-adapter inline seed). + * `mcpServers` (the `ISessionEphemeralMcpServers` seed) gets its + * `ISessionMcpHandle` from the `workspaceMcp` participant of the session + * lifecycle's `onWillCreateSession` event instead: its contribution lands + * after this adapter's provide and replaces the workspace projection. */ import type { ServiceClassRecipe } from '#/_base/di/fiber'; @@ -262,15 +263,8 @@ const SESSION_SEED_ADAPTERS: readonly ServiceClassRecipe[] = [ SessionToolPolicyGateAdapter, ]; -export function assembleSessionSeedAdapters( - container: InstantiationService, - sessionMcpHandle?: ISessionMcpHandle, -): void { +export function installSessionSeedAdapters(container: InstantiationService): void { for (const recipe of SESSION_SEED_ADAPTERS) { - if (recipe === SessionMcpHandleAdapter && sessionMcpHandle !== undefined) { - container.provide(ISessionMcpHandle, sessionMcpHandle); - continue; - } const adapter = container.fiberHost.constructService(recipe, undefined) as Partial; container.anchorKernelEntry(() => { adapter.dispose?.(); diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycle.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycle.ts index 017612c37a..6808b46786 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycle.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycle.ts @@ -14,6 +14,11 @@ * workspace and fork never crosses handlers. Announces lifecycle transitions * through `onDidCreateSession` / `onDidCloseSession` / `onDidArchiveSession` * / `onDidForkSession`; the ordered hook slots are per-session seeds. + * Workspace-scope services that must participate in a session's creation + * (read its seeded facts, contribute a session seed, attach teardown to its + * lifetime) subscribe to `onWillCreateSession` — the participation surface + * speaks the session domain's own vocabulary, so the lifecycle depends on + * neither its participants nor the DI kernel's assembly mechanics. * Workspace-scoped — one instance per materialized handler. */ @@ -94,9 +99,31 @@ export interface SessionForkedEvent { readonly handle: ISessionScopeHandle; } +/** + * Participation surface of `onWillCreateSession` — the business-lifecycle + * moment "a session is being created", fired synchronously before the new + * session's services activate (the `will` half of `onDidCreateSession`; + * resume and fork are creations too). Workspace-scope participants step + * into the creation through the session domain's own vocabulary — read the + * session's seeded facts (`readSeed`), contribute or replace a session seed + * (`contributeSeed`; a seed already projected by the workspace seed + * adapters is replaced), and attach teardown work to the session's lifetime + * (`onSessionDispose` — runs with the session's teardown on every path: + * close, archive, delete, a failed create, workspace teardown). The event + * carries only facts the lifecycle itself owns; anything a participant + * needs beyond them travels as a session-domain seed. + */ +export interface SessionWillCreateEvent { + readonly sessionId: string; + readSeed(id: ServiceIdentifier): T; + contributeSeed(id: ServiceIdentifier, value: T): void; + onSessionDispose(dispose: () => void): void; +} + export interface ISessionLifecycleService { readonly _serviceBrand: undefined; + readonly onWillCreateSession: Event; readonly onDidCreateSession: Event; readonly onDidCloseSession: Event; readonly onDidArchiveSession: Event; diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index fb196abcac..2eecd65f38 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -25,7 +25,7 @@ * watching and connecting all live on the Workspace-scope services; session * consumers read the seeds and refresh off their change events. The five * workspace-projection seeds are provided by the seed-adapter units - * assembled with the scope (`assembleSessionSeedAdapters`), not by `extra`. + * installed with the scope (`installSessionSeedAdapters`), not by `extra`. * Materializes the session's initial metadata on * creation. Bound at Workspace scope. * Persisted sessions are discovered through the session-index read model. @@ -53,11 +53,13 @@ * returns — it connects fire-and-forget at Workspace scope, and the seeded * handle's `ready` promise lets the agent's LLM steps wait on it instead * (see `AgentMcpService`). A session created with ephemeral `mcpServers` - * additionally gets a session overlay from `workspaceMcp` (session-owned - * connections, seeded as a merged view, shut down when the session handle - * disposes — with a backstop in the service's own dispose for teardown - * paths that bypass the handle wrapper), likewise connected in the - * background. + * gets them seeded verbatim (`ISessionEphemeralMcpServers`); connecting + * them is the MCP domain's own concern — `workspaceMcp` subscribes to this + * service's `onWillCreateSession`, reads the session's seeds through the + * event's session-domain surface (`readSeed` / `contributeSeed` / + * `onSessionDispose`), contributes its session overlay handle, and attaches + * the overlay's shutdown to the session's teardown, so this service never + * depends on MCP. * The session-level services whose subscriptions * must exist before the first agent / turn (external hooks, cron, the * secondary-model startup warning) opt into `OnScopeCreated` activation. @@ -104,8 +106,9 @@ import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/ import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent'; import { labelsFromAgentMeta } from '#/session/agentLifecycle/subagentMetadata'; import { ISessionContext, sessionContextSeed } from '#/session/sessionContext/sessionContext'; +import { sessionEphemeralMcpServersSeed } from '#/session/mcp/ephemeralMcpServers'; import { sessionAgentProfileCatalogSeed } from '#/session/sessionAgentProfileCatalog/agentProfileCatalogSeed'; -import { assembleSessionSeedAdapters } from '#/session/sessionSeed/sessionSeedAdapters'; +import { installSessionSeedAdapters } from '#/session/sessionSeed/sessionSeedAdapters'; import { ISessionLifecycleHooks, sessionLifecycleHooksSeed, @@ -134,10 +137,6 @@ import { IWorkspaceAgentProfileLoader, } from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader'; import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; -import { - IWorkspaceMcpService, - type ISessionMcpOverlay, -} from '#/workspace/workspaceMcp/workspaceMcp'; import { agentScopeOf, sessionDirOf, sessionScopeOf } from './internal/addressing'; import { @@ -150,6 +149,7 @@ import { type SessionCreatedEvent, type SessionForkedEvent, type SessionWillCloseEvent, + type SessionWillCreateEvent, ISessionLifecycleService, } from './sessionLifecycle'; @@ -161,6 +161,11 @@ type MaterializeSessionOptions = Omit & { export class SessionLifecycleService extends Disposable implements ISessionLifecycleService { declare readonly _serviceBrand: undefined; private readonly sessions = new Map(); + private readonly _onWillCreateSession = this._register( + new Emitter(), + ); + readonly onWillCreateSession: Event = + this._onWillCreateSession.event; private readonly _onDidCreateSession = this._register(new Emitter()); readonly onDidCreateSession: Event = this._onDidCreateSession.event; private readonly _onDidCloseSession = this._register(new Emitter()); @@ -170,14 +175,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec private readonly _onDidForkSession = this._register(new Emitter()); readonly onDidForkSession: Event = this._onDidForkSession.event; private readonly resuming = new Map>(); - /** - * Live per-session MCP overlays keyed by session id. The session handle's - * dispose removes its overlay here before shutting it down, so whatever - * remains at service teardown (the DI container disposes session scopes - * directly, bypassing the handle wrapper) is shut down from the - * service's own dispose instead — no overlay outlives the lifecycle. - */ - private readonly liveOverlays = new Map(); constructor( @IInstantiationService private readonly instantiation: IInstantiationService, @@ -203,21 +200,10 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec private readonly userAgentProfileLoader: IUserAgentProfileLoader, @IPluginAgentProfileLoader private readonly pluginAgentProfileLoader: IPluginAgentProfileLoader, - @IWorkspaceMcpService private readonly mcp: IWorkspaceMcpService, @IWorkspaceDirs private readonly workspaceDirs: IWorkspaceDirs, @ISessionProcessRunner private readonly processRunner: ISessionProcessRunner, ) { super(); - this._register({ - dispose: () => { - // Service teardown (e.g. workspace/root scope disposal) bypasses the - // per-session handle wrappers — shut down every overlay still live. - for (const overlay of this.liveOverlays.values()) { - void overlay.shutdown(); - } - this.liveOverlays.clear(); - }, - }); } private get workspaceId(): string { @@ -278,19 +264,12 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec 'onWillCloseSession', ]); await this.hostEnv.ready; - const mcpOverlay = - opts.mcpServers !== undefined && Object.keys(opts.mcpServers).length > 0 - ? this.mcp.sessionOverlay(opts.mcpServers, { stdioCwd: opts.workDir }) - : undefined; - if (mcpOverlay !== undefined) { - this.liveOverlays.set(opts.sessionId, mcpOverlay); - } - const scopeHandle = createScopedChildHandle( + const handle = createScopedChildHandle( this.instantiation, LifecycleScope.Session, opts.sessionId, { - extra: [ + seeds: [ ...sessionContextSeed(ctx), ...sessionLifecycleHooksSeed(hooks), [ITelemetryService, this.telemetry.withContext({ sessionId: opts.sessionId })], @@ -299,25 +278,26 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec workspaceKey: workspaceId, }), [ISessionProcessRunner, this.processRunner], + ...sessionEphemeralMcpServersSeed(opts.mcpServers ?? {}), ], - assemble: (container) => assembleSessionSeedAdapters(container, mcpOverlay?.handle), + configureContainer: (container) => { + installSessionSeedAdapters(container); + // The will-create moment is a business-lifecycle event; the DI + // container behind the participation surface stays this service's + // implementation detail. + this._onWillCreateSession.fire({ + sessionId: opts.sessionId, + readSeed: (id) => container.invokeFunction((accessor) => accessor.get(id)), + contributeSeed: (id, value) => { + container.provide(id, value); + }, + onSessionDispose: (dispose) => { + container.anchorKernelEntry(dispose, 'sessionLifecycle:willCreateParticipant'); + }, + }); + }, }, ) as ISessionScopeHandle; - const handle: ISessionScopeHandle = - mcpOverlay === undefined - ? scopeHandle - : { - ...scopeHandle, - dispose: () => { - // Delete-then-shutdown is atomic (single-threaded): the service - // teardown path only shuts down overlays still in the map, so a - // handle dispose and a service dispose can never double-shutdown. - if (this.liveOverlays.delete(opts.sessionId)) { - void mcpOverlay.shutdown(); - } - scopeHandle.dispose(); - }, - }; try { await handle.accessor.get(ISessionMetadata).ready; await handle.accessor.get(ISessionToolPolicy).ready; diff --git a/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcp.ts b/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcp.ts index 220e3eac57..a95695a63c 100644 --- a/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcp.ts +++ b/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcp.ts @@ -11,8 +11,10 @@ * (`sessionOverlay()`): a session-owned manager for those servers — never * persisted, never part of the config domain's effective set, invisible to * the handler's other sessions — presented to the session through a merged - * view, and released by the caller (`shutdown()`) when the session scope - * tears down. Ephemeral servers are a caller-explicit injection channel + * view. The service activates overlays itself from the session lifecycle's + * `onWillCreateSession` event (keyed by the `ISessionEphemeralMcpServers` + * seed) and attaches each overlay's `shutdown()` to the session's teardown. + * Ephemeral servers are a caller-explicit injection channel * (like the user-level `mcp.json`), so they are not gated by workspace * trust — only the project-level config files are. Bound at Workspace scope. */ diff --git a/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcpService.ts b/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcpService.ts index 6253a9bf4d..efd515b62e 100644 --- a/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcpService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcpService.ts @@ -21,8 +21,15 @@ * overlays (`sessionOverlay`): a session-owned manager for a session's * ephemeral (caller-injected, never persisted) servers — baseline members * by construction — presented through a - * `MergedMcpConnectionView` over the shared manager and shut down by the - * session lifecycle when the session scope tears down. An overlay handle's + * `MergedMcpConnectionView` over the shared manager. Overlay activation is + * event-driven: this service subscribes to the session lifecycle's + * `onWillCreateSession`, and a session created with an + * `ISessionEphemeralMcpServers` seed gets its overlay created there — the + * merged handle contributed as the session's `ISessionMcpHandle` (replacing + * the seed adapter's workspace projection), the overlay's shutdown attached + * to the session's teardown, so the session lifecycle never depends on MCP. + * The overlay's stdio cwd is read from the session's own `ISessionContext`. + * An overlay handle's * baseline still freezes on the workspace manager's initial load — never on * the overlay's own connect — so a slow ephemeral connect cannot reopen the * window for mid-session workspace additions. @@ -52,9 +59,12 @@ import { McpOAuthService } from '#/mcpCore/oauth/service'; import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; import { IMcpOAuthStore } from '#/app/mcpConfig/oauthStore'; import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { ISessionEphemeralMcpServers } from '#/session/mcp/ephemeralMcpServers'; import { MergedMcpConnectionView } from '#/session/mcp/mergedConnectionView'; -import type { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle'; +import { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; import { IWorkspaceMcpConfigService, type McpServersChange, @@ -83,6 +93,7 @@ export class WorkspaceMcpService extends Service implements IWorkspaceMcpService @ILogService private readonly log: ILogService, @ITelemetryService private readonly telemetry: ITelemetryService, @IAgentIdentity private readonly identity: IAgentIdentity, + @ISessionLifecycleService sessionLifecycle: ISessionLifecycleService, ) { super(); this.stdioCwd = workspace.cwd; @@ -103,6 +114,19 @@ export class WorkspaceMcpService extends Service implements IWorkspaceMcpService this.scheduleApply(change); }), ); + this._register( + sessionLifecycle.onWillCreateSession((event) => { + const servers = event.readSeed(ISessionEphemeralMcpServers); + if (Object.keys(servers).length === 0) return; + const overlay = this.sessionOverlay(servers, { + stdioCwd: event.readSeed(ISessionContext).cwd, + }); + event.contributeSeed(ISessionMcpHandle, overlay.handle); + event.onSessionDispose(() => { + void overlay.shutdown(); + }); + }), + ); this.ready = this.initialize().catch((error: unknown) => { this.log.error('mcp initial load failed', { error }); }); diff --git a/packages/agent-core-v2/test/_base/di/scope-tree.test.ts b/packages/agent-core-v2/test/_base/di/scope-tree.test.ts index 6e9290863e..77c5b06d36 100644 --- a/packages/agent-core-v2/test/_base/di/scope-tree.test.ts +++ b/packages/agent-core-v2/test/_base/di/scope-tree.test.ts @@ -159,7 +159,7 @@ describe('Scope tree', () => { app.dispose(); }); - it('extra seed injects a context token resolvable from that scope', () => { + it('seeds inject a context token resolvable from that scope', () => { interface ISessionContext { sessionId: string; } @@ -168,7 +168,7 @@ describe('Scope tree', () => { const app = createAppScope(); const session = app.createChild(LifecycleScope.Session, 's1', { - extra: [[ISessionContext as ServiceIdentifier, { sessionId: 's1' }]], + seeds: [[ISessionContext as ServiceIdentifier, { sessionId: 's1' }]], }); expect(session.accessor.get(ISessionContext).sessionId).toBe('s1'); expect(() => app.accessor.get(ISessionContext)).toThrow(); diff --git a/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts b/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts index ea7f41e7f5..932b978ab9 100644 --- a/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts +++ b/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts @@ -351,7 +351,7 @@ describe('AgentToolActivationService', () => { function createScopeTree(agentExtra: ScopeSeed = []) { const app = createAppScope(); const session = app.createChild(LifecycleScope.Session, 'session', { - extra: [ + seeds: [ [ ISessionToolPolicyGate, { @@ -365,7 +365,7 @@ describe('AgentToolActivationService', () => { ], }); const agent = session.createChild(LifecycleScope.Agent, 'agent', { - extra: agentSeeds(agentExtra), + seeds: agentSeeds(agentExtra), }); return { app, session, agent }; } @@ -382,7 +382,7 @@ describe('AgentToolActivationService', () => { expect(registry.resolve('Beta')).toBeInstanceOf(BetaTool); const agent2 = session.createChild(LifecycleScope.Agent, 'agent-2', { - extra: agentSeeds(), + seeds: agentSeeds(), }); await agent2.accessor.get(IAgentToolActivationService).activate(); expect(agent2.accessor.get(IAgentToolRegistryService).resolve('Alpha')).toBeInstanceOf( diff --git a/packages/agent-core-v2/test/app/gateway/gateway.test.ts b/packages/agent-core-v2/test/app/gateway/gateway.test.ts index 2a3926cbca..373beb8be5 100644 --- a/packages/agent-core-v2/test/app/gateway/gateway.test.ts +++ b/packages/agent-core-v2/test/app/gateway/gateway.test.ts @@ -91,6 +91,7 @@ describe('RestGateway', () => { const sessionLifecycle: ISessionLifecycleService = { _serviceBrand: undefined, + onWillCreateSession: () => ({ dispose: () => {} }), onDidCreateSession: () => ({ dispose: () => {} }), onDidCloseSession: () => ({ dispose: () => {} }), onDidArchiveSession: () => ({ dispose: () => {} }), diff --git a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts index 34a49c143b..2e2c9fd03e 100644 --- a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts @@ -916,6 +916,7 @@ function registerSessionExportServices( ISessionLifecycleService, { _serviceBrand: undefined, + onWillCreateSession: noopEvent, onDidCreateSession: noopEvent, onDidCloseSession: noopEvent, onDidArchiveSession: noopEvent, diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 7ea62b7c03..96c26962e2 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -1126,7 +1126,7 @@ export class AgentTestContext { this.serviceOverrides, 'app', ); - this.root = createAppScope({ extra: appSeeds }); + this.root = createAppScope({ seeds: appSeeds }); const initialConfig = this.root.accessor.get(IConfigService); this.root.accessor @@ -1149,7 +1149,7 @@ export class AgentTestContext { .withContext({ agent_id: agentId }); const sessionScope = `${bootstrap.scope('sessions')}/${workspaceId}/${sessionId}`; this.session = this.root.createChild(LifecycleScope.Session, sessionId, { - extra: collectScopeSeed( + seeds: collectScopeSeed( [ (reg) => { reg.defineInstance(ISessionContext, { @@ -1235,7 +1235,7 @@ export class AgentTestContext { const workspace = this.session.accessor.get(ISessionWorkspaceContext); this.agent = this.session.createChild(LifecycleScope.Agent, agentId, { - extra: collectScopeSeed( + seeds: collectScopeSeed( [ (reg) => { reg.defineDescriptor( diff --git a/packages/agent-core-v2/test/session/sessionSeed/sessionSeedAdapters.test.ts b/packages/agent-core-v2/test/session/sessionSeed/sessionSeedAdapters.test.ts index a899aa5f0d..5dcc44e398 100644 --- a/packages/agent-core-v2/test/session/sessionSeed/sessionSeedAdapters.test.ts +++ b/packages/agent-core-v2/test/session/sessionSeed/sessionSeedAdapters.test.ts @@ -2,7 +2,7 @@ * sessionSeed adapters — unit tests over the real scope tree. * * Each adapter observes its workspace upstream through `@ref` and provides - * the Session-scope seed token during the scope's `assemble` hook. Covered + * the Session-scope seed token during the scope's `configureContainer` hook. Covered * per adapter: live reads across an upstream generation swap (getters never * serve a stale closure), `onDidChange` forwarding from the current backing * projection, the re-fire on upstream availability change (switch backing @@ -27,7 +27,7 @@ import type { SkillCatalog } from '#/app/skillCatalog/types'; import type { McpConnectionManager } from '#/mcpCore/connection-manager'; import { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle'; import { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider'; -import { assembleSessionSeedAdapters } from '#/session/sessionSeed/sessionSeedAdapters'; +import { installSessionSeedAdapters } from '#/session/sessionSeed/sessionSeedAdapters'; import { ISessionSkillCatalogData } from '#/session/sessionSkillCatalog/skillCatalogData'; import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; import { NoopSessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGateService'; @@ -234,10 +234,10 @@ describe('sessionSeed adapters', () => { function buildSession(workspaceStubs: ScopeSeed): { workspace: Scope; session: Scope } { host = createScopedTestHost(); const workspace = host.app.createChild(LifecycleScope.Workspace, 'ws', { - extra: workspaceStubs, + seeds: workspaceStubs, }); const session = workspace.createChild(LifecycleScope.Session, 's1', { - assemble: assembleSessionSeedAdapters, + configureContainer: installSessionSeedAdapters, }); return { workspace, session }; } diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts index eb024c0ebe..a848c9521b 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts @@ -28,6 +28,7 @@ import { MAIN_AGENT_ID, } from '#/session/agentLifecycle/agentLifecycle'; import type { McpConnectionManager } from '#/mcpCore/connection-manager'; +import type { McpServerConfig } from '#/mcpCore/config-schema'; import { IWorkspaceSkillCatalog } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalog'; import { IWorkspaceAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader'; import { IExtraAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/extraAgentProfileLoader'; @@ -37,7 +38,7 @@ import { IPluginAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoad import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; import { WorkspaceDirsService } from '#/workspace/workspaceDirs/workspaceDirsService'; import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; -import { IWorkspaceMcpService, type ISessionMcpOverlay } from '#/workspace/workspaceMcp/workspaceMcp'; +import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; import { IAgentPlanService } from '#/features/plan/plan'; import { ISessionCronService } from '#/session/cron/sessionCronService'; import { ISessionSecondaryModelWarningService } from '#/session/subagent/secondaryModelWarning'; @@ -75,6 +76,7 @@ import { WorkspaceStateService } from '#/workspace/state/workspaceStateService'; import { IWorkspaceService, type Workspace } from '#/app/workspace/workspace'; import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionEphemeralMcpServers } from '#/session/mcp/ephemeralMcpServers'; import { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { Error2, ErrorCodes } from '#/errors'; @@ -1172,148 +1174,147 @@ describe('SessionLifecycleService', () => { resolveMcpReady?.(); }); - function overlayStub(ready: Promise = Promise.resolve()) { - const handle: ISessionMcpHandle = { + it('create with mcpServers fires the will-create event with the ephemeral servers seed and applies participant contributions', async () => { + const mcpServers = { eph: { transport: 'stdio' as const, command: 'node' } }; + const contributed: ISessionMcpHandle = { _serviceBrand: undefined, - ready, + ready: Promise.resolve(), connectionManager: {} as unknown as McpConnectionManager, isBaselineServer: () => true, }; - const shutdown = vi.fn(() => Promise.resolve()); - const sessionOverlay = vi.fn( - (..._args: Parameters): ISessionMcpOverlay => ({ - handle, - shutdown, - }), - ); - return { sessionOverlay, handle, shutdown }; - } - - it('create with mcpServers seeds the session overlay handle and shuts the overlay down on close', async () => { - const { sessionOverlay, handle: overlayHandle, shutdown } = overlayStub(); - const svc = await build([ - stubPair(IWorkspaceMcpService, { ...workspaceMcpServiceStub(), sessionOverlay }), - ]); - const mcpServers = { eph: { transport: 'stdio' as const, command: 'node' } }; + const seen: Array<{ + sessionId: string; + servers: Readonly>; + cwd: string; + }> = []; + const svc = await build(); + svc.onWillCreateSession((event) => { + const servers = event.readSeed(ISessionEphemeralMcpServers); + const cwd = event.readSeed(ISessionContext).cwd; + seen.push({ sessionId: event.sessionId, servers, cwd }); + if (Object.keys(servers).length > 0) { + event.contributeSeed(ISessionMcpHandle, contributed); + } + }); const handle = await svc.create({ sessionId: 's1', workDir: '/tmp/proj', mcpServers }); - expect(sessionOverlay).toHaveBeenCalledWith(mcpServers, { stdioCwd: '/tmp/proj' }); - expect(handle.accessor.get(ISessionMcpHandle)).toBe(overlayHandle); - - await svc.close('s1'); - expect(shutdown).toHaveBeenCalledTimes(1); + expect(seen).toEqual([{ sessionId: 's1', servers: mcpServers, cwd: '/tmp/proj' }]); + expect(handle.accessor.get(ISessionMcpHandle)).toBe(contributed); }); - it('resume with mcpServers seeds the session overlay handle on the re-materialized session', async () => { - const { sessionOverlay, handle: overlayHandle } = overlayStub(); + it('resume with mcpServers fires the will-create event for the re-materialized session', async () => { + const mcpServers = { eph: { transport: 'stdio' as const, command: 'node' } }; + const seen: Array>> = []; const svc = await build([ stubPair(ISessionIndex, sessionIndexWithSummary('s1', '/tmp/proj', 'wd_stub')), stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()), - stubPair(IWorkspaceMcpService, { ...workspaceMcpServiceStub(), sessionOverlay }), ]); - const mcpServers = { eph: { transport: 'stdio' as const, command: 'node' } }; + svc.onWillCreateSession((event) => { + seen.push(event.readSeed(ISessionEphemeralMcpServers)); + }); const handle = await svc.resume('s1', { mcpServers }); - expect(sessionOverlay).toHaveBeenCalledWith(mcpServers, { stdioCwd: '/tmp/proj' }); - expect(handle?.accessor.get(ISessionMcpHandle)).toBe(overlayHandle); + expect(handle).toBeDefined(); + expect(seen).toEqual([mcpServers]); }); - it('returns from create without waiting for the session MCP overlay readiness', async () => { - let resolveOverlayReady: (() => void) | undefined; - const overlayReady = new Promise((resolve) => { - resolveOverlayReady = resolve; + it('returns from create without waiting on a participant-provided session MCP handle', async () => { + let resolveReady: (() => void) | undefined; + const ready = new Promise((resolve) => { + resolveReady = resolve; }); - const { sessionOverlay } = overlayStub(overlayReady); - const svc = await build([ - stubPair(IWorkspaceMcpService, { ...workspaceMcpServiceStub(), sessionOverlay }), - ]); - - // Create resolves while the overlay's initial connect is still pending; - // the seeded handle carries the readiness promise so the agent's LLM - // steps can wait on it instead. - const handle = await svc.create({ - sessionId: 's1', - workDir: '/tmp/proj', - mcpServers: { eph: { transport: 'stdio', command: 'node' } }, + const contributed: ISessionMcpHandle = { + _serviceBrand: undefined, + ready, + connectionManager: {} as unknown as McpConnectionManager, + isBaselineServer: () => true, + }; + const svc = await build(); + svc.onWillCreateSession((event) => { + event.contributeSeed(ISessionMcpHandle, contributed); }); - expect(handle.accessor.get(ISessionMcpHandle).ready).toBe(overlayReady); - resolveOverlayReady?.(); + // Create resolves while the contributed handle's readiness is still + // pending; the seeded handle carries the readiness promise so the agent's + // LLM steps can wait on it instead. + const handle = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + expect(handle.accessor.get(ISessionMcpHandle).ready).toBe(ready); + + resolveReady?.(); }); - it('shuts the session MCP overlay down when create fails after materialization', async () => { - const { sessionOverlay, shutdown } = overlayStub(); + it('runs participant-attached teardown when create fails after materialization', async () => { + const onTeardown = vi.fn(); const svc = await build([ - stubPair(IWorkspaceMcpService, { ...workspaceMcpServiceStub(), sessionOverlay }), stubPair(IAgentLifecycleService, { ...agentLifecycleStub(), create: () => Promise.reject(new Error('Unknown agent profile')), }), ]); + svc.onWillCreateSession((event) => { + event.onSessionDispose(onTeardown); + }); await expect( svc.create({ sessionId: 's1', workDir: '/tmp/proj', mainAgentBinding: { profile: 'missing', model: 'mock' }, - mcpServers: { eph: { transport: 'stdio', command: 'node' } }, }), ).rejects.toThrow('Unknown agent profile'); - expect(shutdown).toHaveBeenCalledTimes(1); + expect(onTeardown).toHaveBeenCalledTimes(1); expect(svc.get('s1')).toBeUndefined(); }); - it('shuts the session MCP overlay down when the service is disposed with the session still live', async () => { - const { sessionOverlay, shutdown } = overlayStub(); - const svc = await build([ - stubPair(IWorkspaceMcpService, { ...workspaceMcpServiceStub(), sessionOverlay }), - ]); - await svc.create({ - sessionId: 's1', - workDir: '/tmp/proj', - mcpServers: { eph: { transport: 'stdio' as const, command: 'node' } }, + it('runs participant-attached teardown on close, exactly once across close and host disposal', async () => { + const onTeardown = vi.fn(); + const svc = await build(); + svc.onWillCreateSession((event) => { + event.onSessionDispose(onTeardown); }); + await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); - // No close: app/workspace teardown disposes the service directly, and the - // DI container disposes session scopes without going through the - // overlay-aware handle wrapper. - (svc as unknown as { dispose(): void }).dispose(); - expect(shutdown).toHaveBeenCalledTimes(1); + await svc.close('s1'); + expect(onTeardown).toHaveBeenCalledTimes(1); + + // Host teardown disposes the workspace (and therefore session) container + // directly; the attached teardown has already run and must not run again. + host?.dispose(); + await Promise.resolve(); + expect(onTeardown).toHaveBeenCalledTimes(1); }); - it('does not double-shutdown the overlay when close and service disposal both run', async () => { - const { sessionOverlay, shutdown } = overlayStub(); - const svc = await build([ - stubPair(IWorkspaceMcpService, { ...workspaceMcpServiceStub(), sessionOverlay }), - ]); - await svc.create({ - sessionId: 's1', - workDir: '/tmp/proj', - mcpServers: { eph: { transport: 'stdio' as const, command: 'node' } }, + it('runs participant-attached teardown when the host is disposed with the session still live', async () => { + const onTeardown = vi.fn(); + const svc = await build(); + svc.onWillCreateSession((event) => { + event.onSessionDispose(onTeardown); }); + await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); - await svc.close('s1'); - (svc as unknown as { dispose(): void }).dispose(); - expect(shutdown).toHaveBeenCalledTimes(1); + // No close: app/workspace teardown disposes the session scope directly, + // and the attached teardown runs with it. + host?.dispose(); + await Promise.resolve(); + expect(onTeardown).toHaveBeenCalledTimes(1); }); - it('create without mcpServers keeps the shared workspace handle and builds no overlay', async () => { - const sessionOverlay = vi.fn( - (..._args: Parameters): ISessionMcpOverlay => { - throw new Error('unexpected overlay'); - }, - ); - const svc = await build([ - stubPair(IWorkspaceMcpService, { ...workspaceMcpServiceStub(), sessionOverlay }), - ]); + it('create without mcpServers fires the will-create event with an empty ephemeral seed and keeps the workspace handle', async () => { + const svc = await build(); + const seen: Array>> = []; + svc.onWillCreateSession((event) => { + seen.push(event.readSeed(ISessionEphemeralMcpServers)); + }); const handle = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); - expect(sessionOverlay).not.toHaveBeenCalled(); - expect(handle.id).toBe('s1'); + expect(seen).toEqual([{}]); + // No participant contributed a handle: the session reads the workspace + // projection provided by the seed adapter. + expect(handle.accessor.get(ISessionMcpHandle).isBaselineServer('any')).toBe(true); await svc.close('s1'); }); diff --git a/packages/agent-core-v2/test/workspace/workspaceMcp/initialization.test.ts b/packages/agent-core-v2/test/workspace/workspaceMcp/initialization.test.ts index c00aa152b2..0e20593f80 100644 --- a/packages/agent-core-v2/test/workspace/workspaceMcp/initialization.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceMcp/initialization.test.ts @@ -37,6 +37,10 @@ import { } from '#/os/interface/hostFsWatch'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; import { IWorkspaceTrust } from '#/workspace/workspaceTrust/workspaceTrust'; +import { + ISessionLifecycleService, + type SessionWillCreateEvent, +} from '#/workspace/sessionLifecycle/sessionLifecycle'; import { IWorkspaceMcpConfigService } from '#/workspace/workspaceMcpConfig/workspaceMcpConfig'; import { WorkspaceMcpConfigService } from '#/workspace/workspaceMcpConfig/workspaceMcpConfigService'; import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; @@ -104,6 +108,9 @@ describe('Workspace MCP initialization', () => { onDidChange: Event.None as IWorkspaceTrust['onDidChange'], }); reg.define(IWorkspaceMcpConfigService, WorkspaceMcpConfigService); + reg.definePartialInstance(ISessionLifecycleService, { + onWillCreateSession: Event.None as Event, + }); registerAgentIdentityStub(reg); reg.define(IWorkspaceMcpService, WorkspaceMcpService); }, diff --git a/packages/agent-core-v2/test/workspace/workspaceMcp/workspaceMcp.test.ts b/packages/agent-core-v2/test/workspace/workspaceMcp/workspaceMcp.test.ts index 0815fdd811..cf41af00e4 100644 --- a/packages/agent-core-v2/test/workspace/workspaceMcp/workspaceMcp.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceMcp/workspaceMcp.test.ts @@ -18,20 +18,28 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vite import { DisposableStore } from '#/_base/di/lifecycle'; import { createServices } from '#/_base/di/test'; +import type { ServiceIdentifier } from '#/_base/di/instantiation'; import { Emitter } from '#/_base/event'; import { ILogService } from '#/_base/log/log'; import { McpConnectionManager } from '#/mcpCore/connection-manager'; import type { McpServerConfig } from '#/mcpCore/config-schema'; +import { ISessionEphemeralMcpServers } from '#/session/mcp/ephemeralMcpServers'; import { MergedMcpConnectionView } from '#/session/mcp/mergedConnectionView'; +import { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle'; +import { ISessionContext, makeSessionContext } from '#/session/sessionContext/sessionContext'; import { IMcpOAuthStore } from '#/app/mcpConfig/oauthStore'; import { ITelemetryService, noopTelemetryService } from '#/app/telemetry/telemetry'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { + ISessionLifecycleService, + type SessionWillCreateEvent, +} from '#/workspace/sessionLifecycle/sessionLifecycle'; import { IWorkspaceMcpConfigService, type McpServersChange, type McpTunables, } from '#/workspace/workspaceMcpConfig/workspaceMcpConfig'; -import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; +import { IWorkspaceMcpService, type ISessionMcpOverlay } from '#/workspace/workspaceMcp/workspaceMcp'; import { WorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcpService'; import { stubLog } from '../../_base/log/stubs'; @@ -49,6 +57,7 @@ describe('WorkspaceMcpService', () => { let tunablesValue: McpTunables; let tunablesFn: Mock<() => McpTunables>; let configChanges: Emitter; + let assemblyEvents: Emitter; let manager: InstanceType | undefined; beforeEach(() => { @@ -58,6 +67,7 @@ describe('WorkspaceMcpService', () => { tunablesValue = {}; tunablesFn = vi.fn(() => tunablesValue); configChanges = new Emitter(); + assemblyEvents = disposables.add(new Emitter()); manager = undefined; }); @@ -87,6 +97,9 @@ describe('WorkspaceMcpService', () => { reg.definePartialInstance(IMcpOAuthStore, createMemoryMcpOAuthStore()); reg.defineInstance(ILogService, stubLog()); reg.defineInstance(ITelemetryService, noopTelemetryService); + reg.definePartialInstance(ISessionLifecycleService, { + onWillCreateSession: assemblyEvents.event, + }); registerAgentIdentityStub(reg); reg.define(IWorkspaceMcpService, WorkspaceMcpService); }, @@ -295,6 +308,79 @@ describe('WorkspaceMcpService', () => { expect(view.get('eph')).toBeUndefined(); expect(view.get('base')?.status).toBe('connected'); }, 20000); + + describe('session overlay activation (onWillCreateSession)', () => { + function willCreateEvent(servers: Record, sessionCwd: string) { + const seeds = new Map([ + [ISessionEphemeralMcpServers, servers], + [ + ISessionContext, + makeSessionContext({ + sessionId: 's1', + workspaceId: 'ws', + sessionDir: join(cwd, 's1'), + sessionScope: 'ws/s1', + cwd: sessionCwd, + }), + ], + ]); + const contributed = new Map(); + const disposers: Array<() => void> = []; + const event: SessionWillCreateEvent = { + sessionId: 's1', + readSeed: (id: ServiceIdentifier): T => seeds.get(id) as T, + contributeSeed: (id, value) => { + contributed.set(id, value); + }, + onSessionDispose: (dispose) => { + disposers.push(dispose); + }, + }; + return { event, contributed, disposers }; + } + + it('creates the overlay from the will-create event, contributes the merged handle, and shuts it down with the session', async () => { + const service = createService(); + manager = service.connectionManager(); + await service.ready; + + // A real, spawnable session cwd distinct from the workspace root. + const sessionCwd = mkdtempSync(join(tmpdir(), 'kimi-session-mcp-cwd-')); + const servers = { eph: stdioServer() }; + const sessionOverlay = vi.spyOn(service, 'sessionOverlay'); + const { event, contributed, disposers } = willCreateEvent(servers, sessionCwd); + assemblyEvents.fire(event); + + // The ephemeral servers come from the session seed and the stdio cwd + // from the session's own context — the lifecycle event carries neither. + expect(sessionOverlay).toHaveBeenCalledWith(servers, { stdioCwd: sessionCwd }); + const overlay = sessionOverlay.mock.results[0]?.value as ISessionMcpOverlay; + expect(contributed.get(ISessionMcpHandle)).toBe(overlay.handle); + await overlay.handle.ready; + expect(overlay.handle.connectionManager.get('eph')?.status).toBe('connected'); + + const shutdown = vi.spyOn(overlay, 'shutdown'); + expect(disposers).toHaveLength(1); + disposers[0]!(); + expect(shutdown).toHaveBeenCalledTimes(1); + await shutdown.mock.results[0]?.value; + await rm(sessionCwd, { recursive: true, force: true }); + }, 20000); + + it('ignores a session created without ephemeral servers', async () => { + const service = createService(); + manager = service.connectionManager(); + await service.ready; + + const sessionOverlay = vi.spyOn(service, 'sessionOverlay'); + const { event, contributed, disposers } = willCreateEvent({}, cwd); + assemblyEvents.fire(event); + + expect(sessionOverlay).not.toHaveBeenCalled(); + expect(contributed.size).toBe(0); + expect(disposers).toHaveLength(0); + }); + }); }); describe('MergedMcpConnectionView', () => {