@theia/ai-chat 1.75.0-next.21 → 1.75.0-next.28

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 (38) hide show
  1. package/lib/browser/ai-chat-frontend-module.d.ts.map +1 -1
  2. package/lib/browser/ai-chat-frontend-module.js +4 -0
  3. package/lib/browser/ai-chat-frontend-module.js.map +1 -1
  4. package/lib/browser/change-set-file-element.d.ts +2 -0
  5. package/lib/browser/change-set-file-element.d.ts.map +1 -1
  6. package/lib/browser/change-set-file-element.js +10 -4
  7. package/lib/browser/change-set-file-element.js.map +1 -1
  8. package/lib/browser/file-read-tracker-impl.d.ts +55 -0
  9. package/lib/browser/file-read-tracker-impl.d.ts.map +1 -0
  10. package/lib/browser/file-read-tracker-impl.js +200 -0
  11. package/lib/browser/file-read-tracker-impl.js.map +1 -0
  12. package/lib/browser/file-read-tracker-impl.spec.d.ts +2 -0
  13. package/lib/browser/file-read-tracker-impl.spec.d.ts.map +1 -0
  14. package/lib/browser/file-read-tracker-impl.spec.js +251 -0
  15. package/lib/browser/file-read-tracker-impl.spec.js.map +1 -0
  16. package/lib/common/chat-agents.d.ts +7 -0
  17. package/lib/common/chat-agents.d.ts.map +1 -1
  18. package/lib/common/chat-agents.js +28 -0
  19. package/lib/common/chat-agents.js.map +1 -1
  20. package/lib/common/chat-agents.spec.js +53 -0
  21. package/lib/common/chat-agents.spec.js.map +1 -1
  22. package/lib/common/file-read-tracker.d.ts +12 -0
  23. package/lib/common/file-read-tracker.d.ts.map +1 -0
  24. package/lib/common/file-read-tracker.js +20 -0
  25. package/lib/common/file-read-tracker.js.map +1 -0
  26. package/lib/common/index.d.ts +1 -0
  27. package/lib/common/index.d.ts.map +1 -1
  28. package/lib/common/index.js +1 -0
  29. package/lib/common/index.js.map +1 -1
  30. package/package.json +9 -9
  31. package/src/browser/ai-chat-frontend-module.ts +5 -0
  32. package/src/browser/change-set-file-element.ts +8 -5
  33. package/src/browser/file-read-tracker-impl.spec.ts +318 -0
  34. package/src/browser/file-read-tracker-impl.ts +208 -0
  35. package/src/common/chat-agents.spec.ts +74 -0
  36. package/src/common/chat-agents.ts +25 -0
  37. package/src/common/file-read-tracker.ts +29 -0
  38. package/src/common/index.ts +1 -0
