@theia/ai-chat 1.74.0-next.35 → 1.74.0-next.44
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.js +6 -7
- package/lib/browser/agent-delegation-tool.js.map +1 -1
- package/lib/browser/agent-delegation-tool.spec.js +13 -0
- package/lib/browser/agent-delegation-tool.spec.js.map +1 -1
- package/lib/browser/chat-session-store-impl.d.ts.map +1 -1
- package/lib/browser/chat-session-store-impl.js +2 -0
- package/lib/browser/chat-session-store-impl.js.map +1 -1
- package/lib/browser/chat-session-store-impl.spec.js +51 -2
- package/lib/browser/chat-session-store-impl.spec.js.map +1 -1
- package/lib/common/chat-agents.d.ts +8 -0
- package/lib/common/chat-agents.d.ts.map +1 -1
- package/lib/common/chat-agents.js +31 -1
- package/lib/common/chat-agents.js.map +1 -1
- package/lib/common/chat-agents.spec.js +70 -0
- package/lib/common/chat-agents.spec.js.map +1 -1
- package/lib/common/chat-auto-save.spec.js +13 -0
- package/lib/common/chat-auto-save.spec.js.map +1 -1
- package/lib/common/chat-model-serialization.d.ts +12 -1
- package/lib/common/chat-model-serialization.d.ts.map +1 -1
- package/lib/common/chat-model-serialization.js.map +1 -1
- package/lib/common/chat-model-serialization.spec.js +29 -0
- package/lib/common/chat-model-serialization.spec.js.map +1 -1
- package/lib/common/chat-model.d.ts +22 -1
- package/lib/common/chat-model.d.ts.map +1 -1
- package/lib/common/chat-model.js +20 -1
- package/lib/common/chat-model.js.map +1 -1
- package/lib/common/chat-request-parser.d.ts +4 -1
- package/lib/common/chat-request-parser.d.ts.map +1 -1
- package/lib/common/chat-request-parser.js +43 -3
- package/lib/common/chat-request-parser.js.map +1 -1
- package/lib/common/chat-request-parser.spec.js +59 -0
- package/lib/common/chat-request-parser.spec.js.map +1 -1
- package/lib/common/chat-service-deletion.spec.js +43 -0
- package/lib/common/chat-service-deletion.spec.js.map +1 -1
- package/lib/common/chat-service.d.ts +3 -0
- package/lib/common/chat-service.d.ts.map +1 -1
- package/lib/common/chat-service.js +49 -2
- package/lib/common/chat-service.js.map +1 -1
- package/lib/common/chat-service.spec.d.ts +2 -0
- package/lib/common/chat-service.spec.d.ts.map +1 -0
- package/lib/common/chat-service.spec.js +177 -0
- package/lib/common/chat-service.spec.js.map +1 -0
- package/lib/common/chat-session-store.d.ts +8 -0
- package/lib/common/chat-session-store.d.ts.map +1 -1
- package/package.json +9 -9
- package/src/browser/agent-delegation-tool.spec.ts +16 -0
- package/src/browser/agent-delegation-tool.ts +7 -7
- package/src/browser/chat-session-store-impl.spec.ts +62 -2
- package/src/browser/chat-session-store-impl.ts +2 -0
- package/src/common/chat-agents.spec.ts +83 -2
- package/src/common/chat-agents.ts +34 -1
- package/src/common/chat-auto-save.spec.ts +18 -1
- package/src/common/chat-model-serialization.spec.ts +38 -0
- package/src/common/chat-model-serialization.ts +12 -1
- package/src/common/chat-model.ts +44 -2
- package/src/common/chat-request-parser.spec.ts +69 -1
- package/src/common/chat-request-parser.ts +51 -6
- package/src/common/chat-service-deletion.spec.ts +52 -0
- package/src/common/chat-service.spec.ts +211 -0
- package/src/common/chat-service.ts +53 -4
- package/src/common/chat-session-store.ts +8 -0
|
@@ -20,7 +20,7 @@ import { ChatRequestParserImpl } from './chat-request-parser';
|
|
|
20
20
|
import { ChatAgent, ChatAgentLocation } from './chat-agents';
|
|
21
21
|
import { ChatContext, ChatRequest } from './chat-model';
|
|
22
22
|
import { expect } from 'chai';
|
|
23
|
-
import { AIVariable, DefaultAIVariableService, ResolvedAIVariable, ToolInvocationRegistryImpl, ToolRequest } from '@theia/ai-core';
|
|
23
|
+
import { AIVariable, DefaultAIVariableService, PromptService, ResolvedAIVariable, ToolInvocationRegistryImpl, ToolRequest } from '@theia/ai-core';
|
|
24
24
|
import { ILogger, Logger } from '@theia/core';
|
|
25
25
|
import { ParsedChatRequestAgentPart, ParsedChatRequestFunctionPart, ParsedChatRequestTextPart, ParsedChatRequestVariablePart } from './parsed-chat-request';
|
|
26
26
|
import { AgentDelegationTool } from '../browser/agent-delegation-tool';
|
|
@@ -276,6 +276,74 @@ describe('ChatRequestParserImpl', () => {
|
|
|
276
276
|
expect(varPart.variableArg).to.equal('cmd|"arg with \\"quote\\"" other');
|
|
277
277
|
});
|
|
278
278
|
|
|
279
|
+
it('parses multiple commands in one message', async () => {
|
|
280
|
+
const req: ChatRequest = {
|
|
281
|
+
text: '/skill-one /skill-two'
|
|
282
|
+
};
|
|
283
|
+
const context: ChatContext = { variables: [] };
|
|
284
|
+
const result = await parser.parseChatRequest(req, ChatAgentLocation.Panel, context);
|
|
285
|
+
|
|
286
|
+
expect(result.parts.length).to.equal(3);
|
|
287
|
+
const firstCommand = result.parts[0] as ParsedChatRequestVariablePart;
|
|
288
|
+
const separator = result.parts[1] as ParsedChatRequestTextPart;
|
|
289
|
+
const secondCommand = result.parts[2] as ParsedChatRequestVariablePart;
|
|
290
|
+
expect(firstCommand.variableName).to.equal('prompt');
|
|
291
|
+
expect(firstCommand.variableArg).to.equal('skill-one');
|
|
292
|
+
expect(separator.text).to.equal(' ');
|
|
293
|
+
expect(secondCommand.variableName).to.equal('prompt');
|
|
294
|
+
expect(secondCommand.variableArg).to.equal('skill-two');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('inserts a separator between two argument-taking commands', async () => {
|
|
298
|
+
const req: ChatRequest = {
|
|
299
|
+
text: '/summarize foo /hello bar'
|
|
300
|
+
};
|
|
301
|
+
const context: ChatContext = { variables: [] };
|
|
302
|
+
const result = await parser.parseChatRequest(req, ChatAgentLocation.Panel, context);
|
|
303
|
+
|
|
304
|
+
expect(result.parts.length).to.equal(3);
|
|
305
|
+
const firstCommand = result.parts[0] as ParsedChatRequestVariablePart;
|
|
306
|
+
const separator = result.parts[1] as ParsedChatRequestTextPart;
|
|
307
|
+
const secondCommand = result.parts[2] as ParsedChatRequestVariablePart;
|
|
308
|
+
expect(firstCommand.variableArg).to.equal('summarize|foo');
|
|
309
|
+
expect(separator.kind).to.equal('text');
|
|
310
|
+
expect(separator.text).to.equal(' ');
|
|
311
|
+
expect(secondCommand.variableArg).to.equal('hello|bar');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('keeps path-like slash arguments as command arguments', async () => {
|
|
315
|
+
const req: ChatRequest = {
|
|
316
|
+
text: '/explain /path/to/file'
|
|
317
|
+
};
|
|
318
|
+
const context: ChatContext = { variables: [] };
|
|
319
|
+
const result = await parser.parseChatRequest(req, ChatAgentLocation.Panel, context);
|
|
320
|
+
|
|
321
|
+
expect(result.parts.length).to.equal(1);
|
|
322
|
+
const varPart = result.parts[0] as ParsedChatRequestVariablePart;
|
|
323
|
+
expect(varPart.variableName).to.equal('prompt');
|
|
324
|
+
expect(varPart.variableArg).to.equal('explain|/path/to/file');
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('uses known command names to disambiguate slash command arguments', async () => {
|
|
328
|
+
const promptService = {
|
|
329
|
+
getCommands: () => [
|
|
330
|
+
{ id: 'command-skill-one', template: '', isCommand: true, commandName: 'skill-one' },
|
|
331
|
+
{ id: 'command-skill-two', template: '', isCommand: true, commandName: 'skill-two' },
|
|
332
|
+
]
|
|
333
|
+
} as unknown as PromptService;
|
|
334
|
+
const parserWithPromptService = new ChatRequestParserImpl(chatAgentService, variableService, toolInvocationRegistry, logger);
|
|
335
|
+
(parserWithPromptService as unknown as { promptService: PromptService }).promptService = promptService;
|
|
336
|
+
const context: ChatContext = { variables: [] };
|
|
337
|
+
|
|
338
|
+
const multipleCommands = await parserWithPromptService.parseChatRequest({ text: '/skill-one /skill-two' }, ChatAgentLocation.Panel, context);
|
|
339
|
+
expect((multipleCommands.parts[0] as ParsedChatRequestVariablePart).variableArg).to.equal('skill-one');
|
|
340
|
+
expect((multipleCommands.parts[2] as ParsedChatRequestVariablePart).variableArg).to.equal('skill-two');
|
|
341
|
+
|
|
342
|
+
const pathArgument = await parserWithPromptService.parseChatRequest({ text: '/skill-one /tmp' }, ChatAgentLocation.Panel, context);
|
|
343
|
+
expect(pathArgument.parts.length).to.equal(1);
|
|
344
|
+
expect((pathArgument.parts[0] as ParsedChatRequestVariablePart).variableArg).to.equal('skill-one|/tmp');
|
|
345
|
+
});
|
|
346
|
+
|
|
279
347
|
it('treats the first @agent mention as the selector and does not allow later mentions to override it', async () => {
|
|
280
348
|
const createAgent = (id: string): ChatAgent => ({
|
|
281
349
|
id,
|
|
@@ -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_\-]+)
|
|
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 [
|
|
296
|
-
|
|
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)
|
|
@@ -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
|
+
});
|
|
@@ -73,6 +73,8 @@ export interface ChatSession {
|
|
|
73
73
|
pinnedAgent?: ChatAgent;
|
|
74
74
|
/** ID of the root session in the delegation chain. For delegated sessions, this points to the topmost session where task contexts are stored. */
|
|
75
75
|
rootSessionId?: string;
|
|
76
|
+
/** ID of the immediate parent session that delegated this one. Undefined for top-level sessions. */
|
|
77
|
+
parentSessionId?: string;
|
|
76
78
|
}
|
|
77
79
|
|
|
78
80
|
export interface ActiveSessionChangedEvent {
|
|
@@ -224,6 +226,43 @@ export class ChatServiceImpl implements ChatService {
|
|
|
224
226
|
}
|
|
225
227
|
|
|
226
228
|
async deleteSession(sessionId: string): Promise<void> {
|
|
229
|
+
await this.deleteSessionAndChildren(sessionId, new Set<string>());
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
protected async deleteSessionAndChildren(sessionId: string, visited: Set<string>): Promise<void> {
|
|
233
|
+
if (visited.has(sessionId)) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
visited.add(sessionId);
|
|
237
|
+
|
|
238
|
+
// Delete children first. A child is any session whose immediate parent (parentSessionId) or root
|
|
239
|
+
// (rootSessionId) points to this session. Matching parentSessionId is what lets us cascade through
|
|
240
|
+
// intermediate levels: deleting B in A -> B -> C reaches C via its parentSessionId even though C's
|
|
241
|
+
// rootSessionId still points at A. Children may live only in memory, only in persisted storage
|
|
242
|
+
// (e.g. after a reload, not yet restored), or both, so collect ids from both sources. The visited
|
|
243
|
+
// set guards against cycles and re-deleting a child reachable from more than one ancestor.
|
|
244
|
+
const childIds = new Set<string>();
|
|
245
|
+
for (const s of this._sessions) {
|
|
246
|
+
if (s.parentSessionId === sessionId || s.rootSessionId === sessionId) {
|
|
247
|
+
childIds.add(s.id);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (this.sessionStore) {
|
|
251
|
+
try {
|
|
252
|
+
const index = await this.sessionStore.getSessionIndex();
|
|
253
|
+
for (const metadata of Object.values(index)) {
|
|
254
|
+
if (metadata.parentSessionId === sessionId || metadata.rootSessionId === sessionId) {
|
|
255
|
+
childIds.add(metadata.sessionId);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
} catch (error) {
|
|
259
|
+
this.logger.error('Failed to read session index for cascade delete', { sessionId, error });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
for (const childId of childIds) {
|
|
263
|
+
await this.deleteSessionAndChildren(childId, visited);
|
|
264
|
+
}
|
|
265
|
+
|
|
227
266
|
const sessionIndex = this._sessions.findIndex(candidate => candidate.id === sessionId);
|
|
228
267
|
|
|
229
268
|
// If session is in memory, remove it
|
|
@@ -458,9 +497,15 @@ export class ChatServiceImpl implements ChatService {
|
|
|
458
497
|
// Store session with title, pinned agent info, last interaction timestamp, and error state
|
|
459
498
|
const lastRequest = session.model.getRequests().at(-1);
|
|
460
499
|
const hasError = lastRequest?.response.isComplete === true && lastRequest?.response.isError === true;
|
|
461
|
-
return this.sessionStore.storeSessions(
|
|
462
|
-
|
|
463
|
-
|
|
500
|
+
return this.sessionStore.storeSessions({
|
|
501
|
+
model: session.model,
|
|
502
|
+
title: session.title,
|
|
503
|
+
pinnedAgentId: session.pinnedAgent?.id,
|
|
504
|
+
lastInteraction: session.lastInteraction?.getTime(),
|
|
505
|
+
hasError,
|
|
506
|
+
rootSessionId: session.rootSessionId,
|
|
507
|
+
parentSessionId: session.parentSessionId
|
|
508
|
+
}).catch(error => {
|
|
464
509
|
this.logger.error('Failed to store chat sessions', error);
|
|
465
510
|
});
|
|
466
511
|
}
|
|
@@ -517,8 +562,12 @@ export class ChatServiceImpl implements ChatService {
|
|
|
517
562
|
lastInteraction: new Date(serialized.saveDate),
|
|
518
563
|
model,
|
|
519
564
|
isActive: false,
|
|
520
|
-
pinnedAgent
|
|
565
|
+
pinnedAgent,
|
|
566
|
+
rootSessionId: serialized.rootSessionId,
|
|
567
|
+
parentSessionId: serialized.parentSessionId
|
|
521
568
|
};
|
|
569
|
+
session.model.rootSessionId = serialized.rootSessionId;
|
|
570
|
+
session.model.parentSessionId = serialized.parentSessionId;
|
|
522
571
|
this._sessions.push(session);
|
|
523
572
|
this.setupAutoSaveForSession(session);
|
|
524
573
|
this.onSessionEventEmitter.fire({ type: 'created', sessionId: session.id });
|
|
@@ -28,6 +28,10 @@ export interface ChatModelWithMetadata {
|
|
|
28
28
|
lastInteraction?: number;
|
|
29
29
|
/** Whether the last request ended with an error. */
|
|
30
30
|
hasError?: boolean;
|
|
31
|
+
/** ID of the root session in the delegation chain. */
|
|
32
|
+
rootSessionId?: string;
|
|
33
|
+
/** ID of the immediate parent session that delegated this one. */
|
|
34
|
+
parentSessionId?: string;
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
export interface ChatSessionStore {
|
|
@@ -75,4 +79,8 @@ export interface ChatSessionMetadata {
|
|
|
75
79
|
pinnedAgentId?: string;
|
|
76
80
|
/** Whether the last request ended with an error. */
|
|
77
81
|
hasError?: boolean;
|
|
82
|
+
/** ID of the root session in the delegation chain. */
|
|
83
|
+
rootSessionId?: string;
|
|
84
|
+
/** ID of the immediate parent session that delegated this one. */
|
|
85
|
+
parentSessionId?: string;
|
|
78
86
|
}
|