@theia/ai-ide 1.72.0-next.52 → 1.72.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 (63) hide show
  1. package/lib/browser/app-tester-chat-agent.js +1 -1
  2. package/lib/browser/architect-agent.js +1 -1
  3. package/lib/browser/architect-agent.js.map +1 -1
  4. package/lib/browser/chat-sessions-welcome-message-provider.d.ts +2 -1
  5. package/lib/browser/chat-sessions-welcome-message-provider.d.ts.map +1 -1
  6. package/lib/browser/chat-sessions-welcome-message-provider.js +122 -32
  7. package/lib/browser/chat-sessions-welcome-message-provider.js.map +1 -1
  8. package/lib/browser/chat-sessions-welcome-message-provider.spec.d.ts +2 -0
  9. package/lib/browser/chat-sessions-welcome-message-provider.spec.d.ts.map +1 -0
  10. package/lib/browser/chat-sessions-welcome-message-provider.spec.js +156 -0
  11. package/lib/browser/chat-sessions-welcome-message-provider.spec.js.map +1 -0
  12. package/lib/browser/explore-agent.js +1 -1
  13. package/lib/browser/frontend-module.d.ts.map +1 -1
  14. package/lib/browser/frontend-module.js +2 -0
  15. package/lib/browser/frontend-module.js.map +1 -1
  16. package/lib/browser/github-chat-agent.js +1 -1
  17. package/lib/browser/project-info-agent.js +1 -1
  18. package/lib/browser/review/pr-review-capability-contribution.d.ts +12 -0
  19. package/lib/browser/review/pr-review-capability-contribution.d.ts.map +1 -0
  20. package/lib/browser/review/pr-review-capability-contribution.js +213 -0
  21. package/lib/browser/review/pr-review-capability-contribution.js.map +1 -0
  22. package/lib/browser/review/pr-review-prompt-template.d.ts +5 -0
  23. package/lib/browser/review/pr-review-prompt-template.d.ts.map +1 -1
  24. package/lib/browser/review/pr-review-prompt-template.js +172 -139
  25. package/lib/browser/review/pr-review-prompt-template.js.map +1 -1
  26. package/lib/browser/user-interaction-tool-renderer.d.ts.map +1 -1
  27. package/lib/browser/user-interaction-tool-renderer.js +63 -39
  28. package/lib/browser/user-interaction-tool-renderer.js.map +1 -1
  29. package/lib/browser/user-interaction-tool.d.ts +19 -10
  30. package/lib/browser/user-interaction-tool.d.ts.map +1 -1
  31. package/lib/browser/user-interaction-tool.js +43 -54
  32. package/lib/browser/user-interaction-tool.js.map +1 -1
  33. package/lib/browser/user-interaction-tool.spec.js +66 -41
  34. package/lib/browser/user-interaction-tool.spec.js.map +1 -1
  35. package/lib/common/command-chat-agents.js +1 -1
  36. package/lib/common/command-chat-agents.js.map +1 -1
  37. package/lib/common/orchestrator-chat-agent.js +1 -1
  38. package/lib/common/orchestrator-chat-agent.js.map +1 -1
  39. package/lib/common/user-interaction-tool.d.ts +1 -0
  40. package/lib/common/user-interaction-tool.d.ts.map +1 -1
  41. package/lib/common/user-interaction-tool.js +43 -15
  42. package/lib/common/user-interaction-tool.js.map +1 -1
  43. package/lib/common/user-interaction-tool.spec.js +27 -0
  44. package/lib/common/user-interaction-tool.spec.js.map +1 -1
  45. package/package.json +22 -22
  46. package/src/browser/app-tester-chat-agent.ts +1 -1
  47. package/src/browser/architect-agent.ts +1 -1
  48. package/src/browser/chat-sessions-welcome-message-provider.spec.ts +186 -0
  49. package/src/browser/chat-sessions-welcome-message-provider.tsx +132 -35
  50. package/src/browser/explore-agent.ts +1 -1
  51. package/src/browser/frontend-module.ts +2 -0
  52. package/src/browser/github-chat-agent.ts +1 -1
  53. package/src/browser/project-info-agent.ts +1 -1
  54. package/src/browser/review/pr-review-capability-contribution.ts +232 -0
  55. package/src/browser/review/pr-review-prompt-template.ts +181 -150
  56. package/src/browser/style/index.css +43 -3
  57. package/src/browser/user-interaction-tool-renderer.tsx +74 -49
  58. package/src/browser/user-interaction-tool.spec.ts +73 -46
  59. package/src/browser/user-interaction-tool.ts +52 -58
  60. package/src/common/command-chat-agents.ts +1 -1
  61. package/src/common/orchestrator-chat-agent.ts +1 -1
  62. package/src/common/user-interaction-tool.spec.ts +29 -0
  63. package/src/common/user-interaction-tool.ts +42 -15
