@theia/ai-core 1.75.0-next.8 → 1.75.0

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 (134) hide show
  1. package/lib/browser/agent-plugin-ui-bridge.d.ts +26 -0
  2. package/lib/browser/agent-plugin-ui-bridge.d.ts.map +1 -0
  3. package/lib/browser/agent-plugin-ui-bridge.js +20 -0
  4. package/lib/browser/agent-plugin-ui-bridge.js.map +1 -0
  5. package/lib/browser/ai-core-command-contribution.d.ts.map +1 -1
  6. package/lib/browser/ai-core-command-contribution.js +4 -1
  7. package/lib/browser/ai-core-command-contribution.js.map +1 -1
  8. package/lib/browser/ai-core-frontend-module.d.ts.map +1 -1
  9. package/lib/browser/ai-core-frontend-module.js +6 -0
  10. package/lib/browser/ai-core-frontend-module.js.map +1 -1
  11. package/lib/browser/ai-settings-service.js +1 -0
  12. package/lib/browser/ai-settings-service.js.map +1 -1
  13. package/lib/browser/frontend-language-model-alias-registry.js +1 -1
  14. package/lib/browser/frontend-language-model-registry.d.ts +2 -0
  15. package/lib/browser/frontend-language-model-registry.d.ts.map +1 -1
  16. package/lib/browser/frontend-language-model-registry.js +22 -1
  17. package/lib/browser/frontend-language-model-registry.js.map +1 -1
  18. package/lib/browser/generic-capabilities-variable-contribution.d.ts.map +1 -1
  19. package/lib/browser/generic-capabilities-variable-contribution.js +4 -1
  20. package/lib/browser/generic-capabilities-variable-contribution.js.map +1 -1
  21. package/lib/browser/hide-ai-preferences-contribution.d.ts +30 -0
  22. package/lib/browser/hide-ai-preferences-contribution.d.ts.map +1 -0
  23. package/lib/browser/hide-ai-preferences-contribution.js +90 -0
  24. package/lib/browser/hide-ai-preferences-contribution.js.map +1 -0
  25. package/lib/browser/hide-ai-preferences-contribution.spec.d.ts +2 -0
  26. package/lib/browser/hide-ai-preferences-contribution.spec.d.ts.map +1 -0
  27. package/lib/browser/hide-ai-preferences-contribution.spec.js +95 -0
  28. package/lib/browser/hide-ai-preferences-contribution.spec.js.map +1 -0
  29. package/lib/browser/index.d.ts +3 -0
  30. package/lib/browser/index.d.ts.map +1 -1
  31. package/lib/browser/index.js +3 -0
  32. package/lib/browser/index.js.map +1 -1
  33. package/lib/browser/prompt-variable-contribution.d.ts +1 -1
  34. package/lib/browser/prompt-variable-contribution.d.ts.map +1 -1
  35. package/lib/browser/prompt-variable-contribution.js +1 -0
  36. package/lib/browser/prompt-variable-contribution.js.map +1 -1
  37. package/lib/browser/skill-prompt-coordinator.d.ts +9 -1
  38. package/lib/browser/skill-prompt-coordinator.d.ts.map +1 -1
  39. package/lib/browser/skill-prompt-coordinator.js +29 -14
  40. package/lib/browser/skill-prompt-coordinator.js.map +1 -1
  41. package/lib/browser/skill-prompt-coordinator.spec.d.ts +2 -0
  42. package/lib/browser/skill-prompt-coordinator.spec.d.ts.map +1 -0
  43. package/lib/browser/skill-prompt-coordinator.spec.js +98 -0
  44. package/lib/browser/skill-prompt-coordinator.spec.js.map +1 -0
  45. package/lib/browser/skill-registry-ui-bridge.d.ts +25 -0
  46. package/lib/browser/skill-registry-ui-bridge.d.ts.map +1 -0
  47. package/lib/browser/skill-registry-ui-bridge.js +20 -0
  48. package/lib/browser/skill-registry-ui-bridge.js.map +1 -0
  49. package/lib/browser/skill-service.d.ts +33 -8
  50. package/lib/browser/skill-service.d.ts.map +1 -1
  51. package/lib/browser/skill-service.js +95 -39
  52. package/lib/browser/skill-service.js.map +1 -1
  53. package/lib/browser/skill-service.spec.js +181 -2
  54. package/lib/browser/skill-service.spec.js.map +1 -1
  55. package/lib/browser/skills-variable-contribution.d.ts +4 -0
  56. package/lib/browser/skills-variable-contribution.d.ts.map +1 -1
  57. package/lib/browser/skills-variable-contribution.js +10 -1
  58. package/lib/browser/skills-variable-contribution.js.map +1 -1
  59. package/lib/browser/skills-variable-contribution.spec.js +6 -0
  60. package/lib/browser/skills-variable-contribution.spec.js.map +1 -1
  61. package/lib/browser/theia-variable-contribution.js +6 -12
  62. package/lib/browser/theia-variable-contribution.js.map +1 -1
  63. package/lib/common/ai-core-preferences.d.ts +17 -0
  64. package/lib/common/ai-core-preferences.d.ts.map +1 -1
  65. package/lib/common/ai-core-preferences.js +9 -1
  66. package/lib/common/ai-core-preferences.js.map +1 -1
  67. package/lib/common/ai-variable-resource.d.ts +2 -1
  68. package/lib/common/ai-variable-resource.d.ts.map +1 -1
  69. package/lib/common/ai-variable-resource.js +6 -1
  70. package/lib/common/ai-variable-resource.js.map +1 -1
  71. package/lib/common/capability-variable-contribution.d.ts +1 -1
  72. package/lib/common/capability-variable-contribution.d.ts.map +1 -1
  73. package/lib/common/capability-variable-contribution.js +1 -0
  74. package/lib/common/capability-variable-contribution.js.map +1 -1
  75. package/lib/common/language-model.d.ts +16 -2
  76. package/lib/common/language-model.d.ts.map +1 -1
  77. package/lib/common/language-model.js +39 -3
  78. package/lib/common/language-model.js.map +1 -1
  79. package/lib/common/language-model.spec.js +75 -0
  80. package/lib/common/language-model.spec.js.map +1 -1
  81. package/lib/common/prompt-service.d.ts.map +1 -1
  82. package/lib/common/prompt-service.js +7 -4
  83. package/lib/common/prompt-service.js.map +1 -1
  84. package/lib/common/prompt-service.spec.js +36 -0
  85. package/lib/common/prompt-service.spec.js.map +1 -1
  86. package/lib/common/skill.d.ts +31 -7
  87. package/lib/common/skill.d.ts.map +1 -1
  88. package/lib/common/skill.js +37 -14
  89. package/lib/common/skill.js.map +1 -1
  90. package/lib/common/skill.spec.js +70 -0
  91. package/lib/common/skill.spec.js.map +1 -1
  92. package/lib/common/today-variable-contribution.d.ts.map +1 -1
  93. package/lib/common/today-variable-contribution.js +1 -1
  94. package/lib/common/today-variable-contribution.js.map +1 -1
  95. package/lib/common/tool-invocation-registry.d.ts +2 -1
  96. package/lib/common/tool-invocation-registry.d.ts.map +1 -1
  97. package/lib/common/tool-invocation-registry.js +6 -1
  98. package/lib/common/tool-invocation-registry.js.map +1 -1
  99. package/lib/node/language-model-frontend-delegate.d.ts +2 -2
  100. package/lib/node/language-model-frontend-delegate.d.ts.map +1 -1
  101. package/lib/node/language-model-frontend-delegate.js +1 -0
  102. package/lib/node/language-model-frontend-delegate.js.map +1 -1
  103. package/package.json +12 -12
  104. package/src/browser/agent-plugin-ui-bridge.ts +43 -0
  105. package/src/browser/ai-core-command-contribution.ts +7 -1
  106. package/src/browser/ai-core-frontend-module.ts +11 -2
  107. package/src/browser/ai-settings-service.ts +2 -2
  108. package/src/browser/frontend-language-model-alias-registry.ts +1 -1
  109. package/src/browser/frontend-language-model-registry.ts +6 -2
  110. package/src/browser/generic-capabilities-variable-contribution.ts +4 -1
  111. package/src/browser/hide-ai-preferences-contribution.spec.ts +117 -0
  112. package/src/browser/hide-ai-preferences-contribution.ts +88 -0
  113. package/src/browser/index.ts +3 -0
  114. package/src/browser/prompt-variable-contribution.ts +3 -3
  115. package/src/browser/skill-prompt-coordinator.spec.ts +114 -0
  116. package/src/browser/skill-prompt-coordinator.ts +29 -17
  117. package/src/browser/skill-registry-ui-bridge.ts +41 -0
  118. package/src/browser/skill-service.spec.ts +229 -4
  119. package/src/browser/skill-service.ts +120 -43
  120. package/src/browser/skills-variable-contribution.spec.ts +6 -0
  121. package/src/browser/skills-variable-contribution.ts +14 -4
  122. package/src/browser/theia-variable-contribution.ts +12 -12
  123. package/src/common/ai-core-preferences.ts +19 -0
  124. package/src/common/ai-variable-resource.ts +6 -3
  125. package/src/common/capability-variable-contribution.ts +3 -3
  126. package/src/common/language-model.spec.ts +83 -0
  127. package/src/common/language-model.ts +35 -5
  128. package/src/common/prompt-service.spec.ts +43 -0
  129. package/src/common/prompt-service.ts +8 -6
  130. package/src/common/skill.spec.ts +99 -0
  131. package/src/common/skill.ts +52 -17
  132. package/src/common/today-variable-contribution.ts +2 -1
  133. package/src/common/tool-invocation-registry.ts +5 -2
  134. package/src/node/language-model-frontend-delegate.ts +3 -3
