@theia/ai-copilot 1.75.0 → 1.76.0-next.10

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 (150) hide show
  1. package/README.md +86 -14
  2. package/lib/browser/copilot-auth-dialog.d.ts.map +1 -1
  3. package/lib/browser/copilot-auth-dialog.js +12 -4
  4. package/lib/browser/copilot-auth-dialog.js.map +1 -1
  5. package/lib/browser/copilot-frontend-application-contribution.d.ts +15 -1
  6. package/lib/browser/copilot-frontend-application-contribution.d.ts.map +1 -1
  7. package/lib/browser/copilot-frontend-application-contribution.js +52 -24
  8. package/lib/browser/copilot-frontend-application-contribution.js.map +1 -1
  9. package/lib/common/copilot-auth-service.d.ts +27 -24
  10. package/lib/common/copilot-auth-service.d.ts.map +1 -1
  11. package/lib/common/copilot-language-models-manager.d.ts +3 -21
  12. package/lib/common/copilot-language-models-manager.d.ts.map +1 -1
  13. package/lib/common/copilot-language-models-manager.js +1 -13
  14. package/lib/common/copilot-language-models-manager.js.map +1 -1
  15. package/lib/common/copilot-preferences.d.ts +1 -0
  16. package/lib/common/copilot-preferences.d.ts.map +1 -1
  17. package/lib/common/copilot-preferences.js +24 -6
  18. package/lib/common/copilot-preferences.js.map +1 -1
  19. package/lib/common/index.d.ts +0 -1
  20. package/lib/common/index.d.ts.map +1 -1
  21. package/lib/common/index.js +0 -1
  22. package/lib/common/index.js.map +1 -1
  23. package/lib/node/copilot-auth-service-impl.d.ts +25 -16
  24. package/lib/node/copilot-auth-service-impl.d.ts.map +1 -1
  25. package/lib/node/copilot-auth-service-impl.js +67 -183
  26. package/lib/node/copilot-auth-service-impl.js.map +1 -1
  27. package/lib/node/copilot-auth-service-impl.spec.js +104 -168
  28. package/lib/node/copilot-auth-service-impl.spec.js.map +1 -1
  29. package/lib/node/copilot-backend-module.d.ts.map +1 -1
  30. package/lib/node/copilot-backend-module.js +10 -2
  31. package/lib/node/copilot-backend-module.js.map +1 -1
  32. package/lib/node/copilot-cli-auth-provider.d.ts +146 -0
  33. package/lib/node/copilot-cli-auth-provider.d.ts.map +1 -0
  34. package/lib/node/copilot-cli-auth-provider.js +430 -0
  35. package/lib/node/copilot-cli-auth-provider.js.map +1 -0
  36. package/lib/node/copilot-cli-auth-provider.spec.d.ts +2 -0
  37. package/lib/node/copilot-cli-auth-provider.spec.d.ts.map +1 -0
  38. package/lib/node/copilot-cli-auth-provider.spec.js +249 -0
  39. package/lib/node/copilot-cli-auth-provider.spec.js.map +1 -0
  40. package/lib/node/copilot-cli-locator.d.ts +120 -0
  41. package/lib/node/copilot-cli-locator.d.ts.map +1 -0
  42. package/lib/node/copilot-cli-locator.js +348 -0
  43. package/lib/node/copilot-cli-locator.js.map +1 -0
  44. package/lib/node/copilot-cli-locator.spec.d.ts +2 -0
  45. package/lib/node/copilot-cli-locator.spec.d.ts.map +1 -0
  46. package/lib/node/copilot-cli-locator.spec.js +327 -0
  47. package/lib/node/copilot-cli-locator.spec.js.map +1 -0
  48. package/lib/node/copilot-credential-store.d.ts +45 -0
  49. package/lib/node/copilot-credential-store.d.ts.map +1 -0
  50. package/lib/node/copilot-credential-store.js +96 -0
  51. package/lib/node/copilot-credential-store.js.map +1 -0
  52. package/lib/node/copilot-credential-store.spec.d.ts +2 -0
  53. package/lib/node/copilot-credential-store.spec.d.ts.map +1 -0
  54. package/lib/node/copilot-credential-store.spec.js +93 -0
  55. package/lib/node/copilot-credential-store.spec.js.map +1 -0
  56. package/lib/node/copilot-language-models-manager-impl.d.ts +20 -18
  57. package/lib/node/copilot-language-models-manager-impl.d.ts.map +1 -1
  58. package/lib/node/copilot-language-models-manager-impl.js +39 -67
  59. package/lib/node/copilot-language-models-manager-impl.js.map +1 -1
  60. package/lib/node/copilot-language-models-manager-impl.spec.d.ts +2 -0
  61. package/lib/node/copilot-language-models-manager-impl.spec.d.ts.map +1 -0
  62. package/lib/node/copilot-language-models-manager-impl.spec.js +119 -0
  63. package/lib/node/copilot-language-models-manager-impl.spec.js.map +1 -0
  64. package/lib/node/copilot-runtime-env.d.ts +20 -0
  65. package/lib/node/copilot-runtime-env.d.ts.map +1 -0
  66. package/lib/node/copilot-runtime-env.js +49 -0
  67. package/lib/node/copilot-runtime-env.js.map +1 -0
  68. package/lib/node/copilot-sdk-client-provider.d.ts +103 -0
  69. package/lib/node/copilot-sdk-client-provider.d.ts.map +1 -0
  70. package/lib/node/copilot-sdk-client-provider.js +247 -0
  71. package/lib/node/copilot-sdk-client-provider.js.map +1 -0
  72. package/lib/node/copilot-sdk-client-provider.spec.d.ts +2 -0
  73. package/lib/node/copilot-sdk-client-provider.spec.d.ts.map +1 -0
  74. package/lib/node/copilot-sdk-client-provider.spec.js +215 -0
  75. package/lib/node/copilot-sdk-client-provider.spec.js.map +1 -0
  76. package/lib/node/copilot-sdk-language-model.d.ts +85 -0
  77. package/lib/node/copilot-sdk-language-model.d.ts.map +1 -0
  78. package/lib/node/copilot-sdk-language-model.js +301 -0
  79. package/lib/node/copilot-sdk-language-model.js.map +1 -0
  80. package/lib/node/copilot-sdk-language-model.spec.d.ts +2 -0
  81. package/lib/node/copilot-sdk-language-model.spec.d.ts.map +1 -0
  82. package/lib/node/copilot-sdk-language-model.spec.js +302 -0
  83. package/lib/node/copilot-sdk-language-model.spec.js.map +1 -0
  84. package/lib/node/copilot-sdk-loader.d.ts +57 -0
  85. package/lib/node/copilot-sdk-loader.d.ts.map +1 -0
  86. package/lib/node/copilot-sdk-loader.js +133 -0
  87. package/lib/node/copilot-sdk-loader.js.map +1 -0
  88. package/lib/node/copilot-sdk-loader.spec.d.ts +2 -0
  89. package/lib/node/copilot-sdk-loader.spec.d.ts.map +1 -0
  90. package/lib/node/copilot-sdk-loader.spec.js +113 -0
  91. package/lib/node/copilot-sdk-loader.spec.js.map +1 -0
  92. package/lib/node/copilot-sdk-mappers.d.ts +49 -0
  93. package/lib/node/copilot-sdk-mappers.d.ts.map +1 -0
  94. package/lib/node/copilot-sdk-mappers.js +147 -0
  95. package/lib/node/copilot-sdk-mappers.js.map +1 -0
  96. package/lib/node/copilot-sdk-mappers.spec.d.ts +2 -0
  97. package/lib/node/copilot-sdk-mappers.spec.d.ts.map +1 -0
  98. package/lib/node/copilot-sdk-mappers.spec.js +133 -0
  99. package/lib/node/copilot-sdk-mappers.spec.js.map +1 -0
  100. package/lib/node/copilot-sdk-types.d.ts +198 -0
  101. package/lib/node/copilot-sdk-types.d.ts.map +1 -0
  102. package/lib/{common/copilot-oauth-config.js → node/copilot-sdk-types.js} +2 -13
  103. package/lib/node/copilot-sdk-types.js.map +1 -0
  104. package/lib/node/index.d.ts +9 -1
  105. package/lib/node/index.d.ts.map +1 -1
  106. package/lib/node/index.js +9 -1
  107. package/lib/node/index.js.map +1 -1
  108. package/package.json +4 -6
  109. package/src/browser/copilot-auth-dialog.tsx +12 -8
  110. package/src/browser/copilot-frontend-application-contribution.ts +62 -29
  111. package/src/common/copilot-auth-service.ts +28 -24
  112. package/src/common/copilot-language-models-manager.ts +3 -28
  113. package/src/common/copilot-preferences.ts +24 -5
  114. package/src/common/index.ts +0 -1
  115. package/src/node/copilot-auth-service-impl.spec.ts +122 -207
  116. package/src/node/copilot-auth-service-impl.ts +63 -218
  117. package/src/node/copilot-backend-module.ts +11 -2
  118. package/src/node/copilot-cli-auth-provider.spec.ts +315 -0
  119. package/src/node/copilot-cli-auth-provider.ts +444 -0
  120. package/src/node/copilot-cli-locator.spec.ts +386 -0
  121. package/src/node/copilot-cli-locator.ts +363 -0
  122. package/src/node/copilot-credential-store.spec.ts +106 -0
  123. package/src/node/copilot-credential-store.ts +99 -0
  124. package/src/node/copilot-language-models-manager-impl.spec.ts +137 -0
  125. package/src/node/copilot-language-models-manager-impl.ts +51 -85
  126. package/src/node/copilot-runtime-env.ts +46 -0
  127. package/src/node/copilot-sdk-client-provider.spec.ts +253 -0
  128. package/src/node/copilot-sdk-client-provider.ts +241 -0
  129. package/src/node/copilot-sdk-language-model.spec.ts +363 -0
  130. package/src/node/copilot-sdk-language-model.ts +329 -0
  131. package/src/node/copilot-sdk-loader.spec.ts +126 -0
  132. package/src/node/copilot-sdk-loader.ts +142 -0
  133. package/src/node/copilot-sdk-mappers.spec.ts +151 -0
  134. package/src/node/copilot-sdk-mappers.ts +161 -0
  135. package/src/node/copilot-sdk-types.ts +276 -0
  136. package/src/node/index.ts +9 -1
  137. package/lib/common/copilot-oauth-config.d.ts +0 -33
  138. package/lib/common/copilot-oauth-config.d.ts.map +0 -1
  139. package/lib/common/copilot-oauth-config.js.map +0 -1
  140. package/lib/node/copilot-language-model.d.ts +0 -34
  141. package/lib/node/copilot-language-model.d.ts.map +0 -1
  142. package/lib/node/copilot-language-model.js +0 -236
  143. package/lib/node/copilot-language-model.js.map +0 -1
  144. package/lib/node/copilot-language-model.spec.d.ts +0 -2
  145. package/lib/node/copilot-language-model.spec.d.ts.map +0 -1
  146. package/lib/node/copilot-language-model.spec.js +0 -89
  147. package/lib/node/copilot-language-model.spec.js.map +0 -1
  148. package/src/common/copilot-oauth-config.ts +0 -55
  149. package/src/node/copilot-language-model.spec.ts +0 -106
  150. package/src/node/copilot-language-model.ts +0 -273
