Download packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts from SaylorTwift/kimi-code: direct link, hf CLI and curl.
- Browser
- Download file 59.5 kB
-
https://huggingface.co/SaylorTwift/kimi-code/resolve/main/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts
- Command line
-
hf download hf://SaylorTwift/kimi-code/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts
-
curl -L -o agentLifecycle.test.ts https://huggingface.co/SaylorTwift/kimi-code/resolve/main/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts
59.5 kB
| 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: <R>(_scope: string, _key: string, record: R) => { | |
| const persisted = record as unknown as WireRecord; | |
| records.push(persisted); | |
| appended.push(persisted); | |
| }, | |
| read: async function* <R>(): AsyncIterable<R> { | |
| for (const record of records) { | |
| yield record as R; | |
| } | |
| }, | |
| rewrite: <R>(_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<typeof vi.fn<ISessionMetadata['registerAgent']>>; | |
| let atomicDocs: Map<string, unknown>; | |
| let permissionModeSetMode: ReturnType<typeof vi.fn>; | |
| let stopAllOnExit: ReturnType<typeof vi.fn>; | |
| let suppressAllTerminalNotifications: ReturnType<typeof vi.fn>; | |
| let loopActiveTurnId: number | undefined; | |
| let loopPendingPromptIds: string[]; | |
| let loopCancel: ReturnType<typeof vi.fn<IAgentLoopService['cancel']>>; | |
| let loopSettled: ReturnType<typeof vi.fn<IAgentLoopService['settled']>>; | |
| 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<ISessionMetadata['registerAgent']>().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 <T>(scope: string, key: string): Promise<T | undefined> => | |
| atomicDocs.get(`${scope}/${key}`) as T | undefined, | |
| set: async <T>(scope: string, key: string, value: T): Promise<void> => { | |
| atomicDocs.set(`${scope}/${key}`, value); | |
| }, | |
| delete: async (scope: string, key: string): Promise<void> => { | |
| atomicDocs.delete(`${scope}/${key}`); | |
| }, | |
| list: async (scope: string, prefix = ''): Promise<readonly string[]> => | |
| [...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<IAgentLoopService['cancel']>((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<IAgentLoopService['settled']>(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<void>, | |
| } 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<void>((resolve) => { | |
| gateEntered = resolve; | |
| }); | |
| agentScope!.anchorKernelEntry(() => { | |
| gateEntered(); | |
| return new Promise<void>((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>, | |
| ): void { | |
| class DisposeBeacon { | |
| constructor( | |
| private readonly eventBus: ISessionEventBus, | |
| private readonly scope: IAgentScopeContext, | |
| ) {} | |
| dispose(): void | Promise<void> { | |
| 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( | |
| private readonly eventBus: ISessionEventBus, | |
| private readonly scope: IAgentScopeContext, | |
| instantiation: IInstantiationService, | |
| ) { | |
| (instantiation as InstantiationService).anchorKernelEntry( | |
| () => new Promise<void>((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<void>((resolve) => { | |
| markDrainStarted = resolve; | |
| }); | |
| loopSettled.mockImplementationOnce(() => { | |
| markDrainStarted(); | |
| return new Promise<void>((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<never>((_resolve, reject) => { | |
| rejectCompaction = reject; | |
| }); | |
| const aborted = new Promise<void>((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<void>((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<void>((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<void>((resolve) => { | |
| registerStarted = resolve; | |
| }); | |
| registerAgent.mockImplementationOnce(() => { | |
| registerStarted(); | |
| return new Promise<void>((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<void>((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'); | |
| }); | |
| }); | |