@@ -14,7 +14,8 @@
14
14
  // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
15
  // *****************************************************************************
16
16
 
17
- import { inject, injectable } from '@theia/core/shared/inversify';
17
+ import { inject, injectable, named } from '@theia/core/shared/inversify';
18
+ import { ILogger } from '@theia/core';
18
19
  import { FrontendApplicationContribution } from '@theia/core/lib/browser';
19
20
  import { SkillService } from './skill-service';
20
21
  import { PromptService } from '../common/prompt-service';
@@ -28,45 +29,56 @@ export class SkillPromptCoordinator implements FrontendApplicationContribution {
28
29
  @inject(PromptService)
29
30
  protected readonly promptService: PromptService;
30
31
 
31
- protected registeredSkillCommands = new Set<string>();
32
-
33
- async onStart(): Promise<void> {
34
- // Wait for skills to be loaded before registering commands
35
- await this.skillService.ready;
32
+ @inject(ILogger) @named('ai-core:SkillPromptCoordinator')
33
+ protected readonly logger: ILogger;
36
34
 
37
- // Register initial skills
38
- this.updateSkillCommands();
35
+ protected registeredSkillCommands = new Set<string>();
39
36
 
40
- // Listen for skill changes
41
- this.skillService.onSkillsChanged(() => {
37
+ onStart(): void {
38
+ this.skillService.ready.then(() => {
39
+ this.skillService.onSkillsChanged(() => {
40
+ this.updateSkillCommands();
41
+ });
42
42
  this.updateSkillCommands();
43
+ }).catch(error => {
44
+ this.logger.error('Failed to update skill commands', error);
43
45
  });
44
46
  }
45
47
 
46
48
  protected updateSkillCommands(): void {
47
49
  const currentSkills = this.skillService.getSkills();
48
- const currentSkillNames = new Set(currentSkills.map(s => s.name));
50
+ // Keyed by qualified name: two skills owned by different plugins can share a plain name.
51
+ const currentSkillNames = new Set(currentSkills.map(s => s.qualifiedName));
49
52
 
50
53
  // Unregister removed skills
51
54
  for (const name of this.registeredSkillCommands) {
52
55
  if (!currentSkillNames.has(name)) {
53
- this.promptService.removePromptFragment(`skill-command-${name}`);
56
+ this.promptService.removePromptFragment(this.fragmentId(name));
54
57
  this.registeredSkillCommands.delete(name);
55
58
  }
56
59
  }
57
60
 
58
61
  // Register new skills
59
62
  for (const skill of currentSkills) {
60
- if (!this.registeredSkillCommands.has(skill.name)) {
63
+ if (!this.registeredSkillCommands.has(skill.qualifiedName)) {
61
64
  this.promptService.addBuiltInPromptFragment({
62
- id: `skill-command-${skill.name}`,
63
- template: `Load the skill ${skill.name} using ~{getSkillFileContent}.`,
65
+ id: this.fragmentId(skill.qualifiedName),
66
+ template: `Load the skill ${skill.qualifiedName} using ~{getSkillFileContent}.`,
64
67
  isCommand: true,
65
- commandName: skill.name,
68
+ commandName: skill.qualifiedName,
66
69
  commandDescription: skill.description
67
70
  });
68
- this.registeredSkillCommands.add(skill.name);
71
+ this.registeredSkillCommands.add(skill.qualifiedName);
69
72
  }
70
73
  }
71
74
  }
75
+
76
+ /**
77
+ * A fragment id becomes a file name when the user customizes the fragment, so the qualifier's `:`
78
+ * cannot survive into it - it is reserved on Windows. Only the id is sanitized; the command keeps
79
+ * the qualified name.
80
+ */
81
+ protected fragmentId(qualifiedName: string): string {
82
+ return `skill-command-${qualifiedName.replace(/:/g, '__')}`;
83
+ }
72
84
  }
@@ -0,0 +1,41 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH and others.
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 { Event } from '@theia/core';
18
+ import { Skill } from '../common/skill';
19
+
20
+ export const SkillRegistryUiBridge = Symbol('SkillRegistryUiBridge');
21
+ /**
22
+ * Optional integration point implemented by `@theia/ai-registry`, so that packages which must not
23
+ * depend on it can tell which skills it manages and reveal them in the registry. The counterpart of
24
+ * {@link AgentPluginUiBridge} for skills installed or linked directly, rather than contributed by a
25
+ * plugin.
26
+ *
27
+ * Nothing is bound by default. Consumers inject it `@optional()` and, when it is absent, hide every
28
+ * registry affordance - a product without `@theia/ai-registry` has no registry to install from.
29
+ */
30
+ export interface SkillRegistryUiBridge {
31
+ /**
32
+ * The registry entry `skill` was installed from or linked to, or `undefined` for a skill the
33
+ * registry does not manage - a workspace skill, a configured directory, or one contributed by an
34
+ * Agent Plugin. Takes the whole skill rather than its name: a name alone cannot tell a managed
35
+ * skill from one of the same name in a directory the user controls.
36
+ */
37
+ getRegistryEntryId(skill: Skill): string | undefined;
38
+ /** Reveals the entry in the registry UI. A no-op for a skill the registry does not manage. */
39
+ revealSkill(skillId: string): void;
40
+ readonly onDidChange: Event<void>;
41
+ }
@@ -18,15 +18,22 @@ import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
18
18
  const disableJSDOM = enableJSDOM();
19
19
 
20
20
  import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
21
- FrontendApplicationConfigProvider.set({});
21
+ // Guarded: another spec in the same mocha process may already have set it, and `set` throws on a
22
+ // second call.
23
+ try {
24
+ FrontendApplicationConfigProvider.get();
25
+ } catch {
26
+ FrontendApplicationConfigProvider.set({});
27
+ }
22
28
 
23
29
  import { expect } from 'chai';
24
30
  import * as sinon from 'sinon';
25
- import { parseSkillFile, combineSkillDirectories } from '../common/skill';
31
+ import { parseSkillFile, combineSkillDirectories, Skill, SkillDirectoryEntry } from '../common/skill';
26
32
  import { Path } from '@theia/core/lib/common/path';
27
33
  import { Disposable, Emitter, ILogger, Logger, URI } from '@theia/core';
34
+ import { Deferred } from '@theia/core/lib/common/promise-util';
28
35
  import { FileChangesEvent } from '@theia/filesystem/lib/common/files';
29
- import { DefaultSkillService } from './skill-service';
36
+ import { DefaultSkillService, SkillDirectoryContribution } from './skill-service';
30
37
 
31
38
  disableJSDOM();
32
39
 
@@ -322,8 +329,11 @@ description: Skill with no content
322
329
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
323
330
  let preferenceChangedEmitter: Emitter<any>;
324
331
 
325
- function createService(): DefaultSkillService {
332
+ function createService(contributions: SkillDirectoryContribution[] = []): DefaultSkillService {
326
333
  const service = new DefaultSkillService();
334
+ (service as unknown as { skillDirectoryContributions: unknown }).skillDirectoryContributions = {
335
+ getContributions: () => contributions
336
+ };
327
337
  (service as unknown as { preferences: unknown }).preferences = preferencesMock;
328
338
  (service as unknown as { fileService: unknown }).fileService = fileServiceMock;
329
339
  const loggerMock: ILogger = sinon.createStubInstance(Logger);
@@ -365,6 +375,7 @@ description: Skill with no content
365
375
 
366
376
  preferencesMock = {
367
377
  'ai-features.skills.skillDirectories': [],
378
+ ready: Promise.resolve(),
368
379
  onPreferenceChanged: preferenceChangedEmitter.event
369
380
  };
370
381
  });
@@ -487,6 +498,41 @@ description: Skill with no content
487
498
  )).to.be.true;
488
499
  });
