@theia/ai-ide 1.71.0-next.72 → 1.71.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/lib/browser/frontend-module.d.ts.map +1 -1
  2. package/lib/browser/frontend-module.js +8 -0
  3. package/lib/browser/frontend-module.js.map +1 -1
  4. package/lib/browser/review/pr-review-agent.d.ts +19 -0
  5. package/lib/browser/review/pr-review-agent.d.ts.map +1 -0
  6. package/lib/browser/review/pr-review-agent.js +47 -0
  7. package/lib/browser/review/pr-review-agent.js.map +1 -0
  8. package/lib/browser/review/pr-review-prompt-template.d.ts +4 -0
  9. package/lib/browser/review/pr-review-prompt-template.d.ts.map +1 -0
  10. package/lib/browser/review/pr-review-prompt-template.js +437 -0
  11. package/lib/browser/review/pr-review-prompt-template.js.map +1 -0
  12. package/lib/browser/user-interaction-tool-renderer.d.ts +18 -0
  13. package/lib/browser/user-interaction-tool-renderer.d.ts.map +1 -0
  14. package/lib/browser/user-interaction-tool-renderer.js +330 -0
  15. package/lib/browser/user-interaction-tool-renderer.js.map +1 -0
  16. package/lib/browser/user-interaction-tool.d.ts +47 -0
  17. package/lib/browser/user-interaction-tool.d.ts.map +1 -0
  18. package/lib/browser/user-interaction-tool.js +397 -0
  19. package/lib/browser/user-interaction-tool.js.map +1 -0
  20. package/lib/browser/user-interaction-tool.spec.d.ts +2 -0
  21. package/lib/browser/user-interaction-tool.spec.d.ts.map +1 -0
  22. package/lib/browser/user-interaction-tool.spec.js +336 -0
  23. package/lib/browser/user-interaction-tool.spec.js.map +1 -0
  24. package/lib/common/user-interaction-tool.d.ts +53 -0
  25. package/lib/common/user-interaction-tool.d.ts.map +1 -0
  26. package/lib/common/user-interaction-tool.js +176 -0
  27. package/lib/common/user-interaction-tool.js.map +1 -0
  28. package/lib/common/user-interaction-tool.spec.d.ts +2 -0
  29. package/lib/common/user-interaction-tool.spec.d.ts.map +1 -0
  30. package/lib/common/user-interaction-tool.spec.js +216 -0
  31. package/lib/common/user-interaction-tool.spec.js.map +1 -0
  32. package/package.json +22 -22
  33. package/src/browser/frontend-module.ts +9 -0
  34. package/src/browser/review/pr-review-agent.ts +42 -0
  35. package/src/browser/review/pr-review-prompt-template.ts +449 -0
  36. package/src/browser/style/index.css +299 -0
  37. package/src/browser/user-interaction-tool-renderer.tsx +531 -0
  38. package/src/browser/user-interaction-tool.spec.ts +396 -0
  39. package/src/browser/user-interaction-tool.ts +423 -0
  40. package/src/common/user-interaction-tool.spec.ts +241 -0
  41. package/src/common/user-interaction-tool.ts +237 -0