@@ -0,0 +1,208 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 Ehab Younes.
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, URI } from '@theia/core';
18
+ import { hash } from '@theia/core/lib/common/hash';
19
+ import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
20
+ import { FileService } from '@theia/filesystem/lib/browser/file-service';
21
+ import { FileChangesEvent, FileOperationError, FileOperationResult } from '@theia/filesystem/lib/common/files';
22
+ import { MonacoWorkspace } from '@theia/monaco/lib/browser/monaco-workspace';
23
+ import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
24
+ import { FileReadTracker } from '../common/file-read-tracker';
25
+
26
+ /** The content an agent saw, and whether anything has since signalled a possible change. */
27
+ interface TrackedFile {
28
+ /** `undefined` until the read determining it returns, so that it matches no content. */
29
+ seenHash?: number;
30
+ maybeStale: boolean;
31
+ }
32
+
33
+ @injectable()
34
+ export class FileReadTrackerImpl implements FileReadTracker {
35
+
36
+ @inject(FileService)
37
+ protected readonly fileService: FileService;
38
+
39
+ @inject(MonacoWorkspace)
40
+ protected readonly monacoWorkspace: MonacoWorkspace;
41
+
42
+ @inject(WorkspaceService)
43
+ protected readonly workspaceService: WorkspaceService;
44
+
45
+ /** Session id -> tracked file uri -> state. Both levels are insertion ordered and bounded. */
46
+ protected readonly sessions = new Map<string, Map<string, TrackedFile>>();
47
+
48
+ /**
49
+ * Arbitrary bounds. Sessions are never disposed here, as that would mean depending on the `ChatService`
50
+ * that owns the agents using this service, so the least recently used are evicted; that only loses a notice.
51
+ */
52
+ protected readonly maxSessions: number = 32;
53
+ protected readonly maxFilesPerSession: number = 200;
54
+
55
+ /** A file grown past this is not read to compare, so a flagged one stays reported as changed. The read tools hand an agent far less. */
56
+ protected readonly maxComparedFileSize: number = 4 * 1024 * 1024;
57
+
58
+ /** Only flags entries; reading and hashing happens in {@link isStale} and {@link getChangedFiles}. */
59
+ @postConstruct()
60
+ protected init(): void {
61
+ this.fileChanges(event => this.handleFileChanges(event));
62
+ this.documentChanges(uri => this.invalidate(uri));
63
+ }
64
+
65
+ protected get fileChanges(): Event<FileChangesEvent> {
66
+ return this.fileService.onDidFilesChange;
67
+ }
68
+
69
+ /** Uris of documents whose unsaved content changed, which never reaches the file system. */
70
+ protected get documentChanges(): Event<string> {
71
+ return Event.map(this.monacoWorkspace.onDidChangeTextDocument, event => event.model.uri);
72
+ }
73
+
74
+ async recordRead(sessionId: string, uri: URI, content?: string): Promise<void> {
75
+ const key = uri.toString();
76
+ const files = this.touchSession(sessionId);
77
+ // Re-insert to move the key last, so eviction drops the least recently used.
78
+ files.delete(key);
79
+ // Tracked before the read, so a change arriving during it flags this entry instead of being overwritten.
80
+ const tracked: TrackedFile = { maybeStale: false };
81
+ files.set(key, tracked);
82
+ this.evictOverflow(files, this.maxFilesPerSession);
83
+ const current = content ?? await this.resolveContent(uri);
84
+ if (current === undefined) {
85
+ if (files.get(key) === tracked) { // a later read may own the entry by now
86
+ files.delete(key);
87
+ }
88
+ return;
89
+ }
90
+ tracked.seenHash = hash(current);
91
+ }
92
+
93
+ async isStale(sessionId: string, uri: URI): Promise<boolean> {
94
+ const files = this.sessions.get(sessionId);
95
+ if (!files) {
96
+ return false;
97
+ }
98
+ const key = uri.toString();
99
+ const tracked = files.get(key);
100
+ if (!tracked?.maybeStale) {
101
+ return false;
102
+ }
103
+ return await this.recheck(files, key, tracked) === 'changed';
104
+ }
105
+
106
+ async getChangedFiles(sessionId: string): Promise<string[]> {
107
+ const files = this.sessions.get(sessionId);
108
+ if (!files) {
109
+ return [];
110
+ }
111
+ const labels = await Promise.all([...files]
112
+ .filter(([, tracked]) => tracked.maybeStale)
113
+ .map(async ([key, tracked]) => await this.recheck(files, key, tracked) === 'unchanged' ? undefined : this.getLabel(new URI(key))));
114
+ return labels.filter((label): label is string => label !== undefined);
115
+ }
116
+
117
+ /**
118
+ * Flags the tracked files the event touched, by uri lookup rather than {@link FileChangesEvent.contains},
119
+ * which scans the whole change list. A deleted parent folder is therefore only noticed on the next read.
120
+ */
121
+ protected handleFileChanges(event: FileChangesEvent): void {
122
+ for (const change of event.changes) {
123
+ this.invalidate(change.resource.toString());
124
+ }
125
+ }
126
+
127
+ /** Compares a flagged file against what the agent saw, clearing the flag when the contents match again. */
128
+ protected async recheck(files: Map<string, TrackedFile>, key: string, tracked: TrackedFile): Promise<'unchanged' | 'changed' | 'gone'> {
129
+ let current: string;
130
+ try {
131
+ current = await this.readCurrentContent(new URI(key));
132
+ } catch (error) {
133
+ if (this.isFileNotFound(error)) {
134
+ files.delete(key);
135
+ return 'gone';
136
+ }
137
+ return 'changed';
138
+ }
139
+ if (hash(current) === tracked.seenHash) {
140
+ tracked.maybeStale = false;
141
+ return 'unchanged';
142
+ }
143
+ return 'changed';
144
+ }
145
+
146
+ protected isFileNotFound(error: unknown): boolean {
147
+ return error instanceof FileOperationError && error.fileOperationResult === FileOperationResult.FILE_NOT_FOUND;
148
+ }
149
+
150
+ /** `undefined` when the content cannot be read at all. */
151
+ protected async resolveContent(uri: URI): Promise<string | undefined> {
152
+ try {
153
+ return await this.readCurrentContent(uri);
154
+ } catch {
155
+ return undefined;
156
+ }
157
+ }
158
+
159
+ /** The content the agent would be handed now, preferring the open editor document as `getFileContent` does. */
160
+ protected async readCurrentContent(uri: URI): Promise<string> {
161
+ const document = this.monacoWorkspace.getTextDocument(uri.toString());
162
+ if (document) {
163
+ return document.getText();
164
+ }
165
+ return (await this.fileService.read(uri, { limits: { size: this.maxComparedFileSize } })).value;
166
+ }
167
+
168
+ /** A path the agent can pass back to `getFileContent`, which takes `<rootName>/<relativePath>` as well as a uri. */
169
+ protected getLabel(uri: URI): string {
170
+ const roots = this.workspaceService.tryGetRoots().map(root => root.resource);
171
+ for (const root of roots) {
172
+ const relativePath = root.relative(uri)?.toString();
173
+ // A basename shared with another root would resolve to that other root.
174
+ if (relativePath && !roots.some(other => other.path.base === root.path.base && !other.isEqual(root))) {
175
+ return `${root.path.base}/${relativePath}`;
176
+ }
177
+ }
178
+ return uri.toString();
179
+ }
180
+
181
+ protected invalidate(uriString: string): void {
182
+ for (const files of this.sessions.values()) {
183
+ const tracked = files.get(uriString);
184
+ if (tracked) {
185
+ tracked.maybeStale = true;
186
+ }
187
+ }
188
+ }
189
+
190
+ protected touchSession(sessionId: string): Map<string, TrackedFile> {
191
+ const files = this.sessions.get(sessionId) ?? new Map<string, TrackedFile>();
192
+ // Re-insert to move the key last, so eviction drops the least recently used.
193
+ this.sessions.delete(sessionId);
194
+ this.sessions.set(sessionId, files);
195
+ this.evictOverflow(this.sessions, this.maxSessions);
196
+ return files;
197
+ }
198
+
199
+ /** Drops entries from the front, which insertion order makes the least recently used ones. */
200
+ protected evictOverflow(entries: Map<string, unknown>, limit: number): void {
201
+ for (const key of entries.keys()) {
202
+ if (entries.size <= limit) {
203
+ return;
204
+ }
205
+ entries.delete(key);
206
+ }
207
+ }
208
+ }
@@ -33,6 +33,9 @@ import {
33
33
  ThinkingChatResponseContentImpl,
34
34
  } from './chat-model';
