@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 @@
1
+ {"version":3,"file":"file-read-tracker.d.ts","sourceRoot":"","sources":["../../src/common/file-read-tracker.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAE,GAAG,EAAE,MAAM,aAAa,CAAC;AAElC,eAAO,MAAM,eAAe,eAA4B,CAAC;AAEzD,wHAAwH;AACxH,MAAM,WAAW,eAAe;IAC5B,mIAAmI;IACnI,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACzE,gJAAgJ;IAChJ,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACvD,uIAAuI;IACvI,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;CACzD"}
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ // *****************************************************************************
3
+ // Copyright (C) 2026 Ehab Younes.
4
+ //
5
+ // This program and the accompanying materials are made available under the
6
+ // terms of the Eclipse Public License v. 2.0 which is available at
7
+ // http://www.eclipse.org/legal/epl-2.0.
8
+ //
9
+ // This Source Code may also be made available under the following Secondary
10
+ // Licenses when the conditions for such availability set forth in the Eclipse
11
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
12
+ // with the GNU Classpath Exception which is available at
13
+ // https://www.gnu.org/software/classpath/license.html.
14
+ //
15
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
16
+ // *****************************************************************************
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.FileReadTracker = void 0;
19
+ exports.FileReadTracker = Symbol('FileReadTracker');
20
+ //# sourceMappingURL=file-read-tracker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file-read-tracker.js","sourceRoot":"","sources":["../../src/common/file-read-tracker.ts"],"names":[],"mappings":";AAAA,gFAAgF;AAChF,kCAAkC;AAClC,EAAE;AACF,2EAA2E;AAC3E,mEAAmE;AACnE,wCAAwC;AACxC,EAAE;AACF,4EAA4E;AAC5E,8EAA8E;AAC9E,6EAA6E;AAC7E,yDAAyD;AACzD,uDAAuD;AACvD,EAAE;AACF,gFAAgF;AAChF,gFAAgF;;;AAInE,QAAA,eAAe,GAAG,MAAM,CAAC,iBAAiB,CAAC,CAAC"}
@@ -12,6 +12,7 @@ export * from './custom-chat-agent';
12
12
  export * from './parsed-chat-request';
13
13
  export * from './context-variables';
14
14
  export * from './chat-tool-request-service';
15
+ export * from './file-read-tracker';
15
16
  export * from './chat-tool-confirmation-timeout';
16
17
  export * from './provider-error-formatter';
17
18
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/common/index.ts"],"names":[],"mappings":"AAeA,cAAc,eAAe,CAAC;AAC9B,cAAc,sBAAsB,CAAC;AACrC,cAAc,qCAAqC,CAAC;AACpD,cAAc,cAAc,CAAC;AAC7B,cAAc,4BAA4B,CAAC;AAC3C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,mBAAmB,CAAC;AAClC,cAAc,uBAAuB,CAAC;AACtC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,sBAAsB,CAAC;AACrC,cAAc,qBAAqB,CAAC;AACpC,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,kCAAkC,CAAC;AACjD,cAAc,4BAA4B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/common/index.ts"],"names":[],"mappings":"AAeA,cAAc,eAAe,CAAC;AAC9B,cAAc,sBAAsB,CAAC;AACrC,cAAc,qCAAqC,CAAC;AACpD,cAAc,cAAc,CAAC;AAC7B,cAAc,4BAA4B,CAAC;AAC3C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,mBAAmB,CAAC;AAClC,cAAc,uBAAuB,CAAC;AACtC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,sBAAsB,CAAC;AACrC,cAAc,qBAAqB,CAAC;AACpC,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,qBAAqB,CAAC;AACpC,cAAc,kCAAkC,CAAC;AACjD,cAAc,4BAA4B,CAAC"}
@@ -30,6 +30,7 @@ tslib_1.__exportStar(require("./custom-chat-agent"), exports);
30
30
  tslib_1.__exportStar(require("./parsed-chat-request"), exports);
31
31
  tslib_1.__exportStar(require("./context-variables"), exports);
32
32
  tslib_1.__exportStar(require("./chat-tool-request-service"), exports);
33
+ tslib_1.__exportStar(require("./file-read-tracker"), exports);
33
34
  tslib_1.__exportStar(require("./chat-tool-confirmation-timeout"), exports);
34
35
  tslib_1.__exportStar(require("./provider-error-formatter"), exports);
35
36
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/common/index.ts"],"names":[],"mappings":";;;AAAA,gFAAgF;AAChF,yCAAyC;AACzC,EAAE;AACF,2EAA2E;AAC3E,mEAAmE;AACnE,wCAAwC;AACxC,EAAE;AACF,4EAA4E;AAC5E,8EAA8E;AAC9E,6EAA6E;AAC7E,yDAAyD;AACzD,uDAAuD;AACvD,EAAE;AACF,gFAAgF;AAChF,gFAAgF;AAChF,wDAA8B;AAC9B,+DAAqC;AACrC,8EAAoD;AACpD,uDAA6B;AAC7B,qEAA2C;AAC3C,sEAA4C;AAC5C,4DAAkC;AAClC,gEAAsC;AACtC,yDAA+B;AAC/B,+DAAqC;AACrC,8DAAoC;AACpC,gEAAsC;AACtC,8DAAoC;AACpC,sEAA4C;AAC5C,2EAAiD;AACjD,qEAA2C"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/common/index.ts"],"names":[],"mappings":";;;AAAA,gFAAgF;AAChF,yCAAyC;AACzC,EAAE;AACF,2EAA2E;AAC3E,mEAAmE;AACnE,wCAAwC;AACxC,EAAE;AACF,4EAA4E;AAC5E,8EAA8E;AAC9E,6EAA6E;AAC7E,yDAAyD;AACzD,uDAAuD;AACvD,EAAE;AACF,gFAAgF;AAChF,gFAAgF;AAChF,wDAA8B;AAC9B,+DAAqC;AACrC,8EAAoD;AACpD,uDAA6B;AAC7B,qEAA2C;AAC3C,sEAA4C;AAC5C,4DAAkC;AAClC,gEAAsC;AACtC,yDAA+B;AAC/B,+DAAqC;AACrC,8DAAoC;AACpC,gEAAsC;AACtC,8DAAoC;AACpC,sEAA4C;AAC5C,8DAAoC;AACpC,2EAAiD;AACjD,qEAA2C"}
package/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "@theia/ai-chat",
3
- "version": "1.75.0-next.21+fa058c354",
3
+ "version": "1.75.0-next.28+ba1005c0bf8",
4
4
  "description": "Theia - AI Chat Extension",
