@theia/ai-core 1.73.0-next.7 → 1.73.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 (155) hide show
  1. package/README.md +2 -2
  2. package/lib/browser/{agent-completion-notification-service.d.ts → agent-notification-service.d.ts} +12 -10
  3. package/lib/browser/agent-notification-service.d.ts.map +1 -0
  4. package/lib/browser/{agent-completion-notification-service.js → agent-notification-service.js} +37 -27
  5. package/lib/browser/agent-notification-service.js.map +1 -0
  6. package/lib/browser/ai-core-frontend-module.js +2 -2
  7. package/lib/browser/ai-core-frontend-module.js.map +1 -1
  8. package/lib/browser/frontend-language-model-alias-registry.js +3 -3
  9. package/lib/browser/frontend-prompt-customization-service.d.ts +193 -16
  10. package/lib/browser/frontend-prompt-customization-service.d.ts.map +1 -1
  11. package/lib/browser/frontend-prompt-customization-service.js +686 -46
  12. package/lib/browser/frontend-prompt-customization-service.js.map +1 -1
  13. package/lib/browser/frontend-prompt-customization-service.spec.js +259 -0
  14. package/lib/browser/frontend-prompt-customization-service.spec.js.map +1 -1
  15. package/lib/browser/frontend-variable-service.d.ts +2 -1
  16. package/lib/browser/frontend-variable-service.d.ts.map +1 -1
  17. package/lib/browser/frontend-variable-service.js +6 -1
  18. package/lib/browser/frontend-variable-service.js.map +1 -1
  19. package/lib/browser/index.d.ts +1 -1
  20. package/lib/browser/index.d.ts.map +1 -1
  21. package/lib/browser/index.js +1 -1
  22. package/lib/browser/index.js.map +1 -1
  23. package/lib/browser/os-notification-service.d.ts +10 -6
  24. package/lib/browser/os-notification-service.d.ts.map +1 -1
  25. package/lib/browser/os-notification-service.js +34 -20
  26. package/lib/browser/os-notification-service.js.map +1 -1
  27. package/lib/browser/prompt-variable-contribution.spec.js +2 -1
  28. package/lib/browser/prompt-variable-contribution.spec.js.map +1 -1
  29. package/lib/browser/skill-service.d.ts +6 -1
  30. package/lib/browser/skill-service.d.ts.map +1 -1
  31. package/lib/browser/skill-service.js +47 -18
  32. package/lib/browser/skill-service.js.map +1 -1
  33. package/lib/browser/skill-service.spec.js +153 -31
  34. package/lib/browser/skill-service.spec.js.map +1 -1
  35. package/lib/browser/token-usage-frontend-service-impl.d.ts +2 -0
  36. package/lib/browser/token-usage-frontend-service-impl.d.ts.map +1 -1
  37. package/lib/browser/token-usage-frontend-service-impl.js +6 -1
  38. package/lib/browser/token-usage-frontend-service-impl.js.map +1 -1
  39. package/lib/browser/window-blink-service.d.ts +2 -0
  40. package/lib/browser/window-blink-service.d.ts.map +1 -1
  41. package/lib/browser/window-blink-service.js +8 -3
  42. package/lib/browser/window-blink-service.js.map +1 -1
  43. package/lib/common/agent-preferences.d.ts.map +1 -1
  44. package/lib/common/agent-preferences.js +12 -2
  45. package/lib/common/agent-preferences.js.map +1 -1
  46. package/lib/common/ai-core-preferences.d.ts.map +1 -1
  47. package/lib/common/ai-core-preferences.js +4 -2
  48. package/lib/common/ai-core-preferences.js.map +1 -1
  49. package/lib/common/capability-variable-contribution.spec.js +2 -1
  50. package/lib/common/capability-variable-contribution.spec.js.map +1 -1
  51. package/lib/common/frontmatter.d.ts +33 -0
  52. package/lib/common/frontmatter.d.ts.map +1 -0
  53. package/lib/common/frontmatter.js +80 -0
  54. package/lib/common/frontmatter.js.map +1 -0
  55. package/lib/common/frontmatter.spec.d.ts +2 -0
  56. package/lib/common/frontmatter.spec.d.ts.map +1 -0
  57. package/lib/common/frontmatter.spec.js +73 -0
  58. package/lib/common/frontmatter.spec.js.map +1 -0
  59. package/lib/common/index.d.ts +1 -0
  60. package/lib/common/index.d.ts.map +1 -1
  61. package/lib/common/index.js +1 -0
  62. package/lib/common/index.js.map +1 -1
  63. package/lib/common/language-model-service.js +1 -1
  64. package/lib/common/language-model-service.js.map +1 -1
  65. package/lib/common/language-model-service.spec.d.ts +2 -0
  66. package/lib/common/language-model-service.spec.d.ts.map +1 -0
  67. package/lib/common/language-model-service.spec.js +62 -0
  68. package/lib/common/language-model-service.spec.js.map +1 -0
  69. package/lib/common/language-model.d.ts +64 -2
  70. package/lib/common/language-model.d.ts.map +1 -1
  71. package/lib/common/language-model.js +8 -2
  72. package/lib/common/language-model.js.map +1 -1
  73. package/lib/common/notification-types.d.ts +7 -0
  74. package/lib/common/notification-types.d.ts.map +1 -1
  75. package/lib/common/notification-types.js +7 -1
  76. package/lib/common/notification-types.js.map +1 -1
  77. package/lib/common/prompt-service-util.d.ts +22 -1
  78. package/lib/common/prompt-service-util.d.ts.map +1 -1
  79. package/lib/common/prompt-service-util.js +24 -2
  80. package/lib/common/prompt-service-util.js.map +1 -1
  81. package/lib/common/prompt-service-util.spec.d.ts +2 -0
  82. package/lib/common/prompt-service-util.spec.d.ts.map +1 -0
  83. package/lib/common/prompt-service-util.spec.js +54 -0
  84. package/lib/common/prompt-service-util.spec.js.map +1 -0
  85. package/lib/common/prompt-service.d.ts +72 -8
  86. package/lib/common/prompt-service.d.ts.map +1 -1
  87. package/lib/common/prompt-service.js +36 -4
  88. package/lib/common/prompt-service.js.map +1 -1
  89. package/lib/common/prompt-service.spec.js +49 -2
  90. package/lib/common/prompt-service.spec.js.map +1 -1
  91. package/lib/common/settings-service.d.ts +5 -0
  92. package/lib/common/settings-service.d.ts.map +1 -1
  93. package/lib/common/skill.d.ts +17 -4
  94. package/lib/common/skill.d.ts.map +1 -1
  95. package/lib/common/skill.js +22 -35
  96. package/lib/common/skill.js.map +1 -1
  97. package/lib/common/tool-invocation-registry.d.ts +7 -0
  98. package/lib/common/tool-invocation-registry.d.ts.map +1 -1
  99. package/lib/common/tool-invocation-registry.js +5 -0
  100. package/lib/common/tool-invocation-registry.js.map +1 -1
  101. package/lib/common/variable-service.d.ts +2 -2
  102. package/lib/common/variable-service.d.ts.map +1 -1
  103. package/lib/common/variable-service.js +7 -4
  104. package/lib/common/variable-service.js.map +1 -1
  105. package/lib/common/variable-service.spec.js +2 -1
  106. package/lib/common/variable-service.spec.js.map +1 -1
  107. package/lib/node/backend-language-model-registry.d.ts.map +1 -1
  108. package/lib/node/backend-language-model-registry.js +1 -0
  109. package/lib/node/backend-language-model-registry.js.map +1 -1
  110. package/lib/node/backend-language-model-registry.spec.d.ts +2 -0
  111. package/lib/node/backend-language-model-registry.spec.d.ts.map +1 -0
  112. package/lib/node/backend-language-model-registry.spec.js +48 -0
  113. package/lib/node/backend-language-model-registry.spec.js.map +1 -0
  114. package/lib/node/token-usage-service-impl.d.ts +2 -0
  115. package/lib/node/token-usage-service-impl.d.ts.map +1 -1
  116. package/lib/node/token-usage-service-impl.js +7 -1
  117. package/lib/node/token-usage-service-impl.js.map +1 -1
  118. package/package.json +12 -12
  119. package/src/browser/{agent-completion-notification-service.ts → agent-notification-service.ts} +52 -27
  120. package/src/browser/ai-core-frontend-module.ts +2 -2
  121. package/src/browser/frontend-language-model-alias-registry.ts +3 -3
  122. package/src/browser/frontend-prompt-customization-service.spec.ts +319 -1
  123. package/src/browser/frontend-prompt-customization-service.ts +810 -59
  124. package/src/browser/frontend-variable-service.ts +5 -3
  125. package/src/browser/index.ts +1 -1
  126. package/src/browser/os-notification-service.ts +39 -24
  127. package/src/browser/prompt-variable-contribution.spec.ts +2 -1
  128. package/src/browser/skill-service.spec.ts +191 -38
  129. package/src/browser/skill-service.ts +61 -33
  130. package/src/browser/token-usage-frontend-service-impl.ts +6 -3
  131. package/src/browser/window-blink-service.ts +7 -4
  132. package/src/common/agent-preferences.ts +13 -2
  133. package/src/common/ai-core-preferences.ts +4 -2
  134. package/src/common/capability-variable-contribution.spec.ts +2 -1
  135. package/src/common/frontmatter.spec.ts +82 -0
  136. package/src/common/frontmatter.ts +99 -0
  137. package/src/common/index.ts +1 -0
  138. package/src/common/language-model-service.spec.ts +70 -0
  139. package/src/common/language-model-service.ts +1 -1
  140. package/src/common/language-model.ts +72 -3
  141. package/src/common/notification-types.ts +10 -0
  142. package/src/common/prompt-service-util.spec.ts +60 -0
  143. package/src/common/prompt-service-util.ts +31 -1
  144. package/src/common/prompt-service.spec.ts +59 -2
  145. package/src/common/prompt-service.ts +104 -8
  146. package/src/common/settings-service.ts +5 -0
  147. package/src/common/skill.ts +37 -39
  148. package/src/common/tool-invocation-registry.ts +13 -0
  149. package/src/common/variable-service.spec.ts +2 -2
  150. package/src/common/variable-service.ts +3 -1
  151. package/src/node/backend-language-model-registry.spec.ts +52 -0
  152. package/src/node/backend-language-model-registry.ts +1 -0
  153. package/src/node/token-usage-service-impl.ts +7 -2
  154. package/lib/browser/agent-completion-notification-service.d.ts.map +0 -1
  155. package/lib/browser/agent-completion-notification-service.js.map +0 -1
