@theia/ai-chat 1.71.0-next.8 → 1.71.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/lib/browser/agent-delegation-tool.d.ts +4 -10
  2. package/lib/browser/agent-delegation-tool.d.ts.map +1 -1
  3. package/lib/browser/agent-delegation-tool.js +31 -22
  4. package/lib/browser/agent-delegation-tool.js.map +1 -1
  5. package/lib/browser/agent-delegation-tool.spec.js +3 -2
  6. package/lib/browser/agent-delegation-tool.spec.js.map +1 -1
  7. package/lib/browser/chat-tool-preference-bindings.d.ts +3 -2
  8. package/lib/browser/chat-tool-preference-bindings.d.ts.map +1 -1
  9. package/lib/browser/chat-tool-preference-bindings.js +9 -8
  10. package/lib/browser/chat-tool-preference-bindings.js.map +1 -1
  11. package/lib/browser/chat-tool-preference-bindings.spec.js +72 -21
  12. package/lib/browser/chat-tool-preference-bindings.spec.js.map +1 -1
  13. package/lib/browser/chat-tool-request-service.d.ts.map +1 -1
  14. package/lib/browser/chat-tool-request-service.js +3 -1
  15. package/lib/browser/chat-tool-request-service.js.map +1 -1
  16. package/lib/common/chat-agents.d.ts +5 -2
  17. package/lib/common/chat-agents.d.ts.map +1 -1
  18. package/lib/common/chat-agents.js +54 -1
  19. package/lib/common/chat-agents.js.map +1 -1
  20. package/lib/common/chat-agents.spec.d.ts +2 -0
  21. package/lib/common/chat-agents.spec.d.ts.map +1 -0
  22. package/lib/common/chat-agents.spec.js +100 -0
  23. package/lib/common/chat-agents.spec.js.map +1 -0
  24. package/lib/common/chat-model-serialization.d.ts +2 -0
  25. package/lib/common/chat-model-serialization.d.ts.map +1 -1
  26. package/lib/common/chat-model-serialization.js.map +1 -1
  27. package/lib/common/chat-model.d.ts +59 -11
  28. package/lib/common/chat-model.d.ts.map +1 -1
  29. package/lib/common/chat-model.js +82 -2
  30. package/lib/common/chat-model.js.map +1 -1
  31. package/lib/common/chat-request-parser.spec.js +5 -2
  32. package/lib/common/chat-request-parser.spec.js.map +1 -1
  33. package/lib/common/chat-response-model.spec.d.ts +2 -0
  34. package/lib/common/chat-response-model.spec.d.ts.map +1 -0
  35. package/lib/common/chat-response-model.spec.js +43 -0
  36. package/lib/common/chat-response-model.spec.js.map +1 -0
  37. package/package.json +11 -11
  38. package/src/browser/agent-delegation-tool.spec.ts +4 -2
  39. package/src/browser/agent-delegation-tool.ts +40 -29
  40. package/src/browser/chat-tool-preference-bindings.spec.ts +83 -23
  41. package/src/browser/chat-tool-preference-bindings.ts +17 -8
  42. package/src/browser/chat-tool-request-service.ts +3 -2
  43. package/src/common/chat-agents.spec.ts +124 -0
  44. package/src/common/chat-agents.ts +59 -2
  45. package/src/common/chat-model-serialization.ts +2 -0
  46. package/src/common/chat-model.ts +136 -12
  47. package/src/common/chat-request-parser.spec.ts +6 -2
  48. package/src/common/chat-response-model.spec.ts +47 -0
@@ -15,18 +15,20 @@
15
15
  // *****************************************************************************
16
16
 
17
17
  import { AGENT_DELEGATION_FUNCTION_ID, ToolInvocationContext, ToolProvider, ToolRequest } from '@theia/ai-core';
18
+ import { Disposable } from '@theia/core';
18
19
  import { inject, injectable } from '@theia/core/shared/inversify';
19
20
  import {
20
21
  assertChatContext,
21
22
  ChatAgentService,
22
23
  ChatAgentServiceFactory,
24
+ ChatChangeEvent,
23
25
  ChatRequest,
24
26
  ChatService,
25
27
  ChatServiceFactory,
26
28
  ChatToolContext,
27
29
  MutableChatModel,
28
30
  MutableChatRequestModel,
29
- ChatSession,
31
+ MutableChatResponseModel,
30
32
  ChatRequestInvocation,
31
33
  } from '../common';
32
34
  import { TASK_CONTEXT_VARIABLE } from './task-context-variable';
@@ -114,6 +116,7 @@ export class AgentDelegationTool implements ToolProvider {
114
116
  }
115
117
 
116
118
  let newSession;
