@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.
- package/lib/browser/file-variable-contribution.d.ts.map +1 -1
- package/lib/browser/file-variable-contribution.js +17 -2
- package/lib/browser/file-variable-contribution.js.map +1 -1
- package/lib/browser/frontend-prompt-customization-service.d.ts +11 -1
- package/lib/browser/frontend-prompt-customization-service.d.ts.map +1 -1
- package/lib/browser/frontend-prompt-customization-service.js +44 -3
- package/lib/browser/frontend-prompt-customization-service.js.map +1 -1
- package/lib/browser/frontend-prompt-customization-service.spec.d.ts +1 -1
- package/lib/browser/frontend-prompt-customization-service.spec.d.ts.map +1 -1
- package/lib/browser/frontend-prompt-customization-service.spec.js +129 -0
- package/lib/browser/frontend-prompt-customization-service.spec.js.map +1 -1
- package/lib/browser/generic-capabilities-variable-contribution.d.ts +2 -1
- package/lib/browser/generic-capabilities-variable-contribution.d.ts.map +1 -1
- package/lib/browser/generic-capabilities-variable-contribution.js +13 -2
- package/lib/browser/generic-capabilities-variable-contribution.js.map +1 -1
- package/lib/browser/generic-capabilities-variable-contribution.spec.js +70 -0
- package/lib/browser/generic-capabilities-variable-contribution.spec.js.map +1 -1
- package/lib/browser/open-editors-variable-contribution.d.ts +1 -1
- package/lib/browser/open-editors-variable-contribution.d.ts.map +1 -1
- package/lib/browser/open-editors-variable-contribution.js +2 -4
- package/lib/browser/open-editors-variable-contribution.js.map +1 -1
- package/lib/browser/skill-service.d.ts +1 -1
- package/lib/browser/skill-service.d.ts.map +1 -1
- package/lib/browser/skill-service.js +5 -9
- package/lib/browser/skill-service.js.map +1 -1
- package/lib/browser/theia-variable-contribution.d.ts.map +1 -1
- package/lib/browser/theia-variable-contribution.js +10 -11
- package/lib/browser/theia-variable-contribution.js.map +1 -1
- package/lib/browser/window-blink-service.d.ts.map +1 -1
- package/lib/browser/window-blink-service.js +4 -2
- package/lib/browser/window-blink-service.js.map +1 -1
- package/lib/common/capability-utils.d.ts +0 -2
- package/lib/common/capability-utils.d.ts.map +1 -1
- package/lib/common/capability-utils.js.map +1 -1
- package/package.json +11 -11
- package/src/browser/file-variable-contribution.ts +21 -2
- package/src/browser/frontend-prompt-customization-service.spec.ts +199 -0
- package/src/browser/frontend-prompt-customization-service.ts +48 -4
- package/src/browser/generic-capabilities-variable-contribution.spec.ts +108 -0
- package/src/browser/generic-capabilities-variable-contribution.ts +15 -3
- package/src/browser/open-editors-variable-contribution.ts +4 -5
- package/src/browser/skill-service.ts +5 -9
- package/src/browser/theia-variable-contribution.ts +12 -11
- package/src/browser/window-blink-service.ts +4 -2
- 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:
|
|
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
|
|
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
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
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 =
|
|
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',
|
|
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
|
|
84
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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',
|
|
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}` : '';
|