@theia/ai-core 1.72.0-next.5 → 1.72.0-next.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/lib/browser/file-variable-contribution.d.ts.map +1 -1
  2. package/lib/browser/file-variable-contribution.js +17 -2
  3. package/lib/browser/file-variable-contribution.js.map +1 -1
  4. package/lib/browser/frontend-prompt-customization-service.d.ts +11 -1
  5. package/lib/browser/frontend-prompt-customization-service.d.ts.map +1 -1
  6. package/lib/browser/frontend-prompt-customization-service.js +44 -3
  7. package/lib/browser/frontend-prompt-customization-service.js.map +1 -1
  8. package/lib/browser/frontend-prompt-customization-service.spec.d.ts +1 -1
  9. package/lib/browser/frontend-prompt-customization-service.spec.d.ts.map +1 -1
  10. package/lib/browser/frontend-prompt-customization-service.spec.js +129 -0
  11. package/lib/browser/frontend-prompt-customization-service.spec.js.map +1 -1
  12. package/lib/browser/generic-capabilities-variable-contribution.d.ts +2 -1
  13. package/lib/browser/generic-capabilities-variable-contribution.d.ts.map +1 -1
  14. package/lib/browser/generic-capabilities-variable-contribution.js +13 -2
  15. package/lib/browser/generic-capabilities-variable-contribution.js.map +1 -1
  16. package/lib/browser/generic-capabilities-variable-contribution.spec.js +70 -0
  17. package/lib/browser/generic-capabilities-variable-contribution.spec.js.map +1 -1
  18. package/lib/browser/open-editors-variable-contribution.d.ts +1 -1
  19. package/lib/browser/open-editors-variable-contribution.d.ts.map +1 -1
  20. package/lib/browser/open-editors-variable-contribution.js +2 -4
  21. package/lib/browser/open-editors-variable-contribution.js.map +1 -1
  22. package/lib/browser/skill-service.d.ts +1 -1
  23. package/lib/browser/skill-service.d.ts.map +1 -1
  24. package/lib/browser/skill-service.js +5 -9
  25. package/lib/browser/skill-service.js.map +1 -1
  26. package/lib/browser/theia-variable-contribution.d.ts.map +1 -1
  27. package/lib/browser/theia-variable-contribution.js +10 -11
  28. package/lib/browser/theia-variable-contribution.js.map +1 -1
  29. package/lib/browser/window-blink-service.d.ts.map +1 -1
  30. package/lib/browser/window-blink-service.js +4 -2
  31. package/lib/browser/window-blink-service.js.map +1 -1
  32. package/lib/common/capability-utils.d.ts +0 -2
  33. package/lib/common/capability-utils.d.ts.map +1 -1
  34. package/lib/common/capability-utils.js.map +1 -1
  35. package/package.json +11 -11
  36. package/src/browser/file-variable-contribution.ts +21 -2
  37. package/src/browser/frontend-prompt-customization-service.spec.ts +199 -0
  38. package/src/browser/frontend-prompt-customization-service.ts +48 -4
  39. package/src/browser/generic-capabilities-variable-contribution.spec.ts +108 -0
  40. package/src/browser/generic-capabilities-variable-contribution.ts +15 -3
  41. package/src/browser/open-editors-variable-contribution.ts +4 -5
  42. package/src/browser/skill-service.ts +5 -9
  43. package/src/browser/theia-variable-contribution.ts +12 -11
  44. package/src/browser/window-blink-service.ts +4 -2
  45. package/src/common/capability-utils.ts +0 -2
@@ -71,9 +71,10 @@ export class FileVariableContribution implements AIVariableContribution, AIVaria
71
71
 
72
72
  try {
73
73
  const content = await this.fileService.readFile(uri);
74
+ const relativePath = this.wsService.getRootPrefixedPath(uri);
74
75
  return {
75
76
  variable: request.variable,
76
- value: await this.wsService.getWorkspaceRelativePath(uri),
77
+ value: relativePath,
77
78
  contextValue: content.value.toString(),
78
79
  };
79
80
  } catch (error) {
@@ -103,9 +104,26 @@ export class FileVariableContribution implements AIVariableContribution, AIVaria
103
104
  }
104
105
 