35
35
  import { ParsedChatRequest, ParsedChatRequestTextPart } from './parsed-chat-request';
36
+ import { FileReadTracker } from './file-read-tracker';
37
+ import { ILogger } from '@theia/core';
38
+ import { MockLogger } from '@theia/core/lib/common/test/mock-logger';
36
39
 
37
40
  class TestChatAgent extends AbstractChatAgent {
38
41
  readonly id = 'test-agent';
@@ -59,6 +62,18 @@ class TestChatAgent extends AbstractChatAgent {
59
62
  public setLanguageModelRegistry(registry: LanguageModelRegistry): void {
60
63
  this.languageModelRegistry = registry;
61
64
  }
65
+
66
+ public exposeAppendExternalFileChangeNotice(request: MutableChatRequestModel, messages: LanguageModelMessage[]): Promise<void> {
67
+ return this.appendExternalFileChangeNotice(request, messages);
68
+ }
69
+
70
+ public setFileReadTracker(tracker: FileReadTracker): void {
71
+ this.fileReadTracker = tracker;
72
+ }
73
+
74
+ public setLogger(logger: ILogger): void {
75
+ this.logger = logger;
76
+ }
62
77
  }
63
78
 
64
79
  function createParsedRequest(text: string, request?: Partial<ChatRequest>): ParsedChatRequest {
@@ -292,3 +307,62 @@ describe('AbstractChatAgent.getLanguageModelForRequest', () => {
292
307
  expect(error).to.be.an('error');
293
308
  });
294
309
  });
