@theia/ai-chat 1.76.0-next.6 → 1.76.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/agent-delegation-tool.d.ts +6 -0
- package/lib/browser/agent-delegation-tool.d.ts.map +1 -1
- package/lib/browser/agent-delegation-tool.js +177 -92
- package/lib/browser/agent-delegation-tool.js.map +1 -1
- package/lib/browser/agent-delegation-tool.spec.js +170 -7
- package/lib/browser/agent-delegation-tool.spec.js.map +1 -1
- package/lib/common/chat-model.d.ts +39 -0
- package/lib/common/chat-model.d.ts.map +1 -1
- package/lib/common/chat-model.js +54 -3
- package/lib/common/chat-model.js.map +1 -1
- package/lib/common/chat-response-model.spec.js +157 -0
- package/lib/common/chat-response-model.spec.js.map +1 -1
- package/lib/common/chat-service.d.ts +12 -0
- package/lib/common/chat-service.d.ts.map +1 -1
- package/lib/common/chat-service.js +26 -7
- package/lib/common/chat-service.js.map +1 -1
- package/package.json +11 -11
- package/src/browser/agent-delegation-tool.spec.ts +237 -7
- package/src/browser/agent-delegation-tool.ts +200 -109
- package/src/common/chat-model.ts +87 -3
- package/src/common/chat-response-model.spec.ts +212 -1
- package/src/common/chat-service.ts +29 -6
|
@@ -15,7 +15,15 @@
|
|
|
15
15
|
// *****************************************************************************
|
|
16
16
|
|
|
17
17
|
import { expect } from 'chai';
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
ChatResponseContent,
|
|
20
|
+
InteractiveContent,
|
|
21
|
+
MarkdownChatResponseContentImpl,
|
|
22
|
+
MutableChatRequestModel,
|
|
23
|
+
MutableChatResponseModel,
|
|
24
|
+
QuestionResponseContentImpl,
|
|
25
|
+
ToolCallChatResponseContentImpl
|
|
26
|
+
} from './chat-model';
|
|
19
27
|
|
|
20
28
|
describe('MutableChatResponseModel', () => {
|
|
21
29
|
describe('content change propagation', () => {
|
|
@@ -190,4 +198,207 @@ describe('MutableChatResponseModel', () => {
|
|
|
190
198
|
expect(response.isWaitingForInput).to.equal(false);
|
|
191
199
|
});
|
|
192
200
|
});
|
|
201
|
+
|
|
202
|
+
describe('pending interactions', () => {
|
|
203
|
+
const createAwaitingToolCall = (id: string): ToolCallChatResponseContentImpl => {
|
|
204
|
+
const toolCall = new ToolCallChatResponseContentImpl(id, 'tool', '{}', false);
|
|
205
|
+
toolCall.requestUserConfirmation();
|
|
206
|
+
return toolCall;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
it('should track a content part fired via fireInteractionNeeded until it is resolved', async () => {
|
|
210
|
+
const response = new MutableChatResponseModel('req-1');
|
|
211
|
+
const toolCall = createAwaitingToolCall('tool-1');
|
|
212
|
+
response.response.addContent(toolCall);
|
|
213
|
+
|
|
214
|
+
expect(response.pendingInteractions).to.have.lengthOf(0);
|
|
215
|
+
|
|
216
|
+
response.fireInteractionNeeded(toolCall);
|
|
217
|
+
|
|
218
|
+
// Late subscribers (e.g. a remounted delegation renderer) must be able to
|
|
219
|
+
// rebuild pending interaction state instead of relying on the push event.
|
|
220
|
+
expect(response.pendingInteractions).to.deep.equal([toolCall]);
|
|
221
|
+
|
|
222
|
+
toolCall.complete('done');
|
|
223
|
+
await toolCall.whenResolved;
|
|
224
|
+
|
|
225
|
+
expect(response.pendingInteractions).to.have.lengthOf(0);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should not track the same interaction twice', () => {
|
|
229
|
+
const response = new MutableChatResponseModel('req-1');
|
|
230
|
+
const toolCall = createAwaitingToolCall('tool-1');
|
|
231
|
+
response.response.addContent(toolCall);
|
|
232
|
+
|
|
233
|
+
response.fireInteractionNeeded(toolCall);
|
|
234
|
+
response.fireInteractionNeeded(toolCall);
|
|
235
|
+
|
|
236
|
+
expect(response.pendingInteractions).to.have.lengthOf(1);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('should still emit onInteractionNeeded for every fireInteractionNeeded call', () => {
|
|
240
|
+
const response = new MutableChatResponseModel('req-1');
|
|
241
|
+
const toolCall = createAwaitingToolCall('tool-1');
|
|
242
|
+
response.response.addContent(toolCall);
|
|
243
|
+
|
|
244
|
+
let fireCount = 0;
|
|
245
|
+
response.onInteractionNeeded(() => { fireCount++; });
|
|
246
|
+
|
|
247
|
+
response.fireInteractionNeeded(toolCall);
|
|
248
|
+
response.fireInteractionNeeded(toolCall);
|
|
249
|
+
|
|
250
|
+
expect(fireCount).to.equal(2);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('should clear pending interactions on complete()', () => {
|
|
254
|
+
const response = new MutableChatResponseModel('req-1');
|
|
255
|
+
const toolCall = createAwaitingToolCall('tool-1');
|
|
256
|
+
response.response.addContent(toolCall);
|
|
257
|
+
response.fireInteractionNeeded(toolCall);
|
|
258
|
+
|
|
259
|
+
response.complete();
|
|
260
|
+
|
|
261
|
+
expect(response.pendingInteractions).to.have.lengthOf(0);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('should clear pending interactions on cancel()', () => {
|
|
265
|
+
const response = new MutableChatResponseModel('req-1');
|
|
266
|
+
const toolCall = createAwaitingToolCall('tool-1');
|
|
267
|
+
// cancel() rejects the tool call's confirmed promise; absorb the rejection
|
|
268
|
+
// so it does not surface as an unhandled rejection in stricter runners.
|
|
269
|
+
toolCall.confirmed.catch(() => { /* expected rejection */ });
|
|
270
|
+
response.response.addContent(toolCall);
|
|
271
|
+
response.fireInteractionNeeded(toolCall);
|
|
272
|
+
|
|
273
|
+
response.cancel();
|
|
274
|
+
|
|
275
|
+
expect(response.pendingInteractions).to.have.lengthOf(0);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('should not track interactions fired after the response completed', () => {
|
|
279
|
+
const response = new MutableChatResponseModel('req-1');
|
|
280
|
+
const toolCall = createAwaitingToolCall('tool-1');
|
|
281
|
+
response.response.addContent(toolCall);
|
|
282
|
+
response.complete();
|
|
283
|
+
|
|
284
|
+
// e.g. a bubbled child-session interaction arriving after the parent finished
|
|
285
|
+
response.fireInteractionNeeded(toolCall);
|
|
286
|
+
|
|
287
|
+
expect(response.pendingInteractions).to.have.lengthOf(0);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('should not list a confirmed tool call that is still executing', () => {
|
|
291
|
+
const response = new MutableChatResponseModel('req-1');
|
|
292
|
+
const toolCall = createAwaitingToolCall('tool-1');
|
|
293
|
+
response.response.addContent(toolCall);
|
|
294
|
+
response.fireInteractionNeeded(toolCall);
|
|
295
|
+
|
|
296
|
+
toolCall.confirm();
|
|
297
|
+
|
|
298
|
+
// The confirmation was granted; the tool is executing but no longer actionable
|
|
299
|
+
// by the user, so late subscribers must not resurrect the confirmation UI.
|
|
300
|
+
expect(response.pendingInteractions).to.have.lengthOf(0);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('should not list a tool call whose confirmation was canceled', () => {
|
|
304
|
+
const response = new MutableChatResponseModel('req-1');
|
|
305
|
+
const toolCall = createAwaitingToolCall('tool-1');
|
|
306
|
+
response.response.addContent(toolCall);
|
|
307
|
+
response.fireInteractionNeeded(toolCall);
|
|
308
|
+
toolCall.confirmed.catch(() => { /* expected rejection */ });
|
|
309
|
+
|
|
310
|
+
toolCall.cancelConfirmation(new Error('canceled'));
|
|
311
|
+
|
|
312
|
+
expect(response.pendingInteractions).to.have.lengthOf(0);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('should list a tool call waiting for user input until the input is handled', () => {
|
|
316
|
+
const response = new MutableChatResponseModel('req-1');
|
|
317
|
+
const toolCall = new ToolCallChatResponseContentImpl('tool-1', 'tool', '{}', false);
|
|
318
|
+
response.response.addContent(toolCall);
|
|
319
|
+
|
|
320
|
+
toolCall.requestUserInput();
|
|
321
|
+
response.fireInteractionNeeded(toolCall);
|
|
322
|
+
expect(response.pendingInteractions).to.deep.equal([toolCall]);
|
|
323
|
+
|
|
324
|
+
toolCall.userInputHandled();
|
|
325
|
+
expect(response.pendingInteractions).to.have.lengthOf(0);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('should stop tracking a content part whose whenResolved rejects', async () => {
|
|
329
|
+
const response = new MutableChatResponseModel('req-1');
|
|
330
|
+
let reject!: (reason?: unknown) => void;
|
|
331
|
+
const whenResolved = new Promise<void>((_, rej) => { reject = rej; });
|
|
332
|
+
const contentPart = {
|
|
333
|
+
kind: 'markdown',
|
|
334
|
+
interactionId: 'rejecting',
|
|
335
|
+
isResolved: false,
|
|
336
|
+
isAwaitingInteraction: true,
|
|
337
|
+
whenResolved
|
|
338
|
+
} as unknown as InteractiveContent & ChatResponseContent;
|
|
339
|
+
|
|
340
|
+
response.fireInteractionNeeded(contentPart);
|
|
341
|
+
expect(response.pendingInteractions).to.deep.equal([contentPart]);
|
|
342
|
+
|
|
343
|
+
// Custom InteractiveContent implementations may reject whenResolved; the model
|
|
344
|
+
// must still stop tracking the part and must not raise an unhandled rejection.
|
|
345
|
+
reject(new Error('interaction aborted'));
|
|
346
|
+
await whenResolved.catch(() => { /* expected rejection */ });
|
|
347
|
+
|
|
348
|
+
expect(response.pendingInteractions).to.have.lengthOf(0);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
describe('interactive content awaiting state', () => {
|
|
353
|
+
it('tool call: isAwaitingInteraction follows confirmation and user-input state', () => {
|
|
354
|
+
const toolCall = new ToolCallChatResponseContentImpl('tool-1', 'tool', '{}', false);
|
|
355
|
+
expect(toolCall.isAwaitingInteraction).to.equal(false);
|
|
356
|
+
|
|
357
|
+
toolCall.requestUserConfirmation();
|
|
358
|
+
expect(toolCall.isAwaitingInteraction).to.equal(true);
|
|
359
|
+
toolCall.confirm();
|
|
360
|
+
expect(toolCall.isAwaitingInteraction).to.equal(false);
|
|
361
|
+
|
|
362
|
+
toolCall.requestUserInput();
|
|
363
|
+
expect(toolCall.isAwaitingInteraction).to.equal(true);
|
|
364
|
+
toolCall.userInputHandled();
|
|
365
|
+
expect(toolCall.isAwaitingInteraction).to.equal(false);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('tool call: finishing clears the awaiting-input state', () => {
|
|
369
|
+
const toolCall = new ToolCallChatResponseContentImpl('tool-1', 'tool', '{}', false);
|
|
370
|
+
toolCall.requestUserInput();
|
|
371
|
+
|
|
372
|
+
toolCall.complete('done');
|
|
373
|
+
|
|
374
|
+
expect(toolCall.isAwaitingInteraction).to.equal(false);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('question: isAwaitingInteraction is false once answered or skipped', () => {
|
|
378
|
+
const fakeRequest = {
|
|
379
|
+
response: {
|
|
380
|
+
fireInteractionNeeded: () => { },
|
|
381
|
+
response: { responseContentChanged: () => { } }
|
|
382
|
+
}
|
|
383
|
+
} as unknown as MutableChatRequestModel;
|
|
384
|
+
const options = [{ text: 'Yes', value: 'yes' }];
|
|
385
|
+
|
|
386
|
+
const answered = new QuestionResponseContentImpl('Proceed?', options, fakeRequest, () => { });
|
|
387
|
+
expect(answered.isAwaitingInteraction).to.equal(true);
|
|
388
|
+
answered.selectedOption = options[0];
|
|
389
|
+
expect(answered.isAwaitingInteraction).to.equal(false);
|
|
390
|
+
|
|
391
|
+
// A dismissed question resolves with an empty selection: isResolved stays
|
|
392
|
+
// false, but the question must no longer count as awaiting interaction.
|
|
393
|
+
const skipped = new QuestionResponseContentImpl('Proceed?', options, fakeRequest, () => { });
|
|
394
|
+
skipped.selectedOptions = [];
|
|
395
|
+
expect(skipped.isResolved).to.equal(false);
|
|
396
|
+
expect(skipped.isAwaitingInteraction).to.equal(false);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('question: a read-only (restored) question is never awaiting interaction', () => {
|
|
400
|
+
const question = new QuestionResponseContentImpl('Proceed?', [{ text: 'Yes', value: 'yes' }], undefined, undefined);
|
|
401
|
+
expect(question.isAwaitingInteraction).to.equal(false);
|
|
402
|
+
});
|
|
403
|
+
});
|
|
193
404
|
});
|
|
@@ -49,17 +49,36 @@ import debounce = require('@theia/core/shared/lodash.debounce');
|
|
|
49
49
|
// Re-export for backward compatibility
|
|
50
50
|
export { DefaultChatAgentId, FallbackChatAgentId };
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Error with which {@link ChatRequestInvocation.requestCompleted} and
|
|
54
|
+
* {@link ChatRequestInvocation.responseCreated} are rejected when no chat agent is available
|
|
55
|
+
* to handle the request.
|
|
56
|
+
*/
|
|
57
|
+
export class NoChatAgentError extends Error {
|
|
58
|
+
constructor(message: string) {
|
|
59
|
+
super(message);
|
|
60
|
+
this.name = 'NoChatAgentError';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
static is(error: unknown): error is NoChatAgentError {
|
|
64
|
+
return error instanceof Error && error.name === 'NoChatAgentError';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
52
68
|
export interface ChatRequestInvocation {
|
|
53
69
|
/**
|
|
54
70
|
* Promise which completes once the request preprocessing is complete.
|
|
71
|
+
* Rejected with a {@link NoChatAgentError} when no agent is available to handle the request.
|
|
55
72
|
*/
|
|
56
73
|
requestCompleted: Promise<ChatRequestModel>;
|
|
57
74
|
/**
|
|
58
75
|
* Promise which completes once a response is expected to arrive.
|
|
76
|
+
* Rejected with a {@link NoChatAgentError} when no agent is available to handle the request.
|
|
59
77
|
*/
|
|
60
78
|
responseCreated: Promise<ChatResponseModel>;
|
|
61
79
|
/**
|
|
62
80
|
* Promise which completes once the response is complete.
|
|
81
|
+
* In the no-agent case it resolves with the error response model instead of rejecting.
|
|
63
82
|
*/
|
|
64
83
|
responseCompleted: Promise<ChatResponseModel>;
|
|
65
84
|
}
|
|
@@ -341,14 +360,18 @@ export class ChatServiceImpl implements ChatService {
|
|
|
341
360
|
session.pinnedAgent = agent;
|
|
342
361
|
|
|
343
362
|
if (agent === undefined) {
|
|
344
|
-
const error = 'No agent was found to handle this request. ' +
|
|
363
|
+
const error = new NoChatAgentError('No agent was found to handle this request. ' +
|
|
345
364
|
'Please ensure you have configured a default agent in the preferences and that the agent is enabled in the AI Configuration view. ' +
|
|
346
|
-
'Alternatively, mention a specific agent with @AgentName.';
|
|
347
|
-
this.logger.error(error);
|
|
348
|
-
const chatResponseModel = new ErrorChatResponseModel(generateUuid(),
|
|
365
|
+
'Alternatively, mention a specific agent with @AgentName.');
|
|
366
|
+
this.logger.error(error.message);
|
|
367
|
+
const chatResponseModel = new ErrorChatResponseModel(generateUuid(), error);
|
|
368
|
+
// share one handled rejection so that consumers awaiting only one of the
|
|
369
|
+
// promises do not trigger unhandled rejection reports for the other
|
|
370
|
+
const rejected = Promise.reject<never>(error);
|
|
371
|
+
rejected.catch(() => { });
|
|
349
372
|
return {
|
|
350
|
-
requestCompleted:
|
|
351
|
-
responseCreated:
|
|
373
|
+
requestCompleted: rejected,
|
|
374
|
+
responseCreated: rejected,
|
|
352
375
|
responseCompleted: Promise.resolve(chatResponseModel),
|
|
353
376
|
};
|
|
354
377
|
}
|