@theia/ai-ide 1.72.0-next.59 → 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.
- package/lib/browser/architect-agent.js +1 -1
- package/lib/browser/architect-agent.js.map +1 -1
- package/lib/browser/chat-sessions-welcome-message-provider.d.ts +2 -1
- package/lib/browser/chat-sessions-welcome-message-provider.d.ts.map +1 -1
- package/lib/browser/chat-sessions-welcome-message-provider.js +122 -32
- package/lib/browser/chat-sessions-welcome-message-provider.js.map +1 -1
- package/lib/browser/chat-sessions-welcome-message-provider.spec.d.ts +2 -0
- package/lib/browser/chat-sessions-welcome-message-provider.spec.d.ts.map +1 -0
- package/lib/browser/chat-sessions-welcome-message-provider.spec.js +156 -0
- package/lib/browser/chat-sessions-welcome-message-provider.spec.js.map +1 -0
- package/lib/browser/user-interaction-tool-renderer.d.ts.map +1 -1
- package/lib/browser/user-interaction-tool-renderer.js +63 -39
- package/lib/browser/user-interaction-tool-renderer.js.map +1 -1
- package/lib/browser/user-interaction-tool.d.ts +19 -10
- package/lib/browser/user-interaction-tool.d.ts.map +1 -1
- package/lib/browser/user-interaction-tool.js +43 -54
- package/lib/browser/user-interaction-tool.js.map +1 -1
- package/lib/browser/user-interaction-tool.spec.js +66 -41
- package/lib/browser/user-interaction-tool.spec.js.map +1 -1
- package/lib/common/user-interaction-tool.d.ts +1 -0
- package/lib/common/user-interaction-tool.d.ts.map +1 -1
- package/lib/common/user-interaction-tool.js +43 -15
- package/lib/common/user-interaction-tool.js.map +1 -1
- package/lib/common/user-interaction-tool.spec.js +27 -0
- package/lib/common/user-interaction-tool.spec.js.map +1 -1
- package/package.json +22 -22
- package/src/browser/architect-agent.ts +1 -1
- package/src/browser/chat-sessions-welcome-message-provider.spec.ts +186 -0
- package/src/browser/chat-sessions-welcome-message-provider.tsx +132 -35
- package/src/browser/style/index.css +43 -3
- package/src/browser/user-interaction-tool-renderer.tsx +74 -49
- package/src/browser/user-interaction-tool.spec.ts +73 -46
- package/src/browser/user-interaction-tool.ts +52 -58
- package/src/common/user-interaction-tool.spec.ts +29 -0
- 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
|
-
*
|
|
52
|
-
* response
|
|
53
|
-
*
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
79
|
-
//
|
|
80
|
-
|
|
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
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
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
|
-
|
|
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
|
|
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.
|
|
119
|
-
|
|
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
|
|
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.
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
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.
|
|
166
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
197
|
-
|
|
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
|
|
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.
|
|
235
|
-
tool.completeInteraction('call-
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
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
|
|
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
|
|
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
|
|
144
|
-
+ 'and optional file/diff links that
|
|
145
|
-
+ '
|
|
146
|
-
+ '
|
|
147
|
-
+ '
|
|
148
|
-
+ '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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.
|
|
347
|
+
const cancellationListener = cancellationToken?.onCancellationRequested(() => this.cancelPending(toolCallId));
|
|
354
348
|
|
|
355
349
|
try {
|
|
356
350
|
return await pending.deferred.promise;
|