@@ -0,0 +1,329 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH and others.
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 {
18
+ formatToolCallContentForModel,
19
+ LanguageModel,
20
+ LanguageModelResponse,
21
+ LanguageModelStatus,
22
+ LanguageModelStreamResponsePart,
23
+ ToolRequest,
24
+ UserRequest
25
+ } from '@theia/ai-core';
26
+ import { CancellationToken, ILogger } from '@theia/core';
27
+ import type { CopilotClient, CopilotSession, PermissionHandler, Tool } from './copilot-sdk-types';
28
+ import { buildSdkPrompt, buildSdkSystemMessage } from './copilot-sdk-mappers';
29
+
30
+ /**
31
+ * Answers the permission requests of the CLI for the tools of a request.
32
+ *
33
+ * Only Theia's own tools are declared to the CLI, and those are gated by Theia's tool confirmation
34
+ * already, so a second confirmation by the CLI would ask the user the same question twice. This is
35
+ * the behaviour of the SDK's own `approveAll`, which cannot be used here because it is not imported
36
+ * at runtime.
37
+ *
38
+ * A request that managed policy reserves for an explicit decision is not approved on the user's
39
+ * behalf but denied as unconfirmable. Leaving it unanswered is what the SDK's `no-result` does, and
40
+ * it is only correct when another client is connected that can answer it; here it would leave the
41
+ * turn pending until the request is cancelled.
42
+ */
43
+ export const approveTheiaTools: PermissionHandler = (request, invocation) => {
44
+ if (invocation.managedSettingsEnabled || ('managedApprovalRequired' in request && request.managedApprovalRequired)) {
45
+ return { kind: 'user-not-available' };
46
+ }
47
+ return { kind: 'approve-once' };
48
+ };
49
+
50
+ /**
51
+ * Upper bound for awaiting that a reported tool call has been consumed before the tool is run.
52
+ */
53
+ const TOOL_CALL_FLUSH_TIMEOUT_MS = 2000;
54
+
55
+ /**
56
+ * Collects the stream parts produced by the session events and by the tool handlers, and hands them
57
+ * to the consuming generator as they arrive.
58
+ *
59
+ * A buffer is needed because the parts originate from callbacks rather than from a stream we can
60
+ * await, and because the tool handlers of a turn run while its response is still being produced.
61
+ */
62
+ export class CopilotStreamSink {
63
+
64
+ protected readonly queue: LanguageModelStreamResponsePart[] = [];
65
+ protected readonly flushWaiters: Array<() => void> = [];
66
+ protected notify: (() => void) | undefined;
67
+ protected done = false;
68
+ protected failure: unknown;
69
+
70
+ push(part: LanguageModelStreamResponsePart): void {
71
+ this.queue.push(part);
72
+ this.wake();
73
+ }
74
+
75
+ /**
76
+ * Resolves once everything pushed so far has been taken by the consumer.
77
+ *
78
+ * Needed because a tool call has to be reported before the tool runs: the caller creates the
79
+ * record of the call from the stream, and the tool handler then completes that record. Handing the
80
+ * part over and invoking the handler in the same turn would leave the handler with nothing to
81
+ * complete.
82
+ */
83
+ flush(): Promise<void> {
84
+ if (this.queue.length === 0 || this.done) {
85
+ return Promise.resolve();
86
+ }
87
+ return new Promise<void>(resolve => this.flushWaiters.push(resolve));
88
+ }
89
+
90
+ /**
91
+ * Ends the stream. A given error is raised to the consumer once the buffered parts are drained.
92
+ */
93
+ finish(error?: unknown): void {
94
+ if (error !== undefined) {
95
+ this.failure = error;
96
+ }
97
+ this.done = true;
98
+ this.wake();
99
+ }
100
+
101
+ async *drain(): AsyncIterable<LanguageModelStreamResponsePart> {
102
+ while (true) {
103
+ while (this.queue.length > 0) {
104
+ yield this.queue.shift()!;
105
+ }
106
+ // Asking for the next part means the consumer is done with the previous ones.
107
+ this.releaseFlushWaiters();
108
+ if (this.done) {
109
+ break;
110
+ }
111
+ await new Promise<void>(resolve => { this.notify = resolve; });
112
+ }
113
+ this.releaseFlushWaiters();
114
+ if (this.failure !== undefined) {
115
+ throw this.failure;
116
+ }
117
+ }
118
+
119
+ protected wake(): void {
120
+ const resolve = this.notify;
121
+ this.notify = undefined;
122
+ resolve?.();
123
+ }
124
+
125
+ protected releaseFlushWaiters(): void {
126
+ const waiters = this.flushWaiters.splice(0, this.flushWaiters.length);
127
+ for (const resolve of waiters) {
128
+ resolve();
129
+ }
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Language model implementation for GitHub Copilot backed by the official Copilot
135
+ * CLI via `@github/copilot-sdk`.
136
+ *
137
+ * The CLI is an agent that owns the tool-calling loop, whereas Theia's language model contract
138
+ * expects the caller to drive it. The two are reconciled by handing the tools of the request to the
139
+ * CLI as custom tools whose handlers delegate back to Theia, and by reporting every invocation as a
140
+ * tool call part, so that the conversation and its tool calls are recorded and rendered as usual.
141
+ * None of the CLI's own tools are exposed, so only what Theia provides can run.
142
+ *
143
+ * Structured output is not supported on this path.
144
+ */
145
+ export class CopilotSdkLanguageModel implements LanguageModel {
146
+
147
+ constructor(
148
+ public readonly id: string,
149
+ public model: string,
150
+ public status: LanguageModelStatus,
151
+ public maxRetries: number,
152
+ protected readonly clientProvider: () => Promise<CopilotClient>,
153
+ protected readonly logger: ILogger,
154
+ ) { }
155
+
156
+ async request(request: UserRequest, cancellationToken?: CancellationToken): Promise<LanguageModelResponse> {
157
+ const client = await this.clientProvider();
158
+ const { systemText, prompt } = buildSdkPrompt(request.messages);
159
+ const sink = new CopilotStreamSink();
160
+
161
+ const tools = this.createTools(request.tools ?? [], sink);
162
+ this.logger.debug(`Copilot: request with ${tools.length} tools [${tools.map(tool => tool.name).join(', ')}]`);
163
+
164
+ const session = await client.createSession({
165
+ model: this.model,
166
+ streaming: true,
167
+ tools,
168
+ // The prompt of the Theia agent belongs into the system message of the session rather than
169
+ // into its user prompt, so that it takes the place of the agent instructions of the CLI.
170
+ systemMessage: buildSdkSystemMessage(systemText),
171
+ // Enable exactly the tools declared above and nothing else: `custom:*` matches the tools of
172
+ // this request, while the built-in tools of the CLI and any MCP tools stay disabled, so that
173
+ // Theia remains in control of what runs on the host. An empty list would match nothing and
174
+ // would disable the declared tools as well.
175
+ availableTools: ['custom:*'],
176
+ // The declared tools are Theia's own and are already gated by its tool confirmation.
177
+ onPermissionRequest: approveTheiaTools
178
+ });
179
+
180
+ return { stream: this.streamResponse(client, session, prompt, sink, cancellationToken) };
181
+ }
182
+
183
+ /**
184
+ * Maps the tools of the request onto tools of the CLI, reporting each invocation to the stream.
185
+ */
186
+ protected createTools(tools: ToolRequest[], sink: CopilotStreamSink): Tool[] {
187
+ return tools.map(tool => ({
188
+ name: tool.name,
189
+ description: tool.description,
190
+ parameters: tool.parameters as unknown as Record<string, unknown>,
191
+ // Theia confirms tool invocations itself, so the CLI must not prompt on top of that.
192
+ skipPermission: true,
193
+ handler: async (args: unknown, invocation: { toolCallId: string }) => {
194
+ // Theia's tool handlers take the raw argument string of the model.
195
+ const argumentsString = JSON.stringify(args ?? {});
196
+ sink.push({
197
+ tool_calls: [{ id: invocation.toolCallId, function: { name: tool.name, arguments: argumentsString }, finished: false }]
198
+ });
199
+ // The handler completes the record of the call that the caller creates from the part
200
+ // above, so the part has to be consumed before the tool runs. The wait is bounded so
201
+ // that a consumer which stops reading cannot hold the tool call forever.
202
+ await Promise.race([sink.flush(), this.delay(TOOL_CALL_FLUSH_TIMEOUT_MS)]);
203
+ try {
204
+ // Passing the call id lets the caller correlate the invocation with the part above,
205
+ // instead of having to fall back to matching on the tool name and its arguments.
206
+ const result = await tool.handler(argumentsString, { toolCallId: invocation.toolCallId });
207
+ // The structured result is kept for the stream, so that the UI can render e.g. the
208
+ // HTML of an MCP app, while the model is handed a readable serialization of it.
209
+ sink.push({
210
+ tool_calls: [{ id: invocation.toolCallId, function: { name: tool.name, arguments: argumentsString }, finished: true, result }]
211
+ });
212
+ return formatToolCallContentForModel(result);
213
+ } catch (error) {
214
+ const message = error instanceof Error ? error.message : String(error);
215
+ sink.push({
216
+ tool_calls: [{
217
+ id: invocation.toolCallId,
218
+ function: { name: tool.name, arguments: argumentsString },
219
+ finished: true,
220
+ result: `Error: ${message}`
221
+ }]
222
+ });
223
+ // Report the failure to the agent instead of failing the turn, so that it can react.
224
+ return { error: message };
225
+ }
226
+ }
227
+ }));
228
+ }
229
+
230
+ protected delay(ms: number): Promise<void> {
231
+ return new Promise(resolve => setTimeout(resolve, ms));
232
+ }
233
+
234
+ protected async *streamResponse(
235
+ client: CopilotClient,
236
+ session: CopilotSession,
237
+ prompt: string,
238
+ sink: CopilotStreamSink,
239
+ cancellationToken?: CancellationToken
240
+ ): AsyncIterable<LanguageModelStreamResponsePart> {
241
+ let inputTokens: number | undefined;
242
+ let outputTokens: number | undefined;
243
+
244
+ const disposables: Array<() => void> = [];
245
+ disposables.push(session.on('assistant.message_delta', event => {
246
+ if (event.data.deltaContent) {
247
+ sink.push({ content: event.data.deltaContent });
248
+ }
249
+ }));
250
+ disposables.push(session.on('assistant.reasoning_delta', event => {
251
+ if (event.data.deltaContent) {
252
+ sink.push({ thought: event.data.deltaContent, signature: '' });
253
+ }
254
+ }));
255
+ disposables.push(session.on('assistant.message', event => {
256
+ if (typeof event.data.outputTokens === 'number') {
257
+ outputTokens = event.data.outputTokens;
258
+ }
259
+ }));
260
+ disposables.push(session.on('assistant.usage', event => {
261
+ if (typeof event.data.inputTokens === 'number') {
262
+ inputTokens = event.data.inputTokens;
263
+ }
264
+ if (typeof event.data.outputTokens === 'number') {
265
+ outputTokens = event.data.outputTokens;
266
+ }
267
+ }));
268
+ disposables.push(session.on('session.idle', () => sink.finish()));
269
+ disposables.push(session.on('session.error', event => {
270
+ // The CLI can report a terminal error (auth, quota, rate limit, context
271
+ // limit, ...) without a following `session.idle`. Surface it as a stream
272
+ // failure so the request rejects instead of hanging forever.
273
+ const data = event.data;
274
+ const detail = data.statusCode !== undefined ? ` (${data.errorType}, status ${data.statusCode})` : ` (${data.errorType})`;
275
+ const error = new Error(`Copilot request failed${detail}: ${data.message}`);
276
+ if (data.stack) {
277
+ error.stack = data.stack;
278
+ }
279
+ sink.finish(error);
280
+ }));
281
+
282
+ const cancelListener = cancellationToken?.onCancellationRequested(() => {
283
+ session.abort().catch(() => { /* ignore abort failures */ });
284
+ sink.finish();
285
+ });
286
+
287
+ try {
288
+ if (cancellationToken?.isCancellationRequested) {
289
+ // Already cancelled before we started: don't bother sending.
290
+ await session.abort().catch(() => { /* ignore abort failures */ });
291
+ sink.finish();
292
+ } else {
293
+ // Deliberately not awaited: the turn only completes after the tool loop has run, and
294
+ // its parts have to be yielded while that happens.
295
+ session.send({ prompt }).catch(error => sink.finish(error));
296
+ }
297
+ yield* sink.drain();
298
+ if (inputTokens !== undefined || outputTokens !== undefined) {
299
+ yield { input_tokens: inputTokens ?? 0, output_tokens: outputTokens ?? 0 };
300
+ }
301
+ } finally {
302
+ for (const dispose of disposables) {
303
+ dispose();
304
+ }
305
+ cancelListener?.dispose();
306
+ await this.discardSession(client, session);
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Ends the session of a request and removes what it persisted.
312
+ *
313
+ * Disconnecting only releases the session in memory and keeps it stored for a later resumption,
314
+ * which Theia never does: it drives the conversation itself and starts a fresh session per
315
+ * request. Without the deletion, every request would leave a session behind in the Copilot home.
316
+ */
317
+ protected async discardSession(client: CopilotClient, session: CopilotSession): Promise<void> {
318
+ try {
319
+ await session.disconnect();
320
+ } catch (error) {
321
+ this.logger.warn('Copilot SDK: failed to disconnect session:', error);
322
+ }
323
+ try {
324
+ await client.deleteSession(session.sessionId);
325
+ } catch (error) {
326
+ this.logger.warn(`Copilot SDK: failed to delete session ${session.sessionId}:`, error);
327
+ }
328
+ }
329
+ }
@@ -0,0 +1,126 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH and others.
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 { join } from 'path';
19
+ import { MockLogger } from '@theia/core/lib/common/test/mock-logger';
20
+ import { CopilotSdkLoader } from './copilot-sdk-loader';
21
+
22
+ /** What the loader checks for before accepting a module as the SDK. */
23
+ const usableSdk = {
24
+ CopilotClient: function CopilotClient(): void { },
25
+ RuntimeConnection: { forStdio: () => ({ kind: 'stdio' }) }
26
+ };
27
+
28
+ class TestableCopilotSdkLoader extends CopilotSdkLoader {
29
+
30
+ shipped: unknown;
31
+ installed: unknown;
32
+ installedFailure: Error | undefined;
33
+ readonly importedFromCli: string[] = [];
34
+
35
+ constructor(protected readonly clis: string[]) {
36
+ super();
37
+ let call = 0;
38
+ Object.assign(this, {
39
+ logger: new MockLogger(),
40
+ cliLocator: { resolve: async () => this.clis[Math.min(call++, this.clis.length - 1)] }
41
+ });
42
+ }
43
+
44
+ protected override async importShipped(entry: string): Promise<unknown | undefined> {
45
+ this.importedFromCli.push(entry);
46
+ return this.shipped;
47
+ }
48
+
49
+ protected override importInstalled(): unknown {
50
+ if (this.installedFailure) {
51
+ throw this.installedFailure;
52
+ }
53
+ return this.installed;
54
+ }
55
+ }
56
+
57
+ describe('CopilotSdkLoader', () => {
58
+
59
+ const cli = join('/opt', 'copilot', 'copilot');
60
+
61
+ it('should load the SDK that ships with the CLI', async () => {
62
+ const loader = new TestableCopilotSdkLoader([cli]);
63
+ loader.shipped = usableSdk;
64
+ expect(await loader.load()).to.equal(usableSdk);
65
+ expect(loader.importedFromCli).to.deep.equal([join('/opt', 'copilot', 'copilot-sdk', 'index.js')]);
66
+ });
67
+
68
+ it('should fall back to an installed SDK when the CLI does not carry one', async () => {
69
+ const loader = new TestableCopilotSdkLoader([cli]);
70
+ loader.installed = usableSdk;
71
+ expect(await loader.load()).to.equal(usableSdk);
72
+ });
73
+
74
+ it('should report the CLI when neither it nor an installation provides the SDK', async () => {
75
+ const loader = new TestableCopilotSdkLoader([cli]);
76
+ loader.installedFailure = new Error("Cannot find module '@github/copilot-sdk'");
77
+ try {
78
+ await loader.load();
79
+ expect.fail('should have rejected');
80
+ } catch (error) {
81
+ // The module resolution failure is of no use to the user, the CLI to update is.
82
+ expect(String(error)).to.contain(cli);
83
+ expect(String(error)).to.not.contain('Cannot find module');
84
+ }
85
+ });
86
+
87
+ it('should reject an SDK that does not provide what is used', async () => {
88
+ const loader = new TestableCopilotSdkLoader([cli]);
89
+ loader.shipped = { CopilotClient: function (): void { } };
90
+ try {
91
+ await loader.load();
92
+ expect.fail('should have rejected');
93
+ } catch (error) {
94
+ expect(String(error)).to.contain(cli);
95
+ }
96
+ });
97
+
98
+ it('should load once for as long as the CLI stays the same', async () => {
99
+ const loader = new TestableCopilotSdkLoader([cli]);
100
+ loader.shipped = usableSdk;
101
+ await loader.load();
102
+ await loader.load();
103
+ expect(loader.importedFromCli).to.have.lengthOf(1);
104
+ });
105
+
106
+ it('should load again when a different CLI serves the requests', async () => {
107
+ const other = join('/usr', 'local', 'copilot');
108
+ const loader = new TestableCopilotSdkLoader([cli, other]);
109
+ loader.shipped = usableSdk;
110
+ await loader.load();
111
+ await loader.load();
112
+ expect(loader.importedFromCli).to.deep.equal([
113
+ join('/opt', 'copilot', 'copilot-sdk', 'index.js'),
114
+ join('/usr', 'local', 'copilot-sdk', 'index.js')
115
+ ]);
116
+ });
117
+
118
+ it('should not remember a failed load, since the CLI can be replaced while running', async () => {
119
+ const loader = new TestableCopilotSdkLoader([cli]);
120
+ loader.shipped = {};
121
+ await loader.load().catch(() => { /* expected */ });
122
+ loader.shipped = usableSdk;
123
+ expect(await loader.load()).to.equal(usableSdk);
124
+ expect(loader.importedFromCli).to.have.lengthOf(2);
125
+ });
126
+ });
@@ -0,0 +1,142 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH and others.
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 { createRequire } from 'module';
18
+ import { dirname, join } from 'path';
19
+ import { pathToFileURL } from 'url';
20
+ import { inject, injectable, named } from '@theia/core/shared/inversify';
21
+ import { ILogger, nls } from '@theia/core';
22
+ import { CopilotCliLocator } from './copilot-cli-locator';
23
+ import type { CopilotClientConstructor, RuntimeConnectionFactory } from './copilot-sdk-types';
24
+
25
+ /**
26
+ * The part of the Copilot SDK this integration uses at runtime.
27
+ */
28
+ export interface CopilotSdkApi {
29
+ readonly CopilotClient: CopilotClientConstructor;
30
+ readonly RuntimeConnection: RuntimeConnectionFactory;
31
+ }
32
+
33
+ /**
34
+ * Provides the Copilot SDK that belongs to the Copilot CLI in use.
35
+ *
36
+ * The SDK is not a dependency of this extension. It is published as `@github/copilot-sdk`, but that
37
+ * package depends on the CLI, which is a large proprietary binary that an application must not have
38
+ * to ship, see {@link CopilotCliLocator}. The CLI carries its own copy of the SDK, so the one
39
+ * belonging to the CLI that is actually going to serve the requests is loaded from there. What is used
40
+ * of its API is mirrored in `copilot-sdk-types.ts` rather than taken from the package.
41
+ *
42
+ * An installed `@github/copilot-sdk` is used when the CLI does not carry one, so that an application
43
+ * which does depend on the package keeps working.
44
+ */
45
+ @injectable()
46
+ export class CopilotSdkLoader {
47
+
48
+ @inject(CopilotCliLocator)
49
+ protected readonly cliLocator: CopilotCliLocator;
50
+
51
+ @inject(ILogger) @named('ai-copilot:CopilotSdkLoader')
52
+ protected readonly logger: ILogger;
53
+
54
+ protected loaded: Promise<CopilotSdkApi> | undefined;
55
+ protected loadedFrom: string | undefined;
56
+
57
+ /**
58
+ * Loads the SDK, and remembers it for as long as the CLI it belongs to stays the same.
59
+ */
60
+ async load(): Promise<CopilotSdkApi> {
61
+ const cli = await this.cliLocator.resolve();
62
+ if (!this.loaded || this.loadedFrom !== cli) {
63
+ this.loadedFrom = cli;
64
+ this.loaded = this.doLoad(cli);
65
+ // Don't remember a failed load: a different CLI can be configured while we are running.
66
+ this.loaded.catch(() => {
67
+ if (this.loadedFrom === cli) {
68
+ this.loaded = undefined;
69
+ this.loadedFrom = undefined;
70
+ }
71
+ });
72
+ }
73
+ return this.loaded;
74
+ }
75
+
76
+ protected async doLoad(cli: string): Promise<CopilotSdkApi> {
77
+ const shipped = join(dirname(cli), 'copilot-sdk', 'index.js');
78
+ const candidate = await this.importShipped(shipped) ?? this.tryImportInstalled();
79
+ const api = this.toApi(candidate);
80
+ if (!api) {
81
+ throw new Error(nls.localize('theia/ai/copilot/sdkUnusable',
82
+ 'The GitHub Copilot CLI at {0} does not provide the interface this integration needs. '
83
+ + 'Update it with `npm install -g @github/copilot`.', cli));
84
+ }
85
+ return api;
86
+ }
87
+
88
+ /**
89
+ * Imports the copy of the SDK that ships with the CLI. It is an ES module, hence the import by URL.
90
+ */
91
+ protected async importShipped(entry: string): Promise<unknown | undefined> {
92
+ try {
93
+ const loaded = await this.dynamicImport(pathToFileURL(entry).href);
94
+ this.logger.info(`Copilot: using the Copilot SDK at ${entry}.`);
95
+ return loaded;
96
+ } catch (error) {
97
+ this.logger.info(`Copilot: no Copilot SDK next to the CLI at ${entry}, falling back to an installed one:`, error);
98
+ return undefined;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Imports an installed SDK if there is one, so that a missing package is reported as the CLI not
104
+ * providing what is needed rather than as a module that cannot be found.
105
+ */
106
+ protected tryImportInstalled(): unknown {
107
+ try {
108
+ return this.importInstalled();
109
+ } catch (error) {
110
+ this.logger.info('Copilot: no installed Copilot SDK either:', error);
111
+ return undefined;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Imports an installed `@github/copilot-sdk`, for an application that depends on the package.
117
+ */
118
+ protected importInstalled(): unknown {
119
+ // A real require is needed here: the ambient one is rewritten when the backend is bundled.
120
+ return createRequire(__filename)('@github/copilot-sdk');
121
+ }
122
+
123
+ /**
124
+ * Dynamic import through a constructed function, so that a bundler leaves it alone: what is
125
+ * imported here is resolved on the machine running the backend, not at build time.
126
+ */
127
+ protected dynamicImport(url: string): Promise<unknown> {
128
+ return new Function('url', 'return import(url)')(url);
129
+ }
130
+
131
+ /**
132
+ * Checks that what was loaded provides what is used, so that an incompatible CLI is reported as
133
+ * such instead of failing somewhere in the middle of a request.
134
+ */
135
+ protected toApi(loaded: unknown): CopilotSdkApi | undefined {
136
+ const api = loaded as CopilotSdkApi | undefined;
137
+ if (typeof api?.CopilotClient !== 'function' || typeof api?.RuntimeConnection?.forStdio !== 'function') {
138
+ return undefined;
139
+ }
140
+ return api;
141
+ }
142
+ }