@theia/ai-external-api 1.76.0-next.33

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 (72) hide show
  1. package/README.md +83 -0
  2. package/lib/browser/ai-external-api-frontend-contribution.d.ts +28 -0
  3. package/lib/browser/ai-external-api-frontend-contribution.d.ts.map +1 -0
  4. package/lib/browser/ai-external-api-frontend-contribution.js +88 -0
  5. package/lib/browser/ai-external-api-frontend-contribution.js.map +1 -0
  6. package/lib/browser/ai-external-api-frontend-contribution.spec.d.ts +2 -0
  7. package/lib/browser/ai-external-api-frontend-contribution.spec.d.ts.map +1 -0
  8. package/lib/browser/ai-external-api-frontend-contribution.spec.js +102 -0
  9. package/lib/browser/ai-external-api-frontend-contribution.spec.js.map +1 -0
  10. package/lib/browser/ai-external-api-frontend-module.d.ts +4 -0
  11. package/lib/browser/ai-external-api-frontend-module.d.ts.map +1 -0
  12. package/lib/browser/ai-external-api-frontend-module.js +47 -0
  13. package/lib/browser/ai-external-api-frontend-module.js.map +1 -0
  14. package/lib/browser/frontend-external-chat-session-provider.d.ts +63 -0
  15. package/lib/browser/frontend-external-chat-session-provider.d.ts.map +1 -0
  16. package/lib/browser/frontend-external-chat-session-provider.js +271 -0
  17. package/lib/browser/frontend-external-chat-session-provider.js.map +1 -0
  18. package/lib/browser/frontend-external-chat-session-provider.spec.d.ts +2 -0
  19. package/lib/browser/frontend-external-chat-session-provider.spec.d.ts.map +1 -0
  20. package/lib/browser/frontend-external-chat-session-provider.spec.js +421 -0
  21. package/lib/browser/frontend-external-chat-session-provider.spec.js.map +1 -0
  22. package/lib/browser/index.d.ts +3 -0
  23. package/lib/browser/index.d.ts.map +1 -0
  24. package/lib/browser/index.js +21 -0
  25. package/lib/browser/index.js.map +1 -0
  26. package/lib/common/external-chat-session-provider.d.ts +213 -0
  27. package/lib/common/external-chat-session-provider.d.ts.map +1 -0
  28. package/lib/common/external-chat-session-provider.js +147 -0
  29. package/lib/common/external-chat-session-provider.js.map +1 -0
  30. package/lib/common/index.d.ts +2 -0
  31. package/lib/common/index.d.ts.map +1 -0
  32. package/lib/common/index.js +20 -0
  33. package/lib/common/index.js.map +1 -0
  34. package/lib/node/ai-external-api-backend-module.d.ts +4 -0
  35. package/lib/node/ai-external-api-backend-module.d.ts.map +1 -0
  36. package/lib/node/ai-external-api-backend-module.js +38 -0
  37. package/lib/node/ai-external-api-backend-module.js.map +1 -0
  38. package/lib/node/ai-external-api-endpoint.d.ts +31 -0
  39. package/lib/node/ai-external-api-endpoint.d.ts.map +1 -0
  40. package/lib/node/ai-external-api-endpoint.js +189 -0
  41. package/lib/node/ai-external-api-endpoint.js.map +1 -0
  42. package/lib/node/ai-external-api-endpoint.spec.d.ts +2 -0
  43. package/lib/node/ai-external-api-endpoint.spec.d.ts.map +1 -0
  44. package/lib/node/ai-external-api-endpoint.spec.js +233 -0
  45. package/lib/node/ai-external-api-endpoint.spec.js.map +1 -0
  46. package/lib/node/external-chat-session-registry.d.ts +83 -0
  47. package/lib/node/external-chat-session-registry.d.ts.map +1 -0
  48. package/lib/node/external-chat-session-registry.js +266 -0
  49. package/lib/node/external-chat-session-registry.js.map +1 -0
  50. package/lib/node/external-chat-session-registry.spec.d.ts +2 -0
  51. package/lib/node/external-chat-session-registry.spec.d.ts.map +1 -0
  52. package/lib/node/external-chat-session-registry.spec.js +366 -0
  53. package/lib/node/external-chat-session-registry.spec.js.map +1 -0
  54. package/lib/node/index.d.ts +3 -0
  55. package/lib/node/index.d.ts.map +1 -0
  56. package/lib/node/index.js +21 -0
  57. package/lib/node/index.js.map +1 -0
  58. package/package.json +52 -0
  59. package/src/browser/ai-external-api-frontend-contribution.spec.ts +128 -0
  60. package/src/browser/ai-external-api-frontend-contribution.ts +90 -0
  61. package/src/browser/ai-external-api-frontend-module.ts +48 -0
  62. package/src/browser/frontend-external-chat-session-provider.spec.ts +491 -0
  63. package/src/browser/frontend-external-chat-session-provider.ts +277 -0
  64. package/src/browser/index.ts +18 -0
  65. package/src/common/external-chat-session-provider.ts +330 -0
  66. package/src/common/index.ts +17 -0
  67. package/src/node/ai-external-api-backend-module.ts +41 -0
  68. package/src/node/ai-external-api-endpoint.spec.ts +272 -0
  69. package/src/node/ai-external-api-endpoint.ts +195 -0
  70. package/src/node/external-chat-session-registry.spec.ts +413 -0
  71. package/src/node/external-chat-session-registry.ts +270 -0
  72. package/src/node/index.ts +18 -0
