@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,41 @@
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 { ConnectionHandler, RpcConnectionHandler } from '@theia/core';
18
+ import { ContainerModule } from '@theia/core/shared/inversify';
19
+ import { ExternalApiContribution } from '@theia/external-api/lib/node/external-api-contribution';
20
+ import { EXTERNAL_CHAT_SESSION_PROVIDER_PATH, ExternalChatSessionBackendService, ExternalChatSessionProvider } from '../common/external-chat-session-provider';
21
+ import { AIExternalApiEndpoint } from './ai-external-api-endpoint';
22
+ import { ExternalChatSessionRegistry } from './external-chat-session-registry';
23
+
24
+ export default new ContainerModule(bind => {
25
+ bind(ExternalChatSessionRegistry).toSelf().inSingletonScope();
26
+
27
+ bind(AIExternalApiEndpoint).toSelf().inSingletonScope();
28
+ bind(ExternalApiContribution).toService(AIExternalApiEndpoint);
29
+
30
+ bind(ConnectionHandler).toDynamicValue(ctx =>
31
+ new RpcConnectionHandler<ExternalChatSessionProvider>(EXTERNAL_CHAT_SESSION_PROVIDER_PATH, provider => {
32
+ const registry = ctx.container.get(ExternalChatSessionRegistry);
33
+ registry.addProvider(provider);
34
+ provider.onDidCloseConnection(() => registry.removeProvider(provider));
35
+ const backendService: ExternalChatSessionBackendService = {
36
+ notifySessionsChanged: () => registry.notifySessionsChanged()
37
+ };
38
+ return backendService;
39
+ })
40
+ ).inSingletonScope();
41
+ });
@@ -0,0 +1,272 @@
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 { Emitter } from '@theia/core';
18
+ import * as express from '@theia/core/shared/express';
19
+ import { ExternalApiRouter } from '@theia/external-api/lib/node/external-api-router';
20
+ import { ExternalApiTestSupport } from '@theia/external-api/lib/node/test/external-api-test-support';
21
+ import { expect } from 'chai';
22
+ import * as http from 'http';
23
+ import { AddressInfo } from 'net';
24
+ import { ExternalChatSessionSummary } from '../common/external-chat-session-provider';
25
+ import { AIExternalApiEndpoint } from './ai-external-api-endpoint';
26
+ import { ExternalChatSessionRegistry } from './external-chat-session-registry';
27
+
28
+ describe('AIExternalApiEndpoint', () => {
29
+
30
+ const sessions: ExternalChatSessionSummary[] = [
31
+ {
32
+ id: '1', title: 'session', status: 'running', lastInteraction: 100, workspace: 'file:///test/workspace',
33
+ preview: 'fix the build\nSure.', agentId: 'coder', agentName: 'Coder', restored: true
34
+ }
35
+ ];
36
+
37
+ let server: http.Server | undefined;
38
+
39
+ afterEach(() => {
40
+ server?.close();
41
+ server?.closeAllConnections();
42
+ server = undefined;
43
+ });
44
+
45
+ interface ServedEndpoint {
46
+ url: string;
47
+ router: ExternalApiRouter;
48
+ sessionsChanged: Emitter<void>;
49
+ }
50
+
51
+ async function serve(registry: Partial<ExternalChatSessionRegistry> = {}): Promise<ServedEndpoint> {
52
+ const sessionsChanged = new Emitter<void>();
53
+ const endpoint = ExternalApiTestSupport.inject(new AIExternalApiEndpoint(), {
54
+ registry: {
55
+ getSessions: async () => sessions,
56
+ getSession: async (id: string) => sessions.find(candidate => candidate.id === id),
57
+ openSession: async (id: string) => sessions.some(candidate => candidate.id === id),
58
+ restoreSession: async (id: string) => sessions.find(candidate => candidate.id === id),
59
+ sendPrompt: async (id: string) => sessions.some(candidate => candidate.id === id)
60
+ ? { sent: { sessionId: id, requestId: 'r1' } }
61
+ : undefined,
62
+ createSession: async () => ({ created: { session: sessions[0], requestId: 'r1' } }),
63
+ onDidChangeSessions: sessionsChanged.event,
64
+ ...registry
65
+ }
66
+ });
67
+ const app = express();
68
+ const router = ExternalApiTestSupport.mountContribution(app, endpoint);
69
+ const listening = new Promise<void>(resolve => {
70
+ server = app.listen(0, '127.0.0.1', () => resolve());
71
+ });
72
+ await listening;
73
+ return { url: `http://127.0.0.1:${(server!.address() as AddressInfo).port}${endpoint.path}`, router, sessionsChanged };
74
+ }
75
+
76
+ function eventData(event: string | undefined): unknown {
77
+ const data = event?.split('\n').find(line => line.startsWith('data: '));
78
+ expect(data, `expected a data line in event: ${event}`).to.not.equal(undefined);
79
+ return JSON.parse(data!.substring('data: '.length));
80
+ }
81
+
82
+ it('lists sessions', async () => {
83
+ const { url } = await serve();
84
+ const response = await fetch(url);
85
+ expect(response.status).to.equal(200);
86
+ expect(await response.json()).to.deep.equal({ sessions });
87
+ });
88
+
89
+ it('returns a single session', async () => {
90
+ const { url } = await serve();
91
+ const response = await fetch(`${url}/1`);
92
+ expect(response.status).to.equal(200);
93
+ expect(await response.json()).to.deep.equal(sessions[0]);
94
+ });
95
+
96
+ it('returns 404 for unknown sessions', async () => {
97
+ const { url } = await serve();
98
+ const response = await fetch(`${url}/missing`);
99
+ expect(response.status).to.equal(404);
100
+ expect(await response.json()).to.deep.equal({ error: 'not found' });
101
+ });
102
+
103
+ it('returns 500 when gathering session data fails', async () => {
104
+ const { url } = await serve({ getSessions: async () => { throw new Error('boom'); } });
105
+ const response = await fetch(url);
106
+ expect(response.status).to.equal(500);
107
+ expect(await response.json()).to.deep.equal({ error: 'internal error' });
108
+ });
109
+
110
+ describe('open and restore', () => {
111
+ it('opens a session', async () => {
112
+ const opened: string[] = [];
113
+ const { url } = await serve({ openSession: async (id: string) => { opened.push(id); return true; } });
114
+ const response = await fetch(`${url}/1/open`, { method: 'POST' });
115
+ expect(response.status).to.equal(204);
116
+ expect(opened).to.deep.equal(['1']);
117
+ });
118
+
119
+ it('returns 404 when opening an unknown session', async () => {
120
+ const { url } = await serve();
121
+ const response = await fetch(`${url}/missing/open`, { method: 'POST' });
122
+ expect(response.status).to.equal(404);
123
+ });
124
+
125
+ it('restores a session and returns its detail', async () => {
126
+ const { url } = await serve();
127
+ const response = await fetch(`${url}/1/restore`, { method: 'POST' });
128
+ expect(response.status).to.equal(200);
129
+ expect(await response.json()).to.deep.equal(sessions[0]);
130
+ });
131
+
132
+ it('returns 404 when restoring an unknown session', async () => {
133
+ const { url } = await serve();
134
+ const response = await fetch(`${url}/missing/restore`, { method: 'POST' });
135
+ expect(response.status).to.equal(404);
136
+ });
137
+ });
138
+
139
+ describe('create and prompt', () => {
140
+ function post(url: string, body: unknown): Promise<Response> {
141
+ return fetch(url, {
142
+ method: 'POST',
143
+ headers: { 'content-type': 'application/json' },
144
+ body: typeof body === 'string' ? body : JSON.stringify(body)
145
+ });
146
+ }
147
+
148
+ it('creates a session', async () => {
149
+ const { url } = await serve();
150
+ const response = await post(url, { workspace: 'file:///test/workspace', agentId: 'coder', prompt: 'fix the build' });
151
+ expect(response.status).to.equal(201);
152
+ expect(await response.json()).to.deep.equal({ session: sessions[0], requestId: 'r1' });
153
+ });
154
+
155
+ it('rejects an invalid creation request with the validation errors as details', async () => {
156
+ const { url } = await serve();
157
+ const response = await post(url, { focus: 'yes' });
158
+ expect(response.status).to.equal(400);
159
+ const body = await response.json();
160
+ expect(body.error).to.equal('invalid request');
161
+ expect(body.details).to.have.length(1);
162
+ expect(body.details[0]).to.contain('focus');
163
+ });
164
+
165
+ it('rejects a creation request with a blank workspace', async () => {
166
+ const { url } = await serve();
167
+ const response = await post(url, { workspace: ' ' });
168
+ expect(response.status).to.equal(400);
169
+ const error = await response.json();
170
+ expect(error.error).to.equal('invalid request');
171
+ expect(error.details[0]).to.contain('workspace');
172
+ });
173
+
174
+ it('rejects an unknown agent', async () => {
175
+ const { url } = await serve({ createSession: async () => ({ failure: 'unknownAgent' }) });
176
+ const response = await post(url, { agentId: 'ghost' });
177
+ expect(response.status).to.equal(400);
178
+ expect(await response.json()).to.deep.equal({ error: 'unknown agent' });
179
+ });
180
+
181
+ it('rejects an unknown workspace', async () => {
182
+ const { url } = await serve({ createSession: async () => ({ failure: 'workspaceNotFound' }) });
183
+ const response = await post(url, { workspace: 'file:///other' });
184
+ expect(response.status).to.equal(404);
185
+ expect(await response.json()).to.deep.equal({ error: 'workspace not found' });
186
+ });
187
+
188
+ it('rejects a creation request when no frontend is connected', async () => {
189
+ const { url } = await serve({ createSession: async () => ({ failure: 'noFrontend' }) });
190
+ const response = await post(url, {});
191
+ expect(response.status).to.equal(503);
192
+ expect(await response.json()).to.deep.equal({ error: 'no frontend connected' });
193
+ });
194
+
195
+ it('rejects an ambiguous workspace', async () => {
196
+ const { url } = await serve({ createSession: async () => ({ failure: 'ambiguousWorkspace' }) });
197
+ const response = await post(url, {});
198
+ expect(response.status).to.equal(409);
199
+ expect(await response.json()).to.deep.equal({ error: 'ambiguous workspace' });
200
+ });
201
+
202
+ it('sends a prompt to a session', async () => {
203
+ const { url } = await serve();
204
+ const response = await post(`${url}/1/prompt`, { text: 'continue' });
205
+ expect(response.status).to.equal(202);
206
+ expect(await response.json()).to.deep.equal({ sessionId: '1', requestId: 'r1' });
207
+ });
208
+
209
+ it('returns 404 when prompting an unknown session', async () => {
210
+ const { url } = await serve();
211
+ const response = await post(`${url}/missing/prompt`, { text: 'continue' });
212
+ expect(response.status).to.equal(404);
213
+ });
214
+
215
+ it('returns 409 when the session is busy', async () => {
216
+ const { url } = await serve({ sendPrompt: async () => ({ failure: 'busy' }) });
217
+ const response = await post(`${url}/1/prompt`, { text: 'continue' });
218
+ expect(response.status).to.equal(409);
219
+ expect(await response.json()).to.deep.equal({ error: 'busy' });
220
+ });
221
+
222
+ it('returns 409 when no agent is available', async () => {
223
+ const { url } = await serve({ sendPrompt: async () => ({ failure: 'noAgent' }) });
224
+ const response = await post(`${url}/1/prompt`, { text: 'continue' });
225
+ expect(response.status).to.equal(409);
226
+ expect(await response.json()).to.deep.equal({ error: 'no agent available' });
227
+ });
228
+
229
+ it('rejects a prompt without text', async () => {
230
+ const { url } = await serve();
231
+ const response = await post(`${url}/1/prompt`, { text: ' ' });
232
+ expect(response.status).to.equal(400);
233
+ const error = await response.json();
234
+ expect(error.error).to.equal('invalid request');
235
+ expect(error.details[0]).to.contain('text');
236
+ });
237
+ });
238
+
239
+ describe('events', () => {
240
+ it('streams the current sessions on connect', async () => {
241
+ const { url } = await serve();
242
+ const response = await fetch(`${url}/events`);
243
+ expect(response.status).to.equal(200);
244
+ expect(response.headers.get('content-type')).to.equal('text/event-stream');
245
+ const events = ExternalApiTestSupport.sseReader(response);
246
+ const first = await events.next();
247
+ expect(first).to.contain('event: sessions');
248
+ expect(eventData(first)).to.deep.equal({ sessions });
249
+ await events.cancel();
250
+ });
251
+
252
+ it('pushes an updated list when sessions change', async () => {
253
+ const { url, sessionsChanged } = await serve();
254
+ const response = await fetch(`${url}/events`);
255
+ const events = ExternalApiTestSupport.sseReader(response);
256
+ await events.next();
257
+ sessionsChanged.fire();
258
+ const update = await events.next();
259
+ expect(eventData(update)).to.deep.equal({ sessions });
260
+ await events.cancel();
261
+ });
262
+
263
+ it('closes event streams when the routing is rebuilt', async () => {
264
+ const { url, router } = await serve();
265
+ const response = await fetch(`${url}/events`);
266
+ const events = ExternalApiTestSupport.sseReader(response);
267
+ await events.next();
268
+ router.dispose();
269
+ expect(await events.next()).to.equal(undefined);
270
+ });
271
+ });
272
+ });
@@ -0,0 +1,195 @@
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 { IJSONSchema } from '@theia/core/lib/common/json-schema';
18
+ import { inject, injectable } from '@theia/core/shared/inversify';
19
+ import { ExternalApiContribution, ExternalApiContributionDocumentation } from '@theia/external-api/lib/node/external-api-contribution';
20
+ import { ExternalApiRouter, RestParamDocumentation } from '@theia/external-api/lib/node/external-api-router';
21
+ import { RestResult } from '@theia/external-api/lib/node/rest-result';
22
+ import {
23
+ AI_SESSIONS_API_PATH, ExternalChatPrompt, ExternalChatSessionCreateRequest, ExternalChatSessionDetail, ExternalChatSessionSummary
24
+ } from '../common/external-chat-session-provider';
25
+ import { ExternalChatSessionRegistry } from './external-chat-session-registry';
26
+
27
+ const SESSION_ID_PARAM: RestParamDocumentation = { description: 'The session id.' };
28
+
29
+ const SESSION_LIST_SCHEMA: IJSONSchema = {
30
+ type: 'object',
31
+ required: ['sessions'],
32
+ properties: {
33
+ sessions: { type: 'array', items: ExternalChatSessionSummary.SCHEMA, description: 'The sessions, most recently used first.' }
34
+ }
35
+ };
36
+
37
+ const SESSION_CREATED_SCHEMA: IJSONSchema = {
38
+ type: 'object',
39
+ required: ['session'],
40
+ properties: {
41
+ session: ExternalChatSessionSummary.SCHEMA,
42
+ requestId: { type: 'string', description: 'Id of the request created for the initial prompt; absent when no prompt was sent.' }
43
+ }
44
+ };
45
+
46
+ const PROMPT_ACCEPTED_SCHEMA: IJSONSchema = {
47
+ type: 'object',
48
+ required: ['sessionId', 'requestId'],
49
+ properties: {
50
+ sessionId: { type: 'string', description: 'Id of the prompted session.' },
51
+ requestId: { type: 'string', description: 'Id of the created request.' }
52
+ }
53
+ };
54
+
55
+ /**
56
+ * Contributes the AI session API to the external API server:
57
+ * - `GET /api/ai/sessions` lists all sessions (restored and persisted) with id, title, status,
58
+ * agent, workspace, last interaction time, and a short plain-text preview of the conversation.
59
+ * - `POST /api/ai/sessions` creates a session in a frontend matching the requested workspace,
60
+ * optionally pinning an agent and sending an initial prompt.
61
+ * - `GET /api/ai/sessions/events` streams the session list as server-sent events, pushing an
62
+ * updated list whenever sessions change.
63
+ * - `GET /api/ai/sessions/:id` returns a single session including its conversation reduced to
64
+ * plain-text messages (persisted sessions report metadata only until restored).
65
+ * - `POST /api/ai/sessions/:id/open` shows the session in the chat view of a connected frontend.
66
+ * - `POST /api/ai/sessions/:id/restore` restores a persisted session and returns its detail.
67
+ * - `POST /api/ai/sessions/:id/prompt` sends a prompt to the session.
68
+ *
69
+ * The endpoints are served on the external API port and are token-protected when an
70
+ * external API token is configured (see `@theia/external-api`).
71
+ */
72
+ @injectable()
73
+ export class AIExternalApiEndpoint implements ExternalApiContribution {
74
+
75
+ readonly path = AI_SESSIONS_API_PATH;
76
+
77
+ readonly documentation: ExternalApiContributionDocumentation = {
78
+ title: 'AI Chat Sessions',
79
+ description: 'Inspect, follow, open, prompt, and create the AI chat sessions of this Theia instance.'
80
+ };
81
+
82
+ @inject(ExternalChatSessionRegistry)
83
+ protected readonly registry: ExternalChatSessionRegistry;
84
+
85
+ configure(router: ExternalApiRouter): void {
86
+ router.get('/', {
87
+ operationId: 'listChatSessions',
88
+ summary: 'List all AI chat sessions, most recently used first.',
89
+ responses: {
90
+ 200: { description: 'The current sessions of all connected frontends.', schema: SESSION_LIST_SCHEMA }
91
+ }
92
+ }, async () => RestResult.ok({ sessions: await this.registry.getSessions() }));
93
+ // register before '/:id' so that 'events' is not treated as a session id
94
+ const events = router.eventStream('/events', {
95
+ event: 'sessions',
96
+ operationId: 'streamChatSessions',
97
+ summary: 'Stream the session list as server-sent events.',
98
+ description: 'On connect, the stream immediately delivers the current session list; afterwards, the full, '
99
+ + 'updated list is pushed whenever sessions change. Consume each event as a replacement, not a delta.',
100
+ dataSchema: SESSION_LIST_SCHEMA,
101
+ snapshot: async () => ({ sessions: await this.registry.getSessions() })
102
+ });
103
+ router.toDispose.push(this.registry.onDidChangeSessions(() => events.notifyChanged()));
104
+ router.get('/:id', {
105
+ operationId: 'getChatSession',
106
+ summary: 'Get a single session including its conversation reduced to plain-text messages.',
107
+ params: { id: SESSION_ID_PARAM },
108
+ responses: {
109
+ 200: {
110
+ description: 'The session detail. Persisted sessions that are not restored carry no messages.',
111
+ schema: ExternalChatSessionDetail.SCHEMA
112
+ },
113
+ 404: { description: 'The session is unknown to all connected frontends.' }
114
+ }
115
+ }, async ({ params }) => {
116
+ const session = await this.registry.getSession(params.id);
117
+ return session ? RestResult.ok(session) : RestResult.notFound();
118
+ });
119
+ router.post('/', {
120
+ operationId: 'createChatSession',
121
+ summary: 'Create a chat session in a connected frontend and optionally send an initial prompt.',
122
+ bodySchema: ExternalChatSessionCreateRequest.SCHEMA,
123
+ responses: {
124
+ 201: { description: 'The created session and, if an initial prompt was sent, the id of the created request.', schema: SESSION_CREATED_SCHEMA },
125
+ 400: { description: 'The requested agent is not registered.' },
126
+ 404: { description: 'No connected frontend matches the requested workspace.' },
127
+ 409: { description: 'The workspace is ambiguous or no agent could handle the initial prompt.' },
128
+ 503: { description: 'No frontend is connected.' }
129
+ }
130
+ }, ({ body }) => this.createSession(body));
131
+ router.post('/:id/open', {
132
+ operationId: 'openChatSession',
133
+ summary: 'Show the session in the chat view of a connected frontend, restoring it first if necessary.',
134
+ params: { id: SESSION_ID_PARAM },
135
+ responses: {
136
+ 204: { description: 'The session is shown in a frontend.' },
137
+ 404: { description: 'The session is unknown to all connected frontends.' }
138
+ }
139
+ }, async ({ params }) =>
140
+ await this.registry.openSession(params.id) ? RestResult.noContent() : RestResult.notFound());
141
+ router.post('/:id/restore', {
142
+ operationId: 'restoreChatSession',
143
+ summary: 'Restore the session in a connected frontend without focusing it, and return its detail.',
144
+ params: { id: SESSION_ID_PARAM },
145
+ responses: {
146
+ 200: { description: 'The detail of the restored session.', schema: ExternalChatSessionDetail.SCHEMA },
147
+ 404: { description: 'The session is unknown to all connected frontends.' }
148
+ }
149
+ }, async ({ params }) => {
150
+ const session = await this.registry.restoreSession(params.id);
151
+ return session ? RestResult.ok(session) : RestResult.notFound();
152
+ });
153
+ router.post('/:id/prompt', {
154
+ operationId: 'promptChatSession',
155
+ summary: 'Send a prompt to the session, restoring it first if necessary.',
156
+ bodySchema: ExternalChatPrompt.SCHEMA,
157
+ params: { id: SESSION_ID_PARAM },
158
+ responses: {
159
+ 202: { description: 'The prompt was submitted; follow the progress via the event stream or the read endpoints.', schema: PROMPT_ACCEPTED_SCHEMA },
160
+ 404: { description: 'The session is unknown to all connected frontends.' },
161
+ 409: { description: 'A request is in progress and `interrupt` was not set, or no agent is available.' }
162
+ }
163
+ }, ({ params, body }) => this.sendPrompt(params.id, body));
164
+ }
165
+
166
+ protected async createSession(request: ExternalChatSessionCreateRequest): Promise<RestResult> {
167
+ const result = await this.registry.createSession(request);
168
+ if ('created' in result) {
169
+ return RestResult.created(result.created);
170
+ }
171
+ switch (result.failure) {
172
+ case 'unknownAgent':
173
+ return RestResult.badRequest('unknown agent');
174
+ case 'noAgent':
175
+ return RestResult.conflict('no agent available');
176
+ case 'noFrontend':
177
+ return RestResult.error(503, 'no frontend connected');
178
+ case 'workspaceNotFound':
179
+ return RestResult.notFound('workspace not found');
180
+ case 'ambiguousWorkspace':
181
+ return RestResult.conflict('ambiguous workspace');
182
+ }
183
+ }
184
+
185
+ protected async sendPrompt(sessionId: string, prompt: ExternalChatPrompt): Promise<RestResult> {
186
+ const result = await this.registry.sendPrompt(sessionId, prompt);
187
+ if (!result) {
188
+ return RestResult.notFound();
189
+ }
190
+ if ('sent' in result) {
191
+ return RestResult.accepted(result.sent);
192
+ }
193
+ return RestResult.conflict(result.failure === 'busy' ? 'busy' : 'no agent available');
194
+ }
195
+ }