@theia/ai-chat 1.74.0-next.8 → 1.74.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 (94) hide show
  1. package/lib/browser/agent-delegation-tool.js +6 -7
  2. package/lib/browser/agent-delegation-tool.js.map +1 -1
  3. package/lib/browser/agent-delegation-tool.spec.js +13 -0
  4. package/lib/browser/agent-delegation-tool.spec.js.map +1 -1
  5. package/lib/browser/chat-session-store-impl.d.ts.map +1 -1
  6. package/lib/browser/chat-session-store-impl.js +2 -0
  7. package/lib/browser/chat-session-store-impl.js.map +1 -1
  8. package/lib/browser/chat-session-store-impl.spec.js +51 -2
  9. package/lib/browser/chat-session-store-impl.spec.js.map +1 -1
  10. package/lib/browser/chat-tool-preference-bindings.d.ts +18 -8
  11. package/lib/browser/chat-tool-preference-bindings.d.ts.map +1 -1
  12. package/lib/browser/chat-tool-preference-bindings.js +27 -28
  13. package/lib/browser/chat-tool-preference-bindings.js.map +1 -1
  14. package/lib/browser/chat-tool-preference-bindings.spec.js +43 -46
  15. package/lib/browser/chat-tool-preference-bindings.spec.js.map +1 -1
  16. package/lib/common/chat-agents.d.ts +8 -0
  17. package/lib/common/chat-agents.d.ts.map +1 -1
  18. package/lib/common/chat-agents.js +35 -1
  19. package/lib/common/chat-agents.js.map +1 -1
  20. package/lib/common/chat-agents.spec.js +94 -0
  21. package/lib/common/chat-agents.spec.js.map +1 -1
  22. package/lib/common/chat-auto-save.spec.js +13 -0
  23. package/lib/common/chat-auto-save.spec.js.map +1 -1
  24. package/lib/common/chat-content-deserializer.d.ts.map +1 -1
  25. package/lib/common/chat-content-deserializer.js +4 -0
  26. package/lib/common/chat-content-deserializer.js.map +1 -1
  27. package/lib/common/chat-model-serialization.d.ts +12 -1
  28. package/lib/common/chat-model-serialization.d.ts.map +1 -1
  29. package/lib/common/chat-model-serialization.js.map +1 -1
  30. package/lib/common/chat-model-serialization.spec.js +29 -0
  31. package/lib/common/chat-model-serialization.spec.js.map +1 -1
  32. package/lib/common/chat-model.d.ts +104 -2
  33. package/lib/common/chat-model.d.ts.map +1 -1
  34. package/lib/common/chat-model.js +151 -2
  35. package/lib/common/chat-model.js.map +1 -1
  36. package/lib/common/chat-model.spec.d.ts +2 -0
  37. package/lib/common/chat-model.spec.d.ts.map +1 -0
  38. package/lib/common/chat-model.spec.js +42 -0
  39. package/lib/common/chat-model.spec.js.map +1 -0
  40. package/lib/common/chat-request-parser.d.ts +4 -1
  41. package/lib/common/chat-request-parser.d.ts.map +1 -1
  42. package/lib/common/chat-request-parser.js +43 -3
  43. package/lib/common/chat-request-parser.js.map +1 -1
  44. package/lib/common/chat-request-parser.spec.js +59 -0
  45. package/lib/common/chat-request-parser.spec.js.map +1 -1
  46. package/lib/common/chat-service-deletion.spec.js +43 -0
  47. package/lib/common/chat-service-deletion.spec.js.map +1 -1
  48. package/lib/common/chat-service-pinned-agent.spec.js +52 -2
  49. package/lib/common/chat-service-pinned-agent.spec.js.map +1 -1
  50. package/lib/common/chat-service-session-events.spec.d.ts +2 -0
  51. package/lib/common/chat-service-session-events.spec.d.ts.map +1 -0
  52. package/lib/common/chat-service-session-events.spec.js +130 -0
  53. package/lib/common/chat-service-session-events.spec.js.map +1 -0
  54. package/lib/common/chat-service.d.ts +3 -0
  55. package/lib/common/chat-service.d.ts.map +1 -1
  56. package/lib/common/chat-service.js +60 -6
  57. package/lib/common/chat-service.js.map +1 -1
  58. package/lib/common/chat-service.spec.d.ts +2 -0
  59. package/lib/common/chat-service.spec.d.ts.map +1 -0
  60. package/lib/common/chat-service.spec.js +177 -0
  61. package/lib/common/chat-service.spec.js.map +1 -0
  62. package/lib/common/chat-session-status.spec.d.ts +2 -0
  63. package/lib/common/chat-session-status.spec.d.ts.map +1 -0
  64. package/lib/common/chat-session-status.spec.js +213 -0
  65. package/lib/common/chat-session-status.spec.js.map +1 -0
  66. package/lib/common/chat-session-store.d.ts +8 -0
  67. package/lib/common/chat-session-store.d.ts.map +1 -1
  68. package/lib/common/tool-call-response-content.spec.js +36 -0
  69. package/lib/common/tool-call-response-content.spec.js.map +1 -1
  70. package/package.json +11 -11
  71. package/src/browser/agent-delegation-tool.spec.ts +16 -0
  72. package/src/browser/agent-delegation-tool.ts +7 -7
  73. package/src/browser/chat-session-store-impl.spec.ts +62 -2
  74. package/src/browser/chat-session-store-impl.ts +2 -0
  75. package/src/browser/chat-tool-preference-bindings.spec.ts +43 -48
  76. package/src/browser/chat-tool-preference-bindings.ts +42 -30
  77. package/src/common/chat-agents.spec.ts +112 -2
  78. package/src/common/chat-agents.ts +44 -1
  79. package/src/common/chat-auto-save.spec.ts +18 -1
  80. package/src/common/chat-content-deserializer.ts +11 -0
  81. package/src/common/chat-model-serialization.spec.ts +38 -0
  82. package/src/common/chat-model-serialization.ts +12 -1
  83. package/src/common/chat-model.spec.ts +42 -0
  84. package/src/common/chat-model.ts +233 -2
  85. package/src/common/chat-request-parser.spec.ts +69 -1
  86. package/src/common/chat-request-parser.ts +51 -6
  87. package/src/common/chat-service-deletion.spec.ts +52 -0
  88. package/src/common/chat-service-pinned-agent.spec.ts +58 -2
  89. package/src/common/chat-service-session-events.spec.ts +161 -0
  90. package/src/common/chat-service.spec.ts +211 -0
  91. package/src/common/chat-service.ts +64 -8
  92. package/src/common/chat-session-status.spec.ts +280 -0
  93. package/src/common/chat-session-store.ts +8 -0
  94. package/src/common/tool-call-response-content.spec.ts +42 -0
