import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { Disposable, DisposableStore } from '#/_base/di/lifecycle'; import { IInstantiationService } from '#/_base/di/instantiation'; import { InstantiationService } from '#/_base/di/instantiationService'; import { LifecycleScope } from '#/app/scopes'; import { type ISessionScopeHandle } from '#/_base/di/scope'; import { TestInstantiationService } from '#/_base/di/test'; import { Event } from '#/_base/event'; import { IAgentProfileService } from '#/agent/profile/profile'; import '#/agent/profile/profileService'; import { ProfileBind } from '#/agent/profile/profileOps'; import { TOWER_WORKER_PROFILE } from '#/features/tower/tower'; import { IAgentAgentsMdReminderService } from '#/agent/agentsMdReminder/agentsMdReminder'; import { IAgentMcpService } from '#/agent/mcp/mcp'; import { ISessionMediaStore } from '#/agent/media/sessionMediaStore'; import { SessionMediaStoreService } from '#/agent/media/sessionMediaStoreService'; import { McpConnectionManager } from '#/mcpCore/connection-manager'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import '#/agent/permissionMode/permissionModeService'; import { permissionModeConfiguredKey, permissionModeKey, } from '#/agent/permissionMode/permissionModeOps'; import { IAgentRuntimeBindingService } from '#/agent/runtimeBinding/runtimeBinding'; import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; import { IAgentStateService } from '#/agent/state/agentState'; import { AgentStateService } from '#/agent/state/agentStateService'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { AgentReminderService, IAgentReminderService } from '#/features/reminder/reminderService'; import '#/agent/contextMemory/contextMemoryService'; import { INHERITED_IN_FLIGHT_TOOL_OUTPUT } from '#/agent/contextMemory/openToolExchange'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { agentContextOf, IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; import { IBuiltinAgentProfileLoader } from '#/app/agentProfileCatalog/builtinAgentProfileLoader'; import { IModelCatalog } from '#/llm-adapter/model/catalog'; import type { ToolCall } from '#human/llm/message'; import { IProtocolAdapterRegistry } from '#/llm-adapter/protocol/protocol'; import { IHostClock } from '#/os/interface/hostClock'; import { ISessionStateService } from '#/session/state/sessionState'; import { SessionStateService } from '#/session/state/sessionStateService'; import { ISessionTokenCountingService } from '#/session/tokenCounting/sessionTokenCounting'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { AgentLifecycleService } from '#/session/agentLifecycle/agentLifecycleService'; import { cancelInteractionsForTurn, detachInteractionAgent, } from '#/agent/interaction/interactionWiring'; import { interactions } from '#/human/interaction/facade'; import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent'; import { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle'; import { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider'; import { McpOAuthService } from '#/mcpCore/oauth/service'; import { createMcpOAuthStore } from '#/app/mcpConfig/oauthStore'; import { ISessionSubagentService } from '#/session/subagent/subagent'; import { SessionSubagentService } from '#/session/subagent/subagentService'; import '#/agent/mcp/mcpService'; import { IEventDispatcher } from '#/state/eventDispatcher'; import '#/state/eventDispatcherService'; import { IAgentTaskService } from '#/agent/task/task'; import { AgentCronService, IAgentCronService } from '#/features/cron/cronService'; import { ICronCreateTool } from '#/features/cron/tools/cron-create/cron-create'; import { ICronDeleteTool } from '#/features/cron/tools/cron-delete/cron-delete'; import { ICronListTool } from '#/features/cron/tools/cron-list/cron-list'; import { CRON_SECTION } from '#/features/cron/configSection'; import { Ledger } from '#/_base/lifecycle/ledger'; import { BugIndicatingError } from '#/_base/errors/errors'; import { AgentTodoService, IAgentTodoService } from '#/features/todo/todoService'; import '#/agent/toolDedupe/toolDedupeService'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import { IFlagService } from '#/app/flag/flag'; import { ISessionNotify } from '#/features/notify/sessionNotify'; import { ISessionEventBus } from '#/app/event/eventBus'; import { EventBusService } from '#/app/event/eventBusService'; import '#/app/event/eventBusService'; import { TurnStarted } from '#/agent/loop/turnEvents'; import { IAgentPluginService } from '#/agent/plugin/agentPlugin'; import { ILogService } from '#/_base/log/log'; import { IPluginService } from '#/app/plugin/plugin'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { BlobStoreService } from '#/persistence/backends/node-fs/blobStoreService'; import { IBlobStore } from '#/persistence/interface/blobStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { createWireMetadataRecord, type WireRecord } from '#/wire/record'; import { IWireService } from '#/wire/wire'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IAgentLoopService } from '#/agent/loop/loop'; import type { MachineEngine, MachineEngineAttachBundle, } from '#/agent/loop/machine/engine'; import type { AgentEventStore } from '#human/agent/slices'; import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog'; import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; import { _clearAgentToolContributionsForTests } from '#/agent/toolRegistry/toolContribution'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import '#/agent/toolActivation/toolActivationService'; import { IAgentMediaToolsRegistrar } from '#/agent/media/mediaTools'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { FakeRuntime } from '#/runtime/fakeRuntime'; import { ScopeUnits, type Fiber } from '#/_base/di/fiber'; import { IRuntimeResolver, IWorkspaceInstanceManager, } from '#/workspace/workspaceInstance/workspaceInstanceManager'; import type { OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js'; import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; import { stubAgentContext } from '../../agent/agentContext/stubs'; const noopLog = { _serviceBrand: undefined, level: 'off', setLevel: () => {}, flush: async () => {}, error: () => {}, warn: () => {}, info: () => {}, debug: () => {}, child: () => noopLog, } as unknown as ILogService; const pluginServiceStub = { _serviceBrand: undefined, onDidReload: () => ({ dispose: () => {} }), listPlugins: async () => [], installPlugin: async () => ({ id: '' }) as never, setPluginEnabled: async () => {}, setPluginMcpServerEnabled: async () => {}, removePlugin: async () => {}, reloadPlugins: async () => ({ added: [], removed: [], errors: [] }), getPluginInfo: async () => { throw new Error('getPluginInfo is not used by these tests'); }, listPluginCommands: async () => [], checkUpdates: async () => [], pluginSkillRoots: async () => [], enabledSessionStarts: async () => [], enabledMcpServers: async () => ({}), enabledHooks: async () => [], } as unknown as IPluginService; function stubAttachStore(): AgentEventStore { return { ref: { tree: 'test', branch: 'main' }, getState: () => ({ history: [], queue: [], notifications: [], reminders: [], turnIndex: { nextTurnId: 0 }, }), subscribe: () => () => {}, dispatch: () => Promise.resolve({ kind: 'entry', seq: 0, ts: 0, type: 'noop', payload: null }), registerSlice: () => Promise.resolve(() => {}), reset: () => Promise.resolve(), flush: () => Promise.resolve(), close: () => Promise.resolve(), } as unknown as AgentEventStore; } function stubAttachEngine(): MachineEngine { return { submit: () => {}, steer: () => {}, notify: () => {}, remind: () => {}, cancelQueueItem: () => {}, abort: () => {}, pause: () => {}, resume: () => {}, resetHistory: () => Promise.resolve(), resetJournal: () => Promise.resolve(), stop: () => {}, snapshot: () => ({ running: false, aborting: false, waitingForBackground: false, paused: false, queue: [], queueLength: 0, queueIds: [], notificationCount: 0, reminderCount: 0, backgroundCount: 0, }), currentStep: () => 0, lastFinish: () => undefined, toolExtras: new Map(), handleToolProgress: () => {}, }; } function stubAttachBundle(): MachineEngineAttachBundle { return { store: stubAttachStore(), request: { model: { provider: 'test', model: 'test' } }, } as unknown as MachineEngineAttachBundle; } function recordingAppendLog(initial: readonly WireRecord[] = []): { readonly appended: WireRecord[]; readonly store: IAppendLogStore; rewritten?: readonly WireRecord[]; } { const records = [...initial]; const appended: WireRecord[] = []; const state: { rewritten?: readonly WireRecord[] } = {}; const store: IAppendLogStore = { _serviceBrand: undefined, onDidWrite: Event.None as IAppendLogStore['onDidWrite'], append: (_scope: string, _key: string, record: R) => { const persisted = record as unknown as WireRecord; records.push(persisted); appended.push(persisted); }, read: async function* (): AsyncIterable { for (const record of records) { yield record as R; } }, rewrite: (_scope: string, _key: string, next: readonly R[]) => { const persisted = next as readonly WireRecord[]; state.rewritten = persisted; records.splice(0, records.length, ...persisted); return Promise.resolve(); }, flush: () => Promise.resolve(), flushLog: () => Promise.resolve(), close: () => Promise.resolve(), acquire: () => ({ dispose: () => {} }), drainRetirements: () => Promise.resolve(), }; return { appended, get rewritten() { return state.rewritten; }, store, }; } describe('AgentLifecycleService', () => { let disposables: DisposableStore; let ix: TestInstantiationService; let registerAgent: ReturnType>; let atomicDocs: Map; let permissionModeSetMode: ReturnType; let stopAllOnExit: ReturnType; let suppressAllTerminalNotifications: ReturnType; let loopActiveTurnId: number | undefined; let loopPendingPromptIds: string[]; let loopCancel: ReturnType>; let loopSettled: ReturnType>; let beforeExecuteListeners: number; let didExecuteHookIds: string[]; beforeEach(() => { _clearAgentToolContributionsForTests(); disposables = new DisposableStore(); ix = disposables.add(new TestInstantiationService()); ix.set(ISessionStateService, new SessionStateService()); ix.set(IAgentStateService, new AgentStateService()); ix.set(ISessionEventBus, new SyncDescriptor(EventBusService)); ix.get(IAgentStateService).contributeState(permissionModeKey); ix.get(IAgentStateService).contributeState(permissionModeConfiguredKey); ix.stub(IAppendLogStore, recordingAppendLog().store); ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.stub(IBlobStore, new BlobStoreService(new InMemoryStorageService())); ix.set(ISessionMediaStore, new SyncDescriptor(SessionMediaStoreService)); registerAgent = vi.fn().mockResolvedValue(undefined); atomicDocs = new Map(); ix.stub(ISessionContext, { _serviceBrand: undefined, sessionId: 'sess_test', workspaceId: 'ws_test', sessionDir: '/tmp/kimi-agentLifecycle-test', metaScope: 'test', scope: (subKey?: string) => subKey === undefined || subKey === '' ? 'sessions/ws_test/sess_test' : `sessions/ws_test/sess_test/${subKey}`, } as unknown as ISessionContext); ix.stub(IRuntimeResolver, { _serviceBrand: undefined, inspect: (binding) => new FakeRuntime({ ...binding, generation: `${binding.runtimeId}-one` }), acquire: (binding) => ({ runtime: new FakeRuntime({ ...binding, generation: `${binding.runtimeId}-one` }), track: (resource) => resource, dispose: () => {}, }), }); ix.stub(IWorkspaceInstanceManager, { _serviceBrand: undefined, onDidChange: () => ({ dispose: () => {} }), get: () => undefined, }); ix.stub(ISessionMetadata, { _serviceBrand: undefined, ready: Promise.resolve(), onDidChangeMetadata: () => ({ dispose: () => {} }), read: () => Promise.resolve({ id: 'sess_test', createdAt: 0, updatedAt: 0, archived: false }), update: () => Promise.resolve(), setTitle: () => Promise.resolve(), setArchived: () => Promise.resolve(), registerAgent, }); ix.stub(IBootstrapService, { _serviceBrand: undefined, homeDir: '/tmp/kimi-agentLifecycle-home', cwd: '/tmp/kimi-agentLifecycle-home', getEnv: () => undefined, } as unknown as IBootstrapService); ix.stub(IFlagService, { _serviceBrand: undefined, enabled: () => false, } as unknown as IFlagService); ix.stub(ISessionNotify, { _serviceBrand: undefined, ready: Promise.resolve(), enabled: false }); ix.stub(ISessionWorkspaceContext, { _serviceBrand: undefined, workDir: '/tmp/kimi-agentLifecycle-work', additionalDirs: [], } as unknown as ISessionWorkspaceContext); ix.stub(IPluginService, pluginServiceStub); ix.stub(IConfigService, { ready: Promise.resolve(), get: (() => undefined) as IConfigService['get'], onDidSectionChange: (() => ({ dispose: () => {} })) as IConfigService['onDidSectionChange'], } as unknown as IConfigService); const atomicDocsStore: IAtomicDocumentStore = { _serviceBrand: undefined, get: async (scope: string, key: string): Promise => atomicDocs.get(`${scope}/${key}`) as T | undefined, set: async (scope: string, key: string, value: T): Promise => { atomicDocs.set(`${scope}/${key}`, value); }, delete: async (scope: string, key: string): Promise => { atomicDocs.delete(`${scope}/${key}`); }, list: async (scope: string, prefix = ''): Promise => [...atomicDocs.keys()] .filter((key) => key.startsWith(`${scope}/${prefix}`)) .map((key) => key.slice(scope.length + 1)), acquire: () => ({ dispose: () => {} }), }; ix.stub(IAtomicDocumentStore, atomicDocsStore); ix.stub(ILogService, noopLog); ix.stub(IAgentPluginService, { _serviceBrand: undefined, refreshSessionStart: async () => {}, }); ix.stub(IAgentToolRegistryService, { _serviceBrand: undefined, register: () => ({ dispose: () => {} }), resolve: () => undefined, list: () => [], } as unknown as IAgentToolRegistryService); ix.stub(IAgentMediaToolsRegistrar, { _serviceBrand: undefined, } as IAgentMediaToolsRegistrar); beforeExecuteListeners = 0; didExecuteHookIds = []; ix.stub(IAgentToolExecutorService, { _serviceBrand: undefined, onBeforeExecuteTool: () => { beforeExecuteListeners += 1; return { dispose: () => {} }; }, onWillExecuteTool: () => ({ dispose: () => {} }), hooks: { onDidExecuteTool: { register: (id: string) => { didExecuteHookIds.push(id); return { dispose: () => {} }; }, }, }, } as unknown as IAgentToolExecutorService); loopActiveTurnId = undefined; loopPendingPromptIds = []; loopCancel = vi.fn((target) => { if (target?.promptId !== undefined) { loopPendingPromptIds = loopPendingPromptIds.filter((id) => id !== target.promptId); return true; } if (target?.turnId === undefined) { loopActiveTurnId = undefined; } return true; }); loopSettled = vi.fn(async () => { if (loopActiveTurnId !== undefined || loopPendingPromptIds.length > 0) { throw new Error('Agent loop did not settle'); } }); ix.stub(IAgentLoopService, { _serviceBrand: undefined, hooks: { onWillBeginStep: { register: () => ({ dispose: () => {} }) }, onDidFinishStep: { register: () => ({ dispose: () => {} }) }, }, registerLoopErrorHandler: () => ({ dispose: () => {} }), snapshot: () => ({ state: loopActiveTurnId === undefined ? 'idle' : 'running', activeTurnId: loopActiveTurnId, activePromptId: undefined, queue: loopPendingPromptIds.map((promptId) => ({ message: { role: 'user', content: [] }, meta: { promptId }, })), notificationCount: 0, paused: false, hasPendingRequests: loopActiveTurnId !== undefined || loopPendingPromptIds.length > 0, turn: undefined, activeTraceId: undefined, }), cancel: loopCancel, settled: loopSettled, tryAcquireQuiescence: vi.fn(() => ({ dispose: vi.fn() })), buildAttachBundle: () => stubAttachBundle(), attachEngine: () => stubAttachEngine(), } as unknown as IAgentLoopService); ix.stub(ITelemetryService, { _serviceBrand: undefined, track2: () => {}, withContext: () => ({ _serviceBrand: undefined, track2: () => {}, }) as unknown as ITelemetryService, } as unknown as ITelemetryService); ix.stub(IHostEnvironment, { _serviceBrand: undefined } as IHostEnvironment); ix.stub(IHostFileSystem, { _serviceBrand: undefined } as IHostFileSystem); ix.stub(IHostClock, { _serviceBrand: undefined } as IHostClock); ix.stub(IModelCatalog, { _serviceBrand: undefined } as IModelCatalog); ix.stub(ISessionTokenCountingService, { estimateText: () => 0, estimateMessage: () => 0, estimateMessages: () => 0, recordTruncation: () => {}, } as unknown as ISessionTokenCountingService); ix.stub(IProtocolAdapterRegistry, { _serviceBrand: undefined, } as IProtocolAdapterRegistry); ix.stub(IBuiltinAgentProfileLoader, { _serviceBrand: undefined, } as IBuiltinAgentProfileLoader); ix.stub(IAgentIdentity, { _serviceBrand: undefined } as IAgentIdentity); ix.stub(IAgentAgentsMdReminderService, { _serviceBrand: undefined, } as IAgentAgentsMdReminderService); ix.stub(ISessionAgentProfileCatalog, { _serviceBrand: undefined, ready: Promise.resolve(), get: () => undefined, getDefault: () => { throw new Error('catalog resolution is not expected'); }, list: () => [], load: () => Promise.resolve(), reload: () => Promise.resolve(), onDidChange: Event.None, } as unknown as ISessionAgentProfileCatalog); ix.stub(ISessionSkillCatalog, { _serviceBrand: undefined, catalog: { skills: [] }, ready: Promise.resolve(), onDidChange: Event.None, load: () => Promise.resolve(), reload: () => Promise.resolve(), } as unknown as ISessionSkillCatalog); ix.stub(ISessionToolPolicy, { _serviceBrand: undefined, ready: Promise.resolve(), onDidChange: Event.None, disabledTools: () => [], setDisabledTools: () => Promise.resolve(), } as unknown as ISessionToolPolicy); ix.stub(ISessionToolPolicyGate, { _serviceBrand: undefined, disabledTools: [], onDidChange: Event.None as Event, } satisfies ISessionToolPolicyGate); permissionModeSetMode = vi.fn(); ix.stub(IAgentPermissionModeService, { _serviceBrand: undefined, mode: 'manual', setMode: permissionModeSetMode, onDidChangeMode: Event.None, } as unknown as IAgentPermissionModeService); ix.stub(ISessionInstructionsProvider, { _serviceBrand: undefined, ready: Promise.resolve(), agentsMd: undefined, agentsMdWarning: undefined, agentsMdPaths: undefined, onDidChange: Event.None as ISessionInstructionsProvider['onDidChange'], } satisfies ISessionInstructionsProvider); ix.stub(IAgentAgentsMdReminderService, { _serviceBrand: undefined, seedInjected: () => {}, }); ix.stub(ISessionMcpHandle, { _serviceBrand: undefined, ready: Promise.resolve(), connectionManager: new McpConnectionManager({ log: noopLog, oauthService: new McpOAuthService({ store: createMcpOAuthStore(atomicDocsStore) }), }), isBaselineServer: () => true, } satisfies ISessionMcpHandle); stopAllOnExit = vi.fn(async () => []); suppressAllTerminalNotifications = vi.fn(async () => {}); ix.stub(IAgentTaskService, { _serviceBrand: undefined, stopAllOnExit, suppressAllTerminalNotifications, } as unknown as IAgentTaskService); ix.stub(IAgentFullCompactionService, { _serviceBrand: undefined, compacting: null, } as unknown as IAgentFullCompactionService); ix.fiberHost.addCollectionRecord( ScopeUnits(LifecycleScope.Agent), 'test-reminder', new Ledger('test-reminder'), { name: 'test:agentReminderService', apply(fiber: Fiber): void { fiber.provide(IAgentReminderService, AgentReminderService); }, }, ); ix.set(IAgentLifecycleService, new SyncDescriptor(AgentLifecycleService)); }); afterEach(() => { disposables.dispose(); vi.restoreAllMocks(); }); function contributeTodoService(): void { ix.fiberHost.addCollectionRecord( ScopeUnits(LifecycleScope.Agent), 'test', new Ledger('test'), { name: 'test:agentTodoService', apply(fiber: Fiber): void { fiber.provide(IAgentTodoService, AgentTodoService); }, }, ); } function contributeCronService(): void { ix.fiberHost.addCollectionRecord( ScopeUnits(LifecycleScope.Agent), 'test', new Ledger('test'), { name: 'test:agentCronService', apply(fiber: Fiber): void { fiber.provide(IAgentCronService, AgentCronService); }, }, ); } it('create / get / list / remove', async () => { const svc = ix.get(IAgentLifecycleService); const main = await svc.create({ agentId: 'main' }); expect(main.agentId).toBe('main'); expect(svc.get('main')).toBe(main); expect(svc.handleOf('main')).toBeDefined(); expect(svc.list()).toEqual([main]); await svc.remove(main); expect(svc.get('main')).toBeUndefined(); expect(svc.handleOf('main')).toBeUndefined(); }); it('remove flushes the agent wire journal before disposal', async () => { const svc = ix.get(IAgentLifecycleService); await svc.create({ agentId: 'main' }); const handle = svc.handleOf('main')!; const dispatcher = handle.accessor.get(IEventDispatcher); expect((dispatcher as unknown as { wire: IWireService }).wire).toBe( handle.accessor.get(IWireService), ); const flush = vi.spyOn(dispatcher, 'flush'); await svc.remove(svc.get('main')!); expect(flush).toHaveBeenCalled(); }); it('remove keeps the lifecycle context active through async scope teardown', async () => { const svc = ix.get(IAgentLifecycleService); const bus = ix.get(ISessionEventBus); const main = await svc.create({ agentId: 'main' }); const seen: string[] = []; disposables.add(bus.subscribe(TurnStarted, () => seen.push('delivered'))); const agentScope = ix.children.find((child) => child.debugLabel === 'main'); expect(agentScope).toBeDefined(); let releaseDrain!: () => void; let gateEntered!: () => void; const entered = new Promise((resolve) => { gateEntered = resolve; }); agentScope!.anchorKernelEntry(() => { gateEntered(); return new Promise((resolve) => { releaseDrain = resolve; }); }, 'test-async-disposer'); const unhandled: unknown[] = []; const onUnhandled = (reason: unknown): void => { unhandled.push(reason); }; process.on('unhandledRejection', onUnhandled); try { const removal = svc.remove(main); await entered; bus.publish(new TurnStarted({ agentId: 'main', turnId: 1, origin: { kind: 'user' } }), main); expect(seen).toEqual(['delivered']); releaseDrain(); await removal; expect(() => bus.publish(new TurnStarted({ agentId: 'main', turnId: 2, origin: { kind: 'user' } }), main), ).toThrow("Agent event 'turn.started' has no active lifecycle context"); expect(unhandled).toEqual([]); } finally { process.off('unhandledRejection', onUnhandled); } }); function contributeDisposeBeacon( dispose: (eventBus: ISessionEventBus, scope: IAgentScopeContext) => void | Promise, ): void { class DisposeBeacon { constructor( @ISessionEventBus private readonly eventBus: ISessionEventBus, @IAgentScopeContext private readonly scope: IAgentScopeContext, ) {} dispose(): void | Promise { return dispose(this.eventBus, this.scope); } } ix.fiberHost.addCollectionRecord( ScopeUnits(LifecycleScope.Agent), 'test', new Ledger('test'), DisposeBeacon, ); } function publishDisposed(eventBus: ISessionEventBus, scope: IAgentScopeContext): void { eventBus.publish( new TurnStarted({ agentId: scope.agentId, turnId: 1, origin: { kind: 'user' } }), scope.agentContext, ); } it('remove deactivates after scope-units contributed units are torn down', async () => { const svc = ix.get(IAgentLifecycleService); const bus = ix.get(ISessionEventBus); const seen: string[] = []; disposables.add(bus.subscribe(TurnStarted, () => seen.push('delivered'))); contributeDisposeBeacon(publishDisposed); const unhandled: unknown[] = []; const onUnhandled = (reason: unknown): void => { unhandled.push(reason); }; process.on('unhandledRejection', onUnhandled); try { const main = await svc.create({ agentId: 'main' }); await svc.remove(main); expect(seen).toEqual(['delivered']); expect(unhandled).toEqual([]); } finally { process.off('unhandledRejection', onUnhandled); } }); it('create failure after scope creation keeps the context active through async teardown', async () => { registerAgent.mockRejectedValueOnce(new Error('boom')); const svc = ix.get(IAgentLifecycleService); const bus = ix.get(ISessionEventBus); const seen: string[] = []; disposables.add(bus.subscribe(TurnStarted, () => seen.push('delivered'))); class GatedBeacon { constructor( @ISessionEventBus private readonly eventBus: ISessionEventBus, @IAgentScopeContext private readonly scope: IAgentScopeContext, @IInstantiationService instantiation: IInstantiationService, ) { (instantiation as InstantiationService).anchorKernelEntry( () => new Promise((resolve) => setTimeout(resolve, 20)), 'beacon-gate', ); } dispose(): void { publishDisposed(this.eventBus, this.scope); } } ix.fiberHost.addCollectionRecord( ScopeUnits(LifecycleScope.Agent), 'test', new Ledger('test'), GatedBeacon, ); const unhandled: unknown[] = []; const onUnhandled = (reason: unknown): void => { unhandled.push(reason); }; process.on('unhandledRejection', onUnhandled); try { await expect(svc.create({ agentId: 'main' })).rejects.toThrow('boom'); expect(seen).toEqual(['delivered']); expect(unhandled).toEqual([]); } finally { process.off('unhandledRejection', onUnhandled); } }); it('remove awaits asynchronous contributed-unit teardown before deactivating', async () => { const svc = ix.get(IAgentLifecycleService); const bus = ix.get(ISessionEventBus); const seen: string[] = []; disposables.add(bus.subscribe(TurnStarted, () => seen.push('delivered'))); contributeDisposeBeacon(async (eventBus, scope) => { await new Promise((resolve) => setTimeout(resolve, 0)); publishDisposed(eventBus, scope); }); const unhandled: unknown[] = []; const onUnhandled = (reason: unknown): void => { unhandled.push(reason); }; process.on('unhandledRejection', onUnhandled); try { const main = await svc.create({ agentId: 'main' }); await svc.remove(main); expect(seen).toEqual(['delivered']); expect(unhandled).toEqual([]); } finally { process.off('unhandledRejection', onUnhandled); } }); it('remove stops the agent background tasks before disposal', async () => { const svc = ix.get(IAgentLifecycleService); const main = await svc.create({ agentId: 'main' }); await svc.remove(main); expect(stopAllOnExit).toHaveBeenCalledWith('Session closed'); expect(loopSettled).toHaveBeenCalled(); expect(suppressAllTerminalNotifications).toHaveBeenCalledOnce(); expect(suppressAllTerminalNotifications.mock.invocationCallOrder[0]).toBeLessThan( loopSettled.mock.invocationCallOrder[0]!, ); expect(stopAllOnExit.mock.invocationCallOrder[0]).toBeGreaterThan( loopSettled.mock.invocationCallOrder[0]!, ); }); it('remove finishes teardown instead of reactivating a partially torn-down scope', async () => { const svc = ix.get(IAgentLifecycleService); const main = await svc.create({ agentId: 'main' }); const closed: string[] = []; disposables.add(svc.onDidClose((agent) => closed.push(agent.agentId))); stopAllOnExit.mockRejectedValueOnce(new Error('stop failed')); await expect(svc.remove(main)).rejects.toThrow('stop failed'); expect(svc.get('main')).toBeUndefined(); expect(svc.handleOf('main')).toBeUndefined(); expect(closed).toEqual(['main']); await svc.remove(main); expect(stopAllOnExit).toHaveBeenCalledOnce(); }); it('remove waits for prompt intake to drain before disposing the agent scope', async () => { let releaseDrain!: () => void; let markDrainStarted!: () => void; const drainStarted = new Promise((resolve) => { markDrainStarted = resolve; }); loopSettled.mockImplementationOnce(() => { markDrainStarted(); return new Promise((resolve) => { releaseDrain = resolve; }); }); const svc = ix.get(IAgentLifecycleService); const main = await svc.create({ agentId: 'main' }); const disposed: string[] = []; disposables.add(svc.onDidClose((agent) => disposed.push(agent.agentId))); const removal = svc.remove(main); await drainStarted; await Promise.resolve(); expect(disposed).toEqual([]); releaseDrain(); await removal; expect(disposed).toEqual(['main']); }); it('remove cancels queued turns before waiting for the active turn to settle', async () => { loopActiveTurnId = 1; loopPendingPromptIds = ['q2', 'q3']; const svc = ix.get(IAgentLifecycleService); const main = await svc.create({ agentId: 'main' }); await svc.remove(main); expect(loopCancel.mock.calls.map(([target]) => target)).toEqual([ { promptId: 'q2' }, { promptId: 'q3' }, undefined, ]); expect(loopSettled).toHaveBeenCalledOnce(); }); it('remove waits for an active full compaction to reject after aborting it', async () => { const abortController = new AbortController(); let rejectCompaction!: (reason: unknown) => void; const promise = new Promise((_resolve, reject) => { rejectCompaction = reject; }); const aborted = new Promise((resolve) => { abortController.signal.addEventListener( 'abort', () => { resolve(); }, { once: true }, ); }); ix.stub(IAgentFullCompactionService, { _serviceBrand: undefined, compacting: { abortController, promise, trigger: 'manual', tokenCount: 100, }, } as unknown as IAgentFullCompactionService); const svc = ix.get(IAgentLifecycleService); const main = await svc.create({ agentId: 'main' }); let removed = false; const removal = svc.remove(main).then(() => { removed = true; }); await aborted; await Promise.resolve(); expect(removed).toBe(false); rejectCompaction(abortController.signal.reason); await removal; expect(removed).toBe(true); }); it('ignites the self-wiring toolDedupe plugin so its listeners exist before the first turn', async () => { const svc = ix.get(IAgentLifecycleService); await svc.create({ agentId: 'main' }); expect(beforeExecuteListeners).toBeGreaterThan(0); expect(didExecuteHookIds).toContain('toolDedupe'); }); it('create skips auto ids that collide with agents persisted by a previous run', async () => { ix.stub(ISessionMetadata, { _serviceBrand: undefined, ready: Promise.resolve(), onDidChangeMetadata: () => ({ dispose: () => {} }), read: () => Promise.resolve({ id: 'sess_test', createdAt: 0, updatedAt: 0, archived: false, agents: { 'agent-0': { homedir: '/tmp/kimi-agentLifecycle-test/agents/agent-0', type: 'sub' }, 'agent-1': { homedir: '/tmp/kimi-agentLifecycle-test/agents/agent-1', type: 'sub' }, }, }), update: () => Promise.resolve(), setTitle: () => Promise.resolve(), setArchived: () => Promise.resolve(), registerAgent, }); const svc = ix.get(IAgentLifecycleService); const first = await svc.create({}); expect(first.agentId).toBe('agent-2'); const second = await svc.create({}); expect(second.agentId).toBe('agent-3'); }); it('seeds each agent scope with a telemetry view bound to its own agent id', async () => { const records: TelemetryRecord[] = []; ix.stub(ITelemetryService, recordingTelemetry(records)); const svc = ix.get(IAgentLifecycleService); await svc.create({ agentId: 'main' }); const sub = await svc.create({}); svc.handleOf('main')!.accessor.get(ITelemetryService).track2('yolo_toggle', { enabled: true }); svc.handleOf(sub.agentId)!.accessor.get(ITelemetryService).track2('yolo_toggle', { enabled: false }); expect(records).toContainEqual({ event: 'yolo_toggle', properties: { agent_id: 'main', enabled: true, mode: 'agent' }, }); expect(records).toContainEqual({ event: 'yolo_toggle', properties: { agent_id: sub.agentId, enabled: false, mode: 'agent' }, }); }); it('create assigns sequential ids when unspecified', async () => { const svc = ix.get(IAgentLifecycleService); const a = await svc.create({}); const b = await svc.create({}); expect(a.agentId).not.toBe(b.agentId); }); it('persists complete agent metadata when creating a child', async () => { const svc = ix.get(IAgentLifecycleService); const child = await svc.create({ agentId: 'child', forkedFrom: 'main', labels: { swarmItem: 'swarm-item-1' }, }); expect(child.agentId).toBe('child'); expect(registerAgent).toHaveBeenCalledWith('child', { homedir: '/tmp/kimi-agentLifecycle-home/sessions/ws_test/sess_test/agents/child', type: 'sub', parentAgentId: 'main', forkedFrom: 'main', labels: { swarmItem: 'swarm-item-1' }, }); }); it('seals a fresh wire log with the metadata envelope as the first record', async () => { const log = recordingAppendLog(); ix.stub(IAppendLogStore, log.store); const svc = ix.get(IAgentLifecycleService); await svc.create({ agentId: 'main' }); expect(log.appended[0]).toMatchObject({ type: 'metadata', protocol_version: createWireMetadataRecord().protocol_version, }); }); it('does not re-seal a wire log that already has records', async () => { const existing: WireRecord = { type: 'turn.prompt', input: [{ type: 'text', text: 'existing' }], origin: { kind: 'user' }, }; const log = recordingAppendLog([existing]); ix.stub(IAppendLogStore, log.store); const svc = ix.get(IAgentLifecycleService); await svc.create({ agentId: 'main' }); expect(log.appended.some((record) => record.type === 'metadata')).toBe(false); }); it('leaves permission mode at the default when permissionMode is omitted', async () => { const svc = ix.get(IAgentLifecycleService); await svc.create({ agentId: 'child' }); expect(permissionModeSetMode).not.toHaveBeenCalled(); }); it('applies the configured permission mode when the Agent has no persisted mode', async () => { ix.stub(IConfigService, { ready: Promise.resolve(), get: (() => 'auto') as IConfigService['get'], onDidSectionChange: (() => ({ dispose: () => {} })) as IConfigService['onDidSectionChange'], } as unknown as IConfigService); const svc = ix.get(IAgentLifecycleService); await svc.create({ agentId: 'main' }); expect(svc.handleOf('main')!.accessor.get(IAgentStateService).get(permissionModeKey)).toBe('auto'); }); it('keeps the restored permission mode instead of overwriting it with the default', async () => { ix.stub(IAppendLogStore, recordingAppendLog([ createWireMetadataRecord(1), { type: 'permission.set_mode', mode: 'manual', time: 2 }, ]).store); ix.stub(IConfigService, { ready: Promise.resolve(), get: (() => 'auto') as IConfigService['get'], onDidSectionChange: (() => ({ dispose: () => {} })) as IConfigService['onDidSectionChange'], } as unknown as IConfigService); await ix.get(IAgentLifecycleService).create({ agentId: 'main' }); expect(permissionModeSetMode).not.toHaveBeenCalled(); }); it('restores the runtime binding without persisting a generation', async () => { ix.stub(IAppendLogStore, recordingAppendLog([ createWireMetadataRecord(1), { type: 'runtime.set_binding', workspaceId: 'ws_test', runtimeId: 'remote', time: 2 }, ]).store); const svc = ix.get(IAgentLifecycleService); await svc.create({ agentId: 'main' }); const agent = svc.handleOf('main')!; expect(agent.accessor.get(IAgentRuntimeBindingService).current).toEqual({ workspaceId: 'ws_test', runtimeId: 'remote', }); expect(agent.accessor.get(IAgentRuntimeService).inspect().identity.generation).toBe('remote-one'); }); it('attaches durable runtimes before restore and replays their records', async () => { ix.stub(IAppendLogStore, recordingAppendLog([ createWireMetadataRecord(1), { type: 'tools.update_store', key: 'todo', value: [{ title: 'bridged', status: 'pending' }], time: 2, }, { type: 'cron.add', task: { id: 'cron-1', cron: '0 9 * * *', prompt: 'ping', createdAt: 1, recurring: true }, time: 4, }, ]).store); ix.stub(IConfigService, { ready: Promise.resolve(), get: ((section: unknown) => section === CRON_SECTION ? { disabled: true } : undefined) as IConfigService['get'], onDidSectionChange: (() => ({ dispose: () => {} })) as IConfigService['onDidSectionChange'], } as unknown as IConfigService); ix.stub(IAgentToolRegistryService, { _serviceBrand: undefined, register: () => ({ dispose: () => {} }), } as unknown as IAgentToolRegistryService); ix.stub(ICronCreateTool, { _serviceBrand: undefined }); ix.stub(ICronListTool, { _serviceBrand: undefined }); ix.stub(ICronDeleteTool, { _serviceBrand: undefined }); contributeTodoService(); contributeCronService(); const svc = ix.get(IAgentLifecycleService); const main = await svc.create({ agentId: 'main' }); expect(svc.handleOf('main')!.accessor.get(IAgentTodoService).get()).toEqual([ { title: 'bridged', status: 'pending' }, ]); expect(svc.handleOf('main')!.accessor.get(IAgentCronService).list()).toEqual([ { id: 'cron-1', cron: '0 9 * * *', prompt: 'ping', recurring: true, createdAt: 1, lastFiredAt: undefined }, ]); }); it('waits for Cron restore readiness before create returns', async () => { let releaseConfig!: () => void; const configReady = new Promise((resolve) => { releaseConfig = resolve; }); ix.stub(IConfigService, { ready: configReady, get: ((section: unknown) => section === CRON_SECTION ? { debug: false, noJitter: true, noStale: false, disabled: false, manualTick: true } : undefined) as IConfigService['get'], onDidSectionChange: (() => ({ dispose: () => {} })) as IConfigService['onDidSectionChange'], } as unknown as IConfigService); contributeCronService(); const svc = ix.get(IAgentLifecycleService); let created = false; const creation = svc.create({ agentId: 'main' }).then((agent) => { created = true; return agent; }); await vi.waitFor(() => { expect(registerAgent).toHaveBeenCalledOnce(); }); expect(created).toBe(false); releaseConfig(); await creation; expect(created).toBe(true); expect(svc.handleOf('main')!.accessor.get(IAgentCronService).isDisabled()).toBe(false); }); it('broadcastPermissionMode sets the mode on every live agent', async () => { const svc = ix.get(IAgentLifecycleService); await svc.create({ agentId: 'main' }); await svc.create({ agentId: 'child' }); svc.broadcastPermissionMode('yolo'); expect(svc.handleOf('main')!.accessor.get(IAgentStateService).get(permissionModeKey)).toBe('yolo'); expect(svc.handleOf('child')!.accessor.get(IAgentStateService).get(permissionModeKey)).toBe('yolo'); }); it('broadcastPermissionMode skips agents that have been removed', async () => { const svc = ix.get(IAgentLifecycleService); await svc.create({ agentId: 'main' }); const child = await svc.create({ agentId: 'child' }); await svc.remove(child); svc.broadcastPermissionMode('auto'); expect(svc.handleOf('main')!.accessor.get(IAgentStateService).get(permissionModeKey)).toBe('auto'); }); it('broadcastPermissionMode leaves tower-worker agents pinned to their spawned mode', async () => { const svc = ix.get(IAgentLifecycleService); await svc.create({ agentId: 'main' }); await svc.create({ agentId: 'worker-1' }); void svc.handleOf('worker-1')!.accessor.get(IEventDispatcher).dispatch( new ProfileBind({ agentId: 'worker-1', profileName: TOWER_WORKER_PROFILE, thinkingEffort: 'off', systemPrompt: '', disallowedTools: [], }), ); svc.broadcastPermissionMode('yolo'); expect(svc.handleOf('main')!.accessor.get(IAgentStateService).get(permissionModeKey)).toBe('yolo'); expect(svc.handleOf('worker-1')!.accessor.get(IAgentStateService).get(permissionModeKey)).toBe('manual'); }); it('wires MCP OAuth credentials through the session atomic document store', async () => { const svc = ix.get(IAgentLifecycleService); await svc.create({ agentId: 'main' }); const mcp = svc.handleOf('main')!.accessor.get(IAgentMcpService); const oauth = mcp.oauthService; if (oauth === undefined) throw new Error('Expected session MCP manager to provide OAuth'); const provider = oauth.getProvider('linear', 'https://linear.example.com/mcp'); await provider.ready; await provider.saveTokens({ access_token: 'session-token', token_type: 'Bearer', } satisfies OAuthTokens); const tokenEntries = [...atomicDocs.entries()].filter( ([key]) => key.startsWith('credentials/mcp/') && key.endsWith('-tokens.json'), ); expect(tokenEntries).toEqual([ [ expect.stringMatching(/^credentials\/mcp\/linear-[a-f0-9]{24}-tokens\.json$/), { access_token: 'session-token', token_type: 'Bearer', obtained_at: expect.any(Number), }, ], ]); }); it('returns an agent without waiting for the MCP handle readiness', async () => { let releaseReady!: () => void; const ready = new Promise((resolve) => { releaseReady = resolve; }); ix.stub(ISessionMcpHandle, { _serviceBrand: undefined, ready, connectionManager: new McpConnectionManager({ log: noopLog }), isBaselineServer: () => true, } satisfies ISessionMcpHandle); const svc = ix.get(IAgentLifecycleService); const handle = await svc.create({ agentId: 'main' }); expect(handle.agentId).toBe('main'); releaseReady(); }); it('exposes the in-flight handle and joins it after bootstrap', async () => { let releaseRegister!: () => void; let registerStarted!: () => void; const registerCalled = new Promise((resolve) => { registerStarted = resolve; }); registerAgent.mockImplementationOnce(() => { registerStarted(); return new Promise((resolve) => { releaseRegister = resolve; }); }); const svc = ix.get(IAgentLifecycleService); const create = svc.create({ agentId: 'main' }); const early = svc.handleOf('main'); expect(early).toBeDefined(); const joined = svc.create({ agentId: 'main' }); await registerCalled; releaseRegister(); const handle = await joined; const created = await create; expect(handle).toBe(created); expect(svc.handleOf('main')).toBe(early); }); it('ensureMainAgent returns one handle when calls start concurrently', async () => { const session: ISessionScopeHandle = { id: 'sess_test', kind: LifecycleScope.Session, accessor: ix, dispose: () => {}, }; const [first, second] = await Promise.all([ ensureMainAgent(session), ensureMainAgent(session), ]); expect(first).toBe(second); expect(registerAgent).toHaveBeenCalledTimes(1); expect(ix.get(IAgentLifecycleService).list()).toEqual([first]); }); it('drops the handle when creation bootstrap fails so the next create starts clean', async () => { registerAgent.mockRejectedValueOnce(new Error('bootstrap boom')); const svc = ix.get(IAgentLifecycleService); await expect(svc.create({ agentId: 'main' })).rejects.toThrow('bootstrap boom'); expect(svc.get('main')).toBeUndefined(); expect(svc.handleOf('main')).toBeUndefined(); const main = await svc.create({ agentId: 'main' }); expect(main.agentId).toBe('main'); }); it('fork throws when the source agent does not exist', async () => { const svc = ix.get(IAgentLifecycleService); await expect(svc.fork(stubAgentContext('missing'))).rejects.toThrow( 'Source agent "missing" does not exist', ); }); it('fork copies the bound profile snapshot without catalog resolution', async () => { const svc = ix.get(IAgentLifecycleService); const source = await svc.create({ agentId: 'main' }); svc.handleOf('main')!.accessor.get(IAgentProfileService).applyBindingSnapshot({ profileName: 'deleted-profile', thinkingLevel: 'high', systemPrompt: 'original prompt', activeToolNames: ['Read'], disallowedTools: ['Bash'], subagents: ['explore'], }); const child = await svc.fork(source, { agentId: 'forked' }); expect(svc.handleOf(child.agentId)!.accessor.get(IAgentProfileService).data()).toMatchObject({ profileName: 'deleted-profile', thinkingLevel: 'high', systemPrompt: 'original prompt', activeToolNames: ['Read'], disallowedTools: ['Bash'], subagents: ['explore'], }); }); it('fork snapshots the source runtime and remains independent', async () => { const svc = ix.get(IAgentLifecycleService); const source = await svc.create({ agentId: 'main' }); const sourceRuntime = svc.handleOf('main')!.accessor.get(IAgentRuntimeBindingService); sourceRuntime.switch('remote'); const child = await svc.fork(source, { agentId: 'forked-runtime' }); const childRuntime = svc.handleOf(child.agentId)!.accessor.get(IAgentRuntimeBindingService); expect(childRuntime.current.runtimeId).toBe('remote'); sourceRuntime.switch('local'); expect(childRuntime.current.runtimeId).toBe('remote'); childRuntime.switch('local'); expect(sourceRuntime.current.runtimeId).toBe('local'); }); it('fork seeds the child context, closing the trailing open tool exchange', async () => { const svc = ix.get(IAgentLifecycleService); const source = await svc.create({ agentId: 'main' }); const sourceHandle = svc.handleOf('main')!; const agentCall: ToolCall = { type: 'function', id: 'call_agent', name: 'Agent', arguments: '{}', }; const history: ContextMessage[] = [ { role: 'user', content: [{ type: 'text', text: 'analyze this repo' }], toolCalls: [] }, { role: 'assistant', content: [], toolCalls: [agentCall], partial: true }, ]; sourceHandle.accessor.get(IAgentContextMemoryService).append(...history); const child = await svc.fork(agentContextOf(sourceHandle), { agentId: 'forked' }); const seeded = svc.handleOf(child.agentId)!.accessor.get(IAgentContextMemoryService).get(); expect(seeded).toHaveLength(3); expect(seeded[0]).toMatchObject({ role: 'user' }); expect(seeded[1]).toMatchObject({ role: 'assistant', partial: undefined }); expect(seeded[2]).toMatchObject({ role: 'tool', toolCallId: 'call_agent', content: [{ type: 'text', text: INHERITED_IN_FLIGHT_TOOL_OUTPUT }], }); }); it('fork leaves the child context empty when the source history is empty', async () => { const svc = ix.get(IAgentLifecycleService); const source = await svc.create({ agentId: 'main' }); const child = await svc.fork(agentContextOf(svc.handleOf(source.agentId)!), { agentId: 'forked' }); expect( svc.handleOf(child.agentId)!.accessor.get(IAgentContextMemoryService).get(), ).toEqual([]); }); it('fork passes labels through to the registered agent metadata', async () => { const svc = ix.get(IAgentLifecycleService); const source = await svc.create({ agentId: 'main' }); await svc.fork(agentContextOf(svc.handleOf(source.agentId)!), { agentId: 'forked', labels: { parentAgentId: 'main' }, }); expect(registerAgent).toHaveBeenCalledWith( 'forked', expect.objectContaining({ forkedFrom: 'main', labels: { parentAgentId: 'main' } }), ); }); it('records the bound profile in the subagent metadata at registration', async () => { const svc = ix.get(IAgentLifecycleService); await svc .create({ agentId: 'child', binding: { profile: 'coder', model: 'kimi-test' }, labels: { parentAgentId: 'main' }, }) .catch(() => undefined); expect(registerAgent).toHaveBeenCalledWith( 'child', expect.objectContaining({ type: 'sub', labels: { parentAgentId: 'main', profileName: 'coder' }, }), ); }); it('fork records the inherited profile in the subagent metadata', async () => { const svc = ix.get(IAgentLifecycleService); const source = await svc.create({ agentId: 'main' }); svc.handleOf('main')!.accessor.get(IAgentProfileService).applyBindingSnapshot({ profileName: 'coder', thinkingLevel: 'off', systemPrompt: 'coder prompt', activeToolNames: ['Read'], disallowedTools: [], subagents: undefined, }); await svc.fork(agentContextOf(svc.handleOf(source.agentId)!), { agentId: 'forked', labels: { parentAgentId: 'main' }, }); expect(registerAgent).toHaveBeenCalledWith( 'forked', expect.objectContaining({ forkedFrom: 'main', labels: { parentAgentId: 'main', profileName: 'coder' }, }), ); expect(registerAgent).toHaveBeenCalledWith( 'main', expect.objectContaining({ type: 'main', labels: undefined }), ); }); it('run throws when the agent does not exist', () => { ix.set(ISessionSubagentService, new SyncDescriptor(SessionSubagentService)); const svc = ix.get(ISessionSubagentService); expect(() => svc.run( stubAgentContext('missing'), { kind: 'prompt', prompt: 'hi' }, { signal: new AbortController().signal }, ), ).toThrow('Agent "missing" does not exist'); }); it('fires onDidCreate on create and onDidClose on remove', async () => { const svc = ix.get(IAgentLifecycleService); const created: string[] = []; const closed: string[] = []; disposables.add(svc.onDidCreate((agent) => created.push(agent.agentId))); disposables.add(svc.onDidClose((agent) => closed.push(agent.agentId))); const a = await svc.create({}); expect(created).toEqual([a.agentId]); await svc.remove(a); expect(closed).toEqual([a.agentId]); }); it('registers agent metadata before firing onDidCreate', async () => { const svc = ix.get(IAgentLifecycleService); const order: string[] = []; registerAgent.mockImplementation(async () => { order.push('register'); }); disposables.add(svc.onDidCreate(() => { order.push('create'); })); disposables.add(svc.onDidCreateScope(() => { order.push('createScope'); })); await svc.create({ agentId: 'main' }); expect(order).toEqual(['register', 'create', 'createScope']); }); it('assigns a new lifecycle generation when recreating the same agent id', async () => { const svc = ix.get(IAgentLifecycleService); const first = await svc.create({ agentId: 'main' }); expect(svc.get('main')).toBe(first); await svc.remove(first); expect(svc.get('main')).toBeUndefined(); const second = await svc.create({ agentId: 'main' }); expect(second.agentId).toBe(first.agentId); expect(second.generation).toBeGreaterThan(first.generation); expect(second).not.toBe(first); expect(svc.get('main')).toBe(second); }); it('rejects a stale context after the same agent id is recreated', async () => { contributeTodoService(); const svc = ix.get(IAgentLifecycleService); const first = await svc.create({ agentId: 'agent-1' }); const firstHandle = svc.handleOf('agent-1')!; await svc.remove(first); const second = await svc.create({ agentId: 'agent-1' }); expect(svc.get('agent-1')).toBe(second); expect(() => firstHandle.accessor.get(IAgentTodoService)).toThrow('has been disposed'); expect(svc.handleOf('agent-1')!.accessor.get(IAgentTodoService).get()).toEqual([]); }); it('disposes the agent scope on remove', async () => { const order: string[] = []; contributeTodoService(); const svc = ix.get(IAgentLifecycleService); const willClose: string[] = []; disposables.add(svc.onWillClose((agent) => willClose.push(agent.agentId))); const main = await svc.create({ agentId: 'main' }); const handle = svc.handleOf('main')!; const originalDispose = handle.dispose.bind(handle); handle.dispose = () => { order.push('scope-disposed'); return originalDispose(); }; handle.accessor.get(IAgentTodoService).get(); await svc.remove(main); expect(willClose).toEqual(['main']); expect(order).toEqual(['scope-disposed']); expect(svc.handleOf('main')).toBeUndefined(); }); it('rejects a durable participant attached after restore started', async () => { const svc = ix.get(IAgentLifecycleService); await svc.create({ agentId: 'main' }); const dispatcher = svc.handleOf('main')!.accessor.get(IEventDispatcher); expect(() => dispatcher.attach({ id: 'late-runtime', events: [], undoable: false, transition: () => undefined, getState: () => ({}), commit: () => {}, }), ).toThrow(BugIndicatingError); }); it('de-dupes concurrent create calls for the same agent id', async () => { let resolveRegistration!: () => void; const registration = new Promise((resolve) => { resolveRegistration = resolve; }); registerAgent.mockReturnValue(registration); const svc = ix.get(IAgentLifecycleService); const first = svc.create({ agentId: 'main' }); const second = svc.create({ agentId: 'main' }); resolveRegistration(); const [a, b] = await Promise.all([first, second]); expect(a).toBe(b); expect(registerAgent).toHaveBeenCalledTimes(1); }); it('create returns the existing agent on a sequential duplicate id', async () => { const svc = ix.get(IAgentLifecycleService); const first = await svc.create({ agentId: 'main' }); const second = await svc.create({ agentId: 'main' }); expect(second).toBe(first); expect(registerAgent).toHaveBeenCalledTimes(1); }); it('cancelInteractionsForTurn and detachInteractionAgent stay within their session', () => { const own = interactions.enqueue({ kind: 'approval', payload: {}, tags: { agentId: 'main', sessionId: 'sess_test', turnId: 1 }, }); const other = interactions.enqueue({ kind: 'approval', payload: {}, tags: { agentId: 'main', sessionId: 'sess_other', turnId: 1 }, }); cancelInteractionsForTurn('main', 'sess_test', 1); expect(interactions.findOne({ id: own.id })).toMatchObject({ resolved: true, response: { cancelled: true, reason: 'turn_ended' }, }); expect(interactions.findOne({ id: other.id, resolved: false })).toBeDefined(); detachInteractionAgent('main', 'sess_test'); expect(interactions.findOne({ id: other.id, resolved: false })).toBeDefined(); interactions.respond(other.id, { decision: 'approved' }); interactions.purgeSession('sess_test'); interactions.purgeSession('sess_other'); }); });