5
5
  "dependencies": {
6
- "@theia/ai-core": "1.75.0-next.21+fa058c354",
7
- "@theia/core": "1.75.0-next.21+fa058c354",
8
- "@theia/editor": "1.75.0-next.21+fa058c354",
9
- "@theia/file-search": "1.75.0-next.21+fa058c354",
10
- "@theia/filesystem": "1.75.0-next.21+fa058c354",
11
- "@theia/monaco": "1.75.0-next.21+fa058c354",
6
+ "@theia/ai-core": "1.75.0-next.28+ba1005c0bf8",
7
+ "@theia/core": "1.75.0-next.28+ba1005c0bf8",
8
+ "@theia/editor": "1.75.0-next.28+ba1005c0bf8",
9
+ "@theia/file-search": "1.75.0-next.28+ba1005c0bf8",
10
+ "@theia/filesystem": "1.75.0-next.28+ba1005c0bf8",
11
+ "@theia/monaco": "1.75.0-next.28+ba1005c0bf8",
12
12
  "@theia/monaco-editor-core": "1.108.201",
13
- "@theia/workspace": "1.75.0-next.21+fa058c354",
13
+ "@theia/workspace": "1.75.0-next.28+ba1005c0bf8",
14
14
  "js-yaml": "^4.3.1",
15
15
  "tslib": "^2.8.1"
16
16
  },