489
500
 
501
+ it('should scan configured directories that only become known once the preferences are ready', async () => {
502
+ // The preference proxy serves defaults until its `ready` promise resolves, so a scan started before
503
+ // that point misses the configured directories.
504
+ const preferencesReady = new Deferred<void>();
505
+ preferencesMock.ready = preferencesReady.promise;
506
+
507
+ // Default skills directory exists and is empty, so the configured directory is the only source of warnings.
508
+ fileServiceMock.exists
509
+ .withArgs(sinon.match((uri: URI) => uri.path.toString() === '/home/testuser/.theia-ide/skills'))
510
+ .resolves(true);
511
+ fileServiceMock.resolve
512
+ .withArgs(sinon.match((uri: URI) => uri.path.toString() === '/home/testuser/.theia-ide/skills'))
513
+ .resolves({ children: [] });
514
+ fileServiceMock.exists
515
+ .withArgs(sinon.match((uri: URI) => uri.path.toString() === '/custom/configured/skills'))
516
+ .resolves(false);
517
+
518
+ const service = createService();
519
+ (service as unknown as { init: () => void }).init();
520
+ await workspaceServiceMock.ready;
521
+ await new Promise(resolve => setTimeout(resolve, 10));
522
+
523
+ // Nothing may be scanned yet, the preference value is still the default one.
524
+ expect(fileServiceMock.exists.called).to.be.false;
525
+
526
+ (preferencesMock as Record<string, unknown>)['ai-features.skills.skillDirectories'] = ['/custom/configured/skills'];
527
+ preferencesReady.resolve();
528
+ await new Promise(resolve => setTimeout(resolve, 10));
529
+
530
+ // The scan picked up the configured directory instead of the default preference value.
531
+ expect(loggerWarnSpy.calledWith(
532
+ sinon.match(/Configured skill directory '\/custom\/configured\/skills' does not exist/)
533
+ )).to.be.true;
534
+ });
535
+
490
536
  it('should load skills when directory is created after initialization', async () => {
491
537
  const service = createService();
492
538
 
@@ -630,5 +676,184 @@ Test skill content`
630
676
  sinon.match(/Duplicate skill found.*dup-skill/)
631
677
  )).to.be.true;
632
678
  });
679
+
680
+ describe('contributed skill directories', () => {
681
+
682
+ const bigQueryQualifier = 'bigquery-data-analytics';
683
+ const athenaQualifier = 'athena-analytics';
684
+
685
+ /** A contribution returning a fixed set of roots. */
686
+ function contributing(...entries: SkillDirectoryEntry[]): SkillDirectoryContribution {
687
+ return { getSkillDirectories: () => entries };
688
+ }
689
+
690
+ /**
691
+ * Stubs `directory` as an existing skills root holding one skill directory per given name,
692
+ * each with a valid `SKILL.md` whose description names the root it came from.
693
+ */
694
+ function stubSkillsRoot(directory: string, ...skillNames: string[]): void {
695
+ fileServiceMock.exists
696
+ .withArgs(sinon.match((uri: URI) => uri.path.toString() === directory))
697
+ .resolves(true);
698
+ fileServiceMock.resolve
699
+ .withArgs(sinon.match((uri: URI) => uri.path.toString() === directory))
700
+ .resolves({
701
+ children: skillNames.map(name => ({
702
+ isDirectory: true,
703
+ name,
704
+ resource: URI.fromFilePath(`${directory}/${name}`)
705
+ }))
706
+ });
707
+ for (const name of skillNames) {
708
+ const skillFile = `${directory}/${name}/SKILL.md`;
709
+ fileServiceMock.exists
710
+ .withArgs(sinon.match((uri: URI) => uri.path.toString() === skillFile))
711
+ .resolves(true);
712
+ fileServiceMock.read
713
+ .withArgs(sinon.match((uri: URI) => uri.path.toString() === skillFile))
714
+ .resolves({ value: `---\nname: ${name}\ndescription: from ${directory}\n---\nBody of ${name}` });
715
+ }
716
+ }
717
+
718
+ async function initialize(service: DefaultSkillService): Promise<void> {
719
+ (service as unknown as { init: () => void }).init();
720
+ await workspaceServiceMock.ready;
721
+ await new Promise(resolve => setTimeout(resolve, 10));
722
+ }
723
+
724
+ it('qualifies the names of skills found under an owned contributed root', async () => {
725
+ const pluginSkills = '/home/testuser/.agents/plugins/bigquery/skills';
726
+ stubSkillsRoot(pluginSkills, 'query-builder');
727
+
728
+ const service = createService([contributing({ path: pluginSkills, tier: 'plugin', qualifier: bigQueryQualifier })]);
729
+ await initialize(service);
730
+
731
+ const skills = service.getSkills();
732
+ expect(skills).to.have.length(1);
733
+ expect(skills[0].name).to.equal('query-builder');
734
+ expect(skills[0].qualifiedName).to.equal('bigquery-data-analytics:query-builder');
735
+ expect(Skill.qualifierOf(skills[0])).to.equal('bigquery-data-analytics');
736
+ });
737
+
738
+ it('leaves the qualified name of a skill from a built-in root equal to its plain name', async () => {
739
+ stubSkillsRoot('/home/testuser/.theia-ide/skills', 'code-review');
740
+
741
+ const service = createService();
742
+ await initialize(service);
743
+
744
+ const skills = service.getSkills();
745
+ expect(skills).to.have.length(1);
746
+ expect(skills[0].qualifiedName).to.equal('code-review');
747
+ expect(Skill.qualifierOf(skills[0])).to.be.undefined;
748
+ });
749
+
750
+ it('resolves an owned skill by its qualified name and by its plain name', async () => {
751
+ const pluginSkills = '/home/testuser/.agents/plugins/bigquery/skills';
752
+ stubSkillsRoot(pluginSkills, 'query-builder');
753
+
754
+ const service = createService([contributing({ path: pluginSkills, tier: 'plugin', qualifier: bigQueryQualifier })]);
755
+ await initialize(service);
756
+
757
+ expect(service.getSkill('bigquery-data-analytics:query-builder')?.name).to.equal('query-builder');
758
+ // The plain name keeps working, so every reference written before the skill was owned
759
+ // still resolves.
760
+ expect(service.getSkill('query-builder')?.qualifiedName).to.equal('bigquery-data-analytics:query-builder');
761
+ });
762
+
763
+ it('loads a skill of the same name from two different plugins and keeps both addressable', async () => {
764
+ const bigQuerySkills = '/home/testuser/.agents/plugins/bigquery/skills';
765
+ const athenaSkills = '/home/testuser/.agents/plugins/athena/skills';
766
+ stubSkillsRoot(bigQuerySkills, 'query-builder');
767
+ stubSkillsRoot(athenaSkills, 'query-builder');
768
+
769
+ const service = createService([contributing(
770
+ { path: bigQuerySkills, tier: 'plugin', qualifier: bigQueryQualifier },
771
+ { path: athenaSkills, tier: 'plugin', qualifier: athenaQualifier }
772
+ )]);
773
+ await initialize(service);
774
+
775
+ expect(service.getSkills()).to.have.length(2);
776
+ expect(service.getSkill('bigquery-data-analytics:query-builder')?.description).to.equal(`from ${bigQuerySkills}`);
777
+ expect(service.getSkill('athena-analytics:query-builder')?.description).to.equal(`from ${athenaSkills}`);
778
+ // Neither is dropped as a duplicate, because they no longer share a name.
779
+ expect(loggerWarnSpy.calledWith(sinon.match(/Duplicate skill found/))).to.be.false;
780
+ });
781
+
782
+ it('resolves an ambiguous plain name to undefined rather than guessing one of the candidates', async () => {
783
+ const bigQuerySkills = '/home/testuser/.agents/plugins/bigquery/skills';
784
+ const athenaSkills = '/home/testuser/.agents/plugins/athena/skills';
785
+ stubSkillsRoot(bigQuerySkills, 'query-builder');
786
+ stubSkillsRoot(athenaSkills, 'query-builder');
787
+
788
+ const service = createService([contributing(
789
+ { path: bigQuerySkills, tier: 'plugin', qualifier: bigQueryQualifier },
790
+ { path: athenaSkills, tier: 'plugin', qualifier: athenaQualifier }
791
+ )]);
792
+ await initialize(service);
793
+
794
+ expect(service.getSkill('query-builder')).to.be.undefined;
795
+ });
796
+
797
+ it('keeps loading the remaining roots when one contribution fails', async () => {
798
+ const pluginSkills = '/home/testuser/.agents/plugins/bigquery/skills';
799
+ stubSkillsRoot(pluginSkills, 'query-builder');
800
+ stubSkillsRoot('/home/testuser/.theia-ide/skills', 'code-review');
801
+
802
+ const failing: SkillDirectoryContribution = {
803
+ getSkillDirectories: () => { throw new Error('cannot enumerate plugins'); }
804
+ };
805
+ const service = createService([
806
+ failing,
807
+ contributing({ path: pluginSkills, tier: 'plugin', qualifier: bigQueryQualifier })
808
+ ]);
809
+ await initialize(service);
810
+
811
+ expect(service.getSkills().map(skill => skill.qualifiedName).sort())
812
+ .to.deep.equal(['bigquery-data-analytics:query-builder', 'code-review']);
813
+ });
814
+
815
+ it('watches the parent of a contributed root that does not exist yet instead of warning about it', async () => {
816
+ const pluginRoot = '/home/testuser/.agents/plugins/bigquery';
817
+ fileServiceMock.exists
818
+ .withArgs(sinon.match((uri: URI) => uri.path.toString() === `${pluginRoot}/skills`))
819
+ .resolves(false);
820
+ fileServiceMock.exists
821
+ .withArgs(sinon.match((uri: URI) => uri.path.toString() === pluginRoot))
822
+ .resolves(true);
823
+
824
+ const service = createService([contributing({ path: `${pluginRoot}/skills`, tier: 'plugin', qualifier: bigQueryQualifier })]);
825
+ await initialize(service);
826
+
827
+ expect(fileServiceMock.watch.calledWith(
828
+ sinon.match((uri: URI) => uri.path.toString() === pluginRoot),
829
+ sinon.match({ recursive: false, excludes: [] })
830
+ )).to.be.true;
831
+ // A plugin without skills is not a misconfiguration, so it must not warn like a
832
+ // non-existent configured directory does.
833
+ expect(loggerWarnSpy.calledWith(sinon.match(/does not exist/))).to.be.false;
834
+ });
835
+
836
+ it('rescans when a contribution reports that its set of roots changed', async () => {
837
+ const pluginSkills = '/home/testuser/.agents/plugins/bigquery/skills';
838
+ const onDidChangeEmitter = new Emitter<void>();
839
+ let entries: SkillDirectoryEntry[] = [];
840
+ const contribution: SkillDirectoryContribution = {
841
+ getSkillDirectories: () => entries,
842
+ onDidChange: onDidChangeEmitter.event
843
+ };
844
+
845
+ const service = createService([contribution]);
846
+ await initialize(service);
847
+ expect(service.getSkills()).to.have.length(0);
848
+
849
+ stubSkillsRoot(pluginSkills, 'query-builder');
850
+ entries = [{ path: pluginSkills, tier: 'plugin', qualifier: bigQueryQualifier }];
851
+ onDidChangeEmitter.fire();
852
+ await new Promise(resolve => setTimeout(resolve, 100));
853
+
854
+ expect(service.getSkills().map(skill => skill.qualifiedName)).to.deep.equal(['bigquery-data-analytics:query-builder']);
855
+ onDidChangeEmitter.dispose();
856
+ });
857
+ });
633
858
  });
634
859
  });