@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,241 @@
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 { promises as fs } from 'fs';
18
+ import { join } from 'path';
19
+ import { inject, injectable, named, preDestroy } from '@theia/core/shared/inversify';
20
+ import { ILogger, nls, URI } from '@theia/core';
21
+ import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
22
+ import { FileUri } from '@theia/core/lib/common/file-uri';
23
+ import type { CopilotClient, CopilotClientOptions } from './copilot-sdk-types';
24
+ import { CopilotCredentials, CopilotCredentialStore } from './copilot-credential-store';
25
+ import { CopilotCliLocator } from './copilot-cli-locator';
26
+ import { CopilotRuntimeEnv } from './copilot-runtime-env';
27
+ import { CopilotSdkApi, CopilotSdkLoader } from './copilot-sdk-loader';
28
+ import { selectSdkModelIds } from './copilot-sdk-mappers';
29
+
30
+ /**
31
+ * Manages the lifecycle of a {@link CopilotClient}, which spawns and talks to the
32
+ * official Copilot CLI over JSON-RPC.
33
+ *
34
+ * The runtime is given the token this application holds, and nothing else: credentials the CLI could
35
+ * otherwise pick up on its own, from the environment or from the sign-in of the GitHub CLI, are not a
36
+ * sign-in of this application and are deliberately kept out of reach. The token is established by
37
+ * {@link CopilotCliAuthProvider} and kept in {@link CopilotCredentialStore}.
38
+ *
39
+ * A single client is started lazily and shared for the lifetime of the (connection-scoped) backend
40
+ * container. It is recreated when the host configuration changes.
41
+ *
42
+ * Note: this provider is bound in the per-connection container, so each frontend connection runs
43
+ * its own CLI process, authenticated with the credentials of the backend host. This is not suitable
44
+ * for multi-user backend deployments, where every connected frontend would share a single identity.
45
+ */
46
+ @injectable()
47
+ export class CopilotSdkClientProvider {
48
+
49
+ @inject(CopilotCredentialStore)
50
+ protected readonly credentialStore: CopilotCredentialStore;
51
+
52
+ @inject(CopilotCliLocator)
53
+ protected readonly cliLocator: CopilotCliLocator;
54
+
55
+ @inject(CopilotSdkLoader)
56
+ protected readonly sdkLoader: CopilotSdkLoader;
57
+
58
+ @inject(EnvVariablesServer)
59
+ protected readonly envVariablesServer: EnvVariablesServer;
60
+
61
+ @inject(ILogger) @named('ai-copilot:CopilotSdkClientProvider')
62
+ protected readonly logger: ILogger;
63
+
64
+ protected clientPromise: Promise<CopilotClient> | undefined;
65
+ protected clientConfigKey: string | undefined;
66
+
67
+ /**
68
+ * Returns a started {@link CopilotClient}, creating (or recreating, if the
69
+ * configuration changed) one on demand.
70
+ */
71
+ async getClient(): Promise<CopilotClient> {
72
+ const credentials = await this.credentialStore.get();
73
+ if (!credentials) {
74
+ throw new Error(nls.localize('theia/ai/copilot/notSignedInSignInFirst', 'Not signed in to GitHub Copilot. Please sign in first.'));
75
+ }
76
+ const configKey = this.getConfigKey(credentials);
77
+ if (this.clientPromise && this.clientConfigKey === configKey) {
78
+ return this.clientPromise;
79
+ }
80
+ // First use or changed credentials: (re)create the client. Capture the
81
+ // previous client and assign synchronously so concurrent callers share
82
+ // the same promise instead of spawning multiple CLI processes.
83
+ const previous = this.clientPromise;
84
+ this.clientConfigKey = configKey;
85
+ this.clientPromise = this.recreate(configKey, credentials, previous);
86
+ return this.clientPromise;
87
+ }
88
+
89
+ /**
90
+ * Lists the model IDs available to the authenticated user via the Copilot CLI.
91
+ * Because the CLI runs under an entitled identity, this returns the full current model lineup
92
+ * rather than the baseline set that the direct REST API exposes to an unentitled OAuth app.
93
+ */
94
+ async listModelIds(): Promise<string[]> {
95
+ const client = await this.getClient();
96
+ try {
97
+ const models = await client.listModels();
98
+ return selectSdkModelIds(models);
99
+ } catch (error) {
100
+ throw this.enrichEntitlementError(error);
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Replaces the bare authorization failure of the Copilot runtime with an explanation of the
106
+ * possible causes, because the runtime only reports that the feature is not authorized.
107
+ */
108
+ protected enrichEntitlementError(error: unknown): unknown {
109
+ const message = error instanceof Error ? error.message : String(error);
110
+ if (!message.includes('not authorized to use this Copilot feature') && !message.includes('Access denied by policy settings')) {
111
+ return error;
112
+ }
113
+ return new Error(nls.localize('theia/ai/copilot/notAuthorized',
114
+ 'GitHub Copilot rejected the request as not authorized for this account. Either the account has no Copilot subscription, or '
115
+ + 'access has to be enabled by an administrator through an organization or enterprise policy. Signing out and in again also '
116
+ + 'renews credentials that were revoked. GitHub reports requests sent to an API host that does not match the subscription the '
117
+ + 'same way, so as a last resort the host can be set explicitly with the `COPILOT_API_URL` environment variable of the backend. '
118
+ + 'Reported by the Copilot runtime: {0}', message));
119
+ }
120
+
121
+ protected getConfigKey(credentials: CopilotCredentials): string {
122
+ return `${credentials.enterpriseUrl ?? ''}|${credentials.token}`;
123
+ }
124
+
125
+ protected async recreate(configKey: string, credentials: CopilotCredentials, previous: Promise<CopilotClient> | undefined): Promise<CopilotClient> {
126
+ if (previous) {
127
+ try {
128
+ const previousClient = await previous;
129
+ await previousClient.stop();
130
+ } catch (error) {
131
+ this.logger.warn('Copilot SDK: failed to stop previous client:', error);
132
+ }
133
+ }
134
+ const sdk = await this.sdkLoader.load();
135
+ const client = new sdk.CopilotClient(this.createClientOptions(sdk, credentials, await this.cliLocator.resolve(), await this.getCopilotHome()));
136
+ try {
137
+ await client.start();
138
+ } catch (error) {
139
+ // Don't cache a failed start, otherwise every later call replays the rejection.
140
+ if (this.clientConfigKey === configKey) {
141
+ this.clientPromise = undefined;
142
+ this.clientConfigKey = undefined;
143
+ }
144
+ throw error;
145
+ }
146
+ return client;
147
+ }
148
+
149
+ /**
150
+ * Builds the options of the {@link CopilotClient}.
151
+ *
152
+ * The token of this application is passed explicitly and the resolution of a logged-in user is
153
+ * switched off, so that the runtime cannot fall back to credentials this application does not own.
154
+ *
155
+ * The CLI is passed explicitly as well, see {@link CopilotCliLocator}: the SDK would otherwise look
156
+ * for the JavaScript entry point of a platform package installed next to itself and spawn it with
157
+ * `process.execPath`, which is the Electron binary rather than Node.js in an Electron application.
158
+ *
159
+ * The runtime is configured in `empty` mode rather than in the `copilot-cli` mode the SDK defaults
160
+ * to. That mode brings the ambient behaviour of the CLI along, which the SDK documents as unsuitable
161
+ * for an application serving requests on a backend: instructions and skills found on the host,
162
+ * session and memory stores, host git operations and plugins. Theia drives the conversation itself
163
+ * and declares the tools of each request, so none of that applies, and `empty` mode additionally
164
+ * keeps the runtime away from the system keychain. Its two requirements are met here, a persistence
165
+ * location ({@link getCopilotHome}) and an explicit tool list per session.
166
+ */
167
+ protected createClientOptions(sdk: CopilotSdkApi, credentials: CopilotCredentials, cliPath: string, baseDirectory: string): CopilotClientOptions {
168
+ return {
169
+ connection: sdk.RuntimeConnection.forStdio({ path: cliPath }),
170
+ mode: 'empty',
171
+ baseDirectory,
172
+ gitHubToken: credentials.token,
173
+ useLoggedInUser: false,
174
+ logLevel: 'error',
175
+ env: this.createRuntimeEnv(credentials)
176
+ };
177
+ }
178
+
179
+ /**
180
+ * The Copilot home the runtime is pointed at, below the configuration directory of Theia.
181
+ *
182
+ * Requests are served in sessions that the runtime persists in its home, and the default home is
183
+ * the one the user's own Copilot CLI uses. Prompts sent from Theia would end up listed among the
184
+ * conversations the user started themselves, so this application keeps its own.
185
+ */
186
+ protected async getCopilotHome(): Promise<string> {
187
+ const configDirectory = FileUri.fsPath(new URI(await this.envVariablesServer.getConfigDirUri()));
188
+ const home = join(configDirectory, 'copilot');
189
+ await fs.mkdir(home, { recursive: true });
190
+ return home;
191
+ }
192
+
193
+ /**
194
+ * Returns the environment for the runtime process.
195
+ *
196
+ * Tokens from the environment are removed: the runtime accepts them even when a token is passed
197
+ * explicitly, and they are not a sign-in of this application. The GitHub host is taken from the
198
+ * credentials, so that the runtime always talks to the deployment that was signed in to.
199
+ *
200
+ * The Copilot API endpoint is deliberately not set: the runtime resolves the one belonging to the
201
+ * subscription from the token, including for Business and Enterprise seats. The environment of the
202
+ * backend is inherited as-is, so an operator can still override it with `COPILOT_API_URL` should
203
+ * that resolution ever fail for a deployment.
204
+ */
205
+ protected createRuntimeEnv(credentials: CopilotCredentials): Record<string, string | undefined> {
206
+ return {
207
+ ...CopilotRuntimeEnv.withoutTokens(process.env),
208
+ ...(credentials.enterpriseUrl ? { COPILOT_GH_HOST: credentials.enterpriseUrl } : {})
209
+ };
210
+ }
211
+
212
+ /**
213
+ * Stops and clears the current client. Safe to call when no client exists.
214
+ */
215
+ async reset(): Promise<void> {
216
+ const previous = this.clientPromise;
217
+ this.clientPromise = undefined;
218
+ this.clientConfigKey = undefined;
219
+ if (previous) {
220
+ try {
221
+ const client = await previous;
222
+ await client.stop();
223
+ } catch (error) {
224
+ this.logger.warn('Copilot SDK: failed to stop client during reset:', error);
225
+ }
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Stops the CLI when the container this provider lives in goes away, which happens when the
231
+ * frontend connection it belongs to is closed.
232
+ *
233
+ * A closing connection unbinds its container, and unbinding runs `@preDestroy` rather than
234
+ * `dispose`. Without this hook the CLI of every closed connection would keep running for the rest
235
+ * of the lifetime of the backend.
236
+ */
237
+ @preDestroy()
238
+ protected async stop(): Promise<void> {
239
+ await this.reset();
240
+ }
241
+ }
@@ -0,0 +1,363 @@
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 { LanguageModelStreamResponsePart, ToolCallResponsePart, ToolRequest, UserRequest } from '@theia/ai-core';
19
+ import type { CopilotClient, PermissionRequest, Tool } from './copilot-sdk-types';
20
+ import { MockLogger } from '@theia/core/lib/common/test/mock-logger';
21
+ import { approveTheiaTools, CopilotSdkLanguageModel, CopilotStreamSink } from './copilot-sdk-language-model';
22
+
23
+ class TestableCopilotSdkLanguageModel extends CopilotSdkLanguageModel {
24
+ constructor() {
25
+ super('copilot/test', 'test-model', { status: 'ready' }, 3, async () => ({} as CopilotClient), new MockLogger());
26
+ }
27
+
28
+ callCreateTools(tools: ToolRequest[], sink: CopilotStreamSink): Tool[] {
29
+ return this.createTools(tools, sink);
30
+ }
31
+
32
+ /** Captures the session configuration the model would create for a request. */
33
+ async captureSessionConfig(request: UserRequest): Promise<Record<string, unknown>> {
34
+ let captured: Record<string, unknown> = {};
35
+ const client = {
36
+ createSession: async (config: Record<string, unknown>) => {
37
+ captured = config;
38
+ return { on: () => () => { }, send: async () => { }, abort: async () => { }, disconnect: async () => { } };
39
+ }
40
+ } as unknown as CopilotClient;
41
+ const model = new TestableCopilotSdkLanguageModel();
42
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
43
+ (model as any).clientProvider = async () => client;
44
+ await model.request(request);
45
+ return captured;
46
+ }
47
+
48
+ /**
49
+ * Runs a request against a session that reports itself idle as soon as it was sent to, and
50
+ * reports what the session was configured with, what it was sent, and what was discarded.
51
+ */
52
+ async captureTurn(request: UserRequest): Promise<{ config: Record<string, unknown>, prompt?: string, deleted?: string }> {
53
+ let config: Record<string, unknown> = {};
54
+ let prompt: string | undefined;
55
+ let deleted: string | undefined;
56
+ const listeners = new Map<string, (event: unknown) => void>();
57
+ const session = {
58
+ sessionId: 'test-session',
59
+ on: (event: string, handler: (payload: unknown) => void) => {
60
+ listeners.set(event, handler);
61
+ return () => listeners.delete(event);
62
+ },
63
+ send: async (message: { prompt: string }) => {
64
+ prompt = message.prompt;
65
+ listeners.get('session.idle')?.({});
66
+ },
67
+ abort: async () => { },
68
+ disconnect: async () => { }
69
+ };
70
+ const client = {
71
+ createSession: async (sessionConfig: Record<string, unknown>) => {
72
+ config = sessionConfig;
73
+ return session;
74
+ },
75
+ deleteSession: async (sessionId: string) => { deleted = sessionId; }
76
+ } as unknown as CopilotClient;
77
+ const model = new TestableCopilotSdkLanguageModel();
78
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
79
+ (model as any).clientProvider = async () => client;
80
+ const response = await model.request(request) as { stream: AsyncIterable<LanguageModelStreamResponsePart> };
81
+ for await (const part of response.stream) {
82
+ expect(part).to.exist;
83
+ }
84
+ return { config, prompt, deleted };
85
+ }
86
+ }
87
+
88
+ function toolRequest(name: string, handler: (args: string, ctx?: { toolCallId?: string }) => Promise<string>): ToolRequest {
89
+ return {
90
+ id: name,
91
+ name,
92
+ description: `the ${name} tool`,
93
+ parameters: { type: 'object', properties: { path: { type: 'string' } } },
94
+ handler
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Runs a tool invocation the way the caller does, with the stream being consumed concurrently.
100
+ * The handler waits for its reported call to be consumed, so nothing may invoke it in isolation.
101
+ */
102
+ async function invokeWhileConsuming(
103
+ sink: CopilotStreamSink,
104
+ invoke: () => Promise<unknown> | unknown
105
+ ): Promise<{ result: unknown, parts: LanguageModelStreamResponsePart[] }> {
106
+ const parts: LanguageModelStreamResponsePart[] = [];
107
+ const consumer = (async () => {
108
+ for await (const part of sink.drain()) {
109
+ parts.push(part);
110
+ }
111
+ })();
112
+ const result = await invoke();
113
+ sink.finish();
114
+ await consumer;
115
+ return { result, parts };
116
+ }
117
+
118
+ async function collect(sink: CopilotStreamSink): Promise<LanguageModelStreamResponsePart[]> {
119
+ const parts: LanguageModelStreamResponsePart[] = [];
120
+ for await (const part of sink.drain()) {
121
+ parts.push(part);
122
+ }
123
+ return parts;
124
+ }
125
+
126
+ function toolCalls(part: LanguageModelStreamResponsePart): ToolCallResponsePart['tool_calls'] {
127
+ return (part as ToolCallResponsePart).tool_calls;
128
+ }
129
+
130
+ describe('CopilotSdkLanguageModel - createTools', () => {
131
+
132
+ const model = new TestableCopilotSdkLanguageModel();
133
+
134
+ it('should declare the tools of the request to the CLI', () => {
135
+ const tools = model.callCreateTools([toolRequest('readFile', async () => 'content')], new CopilotStreamSink());
136
+ expect(tools).to.have.lengthOf(1);
137
+ expect(tools[0].name).to.equal('readFile');
138
+ expect(tools[0].description).to.equal('the readFile tool');
139
+ expect(tools[0].parameters).to.deep.equal({ type: 'object', properties: { path: { type: 'string' } } });
140
+ // Theia confirms tool invocations itself, so the CLI must not prompt again.
141
+ expect(tools[0].skipPermission).to.be.true;
142
+ });
143
+
144
+ it('should pass the raw argument string to the handler of the request', async () => {
145
+ let received: string | undefined;
146
+ const sink = new CopilotStreamSink();
147
+ const tools = model.callCreateTools([toolRequest('readFile', async args => { received = args; return 'content'; })], sink);
148
+
149
+ const { result } = await invokeWhileConsuming(sink, () =>
150
+ tools[0].handler!({ path: 'a.ts' }, { sessionId: 's', toolCallId: 'call_1', toolName: 'readFile', arguments: {} }));
151
+
152
+ expect(received).to.equal(JSON.stringify({ path: 'a.ts' }));
153
+ expect(result).to.equal('content');
154
+ });
155
+
156
+ it('should report the invocation as an unfinished and then a finished tool call', async () => {
157
+ const sink = new CopilotStreamSink();
158
+ const tools = model.callCreateTools([toolRequest('readFile', async () => 'content')], sink);
159
+
160
+ const { parts } = await invokeWhileConsuming(sink, () =>
161
+ tools[0].handler!({ path: 'a.ts' }, { sessionId: 's', toolCallId: 'call_1', toolName: 'readFile', arguments: {} }));
162
+
163
+ expect(parts).to.have.lengthOf(2);
164
+ expect(toolCalls(parts[0])[0]).to.deep.include({ id: 'call_1', finished: false });
165
+ expect(toolCalls(parts[0])[0].function).to.deep.equal({ name: 'readFile', arguments: JSON.stringify({ path: 'a.ts' }) });
166
+ expect(toolCalls(parts[1])[0]).to.deep.include({ id: 'call_1', finished: true, result: 'content' });
167
+ });
168
+
169
+ it('should report a failing tool to the stream and to the agent instead of throwing', async () => {
170
+ const sink = new CopilotStreamSink();
171
+ const tools = model.callCreateTools([toolRequest('readFile', async () => { throw new Error('no such file'); })], sink);
172
+
173
+ const { result, parts } = await invokeWhileConsuming(sink, () =>
174
+ tools[0].handler!({ path: 'missing.ts' }, { sessionId: 's', toolCallId: 'call_1', toolName: 'readFile', arguments: {} }));
175
+
176
+ expect(result).to.deep.equal({ error: 'no such file' });
177
+ expect(toolCalls(parts[1])[0]).to.deep.include({ id: 'call_1', finished: true, result: 'Error: no such file' });
178
+ });
179
+
180
+ it('should return an empty tool list when the request has no tools', () => {
181
+ expect(model.callCreateTools([], new CopilotStreamSink())).to.be.empty;
182
+ });
183
+ });
184
+
185
+ describe('CopilotSdkLanguageModel - session configuration', () => {
186
+
187
+ const model = new TestableCopilotSdkLanguageModel();
188
+
189
+ function requestWith(tools: ToolRequest[]): UserRequest {
190
+ return { messages: [{ actor: 'user', type: 'text', text: 'hi' }], tools } as unknown as UserRequest;
191
+ }
192
+
193
+ it('should enable the declared tools and nothing else', async () => {
194
+ const config = await model.captureSessionConfig(requestWith([toolRequest('readFile', async () => 'content')]));
195
+ // An empty list would match nothing and would disable the declared tools as well.
196
+ expect(config.availableTools).to.deep.equal(['custom:*']);
197
+ expect((config.tools as Tool[]).map(tool => tool.name)).to.deep.equal(['readFile']);
198
+ });
199
+
200
+ it('should declare every tool of the request', async () => {
201
+ const config = await model.captureSessionConfig(requestWith([
202
+ toolRequest('readFile', async () => 'a'),
203
+ toolRequest('listFiles', async () => 'b')
204
+ ]));
205
+ expect((config.tools as Tool[]).map(tool => tool.name)).to.deep.equal(['readFile', 'listFiles']);
206
+ });
207
+
208
+ it('should still exclude the built-in tools when the request has none', async () => {
209
+ const config = await model.captureSessionConfig(requestWith([]));
210
+ expect(config.availableTools).to.deep.equal(['custom:*']);
211
+ expect(config.tools).to.be.empty;
212
+ });
213
+ });
214
+
215
+ describe('approveTheiaTools', () => {
216
+
217
+ const customToolRequest = { kind: 'custom-tool', toolName: 'readFile' } as unknown as PermissionRequest;
218
+
219
+ it('should approve a request for a tool Theia declared and confirmed itself', () => {
220
+ expect(approveTheiaTools(customToolRequest, { sessionId: 'test-session' })).to.deep.equal({ kind: 'approve-once' });
221
+ });
222
+
223
+ it('should report that nobody can confirm a request managed policy reserves for a decision', () => {
224
+ const request = { ...customToolRequest, managedApprovalRequired: true } as unknown as PermissionRequest;
225
+ // Not `no-result`: that leaves the request pending for another client, and there is none here.
226
+ expect(approveTheiaTools(request, { sessionId: 'test-session' })).to.deep.equal({ kind: 'user-not-available' });
227
+ });
228
+
229
+ it('should report the same when the machine has managed settings enabled at all', () => {
230
+ expect(approveTheiaTools(customToolRequest, { sessionId: 'test-session', managedSettingsEnabled: true }))
231
+ .to.deep.equal({ kind: 'user-not-available' });
232
+ });
233
+ });
234
+
235
+ describe('CopilotSdkLanguageModel - turn', () => {
236
+
237
+ const model = new TestableCopilotSdkLanguageModel();
238
+
239
+ const request = {
240
+ messages: [
241
+ { actor: 'system', type: 'text', text: 'You are a helpful assistant.' },
242
+ { actor: 'user', type: 'text', text: 'hi' }
243
+ ]
244
+ } as unknown as UserRequest;
245
+
246
+ it('should configure the system prompt as the system message of the session', async () => {
247
+ const { config } = await model.captureTurn(request);
248
+ expect(config.systemMessage).to.deep.include({ mode: 'customize', content: 'You are a helpful assistant.' });
249
+ });
250
+
251
+ it('should send the user turn without the system prompt in it', async () => {
252
+ const { prompt } = await model.captureTurn(request);
253
+ expect(prompt).to.equal('hi');
254
+ });
255
+
256
+ it('should delete the session it created, which the CLI would otherwise keep', async () => {
257
+ const { deleted } = await model.captureTurn(request);
258
+ expect(deleted).to.equal('test-session');
259
+ });
260
+ });
261
+
262
+ describe('CopilotStreamSink', () => {
263
+
264
+ it('should yield parts pushed before draining', async () => {
265
+ const sink = new CopilotStreamSink();
266
+ sink.push({ content: 'a' });
267
+ sink.push({ content: 'b' });
268
+ sink.finish();
269
+ expect(await collect(sink)).to.deep.equal([{ content: 'a' }, { content: 'b' }]);
270
+ });
271
+
272
+ it('should yield parts pushed while draining', async () => {
273
+ const sink = new CopilotStreamSink();
274
+ const collected = collect(sink);
275
+ sink.push({ content: 'a' });
276
+ await Promise.resolve();
277
+ sink.push({ content: 'b' });
278
+ sink.finish();
279
+ expect(await collected).to.deep.equal([{ content: 'a' }, { content: 'b' }]);
280
+ });
281
+
282
+ it('should raise the failure after the buffered parts have been drained', async () => {
283
+ const sink = new CopilotStreamSink();
284
+ sink.push({ content: 'partial' });
285
+ sink.finish(new Error('session failed'));
286
+
287
+ const parts: LanguageModelStreamResponsePart[] = [];
288
+ let error: unknown;
289
+ try {
290
+ for await (const part of sink.drain()) {
291
+ parts.push(part);
292
+ }
293
+ } catch (caught) {
294
+ error = caught;
295
+ }
296
+
297
+ expect(parts).to.deep.equal([{ content: 'partial' }]);
298
+ expect((error as Error).message).to.equal('session failed');
299
+ });
300
+
301
+ it('should end without parts when finished immediately', async () => {
302
+ const sink = new CopilotStreamSink();
303
+ sink.finish();
304
+ expect(await collect(sink)).to.be.empty;
305
+ });
306
+ });
307
+
308
+ describe('CopilotSdkLanguageModel - tool call ordering', () => {
309
+
310
+ const model = new TestableCopilotSdkLanguageModel();
311
+ const invocation = { sessionId: 's', toolCallId: 'call_1', toolName: 'readFile', arguments: {} };
312
+
313
+ it('should report the call before running the tool, so the caller can record it first', async () => {
314
+ const sink = new CopilotStreamSink();
315
+ const observed: string[] = [];
316
+ const tools = model.callCreateTools([toolRequest('readFile', async () => {
317
+ observed.push('tool ran');
318
+ return 'content';
319
+ })], sink);
320
+
321
+ const invoked = tools[0].handler!({ path: 'a.ts' }, invocation);
322
+
323
+ // The consumer has not taken the part yet, so the tool must not have run.
324
+ expect(observed).to.be.empty;
325
+
326
+ const parts: LanguageModelStreamResponsePart[] = [];
327
+ const consumer = (async () => {
328
+ for await (const part of sink.drain()) {
329
+ parts.push(part);
330
+ observed.push('part consumed');
331
+ }
332
+ })();
333
+
334
+ await invoked;
335
+ sink.finish();
336
+ await consumer;
337
+
338
+ expect(observed[0]).to.equal('part consumed');
339
+ expect(observed).to.contain('tool ran');
340
+ expect(toolCalls(parts[0])[0]).to.deep.include({ id: 'call_1', finished: false });
341
+ });
342
+
343
+ it('should pass the call id to the handler so the caller can correlate it', async () => {
344
+ const sink = new CopilotStreamSink();
345
+ let receivedId: string | undefined;
346
+ const tools = model.callCreateTools([toolRequest('readFile', async (_args, ctx) => {
347
+ receivedId = ctx?.toolCallId;
348
+ return 'content';
349
+ })], sink);
350
+
351
+ const invoked = tools[0].handler!({ path: 'a.ts' }, invocation);
352
+ const consumer = (async () => {
353
+ for await (const part of sink.drain()) {
354
+ expect(part).to.exist;
355
+ }
356
+ })();
357
+ await invoked;
358
+ sink.finish();
359
+ await consumer;
360
+
361
+ expect(receivedId).to.equal('call_1');
362
+ });
363
+ });