// ***************************************************************************** // Copyright (C) 2026 EclipseSource GmbH. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { expect } from 'chai'; import { Container } from '@theia/core/shared/inversify'; import { ChatServiceImpl, ChatSession, DefaultChatAgentId, PinChatAgent } from './chat-service'; import { ChatAgentService } from './chat-agent-service'; import { ChatRequestParser } from './chat-request-parser'; import { AIVariableService, ToolInvocationRegistry } from '@theia/ai-core'; import { ILogger } from '@theia/core'; import { ChatContentDeserializerRegistry, ChatContentDeserializerRegistryImpl, DefaultChatContentDeserializerContribution } from './chat-content-deserializer'; import { ChangeSetElementDeserializerRegistry, ChangeSetElementDeserializerRegistryImpl } from './change-set-element-deserializer'; import { ChatAgent, ChatAgentLocation } from './chat-agents'; import { ParsedChatRequest, ParsedChatRequestAgentPart, ParsedChatRequestTextPart } from './parsed-chat-request'; import { ChatRequest } from './chat-model'; describe('ChatService pinned agent behavior', () => { let chatService: ChatServiceImpl; let mockAgentService: MockChatAgentService; let container: Container; const mockPinnedAgent: ChatAgent = { id: 'pinned-agent', name: 'Pinned Agent', description: 'Test pinned agent', locations: [ChatAgentLocation.Panel], invoke: async () => { }, languageModelRequirements: [], tags: [], variables: [], agentSpecificVariables: [], functions: [], prompts: [] }; const mockMentionedAgent: ChatAgent = { id: 'mentioned-agent', name: 'Mentioned Agent', description: 'Test mentioned agent', locations: [ChatAgentLocation.Panel], invoke: async () => { }, languageModelRequirements: [], tags: [], variables: [], agentSpecificVariables: [], functions: [], prompts: [] }; const mockDefaultAgent: ChatAgent = { id: 'default-agent', name: 'Default Agent', description: 'Test default agent', locations: [ChatAgentLocation.Panel], invoke: async () => { }, languageModelRequirements: [], tags: [], variables: [], agentSpecificVariables: [], functions: [], prompts: [] }; const mockHiddenAgent: ChatAgent = { id: 'hidden-worker', name: 'Hidden Worker', description: 'Test agent that is hidden from chat (showInChat: false)', locations: [ChatAgentLocation.Panel], invoke: async () => { }, languageModelRequirements: [], tags: [], variables: [], agentSpecificVariables: [], functions: [], prompts: [] }; class MockChatAgentService { private agents = new Map([ ['pinned-agent', mockPinnedAgent], ['mentioned-agent', mockMentionedAgent], ['default-agent', mockDefaultAgent], ['hidden-worker', mockHiddenAgent] ]); /** Mirrors ChatAgentServiceImpl: agents with showInChat === false are only returned with includeHidden. */ private hiddenIds = new Set(['hidden-worker']); readonly onDidChangeAgents = { dispose: () => { } }; readonly onDefaultAgentChanged = { dispose: () => { } }; getAgent(id: string, includeHidden: boolean = false): ChatAgent | undefined { if (!includeHidden && this.hiddenIds.has(id)) { return undefined; } return this.agents.get(id); } removeAgent(id: string): void { this.agents.delete(id); } resolveAgent(): ChatAgent | undefined { return this.agents.get('default-agent'); } } class MockChatRequestParser { async parseChatRequest(request: ChatRequest): Promise { const agentId = this.getMentionedAgentId(request.text); const parts = agentId ? [ new ParsedChatRequestAgentPart({ start: 0, endExclusive: agentId.length + 1 }, agentId, agentId), new ParsedChatRequestTextPart({ start: agentId.length + 2, endExclusive: request.text.length }, request.text.substring(agentId.length + 2)) ] : [ new ParsedChatRequestTextPart({ start: 0, endExclusive: request.text.length }, request.text) ]; return { request, parts, toolRequests: new Map(), variables: [] }; } private getMentionedAgentId(text: string): string | undefined { if (!text.startsWith('@')) { return undefined; } const spaceIndex = text.indexOf(' '); if (spaceIndex === -1) { return text.substring(1) || undefined; } return text.substring(1, spaceIndex) || undefined; } } class MockAIVariableService { async resolveVariable(): Promise { return undefined; } } class MockLogger { error(): void { } warn(): void { } info(): void { } debug(): void { } } beforeEach(() => { container = new Container(); mockAgentService = new MockChatAgentService(); container.bind(ChatAgentService).toConstantValue(mockAgentService as unknown as ChatAgentService); container.bind(ChatRequestParser).toConstantValue(new MockChatRequestParser() as unknown as ChatRequestParser); container.bind(AIVariableService).toConstantValue(new MockAIVariableService() as unknown as AIVariableService); container.bind(ToolInvocationRegistry).toConstantValue({}); container.bind(ILogger).toConstantValue(new MockLogger() as unknown as ILogger); container.bind(DefaultChatAgentId).toConstantValue({ id: 'default-agent' }); container.bind(PinChatAgent).toConstantValue(true); const contentRegistry = new ChatContentDeserializerRegistryImpl(); new DefaultChatContentDeserializerContribution().registerDeserializers(contentRegistry); container.bind(ChatContentDeserializerRegistry).toConstantValue(contentRegistry); container.bind(ChangeSetElementDeserializerRegistry).toConstantValue(new ChangeSetElementDeserializerRegistryImpl()); container.bind(ChatServiceImpl).toSelf().inSingletonScope(); chatService = container.get(ChatServiceImpl); }); function createMockSession(pinnedAgent?: ChatAgent): ChatSession { const session = chatService.createSession(ChatAgentLocation.Panel); if (pinnedAgent) { session.pinnedAgent = pinnedAgent; } return session; } it('should preserve precedence: mentioned agent > pinned agent > default', () => { const session = createMockSession(mockPinnedAgent); const parsedRequest: ParsedChatRequest = { request: { text: '@mentioned-agent test' }, parts: [ new ParsedChatRequestAgentPart({ start: 0, endExclusive: '@mentioned-agent'.length }, 'mentioned-agent', 'mentioned-agent'), new ParsedChatRequestTextPart({ start: '@mentioned-agent '.length, endExclusive: '@mentioned-agent test'.length }, 'test') ], toolRequests: new Map(), variables: [] }; const agent = chatService.getAgent(parsedRequest, session); expect(agent).to.equal(mockMentionedAgent); }); it('should return pinned agent when no mention exists', () => { const session = createMockSession(mockPinnedAgent); const parsedRequest: ParsedChatRequest = { request: { text: 'test message' }, parts: [new ParsedChatRequestTextPart({ start: 0, endExclusive: 'test message'.length }, 'test message')], toolRequests: new Map(), variables: [] }; const agent = chatService.getAgent(parsedRequest, session); expect(agent).to.equal(mockPinnedAgent); }); it('should return default agent when no mention and no pinned agent exist', () => { const session = createMockSession(undefined); const parsedRequest: ParsedChatRequest = { request: { text: 'test message' }, parts: [new ParsedChatRequestTextPart({ start: 0, endExclusive: 'test message'.length }, 'test message')], toolRequests: new Map(), variables: [] }; const agent = chatService.getAgent(parsedRequest, session); expect(agent).to.equal(mockDefaultAgent); }); it('should auto-pin selected agent during sendRequest', async () => { const session = createMockSession(mockPinnedAgent); await chatService.sendRequest(session.id, { text: '@mentioned-agent test' }); // The selected agent (mentioned-agent) becomes the new pinned agent expect(session.pinnedAgent).to.equal(mockMentionedAgent); }); it('should resolve a hidden pinned agent instead of falling back to the default agent', () => { // AgentDelegationTool pins the delegated agent on the session it creates; worker agents // are typically hidden (showInChat: false). The pin is an explicit choice, so it must // resolve even for hidden agents — falling back to the default agent would silently run // the wrong agent with the wrong prompt and toolset. const session = createMockSession(mockHiddenAgent); const parsedRequest: ParsedChatRequest = { request: { text: 'test message' }, parts: [new ParsedChatRequestTextPart({ start: 0, endExclusive: 'test message'.length }, 'test message')], toolRequests: new Map(), variables: [] }; const agent = chatService.getAgent(parsedRequest, session); expect(agent).to.equal(mockHiddenAgent); }); it('should resolve a hidden pinned agent when the request mentions that hidden agent', () => { // A delegated request's text is '@ '. The mention of a hidden agent does // not resolve (mentions stay hidden-excluding), but the pin must still catch it. const session = createMockSession(mockHiddenAgent); const parsedRequest: ParsedChatRequest = { request: { text: '@hidden-worker test' }, parts: [ new ParsedChatRequestAgentPart({ start: 0, endExclusive: '@hidden-worker'.length }, 'hidden-worker', 'hidden-worker'), new ParsedChatRequestTextPart({ start: '@hidden-worker '.length, endExclusive: '@hidden-worker test'.length }, 'test') ], toolRequests: new Map(), variables: [] }; const agent = chatService.getAgent(parsedRequest, session); expect(agent).to.equal(mockHiddenAgent); }); });