@@ -0,0 +1,491 @@
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 { ChatAgentLocation } from '@theia/ai-chat/lib/common/chat-agents';
23
+ import { ChatModel, ChatSessionStatus } from '@theia/ai-chat/lib/common/chat-model';
24
+ import { ChatService, ChatSession, NoChatAgentError, SessionOptions } from '@theia/ai-chat/lib/common/chat-service';
25
+ import { ChatSessionMetadata } from '@theia/ai-chat/lib/common/chat-session-store';
26
+ import { MockLogger } from '@theia/core/lib/common/test/mock-logger';
27
+ import { WorkspaceService } from '@theia/workspace/lib/browser';
28
+ import { expect } from 'chai';
29
+ import { ExternalApiTestSupport } from '@theia/external-api/lib/node/test/external-api-test-support';
30
+ import { FrontendExternalChatSessionProvider } from './frontend-external-chat-session-provider';
31
+
32
+ disableJSDOM();
33
+
34
+ describe('FrontendExternalChatSessionProvider', () => {
35
+
36
+ interface Exchange {
37
+ request?: string;
38
+ response?: string;
39
+ }
40
+
41
+ function liveSession(
42
+ id: string, status: ChatSessionStatus, exchanges: Exchange[] = [], lastInteraction?: Date, title?: string,
43
+ pinnedAgent?: { id: string; name: string }, delegation?: { parentSessionId?: string; rootSessionId?: string }
44
+ ): ChatSession {
45
+ const requests = exchanges.map(exchange => ({
46
+ request: { text: exchange.request ?? '' },
47
+ response: { response: { asDisplayString: () => exchange.response ?? '' } }
48
+ }));
49
+ const model = { status, getRequests: () => requests } as unknown as ChatModel;
50
+ return { id, title, lastInteraction, model, isActive: false, pinnedAgent, ...delegation } as ChatSession;
51
+ }
52
+
53
+ function persisted(id: string, extra: Partial<ChatSessionMetadata> = {}): ChatSessionMetadata {
54
+ return { sessionId: id, title: `persisted ${id}`, saveDate: 400, location: ChatAgentLocation.Panel, ...extra };
55
+ }
56
+
57
+ interface ProviderContext {
58
+ provider: FrontendExternalChatSessionProvider;
59
+ activatedSessions: { sessionId: string; options?: SessionOptions }[];
60
+ sentRequests: { sessionId: string; text: string }[];
61
+ createdSessions: { options?: SessionOptions; agent?: { id: string } }[];
62
+ deletedSessions: string[];
63
+ }
64
+
65
+ function createProvider(options: {
66
+ sessions?: ChatSession[],
67
+ persisted?: ChatSessionMetadata[],
68
+ persistedFailure?: boolean,
69
+ restorable?: ChatSession[],
70
+ agents?: Record<string, { id: string; name: string }>,
71
+ workspace?: string,
72
+ sessionStorageScope?: 'workspace' | 'global',
73
+ noAgent?: boolean,
74
+ requestFailure?: Error
75
+ }): ProviderContext {
76
+ const sessions = options.sessions ?? [];
77
+ const activatedSessions: { sessionId: string; options?: SessionOptions }[] = [];
78
+ const sentRequests: { sessionId: string; text: string }[] = [];
79
+ const createdSessions: { options?: SessionOptions; agent?: { id: string } }[] = [];
80
+ const deletedSessions: string[] = [];
81
+ const chatService = {
82
+ getSessions: () => sessions,
83
+ getSession: (id: string) => sessions.find(candidate => candidate.id === id),
84
+ getPersistedSessions: async () => {
85
+ if (options.persistedFailure) {
86
+ throw new Error('storage failure');
87
+ }
88
+ return Object.fromEntries((options.persisted ?? []).map(metadata => [metadata.sessionId, metadata]));
89
+ },
90
+ getOrRestoreSession: async (id: string) =>
91
+ sessions.find(candidate => candidate.id === id) ?? options.restorable?.find(candidate => candidate.id === id),
92
+ setActiveSession: (sessionId: string, sessionOptions?: SessionOptions) => {
93
+ activatedSessions.push({ sessionId, options: sessionOptions });
94
+ },
95
+ sendRequest: async (sessionId: string, request: { text: string }) => {
96
+ sentRequests.push({ sessionId, text: request.text });
97
+ const failure = options.noAgent ? new NoChatAgentError('no agent') : options.requestFailure;
98
+ return {
99
+ requestCompleted: failure
100
+ ? Promise.reject(failure)
101
+ : Promise.resolve({ id: `request-for-${sessionId}` })
102
+ };
103
+ },
104
+ createSession: (location: unknown, sessionOptions?: SessionOptions, agent?: { id: string; name: string }) => {
105
+ const session = liveSession('created-session', 'idle', [], new Date(100), undefined, agent);
106
+ sessions.push(session);
107
+ createdSessions.push({ options: sessionOptions, agent });
108
+ return session;
109
+ },
110
+ deleteSession: async (id: string) => {
111
+ deletedSessions.push(id);
112
+ sessions.splice(sessions.findIndex(candidate => candidate.id === id), 1);
113
+ }
114
+ } as unknown as ChatService;
115
+ const workspaceService = {
116
+ ready: Promise.resolve(),
117
+ workspace: options.workspace ? { resource: { toString: () => options.workspace } } : undefined
118
+ } as unknown as WorkspaceService;
119
+ const provider = ExternalApiTestSupport.inject(new FrontendExternalChatSessionProvider(), {
120
+ logger: new MockLogger(),
121
+ chatService,
122
+ agentService: { getAgent: (id: string) => options.agents?.[id] },
123
+ workspaceService,
124
+ preferenceService: {
125
+ ready: Promise.resolve(),
126
+ get: (preference: string, fallback: unknown) => options.sessionStorageScope ?? fallback
127
+ }
128
+ });
129
+ return { provider, activatedSessions, sentRequests, createdSessions, deletedSessions };
130
+ }
131
+
132
+ describe('getSessions', () => {
133
+ it('reports live sessions with status, workspace, agent, and preview', async () => {
134
+ const { provider } = createProvider({
135
+ sessions: [liveSession('1', 'awaitingApproval', [{ request: 'fix the build', response: 'Sure.' }], new Date(500), 'my session',
136
+ { id: 'coder', name: 'Coder' })],
137
+ workspace: 'file:///test/workspace'
138
+ });
139
+ const sessions = await provider.getSessions();
140
+ expect(sessions).to.deep.equal([{
141
+ id: '1',
142
+ title: 'my session',
143
+ status: 'awaitingApproval',
144
+ lastInteraction: 500,
145
+ workspace: 'file:///test/workspace',
146
+ preview: 'fix the build\nSure.',
147
+ agentId: 'coder',
148
+ agentName: 'Coder',
149
+ parentSessionId: undefined,
150
+ rootSessionId: undefined,
151
+ restored: true
152
+ }]);
153
+ });
154
+
155
+ it('reports no workspace when none is open', async () => {
156
+ const { provider } = createProvider({ sessions: [liveSession('1', 'idle')] });
157
+ const sessions = await provider.getSessions();
158
+ expect(sessions).to.have.length(1);
159
+ expect(sessions[0].workspace).to.equal(undefined);
160
+ });
161
+
162
+ it('reports no preview for an empty conversation', async () => {
163
+ const { provider } = createProvider({ sessions: [liveSession('1', 'idle')] });
164
+ const sessions = await provider.getSessions();
165
+ expect(sessions[0].preview).to.equal(undefined);
166
+ });
167
+
168
+ it('limits the preview to the last few non-empty lines', async () => {
169
+ const { provider } = createProvider({
170
+ sessions: [liveSession('1', 'running', [
171
+ { request: 'first question', response: 'old answer' },
172
+ { request: 'second question', response: 'one\n\ntwo\nthree\nfour\nfive' }
173
+ ])]
174
+ });
175
+ const sessions = await provider.getSessions();
176
+ expect(sessions[0].preview).to.equal('one\ntwo\nthree\nfour\nfive');
177
+ });
178
+
179
+ it('truncates overlong preview lines', async () => {
180
+ const { provider } = createProvider({
181
+ sessions: [liveSession('1', 'running', [{ request: 'call a tool', response: `Tool call: writeFile(${'x'.repeat(300)})` }])]
182
+ });
183
+ const sessions = await provider.getSessions();
184
+ const lines = sessions[0].preview!.split('\n');
185
+ expect(lines[1]).to.have.length(201);
186
+ expect(lines[1].endsWith('…')).to.equal(true);
187
+ });
188
+
189
+ it('reports the delegation ids of live and persisted sessions', async () => {
190
+ const { provider } = createProvider({
191
+ sessions: [liveSession('1', 'idle', [], undefined, undefined, undefined, { parentSessionId: 'root', rootSessionId: 'root' })],
192
+ persisted: [persisted('2', { parentSessionId: '1', rootSessionId: 'root' })]
193
+ });
194
+ const sessions = await provider.getSessions();
195
+ expect(sessions.map(session => [session.id, session.parentSessionId, session.rootSessionId])).to.deep.equal([
196
+ ['1', 'root', 'root'],
197
+ ['2', '1', 'root']
198
+ ]);
199
+ });
200
+
201
+ it('renders only the conversation entries the preview needs', async () => {
202
+ const rendered: string[] = [];
203
+ const session = liveSession('1', 'idle', [
204
+ { request: 'old question', response: 'old answer' },
205
+ { request: 'recent question', response: 'one\ntwo\nthree\nfour\nfive' }
206
+ ]);
207
+ for (const request of session.model.getRequests()) {
208
+ const response = request.response.response;
209
+ const original = response.asDisplayString.bind(response);
210
+ response.asDisplayString = () => {
211
+ rendered.push(original());
212
+ return original();
213
+ };
214
+ }
215
+ const { provider } = createProvider({ sessions: [session] });
216
+ const sessions = await provider.getSessions();
217
+ expect(sessions[0].preview).to.equal('one\ntwo\nthree\nfour\nfive');
218
+ expect(rendered).to.deep.equal(['one\ntwo\nthree\nfour\nfive']);
219
+ });
220
+
221
+ it('reports persisted sessions with their metadata', async () => {
222
+ const { provider } = createProvider({
223
+ persisted: [persisted('2', { pinnedAgentId: 'coder', hasError: true })],
224
+ agents: { coder: { id: 'coder', name: 'Coder' } },
225
+ workspace: 'file:///test/workspace'
226
+ });
227
+ const sessions = await provider.getSessions();
228
+ expect(sessions).to.deep.equal([{
229
+ id: '2',
230
+ title: 'persisted 2',
231
+ status: 'failed',
232
+ lastInteraction: 400,
233
+ workspace: 'file:///test/workspace',
234
+ agentId: 'coder',
235
+ agentName: 'Coder',
236
+ parentSessionId: undefined,
237
+ rootSessionId: undefined,
238
+ restored: false
239
+ }]);
240
+ });
241
+
242
+ it('does not attribute a workspace to persisted sessions stored globally', async () => {
243
+ const { provider } = createProvider({
244
+ sessions: [liveSession('1', 'running')],
245
+ persisted: [persisted('2')],
246
+ workspace: 'file:///test/workspace',
247
+ sessionStorageScope: 'global'
248
+ });
249
+ const sessions = await provider.getSessions();
250
+ expect(sessions.map(session => `${session.id}:${session.workspace}`)).to.deep.equal(
251
+ ['1:file:///test/workspace', '2:undefined']);
252
+ });
253
+
254
+ it('reports persisted sessions as idle without a recorded error', async () => {
255
+ const { provider } = createProvider({ persisted: [persisted('2')] });
256
+ const sessions = await provider.getSessions();
257
+ expect(sessions[0].status).to.equal('idle');
258
+ expect(sessions[0].agentName).to.equal(undefined);
259
+ });
260
+
261
+ it('does not duplicate restored sessions from the persisted index', async () => {
262
+ const { provider } = createProvider({
263
+ sessions: [liveSession('1', 'running')],
264
+ persisted: [persisted('1'), persisted('2')]
265
+ });
266
+ const sessions = await provider.getSessions();
267
+ expect(sessions.map(session => `${session.id}:${session.restored}`)).to.deep.equal(['1:true', '2:false']);
268
+ });
269
+
270
+ it('reports only live sessions when reading the persisted index fails', async () => {
271
+ const { provider } = createProvider({
272
+ sessions: [liveSession('1', 'running')],
273
+ persisted: [persisted('2')],
274
+ persistedFailure: true
275
+ });
276
+ const sessions = await provider.getSessions();
277
+ expect(sessions.map(session => session.id)).to.deep.equal(['1']);
278
+ });
279
+ });
280
+
281
+ describe('getSession', () => {
282
+ it('reduces the conversation to plain-text messages', async () => {
283
+ const { provider } = createProvider({
284
+ sessions: [liveSession('1', 'idle', [
285
+ { request: 'fix the build', response: 'Done, the build passes now.' },
286
+ { request: 'thanks' }
287
+ ], new Date(300), 'live')],
288
+ workspace: 'file:///test/workspace'
289
+ });
290
+ const session = await provider.getSession('1');
291
+ expect(session).to.deep.equal({
292
+ id: '1',
293
+ title: 'live',
294
+ status: 'idle',
295
+ lastInteraction: 300,
296
+ workspace: 'file:///test/workspace',
297
+ preview: 'fix the build\nDone, the build passes now.\nthanks',
298
+ agentId: undefined,
299
+ agentName: undefined,
300
+ parentSessionId: undefined,
301
+ rootSessionId: undefined,
302
+ restored: true,
303
+ messages: [
304
+ { actor: 'user', text: 'fix the build' },
305
+ { actor: 'ai', text: 'Done, the build passes now.' },
306
+ { actor: 'user', text: 'thanks' }
307
+ ]
308
+ });
309
+ });
310
+
311
+ it('returns persisted metadata without messages for unrestored sessions', async () => {
312
+ const { provider } = createProvider({ persisted: [persisted('2')] });
313
+ const session = await provider.getSession('2');
314
+ expect(session?.restored).to.equal(false);
315
+ expect(session?.title).to.equal('persisted 2');
316
+ expect(session?.messages).to.equal(undefined);
317
+ });
318
+
319
+ it('returns undefined for unknown sessions', async () => {
320
+ const { provider } = createProvider({});
321
+ expect(await provider.getSession('missing')).to.equal(undefined);
322
+ });
323
+ });
324
+
325
+ describe('getSessionSummary', () => {
326
+ it('reports a live session without its conversation', async () => {
327
+ const { provider } = createProvider({
328
+ sessions: [liveSession('1', 'running', [{ request: 'fix the build', response: 'Done.' }])]
329
+ });
330
+ const summary = await provider.getSessionSummary('1');
331
+ expect(summary?.status).to.equal('running');
332
+ expect(summary).to.not.have.property('messages');
333
+ });
334
+
335
+ it('reports a persisted session that is not restored', async () => {
336
+ const { provider } = createProvider({ persisted: [persisted('2')] });
337
+ const summary = await provider.getSessionSummary('2');
338
+ expect(summary?.id).to.equal('2');
339
+ expect(summary?.restored).to.equal(false);
340
+ });
341
+
342
+ it('returns undefined for unknown sessions', async () => {
343
+ const { provider } = createProvider({});
344
+ expect(await provider.getSessionSummary('missing')).to.equal(undefined);
345
+ });
346
+ });
347
+
348
+ describe('openSession', () => {
349
+ it('activates and focuses a live session', async () => {
350
+ const { provider, activatedSessions } = createProvider({ sessions: [liveSession('1', 'idle')] });
351
+ expect(await provider.openSession('1')).to.equal(true);
352
+ expect(activatedSessions).to.deep.equal([{ sessionId: '1', options: { focus: true } }]);
353
+ });
354
+
355
+ it('restores a persisted session before activating it', async () => {
356
+ const { provider, activatedSessions } = createProvider({ restorable: [liveSession('2', 'idle')] });
357
+ expect(await provider.openSession('2')).to.equal(true);
358
+ expect(activatedSessions).to.have.length(1);
359
+ });
360
+
361
+ it('returns false for unknown sessions', async () => {
362
+ const { provider, activatedSessions } = createProvider({});
363
+ expect(await provider.openSession('missing')).to.equal(false);
364
+ expect(activatedSessions).to.deep.equal([]);
365
+ });
366
+ });
367
+
368
+ describe('sendPrompt', () => {
369
+ it('sends a prompt to an idle session', async () => {
370
+ const { provider, sentRequests } = createProvider({ sessions: [liveSession('1', 'idle')] });
371
+ const result = await provider.sendPrompt('1', { text: 'continue' });
372
+ expect(result).to.deep.equal({ sent: { sessionId: '1', requestId: 'request-for-1' } });
373
+ expect(sentRequests).to.deep.equal([{ sessionId: '1', text: 'continue' }]);
374
+ });
375
+
376
+ it('rejects prompting a busy session', async () => {
377
+ const { provider, sentRequests } = createProvider({ sessions: [liveSession('1', 'running')] });
378
+ const result = await provider.sendPrompt('1', { text: 'continue' });
379
+ expect(result).to.deep.equal({ failure: 'busy' });
380
+ expect(sentRequests).to.deep.equal([]);
381
+ });
382
+
383
+ it('interrupts a busy session when requested', async () => {
384
+ const { provider, sentRequests } = createProvider({ sessions: [liveSession('1', 'awaitingApproval')] });
385
+ const result = await provider.sendPrompt('1', { text: 'continue', interrupt: true });
386
+ expect(result).to.deep.equal({ sent: { sessionId: '1', requestId: 'request-for-1' } });
387
+ expect(sentRequests).to.have.length(1);
388
+ });
389
+
390
+ it('restores a persisted session before prompting', async () => {
391
+ const { provider } = createProvider({ restorable: [liveSession('2', 'idle')] });
392
+ const result = await provider.sendPrompt('2', { text: 'continue' });
393
+ expect(result).to.deep.equal({ sent: { sessionId: '2', requestId: 'request-for-2' } });
394
+ });
395
+
396
+ it('returns undefined for unknown sessions', async () => {
397
+ const { provider } = createProvider({});
398
+ expect(await provider.sendPrompt('missing', { text: 'continue' })).to.equal(undefined);
399
+ });
400
+
401
+ it('reports when no agent can handle the prompt', async () => {
402
+ const { provider } = createProvider({ sessions: [liveSession('1', 'idle')], noAgent: true });
403
+ expect(await provider.sendPrompt('1', { text: 'continue' })).to.deep.equal({ failure: 'noAgent' });
404
+ });
405
+
406
+ it('propagates unexpected request failures instead of reporting them as missing agents', async () => {
407
+ const { provider } = createProvider({ sessions: [liveSession('1', 'idle')], requestFailure: new Error('connection lost') });
408
+ let thrown: unknown;
409
+ try {
410
+ await provider.sendPrompt('1', { text: 'continue' });
411
+ } catch (error) {
412
+ thrown = error;
413
+ }
414
+ expect((thrown as Error).message).to.equal('connection lost');
415
+ });
416
+ });
417
+
418
+ describe('createSession', () => {
419
+ it('creates a session with a pinned agent and sends the initial prompt', async () => {
420
+ const { provider, sentRequests, createdSessions } = createProvider({
421
+ agents: { coder: { id: 'coder', name: 'Coder' } },
422
+ workspace: 'file:///test/workspace'
423
+ });
424
+ const result = await provider.createSession({ agentId: 'coder', prompt: 'fix the build', focus: true });
425
+ expect('created' in result && result.created.session.id).to.equal('created-session');
426
+ expect('created' in result && result.created.session.agentId).to.equal('coder');
427
+ expect('created' in result && result.created.requestId).to.equal('request-for-created-session');
428
+ expect(createdSessions).to.deep.equal([{ options: { focus: true }, agent: { id: 'coder', name: 'Coder' } }]);
429
+ expect(sentRequests).to.deep.equal([{ sessionId: 'created-session', text: 'fix the build' }]);
430
+ });
431
+
432
+ it('creates a session without an initial prompt', async () => {
433
+ const { provider, sentRequests } = createProvider({});
434
+ const result = await provider.createSession({});
435
+ expect('created' in result && result.created.session.id).to.equal('created-session');
436
+ expect('created' in result && result.created.requestId).to.equal(undefined);
437
+ expect(sentRequests).to.deep.equal([]);
438
+ });
439
+
440
+ it('rejects an unknown agent without creating a session', async () => {
441
+ const { provider, createdSessions } = createProvider({});
442
+ expect(await provider.createSession({ agentId: 'ghost' })).to.deep.equal({ failure: 'unknownAgent' });
443
+ expect(createdSessions).to.deep.equal([]);
444
+ });
445
+
446
+ it('deletes the session when no agent handles the initial prompt', async () => {
447
+ const { provider, deletedSessions } = createProvider({ noAgent: true });
448
+ expect(await provider.createSession({ prompt: 'fix the build' })).to.deep.equal({ failure: 'noAgent' });
449
+ expect(deletedSessions).to.deep.equal(['created-session']);
450
+ });
451
+
452
+ it('deletes the session and propagates unexpected initial prompt failures', async () => {
453
+ const { provider, deletedSessions } = createProvider({ requestFailure: new Error('connection lost') });
454
+ let thrown: unknown;
455
+ try {
456
+ await provider.createSession({ prompt: 'fix the build' });
457
+ } catch (error) {
458
+ thrown = error;
459
+ }
460
+ expect((thrown as Error).message).to.equal('connection lost');
461
+ expect(deletedSessions).to.deep.equal(['created-session']);
462
+ });
463
+ });
464
+
465
+ describe('getWorkspace', () => {
466
+ it('reports the workspace of this frontend', async () => {
467
+ const { provider } = createProvider({ workspace: 'file:///test/workspace' });
468
+ expect(await provider.getWorkspace()).to.equal('file:///test/workspace');
469
+ });
470
+ });
471
+
472
+ describe('restoreSession', () => {
473
+ it('restores a persisted session without activating it', async () => {
474
+ const { provider, activatedSessions } = createProvider({
475
+ restorable: [liveSession('2', 'idle', [{ request: 'old question', response: 'old answer' }])]
476
+ });
477
+ const session = await provider.restoreSession('2');
478
+ expect(session?.restored).to.equal(true);
479
+ expect(session?.messages).to.deep.equal([
480
+ { actor: 'user', text: 'old question' },
481
+ { actor: 'ai', text: 'old answer' }
482
+ ]);
483
+ expect(activatedSessions).to.deep.equal([]);
484
+ });
485
+
486
+ it('returns undefined for unknown sessions', async () => {
487
+ const { provider } = createProvider({});
488
+ expect(await provider.restoreSession('missing')).to.equal(undefined);
489
+ });
490
+ });
491
+ });