@@ -19,7 +19,7 @@
19
19
  *--------------------------------------------------------------------------------------------*/
20
20
  // Partially copied from https://github.com/microsoft/vscode/blob/a2cab7255c0df424027be05d58e1b7b941f4ea60/src/vs/workbench/contrib/chat/common/chatRequestParser.ts
21
21
 
22
- import { inject, injectable } from '@theia/core/shared/inversify';
22
+ import { inject, injectable, optional } from '@theia/core/shared/inversify';
23
23
  import { ChatAgentService } from './chat-agent-service';
24
24
  import { ChatAgentLocation } from './chat-agents';
25
25
  import { ChatContext, ChatRequest } from './chat-model';
@@ -37,7 +37,7 @@ import {
37
37
  ParsedChatRequestPart,
38
38
  } from './parsed-chat-request';
39
39
  import {
40
- AIVariable, AIVariableService, createAIResolveVariableCache, getAllResolvedAIVariables, parseFunctionReference, ToolInvocationRegistry, ToolRequest
40
+ AIVariable, AIVariableService, createAIResolveVariableCache, getAllResolvedAIVariables, parseFunctionReference, PromptService, ToolInvocationRegistry, ToolRequest
41
41
  } from '@theia/ai-core';
42
42
  import { ILogger } from '@theia/core';
43
43
 
@@ -47,7 +47,8 @@ const agentReg = /^@([\w_\-\.]+)(?=(\s|$|\b))/i; // An @-agent
47
47
  const functionReg = /^~(\??[\w_\-\.]+)(?=(\s|$|\b))/i;
48
48
  const functionPromptFormatReg = /^\~\{\s*(.*?)\s*\}/i;
49
49
  const variableReg = /^#([\w_\-]+)(?::([\w_\-_\/\\.:]+))?(?=(\s|$|\b))/i; // A #-variable with an optional : arg (#file:workspace/path/name.ext)
50
- const commandReg = /^\/([\w_\-]+)(?:\s+(.+?))?(?=\s*$)/; // A /-command with optional arguments (/commandname arg1 arg2)
50
+ const commandReg = /^\/([\w_\-]+)/; // A /-command (/commandname) with optional arguments parsed separately
51
+ const nextCommandReg = /\s+\/([\w_\-]+)(?=\s|$)/g;
51
52
 
52
53
  export const ChatRequestParser = Symbol('ChatRequestParser');
