SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
f0634fb verified
Raw History Blame Contribute Delete
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(
@ISessionEventBus private readonly eventBus: ISessionEventBus,
@IAgentScopeContext 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(
@ISessionEventBus private readonly eventBus: ISessionEventBus,
@IAgentScopeContext private readonly scope: IAgentScopeContext,
@IInstantiationService 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');
});
});