@@ -33,6 +33,7 @@ import {
33
33
  UserInteractionLink,
34
34
  UserInteractionResult,
35
35
  UserInteractionStep,
36
+ UserInteractionStepResult,
36
37
  buildDiffLabel,
37
38
  isEmptyContentRef,
38
39
  parseUserInteractionArgs,
@@ -41,46 +42,57 @@ import {
41
42
  resolveContentRef,
42
43
  } from '../common/user-interaction-tool';
43
44
 
45
+ interface StepState {
46
+ value?: string;
47
+ comments: string[];
48
+ }
49
+
44
50
  interface UserInteractionComponentProps {
45
51
  args: UserInteractionArgs;
46
52
  toolCallId: string;
47
53
  tool: UserInteractionTool;
48
54
  finished: boolean;
49
55
  canceled: boolean;
56
+ result: UserInteractionResult | undefined;
50
57
  /**
51
- * Whether the parent response has completed (including restoration). When the
52
- * response is complete but no `result` was persisted, the interaction is treated
53
- * as canceled because there is no longer a live agent waiting for input.
58
+ * Called whenever the user changes any step state. The parent persists this partial
59
+ * result on the response (so it survives chat-session reloads) and pushes it to the
60
+ * tool (so a synchronous cancellation can return it instead of all-skipped).
54
61
  */
55
- responseComplete: boolean;
56
- result: UserInteractionResult | undefined;
62
+ onPartialResult: (result: UserInteractionResult) => void;
57
63
  openerService: OpenerService;
58
64
  }
59
65
 
60
- interface StepState {
61
- value?: string;
62
- comments: string[];
63
- }
64
-
65
66
  const UserInteractionComponent: React.FC<UserInteractionComponentProps> = ({
66
- args, toolCallId, tool, finished, canceled, responseComplete, result, openerService
67
+ args, toolCallId, tool, finished, canceled, result, onPartialResult, openerService
67
68
  }) => {
68
69
  const steps = args.interactions;
69
70
  const stepCount = steps.length;
70
71
  const [currentStep, setCurrentStep] = React.useState(0);
71
- const [stepStates, setStepStates] = React.useState<StepState[]>(() => steps.map(() => ({ comments: [] })));
72
+ // The tool's result (partial or final) is the single source of truth for step states.
73
+ const [stepStates, setStepStates] = React.useState<StepState[]>(() => {
74
+ if (result) {
75
+ return steps.map((_, i) => ({
76
+ value: result.steps[i]?.value,
77
+ comments: result.steps[i]?.comments ? [...result.steps[i].comments!] : []
78
+ }));
79
+ }
80
+ return steps.map(() => ({ comments: [] }));
81
+ });
82
+ // Mirror stepStates into a ref so synchronous readers (cancellation fallback,
83
+ // terminal handlers) always see the latest value. The ref is updated synchronously
84
+ // by every code path that writes to stepStates, which also keeps these handlers
85
+ // free of `stepStates` deps and avoids state-updater side effects.
86
+ const stepStatesRef = React.useRef(stepStates);
72
87
  const [pendingComment, setPendingComment] = React.useState('');
73
88
 
74
89
  const activeStep: UserInteractionStep | undefined = steps[currentStep];
75
90
  const isLastStep = currentStep === stepCount - 1;
76
91
  const messageRef = useMarkdownRendering(activeStep?.message ?? '', openerService);
77
92
 
78
- // A parent response that completed without delivering a tool result means the
79
- // interaction was restored from a serialized "waiting for input" state. The
80
- // agent that was waiting is no longer running, so it must be treated as
81
- // canceled and all inputs locked.
82
- const restoredWithoutResult = responseComplete && !result;
83
- const isFinal = finished || !!result || canceled || restoredWithoutResult;
93
+ // A finished tool call has no live handler anymore (completion, cancellation, or
94
+ // restoration of a previously-pending interaction). Lock all inputs in that case.
95
+ const isFinal = finished || canceled;
84
96
 
85
97
  // Auto-open the active step's links the first time the user reaches it.
86
98
  // Going Back and then Forward must not re-open them.
@@ -92,28 +104,40 @@ const UserInteractionComponent: React.FC<UserInteractionComponentProps> = ({
92
104
  visitedStepsRef.current.add(currentStep);
93
105
  const links = activeStep.links ?? [];
94
106
  for (const link of links) {
95
- if (link.autoOpen !== false) {
107
+ if (link.autoOpen) {
96
108
  tool.openLink(link).catch(err => console.warn('Failed to auto-open user-interaction link:', err));
97
109
  }
98
110
  }
99
111
  }, [currentStep, activeStep, isFinal, tool]);
100
112
 
101
- const persistStepState = React.useCallback((stepIndex: number, state: StepState) => {
102
- tool.setStepResult(toolCallId, stepIndex, {
103
- value: state.value,
104
- comments: state.comments.length > 0 ? state.comments : undefined
105
- });
106
- }, [tool, toolCallId]);
113
+ const buildResult = React.useCallback((completed: boolean, states: StepState[]): UserInteractionResult => ({
114
+ completed,
115
+ steps: steps.map((step, i) => {
116
+ const state = states[i];
117
+ const stepResult: UserInteractionStepResult = { title: step.title };
118
+ if (state?.value !== undefined) {
119
+ stepResult.value = state.value;
120
+ }
121
+ if (state?.comments && state.comments.length > 0) {
122
+ stepResult.comments = [...state.comments];
123
+ }
124
+ // For partial/cancel results, mark untouched steps as skipped so the LLM
125
+ // can distinguish "answered" from "not answered" if the interaction never
126
+ // completes.
127
+ if (!completed && stepResult.value === undefined && stepResult.comments === undefined) {
128
+ stepResult.skipped = true;
129
+ }
130
+ return stepResult;
131
+ })
132
+ }), [steps]);
107
133
 
108
134
  const updateStepState = React.useCallback((stepIndex: number, updater: (prev: StepState) => StepState) => {
109
- setStepStates(prev => {
110
- const next = prev.slice();
111
- const updated = updater(prev[stepIndex]);
112
- next[stepIndex] = updated;
113
- persistStepState(stepIndex, updated);
114
- return next;
115
- });
116
- }, [persistStepState]);
135
+ const next = stepStatesRef.current.slice();
136
+ next[stepIndex] = updater(next[stepIndex]);
137
+ stepStatesRef.current = next;
138
+ setStepStates(next);
139
+ onPartialResult(buildResult(false, next));
140
+ }, [buildResult, onPartialResult]);
117
141
 
118
142
  const isSingleStep = stepCount === 1;
119
143
  const hasOptions = !!activeStep?.options && activeStep.options.length > 0;
@@ -123,19 +147,17 @@ const UserInteractionComponent: React.FC<UserInteractionComponentProps> = ({
123
147
  return;
124
148
  }
125
149
  if (isSingleStep) {
126
- setStepStates(prev => {
127
- const next = prev.slice();
128
- next[0] = { ...prev[0], value };
129
- return next;
130
- });
131
- tool.completeInteractionWith(toolCallId, 0, { value });
150
+ const next: StepState[] = [{ ...stepStatesRef.current[0], value }];
151
+ stepStatesRef.current = next;
152
+ setStepStates(next);
153
+ tool.completeInteraction(toolCallId, buildResult(true, next));
132
154
  return;
133
155
  }
134
156
  updateStepState(currentStep, prev => ({
135
157
  ...prev,
136
158
  value: prev.value === value ? undefined : value
137
159
  }));
138
- }, [currentStep, isFinal, isSingleStep, tool, toolCallId, updateStepState]);
160
+ }, [buildResult, currentStep, isFinal, isSingleStep, tool, toolCallId, updateStepState]);
139
161
 
