Download packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts from SaylorTwift/kimi-code: direct link, hf CLI and curl.
- Browser
- Download file 42.4 kB
-
https://huggingface.co/SaylorTwift/kimi-code/resolve/main/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts
- Command line
-
hf download hf://SaylorTwift/kimi-code/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts
-
curl -L -o toolSelectService.test.ts https://huggingface.co/SaylorTwift/kimi-code/resolve/main/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts
42.4 kB
| import { afterEach, beforeEach, describe, expect, it } from 'vitest'; | |
| import { DisposableStore, toDisposable, type IDisposable } from '#/_base/di/lifecycle'; | |
| import { createServices, type ServiceRegistration, type TestInstantiationService } from '#/_base/di/test'; | |
| import { OrderedHookSlot } from '#/hooks'; | |
| import { IEventBus } from '#/app/event/eventBus'; | |
| import type { Event2, Event2Class } from '#/app/event/event2'; | |
| import { IFlagService } from '#/app/flag/flag'; | |
| import type { ModelCapability } from '#/llm-adapter/contract/capability'; | |
| import type { ToolCall } from '#human/llm/message'; | |
| import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; | |
| import { ContextSpliced } from '#/agent/contextMemory/contextEvents'; | |
| import type { UndoCut } from '#/agent/contextMemory/contextOps'; | |
| import type { ContextMessage } from '#/agent/contextMemory/types'; | |
| import type { LoopRecordedEvent } from '#/agent/contextMemory/loopEventFold'; | |
| import { IAgentReminderService } from '#/features/reminder/reminderService'; | |
| import { createReminderHarness } from '../../features/reminder/stubs'; | |
| import { CompactionCompleted } from '#/agent/fullCompaction/compactionOps'; | |
| import { | |
| IAgentLoopService, | |
| type AfterStepContext, | |
| type BeforeStepContext, | |
| type LoopNotifyHandle, | |
| type LoopSnapshot, | |
| type PromptSubmitContext, | |
| type Turn, | |
| } from '#/agent/loop/loop'; | |
| import { TurnStarted } from '#/agent/loop/turnEvents'; | |
| import { IAgentProfileService } from '#/agent/profile/profile'; | |
| import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; | |
| import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; | |
| import type { | |
| ExecutableTool, | |
| ToolDisclosure, | |
| ToolExecution, | |
| } from '#/tool/toolContract'; | |
| import { IAgentToolExecutorService, type ToolExecutionResult } from '#/agent/toolExecutor/toolExecutor'; | |
| import { AgentToolExecutorService } from '#/agent/toolExecutor/toolExecutorService'; | |
| import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; | |
| import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService'; | |
| import { DYNAMIC_TOOL_SCHEMA_VARIANT, LOADABLE_TOOLS_VARIANT } from '#/agent/toolSelect/dynamicTools'; | |
| import { TOOL_SELECT_FLAG_ID } from '#/agent/toolSelect/flag'; | |
| import { IAgentToolSelectService, SELECT_TOOLS_TOOL_NAME } from '#/agent/toolSelect/toolSelect'; | |
| import { IAgentToolSelectAnnouncementsService } from '#/agent/toolSelect/toolSelectAnnouncements'; | |
| import { AgentToolSelectAnnouncementsService } from '#/agent/toolSelect/toolSelectAnnouncementsService'; | |
| import { IAgentToolSelectSchemasService } from '#/agent/toolSelect/toolSelectSchemas'; | |
| import { AgentToolSelectSchemasService } from '#/agent/toolSelect/toolSelectSchemasService'; | |
| import { AgentToolSelectService } from '#/agent/toolSelect/toolSelectService'; | |
| import { SelectToolsTool } from '#/agent/tools/select-tools/selectToolsTool'; | |
| import { ITelemetryService } from '#/app/telemetry/telemetry'; | |
| import { IWireService } from '#/wire/wire'; | |
| import { IEventDispatcher } from '#/state/eventDispatcher'; | |
| import { registerLogServices } from '../../_base/log/stubs'; | |
| import { recordingTelemetry } from '../../app/telemetry/stubs'; | |
| import { registerStateServices } from '../../state/stubs'; | |
| import { stubToolExecutor, stubWire } from '../loop/stubs'; | |
| import { registerToolResultTruncationServices } from '../toolResultTruncation/stubs'; | |
| const MCP_ALPHA = 'mcp__srv__alpha'; | |
| const MCP_BETA = 'mcp__srv__beta'; | |
| const MCP_GAMMA = 'mcp__srv__gamma'; | |
| const MCP_GONE = 'mcp__srv__gone'; | |
| const USER_DEFERRED = 'dashboard_create'; | |
| const USER_INLINE = 'echo_inline'; | |
| const REQUIRED_PAYLOAD_PARAMETERS = { | |
| type: 'object', | |
| required: ['payload'], | |
| properties: { payload: { type: 'string' } }, | |
| additionalProperties: false, | |
| }; | |
| let disposables: DisposableStore; | |
| let capabilities: ModelCapability; | |
| let flagEnabled: boolean; | |
| let activeToolNames: ReadonlySet<string> | undefined; | |
| let disclosureToolActive: boolean; | |
| beforeEach(() => { | |
| disposables = new DisposableStore(); | |
| capabilities = makeCapabilities({ tool_use: true, dynamically_loaded_tools: true }); | |
| flagEnabled = false; | |
| activeToolNames = undefined; | |
| disclosureToolActive = true; | |
| }); | |
| afterEach(() => disposables.dispose()); | |
| function makeCapabilities(overrides: { | |
| readonly tool_use?: boolean; | |
| readonly dynamically_loaded_tools?: boolean; | |
| } = {}): ModelCapability { | |
| return { | |
| image_in: false, | |
| video_in: false, | |
| audio_in: false, | |
| thinking: false, | |
| tool_use: overrides.tool_use ?? false, | |
| max_context_tokens: 128_000, | |
| dynamically_loaded_tools: overrides.dynamically_loaded_tools, | |
| }; | |
| } | |
| function toolCall(id: string, name: string, args: unknown = {}): ToolCall { | |
| return { type: 'function', id, name, arguments: JSON.stringify(args) }; | |
| } | |
| function userMessage(text: string): ContextMessage { | |
| return { role: 'user', content: [{ type: 'text', text }], toolCalls: [] }; | |
| } | |
| function schemaMessage(...names: string[]): ContextMessage { | |
| return { | |
| role: 'system', | |
| content: [], | |
| toolCalls: [], | |
| tools: names.map((name) => ({ name, description: `${name} desc`, parameters: {} })), | |
| origin: { kind: 'injection', variant: DYNAMIC_TOOL_SCHEMA_VARIANT }, | |
| }; | |
| } | |
| class StubMcpTool implements ExecutableTool<Record<string, unknown>> { | |
| readonly description: string; | |
| calls = 0; | |
| readonly parameters: Record<string, unknown>; | |
| constructor( | |
| readonly name: string, | |
| private readonly output: string = 'mcp ok', | |
| parameters?: Record<string, unknown>, | |
| ) { | |
| this.description = `${name} desc`; | |
| this.parameters = parameters ?? { | |
| type: 'object', | |
| additionalProperties: true, | |
| }; | |
| } | |
| resolveExecution(): ToolExecution { | |
| return { | |
| approvalRule: this.name, | |
| execute: async () => { | |
| this.calls += 1; | |
| return { output: this.output }; | |
| }, | |
| }; | |
| } | |
| } | |
| class EchoTool implements ExecutableTool<Record<string, unknown>> { | |
| readonly description = 'Echo input text.'; | |
| readonly parameters: Record<string, unknown> = { type: 'object', additionalProperties: true }; | |
| calls = 0; | |
| constructor(readonly name = 'Echo') {} | |
| resolveExecution(): ToolExecution { | |
| return { | |
| approvalRule: this.name, | |
| execute: async () => { | |
| this.calls += 1; | |
| return { output: 'echo ok' }; | |
| }, | |
| }; | |
| } | |
| } | |
| class RecordingEventBus implements IEventBus { | |
| readonly _serviceBrand = undefined; | |
| private readonly typedHandlers = new Map<string, Array<(event: Event2) => void>>(); | |
| private readonly allHandlers: Array<(event: Event2) => void> = []; | |
| readonly published: Event2[] = []; | |
| publish(event: Event2): void { | |
| this.published.push(event); | |
| for (const handler of this.allHandlers) handler(event); | |
| for (const handler of this.typedHandlers.get(event.type) ?? []) handler(event); | |
| } | |
| subscribe( | |
| typeOrHandler: string | Event2Class | ((event: Event2) => void), | |
| maybeHandler?: (event: Event2) => void, | |
| ) { | |
| if (typeof typeOrHandler === 'function' && !('type' in typeOrHandler)) { | |
| const handler = typeOrHandler as (event: Event2) => void; | |
| this.allHandlers.push(handler); | |
| return toDisposable(() => { | |
| const index = this.allHandlers.indexOf(handler); | |
| if (index >= 0) this.allHandlers.splice(index, 1); | |
| }); | |
| } | |
| const type = typeof typeOrHandler === 'string' ? typeOrHandler : (typeOrHandler as Event2Class).type; | |
| const list = this.typedHandlers.get(type) ?? []; | |
| const handler = maybeHandler!; | |
| list.push(handler); | |
| this.typedHandlers.set(type, list); | |
| return toDisposable(() => { | |
| const index = list.indexOf(handler); | |
| if (index >= 0) list.splice(index, 1); | |
| }); | |
| } | |
| } | |
| class FakeLoopService implements IAgentLoopService { | |
| readonly _serviceBrand = undefined; | |
| readonly hooks: IAgentLoopService['hooks'] = { | |
| onWillBeginStep: new OrderedHookSlot<BeforeStepContext>(), | |
| onDidFinishStep: new OrderedHookSlot<AfterStepContext>(), | |
| onBeforeSubmitPrompt: new OrderedHookSlot<PromptSubmitContext>(), | |
| }; | |
| submit(): never { | |
| throw new Error('unused in this suite'); | |
| } | |
| steer(): never { | |
| throw new Error('unused in this suite'); | |
| } | |
| cancel(): never { | |
| throw new Error('unused in this suite'); | |
| } | |
| snapshot(): LoopSnapshot { | |
| return { | |
| state: 'idle', | |
| activeTurnId: undefined, | |
| activePromptId: undefined, | |
| queue: [], | |
| notificationCount: 0, | |
| paused: false, | |
| hasPendingRequests: false, | |
| turn: undefined, | |
| activeTraceId: undefined, | |
| }; | |
| } | |
| promptHandle(): never { | |
| throw new Error('unused in this suite'); | |
| } | |
| notify(): LoopNotifyHandle { | |
| throw new Error('unused in this suite'); | |
| } | |
| tryAcquireQuiescence(): IDisposable | undefined { | |
| return toDisposable(() => {}); | |
| } | |
| buildAttachBundle(): never { | |
| throw new Error('unused in this suite'); | |
| } | |
| attachEngine(): never { | |
| throw new Error('unused in this suite'); | |
| } | |
| async resetMachineEngine(): Promise<void> {} | |
| async settled(): Promise<void> {} | |
| registerLoopErrorHandler(): IDisposable { | |
| throw new Error('unused in this suite'); | |
| } | |
| } | |
| class FakeContextMemory implements IAgentContextMemoryService { | |
| readonly _serviceBrand = undefined; | |
| readonly history: ContextMessage[] = []; | |
| readonly appended: ContextMessage[] = []; | |
| get(): readonly ContextMessage[] { | |
| return this.history; | |
| } | |
| append(...messages: readonly ContextMessage[]): void { | |
| this.appended.push(...messages); | |
| } | |
| appendLoopEvent(_event: LoopRecordedEvent): void { | |
| throw new Error('unused in this suite'); | |
| } | |
| publishTrailingRemoval(): boolean { | |
| return false; | |
| } | |
| clear(): void { | |
| this.history.length = 0; | |
| this.appended.length = 0; | |
| } | |
| undo(): UndoCut { | |
| throw new Error('unused in this suite'); | |
| } | |
| applyCompaction(): never { | |
| throw new Error('unused in this suite'); | |
| } | |
| landAppended(): void { | |
| this.history.push(...this.appended); | |
| this.appended.length = 0; | |
| } | |
| landAnnouncement(content: string): void { | |
| this.history.push({ | |
| role: 'user', | |
| content: [{ type: 'text', text: `<system-reminder>\n${content.trim()}\n</system-reminder>` }], | |
| toolCalls: [], | |
| origin: { kind: 'system_trigger', name: LOADABLE_TOOLS_VARIANT }, | |
| }); | |
| } | |
| } | |
| interface Harness { | |
| readonly ix: TestInstantiationService; | |
| readonly sut: IAgentToolSelectService; | |
| readonly registry: IAgentToolRegistryService; | |
| readonly contextMemory: FakeContextMemory; | |
| readonly loop: FakeLoopService; | |
| readonly eventBus: RecordingEventBus; | |
| } | |
| function registerSharedServices( | |
| reg: ServiceRegistration, | |
| contextMemory: FakeContextMemory, | |
| loop: FakeLoopService, | |
| eventBus: RecordingEventBus, | |
| ): void { | |
| registerStateServices(reg); | |
| reg.defineInstance(IEventBus, eventBus); | |
| reg.defineInstance(IAgentLoopService, loop); | |
| reg.defineInstance(IAgentContextMemoryService, contextMemory); | |
| reg.defineInstance( | |
| IAgentScopeContext, | |
| makeAgentScopeContext({ agentId: 'main', agentScope: 'agents/main', generation: 1 }), | |
| ); | |
| reg.definePartialInstance(IAgentProfileService, { | |
| getModelCapabilities: () => capabilities, | |
| }); | |
| reg.definePartialInstance(IAgentToolPolicyService, { | |
| isToolActive: (name: string) => activeToolNames === undefined || activeToolNames.has(name), | |
| isToolActiveForDisclosure: () => disclosureToolActive, | |
| }); | |
| reg.definePartialInstance(IFlagService, { | |
| enabled: (id: string) => (id === TOOL_SELECT_FLAG_ID ? flagEnabled : false), | |
| }); | |
| reg.defineInstance(IWireService, stubWire()); | |
| reg.defineInstance(IEventDispatcher, { | |
| _serviceBrand: undefined, | |
| hooks: { onDidRestore: new OrderedHookSlot() }, | |
| dispatch: async (event: Event2) => { | |
| eventBus.publish(event); | |
| }, | |
| } as unknown as IEventDispatcher); | |
| reg.defineInstance( | |
| IAgentReminderService, | |
| createReminderHarness(loop, contextMemory, eventBus), | |
| ); | |
| reg.define(IAgentToolRegistryService, AgentToolRegistryService); | |
| reg.define(IAgentToolSelectService, AgentToolSelectService); | |
| reg.define(IAgentToolSelectAnnouncementsService, AgentToolSelectAnnouncementsService); | |
| reg.define(IAgentToolSelectSchemasService, AgentToolSelectSchemasService); | |
| registerLogServices(reg); | |
| } | |
| function mountAnnouncements(ix: TestInstantiationService): void { | |
| ix.get(IAgentToolSelectAnnouncementsService); | |
| ix.get(IAgentToolSelectSchemasService); | |
| } | |
| function createHarness(): Harness { | |
| const contextMemory = new FakeContextMemory(); | |
| const loop = new FakeLoopService(); | |
| const eventBus = new RecordingEventBus(); | |
| const ix = createServices(disposables, { | |
| additionalServices: (reg) => { | |
| registerSharedServices(reg, contextMemory, loop, eventBus); | |
| reg.defineInstance(IAgentToolExecutorService, stubToolExecutor()); | |
| }, | |
| strict: true, | |
| }); | |
| mountAnnouncements(ix); | |
| return { | |
| ix, | |
| sut: ix.get(IAgentToolSelectService), | |
| registry: ix.get(IAgentToolRegistryService), | |
| contextMemory, | |
| loop, | |
| eventBus, | |
| }; | |
| } | |
| interface ExecutorHarness extends Harness { | |
| readonly executor: IAgentToolExecutorService; | |
| } | |
| function createExecutorHarness(): ExecutorHarness { | |
| const contextMemory = new FakeContextMemory(); | |
| const loop = new FakeLoopService(); | |
| const eventBus = new RecordingEventBus(); | |
| const ix = createServices(disposables, { | |
| additionalServices: (reg) => { | |
| registerSharedServices(reg, contextMemory, loop, eventBus); | |
| reg.defineInstance(ITelemetryService, recordingTelemetry([])); | |
| reg.defineInstance(IAgentScopeContext, makeAgentScopeContext({ agentId: 'main', agentScope: '' })); | |
| reg.define(IAgentToolExecutorService, AgentToolExecutorService); | |
| registerToolResultTruncationServices(reg); | |
| }, | |
| strict: true, | |
| }); | |
| mountAnnouncements(ix); | |
| return { | |
| ix, | |
| sut: ix.get(IAgentToolSelectService), | |
| registry: ix.get(IAgentToolRegistryService), | |
| executor: ix.get(IAgentToolExecutorService), | |
| contextMemory, | |
| loop, | |
| eventBus, | |
| }; | |
| } | |
| function registerMcp( | |
| h: Harness, | |
| tool: StubMcpTool, | |
| disclosure: ToolDisclosure = 'deferred', | |
| ): IDisposable { | |
| const registration = h.registry.register(tool, { source: 'mcp', disclosure }); | |
| disposables.add(registration); | |
| return registration; | |
| } | |
| function registerBuiltin(h: Harness, tool: EchoTool): void { | |
| disposables.add(h.registry.register(tool, { source: 'builtin' })); | |
| } | |
| function registerUser( | |
| h: Harness, | |
| tool: EchoTool, | |
| disclosure?: ToolDisclosure, | |
| ): IDisposable { | |
| const registration = h.registry.register(tool, { source: 'user', disclosure }); | |
| disposables.add(registration); | |
| return registration; | |
| } | |
| function announcementText(message: ContextMessage): string { | |
| return message.content.map((part) => (part.type === 'text' ? part.text : '')).join(''); | |
| } | |
| function isNewAnnouncement(message: ContextMessage): boolean { | |
| return message.origin?.kind === 'injection' && message.origin.variant === LOADABLE_TOOLS_VARIANT; | |
| } | |
| async function announce(h: Harness, step = 1): Promise<string | undefined> { | |
| const before = h.contextMemory.appended.length; | |
| await h.loop.hooks.onWillBeginStep.run({ | |
| turnId: 1, | |
| step, | |
| firstStepOfTurn: step === 1, | |
| signal: new AbortController().signal, | |
| }); | |
| const announcement = h.contextMemory.appended.slice(before).find(isNewAnnouncement); | |
| h.contextMemory.landAppended(); | |
| if (announcement === undefined) return undefined; | |
| return announcementText(announcement); | |
| } | |
| async function announceAfterCompaction(h: Harness): Promise<string | undefined> { | |
| h.eventBus.publish( | |
| new ContextSpliced({ agentId: 'main', | |
| start: 0, | |
| deleteCount: 1, | |
| messages: [ | |
| { | |
| role: 'user', | |
| content: [{ type: 'text', text: 'Compacted summary.' }], | |
| toolCalls: [], | |
| origin: { kind: 'compaction_summary' }, | |
| }, | |
| ], | |
| }), | |
| ); | |
| return announce(h, 99); | |
| } | |
| async function declareSchemas(h: Harness, step = 1): Promise<ContextMessage | undefined> { | |
| const before = h.contextMemory.appended.length; | |
| await h.loop.hooks.onWillBeginStep.run({ | |
| turnId: 1, | |
| step, | |
| firstStepOfTurn: step === 1, | |
| signal: new AbortController().signal, | |
| }); | |
| const fresh = h.contextMemory.appended.splice(before); | |
| const declared = fresh.find( | |
| (message) => | |
| message.origin?.kind === 'injection' && | |
| message.origin.variant === DYNAMIC_TOOL_SCHEMA_VARIANT, | |
| ); | |
| if (declared !== undefined) h.contextMemory.history.push(declared); | |
| return declared; | |
| } | |
| async function execute( | |
| h: ExecutorHarness, | |
| call: ToolCall, | |
| ): Promise<readonly ToolExecutionResult[]> { | |
| const results: ToolExecutionResult[] = []; | |
| for await (const result of h.executor.execute([call], { | |
| signal: new AbortController().signal, | |
| turnId: 1, | |
| })) { | |
| results.push(result); | |
| } | |
| return results; | |
| } | |
| describe('AgentToolSelectService gate', () => { | |
| it('opens only when dynamically_loaded_tools capability, tool_use capability and flag are all on', () => { | |
| flagEnabled = true; | |
| const { sut } = createHarness(); | |
| expect(sut.enabled()).toBe(true); | |
| }); | |
| it('stays closed without the dynamically_loaded_tools capability', () => { | |
| flagEnabled = true; | |
| capabilities = makeCapabilities({ tool_use: true, dynamically_loaded_tools: false }); | |
| const { sut } = createHarness(); | |
| expect(sut.enabled()).toBe(false); | |
| }); | |
| it('stays closed without tool_use capability', () => { | |
| flagEnabled = true; | |
| capabilities = makeCapabilities({ tool_use: false, dynamically_loaded_tools: true }); | |
| const { sut } = createHarness(); | |
| expect(sut.enabled()).toBe(false); | |
| }); | |
| it('stays closed without the flag', () => { | |
| flagEnabled = false; | |
| const { sut } = createHarness(); | |
| expect(sut.enabled()).toBe(false); | |
| }); | |
| }); | |
| describe('AgentToolSelectService S0 baseline (gate closed)', () => { | |
| it('shapeTools returns the identical array when dynamically_loaded_tools is absent', () => { | |
| const h = createHarness(); | |
| registerBuiltin(h, new EchoTool()); | |
| registerMcp(h, new StubMcpTool(MCP_ALPHA)); | |
| const entries = h.registry.list(); | |
| expect(h.sut.shapeTools(entries)).toBe(entries); | |
| }); | |
| it('shapeHistory returns the identical array when there is nothing to strip', () => { | |
| const h = createHarness(); | |
| const messages: readonly ContextMessage[] = [userMessage('a'), userMessage('b')]; | |
| expect(h.sut.shapeHistory(messages)).toBe(messages); | |
| }); | |
| it('shapeTools filters select_tools itself out of the view', () => { | |
| const h = createHarness(); | |
| registerBuiltin(h, new EchoTool()); | |
| const selectTools = h.ix.createInstance(SelectToolsTool); | |
| disposables.add(h.registry.register(selectTools, { source: 'builtin' })); | |
| const shaped = h.sut.shapeTools(h.registry.list()); | |
| expect(shaped.map((entry) => entry.name)).toEqual(['Echo']); | |
| expect(shaped.every((entry) => entry.deferred === undefined)).toBe(true); | |
| }); | |
| it('keeps deferred user tools inline while the disclosure gate is closed', () => { | |
| const h = createHarness(); | |
| registerUser(h, new EchoTool(USER_DEFERRED), 'deferred'); | |
| const shaped = h.sut.shapeTools(h.registry.list()); | |
| expect(shaped.map((entry) => entry.name)).toContain(USER_DEFERRED); | |
| expect(shaped.find((entry) => entry.name === USER_DEFERRED)?.deferred).toBeUndefined(); | |
| }); | |
| it('shapeTools applies profile filtering and removes select_tools while the gate is closed', () => { | |
| const h = createHarness(); | |
| registerBuiltin(h, new EchoTool()); | |
| registerMcp(h, new StubMcpTool(MCP_ALPHA)); | |
| const selectTools = h.ix.createInstance(SelectToolsTool); | |
| disposables.add(h.registry.register(selectTools, { source: 'builtin' })); | |
| activeToolNames = new Set(['Echo']); | |
| const shaped = h.sut.shapeTools(h.registry.list()); | |
| expect(shaped.map((entry) => entry.name)).toEqual(['Echo']); | |
| }); | |
| it('select_tools execution self-guards while the gate is closed', async () => { | |
| const h = createHarness(); | |
| const selectTools = h.ix.createInstance(SelectToolsTool); | |
| const execution = selectTools.resolveExecution({ names: [MCP_ALPHA] }); | |
| expect(execution.isError).toBeUndefined(); | |
| if (execution.isError === true) throw new Error('expected a runnable execution'); | |
| const result = await execution.execute({ | |
| turnId: 1, | |
| toolCallId: 'call-1', | |
| signal: new AbortController().signal, | |
| }); | |
| expect(result).toEqual({ | |
| output: 'select_tools is not available for the current model.', | |
| isError: true, | |
| }); | |
| }); | |
| it('shapeHistory strips dynamic-tool protocol context without touching the canonical history', () => { | |
| const h = createHarness(); | |
| h.contextMemory.landAnnouncement('<tools_added>\nt\n</tools_added>'); | |
| h.contextMemory.history.push(schemaMessage('t'), userMessage('keep')); | |
| const shaped = h.sut.shapeHistory(h.contextMemory.get()); | |
| expect(shaped.map((message) => message.role)).toEqual(['user']); | |
| expect(h.contextMemory.get()).toHaveLength(3); | |
| }); | |
| it('missing-tool wording falls back to the default message', async () => { | |
| const h = createExecutorHarness(); | |
| const results = await execute(h, toolCall('call-1', MCP_GONE)); | |
| expect(results).toHaveLength(1); | |
| expect(results[0]!.result.output).toBe(`Tool "${MCP_GONE}" not found`); | |
| expect(results[0]!.result.isError).toBe(true); | |
| }); | |
| }); | |
| describe('AgentToolSelectService view shaping (gate open)', () => { | |
| beforeEach(() => { | |
| flagEnabled = true; | |
| }); | |
| it('hides unloaded MCP tools, marks loaded MCP tools deferred, keeps builtins and select_tools', () => { | |
| const h = createHarness(); | |
| registerBuiltin(h, new EchoTool()); | |
| registerMcp(h, new StubMcpTool(MCP_ALPHA)); | |
| registerMcp(h, new StubMcpTool(MCP_BETA)); | |
| const selectTools = h.ix.createInstance(SelectToolsTool); | |
| disposables.add(h.registry.register(selectTools, { source: 'builtin' })); | |
| h.contextMemory.history.push(schemaMessage(MCP_ALPHA)); | |
| const shaped = h.sut.shapeTools(h.registry.list()); | |
| expect(shaped.map((entry) => entry.name)).toEqual(['Echo', MCP_ALPHA, SELECT_TOOLS_TOOL_NAME]); | |
| const byName = new Map(shaped.map((entry) => [entry.name, entry])); | |
| expect(byName.get(MCP_ALPHA)?.deferred).toBe(true); | |
| expect(byName.get('Echo')?.deferred).toBeUndefined(); | |
| expect(byName.get(SELECT_TOOLS_TOOL_NAME)?.deferred).toBeUndefined(); | |
| }); | |
| it('keeps inline-disclosed MCP tools visible and out of the loadable manifest', () => { | |
| const h = createHarness(); | |
| registerMcp(h, new StubMcpTool(MCP_ALPHA), 'inline'); | |
| registerMcp(h, new StubMcpTool(MCP_BETA)); | |
| const shaped = h.sut.shapeTools(h.registry.list()); | |
| const byName = new Map(shaped.map((entry) => [entry.name, entry])); | |
| expect(byName.get(MCP_ALPHA)?.deferred).toBeUndefined(); | |
| expect(byName.has(MCP_BETA)).toBe(false); | |
| const announcement = h.sut.loadableToolsAnnouncement(); | |
| expect(announcement).toContain(MCP_BETA); | |
| expect(announcement).not.toContain(MCP_ALPHA); | |
| }); | |
| it('defers only opted-in user tools and restores them after selection', () => { | |
| const h = createHarness(); | |
| registerUser(h, new EchoTool(USER_DEFERRED), 'deferred'); | |
| registerUser(h, new EchoTool(USER_INLINE)); | |
| const beforeLoad = h.sut.shapeTools(h.registry.list()); | |
| expect(beforeLoad.map((entry) => entry.name)).toContain(USER_INLINE); | |
| expect(beforeLoad.map((entry) => entry.name)).not.toContain(USER_DEFERRED); | |
| h.contextMemory.history.push(schemaMessage(USER_DEFERRED)); | |
| const afterLoad = h.sut.shapeTools(h.registry.list()); | |
| expect(afterLoad.map((entry) => entry.name)).toContain(USER_DEFERRED); | |
| expect(afterLoad.find((entry) => entry.name === USER_DEFERRED)?.deferred).toBe(true); | |
| expect(afterLoad.find((entry) => entry.name === USER_INLINE)?.deferred).toBeUndefined(); | |
| }); | |
| it('keeps select_tools visible when the profile omits it while hiding inactive tools', () => { | |
| const h = createHarness(); | |
| registerBuiltin(h, new EchoTool()); | |
| registerMcp(h, new StubMcpTool(MCP_ALPHA)); | |
| const selectTools = h.ix.createInstance(SelectToolsTool); | |
| disposables.add(h.registry.register(selectTools, { source: 'builtin' })); | |
| h.contextMemory.history.push(schemaMessage(MCP_ALPHA)); | |
| activeToolNames = new Set([MCP_ALPHA]); | |
| const shaped = h.sut.shapeTools(h.registry.list()); | |
| expect(shaped.map((entry) => entry.name)).toEqual([ | |
| MCP_ALPHA, | |
| SELECT_TOOLS_TOOL_NAME, | |
| ]); | |
| }); | |
| it('hides select_tools when an explicit policy disables disclosure', () => { | |
| const h = createHarness(); | |
| registerMcp(h, new StubMcpTool(MCP_ALPHA)); | |
| const selectTools = h.ix.createInstance(SelectToolsTool); | |
| disposables.add(h.registry.register(selectTools, { source: 'builtin' })); | |
| activeToolNames = new Set([MCP_ALPHA]); | |
| disclosureToolActive = false; | |
| const shaped = h.sut.shapeTools(h.registry.list()); | |
| expect(shaped.map((entry) => entry.name)).not.toContain(SELECT_TOOLS_TOOL_NAME); | |
| }); | |
| it('shapeHistory returns the identical array', () => { | |
| const h = createHarness(); | |
| h.contextMemory.history.push(userMessage('a'), schemaMessage(MCP_ALPHA)); | |
| const messages = h.contextMemory.get(); | |
| expect(h.sut.shapeHistory(messages)).toBe(messages); | |
| }); | |
| it('shapeHistory removes loaded schemas when the profile disables them', () => { | |
| const h = createHarness(); | |
| h.contextMemory.history.push(schemaMessage(MCP_ALPHA, MCP_BETA), userMessage('keep')); | |
| activeToolNames = new Set([MCP_BETA]); | |
| const shaped = h.sut.shapeHistory(h.contextMemory.get()); | |
| expect(shaped).toHaveLength(2); | |
| expect(shaped[0]!.tools?.map((tool) => tool.name)).toEqual([MCP_BETA]); | |
| expect(h.contextMemory.get()[0]!.tools?.map((tool) => tool.name)).toEqual([ | |
| MCP_ALPHA, | |
| MCP_BETA, | |
| ]); | |
| }); | |
| it('shapeHistory removes a deferred user schema after unregister', () => { | |
| const h = createHarness(); | |
| const registration = registerUser(h, new EchoTool(USER_DEFERRED), 'deferred'); | |
| h.contextMemory.history.push(schemaMessage(USER_DEFERRED)); | |
| registration.dispose(); | |
| expect(h.sut.shapeHistory(h.contextMemory.get())).toEqual([]); | |
| expect(h.sut.load([USER_DEFERRED])).toEqual({ | |
| toLoad: [], | |
| alreadyAvailable: [], | |
| unknown: [USER_DEFERRED], | |
| }); | |
| expect(h.contextMemory.get()[0]?.tools?.map((tool) => tool.name)).toEqual([ | |
| USER_DEFERRED, | |
| ]); | |
| }); | |
| it('shapeHistory removes a deferred schema after re-registering the user tool inline', () => { | |
| const h = createHarness(); | |
| registerUser(h, new EchoTool(USER_DEFERRED), 'deferred'); | |
| h.contextMemory.history.push(schemaMessage(USER_DEFERRED)); | |
| registerUser(h, new EchoTool(USER_DEFERRED)); | |
| expect(h.sut.shapeHistory(h.contextMemory.get())).toEqual([]); | |
| const inline = h.sut | |
| .shapeTools(h.registry.list()) | |
| .find((entry) => entry.name === USER_DEFERRED); | |
| expect(inline).toEqual( | |
| expect.objectContaining({ name: USER_DEFERRED, disclosure: undefined }), | |
| ); | |
| expect(inline?.deferred).toBeUndefined(); | |
| expect(h.sut.load([USER_DEFERRED])).toEqual({ | |
| toLoad: [], | |
| alreadyAvailable: [], | |
| unknown: [USER_DEFERRED], | |
| }); | |
| }); | |
| }); | |
| describe('AgentToolSelectService.load', () => { | |
| beforeEach(() => { | |
| flagEnabled = true; | |
| }); | |
| it('settles per name: toLoad, alreadyAvailable, unknown', async () => { | |
| const h = createHarness(); | |
| registerMcp(h, new StubMcpTool(MCP_ALPHA)); | |
| registerMcp(h, new StubMcpTool(MCP_BETA)); | |
| h.contextMemory.history.push(schemaMessage(MCP_ALPHA)); | |
| const result = h.sut.load([MCP_BETA, MCP_ALPHA, MCP_GONE]); | |
| expect(result.toLoad).toEqual([MCP_BETA]); | |
| expect(result.alreadyAvailable).toEqual([MCP_ALPHA]); | |
| expect(result.unknown).toEqual([MCP_GONE]); | |
| expect(h.contextMemory.appended).toHaveLength(0); | |
| const declared = await declareSchemas(h); | |
| expect(declared?.role).toBe('system'); | |
| expect(declared?.tools?.map((tool) => tool.name)).toEqual([MCP_BETA]); | |
| expect(declared?.origin).toEqual({ kind: 'injection', variant: DYNAMIC_TOOL_SCHEMA_VARIANT }); | |
| }); | |
| it('loads the schema of an opted-in user tool', async () => { | |
| const h = createHarness(); | |
| registerUser(h, new EchoTool(USER_DEFERRED), 'deferred'); | |
| expect(h.sut.load([USER_DEFERRED])).toEqual({ | |
| toLoad: [USER_DEFERRED], | |
| alreadyAvailable: [], | |
| unknown: [], | |
| }); | |
| const declared = await declareSchemas(h); | |
| expect(declared?.tools?.map((tool) => tool.name)).toEqual([USER_DEFERRED]); | |
| }); | |
| it('sorts the declared schemas by name', async () => { | |
| const h = createHarness(); | |
| registerMcp(h, new StubMcpTool(MCP_BETA)); | |
| registerMcp(h, new StubMcpTool(MCP_ALPHA)); | |
| h.sut.load([MCP_BETA, MCP_ALPHA]); | |
| const declared = await declareSchemas(h); | |
| expect(declared?.tools?.map((tool) => tool.name)).toEqual([MCP_ALPHA, MCP_BETA]); | |
| }); | |
| it('declares a selected schema after its MCP tool reconnects before a later boundary', async () => { | |
| const h = createHarness(); | |
| const registration = registerMcp(h, new StubMcpTool(MCP_ALPHA)); | |
| expect(h.sut.load([MCP_ALPHA]).toLoad).toEqual([MCP_ALPHA]); | |
| registration.dispose(); | |
| expect(await declareSchemas(h)).toBeUndefined(); | |
| registerMcp(h, new StubMcpTool(MCP_ALPHA)); | |
| const declared = await declareSchemas(h, 2); | |
| expect(declared?.tools?.map((tool) => tool.name)).toEqual([MCP_ALPHA]); | |
| }); | |
| it('reports names filtered out by the profile as unknown', async () => { | |
| const h = createHarness(); | |
| registerMcp(h, new StubMcpTool(MCP_ALPHA)); | |
| registerMcp(h, new StubMcpTool(MCP_BETA)); | |
| activeToolNames = new Set([MCP_ALPHA]); | |
| const result = h.sut.load([MCP_ALPHA, MCP_BETA]); | |
| expect(result.toLoad).toEqual([MCP_ALPHA]); | |
| expect(result.unknown).toEqual([MCP_BETA]); | |
| const declared = await declareSchemas(h); | |
| expect(declared?.tools?.map((tool) => tool.name)).toEqual([MCP_ALPHA]); | |
| }); | |
| it('pending ledger leads the history inside the defer window', async () => { | |
| const h = createHarness(); | |
| registerMcp(h, new StubMcpTool(MCP_ALPHA)); | |
| h.sut.load([MCP_ALPHA]); | |
| expect(h.contextMemory.get().some((message) => message.tools !== undefined)).toBe(false); | |
| const reselect = h.sut.load([MCP_ALPHA]); | |
| expect(reselect.alreadyAvailable).toEqual([MCP_ALPHA]); | |
| expect(reselect.toLoad).toEqual([]); | |
| await declareSchemas(h); | |
| const afterLanding = h.sut.load([MCP_ALPHA]); | |
| expect(afterLanding.alreadyAvailable).toEqual([MCP_ALPHA]); | |
| }); | |
| it('clears the pending ledger after compaction completes', () => { | |
| const h = createHarness(); | |
| registerMcp(h, new StubMcpTool(MCP_ALPHA)); | |
| h.sut.load([MCP_ALPHA]); | |
| h.eventBus.publish( | |
| new CompactionCompleted({ agentId: 'main', | |
| result: { summary: '', compactedCount: 0, tokensBefore: 0, tokensAfter: 0 }, | |
| }), | |
| ); | |
| expect(h.sut.load([MCP_ALPHA]).toLoad).toEqual([MCP_ALPHA]); | |
| }); | |
| it('clears the pending ledger after a full-prefix context splice', () => { | |
| const h = createHarness(); | |
| registerMcp(h, new StubMcpTool(MCP_ALPHA)); | |
| h.sut.load([MCP_ALPHA]); | |
| h.eventBus.publish(new ContextSpliced({ agentId: 'main', start: 0, deleteCount: 2, messages: [] })); | |
| expect(h.sut.load([MCP_ALPHA]).toLoad).toEqual([MCP_ALPHA]); | |
| }); | |
| it('keeps the pending ledger across a compaction replacement splice', async () => { | |
| const h = createHarness(); | |
| registerMcp(h, new StubMcpTool(MCP_ALPHA)); | |
| h.sut.load([MCP_ALPHA]); | |
| h.eventBus.publish( | |
| new ContextSpliced({ agentId: 'main', | |
| start: 0, | |
| deleteCount: 2, | |
| messages: [userMessage('Compacted summary.')], | |
| }), | |
| ); | |
| expect(h.sut.load([MCP_ALPHA]).alreadyAvailable).toEqual([MCP_ALPHA]); | |
| const declared = await declareSchemas(h); | |
| expect(declared?.tools?.map((tool) => tool.name)).toEqual([MCP_ALPHA]); | |
| }); | |
| it('reconciles the pending ledger with history when a mid-history splice removes schema messages', async () => { | |
| const h = createHarness(); | |
| registerMcp(h, new StubMcpTool(MCP_ALPHA)); | |
| registerMcp(h, new StubMcpTool(MCP_BETA)); | |
| h.sut.load([MCP_ALPHA]); | |
| await declareSchemas(h); | |
| h.sut.load([MCP_BETA]); | |
| await declareSchemas(h, 2); | |
| expect(h.sut.load([MCP_ALPHA]).alreadyAvailable).toEqual([MCP_ALPHA]); | |
| expect(h.sut.load([MCP_BETA]).alreadyAvailable).toEqual([MCP_BETA]); | |
| h.contextMemory.history.splice(1, 1); | |
| h.eventBus.publish(new ContextSpliced({ agentId: 'main', start: 1, deleteCount: 2, messages: [] })); | |
| expect(h.sut.load([MCP_ALPHA]).alreadyAvailable).toEqual([MCP_ALPHA]); | |
| expect(h.sut.load([MCP_BETA]).toLoad).toEqual([MCP_BETA]); | |
| }); | |
| it('keeps the pending ledger across tail appends', () => { | |
| const h = createHarness(); | |
| registerMcp(h, new StubMcpTool(MCP_ALPHA)); | |
| h.sut.load([MCP_ALPHA]); | |
| h.eventBus.publish( | |
| new ContextSpliced({ agentId: 'main', start: 3, deleteCount: 0, messages: [userMessage('x')] }), | |
| ); | |
| expect(h.sut.load([MCP_ALPHA]).alreadyAvailable).toEqual([MCP_ALPHA]); | |
| }); | |
| it('renders the select_tools tool output per name for mixed load results', async () => { | |
| const h = createHarness(); | |
| registerMcp(h, new StubMcpTool(MCP_ALPHA)); | |
| registerMcp(h, new StubMcpTool(MCP_BETA)); | |
| h.contextMemory.history.push(schemaMessage(MCP_ALPHA)); | |
| const selectTools = h.ix.createInstance(SelectToolsTool); | |
| const ctx = { turnId: 1, toolCallId: 'call-1', signal: new AbortController().signal }; | |
| const mixed = selectTools.resolveExecution({ names: [MCP_BETA, MCP_ALPHA, MCP_GONE] }); | |
| if (mixed.isError === true) throw new Error('expected a runnable execution'); | |
| expect(await mixed.execute(ctx)).toEqual({ | |
| output: [ | |
| `Loaded: ${MCP_BETA}`, | |
| `Already available: ${MCP_ALPHA}`, | |
| `Unknown tool: ${MCP_GONE}. Pick from the latest announced tools list.`, | |
| ].join('\n'), | |
| }); | |
| }); | |
| it('returns an error when select_tools only receives unknown names', async () => { | |
| const h = createHarness(); | |
| const selectTools = h.ix.createInstance(SelectToolsTool); | |
| const ctx = { turnId: 1, toolCallId: 'call-1', signal: new AbortController().signal }; | |
| const unknownOnly = selectTools.resolveExecution({ names: [MCP_GONE] }); | |
| if (unknownOnly.isError === true) throw new Error('expected a runnable execution'); | |
| expect(await unknownOnly.execute(ctx)).toEqual({ | |
| output: `Unknown tool: ${MCP_GONE}. Pick from the latest announced tools list.`, | |
| isError: true, | |
| }); | |
| }); | |
| }); | |
| describe('AgentToolSelectService executor interception', () => { | |
| beforeEach(() => { | |
| flagEnabled = true; | |
| }); | |
| it('the executor settles the intercepted call without running the tool', async () => { | |
| const h = createExecutorHarness(); | |
| const alpha = new StubMcpTool(MCP_ALPHA); | |
| registerMcp(h, alpha); | |
| const results = await execute(h, toolCall('call-1', MCP_ALPHA)); | |
| expect(results).toHaveLength(1); | |
| expect(results[0]!.result.isError).toBe(true); | |
| expect(results[0]!.result.output).toContain('is available but not loaded'); | |
| expect(alpha.calls).toBe(0); | |
| }); | |
| it('the executor returns loading guidance before validating args for an unloaded MCP tool', async () => { | |
| const h = createExecutorHarness(); | |
| const alpha = new StubMcpTool(MCP_ALPHA, 'mcp ok', REQUIRED_PAYLOAD_PARAMETERS); | |
| registerMcp(h, alpha); | |
| const results = await execute(h, toolCall('call-1', MCP_ALPHA, { unexpected: true })); | |
| expect(results).toHaveLength(1); | |
| expect(results[0]!.result).toEqual({ | |
| output: | |
| `Tool "${MCP_ALPHA}" is available but not loaded. ` + | |
| `Call select_tools with ["${MCP_ALPHA}"] first, then call the tool.`, | |
| isError: true, | |
| stopTurn: false, | |
| }); | |
| expect(alpha.calls).toBe(0); | |
| }); | |
| it('the executor runs the tool once its schema is loaded', async () => { | |
| const h = createExecutorHarness(); | |
| const alpha = new StubMcpTool(MCP_ALPHA); | |
| registerMcp(h, alpha); | |
| h.contextMemory.history.push(schemaMessage(MCP_ALPHA)); | |
| const results = await execute(h, toolCall('call-1', MCP_ALPHA)); | |
| expect(results).toHaveLength(1); | |
| expect(results[0]!.result.output).toBe('mcp ok'); | |
| expect(alpha.calls).toBe(1); | |
| }); | |
| it('the executor rejects a loaded MCP tool when the profile disables it', async () => { | |
| const h = createExecutorHarness(); | |
| const alpha = new StubMcpTool(MCP_ALPHA); | |
| registerMcp(h, alpha); | |
| h.contextMemory.history.push(schemaMessage(MCP_ALPHA)); | |
| activeToolNames = new Set([]); | |
| const results = await execute(h, toolCall('call-1', MCP_ALPHA)); | |
| expect(results).toHaveLength(1); | |
| expect(results[0]!.result).toEqual({ | |
| output: | |
| `Tool "${MCP_ALPHA}" was loaded but is no longer active. Ask the user to enable it before calling it again.`, | |
| isError: true, | |
| stopTurn: false, | |
| }); | |
| expect(alpha.calls).toBe(0); | |
| }); | |
| it('the executor runs non-MCP tools without loading', async () => { | |
| const h = createExecutorHarness(); | |
| const echo = new EchoTool(); | |
| registerBuiltin(h, echo); | |
| const results = await execute(h, toolCall('call-1', 'Echo')); | |
| expect(results).toHaveLength(1); | |
| expect(results[0]!.result.output).toBe('echo ok'); | |
| expect(echo.calls).toBe(1); | |
| }); | |
| it('intercepts an unloaded deferred user tool and runs it after selection', async () => { | |
| const h = createExecutorHarness(); | |
| const dashboard = new EchoTool(USER_DEFERRED); | |
| registerUser(h, dashboard, 'deferred'); | |
| const beforeLoad = await execute(h, toolCall('call-1', USER_DEFERRED)); | |
| expect(beforeLoad[0]!.result.output).toContain('is available but not loaded'); | |
| expect(dashboard.calls).toBe(0); | |
| h.contextMemory.history.push(schemaMessage(USER_DEFERRED)); | |
| const afterLoad = await execute(h, toolCall('call-2', USER_DEFERRED)); | |
| expect(afterLoad[0]!.result.output).toBe('echo ok'); | |
| expect(dashboard.calls).toBe(1); | |
| }); | |
| }); | |
| describe('AgentToolSelectService missing tool wording', () => { | |
| beforeEach(() => { | |
| flagEnabled = true; | |
| }); | |
| it('tells a loaded-but-disconnected MCP tool apart from an unknown name', async () => { | |
| const h = createExecutorHarness(); | |
| h.contextMemory.history.push(schemaMessage(MCP_GONE)); | |
| const results = await execute(h, toolCall('call-1', MCP_GONE)); | |
| expect(results).toHaveLength(1); | |
| expect(results[0]!.result.isError).toBe(true); | |
| expect(results[0]!.result.output).toBe( | |
| `Tool "${MCP_GONE}" was loaded but its MCP server is currently disconnected. ` + | |
| 'It may become available again when the server reconnects; do not retry immediately.', | |
| ); | |
| }); | |
| it('keeps the default message for a name that was never loaded', async () => { | |
| const h = createExecutorHarness(); | |
| const results = await execute(h, toolCall('call-1', MCP_GONE)); | |
| expect(results[0]!.result.output).toBe(`Tool "${MCP_GONE}" not found`); | |
| }); | |
| it('reports a loaded user tool that is no longer registered', async () => { | |
| const h = createExecutorHarness(); | |
| const registration = registerUser(h, new EchoTool(USER_DEFERRED), 'deferred'); | |
| h.contextMemory.history.push(schemaMessage(USER_DEFERRED)); | |
| registration.dispose(); | |
| const results = await execute(h, toolCall('call-1', USER_DEFERRED)); | |
| expect(results[0]!.result.output).toBe( | |
| `Tool "${USER_DEFERRED}" was loaded but is no longer registered. ` + | |
| 'Do not retry it unless it becomes available again.', | |
| ); | |
| }); | |
| }); | |
| describe('AgentToolSelectService loadable-tools announcements', () => { | |
| beforeEach(() => { | |
| flagEnabled = true; | |
| }); | |
| it('announces the full loadable set on first run, then stays silent while unchanged', async () => { | |
| const h = createHarness(); | |
| registerMcp(h, new StubMcpTool(MCP_BETA)); | |
| registerMcp(h, new StubMcpTool(MCP_ALPHA)); | |
| const first = await announce(h); | |
| expect(first).toContain(`<tools_added>\n${MCP_ALPHA}\n${MCP_BETA}\n</tools_added>`); | |
| expect(first).not.toContain('<tools_removed>'); | |
| expect(await announce(h, 2)).toBeUndefined(); | |
| }); | |
| it('waits until the next boundary before announcing registry diffs', async () => { | |
| const h = createHarness(); | |
| registerMcp(h, new StubMcpTool(MCP_ALPHA)); | |
| await announce(h); | |
| registerMcp(h, new StubMcpTool(MCP_GAMMA)); | |
| expect(await announce(h, 2)).toBeUndefined(); | |
| h.eventBus.publish(new TurnStarted({ agentId: 'main', turnId: 99, origin: { kind: 'user' } })); | |
| const diff = await announce(h); | |
| expect(diff).toContain(`<tools_added>\n${MCP_GAMMA}\n</tools_added>`); | |
| }); | |
| it('diffs registry additions and removals against the folded announcements', async () => { | |
| const h = createHarness(); | |
| registerMcp(h, new StubMcpTool(MCP_ALPHA)); | |
| const betaRegistration = h.registry.register(new StubMcpTool(MCP_BETA), { | |
| source: 'mcp', | |
| disclosure: 'deferred', | |
| }); | |
| disposables.add(betaRegistration); | |
| await announce(h); | |
| betaRegistration.dispose(); | |
| registerMcp(h, new StubMcpTool(MCP_GAMMA)); | |
| h.eventBus.publish(new TurnStarted({ agentId: 'main', turnId: 99, origin: { kind: 'user' } })); | |
| const diff = await announce(h); | |
| expect(diff).toContain(`<tools_added>\n${MCP_GAMMA}\n</tools_added>`); | |
| expect(diff).toContain(`<tools_removed>\n${MCP_BETA}\n</tools_removed>`); | |
| }); | |
| it('re-announces the full set after compaction discards the history', async () => { | |
| const h = createHarness(); | |
| registerMcp(h, new StubMcpTool(MCP_ALPHA)); | |
| registerMcp(h, new StubMcpTool(MCP_BETA)); | |
| await announce(h); | |
| expect(await announce(h, 2)).toBeUndefined(); | |
| h.contextMemory.clear(); | |
| const reannounced = await announceAfterCompaction(h); | |
| expect(reannounced).toContain(`<tools_added>\n${MCP_ALPHA}\n${MCP_BETA}\n</tools_added>`); | |
| }); | |
| it('announces only profile-active tools', async () => { | |
| const h = createHarness(); | |
| registerMcp(h, new StubMcpTool(MCP_ALPHA)); | |
| registerMcp(h, new StubMcpTool(MCP_BETA)); | |
| activeToolNames = new Set([MCP_BETA]); | |
| const first = await announce(h); | |
| expect(first).toContain(`<tools_added>\n${MCP_BETA}\n</tools_added>`); | |
| expect(first).not.toContain(MCP_ALPHA); | |
| }); | |
| it('stays silent while the gate is closed', async () => { | |
| flagEnabled = false; | |
| const h = createHarness(); | |
| registerMcp(h, new StubMcpTool(MCP_ALPHA)); | |
| expect(await announce(h)).toBeUndefined(); | |
| }); | |
| }); | |