@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.
@@ -15,7 +15,15 @@
15
15
  // *****************************************************************************
16
16
 
17
17
  import { expect } from 'chai';
18
- import { MarkdownChatResponseContentImpl, MutableChatResponseModel, ToolCallChatResponseContentImpl } from './chat-model';
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(), new Error(error));
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: Promise.reject(error),
351
- responseCreated: Promise.reject(error),
373
+ requestCompleted: rejected,
374
+ responseCreated: rejected,
352
375
  responseCompleted: Promise.resolve(chatResponseModel),
353
376
  };
354
377
  }