140
162
  const handleAddComment = React.useCallback(() => {
141
163
  const trimmed = pendingComment.trim();
@@ -177,13 +199,13 @@ const UserInteractionComponent: React.FC<UserInteractionComponentProps> = ({
177
199
  const handleAdvance = React.useCallback(() => {
178
200
  if (isLastStep) {
179
201
  if (!isFinal) {
180
- tool.completeInteraction(toolCallId);
202
+ tool.completeInteraction(toolCallId, buildResult(true, stepStatesRef.current));
181
203
  }
182
204
  return;
183
205
  }
184
206
  setCurrentStep(idx => idx + 1);
185
207
  setPendingComment('');
186
- }, [isFinal, isLastStep, tool, toolCallId]);
208
+ }, [buildResult, isFinal, isLastStep, tool, toolCallId]);
187
209
 
188
210
  const handleBack = React.useCallback(() => {
189
211
  if (currentStep === 0) {
@@ -211,14 +233,14 @@ const UserInteractionComponent: React.FC<UserInteractionComponentProps> = ({
211
233
  <span className={codicon('comment-discussion')} />
212
234
  <span className='user-interaction-tool title'>{activeStep.title}</span>
213
235
  {(() => {
214
- // The tool's own result is authoritative: a completed
215
- // interaction must stay "Completed" even if the chat
216
- // session is canceled later. A response that completed
217
- // without a result indicates the interaction was restored
218
- // from a "waiting" state and is treated as canceled.
236
+ // A completed result is authoritative and persists even if the
237
+ // chat is later canceled. While the tool is live we may already
238
+ // have a partial result (`completed: false`) so distinguish
239
+ // "still waiting" from "canceled" using `finished`/`canceled`:
240
+ // both are only true once no live handler is around.
219
241
  const status: 'completed' | 'canceled' | 'waiting' =
220
242
  result?.completed === true ? 'completed' :
221
- result?.completed === false || canceled || restoredWithoutResult ? 'canceled' :
243
+ finished || canceled ? 'canceled' :
222
244
  'waiting';
223
245
  if (status === 'completed') {
224
246
  return (
@@ -515,8 +537,11 @@ export class UserInteractionToolRenderer implements ChatResponsePartRenderer<Too
515
537
  tool={this.userInteractionTool}
516
538
  finished={response.finished}
517
539
  canceled={parentNode.response.isCanceled}
518
- responseComplete={parentNode.response.isComplete}
519
540
  result={parseUserInteractionResult(response.result)}
541
+ onPartialResult={partial => {
542
+ this.userInteractionTool.recordPartial(response.id!, partial);
543
+ response.updateResult(JSON.stringify(partial));
544
+ }}
520
545
  openerService={this.openerService}
521
546
  toolConfirmation={{
522
547
  response,
@@ -14,6 +14,10 @@
14
14
  // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
15
  // *****************************************************************************
16
16
 
17
+ import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
18
+ let disableJSDOM = enableJSDOM();
19
+ import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
20
+ FrontendApplicationConfigProvider.set({});
17
21
  import { expect } from 'chai';
18
22
  import * as sinon from 'sinon';
19
23
  import { Container } from '@theia/core/shared/inversify';
@@ -28,6 +32,8 @@ import { CancellationTokenSource } from '@theia/core/lib/common/cancellation';
28
32
  import { MEMORY_TEXT, MEMORY_TEXT_READONLY, ResourceProvider } from '@theia/core/lib/common/resource';
29
33
  import { DiffUris } from '@theia/core/lib/browser/diff-uris';
30
34
 
35
+ disableJSDOM();
36
+
31
37
  const singleStepArgs = (overrides: Record<string, unknown> = {}) => JSON.stringify({
32
38
  interactions: [{
33
39
  title: 'Choose',
@@ -39,6 +45,12 @@ const singleStepArgs = (overrides: Record<string, unknown> = {}) => JSON.stringi
39
45
 
40
46
  const parseResult = (raw: unknown): UserInteractionResult => JSON.parse(raw as string);
41
47
 
48
+ const makeMockRepo = () => ({
49
+ provider: { id: 'git', rootUri: 'file:///workspace' },
50
+ toUriAtRef: (fileUri: URI, ref: string) =>
51
+ fileUri.withScheme('git').withQuery(JSON.stringify({ path: fileUri.path.fsPath(), ref }))
52
+ });
53
+
42
54
  describe('UserInteractionTool', () => {
43
55
  let container: Container;
44
56
  let tool: UserInteractionTool;
@@ -49,6 +61,14 @@ describe('UserInteractionTool', () => {
49
61
  let mockResourceProvider: sinon.SinonStub;
50
62
  const workspaceRoot = new URI('file:///workspace');
51
63
 
64
+ before(() => {
65
+ disableJSDOM = enableJSDOM();
66
+ });
67
+
68
+ after(() => {
69
+ disableJSDOM();
70
+ });
71
+
52
72
  beforeEach(() => {
53
73
  container = new Container();
54
74
 
@@ -111,12 +131,14 @@ describe('UserInteractionTool', () => {
111
131
  expect(JSON.parse(result as string).error).to.equal('No tool call ID available');
112
132
  });
113
133
 
114
- it('should return single-step result on completion', async () => {
134
+ it('should resolve the handler with the result passed to completeInteraction', async () => {
115
135
  const handler = tool.getTool().handler;
116
136
  const handlerPromise = handler(singleStepArgs(), { toolCallId: 'call-1' });
117
137
 
118
- tool.setStepResult('call-1', 0, { value: 'b' });
119
- tool.completeInteraction('call-1');
138
+ tool.completeInteraction('call-1', {
139
+ completed: true,
140
+ steps: [{ title: 'Choose', value: 'b' }]
141
+ });
120
142
 
121
143
  const result = parseResult(await handlerPromise);
122
144
  expect(result.completed).to.be.true;
@@ -124,18 +146,7 @@ describe('UserInteractionTool', () => {
124
146
  expect(result.steps[0]).to.deep.equal({ title: 'Choose', value: 'b' });
125
147
  });
126
148
 
127
- it('should atomically set and complete via completeInteractionWith', async () => {
128
- const handler = tool.getTool().handler;
129
- const handlerPromise = handler(singleStepArgs(), { toolCallId: 'call-with' });
130
-
131
- tool.completeInteractionWith('call-with', 0, { value: 'a' });
132
-
133
- const result = parseResult(await handlerPromise);
134
- expect(result.completed).to.be.true;
135
- expect(result.steps[0]).to.deep.equal({ title: 'Choose', value: 'a' });
136
- });
137
-
138
- it('should accumulate per-step state across multiple steps', async () => {
149
+ it('should forward multi-step results verbatim', async () => {
139
150
  const handler = tool.getTool().handler;
140
151
  const args = JSON.stringify({
141
152
  interactions: [
@@ -146,10 +157,14 @@ describe('UserInteractionTool', () => {
146
157
  });
147
158
  const handlerPromise = handler(args, { toolCallId: 'call-multi' });
148
159
 
149
- tool.setStepResult('call-multi', 0, { comments: ['nice summary'] });
150
- tool.setStepResult('call-multi', 1, { value: 'approve', comments: ['fix on line 42'] });
151
- tool.setStepResult('call-multi', 2, {});
152
- tool.completeInteraction('call-multi');
160
+ tool.completeInteraction('call-multi', {
161
+ completed: true,
162
+ steps: [
163
+ { title: 'Overview', comments: ['nice summary'] },
164
+ { title: 'Area 1', value: 'approve', comments: ['fix on line 42'] },
165
+ { title: 'Area 2' }
166
+ ]
167
+ });
153
168
 
154
169
  const result = parseResult(await handlerPromise);
155
170
  expect(result.completed).to.be.true;
@@ -159,29 +174,24 @@ describe('UserInteractionTool', () => {
159
174
  expect(result.steps[2]).to.deep.equal({ title: 'Area 2' });
160
175
  });
161
176
 
162
- it('should ignore setStepResult after completion', async () => {
177
+ it('should ignore completeInteraction calls after the interaction resolved', async () => {
163
178
  const handler = tool.getTool().handler;
164
179
  const handlerPromise = handler(singleStepArgs(), { toolCallId: 'call-late' });
165
- tool.setStepResult('call-late', 0, { value: 'a' });
166
- tool.completeInteraction('call-late');
180
+ tool.completeInteraction('call-late', {
181
+ completed: true,
182
+ steps: [{ title: 'Choose', value: 'a' }]
183
+ });
167
184
  const result = parseResult(await handlerPromise);
168
185
  expect(result.steps[0].value).to.equal('a');
169
186
  // Late call must not throw or change anything
170
- tool.setStepResult('call-late', 0, { value: 'b' });
187
+ tool.completeInteraction('call-late', {
188
+ completed: true,
189
+ steps: [{ title: 'Choose', value: 'b' }]
190
+ });
171
191
  // No assertion needed beyond ensuring no exception
172
192
  });
173
193
 
174
- it('should ignore setStepResult for out-of-range step index', async () => {
175
- const handler = tool.getTool().handler;
176
- const handlerPromise = handler(singleStepArgs(), { toolCallId: 'call-range' });
177
- tool.setStepResult('call-range', 5, { value: 'x' });
178
- tool.setStepResult('call-range', -1, { value: 'y' });
179
- tool.completeInteraction('call-range');
180
- const result = parseResult(await handlerPromise);
181
- expect(result.steps[0]).to.deep.equal({ title: 'Choose' });
182
- });
183
-
184
- it('should return partial results with completed=false on cancellation', async () => {
194
+ it('should resolve with the renderer-supplied partial on cancellation', async () => {
185
195
  const handler = tool.getTool().handler;
186
196
  const cts = new CancellationTokenSource();
187
197
  const args = JSON.stringify({
@@ -193,8 +203,17 @@ describe('UserInteractionTool', () => {
193
203
  });
194
204
  const handlerPromise = handler(args, { toolCallId: 'call-cancel', cancellationToken: cts.token });
195
205
 
196
- tool.setStepResult('call-cancel', 0, { comments: ['first done'] });
197
- tool.setStepResult('call-cancel', 1, { value: 'whatever' });
206
+ // Wait a microtask so the handler has registered the pending interaction.
207
+ await Promise.resolve();
208
+ // Simulate the renderer pushing the latest partial state.
209
+ tool.recordPartial('call-cancel', {
210
+ completed: false,
211
+ steps: [
212
+ { title: 'Step A', comments: ['first done'] },
213
+ { title: 'Step B', value: 'whatever' },
214
+ { title: 'Step C', skipped: true }
215
+ ]
216
+ });
198
217
  cts.cancel();
199
218
 
200
219
  const result = parseResult(await handlerPromise);
@@ -204,7 +223,7 @@ describe('UserInteractionTool', () => {
204
223
  expect(result.steps[2].skipped).to.be.true;
205
224
  });
206
225
 
207
- it('should mark all steps as skipped if user did nothing before cancellation', async () => {
226
+ it('should fall back to all-skipped when no renderer claims the cancellation', async () => {
208
227
  const handler = tool.getTool().handler;
209
228
  const cts = new CancellationTokenSource();
210
229
  const args = JSON.stringify({
@@ -231,11 +250,8 @@ describe('UserInteractionTool', () => {
231
250
  interactions: [{ title: 'Q2', message: 'second', options: [{ text: 'B', value: 'b' }] }]
232
251
  }), { toolCallId: 'call-p2' });
233
252
 
234
- tool.setStepResult('call-p2', 0, { value: 'b' });
235
- tool.completeInteraction('call-p2');
236
-
237
- tool.setStepResult('call-p1', 0, { value: 'a' });
238
- tool.completeInteraction('call-p1');
253
+ tool.completeInteraction('call-p2', { completed: true, steps: [{ title: 'Q2', value: 'b' }] });
254
+ tool.completeInteraction('call-p1', { completed: true, steps: [{ title: 'Q1', value: 'a' }] });
239
255
 
240
256
  expect(parseResult(await promise1).steps[0].value).to.equal('a');
241
257
  expect(parseResult(await promise2).steps[0].value).to.equal('b');
@@ -257,6 +273,19 @@ describe('UserInteractionTool', () => {
257
273
  expect(openCall.args[1]).to.deep.equal({ selection: undefined });
258
274
  });
259
275
 
276
+ it('should open a file link when rightRef is an invalid placeholder', async () => {
277
+ await tool.openLink({
278
+ ref: { path: 'README.md', line: 1 },
279
+ rightRef: { path: '' }
280
+ });
281
+
282
+ expect((mockEditorManager.open as sinon.SinonStub).calledOnce).to.be.true;
283
+ expect((mockOpenerService.getOpener as sinon.SinonStub).called).to.be.false;
284
+ const openCall = (mockEditorManager.open as sinon.SinonStub).getCall(0);
285
+ expect(openCall.args[0].toString()).to.equal('file:///workspace/README.md');
286
+ expect(openCall.args[1]).to.deep.equal({ selection: { start: { line: 0, character: 0 } } });
287
+ });
288
+
260
289
  it('should open a diff link with custom label', async () => {
261
290
  await tool.openLink({ ref: 'src/old.ts', rightRef: 'src/new.ts', label: 'My Diff' });
262
291
  expect((mockOpenerService.getOpener as sinon.SinonStub).called).to.be.true;
@@ -290,8 +319,7 @@ describe('UserInteractionTool', () => {
290
319
  };
291
320
  });
292
321
 
293
- const mockRepo = { provider: { id: 'git', rootUri: 'file:///workspace' } };
294
- (mockScmService.findRepository as sinon.SinonStub).returns(mockRepo);
322
+ (mockScmService.findRepository as sinon.SinonStub).returns(makeMockRepo());
295
323
 
296
324
  await tool.openLink({
297
325
  ref: { path: 'src/new-file.ts', gitRef: 'abc123' },
@@ -322,8 +350,7 @@ describe('UserInteractionTool', () => {
322
350
  };
323
351
  });
324
352
 
325
- const mockRepo = { provider: { id: 'git', rootUri: 'file:///workspace' } };
326
- (mockScmService.findRepository as sinon.SinonStub).returns(mockRepo);
353
+ (mockScmService.findRepository as sinon.SinonStub).returns(makeMockRepo());
327
354
 
328
355
  await tool.openLink({
329
356
  ref: { path: 'src/deleted-file.ts', gitRef: 'abc123' },
@@ -32,9 +32,9 @@ import {
32
32
  UserInteractionLink,
33
33
  UserInteractionResult,
34
34
  UserInteractionStep,
35
- UserInteractionStepResult,
36
35
  buildDiffLabel,
37
36
  isEmptyContentRef,
37
+ normalizeUserInteractionLink,
38
38
  parseUserInteractionArgs,
39
39
  resolveContentRef
40
40
  } from '../common/user-interaction-tool';
@@ -42,8 +42,13 @@ import {
42
42
  interface PendingInteraction {
43
43
  deferred: Deferred<string>;
44
44
  steps: UserInteractionStep[];
45
- stepResults: UserInteractionStepResult[];
46
45
  resolved: boolean;
46
+ /**
47
+ * Latest partial result pushed by the renderer via {@link UserInteractionTool.recordPartial}.
48
+ * Used to resolve the handler with what the user has collected so far when the interaction
49
+ * is canceled. If absent (no renderer mounted) the tool resolves with all steps skipped.
50
+ */
51
+ latestPartial?: UserInteractionResult;
47
52
  }
48
53
 
49
54
  // Schemas are module-level constants so they are built once at load time
@@ -111,18 +116,21 @@ const STEP_SCHEMA: ToolRequestParameterProperty = {
111
116
  },
112
117
  rightRef: {
113
118
  ...CONTENT_REF_SCHEMA,
114
- description: 'Optional right-side content reference for diff views. '
119
+ description: 'Right-side content reference for a diff view. '
120
+ + 'Only provide this when the step should show a diff; omit it entirely for a single file link. '
115
121
  + 'Provide "path" for a real file, or "empty": true for files that no longer exist.'
116
122
  },
117
123
  label: { type: 'string', description: 'Optional label for the link or diff tab.' },
118
124
  autoOpen: {
119
125
  type: 'boolean',
120
- description: 'Whether to automatically open the file/diff when this step becomes active. Defaults to true.'
126
+ description: 'Whether to automatically open the file/diff when this step becomes active. Defaults to false; '
127
+ + 'set to true only when the link is essential context the user must see immediately.'
121
128
  }
122
129
  },
123
130
  required: ['ref']
124
131
  },
125
- description: 'Optional links to files or diffs to show alongside this step.'
132
+ description: 'Optional links to files or diffs to show alongside this step. '
133
+ + 'Use "ref" alone for a single file link. Add "rightRef" only when the step should show a diff.'
126
134
  }
127
135
  },
128
136
  required: ['title', 'message']
@@ -134,18 +142,19 @@ const TOOL_PARAMETERS: ToolRequestParameters = {
134
142
  interactions: {
135
143
  type: 'array',
136
144
  items: STEP_SCHEMA,
137
- description: 'Ordered list of wizard steps. The user walks through them sequentially without a back button.'
145
+ description: 'Ordered list of interaction steps. The user walks through them sequentially and can revisit previous steps.'
138
146
  }
139
147
  },
140
148
  required: ['interactions']
141
149
  };
142
150
 
143
- const TOOL_DESCRIPTION = 'Present an interactive interaction to the user. Each step has a title, a markdown message, optional option buttons, '
144
- + 'and optional file/diff links that auto-open when the step is reached. '
145
- + 'Single-step behavior: a single-step interaction with options waits for the user to pick one option, which immediately completes the interaction; '
146
- + 'a single-step interaction without options is purely informational and is auto-completed by the tool '
147
- + '(do not promise the user a "Finish" or "Next" button there is none, and no comments can be entered). '
148
- + 'Multi-step behavior: the user advances through steps with a "Next" button (or "Finish" on the last step), can navigate freely between steps, '
151
+ const TOOL_DESCRIPTION = 'Present an interactive user interaction. Each step has a title, a markdown message, optional option buttons, '
152
+ + 'and optional file/diff links that the user can click. '
153
+ + 'For links, use "ref" alone to show one file; add "rightRef" only when the step should show a diff. '
154
+ + 'Single-step behavior: if the step has options, the tool waits for the user to pick one option and then completes the interaction; '
155
+ + 'if the step has no options, it is purely informational and is auto-completed by the tool '
156
+ + '(do not promise the user a "Finish" or "Next" button; there is none, and no comments can be entered). '
157
+ + 'Multi-step behavior: the user advances through steps with a "Next" button (or "Finish" on the last step), can revisit previous steps, '
149
158
  + 'and may add free-form comments on every step. '
150
159
  + 'The tool returns a JSON string with { "completed": boolean, "steps": [{ "title", "value"?, "comments"?, "skipped"? }] }. '
151
160
  + 'If the user cancels mid-interaction, the tool returns whatever has been collected so far with "completed": false. '
@@ -184,69 +193,56 @@ export class UserInteractionTool implements ToolProvider {
184
193
  };
185
194
  }
186
195
 
187
- setStepResult(toolCallId: string, stepIndex: number, partial: Partial<UserInteractionStepResult>): void {
196
+ /**
197
+ * Resolve the pending interaction with the given final result. The renderer is the single
198
+ * source of truth for the collected user input and passes the full result here on Finish
199
+ * (or on a per-option click for single-step interactions).
200
+ */
201
+ completeInteraction(toolCallId: string, result: UserInteractionResult): void {
202
+ this.resolveInteraction(toolCallId, result);
203
+ }
204
+
205
+ /**
206
+ * Push the latest partial result so the tool can resolve with it on cancellation.
207
+ * The renderer should call this whenever the user changes step state. Calls for
208
+ * an already-resolved interaction are silently ignored.
209
+ */
210
+ recordPartial(toolCallId: string, partial: UserInteractionResult): void {
188
211
  const pending = this.pendingInteractions.get(toolCallId);
189
212
  if (!pending || pending.resolved) {
190
213
  return;
191
214
  }
192
- if (stepIndex < 0 || stepIndex >= pending.steps.length) {
193
- return;
194
- }
195
- const existing = pending.stepResults[stepIndex] ?? { title: pending.steps[stepIndex].title };
196
- pending.stepResults[stepIndex] = {
197
- ...existing,
198
- ...partial,
199
- title: pending.steps[stepIndex].title
200
- };
215
+ pending.latestPartial = partial;
201
216
  }
202
217
 
203
- completeInteraction(toolCallId: string): void {
218
+ protected resolveInteraction(toolCallId: string, result: UserInteractionResult): void {
204
219
  const pending = this.pendingInteractions.get(toolCallId);
205
220
  if (!pending || pending.resolved) {
206
221
  return;
207
222
  }
208
223
  pending.resolved = true;
209
- const result: UserInteractionResult = {
210
- completed: true,
211
- steps: this.normalizeStepResults(pending)
212
- };
213
224
  pending.deferred.resolve(JSON.stringify(result));
214
225
  }
215
226
 
216
- /**
217
- * Set the result for a step and immediately complete the interaction.
218
- * Use this to atomically pass the user's input value into the result, avoiding
219
- * any reliance on synchronous state updates between `setStepResult` and `completeInteraction`.
220
- */
221
- completeInteractionWith(toolCallId: string, stepIndex: number, partial: Partial<UserInteractionStepResult>): void {
222
- this.setStepResult(toolCallId, stepIndex, partial);
223
- this.completeInteraction(toolCallId);
224
- }
225
-
226
- cancelInteraction(toolCallId: string): void {
227
+ protected cancelPending(toolCallId: string): void {
227
228
  const pending = this.pendingInteractions.get(toolCallId);
228
229
  if (!pending || pending.resolved) {
229
230
  return;
230
231
  }
231
- pending.resolved = true;
232
- const steps = this.normalizeStepResults(pending);
233
- // Mark steps without any user input as skipped.
234
- for (let i = 0; i < steps.length; i++) {
235
- const step = steps[i];
236
- const hasInput = step.value !== undefined || (step.comments && step.comments.length > 0);
237
- if (!hasInput) {
238
- steps[i] = { ...step, skipped: true };
239
- }
240
- }
241
- const result: UserInteractionResult = { completed: false, steps };
242
- pending.deferred.resolve(JSON.stringify(result));
243
- }
244
-
245
- protected normalizeStepResults(pending: PendingInteraction): UserInteractionStepResult[] {
246
- return pending.steps.map((step, i) => pending.stepResults[i] ?? { title: step.title });
232
+ const result = pending.latestPartial ?? {
233
+ completed: false,
234
+ steps: pending.steps.map(step => ({ title: step.title, skipped: true }))
235
+ };
236
+ this.resolveInteraction(toolCallId, result);
247
237
  }
248
238
 
249
239
  async openLink(link: UserInteractionLink): Promise<void> {
240
+ const normalizedLink = normalizeUserInteractionLink(link);
241
+ if (!normalizedLink) {
242
+ return;
243
+ }
244
+ link = normalizedLink;
245
+
250
246
  if (link.rightRef !== undefined) {
251
247
  const resolvedLeftUri = await this.resolveDiffSideUri(link.ref);
252
248
  const resolvedRightUri = await this.resolveDiffSideUri(link.rightRef);
@@ -300,8 +296,7 @@ export class UserInteractionTool implements ToolProvider {
300
296
  if (ref.gitRef) {
301
297
  const repo = this.scmService.findRepository(fileUri);
302
298
  if (repo) {
303
- const query = { path: fileUri['codeUri'].fsPath, ref: ref.gitRef };
304
- return fileUri.withScheme(repo.provider.id).withQuery(JSON.stringify(query));
299
+ return repo.toUriAtRef(fileUri, ref.gitRef);
305
300
  }
306
301
  console.warn(`No SCM repository found to resolve gitRef '${ref.gitRef}' for '${ref.path}'`);
307
302
  return undefined;
@@ -344,13 +339,12 @@ export class UserInteractionTool implements ToolProvider {
344
339
  const pending: PendingInteraction = {
345
340
  deferred: new Deferred<string>(),
346
341
  steps,
347
- stepResults: new Array(steps.length),
348
342
  resolved: false
349
343
  };
350
344
  this.pendingInteractions.set(toolCallId, pending);
351
345
 
352
346
  const cancellationToken = ToolInvocationContext.getCancellationToken(ctx);
353
- const cancellationListener = cancellationToken?.onCancellationRequested(() => this.cancelInteraction(toolCallId));
347
+ const cancellationListener = cancellationToken?.onCancellationRequested(() => this.cancelPending(toolCallId));
354
348
 
355
349
  try {
356
350
  return await pending.deferred.promise;
@@ -52,7 +52,7 @@ export class CommandChatAgent extends AbstractTextToModelParsingChatAgent<Parsed
52
52
  name = 'Command';
53
53
  languageModelRequirements: LanguageModelRequirement[] = [{
54
54
  purpose: 'command',
55
- identifier: 'default/universal',
55
+ identifier: 'default/fast',
56
56
  }];
57
57
  override iconClass: string = 'codicon codicon-server-process';
58
58
  protected defaultLanguageModelPurpose: string = 'command';