105
106
  protected async makeAbsolute(pathStr: string): Promise<URI | undefined> {
106
- const path = new Path(Path.normalizePathSeparator(pathStr));
107
+ const normalizedPath = Path.normalizePathSeparator(pathStr);
108
+ const path = new Path(normalizedPath);
109
+
107
110
  if (!path.isAbsolute) {
108
111
  const workspaceRoots = this.wsService.tryGetRoots();
112
+
113
+ const segments = normalizedPath.split('/');
114
+ if (segments.length > 0) {
115
+ const potentialRootName = segments[0];
116
+ for (const root of workspaceRoots) {
117
+ if (root.resource.path.base === potentialRootName) {
118
+ const restOfPath = segments.slice(1).join('/');
119
+ const uri = restOfPath ? root.resource.resolve(restOfPath) : root.resource;
120
+ if (await this.fileService.exists(uri)) {
121
+ return uri;
122
+ }
123
+ }
124
+ }
125
+ }
126
+
109
127
  const wsUris = workspaceRoots.map(root => root.resource.resolve(path));
110
128
  for (const uri of wsUris) {
111
129
  if (await this.fileService.exists(uri)) {
@@ -113,6 +131,7 @@ export class FileVariableContribution implements AIVariableContribution, AIVaria
113
131
  }
114
132
  }
115
133
  }
134
+
116
135
  const argUri = new URI(pathStr);
117
136
  if (await this.fileService.exists(argUri)) {
118
137
  return argUri;
@@ -14,8 +14,20 @@
14
14
  // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
15
  // *****************************************************************************
16
16
 
17
+ import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
18
+
19
+ let disableJSDOM = enableJSDOM();
20
+ import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
21
+ FrontendApplicationConfigProvider.set({});
22
+
23
+ import 'reflect-metadata';
24
+
17
25
  import { expect } from 'chai';
26
+ import URI from '@theia/core/lib/common/uri';
18
27
  import { parseTemplateWithMetadata, ParsedTemplate } from './prompttemplate-parser';
28
+ import { CustomizationSource, DefaultPromptFragmentCustomizationService } from './frontend-prompt-customization-service';
29
+
30
+ disableJSDOM();
19
31
 
20
32
  describe('Prompt Template Parser', () => {
21
33
 
@@ -199,4 +211,191 @@ Template`;
199
211
  expect(result.metadata?.description).to.be.undefined;
200
212
  });
201
213
  });
214
+
215
+ describe('DefaultPromptFragmentCustomizationService - addTemplate conflict resolution', () => {
216
+ before(() => disableJSDOM = enableJSDOM());
217
+ after(() => disableJSDOM());
218
+
219
+ interface FragmentEntry {
220
+ id: string;
221
+ template: string;
222
+ sourceUri: string;
223
+ sourceUris: string[];
224
+ priority: number;
225
+ origin: CustomizationSource;
226
+ customizationId: string;
227
+ }
228
+
229
+ /**
230
+ * Test subclass that exposes the protected `addTemplate` and `provenanceLabel`
231
+ * methods so we can unit-test conflict resolution without mocking the filesystem.
232
+ */
233
+ class TestableCustomizationService extends DefaultPromptFragmentCustomizationService {
234
+ // Prevent @postConstruct from running (it touches preferences)
235
+ protected override init(): void { }
236
+
237
+ /** Configure fake workspace roots so provenanceLabel can resolve them. */
238
+ setRoots(rootUris: string[]): void {
239
+ const roots = rootUris.map(r => new URI(r));
240
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
241
+ (this as any).workspaceService = {
242
+ getWorkspaceRootUri(uri: URI): URI | undefined {
243
+ return roots.find(root => uri.toString().startsWith(root.toString()));
244
+ }
245
+ };
246
+ }
247
+
248
+ public testAddTemplate(
249
+ active: Map<string, FragmentEntry>,
250
+ id: string,
251
+ template: string,
252
+ sourceUri: string,
253
+ all: Map<string, FragmentEntry>,
254
+ priority: number,
255
+ origin: CustomizationSource
256
+ ): void {
257
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
258
+ (this as any).addTemplate(active, id, template, sourceUri, all, priority, origin);
259
+ }
260
+
261
+ public testProvenanceLabel(sourceUri: string): string {
262
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
263
+ return (this as any).provenanceLabel(sourceUri);
264
+ }
265
+ }
266
+
267
+ let service: TestableCustomizationService;
268
+ let activeMap: Map<string, FragmentEntry>;
269
+ let allMap: Map<string, FragmentEntry>;
270
+
271
+ beforeEach(() => {
272
+ service = new TestableCustomizationService();
273
+ service.setRoots([
274
+ 'file:///rootA',
275
+ 'file:///rootB',
276
+ 'file:///rootC',
277
+ 'file:///home/user/my-project'
278
+ ]);
279
+ activeMap = new Map();
280
+ allMap = new Map();
281
+ });
282
+
283
+ it('adds a fragment when no conflict exists', () => {
284
+ const uri = 'file:///rootA/.prompts/project-info.prompttemplate';
285
+ service.testAddTemplate(
286
+ activeMap, 'project-info', 'Content A', uri, allMap, 2, CustomizationSource.FOLDER
287
+ );
288
+
289
+ expect(activeMap.has('project-info')).to.be.true;
290
+ expect(activeMap.get('project-info')!.template).to.equal('Content A');
291
+ expect(activeMap.get('project-info')!.sourceUris).to.deep.equal([uri]);
292
+ });
293
+
294
+ it('higher priority replaces lower priority', () => {
295
+ service.testAddTemplate(
296
+ activeMap, 'project-info', 'Low priority',
297
+ 'file:///rootA/.prompts/project-info.prompttemplate', allMap, 1, CustomizationSource.CUSTOMIZED
298
+ );
299
+ service.testAddTemplate(
300
+ activeMap, 'project-info', 'High priority',
301
+ 'file:///rootB/.prompts/project-info.prompttemplate', allMap, 2, CustomizationSource.FOLDER
302
+ );
303
+
304
+ expect(activeMap.get('project-info')!.template).to.equal('High priority');
305
+ });
306
+
307
+ it('same source URI updates in place', () => {
308
+ const uri = 'file:///rootA/.prompts/project-info.prompttemplate';
309
+ service.testAddTemplate(activeMap, 'project-info', 'Original', uri, allMap, 2, CustomizationSource.FOLDER);
310
+ service.testAddTemplate(activeMap, 'project-info', 'Updated', uri, allMap, 2, CustomizationSource.FOLDER);
311
+
312
+ expect(activeMap.get('project-info')!.template).to.equal('Updated');
313
+ expect(activeMap.get('project-info')!.sourceUris).to.deep.equal([uri]);
314
+ });
315
+
316
+ it('equal priority from different sources concatenates with provenance labels', () => {
317
+ service.testAddTemplate(
318
+ activeMap, 'project-info', 'Content A',
319
+ 'file:///rootA/.prompts/project-info.prompttemplate', allMap, 2, CustomizationSource.FOLDER
320
+ );
321
+ service.testAddTemplate(
322
+ activeMap, 'project-info', 'Content B',
323
+ 'file:///rootB/.prompts/project-info.prompttemplate', allMap, 2, CustomizationSource.FOLDER
324
+ );
325
+
326
+ const entry = activeMap.get('project-info')!;
327
+ expect(entry.sourceUris).to.have.lengthOf(2);
328
+ expect(entry.template).to.contain('Content A');
329
+ expect(entry.template).to.contain('Content B');
330
+ expect(entry.template).to.contain('### rootA');
331
+ expect(entry.template).to.contain('### rootB');
332
+ });
333
+
334
+ it('three-way merge concatenates all sources in order', () => {
335
+ service.testAddTemplate(
336
+ activeMap, 'project-info', 'Content A',
337
+ 'file:///rootA/.prompts/project-info.prompttemplate', allMap, 2, CustomizationSource.FOLDER
338
+ );
339
+ service.testAddTemplate(
340
+ activeMap, 'project-info', 'Content B',
341
+ 'file:///rootB/.prompts/project-info.prompttemplate', allMap, 2, CustomizationSource.FOLDER
342
+ );
343
+ service.testAddTemplate(
344
+ activeMap, 'project-info', 'Content C',
345
+ 'file:///rootC/.prompts/project-info.prompttemplate', allMap, 2, CustomizationSource.FOLDER
346
+ );
347
+
348
+ const entry = activeMap.get('project-info')!;
349
+ expect(entry.sourceUris).to.have.lengthOf(3);
350
+ expect(entry.template).to.contain('### rootA');
351
+ expect(entry.template).to.contain('### rootB');
352
+ expect(entry.template).to.contain('### rootC');
353
+ // Verify ordering: A before B before C
354
+ const idxA = entry.template.indexOf('Content A');
355
+ const idxB = entry.template.indexOf('Content B');
356
+ const idxC = entry.template.indexOf('Content C');
357
+ expect(idxA).to.be.lessThan(idxB);
358
+ expect(idxB).to.be.lessThan(idxC);
359
+ });
360
+
361
+ it('provenanceLabel extracts grandparent directory name from URI', () => {
362
+ // For "file:///home/user/my-project/.prompts/foo.prompttemplate"
363
+ // parent is ".prompts", grandparent is "my-project"
364
+ const label = service.testProvenanceLabel(
365
+ 'file:///home/user/my-project/.prompts/foo.prompttemplate'
366
+ );
367
+ expect(label).to.equal('my-project');
368
+ });
369
+
370
+ it('provenanceLabel falls back to parent if grandparent is empty', () => {
371
+ expect(service.testProvenanceLabel('file:///.prompts/foo.prompttemplate'))
372
+ .to.equal('.prompts');
373
+ });
374
+
375
+ it('merged entry preserves primary sourceUri for backwards compatibility', () => {
376
+ const uriA = 'file:///rootA/.prompts/project-info.prompttemplate';
377
+ const uriB = 'file:///rootB/.prompts/project-info.prompttemplate';
378
+ service.testAddTemplate(activeMap, 'project-info', 'Content A', uriA, allMap, 2, CustomizationSource.FOLDER);
379
+ service.testAddTemplate(activeMap, 'project-info', 'Content B', uriB, allMap, 2, CustomizationSource.FOLDER);
380
+
381
+ const entry = activeMap.get('project-info')!;
382
+ // Primary sourceUri should be the first one added
383
+ expect(entry.sourceUri).to.equal(uriA);
384
+ // All sources tracked
385
+ expect(entry.sourceUris).to.deep.equal([uriA, uriB]);
386
+ });
387
+
388
+ it('all map tracks each source independently', () => {
389
+ const uriA = 'file:///rootA/.prompts/project-info.prompttemplate';
390
+ const uriB = 'file:///rootB/.prompts/project-info.prompttemplate';
391
+ service.testAddTemplate(activeMap, 'project-info', 'Content A', uriA, allMap, 2, CustomizationSource.FOLDER);
392
+ service.testAddTemplate(activeMap, 'project-info', 'Content B', uriB, allMap, 2, CustomizationSource.FOLDER);
393
+
394
+ // allCustomizations is keyed by sourceUri, so both should be present
395
+ expect(allMap.has(uriA)).to.be.true;
396
+ expect(allMap.has(uriB)).to.be.true;
397
+ expect(allMap.get(uriA)!.template).to.equal('Content A');
398
+ expect(allMap.get(uriB)!.template).to.equal('Content B');
399
+ });
400
+ });
202
401
  });
@@ -27,6 +27,7 @@ import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
27
27
  import { dump, load } from 'js-yaml';
28
28
  import { PROMPT_TEMPLATE_EXTENSION } from './prompttemplate-contribution';
29
29
  import { parseTemplateWithMetadata, ParsedTemplate } from './prompttemplate-parser';
30
+ import { WorkspaceService } from '@theia/workspace/lib/browser';
30
31
 
31
32
  /**
32
33
  * Default template entry for creating custom agents
@@ -89,9 +90,12 @@ interface PromptFragmentCustomization extends CommandPromptFragmentMetadata {
89
90
  /** The template content */
90
91
  template: string;
91
92
 
92
- /** Source URI where this template is stored */
93
+ /** Source URI where this template is stored (first/primary source when merged) */
93
94
  sourceUri: string;
94
95
 
96
+ /** All source URIs when multiple equal-priority sources were merged */
97
+ sourceUris: string[];
98
+
95
99
  /** Source type of the customization */
96
100
  origin: CustomizationSource;
97
101
 
@@ -136,6 +140,9 @@ export class DefaultPromptFragmentCustomizationService implements PromptFragment
136
140
  @inject(ConfigurableInMemoryResources)
137
141
  protected readonly inMemoryResources: ConfigurableInMemoryResources;
138
142
 
143
+ @inject(WorkspaceService)
144
+ protected readonly workspaceService: WorkspaceService;
145
+
139
146
  /** Stores URI strings of template files from directories currently being monitored for changes. */
140
147
  protected trackedTemplateURIs = new Set<string>();
141
148
 
@@ -240,6 +247,7 @@ export class DefaultPromptFragmentCustomizationService implements PromptFragment
240
247
  id,
241
248
  template,
242
249
  sourceUri,
250
+ sourceUris: [sourceUri],
243
251
  priority,
244
252
  customizationId,
245
253
  origin,
@@ -262,6 +270,16 @@ export class DefaultPromptFragmentCustomizationService implements PromptFragment
262
270
  const existingEntry = activeCustomizationsCopy.get(id);
263
271
 
264
272
  if (existingEntry) {
273
+ // If the existing entry was merged from multiple sources and we're
274
+ // operating on the live maps (incremental watcher update), a single
275
+ // file change can't reconstruct the merge correctly. Schedule a
276
+ // full rebuild instead. During update() the maps are fresh locals,
277
+ // so this check won't fire.
278
+ if (existingEntry.sourceUris.length > 1 && activeCustomizationsCopy === this.activeCustomizations) {
279
+ this.update();
280
+ return;
281
+ }
282
+
265
283
  // If this is an update to the same file (same source URI)
266
284
  if (sourceUri && existingEntry.sourceUri === sourceUri) {
267
285
  // Update the content while keeping the same priority and source
@@ -274,9 +292,17 @@ export class DefaultPromptFragmentCustomizationService implements PromptFragment
274
292
  activeCustomizationsCopy.set(id, customization);
275
293
  return;
276
294
  } else if (priority === existingEntry.priority) {
277
- // There is a conflict with the same priority, we ignore the new customization
278
- const conflictSourceUri = existingEntry.sourceUri ? ` (Existing source: ${existingEntry.sourceUri}, New source: ${sourceUri})` : '';
279
- console.warn(`Fragment conflict detected for ID '${id}' with equal priority.${conflictSourceUri}`);
295
+ // Same priority from different sources: concatenate with provenance labels.
296
+ // Build a new object so we don't mutate the entry shared with allCustomizationsCopy.
297
+ const existingLabel = this.provenanceLabel(existingEntry.sourceUri);
298
+ const newLabel = this.provenanceLabel(sourceUri);
299
+ const mergedTemplate = `### ${existingLabel}\n\n${existingEntry.template}\n\n### ${newLabel}\n\n${template}`;
300
+ const mergedEntry: PromptFragmentCustomization = {
301
+ ...existingEntry,
302
+ template: mergedTemplate,
303
+ sourceUris: [...existingEntry.sourceUris, sourceUri],
304
+ };
305
+ activeCustomizationsCopy.set(id, mergedEntry);
280
306
  }
281
307
  return;
282
308
  }
@@ -298,6 +324,24 @@ export class DefaultPromptFragmentCustomizationService implements PromptFragment
298
324
  return `${id}_${sourceHash}`;
299
325
  }
300
326
 
327
+ /**
328
+ * Extracts a human-readable provenance label from a source URI.
329
+ * Returns the name of the workspace root that contains the file,
330
+ * falling back to the file's own base name if it is not inside any root.
331
+ */
332
+ protected provenanceLabel(uri: string): string {
333
+ try {
334
+ const parsed = new URI(uri);
335
+ const rootUri = this.workspaceService.getWorkspaceRootUri(parsed);
336
+ if (rootUri) {
337
+ return rootUri.path.base;
338
+ }
339
+ return parsed.path.dir.base || parsed.path.base || uri;
340
+ } catch {
341
+ return uri;
342
+ }
343
+ }
344
+
301
345
  /**
302
346
  * Simple hash function to generate a short identifier from a string
303
347
  * @param str The string to hash
@@ -27,9 +27,12 @@ import {
27
27
  GenericCapabilitiesVariableContribution,
28
28
  SELECTED_SKILLS_VARIABLE,
29
29
  SELECTED_FUNCTIONS_VARIABLE,
30
+ SELECTED_MCP_FUNCTIONS_VARIABLE,
30
31
  SELECTED_VARIABLES_VARIABLE
31
32
  } from './generic-capabilities-variable-contribution';
32
33
  import { CapabilityAwareContext } from '../common/capability-utils';
34
+ import { ToolInvocationRegistry } from '../common/tool-invocation-registry';
35
+ import { ToolRequest } from '../common/language-model';
33
36
 
34
37
  disableJSDOM();
35
38
 
@@ -39,6 +42,22 @@ describe('GenericCapabilitiesVariableContribution', () => {
39
42
  let contribution: GenericCapabilitiesVariableContribution;
40
43
  let container: Container;
41
44
 
45
+ function createMockToolInvocationRegistry(registeredToolIds: string[]): ToolInvocationRegistry {
46
+ const tools = new Map<string, ToolRequest>();
47
+ for (const id of registeredToolIds) {
48
+ tools.set(id, { id, providerName: 'test', parameters: {}, handler: async () => '' } as unknown as ToolRequest);
49
+ }
50
+ return {
51
+ getFunction: (toolId: string) => tools.get(toolId),
52
+ getFunctions: (...toolIds: string[]) => toolIds.map(id => tools.get(id)).filter((t): t is ToolRequest => t !== undefined),
53
+ registerTool: () => { },
54
+ unregisterTool: () => { },
55
+ unregisterAllTools: () => { },
56
+ getAllFunctions: () => Array.from(tools.values()),
57
+ onDidChange: () => ({ dispose: () => { } }),
58
+ } as unknown as ToolInvocationRegistry;
59
+ }
60
+
42
61
  beforeEach(() => {
43
62
  container = new Container();
44
63
  container.bind<GenericCapabilitiesVariableContribution>(GenericCapabilitiesVariableContribution).toSelf().inSingletonScope();
@@ -80,6 +99,95 @@ describe('GenericCapabilitiesVariableContribution', () => {
80
99
  });
81
100
 
82
101
  describe('resolve', () => {
102
+ describe('resolveSelectedFunctions filtering', () => {
103
+ it('returns empty string when toolInvocationRegistry is not available', async () => {
104
+ const context: CapabilityAwareContext = {
105
+ genericCapabilitySelections: {
106
+ functions: ['tool1', 'tool2']
107
+ }
108
+ };
109
+
110
+ const result = await contribution.resolve(
111
+ { variable: SELECTED_FUNCTIONS_VARIABLE },
112
+ context
113
+ );
114
+
115
+ expect(result?.value).to.equal('');
116
+ });
117
+
118
+ it('filters out stale tool IDs that are not in the registry', async () => {
119
+ const mockRegistry = createMockToolInvocationRegistry(['tool1', 'tool3']);
120
+ (contribution as unknown as { toolInvocationRegistry: ToolInvocationRegistry }).toolInvocationRegistry = mockRegistry;
121
+
122
+ const context: CapabilityAwareContext = {
123
+ genericCapabilitySelections: {
124
+ functions: ['tool1', 'tool2_stale', 'tool3']
125
+ }
126
+ };
127
+
128
+ const result = await contribution.resolve(
129
+ { variable: SELECTED_FUNCTIONS_VARIABLE },
130
+ context
131
+ );
132
+
133
+ expect(result?.value).to.equal('~{tool1}\n~{tool3}');
134
+ });
135
+
136
+ it('returns empty string when all tool IDs are stale', async () => {
137
+ const mockRegistry = createMockToolInvocationRegistry([]);
138
+ (contribution as unknown as { toolInvocationRegistry: ToolInvocationRegistry }).toolInvocationRegistry = mockRegistry;
139
+
140
+ const context: CapabilityAwareContext = {
141
+ genericCapabilitySelections: {
142
+ functions: ['stale1', 'stale2']
143
+ }
144
+ };
145
+
146
+ const result = await contribution.resolve(
147
+ { variable: SELECTED_FUNCTIONS_VARIABLE },
148
+ context
149
+ );
150
+
151
+ expect(result?.value).to.equal('');
152
+ });
153
+
154
+ it('returns all tool references when all IDs are valid', async () => {
155
+ const mockRegistry = createMockToolInvocationRegistry(['tool1', 'tool2']);
156
+ (contribution as unknown as { toolInvocationRegistry: ToolInvocationRegistry }).toolInvocationRegistry = mockRegistry;
157
+
158
+ const context: CapabilityAwareContext = {
159
+ genericCapabilitySelections: {
160
+ functions: ['tool1', 'tool2']
161
+ }
162
+ };
163
+
164
+ const result = await contribution.resolve(
165
+ { variable: SELECTED_FUNCTIONS_VARIABLE },
166
+ context
167
+ );
168
+
169
+ expect(result?.value).to.equal('~{tool1}\n~{tool2}');
170
+ });
171
+
172
+ it('filters stale MCP function IDs the same way', async () => {
173
+ const mockRegistry = createMockToolInvocationRegistry(['mcp_fetch_fetch']);
174
+ (contribution as unknown as { toolInvocationRegistry: ToolInvocationRegistry }).toolInvocationRegistry = mockRegistry;
175
+
176
+ const context: CapabilityAwareContext = {
177
+ genericCapabilitySelections: {
178
+ mcpFunctions: ['mcp_fetch_fetch', 'mcp_removed_server_tool']
179
+ }
180
+ };
181
+
182
+ const result = await contribution.resolve(
183
+ { variable: SELECTED_MCP_FUNCTIONS_VARIABLE },
184
+ context
185
+ );
186
+
187
+ expect(result?.value).to.equal('~{mcp_fetch_fetch}');
188
+ });
189
+ });
190
+
83
191
  it('returns empty string when no selections exist', async () => {
84
192
  const context: CapabilityAwareContext = {};
85
193
 
@@ -27,7 +27,8 @@ import {
27
27
  ResolvedAIVariable,
28
28
  CapabilityAwareContext,
29
29
  AgentService,
30
- AgentsVariableContribution
30
+ AgentsVariableContribution,
31
+ ToolInvocationRegistry
31
32
  } from '../common';
32
33
  import { PromptVariableContribution } from './prompt-variable-contribution';
33
34
  import { SkillService } from './skill-service';
@@ -105,6 +106,9 @@ export class GenericCapabilitiesVariableContribution implements AIVariableContri
105
106
  @inject(AgentsVariableContribution) @optional()
106
107
  protected readonly agentsContribution: AgentsVariableContribution | undefined;
107
108
 
109
+ @inject(ToolInvocationRegistry) @optional()
110
+ protected readonly toolInvocationRegistry: ToolInvocationRegistry | undefined;
111
+
108
112
  @inject(PromptVariableContribution) @optional()
109
113
  protected readonly promptContribution: PromptVariableContribution | undefined;
110
114
 
@@ -170,12 +174,20 @@ export class GenericCapabilitiesVariableContribution implements AIVariableContri
170
174
  * The chat request parser will pick these up and add them to the toolRequests map.
171
175
  */
172
176
  protected resolveSelectedFunctions(variable: AIVariable, functionIds: string[] | undefined): ResolvedAIVariable {
173
- if (!functionIds || functionIds.length === 0) {
177
+ if (!functionIds || functionIds.length === 0 || !this.toolInvocationRegistry) {
178
+ return { variable, value: '' };
179
+ }
180
+
181
+ // Filter out stale/orphaned tool IDs that no longer exist in the registry
182
+ const validIds = functionIds
183
+ .filter(id => this.toolInvocationRegistry!.getFunction(id) !== undefined);
184
+
185
+ if (validIds.length === 0) {
174
186
  return { variable, value: '' };
175
187
  }
176
188
 
177
189
  // Output function references in ~{id} format so the chat parser picks them up
178
- const functionRefs = functionIds.map(id => `~{${id}}`).join('\n');
190
+ const functionRefs = validIds.map(id => `~{${id}}`).join('\n');
179
191
  return { variable, value: functionRefs };
180
192
  }
181
193
 
@@ -23,7 +23,8 @@ import { AIVariable, ResolvedAIVariable, AIVariableContribution, AIVariableResol
23
23
 
24
24
  export const OPEN_EDITORS_VARIABLE: AIVariable = {
25
25
  id: 'openEditors',
26
- description: nls.localize('theia/ai/core/openEditorsVariable/description', 'A comma-separated list of all currently open files, relative to the workspace root.'),
26
+ description: nls.localize('theia/ai/core/openEditorsVariable/description',
27
+ 'A comma-separated list of all currently open files as workspace-relative paths (e.g., my-project/src/index.ts).'),
27
28
  name: 'openEditors',
28
29
  };
29
30
 
@@ -80,9 +81,7 @@ export class OpenEditorsVariableContribution implements AIVariableContribution,
80
81
  return openFiles.join(', ');
81
82
  }
82
83
 
83
- protected getWorkspaceRelativePath(uri: URI): string | undefined {
84
- const workspaceRootUri = this.workspaceService.getWorkspaceRootUri(uri);
85
- const path = workspaceRootUri && workspaceRootUri.path.relative(uri.path);
86
- return path && path.toString();
84
+ protected getWorkspaceRelativePath(uri: URI): string {
85
+ return this.workspaceService.getRootPrefixedPath(uri);
87
86
  }
88
87
  }
@@ -171,7 +171,7 @@ export class DefaultSkillService implements SkillService {
171
171
  const newDisposables = new DisposableCollection();
172
172
  const newSkills = new Map<string, Skill>();
173
173
 
174
- const workspaceSkillsDir = this.getWorkspaceSkillsDirectoryPath();
174
+ const workspaceSkillsDirs = this.getWorkspaceSkillsDirectoryPaths();
175
175
 
176
176
  const homeDirUri = await this.envVariablesServer.getHomeDirUri();
177
177
  const homePath = new URI(homeDirUri).path.fsPath();
@@ -183,7 +183,7 @@ export class DefaultSkillService implements SkillService {
183
183
  const newWatchedDirectories = new Set<string>();
184
184
  const newParentWatchers = new Map<string, string>();
185
185
 
186
- if (workspaceSkillsDir) {
186
+ for (const workspaceSkillsDir of workspaceSkillsDirs) {
187
187
  await this.processSkillDirectoryWithParentWatching(
188
188
  workspaceSkillsDir,
189
189
  newSkills,
@@ -223,13 +223,9 @@ export class DefaultSkillService implements SkillService {
223
223
  this.onSkillsChangedEmitter.fire();
224
224
  }
225
225
 
226
- protected getWorkspaceSkillsDirectoryPath(): string | undefined {
227
- const roots = this.workspaceService.tryGetRoots();
228
- if (roots.length === 0) {
229
- return undefined;
230
- }
231
- // Use primary workspace root
232
- return roots[0].resource.resolve('.prompts/skills').path.fsPath();
226
+ protected getWorkspaceSkillsDirectoryPaths(): string[] {
227
+ return this.workspaceService.tryGetRoots()
228
+ .map(root => root.resource.resolve('.prompts/skills').path.fsPath());
233
229
  }
234
230
 
235
231
  protected async getDefaultSkillsDirectoryPath(): Promise<string> {
@@ -14,6 +14,7 @@
14
14
  // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
15
  // *****************************************************************************
16
16
  import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state';
17
+ import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
17
18
  import { nls } from '@theia/core/lib/common/nls';
18
19
  import { inject, injectable } from '@theia/core/shared/inversify';
19
20
  import { VariableRegistry, VariableResolverService } from '@theia/variable-resolver/lib/browser';
@@ -51,41 +52,39 @@ export class TheiaVariableContribution implements AIVariableContribution, AIVari
51
52
  {
52
53
  name: 'currentAbsoluteFilePath',
53
54
  description: nls.localize('theia/ai/core/variable-contribution/currentAbsoluteFilePath', 'The absolute path of the \
54
- currently opened file. Please note that most agents will expect a relative file path (relative to the current workspace).')
55
+ currently opened file.')
55
56
  }
56
57
  ]],
57
58
  ['selectedText', [
58
59
  {
59
60
  description: nls.localize('theia/ai/core/variable-contribution/currentSelectedText', 'The plain text that is currently selected in the \
60
- opened file. This excludes the information where the content is coming from. Please note that most agents will work better with a relative file path \
61
- (relative to the current workspace).')
61
+ opened file. This excludes the information where the content is coming from.')
62
62
  }
63
63
  ]],
64
64
  ['currentText', [
65
65
  {
66
66
  name: 'currentFileContent',
67
67
  description: nls.localize('theia/ai/core/variable-contribution/currentFileContent', 'The plain content of the \
68
- currently opened file. This excludes the information where the content is coming from. Please note that most agents will work better with a relative file path \
69
- (relative to the current workspace).')
68
+ currently opened file. This excludes the information where the content is coming from.')
70
69
  }
71
70
  ]],
72
71
  ['relativeFile', [
73
72
  {
74
73
  name: 'currentRelativeFilePath',
75
- description: nls.localize('theia/ai/core/variable-contribution/currentRelativeFilePath', 'The relative path of the \
76
- currently opened file.')
74
+ description: nls.localize('theia/ai/core/variable-contribution/currentRelativeFilePath', 'The workspace-relative path of the \
75
+ currently opened file (e.g., my-project/src/index.ts).')
77
76
  },
78
77
  {
79
78
  name: '_f',
80
- description: nls.localize('theia/ai/core/variable-contribution/dotRelativePath', 'Short reference to the relative path of the \
79
+ description: nls.localize('theia/ai/core/variable-contribution/dotRelativePath', 'Short reference to the workspace-relative path of the \
81
80
  currently opened file (\'currentRelativeFilePath\').')
82
81
  }
83
82
  ]],
84
83
  ['relativeFileDirname', [
85
84
  {
86
85
  name: 'currentRelativeDirPath',
87
- description: nls.localize('theia/ai/core/variable-contribution/currentRelativeDirPath', 'The relative path of the directory \
88
- containing the currently opened file.')
86
+ description: nls.localize('theia/ai/core/variable-contribution/currentRelativeDirPath', 'The workspace-relative path of the directory \
87
+ containing the currently opened file (e.g., my-project/src).')
89
88
  }
90
89
  ]],
91
90
  ['lineNumber', [{}]],
@@ -108,7 +107,9 @@ export class TheiaVariableContribution implements AIVariableContribution, AIVari
108
107
  const newName = (mapping.name && mapping.name.trim() !== '') ? mapping.name : variable.name;
109
108
  const newDescription = (mapping.description && mapping.description.trim() !== '') ? mapping.description
110
109
  : (variable.description && variable.description.trim() !== '' ? variable.description
111
- : nls.localize('theia/ai/core/variable-contribution/builtInVariable', 'Theia Built-in Variable'));
110
+ : nls.localize('theia/ai/core/variable-contribution/builtInVariable',
111
+ '{0} Built-in Variable',
112
+ FrontendApplicationConfigProvider.get().applicationName));
112
113
 
113
114
  // For multiple mappings of the same variable, add a suffix to the ID to make it unique
114
115
  const idSuffix = mappings.length > 1 ? `-${index}` : '';