@theia/ai-chat 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.
- package/lib/browser/ai-chat-frontend-module.d.ts.map +1 -1
- package/lib/browser/ai-chat-frontend-module.js +4 -0
- package/lib/browser/ai-chat-frontend-module.js.map +1 -1
- package/lib/browser/change-set-file-element.d.ts +2 -0
- package/lib/browser/change-set-file-element.d.ts.map +1 -1
- package/lib/browser/change-set-file-element.js +10 -4
- package/lib/browser/change-set-file-element.js.map +1 -1
- package/lib/browser/change-set-file-service.js +1 -0
- package/lib/browser/change-set-file-service.js.map +1 -1
- package/lib/browser/chat-session-store-impl.js +1 -1
- package/lib/browser/chat-session-store-impl.js.map +1 -1
- package/lib/browser/chat-session-store-impl.spec.js +1 -2
- package/lib/browser/chat-session-store-impl.spec.js.map +1 -1
- package/lib/browser/chat-tool-request-service.js +1 -1
- package/lib/browser/chat-tool-request-service.js.map +1 -1
- package/lib/browser/file-chat-variable-contribution.js +1 -0
- package/lib/browser/file-chat-variable-contribution.js.map +1 -1
- package/lib/browser/file-read-tracker-impl.d.ts +55 -0
- package/lib/browser/file-read-tracker-impl.d.ts.map +1 -0
- package/lib/browser/file-read-tracker-impl.js +200 -0
- package/lib/browser/file-read-tracker-impl.js.map +1 -0
- package/lib/browser/file-read-tracker-impl.spec.d.ts +2 -0
- package/lib/browser/file-read-tracker-impl.spec.d.ts.map +1 -0
- package/lib/browser/file-read-tracker-impl.spec.js +251 -0
- package/lib/browser/file-read-tracker-impl.spec.js.map +1 -0
- package/lib/common/ai-chat-preferences.d.ts.map +1 -1
- package/lib/common/ai-chat-preferences.js +4 -0
- package/lib/common/ai-chat-preferences.js.map +1 -1
- package/lib/common/chat-agent-service.js +1 -0
- package/lib/common/chat-agent-service.js.map +1 -1
- package/lib/common/chat-agents.d.ts +8 -1
- package/lib/common/chat-agents.d.ts.map +1 -1
- package/lib/common/chat-agents.js +31 -2
- package/lib/common/chat-agents.js.map +1 -1
- package/lib/common/chat-agents.spec.js +51 -0
- package/lib/common/chat-agents.spec.js.map +1 -1
- package/lib/common/chat-content-deserializer.js +1 -1
- package/lib/common/chat-content-deserializer.js.map +1 -1
- package/lib/common/chat-request-parser.d.ts +1 -1
- package/lib/common/chat-request-parser.d.ts.map +1 -1
- package/lib/common/chat-request-parser.js +5 -2
- package/lib/common/chat-request-parser.js.map +1 -1
- package/lib/common/chat-request-parser.spec.js +32 -1
- package/lib/common/chat-request-parser.spec.js.map +1 -1
- package/lib/common/chat-service-session-restore.spec.d.ts +2 -0
- package/lib/common/chat-service-session-restore.spec.d.ts.map +1 -0
- package/lib/common/chat-service-session-restore.spec.js +165 -0
- package/lib/common/chat-service-session-restore.spec.js.map +1 -0
- package/lib/common/chat-service.d.ts +4 -1
- package/lib/common/chat-service.d.ts.map +1 -1
- package/lib/common/chat-service.js +17 -0
- package/lib/common/chat-service.js.map +1 -1
- package/lib/common/chat-session-summary-agent.d.ts +2 -0
- package/lib/common/chat-session-summary-agent.d.ts.map +1 -1
- package/lib/common/chat-session-summary-agent.js +5 -0
- package/lib/common/chat-session-summary-agent.js.map +1 -1
- package/lib/common/chat-tool-preferences.d.ts +2 -0
- package/lib/common/chat-tool-preferences.d.ts.map +1 -1
- package/lib/common/chat-tool-preferences.js +7 -7
- package/lib/common/chat-tool-preferences.js.map +1 -1
- package/lib/common/context-summary-variable.d.ts.map +1 -1
- package/lib/common/context-summary-variable.js +1 -1
- package/lib/common/context-summary-variable.js.map +1 -1
- package/lib/common/custom-chat-agent.d.ts +2 -0
- package/lib/common/custom-chat-agent.d.ts.map +1 -1
- package/lib/common/custom-chat-agent.js +6 -0
- package/lib/common/custom-chat-agent.js.map +1 -1
- package/lib/common/file-read-tracker.d.ts +12 -0
- package/lib/common/file-read-tracker.d.ts.map +1 -0
- package/lib/common/file-read-tracker.js +20 -0
- package/lib/common/file-read-tracker.js.map +1 -0
- package/lib/common/index.d.ts +1 -0
- package/lib/common/index.d.ts.map +1 -1
- package/lib/common/index.js +1 -0
- package/lib/common/index.js.map +1 -1
- package/package.json +11 -11
- package/src/browser/ai-chat-frontend-module.ts +5 -0
- package/src/browser/change-set-file-element.ts +8 -5
- package/src/browser/change-set-file-service.ts +2 -2
- package/src/browser/chat-session-store-impl.spec.ts +2 -3
- package/src/browser/chat-session-store-impl.ts +1 -1
- package/src/browser/chat-tool-request-service.ts +1 -1
- package/src/browser/file-chat-variable-contribution.ts +2 -2
- package/src/browser/file-read-tracker-impl.spec.ts +318 -0
- package/src/browser/file-read-tracker-impl.ts +208 -0
- package/src/common/ai-chat-preferences.ts +4 -0
- package/src/common/chat-agent-service.ts +1 -1
- package/src/common/chat-agents.spec.ts +72 -0
- package/src/common/chat-agents.ts +31 -3
- package/src/common/chat-content-deserializer.ts +1 -1
- package/src/common/chat-request-parser.spec.ts +39 -1
- package/src/common/chat-request-parser.ts +7 -4
- package/src/common/chat-service-session-restore.spec.ts +190 -0
- package/src/common/chat-service.ts +20 -3
- package/src/common/chat-session-summary-agent.ts +6 -2
- package/src/common/chat-tool-preferences.ts +2 -2
- package/src/common/context-summary-variable.ts +2 -1
- package/src/common/custom-chat-agent.ts +5 -1
- package/src/common/file-read-tracker.ts +29 -0
- package/src/common/index.ts +1 -0
|
@@ -0,0 +1,318 @@
|
|
|
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 { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
|
|
18
|
+
const disableJSDOM = enableJSDOM();
|
|
19
|
+
import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
|
|
20
|
+
FrontendApplicationConfigProvider.set({});
|
|
21
|
+
|
|
22
|
+
import { Emitter, Event, URI } from '@theia/core';
|
|
23
|
+
import { BinaryBuffer } from '@theia/core/lib/common/buffer';
|
|
24
|
+
import { FileService } from '@theia/filesystem/lib/browser/file-service';
|
|
25
|
+
import { FileChangesEvent, FileChangeType, FileOperationError, FileOperationResult, FileStat } from '@theia/filesystem/lib/common/files';
|
|
26
|
+
import { MonacoWorkspace } from '@theia/monaco/lib/browser/monaco-workspace';
|
|
27
|
+
import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
|
|
28
|
+
import { expect } from 'chai';
|
|
29
|
+
import { FileReadTrackerImpl } from './file-read-tracker-impl';
|
|
30
|
+
|
|
31
|
+
disableJSDOM();
|
|
32
|
+
|
|
33
|
+
const SESSION = 'session-1';
|
|
34
|
+
const OTHER_SESSION = 'session-2';
|
|
35
|
+
const ROOT = new URI('file:///workspace');
|
|
36
|
+
const FILE = ROOT.resolve('a.ts');
|
|
37
|
+
/** As `resolveRelativePath` expects it back: root name plus path within that root. */
|
|
38
|
+
const FILE_LABEL = 'workspace/a.ts';
|
|
39
|
+
|
|
40
|
+
/** Drives the tracker against in-memory content: `contents` stands in for the files on disk. */
|
|
41
|
+
class TestFileReadTracker extends FileReadTrackerImpl {
|
|
42
|
+
readonly contents = new Map<string, string>([[FILE.toString(), 'original']]);
|
|
43
|
+
/** Makes reading fail as a file too large to compare would, rather than as a missing one. */
|
|
44
|
+
readFails = false;
|
|
45
|
+
/** Runs once inside a read, to change the file while the tracker is still reading it. */
|
|
46
|
+
duringNextRead?: () => void;
|
|
47
|
+
/** The encoding the files are stored in: `read` decodes with it, `readFile` hands back their bytes. */
|
|
48
|
+
encoding: BufferEncoding = 'utf8';
|
|
49
|
+
protected override readonly maxFilesPerSession = 2;
|
|
50
|
+
/** The workspace roots labels are built against. */
|
|
51
|
+
readonly roots = [ROOT];
|
|
52
|
+
protected override readonly workspaceService = {
|
|
53
|
+
tryGetRoots: () => this.roots.map(resource => ({ resource } as FileStat))
|
|
54
|
+
} as WorkspaceService;
|
|
55
|
+
protected override readonly monacoWorkspace = { getTextDocument: () => undefined } as unknown as MonacoWorkspace;
|
|
56
|
+
/** Both decoders, so that a test can tell which one the tracker reads through. */
|
|
57
|
+
protected override readonly fileService = {
|
|
58
|
+
read: async (uri: URI) => ({ value: this.contentOf(uri) }),
|
|
59
|
+
readFile: async (uri: URI) => ({ value: BinaryBuffer.wrap(Buffer.from(this.contentOf(uri), this.encoding)) })
|
|
60
|
+
} as unknown as FileService;
|
|
61
|
+
protected readonly fileChangeEmitter = new Emitter<FileChangesEvent>();
|
|
62
|
+
protected readonly documentChangeEmitter = new Emitter<string>();
|
|
63
|
+
|
|
64
|
+
constructor() {
|
|
65
|
+
super();
|
|
66
|
+
// `@postConstruct` only runs on container resolution, so wire the listeners here.
|
|
67
|
+
this.init();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
protected override get fileChanges(): Event<FileChangesEvent> {
|
|
71
|
+
return this.fileChangeEmitter.event;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
protected override get documentChanges(): Event<string> {
|
|
75
|
+
return this.documentChangeEmitter.event;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Stands in for the file on disk, failing the way the file service would. */
|
|
79
|
+
protected contentOf(uri: URI): string {
|
|
80
|
+
if (this.readFails) {
|
|
81
|
+
throw new FileOperationError(`${uri} is too large`, FileOperationResult.FILE_TOO_LARGE);
|
|
82
|
+
}
|
|
83
|
+
const content = this.contents.get(uri.toString());
|
|
84
|
+
if (content === undefined) {
|
|
85
|
+
throw new FileOperationError(`${uri} does not exist`, FileOperationResult.FILE_NOT_FOUND);
|
|
86
|
+
}
|
|
87
|
+
const during = this.duringNextRead;
|
|
88
|
+
this.duringNextRead = undefined;
|
|
89
|
+
during?.();
|
|
90
|
+
return content;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Records what the agent saw, as `getFileContent` does. */
|
|
94
|
+
agentRead(uri = FILE, sessionId = SESSION): Promise<void> {
|
|
95
|
+
return this.recordRead(sessionId, uri, this.contents.get(uri.toString()));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Changes content behind the agent's back and notifies the tracker, as the workspace would. */
|
|
99
|
+
somebodyElseChanges(content: string, uri = FILE): void {
|
|
100
|
+
this.contents.set(uri.toString(), content);
|
|
101
|
+
this.fileChanged(uri);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
somebodyElseDeletes(uri = FILE): void {
|
|
105
|
+
this.contents.delete(uri.toString());
|
|
106
|
+
this.fileChanged(uri);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
fileChanged(...uris: URI[]): void {
|
|
110
|
+
this.fileChangeEmitter.fire(new FileChangesEvent(uris.map(resource => ({ resource, type: FileChangeType.UPDATED }))));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
documentChanged(uri = FILE): void {
|
|
114
|
+
this.documentChangeEmitter.fire(uri.toString());
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
describe('FileReadTrackerImpl', () => {
|
|
119
|
+
|
|
120
|
+
it('reports a file that changed after it was read', async () => {
|
|
121
|
+
const tracker = new TestFileReadTracker();
|
|
122
|
+
await tracker.agentRead();
|
|
123
|
+
|
|
124
|
+
tracker.somebodyElseChanges('changed');
|
|
125
|
+
|
|
126
|
+
expect(await tracker.getChangedFiles(SESSION)).to.deep.equal([FILE_LABEL]);
|
|
127
|
+
expect(await tracker.isStale(SESSION, FILE)).to.be.true;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('reports a file whose unsaved document changed', async () => {
|
|
131
|
+
const tracker = new TestFileReadTracker();
|
|
132
|
+
await tracker.agentRead();
|
|
133
|
+
|
|
134
|
+
tracker.contents.set(FILE.toString(), 'the user typed something');
|
|
135
|
+
tracker.documentChanged();
|
|
136
|
+
|
|
137
|
+
expect(await tracker.getChangedFiles(SESSION)).to.deep.equal([FILE_LABEL]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('keeps reporting a change until the file is read again', async () => {
|
|
141
|
+
const tracker = new TestFileReadTracker();
|
|
142
|
+
await tracker.agentRead();
|
|
143
|
+
tracker.somebodyElseChanges('changed');
|
|
144
|
+
expect(await tracker.getChangedFiles(SESSION)).to.have.lengthOf(1);
|
|
145
|
+
expect(await tracker.getChangedFiles(SESSION)).to.have.lengthOf(1);
|
|
146
|
+
|
|
147
|
+
await tracker.agentRead();
|
|
148
|
+
|
|
149
|
+
expect(await tracker.getChangedFiles(SESSION)).to.be.empty;
|
|
150
|
+
expect(await tracker.isStale(SESSION, FILE)).to.be.false;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('ignores files that were never read', async () => {
|
|
154
|
+
const tracker = new TestFileReadTracker();
|
|
155
|
+
|
|
156
|
+
tracker.somebodyElseChanges('changed');
|
|
157
|
+
|
|
158
|
+
expect(await tracker.getChangedFiles(SESSION)).to.be.empty;
|
|
159
|
+
expect(await tracker.isStale(SESSION, FILE)).to.be.false;
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('ignores a change that left the content untouched', async () => {
|
|
163
|
+
const tracker = new TestFileReadTracker();
|
|
164
|
+
await tracker.agentRead();
|
|
165
|
+
|
|
166
|
+
// e.g. saving an unmodified document, or a formatter that had nothing to do
|
|
167
|
+
tracker.fileChanged(FILE);
|
|
168
|
+
|
|
169
|
+
expect(await tracker.getChangedFiles(SESSION)).to.be.empty;
|
|
170
|
+
expect(await tracker.isStale(SESSION, FILE)).to.be.false;
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('stops reporting once an edit is undone', async () => {
|
|
174
|
+
const tracker = new TestFileReadTracker();
|
|
175
|
+
await tracker.agentRead();
|
|
176
|
+
tracker.somebodyElseChanges('changed');
|
|
177
|
+
expect(await tracker.getChangedFiles(SESSION)).to.have.lengthOf(1);
|
|
178
|
+
|
|
179
|
+
tracker.somebodyElseChanges('original');
|
|
180
|
+
|
|
181
|
+
expect(await tracker.getChangedFiles(SESSION)).to.be.empty;
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('reports a deleted file once, then forgets it and allows writing it', async () => {
|
|
185
|
+
const tracker = new TestFileReadTracker();
|
|
186
|
+
await tracker.agentRead();
|
|
187
|
+
|
|
188
|
+
tracker.somebodyElseDeletes();
|
|
189
|
+
|
|
190
|
+
expect(await tracker.getChangedFiles(SESSION)).to.deep.equal([FILE_LABEL]);
|
|
191
|
+
expect(await tracker.getChangedFiles(SESSION)).to.be.empty;
|
|
192
|
+
expect(await tracker.isStale(SESSION, FILE)).to.be.false;
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('forgets a file that no longer exists when it is recorded', async () => {
|
|
196
|
+
const tracker = new TestFileReadTracker();
|
|
197
|
+
await tracker.agentRead();
|
|
198
|
+
tracker.somebodyElseDeletes();
|
|
199
|
+
|
|
200
|
+
// as the change set element does after applying a deletion
|
|
201
|
+
await tracker.recordRead(SESSION, FILE);
|
|
202
|
+
|
|
203
|
+
expect(await tracker.getChangedFiles(SESSION)).to.be.empty;
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('keeps reporting a file it cannot read, and does not allow overwriting it', async () => {
|
|
207
|
+
const tracker = new TestFileReadTracker();
|
|
208
|
+
await tracker.agentRead();
|
|
209
|
+
tracker.fileChanged(FILE);
|
|
210
|
+
tracker.readFails = true;
|
|
211
|
+
|
|
212
|
+
expect(await tracker.getChangedFiles(SESSION)).to.deep.equal([FILE_LABEL]);
|
|
213
|
+
expect(await tracker.getChangedFiles(SESSION)).to.deep.equal([FILE_LABEL]);
|
|
214
|
+
expect(await tracker.isStale(SESSION, FILE)).to.be.true;
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('reports a change made while the read recording it was still in flight', async () => {
|
|
218
|
+
const tracker = new TestFileReadTracker();
|
|
219
|
+
tracker.duringNextRead = () => {
|
|
220
|
+
tracker.contents.set(FILE.toString(), 'the user typed something');
|
|
221
|
+
tracker.documentChanged();
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// no content handed in, so the tracker reads it itself
|
|
225
|
+
await tracker.recordRead(SESSION, FILE);
|
|
226
|
+
|
|
227
|
+
expect(await tracker.getChangedFiles(SESSION)).to.deep.equal([FILE_LABEL]);
|
|
228
|
+
expect(await tracker.isStale(SESSION, FILE)).to.be.true;
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('records nothing when the content cannot be read', async () => {
|
|
232
|
+
const tracker = new TestFileReadTracker();
|
|
233
|
+
tracker.readFails = true;
|
|
234
|
+
|
|
235
|
+
await tracker.recordRead(SESSION, FILE);
|
|
236
|
+
|
|
237
|
+
expect(await tracker.getChangedFiles(SESSION)).to.be.empty;
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('treats a write by another session as an external change', async () => {
|
|
241
|
+
const tracker = new TestFileReadTracker();
|
|
242
|
+
await tracker.agentRead();
|
|
243
|
+
await tracker.agentRead(FILE, OTHER_SESSION);
|
|
244
|
+
|
|
245
|
+
tracker.somebodyElseChanges('written by the other session');
|
|
246
|
+
await tracker.recordRead(OTHER_SESSION, FILE);
|
|
247
|
+
|
|
248
|
+
expect(await tracker.getChangedFiles(OTHER_SESSION)).to.be.empty;
|
|
249
|
+
expect(await tracker.getChangedFiles(SESSION)).to.deep.equal([FILE_LABEL]);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('compares a file the read tools decode as UTF-16, not as UTF-8', async () => {
|
|
253
|
+
const tracker = new TestFileReadTracker();
|
|
254
|
+
tracker.encoding = 'utf16le'; // as a Windows-written `.ps1` is stored
|
|
255
|
+
await tracker.agentRead();
|
|
256
|
+
|
|
257
|
+
tracker.fileChanged(FILE);
|
|
258
|
+
|
|
259
|
+
expect(await tracker.getChangedFiles(SESSION)).to.be.empty;
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe('naming changed files', () => {
|
|
263
|
+
|
|
264
|
+
/** How the notice names `file` after it changed, in a workspace with the given extra roots. */
|
|
265
|
+
async function nameOf(file: URI, ...extraRoots: URI[]): Promise<string[]> {
|
|
266
|
+
const tracker = new TestFileReadTracker();
|
|
267
|
+
tracker.roots.push(...extraRoots);
|
|
268
|
+
tracker.contents.set(file.toString(), 'original');
|
|
269
|
+
await tracker.agentRead(file);
|
|
270
|
+
tracker.somebodyElseChanges('changed', file);
|
|
271
|
+
return tracker.getChangedFiles(SESSION);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
it('names a file by the root it is in', async () => {
|
|
275
|
+
expect(await nameOf(new URI('file:///elsewhere/lib/b.ts'), new URI('file:///elsewhere/lib'))).to.deep.equal(['lib/b.ts']);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('names a file by its root when that root is listed twice', async () => {
|
|
279
|
+
expect(await nameOf(FILE, ROOT)).to.deep.equal([FILE_LABEL]);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('names a file by uri when two roots share the name it would use', async () => {
|
|
283
|
+
expect(await nameOf(FILE, new URI('file:///elsewhere/workspace'))).to.deep.equal([FILE.toString()]);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('names a file by uri when it is outside every root', async () => {
|
|
287
|
+
expect(await nameOf(new URI('file:///etc/hosts'))).to.deep.equal(['file:///etc/hosts']);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('evicts the least recently recorded file beyond the per-session limit', async () => {
|
|
292
|
+
const tracker = new TestFileReadTracker();
|
|
293
|
+
const files = ['a', 'b', 'c'].map(name => new URI(`file:///workspace/${name}.ts`));
|
|
294
|
+
for (const uri of files) {
|
|
295
|
+
tracker.contents.set(uri.toString(), 'original');
|
|
296
|
+
await tracker.agentRead(uri);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
files.forEach(uri => tracker.somebodyElseChanges('changed', uri));
|
|
300
|
+
|
|
301
|
+
expect(await tracker.getChangedFiles(SESSION)).to.deep.equal(['workspace/b.ts', 'workspace/c.ts']);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('keeps a re-read file tracked, evicting the one untouched for longest', async () => {
|
|
305
|
+
const tracker = new TestFileReadTracker();
|
|
306
|
+
const [a, b, c] = ['a', 'b', 'c'].map(name => new URI(`file:///workspace/${name}.ts`));
|
|
307
|
+
[a, b, c].forEach(uri => tracker.contents.set(uri.toString(), 'original'));
|
|
308
|
+
await tracker.agentRead(a);
|
|
309
|
+
await tracker.agentRead(b);
|
|
310
|
+
await tracker.agentRead(a);
|
|
311
|
+
await tracker.agentRead(c);
|
|
312
|
+
|
|
313
|
+
tracker.somebodyElseChanges('changed', a);
|
|
314
|
+
tracker.somebodyElseChanges('changed', b);
|
|
315
|
+
|
|
316
|
+
expect(await tracker.getChangedFiles(SESSION)).to.deep.equal(['workspace/a.ts']);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -72,6 +72,10 @@ export const aiChatPreferences: PreferenceSchema = {
|
|
|
72
72
|
[SESSION_STORAGE_PREF]: {
|
|
73
73
|
type: 'string',
|
|
74
74
|
enum: ['workspace', 'global'] satisfies SessionStorageScope[],
|
|
75
|
+
enumItemLabels: [
|
|
76
|
+
nls.localizeByDefault('Workspace'),
|
|
77
|
+
nls.localizeByDefault('Global')
|
|
78
|
+
],
|
|
75
79
|
enumDescriptions: [
|
|
76
80
|
nls.localize('theia/ai/chat/sessionStorageScope/workspace',
|
|
77
81
|
'Store chat sessions in workspace-specific metadata storage. Sessions are associated with the workspace but stored outside the workspace directory.'),
|
|
@@ -130,7 +130,7 @@ export class ChatAgentServiceImpl implements ChatAgentService {
|
|
|
130
130
|
@inject(ContributionProvider) @named(ChatAgent)
|
|
131
131
|
protected readonly agentContributions: ContributionProvider<ChatAgent>;
|
|
132
132
|
|
|
133
|
-
@inject(ILogger)
|
|
133
|
+
@inject(ILogger) @named('ai-chat:ChatAgentServiceImpl')
|
|
134
134
|
protected readonly logger: ILogger;
|
|
135
135
|
|
|
136
136
|
@inject(AgentService)
|
|
@@ -33,12 +33,16 @@ 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';
|
|
39
42
|
readonly name = 'Test Agent';
|
|
40
43
|
readonly languageModelRequirements: LanguageModelRequirement[] = [];
|
|
41
44
|
protected readonly defaultLanguageModelPurpose = 'chat';
|
|
45
|
+
protected override logger: ILogger = new MockLogger();
|
|
42
46
|
|
|
43
47
|
protected addContentsToResponse(): Promise<void> {
|
|
44
48
|
return Promise.resolve();
|
|
@@ -59,6 +63,15 @@ class TestChatAgent extends AbstractChatAgent {
|
|
|
59
63
|
public setLanguageModelRegistry(registry: LanguageModelRegistry): void {
|
|
60
64
|
this.languageModelRegistry = registry;
|
|
61
65
|
}
|
|
66
|
+
|
|
67
|
+
public exposeAppendExternalFileChangeNotice(request: MutableChatRequestModel, messages: LanguageModelMessage[]): Promise<void> {
|
|
68
|
+
return this.appendExternalFileChangeNotice(request, messages);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
public setFileReadTracker(tracker: FileReadTracker): void {
|
|
72
|
+
this.fileReadTracker = tracker;
|
|
73
|
+
}
|
|
74
|
+
|
|
62
75
|
}
|
|
63
76
|
|
|
64
77
|
function createParsedRequest(text: string, request?: Partial<ChatRequest>): ParsedChatRequest {
|
|
@@ -201,6 +214,7 @@ class StreamParsingTestChatAgent extends AbstractStreamParsingChatAgent {
|
|
|
201
214
|
readonly name = 'Stream Test Agent';
|
|
202
215
|
readonly languageModelRequirements: LanguageModelRequirement[] = [];
|
|
203
216
|
protected readonly defaultLanguageModelPurpose = 'chat';
|
|
217
|
+
protected override logger: ILogger = new MockLogger();
|
|
204
218
|
|
|
205
219
|
exposeParse(token: LanguageModelStreamResponsePart): ChatResponseContent | ChatResponseContent[] {
|
|
206
220
|
return this.parse(token, undefined as never);
|
|
@@ -292,3 +306,61 @@ describe('AbstractChatAgent.getLanguageModelForRequest', () => {
|
|
|
292
306
|
expect(error).to.be.an('error');
|
|
293
307
|
});
|
|
294
308
|
});
|
|
309
|
+
|
|
310
|
+
describe('AbstractChatAgent.appendExternalFileChangeNotice', () => {
|
|
311
|
+
|
|
312
|
+
function createAgent(getChangedFiles: () => Promise<string[]>): TestChatAgent {
|
|
313
|
+
const agent = new TestChatAgent();
|
|
314
|
+
agent.setFileReadTracker({
|
|
315
|
+
recordRead: async () => { },
|
|
316
|
+
isStale: async () => false,
|
|
317
|
+
getChangedFiles
|
|
318
|
+
});
|
|
319
|
+
return agent;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function createRequest(): MutableChatRequestModel {
|
|
323
|
+
return new MutableChatModel(ChatAgentLocation.Panel).addRequest(createParsedRequest('Hello'));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function textsOf(messages: LanguageModelMessage[]): string[] {
|
|
327
|
+
return messages.flatMap(message => message.type === 'text' ? [message.text] : []);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
it('appends a trailing user message listing the changed files', async () => {
|
|
331
|
+
const agent = createAgent(async () => ['/workspace/a.ts', '/workspace/b.ts']);
|
|
332
|
+
const messages: LanguageModelMessage[] = [];
|
|
333
|
+
|
|
334
|
+
await agent.exposeAppendExternalFileChangeNotice(createRequest(), messages);
|
|
335
|
+
|
|
336
|
+
expect(messages).to.have.lengthOf(1);
|
|
337
|
+
expect(messages[0].actor).to.equal('user');
|
|
338
|
+
expect(textsOf(messages)[0]).to.contain('/workspace/a.ts').and.to.contain('/workspace/b.ts');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('appends nothing when no file changed', async () => {
|
|
342
|
+
const agent = createAgent(async () => []);
|
|
343
|
+
const messages: LanguageModelMessage[] = [];
|
|
344
|
+
|
|
345
|
+
await agent.exposeAppendExternalFileChangeNotice(createRequest(), messages);
|
|
346
|
+
|
|
347
|
+
expect(messages).to.be.empty;
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('appends nothing when no tracker is bound', async () => {
|
|
351
|
+
const messages: LanguageModelMessage[] = [];
|
|
352
|
+
|
|
353
|
+
await new TestChatAgent().exposeAppendExternalFileChangeNotice(createRequest(), messages);
|
|
354
|
+
|
|
355
|
+
expect(messages).to.be.empty;
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('does not fail the request when the changed files cannot be determined', async () => {
|
|
359
|
+
const agent = createAgent(async () => { throw new Error('tracker unavailable'); });
|
|
360
|
+
const messages: LanguageModelMessage[] = [];
|
|
361
|
+
|
|
362
|
+
await agent.exposeAppendExternalFileChangeNotice(createRequest(), messages);
|
|
363
|
+
|
|
364
|
+
expect(messages).to.be.empty;
|
|
365
|
+
});
|
|
366
|
+
});
|