@@ -14,9 +14,9 @@
14
14
  // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
15
  // *****************************************************************************
16
16
 
17
- import { Disposable, MessageService, nls, Prioritizeable } from '@theia/core';
17
+ import { Disposable, MessageService, nls, Prioritizeable, ILogger } from '@theia/core';
18
18
  import { FrontendApplicationContribution, OpenerService, open } from '@theia/core/lib/browser';
19
- import { inject, injectable } from '@theia/core/shared/inversify';
19
+ import { inject, injectable, named } from '@theia/core/shared/inversify';
20
20
  import {
21
21
  AIVariable,
22
22
  AIVariableArg,
@@ -108,6 +108,8 @@ export class DefaultFrontendVariableService extends DefaultAIVariableService imp
108
108
  @inject(MessageService) protected readonly messageService: MessageService;
109
109
  @inject(AIVariableResourceResolver) protected readonly aiResourceResolver: AIVariableResourceResolver;
110
110
  @inject(OpenerService) protected readonly openerService: OpenerService;
111
+ @inject(ILogger) @named('ai-core:DefaultFrontendVariableService')
112
+ protected override readonly logger: ILogger;
111
113
 
112
114
  onStart(): void {
113
115
  this.initContributions();
@@ -199,7 +201,7 @@ export class DefaultFrontendVariableService extends DefaultAIVariableService imp
199
201
  try {
200
202
  return opener ? opener.open({ variable, arg }, context ?? {}) : this.openReadonly({ variable, arg }, context);
201
203
  } catch (err) {
202
- console.error('Unable to open variable:', err);
204
+ this.logger.error('Unable to open variable:', err);
203
205
  this.messageService.error(nls.localize('theia/ai/core/unableToDisplayVariableValue', 'Unable to display variable value.'));
204
206
  }
205
207
  }
@@ -14,7 +14,7 @@
14
14
  // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
15
  // *****************************************************************************
16
16
 
17
- export * from './agent-completion-notification-service';
17
+ export * from './agent-notification-service';
18
18
  export * from './os-notification-service';
19
19
  export * from './window-blink-service';
20
20
  export * from './ai-activation-service';
@@ -14,9 +14,10 @@
14
14
  // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
15
  // *****************************************************************************
16
16
 
17
- import { injectable } from '@theia/core/shared/inversify';
17
+ import { injectable, inject, named } from '@theia/core/shared/inversify';
18
18
  import { nls } from '@theia/core/lib/common/nls';
19
- import { environment } from '@theia/core';
19
+ import { environment, ILogger } from '@theia/core';
20
+ import { AgentNotificationKind, AGENT_NOTIFICATION_KIND_INPUT_NEEDED } from '../common/notification-types';
20
21
 
21
22
  /**
22
23
  * Configuration options for OS notifications
@@ -55,6 +56,9 @@ export interface OSNotificationResult {
55
56
  @injectable()
56
57
  export class OSNotificationService {
57
58
 
59
+ @inject(ILogger) @named('ai-core:OSNotificationService')
60
+ protected readonly logger: ILogger;
61
+
58
62
  private isElectron: boolean;
59
63
 
60
64
  constructor() {
@@ -96,7 +100,7 @@ export class OSNotificationService {
96
100
  };
97
101
 
98
102
  } catch (error) {
99
- console.error('Failed to show OS notification:', error);
103
+ this.logger.error('Failed to show OS notification:', error);
100
104
  return {
101
105
  success: false,
102
106
  error: error instanceof Error ? error.message : 'Unknown error occurred'
@@ -134,7 +138,7 @@ export class OSNotificationService {
134
138
  const permission = await Notification.requestPermission();
135
139
  return permission;
136
140
  } catch (error) {
137
- console.error('Failed to request notification permission:', error);
141
+ this.logger.error('Failed to request notification permission:', error);
138
142
  return 'denied';
139
143
  }
140
144
  }
@@ -149,33 +153,44 @@ export class OSNotificationService {
149
153
  }
150
154
 
151
155
  /**
152
- * Show a notification specifically for agent completion
153
- * This is a convenience method with pre-configured options for agent notifications
156
+ * Show a notification for an agent event (task completed or input needed).
157
+ * This is a convenience method with pre-configured options for agent notifications.
154
158
  *
155
- * @param agentName The name of the agent that completed
159
+ * @param agentName The name of the agent
160
+ * @param kind Whether the agent completed its task or needs user input
156
161
  * @param sessionTitle Optional title of the chat session for identification
157
162
  * @param onClickCallback Optional callback to invoke when the notification is clicked
158
163
  * @returns Promise resolving to the notification result
159
164
  */
160
- async showAgentCompletionNotification(
165
+ async showAgentNotification(
161
166
  agentName: string,
167
+ kind: AgentNotificationKind,
162
168
  sessionTitle?: string,
163
169
  onClickCallback?: () => void,
164
170
  ): Promise<OSNotificationResult> {
165
- const title = nls.localize('theia/ai-core/agentCompletionTitle', 'Agent "{0}" Task Completed', agentName);
166
- const body = sessionTitle
167
- ? nls.localize('theia/ai-core/agentCompletionMessageWithSession',
168
- 'Agent "{0}" has completed its task in "{1}".', agentName, sessionTitle)
169
- : nls.localize('theia/ai-core/agentCompletionMessage',
170
- 'Agent "{0}" has completed its task.', agentName);
171
+ const inputNeeded = kind === AGENT_NOTIFICATION_KIND_INPUT_NEEDED;
172
+ const title = inputNeeded
173
+ ? nls.localize('theia/ai-core/agentInputNeededTitle', 'Agent "{0}" Needs Your Input', agentName)
174
+ : nls.localize('theia/ai-core/agentCompletionTitle', 'Agent "{0}" Task Completed', agentName);
175
+ const body = inputNeeded
176
+ ? (sessionTitle
177
+ ? nls.localize('theia/ai-core/agentInputNeededMessageWithSession',
178
+ 'Agent "{0}" needs your input in "{1}".', agentName, sessionTitle)
179
+ : nls.localize('theia/ai-core/agentInputNeededMessage',
180
+ 'Agent "{0}" needs your input.', agentName))
181
+ : (sessionTitle
182
+ ? nls.localize('theia/ai-core/agentCompletionMessageWithSession',
183
+ 'Agent "{0}" has completed its task in "{1}".', agentName, sessionTitle)
184
+ : nls.localize('theia/ai-core/agentCompletionMessage',
185
+ 'Agent "{0}" has completed its task.', agentName));
171
186
 
172
187
  return this.showNotification(title, {
173
188
  body,
174
- icon: this.getAgentCompletionIcon(),
175
- tag: `agent-completion-${agentName}-${sessionTitle ?? 'default'}`,
189
+ icon: this.getAgentNotificationIcon(),
190
+ tag: `agent-${kind}-${agentName}-${sessionTitle ?? 'default'}`,
176
191
  requireInteraction: false,
177
192
  data: {
178
- type: 'agent-completion',
193
+ type: `agent-${kind}`,
179
194
  agentName,
180
195
  sessionTitle,
181
196
  timestamp: Date.now()
@@ -229,16 +244,16 @@ export class OSNotificationService {
229
244
  const notification = new Notification(title, notificationOptions);
230
245
 
231
246
  notification.onshow = () => {
232
- console.debug('OS notification shown:', title);
247
+ this.logger.debug('OS notification shown:', title);
233
248
  };
234
249
 
235
250
  notification.onerror = error => {
236
- console.error('OS notification error:', error);
251
+ this.logger.error('OS notification error:', error);
237
252
  reject(new Error('Failed to show notification'));
238
253
  };
239
254
 
240
255
  notification.onclick = () => {
241
- console.debug('OS notification clicked:', title);
256
+ this.logger.debug('OS notification clicked:', title);
242
257
  this.focusApplicationWindow();
243
258
  if (onClickCallback) {
244
259
  onClickCallback();
@@ -247,7 +262,7 @@ export class OSNotificationService {
247
262
  };
248
263
 
249
264
  notification.onclose = () => {
250
- console.debug('OS notification closed:', title);
265
+ this.logger.debug('OS notification closed:', title);
251
266
  };
252
267
 
253
268
  resolve(notification);
@@ -271,16 +286,16 @@ export class OSNotificationService {
271
286
  }
272
287
  }
273
288
  } catch (error) {
274
- console.debug('Could not focus application window:', error);
289
+ this.logger.debug('Could not focus application window:', error);
275
290
  }
276
291
  }
277
292
 
278
293
  /**
279
- * Get the icon URL for agent completion notifications
294
+ * Get the icon URL for agent notifications
280
295
  *
281
296
  * @returns The icon URL or undefined if not available
282
297
  */
283
- private getAgentCompletionIcon(): string | undefined {
298
+ private getAgentNotificationIcon(): string | undefined {
284
299
  // This could return a path to an icon file
285
300
  // For now, we'll return undefined to use the default system icon
286
301
  return undefined;
@@ -44,7 +44,8 @@ describe('PromptVariableContribution', () => {
44
44
  // Set up PromptService
45
45
  container.bind<PromptService>(PromptService).to(PromptServiceImpl).inSingletonScope();
46
46
  const logger = sinon.createStubInstance(Logger);
47
- const variableService = new DefaultAIVariableService({ getContributions: () => [] }, logger);
47
+ const variableService = new DefaultAIVariableService({ getContributions: () => [] });
48
+ (variableService as unknown as Record<string, unknown>)['logger'] = logger;
48
49
  container.bind<AIVariableService>(AIVariableService).toConstantValue(variableService);
49
50
  container.bind<ILogger>(ILogger).toConstantValue(new MockLogger);
50
51
 
@@ -55,94 +55,127 @@ describe('SkillService', () => {
55
55
  });
56
56
 
57
57
  describe('directory prioritization', () => {
58
- it('workspace directory comes first when all directories provided', () => {
58
+ it('workspace directories come first when all directories provided', () => {
59
59
  const result = combineSkillDirectories(
60
- '/workspace/.prompts/skills',
60
+ ['/workspace/.prompts/skills', '/workspace/.agents/skills'],
61
61
  ['/custom/skills1', '/custom/skills2'],
62
- '/home/user/.theia/skills'
62
+ ['/home/user/.theia/skills', '/home/user/.agents/skills']
63
63
  );
64
64
 
65
65
  expect(result).to.deep.equal([
66
+ { path: '/workspace/.prompts/skills', tier: 'workspace' },
67
+ { path: '/workspace/.agents/skills', tier: 'workspace' },
68
+ { path: '/custom/skills1', tier: 'configured' },
69
+ { path: '/custom/skills2', tier: 'configured' },
70
+ { path: '/home/user/.theia/skills', tier: 'default' },
71
+ { path: '/home/user/.agents/skills', tier: 'default' }
72
+ ]);
73
+ });
74
+
75
+ it('preserves the workspace-tier order: .prompts/skills wins over .agents/skills on duplicate skill names', () => {
76
+ // The `.prompts/skills` directory is intentionally listed first within the workspace tier
77
+ // because it is the Theia-explicit folder; users opting into it signal stronger intent than
78
+ // those relying on the generic `.agents/skills` convention.
79
+ const result = combineSkillDirectories(
80
+ ['/workspace/.prompts/skills', '/workspace/.agents/skills'],
81
+ [],
82
+ []
83
+ );
84
+
85
+ expect(result.map(entry => entry.path)).to.deep.equal([
66
86
  '/workspace/.prompts/skills',
67
- '/custom/skills1',
68
- '/custom/skills2',
69
- '/home/user/.theia/skills'
87
+ '/workspace/.agents/skills'
70
88
  ]);
71
89
  });
72
90
 
73
- it('works without workspace directory', () => {
91
+ it('works without workspace directories', () => {
74
92
  const result = combineSkillDirectories(
75
- undefined,
93
+ [],
76
94
  ['/custom/skills'],
77
- '/home/user/.theia/skills'
95
+ ['/home/user/.theia/skills']
78
96
  );
79
97
 
80
98
  expect(result).to.deep.equal([
81
- '/custom/skills',
82
- '/home/user/.theia/skills'
99
+ { path: '/custom/skills', tier: 'configured' },
100
+ { path: '/home/user/.theia/skills', tier: 'default' }
83
101
  ]);
84
102
  });
85
103
 
86
- it('works with only default directory', () => {
104
+ it('works with only default directories', () => {
87
105
  const result = combineSkillDirectories(
88
- undefined,
89
106
  [],
90
- '/home/user/.theia/skills'
107
+ [],
108
+ ['/home/user/.theia/skills', '/home/user/.agents/skills']
91
109
  );
92
110
 
93
- expect(result).to.deep.equal(['/home/user/.theia/skills']);
111
+ expect(result).to.deep.equal([
112
+ { path: '/home/user/.theia/skills', tier: 'default' },
113
+ { path: '/home/user/.agents/skills', tier: 'default' }
114
+ ]);
94
115
  });
95
116
 
96
117
  it('deduplicates workspace directory if also in configured', () => {
97
118
  const result = combineSkillDirectories(
98
- '/workspace/.prompts/skills',
119
+ ['/workspace/.prompts/skills'],
99
120
  ['/workspace/.prompts/skills', '/custom/skills'],
100
- '/home/user/.theia/skills'
121
+ ['/home/user/.theia/skills']
101
122
  );
102
123
 
103
124
  expect(result).to.deep.equal([
104
- '/workspace/.prompts/skills',
105
- '/custom/skills',
106
- '/home/user/.theia/skills'
125
+ { path: '/workspace/.prompts/skills', tier: 'workspace' },
126
+ { path: '/custom/skills', tier: 'configured' },
127
+ { path: '/home/user/.theia/skills', tier: 'default' }
107
128
  ]);
108
129
  });
109
130
 
110
131
  it('deduplicates default directory if also in configured', () => {
111
132
  const result = combineSkillDirectories(
112
- '/workspace/.prompts/skills',
133
+ ['/workspace/.prompts/skills'],
113
134
  ['/home/user/.theia/skills'],
114
- '/home/user/.theia/skills'
135
+ ['/home/user/.theia/skills']
115
136
  );
116
137
 
117
138
  expect(result).to.deep.equal([
118
- '/workspace/.prompts/skills',
119
- '/home/user/.theia/skills'
139
+ { path: '/workspace/.prompts/skills', tier: 'workspace' },
140
+ { path: '/home/user/.theia/skills', tier: 'configured' }
120
141
  ]);
121
142
  });
122
143
 
123
144
  it('handles empty configured directories', () => {
124
145
  const result = combineSkillDirectories(
125
- '/workspace/.prompts/skills',
146
+ ['/workspace/.prompts/skills'],
126
147
  [],
127
- '/home/user/.theia/skills'
148
+ ['/home/user/.theia/skills']
128
149
  );
129
150
 
130
151
  expect(result).to.deep.equal([
131
- '/workspace/.prompts/skills',
132
- '/home/user/.theia/skills'
152
+ { path: '/workspace/.prompts/skills', tier: 'workspace' },
153
+ { path: '/home/user/.theia/skills', tier: 'default' }
133
154
  ]);
134
155
  });
135
156
 
136
- it('handles undefined default directory', () => {
157
+ it('handles empty default directories', () => {
137
158
  const result = combineSkillDirectories(
138
- '/workspace/.prompts/skills',
159
+ ['/workspace/.prompts/skills'],
139
160
  ['/custom/skills'],
140
- undefined
161
+ []
141
162
  );
142
163
 
143
164
  expect(result).to.deep.equal([
144
- '/workspace/.prompts/skills',
145
- '/custom/skills'
165
+ { path: '/workspace/.prompts/skills', tier: 'workspace' },
166
+ { path: '/custom/skills', tier: 'configured' }
167
+ ]);
168
+ });
169
+
170
+ it('deduplicates duplicate workspace directories', () => {
171
+ const result = combineSkillDirectories(
172
+ ['/workspace/.prompts/skills', '/workspace/.prompts/skills'],
173
+ [],
174
+ []
175
+ );
176
+
177
+ expect(result).to.deep.equal([
178
+ { path: '/workspace/.prompts/skills', tier: 'workspace' }
146
179
  ]);
147
180
  });
148
181
  });
@@ -278,6 +311,7 @@ description: Skill with no content
278
311
  let fileServiceMock: any;
279
312
  let loggerWarnSpy: sinon.SinonStub;
280
313
  let loggerInfoSpy: sinon.SinonStub;
314
+ let loggerDebugSpy: sinon.SinonStub;
281
315
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
282
316
  let envVariablesServerMock: any;
283
317
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -295,6 +329,7 @@ description: Skill with no content
295
329
  const loggerMock: ILogger = sinon.createStubInstance(Logger);
296
330
  loggerMock.warn = loggerWarnSpy;
297
331
  loggerMock.info = loggerInfoSpy;
332
+ loggerMock.debug = loggerDebugSpy;
298
333
  (service as unknown as { logger: unknown }).logger = loggerMock;
299
334
  (service as unknown as { envVariablesServer: unknown }).envVariablesServer = envVariablesServerMock;
300
335
  (service as unknown as { workspaceService: unknown }).workspaceService = workspaceServiceMock;
@@ -315,6 +350,7 @@ description: Skill with no content
315
350
 
316
351
  loggerWarnSpy = sinon.stub();
317
352
  loggerInfoSpy = sinon.stub();
353
+ loggerDebugSpy = sinon.stub();
318
354
 
319
355
  envVariablesServerMock = {
320
356
  getHomeDirUri: sinon.stub().resolves('file:///home/testuser'),
@@ -362,13 +398,17 @@ description: Skill with no content
362
398
  sinon.match({ recursive: false, excludes: [] })
363
399
  )).to.be.true;
364
400
 
365
- // Verify info log about watching parent
366
- expect(loggerInfoSpy.calledWith(
401
+ // Verify debug log about watching parent (demoted from info to keep startup quiet)
402
+ expect(loggerDebugSpy.calledWith(
367
403
  sinon.match(/Watching parent directory.*for skills folder creation/)
368
404
  )).to.be.true;
405
+ // And the same line must not be emitted at info level any more.
406
+ expect(loggerInfoSpy.calledWith(
407
+ sinon.match(/Watching parent directory/)
408
+ )).to.be.false;
369
409
  });
370
410
 
371
- it('should log warning when parent directory does not exist', async () => {
411
+ it('should log a debug entry when parent directory does not exist', async () => {
372
412
  const service = createService();
373
413
 
374
414
  // Neither skills directory nor parent exists
@@ -379,10 +419,42 @@ description: Skill with no content
379
419
  await workspaceServiceMock.ready;
380
420
  await new Promise(resolve => setTimeout(resolve, 10));
381
421
 
382
- // Verify warning is logged about parent not existing
383
- expect(loggerWarnSpy.calledWith(
422
+ // The absent-default-tree case is expected on fresh machines and is now debug-level only.
423
+ expect(loggerDebugSpy.calledWith(
384
424
  sinon.match(/Cannot watch skills directory.*parent directory does not exist/)
385
425
  )).to.be.true;
426
+ // It must no longer surface as a startup warning.
427
+ expect(loggerWarnSpy.calledWith(
428
+ sinon.match(/Cannot watch skills directory.*parent directory does not exist/)
429
+ )).to.be.false;
430
+ });
431
+
432
+ it('coalesces concurrent update() calls so a single scan runs at a time', async () => {
433
+ const service = createService();
434
+
435
+ // Default skills directory does not exist, but parent does, so each update logs once.
436
+ fileServiceMock.exists
437
+ .withArgs(sinon.match((uri: URI) => uri.path.toString() === '/home/testuser/.theia-ide/skills'))
438
+ .resolves(false);
439
+ fileServiceMock.exists
440
+ .withArgs(sinon.match((uri: URI) => uri.path.toString() === '/home/testuser/.theia-ide'))
441
+ .resolves(true);
442
+
443
+ // Drive two updates in the same tick - the second must be coalesced into a follow-up
444
+ // re-schedule rather than racing the first and producing duplicated log lines.
445
+ const update = (service as unknown as { update: () => Promise<void> }).update.bind(service);
446
+ await Promise.all([update(), update()]);
447
+ // Give the rescheduled run (debounced via scheduleUpdate) time to fire.
448
+ await new Promise(resolve => setTimeout(resolve, 80));
449
+
450
+ // The watcher must be installed (the underlying scan must have run); the same log line
451
+ // must have been emitted at most once per scan, never twice from interleaved scans.
452
+ const matchingDebugCalls = loggerDebugSpy.getCalls().filter(call =>
453
+ /Watching parent directory.*for skills folder creation/.test(String(call.args[0]))
454
+ );
455
+ // Two scans (initial + rescheduled) are allowed, but never more, and never zero.
456
+ expect(matchingDebugCalls.length).to.be.greaterThan(0);
457
+ expect(matchingDebugCalls.length).to.be.lessThan(3);
386
458
  });
387
459
 
388
460
  it('should log warning for non-existent configured directories', async () => {
@@ -477,5 +549,86 @@ Test skill content`
477
549
  expect(skills).to.have.length(1);
478
550
  expect(skills[0].name).to.equal('test-skill');
479
551
  });
552
+
553
+ it('prefers a workspace `.prompts/skills` skill over a same-named `.agents/skills` skill in the same root', async () => {
554
+ const service = createService();
555
+
556
+ workspaceServiceMock.tryGetRoots.returns([
557
+ { resource: URI.fromFilePath('/workspace') }
558
+ ]);
559
+
560
+ // Stub default skill directories as non-existent to keep focus on workspace behavior.
561
+ fileServiceMock.exists
562
+ .withArgs(sinon.match((uri: URI) => uri.path.toString() === '/home/testuser/.theia-ide/skills'))
563
+ .resolves(false);
564
+ fileServiceMock.exists
565
+ .withArgs(sinon.match((uri: URI) => uri.path.toString() === '/home/testuser/.theia-ide'))
566
+ .resolves(false);
567
+ fileServiceMock.exists
568
+ .withArgs(sinon.match((uri: URI) => uri.path.toString() === '/home/testuser/.agents/skills'))
569
+ .resolves(false);
570
+ fileServiceMock.exists
571
+ .withArgs(sinon.match((uri: URI) => uri.path.toString() === '/home/testuser/.agents'))
572
+ .resolves(false);
573
+
574
+ // Both workspace skill directories exist and contain a `dup-skill` directory.
575
+ const promptsSkillDir = '/workspace/.prompts/skills';
576
+ const agentsSkillDir = '/workspace/.agents/skills';
577
+ fileServiceMock.exists
578
+ .withArgs(sinon.match((uri: URI) => uri.path.toString() === promptsSkillDir))
579
+ .resolves(true);
580
+ fileServiceMock.exists
581
+ .withArgs(sinon.match((uri: URI) => uri.path.toString() === agentsSkillDir))
582
+ .resolves(true);
583
+ fileServiceMock.resolve
584
+ .withArgs(sinon.match((uri: URI) => uri.path.toString() === promptsSkillDir))
585
+ .resolves({
586
+ children: [{
587
+ isDirectory: true,
588
+ name: 'dup-skill',
589
+ resource: URI.fromFilePath(`${promptsSkillDir}/dup-skill`)
590
+ }]
591
+ });
592
+ fileServiceMock.resolve
593
+ .withArgs(sinon.match((uri: URI) => uri.path.toString() === agentsSkillDir))
594
+ .resolves({
595
+ children: [{
596
+ isDirectory: true,
597
+ name: 'dup-skill',
598
+ resource: URI.fromFilePath(`${agentsSkillDir}/dup-skill`)
599
+ }]
600
+ });
601
+
602
+ // Each tier provides its own SKILL.md so we can distinguish them by location.
603
+ fileServiceMock.exists
604
+ .withArgs(sinon.match((uri: URI) => uri.path.toString() === `${promptsSkillDir}/dup-skill/SKILL.md`))
605
+ .resolves(true);
606
+ fileServiceMock.exists
607
+ .withArgs(sinon.match((uri: URI) => uri.path.toString() === `${agentsSkillDir}/dup-skill/SKILL.md`))
608
+ .resolves(true);
609
+ fileServiceMock.read
610
+ .withArgs(sinon.match((uri: URI) => uri.path.toString() === `${promptsSkillDir}/dup-skill/SKILL.md`))
611
+ .resolves({
612
+ value: '---\nname: dup-skill\ndescription: from prompts\n---\nFrom prompts'
613
+ });
614
+ fileServiceMock.read
615
+ .withArgs(sinon.match((uri: URI) => uri.path.toString() === `${agentsSkillDir}/dup-skill/SKILL.md`))
616
+ .resolves({
617
+ value: '---\nname: dup-skill\ndescription: from agents\n---\nFrom agents'
618
+ });
619
+
620
+ (service as unknown as { init: () => void }).init();
621
+ await workspaceServiceMock.ready;
622
+ await new Promise(resolve => setTimeout(resolve, 10));
623
+
624
+ const skill = service.getSkill('dup-skill');
625
+ expect(skill, 'dup-skill should be discovered').to.not.be.undefined;
626
+ expect(skill!.description).to.equal('from prompts');
627
+ expect(skill!.location).to.equal(new Path(`${promptsSkillDir}/dup-skill/SKILL.md`).fsPath());
628
+ // The second occurrence is reported (not silently swallowed) via a warning.
629
+ expect(loggerWarnSpy.calledWith(
630
+ sinon.match(/Duplicate skill found.*dup-skill/)
631
+ )).to.be.true;
632
+ });
480
633
  });
481
634
  });