119
+ let childModelDisposable: Disposable | undefined;
117
120
  try {
118
121
  // FIXME: this creates a new conversation visible in the UI (Panel), which we don't want
119
122
  // It is not possible to start a session without specifying a location (default=Panel)
@@ -146,8 +149,8 @@ export class AgentDelegationTool implements ToolProvider {
146
149
  chatService.setActiveSession(currentActiveSession.id, { focus: false });
147
150
  }
148
151
 
149
- // Setup ChangeSet bubbling from delegated session to parent session
150
- this.setupChangeSetBubbling(newSession, ctx.request.session);
152
+ // Setup bubbling of child session events to parent session
153
+ childModelDisposable = this.setupChildSessionBubbling(newSession.model as MutableChatModel, ctx.request.session, ctx.response);
151
154
  } catch (sessionError) {
152
155
  const errorMsg = `Failed to create chat session for agent '${agentId}': ${sessionError instanceof Error ? sessionError.message : sessionError}`;
153
156
  console.error(errorMsg, sessionError);
@@ -209,7 +212,8 @@ export class AgentDelegationTool implements ToolProvider {
209
212
  const result = await response.responseCompleted;
210
213
  const stringResult = result.response.asString();
211
214
 
212
- // Clean up the session after completion (no need to await)
215
+ // Clean up the session and parent-child link after completion
216
+ childModelDisposable?.dispose();
213
217
  const chatService = this.getChatService();
214
218
  chatService.deleteSession(newSession.id).catch(error => {
215
219
  console.error('Failed to delete delegated session', error);
@@ -242,33 +246,40 @@ export class AgentDelegationTool implements ToolProvider {
242
246
  }
243
247
 
244
248
  /**
245
- * Sets up monitoring of the ChangeSet in the delegated session and bubbles changes to the parent session.
246
- * @param delegatedSession The session created for the delegated agent
247
- * @param parentModel The parent session model that should receive the bubbled changes
249
+ * Sets up all event bubbling from a delegated child session to the parent session:
250
+ * - Interaction forwarding: child interactionNeeded events are forwarded to the parent response
251
+ * - ChangeSet bubbling: child changeset changes are forwarded to the parent model
248
252
  */
249
- private setupChangeSetBubbling(
250
- delegatedSession: ChatSession,
251
- parentModel: MutableChatModel
252
- ): void {
253
- // Monitor ChangeSet for bubbling
254
- delegatedSession.model.changeSet.onDidChange(_event => {
255
- this.bubbleChangeSet(delegatedSession, parentModel);
253
+ private setupChildSessionBubbling(
254
+ childModel: MutableChatModel,
255
+ parentModel: MutableChatModel,
256
+ parentResponse: MutableChatResponseModel
257
+ ): Disposable {
258
+
259
+ // Forward interactionNeeded events to the parent response model
260
+ // so the UI (which subscribes to response.onInteractionNeeded) can display them.
261
+ // Also watch for each forwarded interaction's resolution to trigger cleanup.
262
+ const eventForwarding = childModel.onDidChange(event => {
263
+ if (ChatChangeEvent.isInteractionNeededEvent(event)) {
264
+ parentResponse.fireInteractionNeeded(event.contentPart);
265
+ event.contentPart.whenResolved.then(() => parentResponse.notifyChanged());
266
+ }
256
267
  });
257
- }
258
268
 
259
- /**
260
- * Bubbles the ChangeSet from the delegated session to the parent session.
261
- * @param delegatedSession The session from which to bubble changes
262
- * @param parentModel The parent session model to receive the bubbled changes
263
- */
264
- private bubbleChangeSet(
265
- delegatedSession: ChatSession,
266
- parentModel: MutableChatModel
267
- ): void {
268
- const delegatedElements = delegatedSession.model.changeSet.getElements();
269
- if (delegatedElements.length > 0) {
270
- parentModel.changeSet.setTitle(delegatedSession.model.changeSet.title);
271
- parentModel.changeSet.addElements(...delegatedElements);
272
- }
269
+ // Bubble ChangeSet changes to the parent model
270
+ const changeSetForwarding = childModel.changeSet.onDidChange(() => {
271
+ const delegatedElements = childModel.changeSet.getElements();
272
+ if (delegatedElements.length > 0) {
273
+ parentModel.changeSet.setTitle(childModel.changeSet.title);
274
+ parentModel.changeSet.addElements(...delegatedElements);
275
+ }
276
+ });
277
+
278
+ return {
279
+ dispose: () => {
280
+ eventForwarding.dispose();
281
+ changeSetForwarding.dispose();
282
+ }
283
+ };
273
284
  }
274
285
  }
@@ -16,15 +16,24 @@
16
16
 
17
17
  import { expect } from 'chai';
18
18
  import * as sinon from 'sinon';
19
+ import { Container } from '@theia/core/shared/inversify';
19
20
  import { ToolConfirmationManager } from './chat-tool-preference-bindings';
20
- import { ToolConfirmationMode, TOOL_CONFIRMATION_PREFERENCE, ChatToolPreferences } from '../common/chat-tool-preferences';
21
+ import { ToolConfirmationMode } from '../common/chat-tool-preferences';
21
22
  import { ToolRequest } from '@theia/ai-core';
22
23
  import { PreferenceService } from '@theia/core/lib/common/preferences';
24
+ import { TrustAwarePreferenceReader } from '@theia/ai-core/lib/browser/trust-aware-preference-reader';
23
25
 
24
26
  describe('ToolConfirmationManager', () => {
25
27
  let manager: ToolConfirmationManager;
26
28
  let preferenceServiceMock: sinon.SinonStubbedInstance<PreferenceService>;
29
+ let trustAwareReaderMock: sinon.SinonStubbedInstance<TrustAwarePreferenceReader>;
27
30
  let storedPreferences: { [toolId: string]: ToolConfirmationMode };
31
+ let trusted: boolean;
32
+ let inspectResult: {
33
+ defaultValue?: { [toolId: string]: ToolConfirmationMode };
34
+ globalValue?: { [toolId: string]: ToolConfirmationMode };
35
+ workspaceValue?: { [toolId: string]: ToolConfirmationMode };
36
+ } | undefined;
28
37
 
29
38
  const createToolRequest = (id: string, confirmAlwaysAllow?: boolean | string): ToolRequest => ({
30
39
  id,
@@ -34,26 +43,35 @@ describe('ToolConfirmationManager', () => {
34
43
  confirmAlwaysAllow
35
44
  });
36
45
 
37
- const getPreferencesMock = (): ChatToolPreferences => ({
38
- get [TOOL_CONFIRMATION_PREFERENCE](): { [toolId: string]: ToolConfirmationMode } {
39
- return storedPreferences;
40
- }
41
- }) as unknown as ChatToolPreferences;
42
-
43
46
  beforeEach(() => {
44
47
  storedPreferences = {};
48
+ trusted = true;
49
+ inspectResult = undefined;
45
50
 
46
51
  preferenceServiceMock = {
47
52
  updateValue: sinon.stub().callsFake((_key: string, value: { [toolId: string]: ToolConfirmationMode }) => {
48
53
  storedPreferences = value;
49
54
  return Promise.resolve();
50
55
  }),
51
- inspect: sinon.stub().returns(undefined)
56
+ inspect: sinon.stub().callsFake(() => inspectResult)
52
57
  } as unknown as sinon.SinonStubbedInstance<PreferenceService>;
53
58
 
54
- manager = new ToolConfirmationManager();
55
- (manager as unknown as { preferences: ChatToolPreferences }).preferences = getPreferencesMock();
56
- (manager as unknown as { preferenceService: PreferenceService }).preferenceService = preferenceServiceMock;
59
+ trustAwareReaderMock = {
60
+ get: sinon.stub().callsFake(<T>(_name: string, fallback?: T): T | undefined => {
61
+ if (trusted) {
62
+ return (storedPreferences as unknown as T) ?? fallback;
63
+ }
64
+ const insp = inspectResult;
65
+ const value = insp?.globalValue ?? insp?.defaultValue;
66
+ return ((value as unknown as T) ?? fallback);
67
+ })
68
+ } as unknown as sinon.SinonStubbedInstance<TrustAwarePreferenceReader>;
69
+
70
+ const container = new Container();
71
+ container.bind(ToolConfirmationManager).toSelf().inSingletonScope();
72
+ container.bind(PreferenceService).toConstantValue(preferenceServiceMock as unknown as PreferenceService);
73
+ container.bind(TrustAwarePreferenceReader).toConstantValue(trustAwareReaderMock as unknown as TrustAwarePreferenceReader);
74
+ manager = container.get(ToolConfirmationManager);
57
75
  });
58
76
 
59
77
  describe('getConfirmationMode', () => {
@@ -95,6 +113,48 @@ describe('ToolConfirmationManager', () => {
95
113
  });
96
114
  });
97
115
 
116
+ describe('workspace trust', () => {
117
+ it('ignores workspace {"*": "always_allow"} when workspace is untrusted', () => {
118
+ trusted = false;
119
+ // Simulate the effective (workspace-merged) preference containing always_allow,
120
+ // while the user/global scope is not set and the schema default prescribes
121
+ // CONFIRM. If the workspace override were honoured the mode would be
122
+ // ALWAYS_ALLOW; because trust filters the workspace scope out, the default
123
+ // (CONFIRM) wins, which demonstrably proves the override was dropped.
124
+ storedPreferences['*'] = ToolConfirmationMode.ALWAYS_ALLOW;
125
+ inspectResult = {
126
+ defaultValue: { '*': ToolConfirmationMode.CONFIRM },
127
+ workspaceValue: { '*': ToolConfirmationMode.ALWAYS_ALLOW }
128
+ };
129
+
130
+ const mode = manager.getConfirmationMode('someTool', 'chat-1');
131
+ expect(mode).to.equal(ToolConfirmationMode.CONFIRM);
132
+ });
133
+
134
+ it('blocks confirmAlwaysAllow bypass via workspace {"*": "always_allow"} when untrusted', () => {
135
+ trusted = false;
136
+ storedPreferences['*'] = ToolConfirmationMode.ALWAYS_ALLOW;
137
+ inspectResult = {
138
+ defaultValue: {},
139
+ workspaceValue: { '*': ToolConfirmationMode.ALWAYS_ALLOW }
140
+ };
141
+
142
+ const toolRequest = createToolRequest('dangerousTool', true);
143
+ const mode = manager.getConfirmationMode('dangerousTool', 'chat-1', toolRequest);
144
+ // Without the workspace value the map is empty, so the default for a
145
+ // confirmAlwaysAllow tool (CONFIRM) is used.
146
+ expect(mode).to.equal(ToolConfirmationMode.CONFIRM);
147
+ });
148
+
149
+ it('honours workspace {"*": "always_allow"} when workspace is trusted', () => {
150
+ trusted = true;
151
+ storedPreferences['*'] = ToolConfirmationMode.ALWAYS_ALLOW;
152
+
153
+ const mode = manager.getConfirmationMode('regularTool', 'chat-1');
154
+ expect(mode).to.equal(ToolConfirmationMode.ALWAYS_ALLOW);
155
+ });
156
+ });
157
+
98
158
  describe('setConfirmationMode', () => {
99
159
  it('should persist ALWAYS_ALLOW for regular tools when different from default', () => {
100
160
  storedPreferences['*'] = ToolConfirmationMode.CONFIRM;
@@ -116,34 +176,34 @@ describe('ToolConfirmationManager', () => {
116
176
  });
117
177
 
118
178
  it('should not persist when mode matches the global preference default', () => {
119
- (preferenceServiceMock.inspect as sinon.SinonStub).returns({
179
+ inspectResult = {
120
180
  defaultValue: { '*': ToolConfirmationMode.CONFIRM }
121
- });
181
+ };
122
182
  manager.setConfirmationMode('regularTool', ToolConfirmationMode.CONFIRM);
123
183
  expect(preferenceServiceMock.updateValue.called).to.be.false;
124
184
  });
125
185
 
126
186
  it('should persist when mode differs from the global preference default', () => {
127
- (preferenceServiceMock.inspect as sinon.SinonStub).returns({
187
+ inspectResult = {
128
188
  defaultValue: { '*': ToolConfirmationMode.CONFIRM }
129
- });
189
+ };
130
190
  manager.setConfirmationMode('regularTool', ToolConfirmationMode.DISABLED);
131
191
  expect(preferenceServiceMock.updateValue.calledOnce).to.be.true;
132
192
  expect(storedPreferences['regularTool']).to.equal(ToolConfirmationMode.DISABLED);
133
193
  });
134
194
 
135
195
  it('should not persist when mode matches the tool-specific preference default', () => {
136
- (preferenceServiceMock.inspect as sinon.SinonStub).returns({
196
+ inspectResult = {
137
197
  defaultValue: { 'myTool': ToolConfirmationMode.DISABLED }
138
- });
198
+ };
139
199
  manager.setConfirmationMode('myTool', ToolConfirmationMode.DISABLED);
140
200
  expect(preferenceServiceMock.updateValue.called).to.be.false;
141
201
  });
142
202
 
143
203
  it('should remove entry when mode matches the tool-specific preference default and entry exists', () => {
144
- (preferenceServiceMock.inspect as sinon.SinonStub).returns({
204
+ inspectResult = {
145
205
  defaultValue: { 'myTool': ToolConfirmationMode.DISABLED }
146
- });
206
+ };
147
207
  storedPreferences['myTool'] = ToolConfirmationMode.ALWAYS_ALLOW;
148
208
  manager.setConfirmationMode('myTool', ToolConfirmationMode.DISABLED);
149
209
  expect(preferenceServiceMock.updateValue.calledOnce).to.be.true;
@@ -151,18 +211,18 @@ describe('ToolConfirmationManager', () => {
151
211
  });
152
212
 
153
213
  it('should not persist CONFIRM for confirmAlwaysAllow tool when global preference default is CONFIRM', () => {
154
- (preferenceServiceMock.inspect as sinon.SinonStub).returns({
214
+ inspectResult = {
155
215
  defaultValue: { '*': ToolConfirmationMode.CONFIRM }
156
- });
216
+ };
157
217
  const toolRequest = createToolRequest('dangerousTool', true);
158
218
  manager.setConfirmationMode('dangerousTool', ToolConfirmationMode.CONFIRM, toolRequest);
159
219
  expect(preferenceServiceMock.updateValue.called).to.be.false;
160
220
  });
161
221
 
162
222
  it('should persist ALWAYS_ALLOW for confirmAlwaysAllow tool when global preference default is CONFIRM', () => {
163
- (preferenceServiceMock.inspect as sinon.SinonStub).returns({
223
+ inspectResult = {
164
224
  defaultValue: { '*': ToolConfirmationMode.CONFIRM }
165
- });
225
+ };
166
226
  const toolRequest = createToolRequest('dangerousTool', true);
167
227
  manager.setConfirmationMode('dangerousTool', ToolConfirmationMode.ALWAYS_ALLOW, toolRequest);
168
228
  expect(preferenceServiceMock.updateValue.calledOnce).to.be.true;
@@ -18,20 +18,21 @@ import { injectable, inject } from '@theia/core/shared/inversify';
18
18
  import {
19
19
  PreferenceService,
20
20
  } from '@theia/core/lib/common/preferences';
21
- import { ToolConfirmationMode, TOOL_CONFIRMATION_PREFERENCE, ChatToolPreferences } from '../common/chat-tool-preferences';
21
+ import { ToolConfirmationMode, TOOL_CONFIRMATION_PREFERENCE } from '../common/chat-tool-preferences';
22
22
  import { ToolRequest } from '@theia/ai-core';
23
+ import { TrustAwarePreferenceReader } from '@theia/ai-core/lib/browser/trust-aware-preference-reader';
23
24
 
24
25
  /**
25
26
  * Utility class to manage tool confirmation settings
26
27
  */
27
28
  @injectable()
28
29
  export class ToolConfirmationManager {
29
- @inject(ChatToolPreferences)
30
- protected readonly preferences: ChatToolPreferences;
31
-
32
30
  @inject(PreferenceService)
33
31
  protected readonly preferenceService: PreferenceService;
34
32
 
33
+ @inject(TrustAwarePreferenceReader)
34
+ protected readonly trustAwareReader: TrustAwarePreferenceReader;
35
+
35
36
  // In-memory session overrides (not persisted), per chat
36
37
  protected sessionOverrides: Map<string, Map<string, ToolConfirmationMode>> = new Map();
37
38
 
@@ -51,7 +52,9 @@ export class ToolConfirmationManager {
51
52
  if (chatMap && chatMap.has(toolId)) {
52
53
  return chatMap.get(toolId)!;
53
54
  }
54
- const toolConfirmation = this.preferences[TOOL_CONFIRMATION_PREFERENCE];
55
+ const toolConfirmation = this.trustAwareReader.get<Record<string, ToolConfirmationMode>>(
56
+ TOOL_CONFIRMATION_PREFERENCE, {}
57
+ ) ?? {};
55
58
  if (toolConfirmation[toolId]) {
56
59
  return toolConfirmation[toolId];
57
60
  }
@@ -80,7 +83,9 @@ export class ToolConfirmationManager {
80
83
  const defaultPref = this.preferenceService.inspect(TOOL_CONFIRMATION_PREFERENCE)?.defaultValue as {
81
84
  [toolId: string]: ToolConfirmationMode;
82
85
  } || {};
83
- const current = this.preferences[TOOL_CONFIRMATION_PREFERENCE] || {};
86
+ const current = this.trustAwareReader.get<Record<string, ToolConfirmationMode>>(
87
+ TOOL_CONFIRMATION_PREFERENCE, {}
88
+ ) ?? {};
84
89
  let starMode = current['*'];
85
90
  if (starMode === undefined) {
86
91
  starMode = defaultPref['*'] ?? ToolConfirmationMode.ALWAYS_ALLOW;
@@ -127,11 +132,15 @@ export class ToolConfirmationManager {
127
132
  * Get all tool confirmation settings
128
133
  */
129
134
  getAllConfirmationSettings(): { [toolId: string]: ToolConfirmationMode } {
130
- return this.preferences[TOOL_CONFIRMATION_PREFERENCE] || {};
135
+ return this.trustAwareReader.get<Record<string, ToolConfirmationMode>>(
136
+ TOOL_CONFIRMATION_PREFERENCE, {}
137
+ ) ?? {};
131
138
  }
132
139
 
133
140
  resetAllConfirmationModeSettings(): void {
134
- const current = this.preferences[TOOL_CONFIRMATION_PREFERENCE] || {};
141
+ const current = this.trustAwareReader.get<Record<string, ToolConfirmationMode>>(
142
+ TOOL_CONFIRMATION_PREFERENCE, {}
143
+ ) ?? {};
135
144
  if ('*' in current) {
136
145
  this.preferenceService.updateValue(TOOL_CONFIRMATION_PREFERENCE, { '*': current['*'] });
137
146
  } else {
@@ -39,12 +39,12 @@ export class FrontendChatToolRequestService extends ChatToolRequestService {
39
39
  protected readonly preferences: ChatToolPreferences;
40
40
 
41
41
  protected override toChatToolRequest(toolRequest: ToolRequest, request: MutableChatRequestModel): ToolRequest {
42
- const confirmationMode = this.confirmationManager.getConfirmationMode(toolRequest.id, request.session.id, toolRequest);
43
-
44
42
  return {
45
43
  ...toolRequest,
46
44
  handler: async (arg_string: string, ctx?: ToolInvocationContext) => {
47
45
  const toolCallId = ctx?.toolCallId;
46
+ const sessionId = request.session.rootSessionId ?? request.session.id;
47
+ const confirmationMode = this.confirmationManager.getConfirmationMode(toolRequest.id, sessionId, toolRequest);
48
48
 
49
49
  switch (confirmationMode) {
50
50
  case ToolConfirmationMode.DISABLED:
@@ -84,6 +84,7 @@ export class FrontendChatToolRequestService extends ChatToolRequestService {
84
84
  : this.preferences[TOOL_CONFIRMATION_TIMEOUT_PREFERENCE];
85
85
  toolCallContent.confirmationTimeout = timeoutSeconds;
86
86
  toolCallContent.requestUserConfirmation();
87
+ request.response.fireInteractionNeeded(toolCallContent);
87
88
  confirmed = await raceConfirmationWithTimeout(toolCallContent, timeoutSeconds);
88
89
  }
89
90
 
@@ -0,0 +1,124 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import 'reflect-metadata';
18
+
19
+ import { expect } from 'chai';
20
+ import { LanguageModelMessage, LanguageModelRequirement } from '@theia/ai-core';
21
+ import { AbstractChatAgent, ChatAgentLocation } from './chat-agents';
22
+ import {
23
+ MutableChatModel,
24
+ MutableChatRequestModel,
25
+ ChatModel,
26
+ TextChatResponseContentImpl,
27
+ ThinkingChatResponseContentImpl,
28
+ } from './chat-model';
29
+ import { ParsedChatRequest, ParsedChatRequestTextPart } from './parsed-chat-request';
30
+
31
+ class TestChatAgent extends AbstractChatAgent {
32
+ readonly id = 'test-agent';
33
+ readonly name = 'Test Agent';
34
+ readonly languageModelRequirements: LanguageModelRequirement[] = [];
35
+ protected readonly defaultLanguageModelPurpose = 'chat';
36
+
37
+ protected addContentsToResponse(): Promise<void> {
38
+ return Promise.resolve();
39
+ }
40
+
41
+ public async exposeGetMessages(model: ChatModel, includeResponseInProgress = false): Promise<LanguageModelMessage[]> {
42
+ return this.getMessages(model, includeResponseInProgress);
43
+ }
44
+ }
45
+
46
+ function createParsedRequest(text: string): ParsedChatRequest {
47
+ return {
48
+ request: { text },
49
+ parts: [
50
+ new ParsedChatRequestTextPart({ start: 0, endExclusive: text.length }, text)
51
+ ],
52
+ toolRequests: new Map(),
53
+ variables: []
54
+ };
55
+ }
56
+
57
+ describe('AbstractChatAgent.getMessages', () => {
58
+
59
+ let agent: TestChatAgent;
60
+
61
+ beforeEach(() => {
62
+ agent = new TestChatAgent();
63
+ });
64
+
65
+ function addThinkingResponse(request: MutableChatRequestModel, content: string, signature: string): void {
66
+ request.response.response.addContent(new ThinkingChatResponseContentImpl(content, signature));
67
+ }
68
+
69
+ function addTextResponse(request: MutableChatRequestModel, text: string): void {
70
+ request.response.response.addContent(new TextChatResponseContentImpl(text));
71
+ }
72
+
73
+ it('filters out incomplete thinking blocks (empty signature) from a cancelled stream', async () => {
74
+ const model = new MutableChatModel(ChatAgentLocation.Panel);
75
+ const request = model.addRequest(createParsedRequest('Hello'));
76
+
77
+ addThinkingResponse(request, 'Some thinking that was cancelled', '');
78
+ request.response.cancel();
79
+
80
+ const messages = await agent.exposeGetMessages(model);
81
+
82
+ expect(messages.filter(LanguageModelMessage.isThinkingMessage)).to.have.lengthOf(0);
83
+ // The user text message should still be included
84
+ const userTextMessages = messages
85
+ .filter(LanguageModelMessage.isTextMessage)
86
+ .filter(m => m.actor === 'user');
87
+ expect(userTextMessages).to.have.lengthOf(1);
88
+ });
89
+
90
+ it('keeps thinking blocks with a valid signature', async () => {
91
+ const model = new MutableChatModel(ChatAgentLocation.Panel);
92
+ const request = model.addRequest(createParsedRequest('Hello'));
93
+
94
+ addThinkingResponse(request, 'Complete thought', 'sig-abc');
95
+ addTextResponse(request, 'Hi there');
96
+ request.response.complete();
97
+
98
+ const messages = await agent.exposeGetMessages(model);
99
+
100
+ const thinkingMessages = messages.filter(LanguageModelMessage.isThinkingMessage);
101
+ expect(thinkingMessages).to.have.lengthOf(1);
102
+ expect(thinkingMessages[0].thinking).to.equal('Complete thought');
103
+ expect(thinkingMessages[0].signature).to.equal('sig-abc');
104
+ });
105
+
106
+ it('filters incomplete thinking but preserves following text content from the same response', async () => {
107
+ const model = new MutableChatModel(ChatAgentLocation.Panel);
108
+ const request = model.addRequest(createParsedRequest('Hello'));
109
+
110
+ addThinkingResponse(request, 'Cancelled thought', '');
111
+ addTextResponse(request, 'Partial reply before cancel');
112
+ request.response.cancel();
113
+
114
+ const messages = await agent.exposeGetMessages(model);
115
+
116
+ expect(messages.filter(LanguageModelMessage.isThinkingMessage)).to.have.lengthOf(0);
117
+
118
+ const aiTextMessages = messages
119
+ .filter(LanguageModelMessage.isTextMessage)
120
+ .filter(m => m.actor === 'ai');
121
+ expect(aiTextMessages).to.have.lengthOf(1);
122
+ expect(aiTextMessages[0].text).to.equal('Partial reply before cancel');
123
+ });
124
+ });
@@ -33,6 +33,8 @@ import {
33
33
  isToolCallResponsePart,
34
34
  isUsageResponsePart,
35
35
  LanguageModel,
36
+ TokenUsageService,
37
+ UsageResponsePart,
36
38
  LanguageModelMessage,
37
39
  LanguageModelRequirement,
38
40
  LanguageModelResponse,
@@ -47,13 +49,14 @@ import {
47
49
  } from '@theia/ai-core';
48
50
  import {
49
51
  Agent,
52
+ isLanguageModelParsedResponse,
50
53
  isLanguageModelStreamResponse,
51
54
  isLanguageModelTextResponse,
52
55
  LanguageModelRegistry,
53
56
  LanguageModelStreamResponsePart
54
57
  } from '@theia/ai-core/lib/common';
55
58
  import { ContributionProvider, ILogger, isArray, nls } from '@theia/core';
56
- import { inject, injectable, named, postConstruct } from '@theia/core/shared/inversify';
59
+ import { inject, injectable, named, optional, postConstruct } from '@theia/core/shared/inversify';
57
60
  import { ChatAgentService } from './chat-agent-service';
58
61
  import {
59
62
  ChatModel,
@@ -68,6 +71,8 @@ import {
68
71
  ToolCallArgumentsDeltaContent,
69
72
  ErrorChatResponseContent,
70
73
  InformationalChatResponseContent,
74
+ ResponseTokenUsage,
75
+ ThinkingChatResponseContent,
71
76
  } from './chat-model';
72
77
  import { ChatToolRequestService } from './chat-tool-request-service';
73
78
  import { parseContents } from './parse-contents';
@@ -179,6 +184,8 @@ export abstract class AbstractChatAgent implements ChatAgent {
179
184
  @inject(DefaultResponseContentFactory)
180
185
  protected defaultContentFactory: DefaultResponseContentFactory;
181
186
 
187
+ @inject(TokenUsageService) @optional() protected tokenUsageService: TokenUsageService | undefined;
188
+
182
189
  readonly abstract id: string;
183
190
  readonly abstract name: string;
184
191
  readonly abstract languageModelRequirements: LanguageModelRequirement[];
@@ -259,6 +266,7 @@ export abstract class AbstractChatAgent implements ChatAgent {
259
266
  );
260
267
 
261
268
  await this.addContentsToResponse(languageModelResponse, request);
269
+ await this.recordTokenUsageFromResponse(request, languageModel);
262
270
  await this.onResponseComplete(request);
263
271
 
264
272
  } catch (e) {
@@ -418,6 +426,12 @@ export abstract class AbstractChatAgent implements ChatAgent {
418
426
  if (ErrorChatResponseContent.is(c) || InformationalChatResponseContent.is(c)) {
419
427
  return false;
420
428
  }
429
+ // skip incomplete thinking blocks (e.g. from a cancelled stream where the
430
+ // signature_delta never arrived). Some LLMs (e.g. Anthropic) reject thinking
431
+ // blocks without a signature.
432
+ if (ThinkingChatResponseContent.is(c) && !c.signature) {
433
+ return false;
434
+ }
421
435
  // content even has an own converter, definitely include it
422
436
  if (ChatResponseContent.hasToLanguageModelMessage(c)) {
423
437
  return true;
@@ -498,7 +512,7 @@ export abstract class AbstractChatAgent implements ChatAgent {
498
512
  messages,
499
513
  tools,
500
514
  settings,
501
- thinkingMode: commonSettings?.thinkingMode,
515
+ reasoning: commonSettings?.reasoning,
502
516
  agentId: this.id,
503
517
  sessionId: request.session.id,
504
518
  requestId: request.id,
@@ -526,6 +540,34 @@ export abstract class AbstractChatAgent implements ChatAgent {
526
540
  return request.response.complete();
527
541
  }
528
542
 
543
+ protected mapUsageResponsePart(usage: UsageResponsePart): ResponseTokenUsage {
544
+ return {
545
+ inputTokens: usage.input_tokens,
546
+ outputTokens: usage.output_tokens,
547
+ cacheCreationInputTokens: usage.cache_creation_input_tokens,
548
+ cacheReadInputTokens: usage.cache_read_input_tokens,
549
+ };
550
+ }
551
+
552
+ protected async recordTokenUsageFromResponse(request: MutableChatRequestModel, languageModel: LanguageModel): Promise<void> {
553
+ if (!this.tokenUsageService) {
554
+ return;
555
+ }
556
+ const entries = request.response.tokenUsageEntries;
557
+ if (entries.length === 0) {
558
+ return;
559
+ }
560
+ for (const entry of entries) {
561
+ await this.tokenUsageService.recordTokenUsage(languageModel.id, {
562
+ inputTokens: entry.inputTokens,
563
+ outputTokens: entry.outputTokens,
564
+ cachedInputTokens: entry.cacheCreationInputTokens,
565
+ readCachedInputTokens: entry.cacheReadInputTokens,
566
+ requestId: request.id,
567
+ });
568
+ }
569
+ }
570
+
529
571
  protected abstract addContentsToResponse(languageModelResponse: LanguageModelResponse, request: MutableChatRequestModel): Promise<void>;
530
572
  }
531
573
 
@@ -537,6 +579,9 @@ export abstract class AbstractTextToModelParsingChatAgent<T> extends AbstractCha
537
579
  const parsedCommand = await this.parseTextResponse(responseAsText);
538
580
  const content = this.createResponseContent(parsedCommand, request);
539
581
  request.response.response.addContent(content);
582
+ if ('usage' in languageModelResponse && languageModelResponse.usage) {
583
+ request.response.setTokenUsage(this.mapUsageResponsePart(languageModelResponse.usage));
584
+ }
540
585
  }
541
586
 
542
587
  protected abstract parseTextResponse(text: string): Promise<T>;
@@ -581,6 +626,17 @@ export abstract class AbstractStreamParsingChatAgent extends AbstractChatAgent {
581
626
  if (isLanguageModelTextResponse(languageModelResponse)) {
582
627
  const contents = this.parseContents(languageModelResponse.text, request);
583
628
  request.response.response.addContents(contents);
629
+ if (languageModelResponse.usage) {
630
+ request.response.setTokenUsage(this.mapUsageResponsePart(languageModelResponse.usage));
631
+ }
632
+ return;
633
+ }
634
+ if (isLanguageModelParsedResponse(languageModelResponse)) {
635
+ const contents = this.parseContents(languageModelResponse.content, request);
636
+ request.response.response.addContents(contents);
637
+ if (languageModelResponse.usage) {
638
+ request.response.setTokenUsage(this.mapUsageResponsePart(languageModelResponse.usage));
639
+ }
584
640
  return;
585
641
  }
586
642
  if (isLanguageModelStreamResponse(languageModelResponse)) {
@@ -655,6 +711,7 @@ export abstract class AbstractStreamParsingChatAgent extends AbstractChatAgent {
655
711
  return new ThinkingChatResponseContentImpl(token.thought, token.signature);
656
712
  }
657
713
  if (isUsageResponsePart(token)) {
714
+ request.response.setTokenUsage(this.mapUsageResponsePart(token));
658
715
  return [];
659
716
  }
660
717
  return this.defaultContentFactory.create('', request);