53
54
  export interface ChatRequestParser {
@@ -62,11 +63,15 @@ function offsetRange(start: number, endExclusive: number): OffsetRange {
62
63
  }
63
64
  @injectable()
64
65
  export class ChatRequestParserImpl implements ChatRequestParser {
66
+
67
+ @inject(PromptService) @optional()
68
+ protected readonly promptService?: PromptService;
69
+
65
70
  constructor(
66
71
  @inject(ChatAgentService) private readonly agentService: ChatAgentService,
67
72
  @inject(AIVariableService) private readonly variableService: AIVariableService,
68
73
  @inject(ToolInvocationRegistry) private readonly toolInvocationRegistry: ToolInvocationRegistry,
69
- @inject(ILogger) private readonly logger: ILogger
74
+ @inject(ILogger) private readonly logger: ILogger,
70
75
  ) { }
71
76
 
72
77
  async parseChatRequest(request: ChatRequest, location: ChatAgentLocation, context: ChatContext): Promise<ParsedChatRequest> {
@@ -185,6 +190,7 @@ export class ChatRequestParserImpl implements ChatRequestParser {
185
190
  }
186
191
 
187
192
  parts.push(newPart);
193
+ i = newPart.range.endExclusive - 1;
188
194
  }
189
195
  }
190
196
 
@@ -292,13 +298,52 @@ export class ChatRequestParserImpl implements ChatRequestParser {
292
298
  return;
293
299
  }
294
300
 
295
- const [full, commandName, commandArgs] = nextCommandMatch;
296
- const commandRange = offsetRange(offset, offset + full.length);
301
+ const [commandText, commandName] = nextCommandMatch;
302
+ let commandEnd = commandText.length;
303
+ let commandArgs: string | undefined;
304
+
305
+ const nextCommandOffset = this.findNextCommandOffset(message, commandEnd);
306
+ const argsEnd = nextCommandOffset ?? message.length;
307
+ const rawArgs = message.slice(commandEnd, argsEnd);
308
+ const args = rawArgs.trim();
309
+ if (args) {
310
+ commandArgs = args;
311
+ // Advance the consumed range only up to the end of the trimmed argument, not up to the
312
+ // next command. This keeps the whitespace between the argument and a following command
313
+ // out of this part's range, so parseParts emits it as a separator text part. Otherwise
314
+ // the two resolved prompt fragments would run into each other with no space between them.
315
+ commandEnd = argsEnd - (rawArgs.length - rawArgs.trimEnd().length);
316
+ }
317
+
318
+ const commandRange = offsetRange(offset, offset + commandEnd);
297
319
 
298
320
  const variableArg = commandArgs ? `${commandName}|${commandArgs}` : commandName;
299
321
  return new ParsedChatRequestVariablePart(commandRange, 'prompt', variableArg);
300
322
  }
301
323
 
