Skip to content

Commit accf97b

Browse files
authored
feat: Add showInChat property to control agent visibility in chat (#16925)
Adds the ability to mark agents as non-chat agents, making them available for programmatic use (e.g., delegation) while hiding them from the chat UI. Changes: - Add `showInChat` optional property to `CustomAgentDescription` interface - Add `showInChat` to `AgentSettings` and preference schema (default: true) - Update `ChatAgentService.getAgents()` to filter by `showInChat` preference - Add "Show in Chat" toggle in AI Agent Configuration widget - Pass `showInChat` from YAML through factory to initialize preference The setting can be configured via: 1. YAML: `showInChat: false` in customAgents.yml 2. UI: Toggle in AI Configuration > Agents panel Agents with showInChat=false are filtered from: - Chat `@` autocomplete - Agent selection dialogs - Orchestrator delegation list Tests: - Type guard tests for CustomAgentDescription.showInChat - ChatAgentServiceImpl filtering tests (getAgents, getAllAgents, settings updates) Backward compatible: existing agents without the property default to visible.
1 parent 8d99d5f commit accf97b

13 files changed

+433
-25
lines changed

.prompts/project-info.prompttemplate

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ Tests are located in the same directory as the components under test.
5656

5757
### Compile and Test
5858

59-
This projects uses npm.
59+
Use `npm` (not `yarn`) for all package management and script execution.
60+
6061
If you want to compile something, run the linter or tests, prefer to execute them for changed packages first, as they will run faster. Only build the full project once you are
6162
done for a final validation. There are usually pre-defined tasks for all these operations, prefer to use these instead of npm directly.
6263

packages/ai-chat/src/browser/ai-chat-frontend-module.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
1515
// *****************************************************************************
1616

17-
import { Agent, AgentService, AIVariableContribution, bindToolProvider } from '@theia/ai-core/lib/common';
17+
import { Agent, AgentService, AISettingsService, AIVariableContribution, bindToolProvider } from '@theia/ai-core/lib/common';
1818
import { bindContributionProvider, CommandContribution, PreferenceContribution } from '@theia/core';
1919
import { FrontendApplicationContribution, LabelProviderContribution } from '@theia/core/lib/browser';
2020
import { ContainerModule } from '@theia/core/shared/inversify';
@@ -129,8 +129,8 @@ export default new ContainerModule(bind => {
129129
bind(ToolConfirmationManager).toSelf().inSingletonScope();
130130

131131
bind(CustomChatAgent).toSelf();
132-
bind(CustomAgentFactory).toFactory<CustomChatAgent, [string, string, string, string, string]>(
133-
ctx => (id: string, name: string, description: string, prompt: string, defaultLLM: string) => {
132+
bind(CustomAgentFactory).toFactory<CustomChatAgent, [string, string, string, string, string, boolean | undefined]>(
133+
ctx => (id: string, name: string, description: string, prompt: string, defaultLLM: string, showInChat?: boolean) => {
134134
const agent = ctx.container.get<CustomChatAgent>(CustomChatAgent);
135135
agent.id = id;
136136
agent.name = name;
@@ -142,6 +142,17 @@ export default new ContainerModule(bind => {
142142
}];
143143
ctx.container.get<ChatAgentService>(ChatAgentService).registerChatAgent(agent);
144144
ctx.container.get<AgentService>(AgentService).registerAgent(agent);
145+
146+
// Initialize showInChat preference from YAML if not already set
147+
if (showInChat === false) {
148+
const settingsService = ctx.container.get<AISettingsService>(AISettingsService);
149+
settingsService.getAgentSettings(id).then(settings => {
150+
if (settings?.showInChat === undefined) {
151+
settingsService.updateAgentSettings(id, { showInChat: false });
152+
}
153+
});
154+
}
155+
145156
return agent;
146157
});
147158
bind(FrontendApplicationContribution).to(AICustomAgentsFrontendApplicationContribution).inSingletonScope();

packages/ai-chat/src/browser/custom-agent-factory.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,4 @@
1717
import { CustomChatAgent } from '../common';
1818

1919
export const CustomAgentFactory = Symbol('CustomAgentFactory');
20-
export type CustomAgentFactory = (id: string, name: string, description: string, prompt: string, defaultLLM: string) => CustomChatAgent;
20+
export type CustomAgentFactory = (id: string, name: string, description: string, prompt: string, defaultLLM: string, showInChat?: boolean) => CustomChatAgent;

packages/ai-chat/src/browser/custom-agent-frontend-application-contribution.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export class AICustomAgentsFrontendApplicationContribution implements FrontendAp
3838
onStart(): void {
3939
this.customizationService?.getCustomAgents().then(customAgents => {
4040
customAgents.forEach(agent => {
41-
this.customAgentFactory(agent.id, agent.name, agent.description, agent.prompt, agent.defaultLLM);
41+
this.customAgentFactory(agent.id, agent.name, agent.description, agent.prompt, agent.defaultLLM, agent.showInChat);
4242
this.knownCustomAgents.set(agent.id, agent);
4343
});
4444
}).catch(e => {
@@ -59,7 +59,7 @@ export class AICustomAgentsFrontendApplicationContribution implements FrontendAp
5959
});
6060
customAgentsToAdd
6161
.forEach(agent => {
62-
this.customAgentFactory(agent.id, agent.name, agent.description, agent.prompt, agent.defaultLLM);
62+
this.customAgentFactory(agent.id, agent.name, agent.description, agent.prompt, agent.defaultLLM, agent.showInChat);
6363
this.knownCustomAgents.set(agent.id, agent);
6464
});
6565
}).catch(e => {
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
// *****************************************************************************
2+
// Copyright (C) 2026 EclipseSource GmbH.
3+
//
4+
// This program and the accompanying materials are made available under the
5+
// terms of the Eclipse Public License v. 2.0 which is available at
6+
// http://www.eclipse.org/legal/epl-2.0.
7+
//
8+
// This Source Code may also be made available under the following Secondary
9+
// Licenses when the conditions for such availability set forth in the Eclipse
10+
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
11+
// with the GNU Classpath Exception which is available at
12+
// https://www.gnu.org/software/classpath/license.html.
13+
//
14+
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15+
// *****************************************************************************
16+
17+
import 'reflect-metadata';
18+
19+
import { expect } from 'chai';
20+
import * as sinon from 'sinon';
21+
import { Container } from 'inversify';
22+
import { ContributionProvider, ILogger } from '@theia/core';
23+
import { Emitter } from '@theia/core/lib/common';
24+
import { AgentService, AISettings, AISettingsService } from '@theia/ai-core';
25+
import { ChatAgent } from './chat-agents';
26+
import { ChatAgentService, ChatAgentServiceImpl } from './chat-agent-service';
27+
28+
describe('ChatAgentServiceImpl', () => {
29+
let container: Container;
30+
let chatAgentService: ChatAgentServiceImpl;
31+
let sandbox: sinon.SinonSandbox;
32+
33+
let mockAgentService: AgentService;
34+
let mockAISettingsService: AISettingsService;
35+
let settingsStub: sinon.SinonStub;
36+
let onDidChangeEmitter: Emitter<void>;
37+
let mockContributionProvider: { getContributions: sinon.SinonStub };
38+
39+
// Test agents
40+
const agent1: ChatAgent = {
41+
id: 'agent1',
42+
name: 'Agent 1',
43+
description: 'Test agent 1',
44+
variables: [],
45+
functions: [],
46+
agentSpecificVariables: [],
47+
tags: [],
48+
prompts: [],
49+
languageModelRequirements: [],
50+
locations: [],
51+
invoke: sinon.stub()
52+
};
53+
54+
const agent2: ChatAgent = {
55+
id: 'agent2',
56+
name: 'Agent 2',
57+
description: 'Test agent 2',
58+
variables: [],
59+
functions: [],
60+
agentSpecificVariables: [],
61+
tags: [],
62+
prompts: [],
63+
languageModelRequirements: [],
64+
locations: [],
65+
invoke: sinon.stub()
66+
};
67+
68+
const agent3: ChatAgent = {
69+
id: 'agent3',
70+
name: 'Agent 3',
71+
description: 'Test agent 3',
72+
variables: [],
73+
functions: [],
74+
agentSpecificVariables: [],
75+
tags: [],
76+
prompts: [],
77+
languageModelRequirements: [],
78+
locations: [],
79+
invoke: sinon.stub()
80+
};
81+
82+
function createContainer(settings: AISettings = {}): Container {
83+
const testContainer = new Container();
84+
85+
// Mock AgentService
86+
mockAgentService = {
87+
isEnabled: () => true,
88+
getAgents: () => [],
89+
getAllAgents: () => [],
90+
enableAgent: async () => { },
91+
disableAgent: async () => { },
92+
registerAgent: () => { },
93+
unregisterAgent: () => { },
94+
onDidChangeAgents: new Emitter<void>().event
95+
};
96+
testContainer.bind(AgentService).toConstantValue(mockAgentService);
97+
98+
// Mock AISettingsService with Event
99+
onDidChangeEmitter = new Emitter<void>();
100+
settingsStub = sandbox.stub().resolves(settings);
101+
mockAISettingsService = {
102+
getSettings: settingsStub,
103+
getAgentSettings: async () => undefined,
104+
updateAgentSettings: async () => { },
105+
onDidChange: onDidChangeEmitter.event
106+
};
107+
testContainer.bind(AISettingsService).toConstantValue(mockAISettingsService);
108+
109+
// Mock ContributionProvider
110+
mockContributionProvider = {
111+
getContributions: sandbox.stub().returns([agent1, agent2, agent3])
112+
};
113+
testContainer.bind(ContributionProvider).toConstantValue(mockContributionProvider).whenTargetNamed(ChatAgent);
114+
115+
// Mock Logger
116+
const mockLogger = {
117+
debug: sandbox.stub(),
118+
info: sandbox.stub(),
119+
warn: sandbox.stub(),
120+
error: sandbox.stub()
121+
};
122+
testContainer.bind(ILogger).toConstantValue(mockLogger as unknown as ILogger);
123+
124+
// Bind the service under test
125+
testContainer.bind(ChatAgentService).to(ChatAgentServiceImpl).inSingletonScope();
126+
127+
return testContainer;
128+
}
129+
130+
async function initializeService(settings: AISettings = {}): Promise<void> {
131+
sandbox = sinon.createSandbox();
132+
container = createContainer(settings);
133+
chatAgentService = container.get<ChatAgentServiceImpl>(ChatAgentService);
134+
// Wait for @postConstruct async initialization
135+
await settingsStub();
136+
// Allow promise chain to complete
137+
await new Promise(resolve => setTimeout(resolve, 0));
138+
}
139+
140+
afterEach(() => {
141+
sandbox.restore();
142+
onDidChangeEmitter?.dispose();
143+
});
144+
145+
describe('getAgents() showInChat filtering', () => {
146+
it('returns agents when showInChat preference is not set (default = visible)', async () => {
147+
await initializeService({});
148+
149+
const agents = chatAgentService.getAgents();
150+
151+
expect(agents).to.have.lengthOf(3);
152+
expect(agents.map(a => a.id)).to.include.members(['agent1', 'agent2', 'agent3']);
153+
});
154+
155+
it('filters out agents where showInChat preference is false', async () => {
156+
await initializeService({
157+
agent1: { showInChat: false },
158+
agent2: { showInChat: false }
159+
});
160+
161+
const agents = chatAgentService.getAgents();
162+
163+
expect(agents).to.have.lengthOf(1);
164+
expect(agents[0].id).to.equal('agent3');
165+
});
166+
167+
it('includes agents where showInChat preference is true', async () => {
168+
await initializeService({
169+
agent1: { showInChat: true },
170+
agent2: { showInChat: true },
171+
agent3: { showInChat: true }
172+
});
173+
174+
const agents = chatAgentService.getAgents();
175+
176+
expect(agents).to.have.lengthOf(3);
177+
expect(agents.map(a => a.id)).to.include.members(['agent1', 'agent2', 'agent3']);
178+
});
179+
});
180+
181+
describe('getAllAgents()', () => {
182+
it('returns all agents regardless of showInChat setting', async () => {
183+
await initializeService({
184+
agent1: { showInChat: false },
185+
agent2: { showInChat: false },
186+
agent3: { showInChat: false }
187+
});
188+
189+
const allAgents = chatAgentService.getAllAgents();
190+
191+
expect(allAgents).to.have.lengthOf(3);
192+
expect(allAgents.map(a => a.id)).to.include.members(['agent1', 'agent2', 'agent3']);
193+
});
194+
});
195+
196+
describe('onDidChange updates', () => {
197+
it('updates filtered list when AISettingsService.onDidChange fires', async () => {
198+
// Start with agent1 hidden
199+
await initializeService({
200+
agent1: { showInChat: false }
201+
});
202+
203+
let agents = chatAgentService.getAgents();
204+
expect(agents).to.have.lengthOf(2);
205+
expect(agents.map(a => a.id)).to.not.include('agent1');
206+
207+
// Update settings to show agent1 and hide agent2
208+
settingsStub.resolves({
209+
agent2: { showInChat: false }
210+
});
211+
212+
// Fire the change event
213+
onDidChangeEmitter.fire();
214+
215+
// Wait for async update
216+
await settingsStub();
217+
await new Promise(resolve => setTimeout(resolve, 0));
218+
219+
agents = chatAgentService.getAgents();
220+
expect(agents).to.have.lengthOf(2);
221+
expect(agents.map(a => a.id)).to.include('agent1');
222+
expect(agents.map(a => a.id)).to.not.include('agent2');
223+
});
224+
});
225+
});

packages/ai-chat/src/common/chat-agent-service.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@
2020
// Partially copied from https://github.com/microsoft/vscode/blob/a2cab7255c0df424027be05d58e1b7b941f4ea60/src/vs/workbench/contrib/chat/common/chatAgents.ts
2121

2222
import { ContributionProvider, ILogger } from '@theia/core';
23-
import { inject, injectable, named } from '@theia/core/shared/inversify';
23+
import { inject, injectable, named, optional, postConstruct } from '@theia/core/shared/inversify';
2424
import { ChatAgent } from './chat-agents';
25-
import { AgentService } from '@theia/ai-core';
25+
import { AgentService, AISettingsService } from '@theia/ai-core';
2626

2727
export const ChatAgentService = Symbol('ChatAgentService');
2828
export const ChatAgentServiceFactory = Symbol('ChatAgentServiceFactory');
@@ -67,8 +67,34 @@ export class ChatAgentServiceImpl implements ChatAgentService {
6767
@inject(AgentService)
6868
protected agentService: AgentService;
6969

70+
@inject(AISettingsService) @optional()
71+
protected readonly aiSettingsService: AISettingsService | undefined;
72+
7073
protected _agents: ChatAgent[] = [];
7174

75+
protected hiddenFromChatAgents = new Set<string>();
76+
77+
@postConstruct()
78+
protected init(): void {
79+
this.aiSettingsService?.getSettings().then(settings => {
80+
Object.entries(settings).forEach(([agentId, agentSettings]) => {
81+
if (agentSettings.showInChat === false) {
82+
this.hiddenFromChatAgents.add(agentId);
83+
}
84+
});
85+
});
86+
this.aiSettingsService?.onDidChange(() => {
87+
this.aiSettingsService?.getSettings().then(settings => {
88+
this.hiddenFromChatAgents.clear();
89+
Object.entries(settings).forEach(([agentId, agentSettings]) => {
90+
if (agentSettings.showInChat === false) {
91+
this.hiddenFromChatAgents.add(agentId);
92+
}
93+
});
94+
});
95+
});
96+
}
97+
7298
protected get agents(): ChatAgent[] {
7399
// We can't collect the contributions at @postConstruct because this will lead to a circular dependency
74100
// with chat agents reusing the chat agent service (e.g. orchestrator)
@@ -89,7 +115,7 @@ export class ChatAgentServiceImpl implements ChatAgentService {
89115
return this.getAgents().find(agent => agent.id === id);
90116
}
91117
getAgents(): ChatAgent[] {
92-
return this.agents.filter(a => this._agentIsEnabled(a.id));
118+
return this.agents.filter(a => this._agentIsEnabled(a.id) && this._agentShowsInChat(a.id));
93119
}
94120
getAllAgents(): ChatAgent[] {
95121
return this.agents;
@@ -98,4 +124,8 @@ export class ChatAgentServiceImpl implements ChatAgentService {
98124
private _agentIsEnabled(id: string): boolean {
99125
return this.agentService.isEnabled(id);
100126
}
127+
128+
private _agentShowsInChat(id: string): boolean {
129+
return !this.hiddenFromChatAgents.has(id);
130+
}
101131
}

packages/ai-core/src/browser/frontend-prompt-customization-service.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ You are an example agent. Be nice and helpful to the user.
4545
## Current Context
4646
Some files and other pieces of data may have been added by the user to the context of the chat. If any have, the details can be found below.
4747
{{contextDetails}}`,
48-
defaultLLM: 'openai/gpt-4o'
48+
defaultLLM: 'openai/gpt-4o',
49+
showInChat: true
4950
};
5051

5152
export enum CustomizationSource {

packages/ai-core/src/common/agent-preferences.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ export const AgentSettingsPreferenceSchema: PreferenceSchema = {
3838
markdownDescription: nls.localize('theia/ai/agents/enable/mdDescription', 'Specifies whether the agent should be enabled (true) or disabled (false).'),
3939
default: true
4040
},
41+
showInChat: {
42+
type: 'boolean',
43+
title: nls.localize('theia/ai/agents/showInChat/title', 'Show in Chat'),
44+
markdownDescription: nls.localize('theia/ai/agents/showInChat/mdDescription',
45+
'Specifies whether the agent should be shown in the chat UI (true) or hidden (false).'),
46+
default: true
47+
},
4148
languageModelRequirements: {
4249
type: 'array',
4350
title: nls.localize('theia/ai/agents/languageModelRequirements/title', 'Language Model Requirements'),

0 commit comments

Comments
 (0)