@theia/ai-core 1.77.0-next.49 → 1.77.0-next.57

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 (102) hide show
  1. package/lib/browser/agents-md-frontend-application-contribution.d.ts +65 -0
  2. package/lib/browser/agents-md-frontend-application-contribution.d.ts.map +1 -0
  3. package/lib/browser/agents-md-frontend-application-contribution.js +269 -0
  4. package/lib/browser/agents-md-frontend-application-contribution.js.map +1 -0
  5. package/lib/browser/agents-md-frontend-application-contribution.spec.d.ts +2 -0
  6. package/lib/browser/agents-md-frontend-application-contribution.spec.d.ts.map +1 -0
  7. package/lib/browser/agents-md-frontend-application-contribution.spec.js +225 -0
  8. package/lib/browser/agents-md-frontend-application-contribution.spec.js.map +1 -0
  9. package/lib/browser/agents-md-migration-service.d.ts +78 -0
  10. package/lib/browser/agents-md-migration-service.d.ts.map +1 -0
  11. package/lib/browser/agents-md-migration-service.js +195 -0
  12. package/lib/browser/agents-md-migration-service.js.map +1 -0
  13. package/lib/browser/agents-md-migration-service.spec.d.ts +2 -0
  14. package/lib/browser/agents-md-migration-service.spec.d.ts.map +1 -0
  15. package/lib/browser/agents-md-migration-service.spec.js +278 -0
  16. package/lib/browser/agents-md-migration-service.spec.js.map +1 -0
  17. package/lib/browser/agents-md-prompt-fragment-contribution.d.ts +13 -0
  18. package/lib/browser/agents-md-prompt-fragment-contribution.d.ts.map +1 -0
  19. package/lib/browser/agents-md-prompt-fragment-contribution.js +76 -0
  20. package/lib/browser/agents-md-prompt-fragment-contribution.js.map +1 -0
  21. package/lib/browser/agents-md-service.d.ts +58 -0
  22. package/lib/browser/agents-md-service.d.ts.map +1 -0
  23. package/lib/browser/agents-md-service.js +191 -0
  24. package/lib/browser/agents-md-service.js.map +1 -0
  25. package/lib/browser/agents-md-service.spec.d.ts +2 -0
  26. package/lib/browser/agents-md-service.spec.d.ts.map +1 -0
  27. package/lib/browser/agents-md-service.spec.js +220 -0
  28. package/lib/browser/agents-md-service.spec.js.map +1 -0
  29. package/lib/browser/agents-md-variable-contribution.d.ts +66 -0
  30. package/lib/browser/agents-md-variable-contribution.d.ts.map +1 -0
  31. package/lib/browser/agents-md-variable-contribution.js +169 -0
  32. package/lib/browser/agents-md-variable-contribution.js.map +1 -0
  33. package/lib/browser/agents-md-variable-contribution.spec.d.ts +2 -0
  34. package/lib/browser/agents-md-variable-contribution.spec.d.ts.map +1 -0
  35. package/lib/browser/agents-md-variable-contribution.spec.js +148 -0
  36. package/lib/browser/agents-md-variable-contribution.spec.js.map +1 -0
  37. package/lib/browser/ai-core-frontend-module.d.ts.map +1 -1
  38. package/lib/browser/ai-core-frontend-module.js +15 -0
  39. package/lib/browser/ai-core-frontend-module.js.map +1 -1
  40. package/lib/browser/frontend-prompt-customization-service.d.ts +2 -1
  41. package/lib/browser/frontend-prompt-customization-service.d.ts.map +1 -1
  42. package/lib/browser/frontend-prompt-customization-service.js +10 -0
  43. package/lib/browser/frontend-prompt-customization-service.js.map +1 -1
  44. package/lib/browser/frontend-prompt-customization-service.spec.js +19 -1
  45. package/lib/browser/frontend-prompt-customization-service.spec.js.map +1 -1
  46. package/lib/browser/frontend-variable-service.js +1 -1
  47. package/lib/browser/frontend-variable-service.js.map +1 -1
  48. package/lib/browser/index.d.ts +5 -0
  49. package/lib/browser/index.d.ts.map +1 -1
  50. package/lib/browser/index.js +5 -0
  51. package/lib/browser/index.js.map +1 -1
  52. package/lib/browser/prompt-variable-contribution.js +1 -1
  53. package/lib/browser/prompt-variable-contribution.js.map +1 -1
  54. package/lib/browser/prompt-variable-contribution.spec.js +2 -1
  55. package/lib/browser/prompt-variable-contribution.spec.js.map +1 -1
  56. package/lib/browser/prompttemplate-contribution.js +1 -1
  57. package/lib/browser/prompttemplate-contribution.js.map +1 -1
  58. package/lib/browser/skill-service.spec.js +2 -1
  59. package/lib/browser/skill-service.spec.js.map +1 -1
  60. package/lib/browser/skills-variable-contribution.spec.js +2 -1
  61. package/lib/browser/skills-variable-contribution.spec.js.map +1 -1
  62. package/lib/common/agents-md.d.ts +20 -0
  63. package/lib/common/agents-md.d.ts.map +1 -0
  64. package/lib/common/agents-md.js +38 -0
  65. package/lib/common/agents-md.js.map +1 -0
  66. package/lib/common/ai-variable-resource.js +3 -3
  67. package/lib/common/ai-variable-resource.js.map +1 -1
  68. package/lib/common/capability-variable-contribution.js +1 -1
  69. package/lib/common/capability-variable-contribution.js.map +1 -1
  70. package/lib/common/index.d.ts +1 -0
  71. package/lib/common/index.d.ts.map +1 -1
  72. package/lib/common/index.js +1 -0
  73. package/lib/common/index.js.map +1 -1
  74. package/lib/common/prompt-service.d.ts +12 -0
  75. package/lib/common/prompt-service.d.ts.map +1 -1
  76. package/lib/common/prompt-service.js.map +1 -1
  77. package/lib/common/prompt-service.spec.js +2 -1
  78. package/lib/common/prompt-service.spec.js.map +1 -1
  79. package/lib/common/variable-service.spec.js +2 -1
  80. package/lib/common/variable-service.spec.js.map +1 -1
  81. package/lib/node/model-snapshot-store.js +2 -2
  82. package/lib/node/model-snapshot-store.js.map +1 -1
  83. package/lib/node/proxy-util.js +2 -1
  84. package/lib/node/proxy-util.js.map +1 -1
  85. package/package.json +9 -9
  86. package/src/browser/agents-md-frontend-application-contribution.spec.ts +244 -0
  87. package/src/browser/agents-md-frontend-application-contribution.ts +291 -0
  88. package/src/browser/agents-md-migration-service.spec.ts +338 -0
  89. package/src/browser/agents-md-migration-service.ts +226 -0
  90. package/src/browser/agents-md-prompt-fragment-contribution.ts +73 -0
  91. package/src/browser/agents-md-service.spec.ts +258 -0
  92. package/src/browser/agents-md-service.ts +212 -0
  93. package/src/browser/agents-md-variable-contribution.spec.ts +180 -0
  94. package/src/browser/agents-md-variable-contribution.ts +182 -0
  95. package/src/browser/ai-core-frontend-module.ts +17 -0
  96. package/src/browser/frontend-prompt-customization-service.spec.ts +24 -0
  97. package/src/browser/frontend-prompt-customization-service.ts +13 -1
  98. package/src/browser/index.ts +5 -0
  99. package/src/common/agents-md.ts +37 -0
  100. package/src/common/ai-variable-resource.ts +1 -1
  101. package/src/common/index.ts +1 -0
  102. package/src/common/prompt-service.ts +14 -0