324
+ private findNextCommandOffset(message: string, startOffset: number): number | undefined {
325
+ nextCommandReg.lastIndex = startOffset;
326
+ let match = nextCommandReg.exec(message);
327
+ while (match) {
328
+ const commandName = match[1];
329
+ // Deliberate behavior difference: without a PromptService we cannot tell commands from
330
+ // path-like arguments, so any `/word` token is treated as a command boundary. With a
331
+ // PromptService we only break on known command names and keep unknown `/word` tokens
332
+ // (e.g. `/tmp`, `/path/to/file`) as part of the current command's arguments.
333
+ if (!this.promptService || this.isKnownCommand(commandName)) {
334
+ return match.index + match[0].indexOf(chatSubcommandLeader);
335
+ }
336
+ match = nextCommandReg.exec(message);
337
+ }
338
+ return undefined;
339
+ }
340
+
341
+ private isKnownCommand(commandName: string): boolean {
342
+ return this.promptService?.getCommands().some(command =>
343
+ (command.commandName ?? command.id) === commandName
344
+ ) ?? false;
345
+ }
346
+
302
347
  private tryToParseFunction(message: string, offset: number): ParsedChatRequestFunctionPart | undefined {
303
348
  // Support both the chat and prompt formats for functions
304
349
  const nextFunctionMatch = message.match(functionPromptFormatReg) || message.match(functionReg);
@@ -267,6 +267,58 @@ describe('ChatService Session Deletion', () => {
267
267
  expect(sessionStore.deletedSessions).to.include(persistedSessionId);
268
268
  });
269
269
 
270
+ it('should cascade-delete descendants when deleting an intermediate session (A -> B -> C, in memory)', async () => {
271
+ // Delegation chain A -> B -> C, all loaded in memory. C's rootSessionId points at A (the
272
+ // root of the chain), but its immediate parent is B, so deleting B must still remove C.
273
+ const a = chatService.createSession(ChatAgentLocation.Panel);
274
+ const b = chatService.createSession(ChatAgentLocation.Panel);
275
+ const c = chatService.createSession(ChatAgentLocation.Panel);
276
+ const internal = b as unknown as { rootSessionId?: string; parentSessionId?: string };
277
+ internal.rootSessionId = a.id;
278
+ internal.parentSessionId = a.id;
279
+ const cInternal = c as unknown as { rootSessionId?: string; parentSessionId?: string };
280
+ cInternal.rootSessionId = a.id;
281
+ cInternal.parentSessionId = b.id;
282
+
283
+ await chatService.deleteSession(b.id);
284
+
285
+ const remaining = chatService.getSessions().map(s => s.id);
286
+ expect(remaining).to.include(a.id);
287
+ expect(remaining).to.not.include(b.id);
288
+ expect(remaining).to.not.include(c.id);
289
+ expect(sessionStore.deletedSessions).to.include(b.id);
290
+ expect(sessionStore.deletedSessions).to.include(c.id);
291
+ });
292
+
293
+ it('should cascade-delete persisted-only descendants of an intermediate session', async () => {
294
+ // The whole chain lives only in the persisted index (e.g. after a reload). Deleting the
295
+ // intermediate B must reach C via its parentSessionId even though C's rootSessionId is A.
296
+ sessionStore.getSessionIndex = async (): Promise<ChatSessionIndex> => ({
297
+ A: { sessionId: 'A', title: 'A', saveDate: 0, location: ChatAgentLocation.Panel },
298
+ B: { sessionId: 'B', title: 'B', saveDate: 0, location: ChatAgentLocation.Panel, rootSessionId: 'A', parentSessionId: 'A' },
299
+ C: { sessionId: 'C', title: 'C', saveDate: 0, location: ChatAgentLocation.Panel, rootSessionId: 'A', parentSessionId: 'B' }
300
+ });
301
+
302
+ await chatService.deleteSession('B');
303
+
304
+ expect(sessionStore.deletedSessions).to.include('B');
305
+ expect(sessionStore.deletedSessions).to.include('C');
306
+ expect(sessionStore.deletedSessions).to.not.include('A');
307
+ });
308
+
309
+ it('should not loop forever on a cyclic parent reference', async () => {
310
+ // Defensive: a corrupted index where two sessions reference each other must still terminate.
311
+ sessionStore.getSessionIndex = async (): Promise<ChatSessionIndex> => ({
312
+ X: { sessionId: 'X', title: 'X', saveDate: 0, location: ChatAgentLocation.Panel, parentSessionId: 'Y' },
313
+ Y: { sessionId: 'Y', title: 'Y', saveDate: 0, location: ChatAgentLocation.Panel, parentSessionId: 'X' }
314
+ });
315
+
316
+ await chatService.deleteSession('X');
317
+
318
+ expect(sessionStore.deletedSessions).to.include('X');
319
+ expect(sessionStore.deletedSessions).to.include('Y');
320
+ });
321
+
270
322
  it('should fire SessionDeletedEvent for persisted-only sessions', async () => {
271
323
  // When deleting a persisted-only session (not in memory),
272
324
  // we still fire the event so consumers (e.g. the Welcome view's chat cards)
@@ -74,17 +74,38 @@ describe('ChatService pinned agent behavior', () => {
74
74
  prompts: []
75
75
  };
76
76
 
77
+ const mockHiddenAgent: ChatAgent = {
78
+ id: 'hidden-worker',
79
+ name: 'Hidden Worker',
80
+ description: 'Test agent that is hidden from chat (showInChat: false)',
81
+ locations: [ChatAgentLocation.Panel],
82
+ invoke: async () => { },
83
+ languageModelRequirements: [],
84
+ tags: [],
85
+ variables: [],
86
+ agentSpecificVariables: [],
87
+ functions: [],
88
+ prompts: []
89
+ };
90
+
77
91
  class MockChatAgentService {
78
92
  private agents = new Map<string, ChatAgent>([
79
93
  ['pinned-agent', mockPinnedAgent],
80
94
  ['mentioned-agent', mockMentionedAgent],
81
- ['default-agent', mockDefaultAgent]
95
+ ['default-agent', mockDefaultAgent],
96
+ ['hidden-worker', mockHiddenAgent]
82
97
  ]);
83
98
 
99
+ /** Mirrors ChatAgentServiceImpl: agents with showInChat === false are only returned with includeHidden. */
100
+ private hiddenIds = new Set<string>(['hidden-worker']);
101
+
84
102
  readonly onDidChangeAgents = { dispose: () => { } };
85
103
  readonly onDefaultAgentChanged = { dispose: () => { } };
86
104
 
87
- getAgent(id: string): ChatAgent | undefined {
105
+ getAgent(id: string, includeHidden: boolean = false): ChatAgent | undefined {
106
+ if (!includeHidden && this.hiddenIds.has(id)) {
107
+ return undefined;
108
+ }
88
109
  return this.agents.get(id);
89
110
  }
90
111
 
@@ -222,4 +243,39 @@ describe('ChatService pinned agent behavior', () => {
222
243
  // The selected agent (mentioned-agent) becomes the new pinned agent
223
244
  expect(session.pinnedAgent).to.equal(mockMentionedAgent);
224
245
  });
246
+
247
+ it('should resolve a hidden pinned agent instead of falling back to the default agent', () => {
248
+ // AgentDelegationTool pins the delegated agent on the session it creates; worker agents
249
+ // are typically hidden (showInChat: false). The pin is an explicit choice, so it must
250
+ // resolve even for hidden agents — falling back to the default agent would silently run
251
+ // the wrong agent with the wrong prompt and toolset.
252
+ const session = createMockSession(mockHiddenAgent);
253
+ const parsedRequest: ParsedChatRequest = {
254
+ request: { text: 'test message' },
255
+ parts: [new ParsedChatRequestTextPart({ start: 0, endExclusive: 'test message'.length }, 'test message')],
256
+ toolRequests: new Map(),
257
+ variables: []
258
+ };
259
+
260
+ const agent = chatService.getAgent(parsedRequest, session);
261
+ expect(agent).to.equal(mockHiddenAgent);
262
+ });
263
+
264
+ it('should resolve a hidden pinned agent when the request mentions that hidden agent', () => {
265
+ // A delegated request's text is '@<agentId> <prompt>'. The mention of a hidden agent does
266
+ // not resolve (mentions stay hidden-excluding), but the pin must still catch it.
267
+ const session = createMockSession(mockHiddenAgent);
268
+ const parsedRequest: ParsedChatRequest = {
269
+ request: { text: '@hidden-worker test' },
270
+ parts: [
271
+ new ParsedChatRequestAgentPart({ start: 0, endExclusive: '@hidden-worker'.length }, 'hidden-worker', 'hidden-worker'),
272
+ new ParsedChatRequestTextPart({ start: '@hidden-worker '.length, endExclusive: '@hidden-worker test'.length }, 'test')
273
+ ],
274
+ toolRequests: new Map(),
275
+ variables: []
276
+ };
277
+
278
+ const agent = chatService.getAgent(parsedRequest, session);
279
+ expect(agent).to.equal(mockHiddenAgent);
280
+ });
225
281
  });
@@ -0,0 +1,161 @@
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 { expect } from 'chai';
18
+ import { Container } from '@theia/core/shared/inversify';
19
+ import {
20
+ ActiveSessionChangedEvent,
21
+ ChatServiceImpl,
22
+ DefaultChatAgentId,
23
+ PinChatAgent,
24
+ SessionCreatedEvent,
25
+ SessionDeletedEvent,
26
+ SessionRenamedEvent
27
+ } from './chat-service';
28
+ import { ChatAgentService } from './chat-agent-service';
29
+ import { ChatRequestParser } from './chat-request-parser';
30
+ import { AIVariableService, ToolInvocationRegistry } from '@theia/ai-core';
31
+ import { ILogger } from '@theia/core';
32
+ import { ChatContentDeserializerRegistry, ChatContentDeserializerRegistryImpl, DefaultChatContentDeserializerContribution } from './chat-content-deserializer';
33
+ import { ChangeSetElementDeserializerRegistry, ChangeSetElementDeserializerRegistryImpl } from './change-set-element-deserializer';
34
+ import { ChatAgent, ChatAgentLocation } from './chat-agents';
35
+ import { ChatRequest } from './chat-model';
36
+ import { ParsedChatRequest, ParsedChatRequestTextPart } from './parsed-chat-request';
37
+
38
+ describe('ChatService session events', () => {
39
+ let chatService: ChatServiceImpl;
40
+ let container: Container;
41
+
42
+ const mockDefaultAgent: ChatAgent = {
43
+ id: 'default-agent',
44
+ name: 'Default Agent',
45
+ description: 'Test default agent',
46
+ locations: [ChatAgentLocation.Panel],
47
+ invoke: async () => { },
48
+ languageModelRequirements: [],
49
+ tags: [],
50
+ variables: [],
51
+ agentSpecificVariables: [],
52
+ functions: [],
53
+ prompts: []
54
+ };
55
+
56
+ class MockChatAgentService {
57
+ readonly onDidChangeAgents = { dispose: () => { } };
58
+ readonly onDefaultAgentChanged = { dispose: () => { } };
59
+
60
+ getAgent(id: string): ChatAgent | undefined {
61
+ return id === 'default-agent' ? mockDefaultAgent : undefined;
62
+ }
63
+
64
+ resolveAgent(): ChatAgent | undefined {
65
+ return mockDefaultAgent;
66
+ }
67
+ }
68
+
69
+ class MockChatRequestParser {
70
+ async parseChatRequest(request: ChatRequest): Promise<ParsedChatRequest> {
71
+ return {
72
+ request,
73
+ parts: [new ParsedChatRequestTextPart({ start: 0, endExclusive: request.text.length }, request.text)],
74
+ toolRequests: new Map(),
75
+ variables: []
76
+ };
77
+ }
78
+ }
79
+
80
+ class MockAIVariableService {
81
+ async resolveVariable(): Promise<unknown> {
82
+ return undefined;
83
+ }
84
+ }
85
+
86
+ class MockLogger {
87
+ error(): void { }
88
+ warn(): void { }
89
+ info(): void { }
90
+ debug(): void { }
91
+ }
92
+
93
+ beforeEach(() => {
94
+ container = new Container();
95
+
96
+ container.bind(ChatAgentService).toConstantValue(new MockChatAgentService() as unknown as ChatAgentService);
97
+ container.bind(ChatRequestParser).toConstantValue(new MockChatRequestParser() as unknown as ChatRequestParser);
98
+ container.bind(AIVariableService).toConstantValue(new MockAIVariableService() as unknown as AIVariableService);
99
+ container.bind(ToolInvocationRegistry).toConstantValue({});
100
+ container.bind(ILogger).toConstantValue(new MockLogger() as unknown as ILogger);
101
+
102
+ container.bind(DefaultChatAgentId).toConstantValue({ id: 'default-agent' });
103
+ container.bind(PinChatAgent).toConstantValue(true);
104
+
105
+ const contentRegistry = new ChatContentDeserializerRegistryImpl();
106
+ new DefaultChatContentDeserializerContribution().registerDeserializers(contentRegistry);
107
+ container.bind(ChatContentDeserializerRegistry).toConstantValue(contentRegistry);
108
+ container.bind(ChangeSetElementDeserializerRegistry).toConstantValue(new ChangeSetElementDeserializerRegistryImpl());
109
+ container.bind(ChatServiceImpl).toSelf().inSingletonScope();
110
+
111
+ chatService = container.get(ChatServiceImpl);
112
+ });
113
+
114
+ it('should fire a renamed event when sendRequest sets the session title', async () => {
115
+ const session = chatService.createSession(ChatAgentLocation.Panel);
116
+ expect(session.title).to.be.undefined;
117
+
118
+ const events: (ActiveSessionChangedEvent | SessionCreatedEvent | SessionDeletedEvent | SessionRenamedEvent)[] = [];
119
+ chatService.onSessionEvent(e => events.push(e));
120
+
121
+ await chatService.sendRequest(session.id, { text: 'Hello world' });
122
+
123
+ expect(session.title).to.equal('Hello world');
124
+ const renamedEvents = events.filter(e => e.type === 'renamed');
125
+ expect(renamedEvents).to.have.length(1);
126
+ expect((renamedEvents[0] as SessionRenamedEvent).sessionId).to.equal(session.id);
127
+ });
128
+
129
+ it('should not fire a renamed event on subsequent requests when title is already set', async () => {
130
+ const session = chatService.createSession(ChatAgentLocation.Panel);
131
+
132
+ // First request sets the title
133
+ await chatService.sendRequest(session.id, { text: 'First message' });
134
+ expect(session.title).to.equal('First message');
135
+
136
+ const events: (ActiveSessionChangedEvent | SessionCreatedEvent | SessionDeletedEvent | SessionRenamedEvent)[] = [];
137
+ chatService.onSessionEvent(e => events.push(e));
138
+
139
+ // Second request should not trigger a renamed event
140
+ await chatService.sendRequest(session.id, { text: 'Second message' });
141
+
142
+ const renamedEvents = events.filter(e => e.type === 'renamed');
143
+ expect(renamedEvents).to.have.length(0);
144
+ // Title should remain the first message
145
+ expect(session.title).to.equal('First message');
146
+ });
147
+
148
+ it('should fire a renamed event via renameSession', async () => {
149
+ const session = chatService.createSession(ChatAgentLocation.Panel);
150
+
151
+ const events: (ActiveSessionChangedEvent | SessionCreatedEvent | SessionDeletedEvent | SessionRenamedEvent)[] = [];
152
+ chatService.onSessionEvent(e => events.push(e));
153
+
154
+ await chatService.renameSession(session.id, 'New Title');
155
+
156
+ expect(session.title).to.equal('New Title');
157
+ const renamedEvents = events.filter(e => e.type === 'renamed');
158
+ expect(renamedEvents).to.have.length(1);
159
+ expect((renamedEvents[0] as SessionRenamedEvent).sessionId).to.equal(session.id);
160
+ });
161
+ });
@@ -0,0 +1,211 @@
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 { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
18
+ const disableJSDOM = enableJSDOM();
19
+ import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
20
+ FrontendApplicationConfigProvider.set({});
21
+
22
+ import { expect } from 'chai';
23
+ import * as sinon from 'sinon';
24
+ import { Container } from '@theia/core/shared/inversify';
25
+ import { ChatServiceImpl } from './chat-service';
26
+ import { ChatAgentService } from './chat-agent-service';
27
+ import { ChatSessionStore } from './chat-session-store';
28
+ import { ChatContentDeserializerRegistry } from './chat-content-deserializer';
29
+ import { ChangeSetElementDeserializerRegistry } from './change-set-element-deserializer';
30
+ import { ToolInvocationRegistry, AIVariableService } from '@theia/ai-core';
31
+ import { ILogger } from '@theia/core';
32
+ import { ChatRequestParser } from './chat-request-parser';
33
+
34
+ disableJSDOM();
35
+
36
+ describe('ChatServiceImpl', () => {
37
+ let sandbox: sinon.SinonSandbox;
38
+ let chatService: ChatServiceImpl;
39
+ let mockSessionStore: sinon.SinonStubbedInstance<ChatSessionStore>;
40
+ let mockAgentService: sinon.SinonStubbedInstance<ChatAgentService>;
41
+ let mockDeserRegistry: sinon.SinonStubbedInstance<ChatContentDeserializerRegistry>;
42
+ let mockChangeSetDeserRegistry: sinon.SinonStubbedInstance<ChangeSetElementDeserializerRegistry>;
43
+ let mockToolInvocationRegistry: sinon.SinonStubbedInstance<ToolInvocationRegistry>;
44
+ let mockVariableService: sinon.SinonStubbedInstance<AIVariableService>;
45
+ let mockRequestParser: sinon.SinonStubbedInstance<ChatRequestParser>;
46
+ let mockLogger: sinon.SinonStubbedInstance<ILogger>;
47
+
48
+ beforeEach(() => {
49
+ sandbox = sinon.createSandbox();
50
+
51
+ mockSessionStore = {
52
+ storeSessions: sandbox.stub().resolves(),
53
+ readSession: sandbox.stub().resolves(undefined) as never,
54
+ deleteSession: sandbox.stub().resolves() as never,
55
+ clearAllSessions: sandbox.stub().resolves() as never,
56
+ getSessionIndex: sandbox.stub().resolves({}) as never,
57
+ hasPersistedSessions: sandbox.stub().resolves(false) as never
58
+ };
59
+
60
+ mockAgentService = {
61
+ getAgent: sandbox.stub().returns(undefined),
62
+ getAgents: sandbox.stub().returns([]),
63
+ resolveAgent: sandbox.stub().returns(undefined)
64
+ } as sinon.SinonStubbedInstance<ChatAgentService>;
65
+
66
+ mockDeserRegistry = {
67
+ deserialize: sandbox.stub().resolves([])
68
+ } as sinon.SinonStubbedInstance<ChatContentDeserializerRegistry>;
69
+
70
+ mockChangeSetDeserRegistry = {
71
+ deserialize: sandbox.stub().resolves([])
72
+ } as sinon.SinonStubbedInstance<ChangeSetElementDeserializerRegistry>;
73
+
74
+ mockToolInvocationRegistry = {
75
+ getFunction: sandbox.stub().returns(undefined)
76
+ } as sinon.SinonStubbedInstance<ToolInvocationRegistry>;
77
+
78
+ mockVariableService = {
79
+ resolveVariable: sandbox.stub().resolves(undefined)
80
+ } as sinon.SinonStubbedInstance<AIVariableService>;
81
+
82
+ mockRequestParser = {
83
+ parseChatRequest: sandbox.stub().resolves({ parts: [], toolRequests: [], variables: [] })
84
+ } as sinon.SinonStubbedInstance<ChatRequestParser>;
85
+
86
+ mockLogger = {
87
+ debug: sandbox.stub(),
88
+ info: sandbox.stub(),
89
+ warn: sandbox.stub(),
90
+ error: sandbox.stub()
91
+ } as sinon.SinonStubbedInstance<ILogger>;
92
+
93
+ const container = new Container();
94
+ container.bind(ChatServiceImpl).toSelf().inSingletonScope();
95
+ container.bind(ChatAgentService).toConstantValue(mockAgentService);
96
+ container.bind(ChatSessionStore).toConstantValue(mockSessionStore);
97
+ container.bind(ChatContentDeserializerRegistry).toConstantValue(mockDeserRegistry);
98
+ container.bind(ChangeSetElementDeserializerRegistry).toConstantValue(mockChangeSetDeserRegistry);
99
+ container.bind(ToolInvocationRegistry).toConstantValue(mockToolInvocationRegistry);
100
+ container.bind(ChatRequestParser).toConstantValue(mockRequestParser);
101
+ container.bind(AIVariableService).toConstantValue(mockVariableService);
102
+ container.bind(ILogger).toConstantValue(mockLogger);
103
+
104
+ chatService = container.get(ChatServiceImpl);
105
+ });
106
+
107
+ afterEach(() => {
108
+ sandbox.restore();
109
+ });
110
+
111
+ describe('deleteSession', () => {
112
+ it('should delete child sessions when deleting a parent session', async () => {
113
+ // Create a parent session
114
+ const parentSession = chatService.createSession();
115
+
116
+ // Create a child session by directly setting rootSessionId
117
+ const childSession = chatService.createSession();
118
+ childSession.rootSessionId = parentSession.id;
119
+
120
+ // Delete the parent session
121
+ await chatService.deleteSession(parentSession.id);
122
+
123
+ // Verify parent session was deleted
124
+ expect(chatService.getSession(parentSession.id)).to.be.undefined;
125
+
126
+ // Verify child session was also deleted (cascade delete)
127
+ expect(chatService.getSession(childSession.id)).to.be.undefined;
128
+
129
+ // Verify deleteSession was called for both sessions
130
+ expect(mockSessionStore.deleteSession.callCount).to.equal(2);
131
+ expect(mockSessionStore.deleteSession.calledWith(parentSession.id)).to.be.true;
132
+ expect(mockSessionStore.deleteSession.calledWith(childSession.id)).to.be.true;
133
+ });
134
+
135
+ it('should handle multiple child sessions', async () => {
136
+ // Create a parent session
137
+ const parentSession = chatService.createSession();
138
+
139
+ // Create multiple child sessions
140
+ const child1 = chatService.createSession();
141
+ child1.rootSessionId = parentSession.id;
142
+ const child2 = chatService.createSession();
143
+ child2.rootSessionId = parentSession.id;
144
+ const child3 = chatService.createSession();
145
+ child3.rootSessionId = parentSession.id;
146
+
147
+ // Delete the parent session
148
+ await chatService.deleteSession(parentSession.id);
149
+
150
+ // Verify all sessions were deleted
151
+ expect(chatService.getSession(parentSession.id)).to.be.undefined;
152
+ expect(chatService.getSession(child1.id)).to.be.undefined;
153
+ expect(chatService.getSession(child2.id)).to.be.undefined;
154
+ expect(chatService.getSession(child3.id)).to.be.undefined;
155
+
156
+ // Verify deleteSession was called for all sessions
157
+ expect(mockSessionStore.deleteSession.callCount).to.equal(4);
158
+ });
159
+
160
+ it('should not delete sessions without rootSessionId', async () => {
161
+ // Create a standalone session
162
+ const standaloneSession = chatService.createSession();
163
+
164
+ // Delete it
165
+ await chatService.deleteSession(standaloneSession.id);
166
+
167
+ // Verify only that session was deleted
168
+ expect(chatService.getSession(standaloneSession.id)).to.be.undefined;
169
+ expect(mockSessionStore.deleteSession.callCount).to.equal(1);
170
+ });
171
+
172
+ it('should delete persisted-only child sessions that are not loaded in memory', async () => {
173
+ // Parent is in memory; the child exists only in the persisted index (e.g. after a reload).
174
+ const parentSession = chatService.createSession();
175
+ const persistedChildId = 'persisted-child-id';
176
+ mockSessionStore.getSessionIndex.resolves({
177
+ [persistedChildId]: {
178
+ sessionId: persistedChildId,
179
+ title: 'Persisted child',
180
+ saveDate: 0,
181
+ location: parentSession.model.location,
182
+ rootSessionId: parentSession.id
183
+ }
184
+ });
185
+
186
+ await chatService.deleteSession(parentSession.id);
187
+
188
+ // Both the in-memory parent and the persisted-only child are removed from storage.
189
+ expect(mockSessionStore.deleteSession.calledWith(parentSession.id)).to.be.true;
190
+ expect(mockSessionStore.deleteSession.calledWith(persistedChildId)).to.be.true;
191
+ });
192
+
193
+ it('should not delete sessions with different rootSessionId', async () => {
194
+ // Create two independent sessions
195
+ const session1 = chatService.createSession();
196
+ const session2 = chatService.createSession();
197
+
198
+ // Set session2 as a child of a non-existent session
199
+ session2.rootSessionId = 'non-existent-session-id';
200
+
201
+ // Delete session1
202
+ await chatService.deleteSession(session1.id);
203
+
204
+ // Verify only session1 was deleted
205
+ expect(chatService.getSession(session1.id)).to.be.undefined;
206
+ expect(chatService.getSession(session2.id)).to.not.be.undefined;
207
+ expect(mockSessionStore.deleteSession.callCount).to.equal(1);
208
+ expect(mockSessionStore.deleteSession.calledWith(session1.id)).to.be.true;
209
+ });
210
+ });
211
+ });