@@ -0,0 +1,396 @@
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 { expect } from 'chai';
18
+ import * as sinon from 'sinon';
19
+ import { Container } from '@theia/core/shared/inversify';
20
+ import { UserInteractionTool } from './user-interaction-tool';
21
+ import { UserInteractionResult } from '../common/user-interaction-tool';
22
+ import { WorkspaceFunctionScope } from './workspace-functions';
23
+ import { OpenerService } from '@theia/core/lib/browser';
24
+ import { EditorManager } from '@theia/editor/lib/browser';
25
+ import { ScmService } from '@theia/scm/lib/browser/scm-service';
26
+ import { URI } from '@theia/core/lib/common/uri';
27
+ import { CancellationTokenSource } from '@theia/core/lib/common/cancellation';
28
+ import { MEMORY_TEXT, MEMORY_TEXT_READONLY, ResourceProvider } from '@theia/core/lib/common/resource';
29
+ import { DiffUris } from '@theia/core/lib/browser/diff-uris';
30
+
31
+ const singleStepArgs = (overrides: Record<string, unknown> = {}) => JSON.stringify({
32
+ interactions: [{
33
+ title: 'Choose',
34
+ message: 'Pick one',
35
+ options: [{ text: 'A', value: 'a' }, { text: 'B', value: 'b' }],
36
+ ...overrides
37
+ }]
38
+ });
39
+
40
+ const parseResult = (raw: unknown): UserInteractionResult => JSON.parse(raw as string);
41
+
42
+ describe('UserInteractionTool', () => {
43
+ let container: Container;
44
+ let tool: UserInteractionTool;
45
+ let mockOpenerService: sinon.SinonStubbedInstance<OpenerService>;
46
+ let mockEditorManager: Partial<EditorManager>;
47
+ let mockWorkspaceScope: Partial<WorkspaceFunctionScope>;
48
+ let mockScmService: Partial<ScmService>;
49
+ let mockResourceProvider: sinon.SinonStub;
50
+ const workspaceRoot = new URI('file:///workspace');
51
+
52
+ beforeEach(() => {
53
+ container = new Container();
54
+
55
+ mockWorkspaceScope = {
56
+ getWorkspaceRoot: sinon.stub().resolves(workspaceRoot),
57
+ ensureWithinWorkspace: sinon.stub()
58
+ };
59
+
60
+ mockOpenerService = {
61
+ getOpeners: sinon.stub().resolves([]),
62
+ getOpener: sinon.stub().resolves({
63
+ open: sinon.stub().resolves(undefined)
64
+ })
65
+ } as unknown as sinon.SinonStubbedInstance<OpenerService>;
66
+
67
+ mockEditorManager = {
68
+ open: sinon.stub().resolves(undefined)
69
+ };
70
+
71
+ mockScmService = {
72
+ findRepository: sinon.stub().returns(undefined)
73
+ };
74
+
75
+ mockResourceProvider = sinon.stub().callsFake(async (uri: URI) => ({
76
+ uri,
77
+ readContents: sinon.stub().resolves(''),
78
+ dispose: sinon.stub()
79
+ }));
80
+
81
+ container.bind(WorkspaceFunctionScope).toConstantValue(mockWorkspaceScope as WorkspaceFunctionScope);
82
+ container.bind(ScmService).toConstantValue(mockScmService as ScmService);
83
+ container.bind(OpenerService).toConstantValue(mockOpenerService as unknown as OpenerService);
84
+ container.bind(EditorManager).toConstantValue(mockEditorManager as EditorManager);
85
+ container.bind(ResourceProvider).toConstantValue(mockResourceProvider as unknown as ResourceProvider);
86
+ container.bind(UserInteractionTool).toSelf();
87
+
88
+ tool = container.get(UserInteractionTool);
89
+ });
90
+
91
+ afterEach(() => {
92
+ sinon.restore();
93
+ });
94
+
95
+ it('should return error when no interactions are provided', async () => {
96
+ const handler = tool.getTool().handler;
97
+ const result = await handler(JSON.stringify({ interactions: [] }), { toolCallId: 'x' });
98
+ expect(JSON.parse(result as string).error).to.equal('No interactions provided');
99
+ });
100
+
101
+ it('should return error when arguments are invalid JSON', async () => {
102
+ const handler = tool.getTool().handler;
103
+ const result = await handler('not-json', { toolCallId: 'x' });
104
+ expect(JSON.parse(result as string).error).to.equal('Invalid arguments');
105
+ });
106
+
107
+ it('should return error when no tool call ID is available', async () => {
108
+ const handler = tool.getTool().handler;
109
+ const result = await handler(singleStepArgs(), undefined);
110
+ expect(JSON.parse(result as string).error).to.equal('No tool call ID available');
111
+ });
112
+
113
+ it('should return single-step result on completion', async () => {
114
+ const handler = tool.getTool().handler;
115
+ const handlerPromise = handler(singleStepArgs(), { toolCallId: 'call-1' });
116
+
117
+ tool.setStepResult('call-1', 0, { value: 'b' });
118
+ tool.completeInteraction('call-1');
119
+
120
+ const result = parseResult(await handlerPromise);
121
+ expect(result.completed).to.be.true;
122
+ expect(result.steps).to.have.length(1);
123
+ expect(result.steps[0]).to.deep.equal({ title: 'Choose', value: 'b' });
124
+ });
125
+
126
+ it('should atomically set and complete via completeInteractionWith', async () => {
127
+ const handler = tool.getTool().handler;
128
+ const handlerPromise = handler(singleStepArgs(), { toolCallId: 'call-with' });
129
+
130
+ tool.completeInteractionWith('call-with', 0, { value: 'a' });
131
+
132
+ const result = parseResult(await handlerPromise);
133
+ expect(result.completed).to.be.true;
134
+ expect(result.steps[0]).to.deep.equal({ title: 'Choose', value: 'a' });
135
+ });
136
+
137
+ it('should accumulate per-step state across multiple steps', async () => {
138
+ const handler = tool.getTool().handler;
139
+ const args = JSON.stringify({
140
+ interactions: [
141
+ { title: 'Overview', message: 'summary' },
142
+ { title: 'Area 1', message: 'finding', options: [{ text: 'OK', value: 'approve' }] },
143
+ { title: 'Area 2', message: 'no findings' }
144
+ ]
145
+ });
146
+ const handlerPromise = handler(args, { toolCallId: 'call-multi' });
147
+
148
+ tool.setStepResult('call-multi', 0, { comments: ['nice summary'] });
149
+ tool.setStepResult('call-multi', 1, { value: 'approve', comments: ['fix on line 42'] });
150
+ tool.setStepResult('call-multi', 2, {});
151
+ tool.completeInteraction('call-multi');
152
+
153
+ const result = parseResult(await handlerPromise);
154
+ expect(result.completed).to.be.true;
155
+ expect(result.steps).to.have.length(3);
156
+ expect(result.steps[0]).to.deep.equal({ title: 'Overview', comments: ['nice summary'] });
157
+ expect(result.steps[1]).to.deep.equal({ title: 'Area 1', value: 'approve', comments: ['fix on line 42'] });
158
+ expect(result.steps[2]).to.deep.equal({ title: 'Area 2' });
159
+ });
160
+
161
+ it('should ignore setStepResult after completion', async () => {
162
+ const handler = tool.getTool().handler;
163
+ const handlerPromise = handler(singleStepArgs(), { toolCallId: 'call-late' });
164
+ tool.setStepResult('call-late', 0, { value: 'a' });
165
+ tool.completeInteraction('call-late');
166
+ const result = parseResult(await handlerPromise);
167
+ expect(result.steps[0].value).to.equal('a');
168
+ // Late call must not throw or change anything
169
+ tool.setStepResult('call-late', 0, { value: 'b' });
170
+ // No assertion needed beyond ensuring no exception
171
+ });
172
+
173
+ it('should ignore setStepResult for out-of-range step index', async () => {
174
+ const handler = tool.getTool().handler;
175
+ const handlerPromise = handler(singleStepArgs(), { toolCallId: 'call-range' });
176
+ tool.setStepResult('call-range', 5, { value: 'x' });
177
+ tool.setStepResult('call-range', -1, { value: 'y' });
178
+ tool.completeInteraction('call-range');
179
+ const result = parseResult(await handlerPromise);
180
+ expect(result.steps[0]).to.deep.equal({ title: 'Choose' });
181
+ });
182
+
183
+ it('should return partial results with completed=false on cancellation', async () => {
184
+ const handler = tool.getTool().handler;
185
+ const cts = new CancellationTokenSource();
186
+ const args = JSON.stringify({
187
+ interactions: [
188
+ { title: 'Step A', message: 'm' },
189
+ { title: 'Step B', message: 'm' },
190
+ { title: 'Step C', message: 'm' }
191
+ ]
192
+ });
193
+ const handlerPromise = handler(args, { toolCallId: 'call-cancel', cancellationToken: cts.token });
194
+
195
+ tool.setStepResult('call-cancel', 0, { comments: ['first done'] });
196
+ tool.setStepResult('call-cancel', 1, { value: 'whatever' });
197
+ cts.cancel();
198
+
199
+ const result = parseResult(await handlerPromise);
200
+ expect(result.completed).to.be.false;
201
+ expect(result.steps[0]).to.deep.equal({ title: 'Step A', comments: ['first done'] });
202
+ expect(result.steps[1]).to.deep.equal({ title: 'Step B', value: 'whatever' });
203
+ expect(result.steps[2].skipped).to.be.true;
204
+ });
205
+
206
+ it('should mark all steps as skipped if user did nothing before cancellation', async () => {
207
+ const handler = tool.getTool().handler;
208
+ const cts = new CancellationTokenSource();
209
+ const args = JSON.stringify({
210
+ interactions: [
211
+ { title: 'Step A', message: 'm' },
212
+ { title: 'Step B', message: 'm' }
213
+ ]
214
+ });
215
+ const handlerPromise = handler(args, { toolCallId: 'call-skip-all', cancellationToken: cts.token });
216
+ cts.cancel();
217
+ const result = parseResult(await handlerPromise);
218
+ expect(result.completed).to.be.false;
219
+ expect(result.steps.every(s => s.skipped)).to.be.true;
220
+ });
221
+
222
+ it('should handle parallel calls independently', async () => {
223
+ const handler = tool.getTool().handler;
224
+
225
+ const promise1 = handler(JSON.stringify({
226
+ interactions: [{ title: 'Q1', message: 'first', options: [{ text: 'A', value: 'a' }] }]
227
+ }), { toolCallId: 'call-p1' });
228
+
229
+ const promise2 = handler(JSON.stringify({
230
+ interactions: [{ title: 'Q2', message: 'second', options: [{ text: 'B', value: 'b' }] }]
231
+ }), { toolCallId: 'call-p2' });
232
+
233
+ tool.setStepResult('call-p2', 0, { value: 'b' });
234
+ tool.completeInteraction('call-p2');
235
+
236
+ tool.setStepResult('call-p1', 0, { value: 'a' });
237
+ tool.completeInteraction('call-p1');
238
+
239
+ expect(parseResult(await promise1).steps[0].value).to.equal('a');
240
+ expect(parseResult(await promise2).steps[0].value).to.equal('b');
241
+ });
242
+
243
+ it('should open a file link via openLink helper', async () => {
244
+ await tool.openLink({ ref: { path: 'src/index.ts', line: 10 } });
245
+
246
+ expect((mockEditorManager.open as sinon.SinonStub).calledOnce).to.be.true;
247
+ const openCall = (mockEditorManager.open as sinon.SinonStub).getCall(0);
248
+ expect(openCall.args[1]).to.deep.equal({ selection: { start: { line: 9, character: 0 } } });
249
+ });
250
+
251
+ it('should open a file link without line number', async () => {
252
+ await tool.openLink({ ref: 'src/app.ts' });
253
+
254
+ expect((mockEditorManager.open as sinon.SinonStub).calledOnce).to.be.true;
255
+ const openCall = (mockEditorManager.open as sinon.SinonStub).getCall(0);
256
+ expect(openCall.args[1]).to.deep.equal({ selection: undefined });
257
+ });
258
+
259
+ it('should open a diff link with custom label', async () => {
260
+ await tool.openLink({ ref: 'src/old.ts', rightRef: 'src/new.ts', label: 'My Diff' });
261
+ expect((mockOpenerService.getOpener as sinon.SinonStub).called).to.be.true;
262
+ });
263
+
264
+ it('should forward right-side line as selection when opening a diff', async () => {
265
+ await tool.openLink({ ref: 'src/foo.ts', rightRef: { path: 'src/foo.ts', line: 42 } });
266
+ const getOpenerCall = (mockOpenerService.getOpener as sinon.SinonStub).getCall(0);
267
+ expect(getOpenerCall.args[1]).to.deep.equal({ selection: { start: { line: 41, character: 0 } } });
268
+ });
269
+
270
+ it('should fall back to left-side line when right side has none', async () => {
271
+ await tool.openLink({ ref: { path: 'src/foo.ts', line: 7 }, rightRef: 'src/foo.ts' });
272
+ const getOpenerCall = (mockOpenerService.getOpener as sinon.SinonStub).getCall(0);
273
+ expect(getOpenerCall.args[1]).to.deep.equal({ selection: { start: { line: 6, character: 0 } } });
274
+ });
275
+
276
+ it('should treat new files (content unreadable at gitRef) as empty rather than an error', async () => {
277
+ mockResourceProvider.callsFake(async (uri: URI) => {
278
+ if (uri.scheme === 'git') {
279
+ return {
280
+ uri,
281
+ readContents: sinon.stub().rejects(new Error('file not found at ref')),
282
+ dispose: sinon.stub()
283
+ };
284
+ }
285
+ return {
286
+ uri,
287
+ readContents: sinon.stub().resolves('content'),
288
+ dispose: sinon.stub()
289
+ };
290
+ });
291
+
292
+ const mockRepo = { provider: { id: 'git', rootUri: 'file:///workspace' } };
293
+ (mockScmService.findRepository as sinon.SinonStub).returns(mockRepo);
294
+
295
+ await tool.openLink({
296
+ ref: { path: 'src/new-file.ts', gitRef: 'abc123' },
297
+ rightRef: 'src/new-file.ts'
298
+ });
299
+
300
+ expect((mockOpenerService.getOpener as sinon.SinonStub).called).to.be.true;
301
+ const openCall = (mockOpenerService.getOpener as sinon.SinonStub).getCall(0);
302
+ const diffUri = openCall.args[0] as URI;
303
+ expect(DiffUris.isDiffUri(diffUri)).to.be.true;
304
+ const [leftUri] = DiffUris.decode(diffUri);
305
+ expect(leftUri.scheme).to.equal(MEMORY_TEXT);
306
+ });
307
+
308
+ it('should open diff with empty right side when right-side cannot be resolved', async () => {
309
+ mockResourceProvider.callsFake(async (uri: URI) => {
310
+ if (uri.scheme === 'file') {
311
+ return {
312
+ uri,
313
+ readContents: sinon.stub().rejects(new Error('file not found')),
314
+ dispose: sinon.stub()
315
+ };
316
+ }
317
+ return {
318
+ uri,
319
+ readContents: sinon.stub().resolves('content'),
320
+ dispose: sinon.stub()
321
+ };
322
+ });
323
+
324
+ const mockRepo = { provider: { id: 'git', rootUri: 'file:///workspace' } };
325
+ (mockScmService.findRepository as sinon.SinonStub).returns(mockRepo);
326
+
327
+ await tool.openLink({
328
+ ref: { path: 'src/deleted-file.ts', gitRef: 'abc123' },
329
+ rightRef: { path: 'src/deleted-file.ts' }
330
+ });
331
+
332
+ expect((mockOpenerService.getOpener as sinon.SinonStub).called).to.be.true;
333
+ const openCall = (mockOpenerService.getOpener as sinon.SinonStub).getCall(0);
334
+ const diffUri = openCall.args[0] as URI;
335
+ expect(DiffUris.isDiffUri(diffUri)).to.be.true;
336
+ const [, rightUri] = DiffUris.decode(diffUri);
337
+ expect(rightUri.scheme).to.equal(MEMORY_TEXT);
338
+ });
339
+
340
+ it('should open diff with both sides empty when neither can be resolved', async () => {
341
+ mockResourceProvider.rejects(new Error('no resolver found'));
342
+
343
+ await tool.openLink({
344
+ ref: { path: 'src/file.ts', gitRef: 'abc123' },
345
+ rightRef: 'src/file.ts'
346
+ });
347
+
348
+ expect((mockOpenerService.getOpener as sinon.SinonStub).called).to.be.true;
349
+ const openCall = (mockOpenerService.getOpener as sinon.SinonStub).getCall(0);
350
+ const diffUri = openCall.args[0] as URI;
351
+ expect(DiffUris.isDiffUri(diffUri)).to.be.true;
352
+ const [leftUri, rightUri] = DiffUris.decode(diffUri);
353
+ expect(leftUri.scheme).to.equal(MEMORY_TEXT_READONLY);
354
+ expect(rightUri.scheme).to.equal(MEMORY_TEXT);
355
+ });
356
+
357
+ it('should open diff with empty left side when ref is EmptyContentRef', async () => {
358
+ await tool.openLink({
359
+ ref: { empty: true, label: 'new file' },
360
+ rightRef: 'src/new-file.ts'
361
+ });
362
+
363
+ expect((mockOpenerService.getOpener as sinon.SinonStub).called).to.be.true;
364
+ const openCall = (mockOpenerService.getOpener as sinon.SinonStub).getCall(0);
365
+ const diffUri = openCall.args[0] as URI;
366
+ expect(DiffUris.isDiffUri(diffUri)).to.be.true;
367
+ const [leftUri, rightUri] = DiffUris.decode(diffUri);
368
+ expect(leftUri.scheme).to.equal(MEMORY_TEXT);
369
+ expect(rightUri.scheme).to.equal('file');
370
+ });
371
+
372
+ it('should show error content when gitRef cannot be resolved due to missing SCM repo', async () => {
373
+ (mockScmService.findRepository as sinon.SinonStub).returns(undefined);
374
+
375
+ await tool.openLink({
376
+ ref: { path: 'src/file.ts', gitRef: 'abc123' },
377
+ rightRef: 'src/file.ts'
378
+ });
379
+
380
+ expect((mockOpenerService.getOpener as sinon.SinonStub).called).to.be.true;
381
+ const openCall = (mockOpenerService.getOpener as sinon.SinonStub).getCall(0);
382
+ const diffUri = openCall.args[0] as URI;
383
+ expect(DiffUris.isDiffUri(diffUri)).to.be.true;
384
+ const [leftUri] = DiffUris.decode(diffUri);
385
+ expect(leftUri.scheme).to.equal(MEMORY_TEXT_READONLY);
386
+ expect(leftUri.query).to.contain('Unable to resolve revision');
387
+ expect(leftUri.query).to.contain('abc123');
388
+ });
389
+
390
+ it('should not open anything when single link ref is EmptyContentRef', async () => {
391
+ await tool.openLink({ ref: { empty: true } });
392
+
393
+ expect((mockOpenerService.getOpener as sinon.SinonStub).called).to.be.false;
394
+ expect((mockEditorManager.open as sinon.SinonStub).called).to.be.false;
395
+ });
396
+ });