@theia/ai-chat 1.74.0-next.9 → 1.74.1
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-request-command-resolution.spec.d.ts +2 -0
- package/lib/browser/chat-request-command-resolution.spec.d.ts.map +1 -0
- package/lib/browser/chat-request-command-resolution.spec.js +94 -0
- package/lib/browser/chat-request-command-resolution.spec.js.map +1 -0
- 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/browser/chat-tool-preference-bindings.d.ts +18 -8
- package/lib/browser/chat-tool-preference-bindings.d.ts.map +1 -1
- package/lib/browser/chat-tool-preference-bindings.js +27 -28
- package/lib/browser/chat-tool-preference-bindings.js.map +1 -1
- package/lib/browser/chat-tool-preference-bindings.spec.js +43 -46
- package/lib/browser/chat-tool-preference-bindings.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 +35 -1
- package/lib/common/chat-agents.js.map +1 -1
- package/lib/common/chat-agents.spec.js +94 -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-content-deserializer.d.ts.map +1 -1
- package/lib/common/chat-content-deserializer.js +4 -0
- package/lib/common/chat-content-deserializer.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 +105 -2
- package/lib/common/chat-model.d.ts.map +1 -1
- package/lib/common/chat-model.js +159 -3
- package/lib/common/chat-model.js.map +1 -1
- package/lib/common/chat-model.spec.d.ts +2 -0
- package/lib/common/chat-model.spec.d.ts.map +1 -0
- package/lib/common/chat-model.spec.js +42 -0
- package/lib/common/chat-model.spec.js.map +1 -0
- package/lib/common/chat-request-parser.d.ts +9 -1
- package/lib/common/chat-request-parser.d.ts.map +1 -1
- package/lib/common/chat-request-parser.js +54 -3
- package/lib/common/chat-request-parser.js.map +1 -1
- package/lib/common/chat-request-parser.spec.js +333 -0
- package/lib/common/chat-request-parser.spec.js.map +1 -1
- package/lib/common/chat-response-model.spec.js +31 -0
- package/lib/common/chat-response-model.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-pinned-agent.spec.js +52 -2
- package/lib/common/chat-service-pinned-agent.spec.js.map +1 -1
- package/lib/common/chat-service-session-events.spec.d.ts +2 -0
- package/lib/common/chat-service-session-events.spec.d.ts.map +1 -0
- package/lib/common/chat-service-session-events.spec.js +130 -0
- package/lib/common/chat-service-session-events.spec.js.map +1 -0
- 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 +60 -6
- 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-status.spec.d.ts +2 -0
- package/lib/common/chat-session-status.spec.d.ts.map +1 -0
- package/lib/common/chat-session-status.spec.js +213 -0
- package/lib/common/chat-session-status.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/lib/common/tool-call-response-content.spec.js +36 -0
- package/lib/common/tool-call-response-content.spec.js.map +1 -1
- package/package.json +11 -11
- package/src/browser/agent-delegation-tool.spec.ts +16 -0
- package/src/browser/agent-delegation-tool.ts +7 -7
- package/src/browser/chat-request-command-resolution.spec.ts +117 -0
- package/src/browser/chat-session-store-impl.spec.ts +62 -2
- package/src/browser/chat-session-store-impl.ts +2 -0
- package/src/browser/chat-tool-preference-bindings.spec.ts +43 -48
- package/src/browser/chat-tool-preference-bindings.ts +42 -30
- package/src/common/chat-agents.spec.ts +112 -2
- package/src/common/chat-agents.ts +44 -1
- package/src/common/chat-auto-save.spec.ts +18 -1
- package/src/common/chat-content-deserializer.ts +11 -0
- package/src/common/chat-model-serialization.spec.ts +38 -0
- package/src/common/chat-model-serialization.ts +12 -1
- package/src/common/chat-model.spec.ts +42 -0
- package/src/common/chat-model.ts +241 -3
- package/src/common/chat-request-parser.spec.ts +401 -1
- package/src/common/chat-request-parser.ts +60 -5
- package/src/common/chat-response-model.spec.ts +41 -1
- package/src/common/chat-service-deletion.spec.ts +52 -0
- package/src/common/chat-service-pinned-agent.spec.ts +58 -2
- package/src/common/chat-service-session-events.spec.ts +161 -0
- package/src/common/chat-service.spec.ts +211 -0
- package/src/common/chat-service.ts +64 -8
- package/src/common/chat-session-status.spec.ts +280 -0
- package/src/common/chat-session-store.ts +8 -0
- package/src/common/tool-call-response-content.spec.ts +42 -0
|
@@ -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
|
+
});
|
|
@@ -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
|
|
@@ -351,6 +390,7 @@ export class ChatServiceImpl implements ChatService {
|
|
|
351
390
|
}
|
|
352
391
|
const requestText = request.request.displayText ?? request.request.text;
|
|
353
392
|
session.title = requestText;
|
|
393
|
+
this.onSessionEventEmitter.fire({ type: 'renamed', sessionId: session.id });
|
|
354
394
|
if (this.chatSessionNamingService) {
|
|
355
395
|
const otherSessionNames = this._sessions.map(s => s.title).filter((title): title is string => title !== undefined);
|
|
356
396
|
const namingService = this.chatSessionNamingService;
|
|
@@ -362,6 +402,7 @@ export class ChatServiceImpl implements ChatService {
|
|
|
362
402
|
session.title = name;
|
|
363
403
|
// Trigger persistence when title changes
|
|
364
404
|
this.saveSession(session.id);
|
|
405
|
+
this.onSessionEventEmitter.fire({ type: 'renamed', sessionId: session.id });
|
|
365
406
|
}
|
|
366
407
|
didGenerateName = true;
|
|
367
408
|
}).catch(error => this.logger.error('Failed to generate chat session name', error));
|
|
@@ -411,8 +452,12 @@ export class ChatServiceImpl implements ChatService {
|
|
|
411
452
|
return mentionedAgent;
|
|
412
453
|
} else if (session.pinnedAgent) {
|
|
413
454
|
// If we have a valid pinned agent, use it (pinned agent may become stale
|
|
414
|
-
// if it was disabled; so we always need to recheck)
|
|
415
|
-
|
|
455
|
+
// if it was disabled; so we always need to recheck). A pin is an explicit choice —
|
|
456
|
+
// e.g. AgentDelegationTool pins the delegated agent on the session it creates, and
|
|
457
|
+
// worker agents are often hidden (showInChat: false) — so hidden agents are honored
|
|
458
|
+
// here, while plain @-mentions above stay hidden-excluding. Without this, a request
|
|
459
|
+
// in a session pinned to a hidden agent silently falls back to the default agent.
|
|
460
|
+
const pinnedAgent = this.chatAgentService.getAgent(session.pinnedAgent.id, true);
|
|
416
461
|
if (pinnedAgent) {
|
|
417
462
|
return pinnedAgent;
|
|
418
463
|
}
|
|
@@ -458,9 +503,15 @@ export class ChatServiceImpl implements ChatService {
|
|
|
458
503
|
// Store session with title, pinned agent info, last interaction timestamp, and error state
|
|
459
504
|
const lastRequest = session.model.getRequests().at(-1);
|
|
460
505
|
const hasError = lastRequest?.response.isComplete === true && lastRequest?.response.isError === true;
|
|
461
|
-
return this.sessionStore.storeSessions(
|
|
462
|
-
|
|
463
|
-
|
|
506
|
+
return this.sessionStore.storeSessions({
|
|
507
|
+
model: session.model,
|
|
508
|
+
title: session.title,
|
|
509
|
+
pinnedAgentId: session.pinnedAgent?.id,
|
|
510
|
+
lastInteraction: session.lastInteraction?.getTime(),
|
|
511
|
+
hasError,
|
|
512
|
+
rootSessionId: session.rootSessionId,
|
|
513
|
+
parentSessionId: session.parentSessionId
|
|
514
|
+
}).catch(error => {
|
|
464
515
|
this.logger.error('Failed to store chat sessions', error);
|
|
465
516
|
});
|
|
466
517
|
}
|
|
@@ -505,9 +556,10 @@ export class ChatServiceImpl implements ChatService {
|
|
|
505
556
|
const model = new MutableChatModel(serialized.model);
|
|
506
557
|
await this.restoreSessionData(model, serialized.model);
|
|
507
558
|
|
|
508
|
-
// Determine pinned agent
|
|
559
|
+
// Determine pinned agent (hidden agents included — a restored pin, like a live one,
|
|
560
|
+
// is an explicit choice; see getPinnedAgent)
|
|
509
561
|
const pinnedAgent = serialized.pinnedAgentId
|
|
510
|
-
? this.chatAgentService.getAgent(serialized.pinnedAgentId)
|
|
562
|
+
? this.chatAgentService.getAgent(serialized.pinnedAgentId, true)
|
|
511
563
|
: undefined;
|
|
512
564
|
|
|
513
565
|
// Register as session
|
|
@@ -517,8 +569,12 @@ export class ChatServiceImpl implements ChatService {
|
|
|
517
569
|
lastInteraction: new Date(serialized.saveDate),
|
|
518
570
|
model,
|
|
519
571
|
isActive: false,
|
|
520
|
-
pinnedAgent
|
|
572
|
+
pinnedAgent,
|
|
573
|
+
rootSessionId: serialized.rootSessionId,
|
|
574
|
+
parentSessionId: serialized.parentSessionId
|
|
521
575
|
};
|
|
576
|
+
session.model.rootSessionId = serialized.rootSessionId;
|
|
577
|
+
session.model.parentSessionId = serialized.parentSessionId;
|
|
522
578
|
this._sessions.push(session);
|
|
523
579
|
this.setupAutoSaveForSession(session);
|
|
524
580
|
this.onSessionEventEmitter.fire({ type: 'created', sessionId: session.id });
|