310
+
311
+ describe('AbstractChatAgent.appendExternalFileChangeNotice', () => {
312
+
313
+ function createAgent(getChangedFiles: () => Promise<string[]>): TestChatAgent {
314
+ const agent = new TestChatAgent();
315
+ agent.setLogger(new MockLogger());
316
+ agent.setFileReadTracker({
317
+ recordRead: async () => { },
318
+ isStale: async () => false,
319
+ getChangedFiles
320
+ });
321
+ return agent;
322
+ }
323
+
324
+ function createRequest(): MutableChatRequestModel {
325
+ return new MutableChatModel(ChatAgentLocation.Panel).addRequest(createParsedRequest('Hello'));
326
+ }
327
+
328
+ function textsOf(messages: LanguageModelMessage[]): string[] {
329
+ return messages.flatMap(message => message.type === 'text' ? [message.text] : []);
330
+ }
331
+
332
+ it('appends a trailing user message listing the changed files', async () => {
333
+ const agent = createAgent(async () => ['/workspace/a.ts', '/workspace/b.ts']);
334
+ const messages: LanguageModelMessage[] = [];
335
+
336
+ await agent.exposeAppendExternalFileChangeNotice(createRequest(), messages);
337
+
338
+ expect(messages).to.have.lengthOf(1);
339
+ expect(messages[0].actor).to.equal('user');
340
+ expect(textsOf(messages)[0]).to.contain('/workspace/a.ts').and.to.contain('/workspace/b.ts');
341
+ });
342
+
343
+ it('appends nothing when no file changed', async () => {
344
+ const agent = createAgent(async () => []);
345
+ const messages: LanguageModelMessage[] = [];
346
+
347
+ await agent.exposeAppendExternalFileChangeNotice(createRequest(), messages);
348
+
349
+ expect(messages).to.be.empty;
350
+ });
351
+
352
+ it('appends nothing when no tracker is bound', async () => {
353
+ const messages: LanguageModelMessage[] = [];
354
+
355
+ await new TestChatAgent().exposeAppendExternalFileChangeNotice(createRequest(), messages);
356
+
357
+ expect(messages).to.be.empty;
358
+ });
359
+
360
+ it('does not fail the request when the changed files cannot be determined', async () => {
361
+ const agent = createAgent(async () => { throw new Error('tracker unavailable'); });
362
+ const messages: LanguageModelMessage[] = [];
363
+
364
+ await agent.exposeAppendExternalFileChangeNotice(createRequest(), messages);
365
+
366
+ expect(messages).to.be.empty;
367
+ });
368
+ });
@@ -62,6 +62,7 @@ import {
62
62
  import { ContributionProvider, ILogger, isArray, nls } from '@theia/core';
63
63
  import { inject, injectable, named, optional, postConstruct } from '@theia/core/shared/inversify';
64
64
  import { ChatAgentService } from './chat-agent-service';
65
+ import { FileReadTracker } from './file-read-tracker';
65
66
  import {
66
67
  ChatModel,
67
68
  ChatRequestModel,
@@ -200,6 +201,8 @@ export abstract class AbstractChatAgent implements ChatAgent {
200
201
 
201
202
  @inject(TokenUsageService) @optional() protected tokenUsageService: TokenUsageService | undefined;
202
203
 
204
+ @inject(FileReadTracker) @optional() protected fileReadTracker: FileReadTracker | undefined;
205
+
203
206
  readonly abstract id: string;
204
207
  readonly abstract name: string;
205
208
  readonly abstract languageModelRequirements: LanguageModelRequirement[];
@@ -255,6 +258,7 @@ export abstract class AbstractChatAgent implements ChatAgent {
255
258
  }
256
259
 
257
260
  const messages = await this.getMessages(request.session);
261
+ await this.appendExternalFileChangeNotice(request, messages);
258
262
 
259
263
  if (systemMessageDescription) {
260
264
  const systemMsg: LanguageModelMessage = {
@@ -295,6 +299,27 @@ export abstract class AbstractChatAgent implements ChatAgent {
295
299
  }
296
300
  }
297
301
 
302
+ /**
303
+ * Tells the agent which files it read were meanwhile changed by somebody else. A trailing user message
304
+ * rather than the cached system message; providers requiring alternating roles merge same-role runs.
305
+ */
306
+ protected async appendExternalFileChangeNotice(request: MutableChatRequestModel, messages: LanguageModelMessage[]): Promise<void> {
307
+ try {
308
+ const changedFiles = await this.fileReadTracker?.getChangedFiles(request.session.id);
309
+ if (changedFiles?.length) {
310
+ messages.push({
311
+ actor: 'user',
312
+ type: 'text',
313
+ text: `The following files changed since you last read them: ${changedFiles.join(', ')}. ` +
314
+ 'Read them again before relying on their content or overwriting them.'
315
+ });
316
+ }
317
+ } catch (error) {
318
+ // Advisory, so failing to determine it must not fail the request.
319
+ this.logger.warn('Could not determine externally changed files.', error);
320
+ }
321
+ }
322
+
298
323
  protected parseContents(text: string, request: MutableChatRequestModel): ChatResponseContent[] {
299
324
  return parseContents(
300
325
  text,
@@ -0,0 +1,29 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 Ehab Younes.
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 { URI } from '@theia/core';
18
+
19
+ export const FileReadTracker = Symbol('FileReadTracker');
20
+
21
+ /** Tracks the content an agent has seen per chat session, so that changes by anyone else can be reported back to it. */
22
+ export interface FileReadTracker {
23
+ /** Snapshots `uri` as the session's agent just saw it, or forgets it if it is gone. Pass `content` when at hand to save a read. */
24
+ recordRead(sessionId: string, uri: URI, content?: string): Promise<void>;
25
+ /** Whether `uri` differs from what the session's agent last saw. `false` if never read or gone: neither holds content a write could discard. */
26
+ isStale(sessionId: string, uri: URI): Promise<boolean>;
27
+ /** Files changed since the session's agent read them, reported again until it reads them anew so an ignored notice cannot get lost. */
28
+ getChangedFiles(sessionId: string): Promise<string[]>;
29
+ }
@@ -27,5 +27,6 @@ export * from './custom-chat-agent';
27
27
  export * from './parsed-chat-request';
28
28
  export * from './context-variables';
29
29
  export * from './chat-tool-request-service';
30
+ export * from './file-read-tracker';
30
31
  export * from './chat-tool-confirmation-timeout';
31
32
  export * from './provider-error-formatter';