@@ -0,0 +1,258 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
18
+ const disableJSDOM = enableJSDOM();
19
+
20
+ import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
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
+ }
28
+
29
+ import { expect } from 'chai';
30
+ import * as sinon from 'sinon';
31
+ import { Disposable, Emitter, ILogger, Logger, URI } from '@theia/core';
32
+ import { FileChangesEvent, FileChangeType, FileOperation, FileOperationEvent, FileStatWithMetadata } from '@theia/filesystem/lib/common/files';
33
+ import { DefaultAgentsMdService } from './agents-md-service';
34
+
35
+ disableJSDOM();
36
+
37
+ /** Long enough to outlast the service's 50ms coalescing window. */
38
+ const AFTER_DEBOUNCE_MS = 80;
39
+
40
+ describe('AgentsMdService', () => {
41
+ /* eslint-disable @typescript-eslint/no-explicit-any */
42
+ let fileServiceMock: any;
43
+ let workspaceServiceMock: any;
44
+ let loggerErrorSpy: sinon.SinonStub;
45
+ let fileChangesEmitter: Emitter<FileChangesEvent>;
46
+ let operationsEmitter: Emitter<FileOperationEvent>;
47
+ /* eslint-enable @typescript-eslint/no-explicit-any */
48
+
49
+ function root(path: string): { resource: URI, name: string } {
50
+ const resource = new URI(`file://${path}`);
51
+ return { resource, name: resource.path.base };
52
+ }
53
+
54
+ /** Only `resource` is read by the service; the rest satisfies the event's signature. */
55
+ function stat(path: string): FileStatWithMetadata {
56
+ return { resource: new URI(`file://${path}`) } as FileStatWithMetadata;
57
+ }
58
+
59
+ function createService(): DefaultAgentsMdService {
60
+ const service = new DefaultAgentsMdService();
61
+ (service as unknown as { fileService: unknown }).fileService = fileServiceMock;
62
+ (service as unknown as { workspaceService: unknown }).workspaceService = workspaceServiceMock;
63
+ const loggerMock: ILogger = sinon.createStubInstance(Logger);
64
+ loggerMock.error = loggerErrorSpy;
65
+ (service as unknown as { logger: unknown }).logger = loggerMock;
66
+ return service;
67
+ }
68
+
69
+ /** Runs the `@postConstruct` initialization and waits for the initial scan. */
70
+ async function start(service: DefaultAgentsMdService): Promise<void> {
71
+ (service as unknown as { init(): void }).init();
72
+ await service.ready;
73
+ }
74
+
75
+ function existing(...paths: string[]): void {
76
+ fileServiceMock.exists.callsFake((uri: URI) => Promise.resolve(paths.includes(uri.path.toString())));
77
+ }
78
+
79
+ beforeEach(() => {
80
+ fileChangesEmitter = new Emitter<FileChangesEvent>();
81
+ operationsEmitter = new Emitter<FileOperationEvent>();
82
+ loggerErrorSpy = sinon.stub();
83
+ fileServiceMock = {
84
+ exists: sinon.stub().resolves(false),
85
+ read: sinon.stub().resolves({ value: '' }),
86
+ watch: sinon.stub().returns(Disposable.NULL),
87
+ onDidFilesChange: (listener: (e: FileChangesEvent) => void) => fileChangesEmitter.event(listener),
88
+ onDidRunOperation: (listener: (e: FileOperationEvent) => void) => operationsEmitter.event(listener)
89
+ };
90
+ workspaceServiceMock = {
91
+ ready: Promise.resolve(),
92
+ tryGetRoots: sinon.stub().returns([]),
93
+ onWorkspaceChanged: sinon.stub().returns(Disposable.NULL)
94
+ };
95
+ });
96
+
97
+ afterEach(() => {
98
+ sinon.restore();
99
+ fileChangesEmitter.dispose();
100
+ operationsEmitter.dispose();
101
+ });
102
+
103
+ it('finds nothing without a workspace root', async () => {
104
+ const service = createService();
105
+ await start(service);
106
+
107
+ expect(service.getAgentsMdFiles()).to.be.empty;
108
+ });
109
+
110
+ it('finds nothing when the root has no AGENTS.md', async () => {
111
+ workspaceServiceMock.tryGetRoots.returns([root('/ws')]);
112
+ const service = createService();
113
+ await start(service);
114
+
115
+ expect(service.getAgentsMdFiles()).to.be.empty;
116
+ });
117
+
118
+ it('discovers AGENTS.md at the workspace root', async () => {
119
+ workspaceServiceMock.tryGetRoots.returns([root('/ws')]);
120
+ existing('/ws/AGENTS.md');
121
+ fileServiceMock.read.resolves({ value: '# Project' });
122
+ const service = createService();
123
+ await start(service);
124
+
125
+ const files = service.getAgentsMdFiles();
126
+ expect(files).to.have.lengthOf(1);
127
+ expect(files[0].uri.path.toString()).to.equal('/ws/AGENTS.md');
128
+ expect(files[0].content).to.equal('# Project');
129
+ expect(files[0].rootName).to.equal('ws');
130
+ });
131
+
132
+ it('ignores a nested AGENTS.md, only the root is scanned', async () => {
133
+ workspaceServiceMock.tryGetRoots.returns([root('/ws')]);
134
+ existing('/ws/packages/core/AGENTS.md');
135
+ const service = createService();
136
+ await start(service);
137
+
138
+ expect(service.getAgentsMdFiles()).to.be.empty;
139
+ });
140
+
141
+ it('reports one file per root, in workspace root order', async () => {
142
+ workspaceServiceMock.tryGetRoots.returns([root('/a'), root('/b')]);
143
+ existing('/a/AGENTS.md', '/b/AGENTS.md');
144
+ fileServiceMock.read.callsFake((uri: URI) => Promise.resolve({ value: `content of ${uri.path.toString()}` }));
145
+ const service = createService();
146
+ await start(service);
147
+
148
+ expect(service.getAgentsMdFiles().map(file => file.rootName)).to.deep.equal(['a', 'b']);
149
+ expect(service.getAgentsMdFiles()[1].content).to.equal('content of /b/AGENTS.md');
150
+ });
151
+
152
+ it('keeps the other roots when one file cannot be read', async () => {
153
+ workspaceServiceMock.tryGetRoots.returns([root('/broken'), root('/ok')]);
154
+ existing('/broken/AGENTS.md', '/ok/AGENTS.md');
155
+ fileServiceMock.read.callsFake((uri: URI) => uri.path.toString() === '/broken/AGENTS.md'
156
+ ? Promise.reject(new Error('boom'))
157
+ : Promise.resolve({ value: 'fine' }));
158
+ const service = createService();
159
+ await start(service);
160
+
161
+ expect(service.getAgentsMdFiles().map(file => file.rootName)).to.deep.equal(['ok']);
162
+ expect(loggerErrorSpy.called).to.be.true;
163
+ });
164
+
165
+ it('picks up an AGENTS.md created after the initial scan', async () => {
166
+ workspaceServiceMock.tryGetRoots.returns([root('/ws')]);
167
+ const service = createService();
168
+ await start(service);
169
+ expect(service.getAgentsMdFiles()).to.be.empty;
170
+
171
+ existing('/ws/AGENTS.md');
172
+ fileServiceMock.read.resolves({ value: 'added later' });
173
+ const changed = new Promise<void>(resolve => service.onDidChange(() => resolve()));
174
+ fileChangesEmitter.fire(new FileChangesEvent([{ resource: new URI('file:///ws/AGENTS.md'), type: FileChangeType.ADDED }]));
175
+
176
+ await changed;
177
+ expect(service.getAgentsMdFiles().map(file => file.content)).to.deep.equal(['added later']);
178
+ });
179
+
180
+ it('picks up an AGENTS.md written through the FileService before the watcher is live', async () => {
181
+ // The migration writes AGENTS.md milliseconds after startup, while `FileService.watch` is
182
+ // still registering its backend watcher, so no file change event is emitted for it.
183
+ workspaceServiceMock.tryGetRoots.returns([root('/ws')]);
184
+ const service = createService();
185
+ await start(service);
186
+ expect(service.getAgentsMdFiles()).to.be.empty;
187
+
188
+ existing('/ws/AGENTS.md');
189
+ fileServiceMock.read.resolves({ value: 'migrated' });
190
+ const changed = new Promise<void>(resolve => service.onDidChange(() => resolve()));
191
+ operationsEmitter.fire(new FileOperationEvent(new URI('file:///ws/AGENTS.md'), FileOperation.CREATE, stat('/ws/AGENTS.md')));
192
+
193
+ await changed;
194
+ expect(service.getAgentsMdFiles().map(file => file.content)).to.deep.equal(['migrated']);
195
+ });
196
+
197
+ it('picks up a file moved onto the AGENTS.md path, where only the move target matches', async () => {
198
+ workspaceServiceMock.tryGetRoots.returns([root('/ws')]);
199
+ const service = createService();
200
+ await start(service);
201
+
202
+ existing('/ws/AGENTS.md');
203
+ fileServiceMock.read.resolves({ value: 'renamed into place' });
204
+ const changed = new Promise<void>(resolve => service.onDidChange(() => resolve()));
205
+ operationsEmitter.fire(new FileOperationEvent(new URI('file:///ws/NOTES.md'), FileOperation.MOVE, stat('/ws/AGENTS.md')));
206
+
207
+ await changed;
208
+ expect(service.getAgentsMdFiles().map(file => file.content)).to.deep.equal(['renamed into place']);
209
+ });
210
+
211
+ it('rescans when an AGENTS.md is written while the initial scan is still running', async () => {
212
+ // Regression: `watchedFiles` used to be published only after a scan finished, so an
213
+ // operation arriving mid-scan was dropped and the empty result stuck for the session.
214
+ workspaceServiceMock.tryGetRoots.returns([root('/ws')]);
215
+ const service = createService();
216
+ let firstCheck = true;
217
+ fileServiceMock.exists.callsFake(() => {
218
+ if (firstCheck) {
219
+ firstCheck = false;
220
+ operationsEmitter.fire(new FileOperationEvent(new URI('file:///ws/AGENTS.md'), FileOperation.CREATE, stat('/ws/AGENTS.md')));
221
+ return Promise.resolve(false);
222
+ }
223
+ return Promise.resolve(true);
224
+ });
225
+ fileServiceMock.read.resolves({ value: 'written during the scan' });
226
+
227
+ await start(service);
228
+ await new Promise(resolve => setTimeout(resolve, AFTER_DEBOUNCE_MS));
229
+
230
+ expect(service.getAgentsMdFiles().map(file => file.content)).to.deep.equal(['written during the scan']);
231
+ });
232
+
233
+ it('ignores operations on other files in the root', async () => {
234
+ workspaceServiceMock.tryGetRoots.returns([root('/ws')]);
235
+ const service = createService();
236
+ await start(service);
237
+ const onDidChange = sinon.spy();
238
+ service.onDidChange(onDidChange);
239
+
240
+ operationsEmitter.fire(new FileOperationEvent(new URI('file:///ws/README.md'), FileOperation.CREATE, stat('/ws/README.md')));
241
+ await new Promise(resolve => setTimeout(resolve, AFTER_DEBOUNCE_MS));
242
+
243
+ expect(onDidChange.called).to.be.false;
244
+ });
245
+
246
+ it('ignores changes to other files in the root', async () => {
247
+ workspaceServiceMock.tryGetRoots.returns([root('/ws')]);
248
+ const service = createService();
249
+ await start(service);
250
+ const onDidChange = sinon.spy();
251
+ service.onDidChange(onDidChange);
252
+
253
+ fileChangesEmitter.fire(new FileChangesEvent([{ resource: new URI('file:///ws/README.md'), type: FileChangeType.UPDATED }]));
254
+ await new Promise(resolve => setTimeout(resolve, AFTER_DEBOUNCE_MS));
255
+
256
+ expect(onDidChange.called).to.be.false;
257
+ });
258
+ });
@@ -0,0 +1,212 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { inject, injectable, named, postConstruct } from '@theia/core/shared/inversify';
18
+ import { Deferred } from '@theia/core/lib/common/promise-util';
19
+ import { Disposable, DisposableCollection, Emitter, Event, ILogger, URI } from '@theia/core';
20
+ import { FileService } from '@theia/filesystem/lib/browser/file-service';
21
+ import { FileChangesEvent, FileOperationEvent } from '@theia/filesystem/lib/common/files';
22
+ import { WorkspaceService } from '@theia/workspace/lib/browser';
23
+ import { AGENTS_MD_FILE_NAME } from '../common/agents-md';
24
+
25
+ /** Debounce delay for coalescing rapid file system events. */
26
+ const UPDATE_DEBOUNCE_MS = 50;
27
+
28
+ export interface AgentsMdFile {
29
+ /** URI of the discovered `AGENTS.md`. */
30
+ uri: URI;
31
+ /** Content as of the last scan. */
32
+ content: string;
33
+ /** Name of the workspace root holding the file, used as provenance label when merging several roots. */
34
+ rootName: string;
35
+ }
36
+
37
+ export const AgentsMdService = Symbol('AgentsMdService');
38
+ export interface AgentsMdService {
39
+ /** The `AGENTS.md` files discovered in the workspace roots, in workspace root order. */
40
+ getAgentsMdFiles(): AgentsMdFile[];
41
+
42
+ /** Fired after every scan, whether or not the result changed. */
43
+ readonly onDidChange: Event<void>;
44
+
45
+ /** Resolves once the initial scan is complete. Always resolves, even if the scan failed. */
46
+ readonly ready: Promise<void>;
47
+ }
48
+
49
+ /**
50
+ * Discovers `<root>/AGENTS.md` for every workspace root and keeps the content up to date.
51
+ *
52
+ * Only the workspace roots themselves are scanned. A nested `AGENTS.md` cascade with proximity
53
+ * precedence, `@path` imports and a global `~/.agents/AGENTS.md` are deliberately out of scope.
54
+ */
55
+ @injectable()
56
+ export class DefaultAgentsMdService implements AgentsMdService, Disposable {
57
+
58
+ @inject(FileService)
59
+ protected readonly fileService: FileService;
60
+
61
+ @inject(WorkspaceService)
62
+ protected readonly workspaceService: WorkspaceService;
63
+
64
+ @inject(ILogger) @named('ai-core:DefaultAgentsMdService')
65
+ protected readonly logger: ILogger;
66
+
67
+ protected files: AgentsMdFile[] = [];
68
+
69
+ /** Watchers of the current scan; disposed and rebuilt on every rescan. */
70
+ protected toDispose = new DisposableCollection();
71
+ /** Kept apart from {@link toDispose}, which does not survive a rescan. */
72
+ protected readonly toDisposeOnServiceDispose = new DisposableCollection();
73
+
74
+ /** URIs of the `AGENTS.md` files we react to: one per workspace root, whether or not it exists. */
75
+ protected watchedFiles = new Set<string>();
76
+
77
+ protected updateDebounceTimeout: ReturnType<typeof setTimeout> | undefined;
78
+
79
+ /** True while {@link update} is running, so concurrent callers do not duplicate the scan. */
80
+ protected updateInProgress = false;
81
+ /** Set when {@link update} is called while another run is in progress; triggers a follow-up scan. */
82
+ protected updateRescheduled = false;
83
+
84
+ protected readonly onDidChangeEmitter = new Emitter<void>();
85
+ readonly onDidChange: Event<void> = this.onDidChangeEmitter.event;
86
+
87
+ protected _ready = new Deferred<void>();
88
+ get ready(): Promise<void> {
89
+ return this._ready.promise;
90
+ }
91
+
92
+ @postConstruct()
93
+ protected init(): void {
94
+ this.toDisposeOnServiceDispose.push(this.onDidChangeEmitter);
95
+ this.toDisposeOnServiceDispose.push(this.fileService.onDidFilesChange((event: FileChangesEvent) => {
96
+ if (event.changes.some(change => this.isWatched(change.resource))) {
97
+ this.scheduleUpdate();
98
+ }
99
+ }));
100
+ // The watcher above only reports what the backend sees, and `FileService.watch` registers
101
+ // that watcher asynchronously. An `AGENTS.md` written through the application right after
102
+ // startup - most notably by the migration - therefore lands in the gap before the watcher is
103
+ // live and would be missed for the rest of the session. Operations performed through the
104
+ // FileService are reported here synchronously, independent of any watcher.
105
+ this.toDisposeOnServiceDispose.push(this.fileService.onDidRunOperation((event: FileOperationEvent) => {
106
+ if (this.isWatched(event.resource) || (event.target && this.isWatched(event.target.resource))) {
107
+ this.scheduleUpdate();
108
+ }
109
+ }));
110
+ this.initialize();
111
+ }
112
+
113
+ protected async initialize(): Promise<void> {
114
+ try {
115
+ await this.workspaceService.ready;
116
+ } catch (error) {
117
+ this.logger.error('Failed to resolve the workspace, scanning the already known roots for AGENTS.md', error);
118
+ }
119
+ // Listen before the initial scan, otherwise a change landing while it runs is recorded as
120
+ // already applied and dropped. update()'s in-progress guard coalesces the overlap.
121
+ this.toDisposeOnServiceDispose.push(this.workspaceService.onWorkspaceChanged(() => this.scheduleUpdate()));
122
+ try {
123
+ await this.update();
124
+ } catch (error) {
125
+ this.logger.error('Initial AGENTS.md scan failed', error);
126
+ }
127
+ this._ready.resolve();
128
+ }
129
+
130
+ getAgentsMdFiles(): AgentsMdFile[] {
131
+ return [...this.files];
132
+ }
133
+
134
+ /** Whether `resource` is one of the `AGENTS.md` locations this service tracks. */
135
+ protected isWatched(resource: URI): boolean {
136
+ return this.watchedFiles.has(resource.toString());
137
+ }
138
+
139
+ dispose(): void {
140
+ if (this.updateDebounceTimeout) {
141
+ clearTimeout(this.updateDebounceTimeout);
142
+ this.updateDebounceTimeout = undefined;
143
+ }
144
+ this.toDisposeOnServiceDispose.dispose();
145
+ this.toDispose.dispose();
146
+ }
147
+
148
+ protected scheduleUpdate(): void {
149
+ if (this.updateDebounceTimeout) {
150
+ clearTimeout(this.updateDebounceTimeout);
151
+ }
152
+ this.updateDebounceTimeout = setTimeout(() => {
153
+ this.updateDebounceTimeout = undefined;
154
+ this.update();
155
+ }, UPDATE_DEBOUNCE_MS);
156
+ }
157
+
158
+ protected async update(): Promise<void> {
159
+ if (this.updateDebounceTimeout) {
160
+ clearTimeout(this.updateDebounceTimeout);
161
+ this.updateDebounceTimeout = undefined;
162
+ }
163
+ // Serialise concurrent calls: a workspace-ready trigger and a file-change-driven
164
+ // scheduleUpdate() can fire within the same async tick. The second caller records a pending
165
+ // request and lets the first finish; the follow-up is re-scheduled through the debouncer.
166
+ if (this.updateInProgress) {
167
+ this.updateRescheduled = true;
168
+ return;
169
+ }
170
+ this.updateInProgress = true;
171
+ try {
172
+ await this.doUpdate();
173
+ } finally {
174
+ this.updateInProgress = false;
175
+ if (this.updateRescheduled) {
176
+ this.updateRescheduled = false;
177
+ this.scheduleUpdate();
178
+ }
179
+ }
180
+ }
181
+
182
+ protected async doUpdate(): Promise<void> {
183
+ const newDisposables = new DisposableCollection();
184
+ const newFiles: AgentsMdFile[] = [];
185
+ const roots = this.workspaceService.tryGetRoots();
186
+
187
+ // Publish the tracked locations before doing any I/O. A file operation landing while this
188
+ // scan is still running has to be recognized so that the in-progress guard can reschedule;
189
+ // assigning only at the end would drop it and leave this scan's stale result in place.
190
+ this.watchedFiles = new Set(roots.map(root => root.resource.resolve(AGENTS_MD_FILE_NAME).toString()));
191
+
192
+ for (const root of roots) {
193
+ const fileUri = root.resource.resolve(AGENTS_MD_FILE_NAME);
194
+ // Watch the root, not the file: a watcher on a missing file cannot report its creation.
195
+ newDisposables.push(this.fileService.watch(root.resource, { recursive: false, excludes: [] }));
196
+ try {
197
+ if (await this.fileService.exists(fileUri)) {
198
+ const content = await this.fileService.read(fileUri);
199
+ newFiles.push({ uri: fileUri, content: content.value, rootName: root.name });
200
+ }
201
+ } catch (error) {
202
+ this.logger.error(`Failed to read '${fileUri.toString()}': ${error}`);
203
+ }
204
+ }
205
+
206
+ this.toDispose.dispose();
207
+ this.toDispose = newDisposables;
208
+ this.files = newFiles;
209
+
210
+ this.onDidChangeEmitter.fire();
211
+ }
212
+ }
@@ -0,0 +1,180 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
18
+ const disableJSDOM = enableJSDOM();
19
+
20
+ import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
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
+ }
28
+
29
+ import { expect } from 'chai';
30
+ import { URI } from '@theia/core';
31
+ import { AgentsMdFile, AgentsMdService } from './agents-md-service';
32
+ import { AGENTS_MD_AUTO_LOADED_VARIABLE, AGENTS_MD_CONTENT_VARIABLE, AGENTS_MD_VARIABLE, AgentsMdVariableContribution } from './agents-md-variable-contribution';
33
+ import { AIVariableContext } from '../common/variable-service';
34
+ import { matchFunctionsRegEx } from '../common/prompt-service-util';
35
+
36
+ disableJSDOM();
37
+
38
+ describe('AgentsMdVariableContribution', () => {
39
+
40
+ function file(rootName: string, content: string): AgentsMdFile {
41
+ return { uri: new URI(`file:///${rootName}/AGENTS.md`), content, rootName };
42
+ }
43
+
44
+ /**
45
+ * @param rootCount how many workspace roots are open. Defaults to one per discovered file, which
46
+ * is the ordinary case; pass it explicitly to build a workspace whose root count and file count
47
+ * differ, since auto-loading keys on roots rather than files.
48
+ */
49
+ function createContribution(files: AgentsMdFile[], rootCount = files.length): AgentsMdVariableContribution {
50
+ const contribution = new AgentsMdVariableContribution();
51
+ const service: AgentsMdService = {
52
+ getAgentsMdFiles: () => files,
53
+ onDidChange: () => ({ dispose: () => { } }),
54
+ ready: Promise.resolve()
55
+ };
56
+ (contribution as unknown as { agentsMdService: AgentsMdService }).agentsMdService = service;
57
+ (contribution as unknown as { workspaceService: unknown }).workspaceService = {
58
+ tryGetRoots: () => new Array(rootCount).fill({})
59
+ };
60
+ return contribution;
61
+ }
62
+
63
+ async function resolve(files: AgentsMdFile[], variable: typeof AGENTS_MD_VARIABLE, rootCount?: number): Promise<string> {
64
+ const resolved = await createContribution(files, rootCount).resolve({ variable }, {} as AIVariableContext);
65
+ return resolved!.value;
66
+ }
67
+
68
+ describe('agentsMdAutoLoaded', () => {
69
+ it('resolves to nothing when the workspace has no AGENTS.md', async () => {
70
+ expect(await resolve([], AGENTS_MD_AUTO_LOADED_VARIABLE, 1)).to.equal('');
71
+ });
72
+
73
+ it('inlines the root file when the workspace has a single root', async () => {
74
+ expect(await resolve([file('theia', '# Project\n\nUse npm.')], AGENTS_MD_AUTO_LOADED_VARIABLE)).to.equal(
75
+ '<project_instructions path="theia/AGENTS.md">\n# Project\n\nUse npm.\n</project_instructions>'
76
+ );
77
+ });
78
+
79
+ it('loads nothing once the workspace has more than one root', async () => {
80
+ expect(await resolve([file('a', 'x'), file('b', 'y')], AGENTS_MD_AUTO_LOADED_VARIABLE)).to.equal('');
81
+ });
82
+
83
+ it('loads nothing in a multi-root workspace where only one root has a file', async () => {
84
+ // Keyed on root count, not file count: a workspace with several roots has no single
85
+ // project root, even when only one of them carries an AGENTS.md.
86
+ expect(await resolve([file('a', 'x')], AGENTS_MD_AUTO_LOADED_VARIABLE, 2)).to.equal('');
87
+ });
88
+
89
+ it('escapes the path but leaves the Markdown content untouched', async () => {
90
+ const resolved = await resolve([file('a&b', '# Heading <not xml> & "quoted"')], AGENTS_MD_AUTO_LOADED_VARIABLE, 1);
91
+ expect(resolved).to.contain('path="a&amp;b/AGENTS.md"');
92
+ expect(resolved).to.contain('# Heading <not xml> & "quoted"');
93
+ });
94
+
95
+ it('keeps function references in the content from attaching tools', async () => {
96
+ const resolved = await resolve([file('a', 'Use ~{getFileContent} and {{selectedText}}.')], AGENTS_MD_AUTO_LOADED_VARIABLE, 1);
97
+ expect(matchFunctionsRegEx(resolved)).to.be.empty;
98
+ expect(resolved).to.contain('{{selectedText}}');
99
+ });
100
+ });
101
+
102
+ describe('agentsMd', () => {
103
+ it('resolves to nothing when the workspace has no AGENTS.md', async () => {
104
+ expect(await resolve([], AGENTS_MD_VARIABLE)).to.equal('');
105
+ });
106
+
107
+ it('advertises nothing when the single root file is already loaded', async () => {
108
+ // Otherwise the model is told to read a file it has just been handed.
109
+ expect(await resolve([file('theia', 'x')], AGENTS_MD_VARIABLE)).to.equal('');
110
+ });
111
+
112
+ it('advertises one entry per root once there is more than one root', async () => {
113
+ expect(await resolve([file('a', 'x'), file('b', 'y')], AGENTS_MD_VARIABLE)).to.equal(
114
+ '<agents_md_files>\n<file>a/AGENTS.md</file>\n<file>b/AGENTS.md</file>\n</agents_md_files>'
115
+ );
116
+ });
117
+
118
+ it('advertises the single file of a multi-root workspace rather than loading it', async () => {
119
+ expect(await resolve([file('a', 'x')], AGENTS_MD_VARIABLE, 2)).to.equal(
120
+ '<agents_md_files>\n<file>a/AGENTS.md</file>\n</agents_md_files>'
121
+ );
122
+ });
123
+
124
+ it('escapes XML in the root name', async () => {
125
+ expect(await resolve([file('a&b', 'x')], AGENTS_MD_VARIABLE, 2)).to.contain('<file>a&amp;b/AGENTS.md</file>');
126
+ });
127
+
128
+ it('never inlines the content', async () => {
129
+ expect(await resolve([file('a', 'secret project conventions'), file('b', 'y')], AGENTS_MD_VARIABLE))
130
+ .to.not.contain('secret project conventions');
131
+ });
132
+ });
133
+
134
+ describe('the two are mutually exclusive', () => {
135
+ it('never emits both for the same workspace', async () => {
136
+ for (const [files, roots] of [
137
+ [[file('theia', 'x')], 1],
138
+ [[file('a', 'x'), file('b', 'y')], 2],
139
+ [[file('a', 'x')], 2],
140
+ [[], 1]
141
+ ] as [AgentsMdFile[], number][]) {
142
+ const loaded = await resolve(files, AGENTS_MD_AUTO_LOADED_VARIABLE, roots);
143
+ const listed = await resolve(files, AGENTS_MD_VARIABLE, roots);
144
+ expect(loaded === '' || listed === '', `both were non-empty for ${roots} root(s)`).to.be.true;
145
+ }
146
+ });
147
+ });
148
+
149
+ describe('agentsMdContent', () => {
150
+ it('resolves to nothing when the workspace has no AGENTS.md', async () => {
151
+ expect(await resolve([], AGENTS_MD_CONTENT_VARIABLE)).to.equal('');
152
+ });
153
+
154
+ it('inlines a single root verbatim, without provenance framing', async () => {
155
+ expect(await resolve([file('theia', '# Project\n\nUse npm.')], AGENTS_MD_CONTENT_VARIABLE)).to.equal('# Project\n\nUse npm.');
156
+ });
157
+
158
+ it('labels the sources when several roots contribute', async () => {
159
+ expect(await resolve([file('a', 'first'), file('b', 'second')], AGENTS_MD_CONTENT_VARIABLE)).to.equal(
160
+ '### a\n\nfirst\n\n### b\n\nsecond'
161
+ );
162
+ });
163
+
164
+ it('keeps function references in the content from attaching tools', async () => {
165
+ expect(matchFunctionsRegEx(await resolve([file('a', '~{getFileContent}')], AGENTS_MD_CONTENT_VARIABLE))).to.be.empty;
166
+ expect(matchFunctionsRegEx(await resolve([file('a', '~{x}'), file('b', '~{y}')], AGENTS_MD_CONTENT_VARIABLE))).to.be.empty;
167
+ });
168
+ });
169
+
170
+ describe('canResolve', () => {
171
+ it('claims all three variables and nothing else', () => {
172
+ const contribution = createContribution([]);
173
+ const context = {} as AIVariableContext;
174
+ expect(contribution.canResolve({ variable: AGENTS_MD_AUTO_LOADED_VARIABLE }, context)).to.equal(1);
175
+ expect(contribution.canResolve({ variable: AGENTS_MD_VARIABLE }, context)).to.equal(1);
176
+ expect(contribution.canResolve({ variable: AGENTS_MD_CONTENT_VARIABLE }, context)).to.equal(1);
177
+ expect(contribution.canResolve({ variable: { id: 'other', name: 'other', description: '' } }, context)).to.equal(-1);
178
+ });
179
+ });
180
+ });