@@ -54,5 +54,5 @@
54
54
  "nyc": {
55
55
  "extends": "../../configs/nyc.json"
56
56
  },
57
- "gitHead": "fa058c354e5bfa630cb6766d042131cc8de8a7ff"
57
+ "gitHead": "ba1005c0bf87bd848c6ec51ce1b4e6bb06748e3c"
58
58
  }
@@ -42,6 +42,8 @@ import { CustomAgentFactory } from './custom-agent-factory';
42
42
  import { ChatToolRequestService } from '../common/chat-tool-request-service';
43
43
  import { FrontendChatToolRequestService } from './chat-tool-request-service';
44
44
  import { ChangeSetFileService } from './change-set-file-service';
45
+ import { FileReadTracker } from '../common/file-read-tracker';
46
+ import { FileReadTrackerImpl } from './file-read-tracker-impl';
45
47
  import { ContextVariableLabelProvider } from './context-variable-label-provider';
46
48
  import { ContextFileVariableLabelProvider } from './context-file-variable-label-provider';
47
49
  import { FileChatVariableContribution } from './file-chat-variable-contribution';
@@ -168,6 +170,9 @@ export default new ContainerModule(bind => {
168
170
  bind(ContextFileVariableLabelProvider).toSelf().inSingletonScope();
169
171
  bind(LabelProviderContribution).toService(ContextFileVariableLabelProvider);
170
172
 
173
+ bind(FileReadTrackerImpl).toSelf().inSingletonScope();
174
+ bind(FileReadTracker).toService(FileReadTrackerImpl);
175
+
171
176
  bind(ChangeSetFileService).toSelf().inSingletonScope();
172
177
  bind(ChangeSetFileElementFactory).toFactory(ctx => (args: ChangeSetElementArgs) => {
173
178
  const container = ctx.container.createChild();
@@ -34,6 +34,7 @@ import { insertFinalNewline } from '@theia/monaco/lib/browser/monaco-utilities';
34
34
  import { MonacoEditorModel } from '@theia/monaco/lib/browser/monaco-editor-model';
35
35
  import { MonacoWorkspace } from '@theia/monaco/lib/browser/monaco-workspace';
36
36
  import { ChangeSetElement } from '../common';
37
+ import { FileReadTracker } from '../common/file-read-tracker';
37
38
  import { SerializableChangeSetElement } from '../common/chat-model-serialization';
38
39
  import { createChangeSetFileUri } from './change-set-file-resource';
39
40
  import { ChangeSetFileService } from './change-set-file-service';
@@ -106,6 +107,9 @@ export class ChangeSetFileElement implements ChangeSetElement {
106
107
  @inject(MonacoWorkspace)
107
108
  protected readonly monacoWorkspace: MonacoWorkspace;
108
109
 
110
+ @inject(FileReadTracker)
111
+ protected readonly fileReadTracker: FileReadTracker;
112
+
109
113
  protected readonly toDispose = new DisposableCollection();
110
114
  protected _state: ChangeSetElementState;
111
115
 
@@ -303,13 +307,12 @@ export class ChangeSetFileElement implements ChangeSetElement {
303
307
  if (this.type === 'delete') {
304
308
  await this.changeSetFileService.delete(this.uri);
305
309
  this.state = 'applied';
306
- this.changeSetFileService.closeDiff(this.readOnlyUri);
307
- return;
310
+ } else {
311
+ await this.applyChangesWithMonaco(contents);
308
312
  }
309
-
310
- // Load Monaco model for the base file URI and apply changes
311
- await this.applyChangesWithMonaco(contents);
312
313
  this.changeSetFileService.closeDiff(this.readOnlyUri);
314
+ // Re-snapshot so the agent's own write is not reported back as external, reading because code actions and formatting on save alter the target state.
315
+ await this.fileReadTracker.recordRead(this.elementProps.chatSessionId, this.uri);
313
316
  }
314
317
 
315
318
  async writeChanges(contents?: string): Promise<void> {
@@ -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
+ });