@theia/ai-core 1.74.0-next.9 → 1.74.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/lib/browser/ai-configuration-service-impl.d.ts +32 -0
  2. package/lib/browser/ai-configuration-service-impl.d.ts.map +1 -0
  3. package/lib/browser/ai-configuration-service-impl.js +120 -0
  4. package/lib/browser/ai-configuration-service-impl.js.map +1 -0
  5. package/lib/browser/ai-configuration-service-impl.spec.d.ts +2 -0
  6. package/lib/browser/ai-configuration-service-impl.spec.d.ts.map +1 -0
  7. package/lib/browser/ai-configuration-service-impl.spec.js +214 -0
  8. package/lib/browser/ai-configuration-service-impl.spec.js.map +1 -0
  9. package/lib/browser/ai-core-frontend-module.d.ts.map +1 -1
  10. package/lib/browser/ai-core-frontend-module.js +4 -0
  11. package/lib/browser/ai-core-frontend-module.js.map +1 -1
  12. package/lib/browser/ai-settings-service.d.ts +2 -5
  13. package/lib/browser/ai-settings-service.d.ts.map +1 -1
  14. package/lib/browser/ai-settings-service.js +8 -15
  15. package/lib/browser/ai-settings-service.js.map +1 -1
  16. package/lib/browser/frontend-language-model-alias-registry.d.ts +2 -4
  17. package/lib/browser/frontend-language-model-alias-registry.d.ts.map +1 -1
  18. package/lib/browser/frontend-language-model-alias-registry.js +21 -26
  19. package/lib/browser/frontend-language-model-alias-registry.js.map +1 -1
  20. package/lib/browser/frontend-language-model-service.d.ts +2 -3
  21. package/lib/browser/frontend-language-model-service.d.ts.map +1 -1
  22. package/lib/browser/frontend-language-model-service.js +6 -6
  23. package/lib/browser/frontend-language-model-service.js.map +1 -1
  24. package/lib/browser/index.d.ts +1 -0
  25. package/lib/browser/index.d.ts.map +1 -1
  26. package/lib/browser/index.js +1 -0
  27. package/lib/browser/index.js.map +1 -1
  28. package/lib/browser/prompt-variable-contribution.d.ts.map +1 -1
  29. package/lib/browser/prompt-variable-contribution.js +13 -16
  30. package/lib/browser/prompt-variable-contribution.js.map +1 -1
  31. package/lib/browser/prompt-variable-contribution.spec.js +64 -2
  32. package/lib/browser/prompt-variable-contribution.spec.js.map +1 -1
  33. package/lib/browser/trust-aware-preference-reader.d.ts +13 -1
  34. package/lib/browser/trust-aware-preference-reader.d.ts.map +1 -1
  35. package/lib/browser/trust-aware-preference-reader.js +21 -2
  36. package/lib/browser/trust-aware-preference-reader.js.map +1 -1
  37. package/lib/browser/trust-aware-preference-reader.spec.js +26 -0
  38. package/lib/browser/trust-aware-preference-reader.spec.js.map +1 -1
  39. package/lib/common/ai-configuration-service.d.ts +104 -0
  40. package/lib/common/ai-configuration-service.d.ts.map +1 -0
  41. package/lib/common/ai-configuration-service.js +20 -0
  42. package/lib/common/ai-configuration-service.js.map +1 -0
  43. package/lib/common/ai-core-preferences.d.ts +4 -0
  44. package/lib/common/ai-core-preferences.d.ts.map +1 -1
  45. package/lib/common/ai-core-preferences.js +20 -1
  46. package/lib/common/ai-core-preferences.js.map +1 -1
  47. package/lib/common/index.d.ts +1 -0
  48. package/lib/common/index.d.ts.map +1 -1
  49. package/lib/common/index.js +1 -0
  50. package/lib/common/index.js.map +1 -1
  51. package/lib/common/language-model.d.ts +56 -2
  52. package/lib/common/language-model.d.ts.map +1 -1
  53. package/lib/common/language-model.js +53 -3
  54. package/lib/common/language-model.js.map +1 -1
  55. package/lib/common/language-model.spec.js +75 -0
  56. package/lib/common/language-model.spec.js.map +1 -1
  57. package/lib/common/prompt-service.d.ts +13 -0
  58. package/lib/common/prompt-service.d.ts.map +1 -1
  59. package/lib/common/prompt-service.js +8 -0
  60. package/lib/common/prompt-service.js.map +1 -1
  61. package/lib/common/prompt-service.spec.js +46 -0
  62. package/lib/common/prompt-service.spec.js.map +1 -1
  63. package/lib/node/backend-language-model-registry.d.ts.map +1 -1
  64. package/lib/node/backend-language-model-registry.js +1 -0
  65. package/lib/node/backend-language-model-registry.js.map +1 -1
  66. package/package.json +12 -12
  67. package/src/browser/ai-configuration-service-impl.spec.ts +254 -0
  68. package/src/browser/ai-configuration-service-impl.ts +120 -0
  69. package/src/browser/ai-core-frontend-module.ts +7 -1
  70. package/src/browser/ai-settings-service.ts +8 -16
  71. package/src/browser/frontend-language-model-alias-registry.ts +22 -26
  72. package/src/browser/frontend-language-model-service.ts +5 -6
  73. package/src/browser/index.ts +1 -0
  74. package/src/browser/prompt-variable-contribution.spec.ts +88 -2
  75. package/src/browser/prompt-variable-contribution.ts +13 -16
  76. package/src/browser/trust-aware-preference-reader.spec.ts +29 -0
  77. package/src/browser/trust-aware-preference-reader.ts +23 -3
  78. package/src/common/ai-configuration-service.ts +128 -0
  79. package/src/common/ai-core-preferences.ts +23 -1
  80. package/src/common/index.ts +1 -0
  81. package/src/common/language-model.spec.ts +98 -1
  82. package/src/common/language-model.ts +107 -4
  83. package/src/common/prompt-service.spec.ts +56 -0
  84. package/src/common/prompt-service.ts +22 -0
  85. package/src/node/backend-language-model-registry.ts +1 -0
@@ -18,7 +18,7 @@ import { nls } from '@theia/core/lib/common/nls';
18
18
  import { inject, injectable } from '@theia/core/shared/inversify';
19
19
  import { Prioritizeable } from '@theia/core/lib/common/prioritizeable';
20
20
  import { WorkspaceTrustService } from '@theia/workspace/lib/browser/workspace-trust-service';
21
- import { LanguageModel, LanguageModelResponse, ReasoningSettings, UserRequest } from '../common';
21
+ import { AiConfigurationService, LanguageModel, LanguageModelResponse, ReasoningSettings, UserRequest } from '../common';
22
22
  import { LanguageModelServiceImpl } from '../common/language-model-service';
23
23
  import {
24
24
  PREFERENCE_NAME_REQUEST_SETTINGS,
@@ -27,7 +27,6 @@ import {
27
27
  ReasoningPreferenceEntry,
28
28
  getRequestSettingSpecificity
29
29
  } from '../common/ai-core-preferences';
30
- import { TrustAwarePreferenceReader } from './trust-aware-preference-reader';
31
30
 
32
31
  @injectable()
33
32
  export class FrontendLanguageModelServiceImpl extends LanguageModelServiceImpl {
@@ -35,15 +34,15 @@ export class FrontendLanguageModelServiceImpl extends LanguageModelServiceImpl {
35
34
  @inject(WorkspaceTrustService)
36
35
  protected readonly workspaceTrustService: WorkspaceTrustService;
37
36
 
38
- @inject(TrustAwarePreferenceReader)
39
- protected readonly trustAwareReader: TrustAwarePreferenceReader;
37
+ @inject(AiConfigurationService)
38
+ protected readonly aiConfiguration: AiConfigurationService;
40
39
 
41
40
  override async sendRequest(
42
41
  languageModel: LanguageModel,
43
42
  languageModelRequest: UserRequest
44
43
  ): Promise<LanguageModelResponse> {
45
- const requestSettings = this.trustAwareReader.get<RequestSetting[]>(PREFERENCE_NAME_REQUEST_SETTINGS, []) ?? [];
46
- const reasoningEntries = this.trustAwareReader.get<ReasoningPreferenceEntry[]>(PREFERENCE_NAME_REASONING, []) ?? [];
44
+ const requestSettings = this.aiConfiguration.get<RequestSetting[]>(PREFERENCE_NAME_REQUEST_SETTINGS, []) ?? [];
45
+ const reasoningEntries = this.aiConfiguration.get<ReasoningPreferenceEntry[]>(PREFERENCE_NAME_REASONING, []) ?? [];
47
46
  const trusted = await this.workspaceTrustService.getWorkspaceTrust();
48
47
  if (!trusted) {
49
48
  throw new Error(nls.localize('theia/ai-core/aiDisabledInRestrictedMode', 'AI features are not available in untrusted workspaces.'));
@@ -23,6 +23,7 @@ export * from './ai-core-frontend-application-contribution';
23
23
  export * from './ai-core-frontend-module';
24
24
  export * from '../common/ai-core-preferences';
25
25
  export * from './ai-settings-service';
26
+ export * from './ai-configuration-service-impl';
26
27
  export * from './ai-view-contribution';
27
28
  export * from './frontend-language-model-registry';
28
29
  export * from './frontend-language-model-alias-registry';
@@ -225,13 +225,99 @@ describe('PromptVariableContribution', () => {
225
225
  expect(result?.variable).to.deep.equal(PROMPT_VARIABLE);
226
226
  });
227
227
 
228
- it('returns empty string for non-existent prompts', async () => {
228
+ describe('customized (file-based) commands, whose fragment id differs from the command name', () => {
229
+ const customized = {
230
+ id: 'my-file',
231
+ customizationId: 'customization-1',
232
+ priority: 1,
233
+ template: 'Deploy $ARGUMENTS now.',
234
+ isCommand: true,
235
+ commandName: 'deploy'
236
+ };
237
+
238
+ beforeEach(() => {
239
+ (promptService as unknown as Record<string, unknown>)['customizationService'] = {
240
+ isPromptFragmentCustomized: (id: string) => id === customized.id,
241
+ getActivePromptFragmentCustomization: (id: string) => (id === customized.id ? customized : undefined),
242
+ getCustomizedPromptFragmentIds: () => [customized.id]
243
+ };
244
+ });
245
+
246
+ it('resolves the command with arguments', async () => {
247
+ const result = await contribution.resolve({ variable: PROMPT_VARIABLE, arg: 'deploy|prod' }, {});
248
+ expect(result?.value).to.equal('Deploy prod now.');
249
+ });
250
+
251
+ it('resolves the command without arguments', async () => {
252
+ const result = await contribution.resolve({ variable: PROMPT_VARIABLE, arg: 'deploy' }, {});
253
+ expect(result?.value).to.equal('Deploy $ARGUMENTS now.');
254
+ });
255
+
256
+ it('is accepted by isKnownCommand for both invocations', () => {
257
+ expect(promptService.isKnownCommand('deploy')).to.be.true;
258
+ });
259
+
260
+ it('still resolves the command by its fragment id', async () => {
261
+ const result = await contribution.resolve({ variable: PROMPT_VARIABLE, arg: 'my-file' }, {});
262
+ expect(result?.value).to.equal('Deploy $ARGUMENTS now.');
263
+ });
264
+ });
265
+
266
+ // Resolving to `undefined` instead of an empty string makes callers keep the original text
267
+ // (the literal `#prompt:...` / `{{prompt:...}}` placeholder) rather than silently dropping it.
268
+ it('returns undefined for a non-existent prompt with arguments', async () => {
229
269
  const result = await contribution.resolve(
230
270
  { variable: PROMPT_VARIABLE, arg: 'non-existent|args' },
231
271
  {}
232
272
  );
233
273
 
234
- expect(result?.value).to.equal('');
274
+ expect(result).to.be.undefined;
275
+ });
276
+
277
+ it('returns undefined for a non-existent prompt without arguments', async () => {
278
+ const result = await contribution.resolve(
279
+ { variable: PROMPT_VARIABLE, arg: 'non-existent' },
280
+ {}
281
+ );
282
+
283
+ expect(result).to.be.undefined;
284
+ });
285
+
286
+ it('returns undefined for a path-like argument', async () => {
287
+ promptService.addBuiltInPromptFragment({
288
+ id: 'hello-cmd',
289
+ template: 'Hello, world!',
290
+ isCommand: true,
291
+ commandName: 'hello'
292
+ });
293
+
294
+ const result = await contribution.resolve(
295
+ { variable: PROMPT_VARIABLE, arg: 'home|/user/notes.txt and fix the bug' },
296
+ {}
297
+ );
298
+
299
+ expect(result).to.be.undefined;
300
+ });
301
+
302
+ it('returns undefined when no argument is given', async () => {
303
+ expect(await contribution.resolve({ variable: PROMPT_VARIABLE, arg: undefined }, {})).to.be.undefined;
304
+ expect(await contribution.resolve({ variable: PROMPT_VARIABLE, arg: ' ' }, {})).to.be.undefined;
305
+ });
306
+
307
+ it('resolves a command referenced by its fragment id without arguments', async () => {
308
+ promptService.addBuiltInPromptFragment({
309
+ id: 'hello-cmd',
310
+ template: 'Hello, world!',
311
+ isCommand: true,
312
+ commandName: 'hello'
313
+ });
314
+
315
+ const result = await contribution.resolve(
316
+ { variable: PROMPT_VARIABLE, arg: 'hello' },
317
+ {}
318
+ );
319
+
320
+ expect(result?.value).to.equal('Hello, world!');
235
321
  });
236
322
  });
237
323
  });
@@ -76,11 +76,13 @@ export class PromptVariableContribution implements AIVariableContribution, AIVar
76
76
  const promptIdOrCommandName = pipeIndex >= 0 ? arg.substring(0, pipeIndex) : arg;
77
77
  const commandArgs = pipeIndex >= 0 ? arg.substring(pipeIndex + 1) : '';
78
78
 
79
- // Determine the actual fragment ID
80
- // If this is a command invocation (has args), try to find by command name first
81
- let fragment = commandArgs
82
- ? this.promptService.getPromptFragmentByCommandName(promptIdOrCommandName)
83
- : undefined;
79
+ // Determine the actual fragment ID by looking up the command name first.
80
+ // This must not be conditional on arguments being present: a customized (file-based)
81
+ // command has a fragment id that differs from its command name, so `/deploy` without
82
+ // arguments would otherwise not resolve even though `/deploy prod` does. It also keeps
83
+ // this lookup aligned with `PromptService.isKnownCommand`, which the chat request
84
+ // parser uses to decide whether `/deploy` is a command at all.
85
+ let fragment = this.promptService.getPromptFragmentByCommandName(promptIdOrCommandName);
84
86
 
85
87
  // Fall back to looking up by fragment ID if not found by command name
86
88
  if (!fragment) {
@@ -90,11 +92,7 @@ export class PromptVariableContribution implements AIVariableContribution, AIVar
90
92
  // If we still don't have a fragment, we can't resolve
91
93
  if (!fragment) {
92
94
  this.logger.debug(`Could not find prompt fragment or command '${promptIdOrCommandName}'`);
93
- return {
94
- variable: request.variable,
95
- value: '',
96
- allResolvedDependencies: []
97
- };
95
+ return undefined;
98
96
  }
99
97
 
100
98
  const fragmentId = fragment.id;
@@ -124,12 +122,11 @@ export class PromptVariableContribution implements AIVariableContribution, AIVar
124
122
  }
125
123
  }
126
124
  }
127
- this.logger.debug(`Could not resolve prompt variable '${request.variable.name}' with arg '${request.arg}'. Returning empty string.`);
128
- return {
129
- variable: request.variable,
130
- value: '',
131
- allResolvedDependencies: []
132
- };
125
+ // Returning `undefined` rather than an empty resolution keeps the original text in place:
126
+ // callers fall back to the literal `#prompt:...` / `{{prompt:...}}` placeholder instead of
127
+ // silently dropping it, which is also how every other unresolvable variable behaves.
128
+ this.logger.debug(`Could not resolve prompt variable '${request.variable.name}' with arg '${request.arg}'.`);
129
+ return undefined;
133
130
  }
134
131
 
135
132
  private substituteCommandArguments(template: string, commandName: string, commandArgs: string): string {
@@ -165,6 +165,35 @@ describe('TrustAwarePreferenceReader', () => {
165
165
  });
166
166
  });
167
167
 
168
+ describe('suppressUntrusted', () => {
169
+ const inspection = {
170
+ preferenceName: PREFERENCE_NAME,
171
+ defaultValue: 'default',
172
+ globalValue: 'user',
173
+ workspaceValue: 'workspace',
174
+ workspaceFolderValue: 'folder',
175
+ value: 'folder'
176
+ };
177
+
178
+ it('clears the workspace and folder values when untrusted', async () => {
179
+ trust.trustDeferred.resolve(false);
180
+ await reader.ready;
181
+ const result = reader.suppressUntrusted({ ...inspection });
182
+ expect(result.workspaceValue).to.equal(undefined);
183
+ expect(result.workspaceFolderValue).to.equal(undefined);
184
+ expect(result.globalValue).to.equal('user');
185
+ expect(result.defaultValue).to.equal('default');
186
+ });
187
+
188
+ it('returns the inspection unchanged when trusted', async () => {
189
+ trust.trustDeferred.resolve(true);
190
+ await reader.ready;
191
+ const result = reader.suppressUntrusted({ ...inspection });
192
+ expect(result.workspaceValue).to.equal('workspace');
193
+ expect(result.workspaceFolderValue).to.equal('folder');
194
+ });
195
+ });
196
+
168
197
  describe('onDidChangeTrust', () => {
169
198
  beforeEach(async () => {
170
199
  trust.trustDeferred.resolve(false);
@@ -18,7 +18,7 @@ import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'
18
18
  import { JSONValue } from '@theia/core/shared/@lumino/coreutils';
19
19
  import { Emitter, Event } from '@theia/core';
20
20
  import { Deferred } from '@theia/core/lib/common/promise-util';
21
- import { PreferenceService } from '@theia/core/lib/common/preferences';
21
+ import { PreferenceInspection, PreferenceService } from '@theia/core/lib/common/preferences';
22
22
  import { WorkspaceTrustService } from '@theia/workspace/lib/browser/workspace-trust-service';
23
23
 
24
24
  /**
@@ -27,6 +27,9 @@ import { WorkspaceTrustService } from '@theia/workspace/lib/browser/workspace-tr
27
27
  *
28
28
  * Writes should continue to go through `PreferenceService` directly; trust only
29
29
  * affects reads.
30
+ *
31
+ * @internal This is an implementation detail of {@link AiConfigurationService}. AI configuration
32
+ * consumers should go through `AiConfigurationService` rather than injecting this reader directly.
30
33
  */
31
34
  @injectable()
32
35
  export class TrustAwarePreferenceReader {
@@ -102,7 +105,24 @@ export class TrustAwarePreferenceReader {
102
105
  return this.preferences.get<T>(preferenceName, fallback, resourceUri);
103
106
  }
104
107
  const inspection = this.preferences.inspect<JSONValue>(preferenceName, resourceUri);
105
- const value = inspection?.globalValue ?? inspection?.defaultValue;
106
- return (value as T | undefined) ?? fallback;
108
+ if (!inspection) {
109
+ return fallback;
110
+ }
111
+ const suppressed = this.suppressUntrusted(inspection);
112
+ return ((suppressed.globalValue ?? suppressed.defaultValue) as T | undefined) ?? fallback;
113
+ }
114
+
115
+ /**
116
+ * Narrows a raw {@link PreferenceInspection} according to workspace trust: when the workspace is
117
+ * untrusted the folder and workspace scope values are dropped so that only the user and default
118
+ * scopes contribute (failing closed until the trust state resolves). A trusted inspection is
119
+ * returned unchanged. This is the single definition of the trust-suppression rule, shared by
120
+ * {@link get} and by inspection-based consumers, so value reads and inspections cannot drift.
121
+ */
122
+ suppressUntrusted<T extends JSONValue>(inspection: PreferenceInspection<T>): PreferenceInspection<T> {
123
+ if (this.trusted) {
124
+ return inspection;
125
+ }
126
+ return { ...inspection, workspaceValue: undefined, workspaceFolderValue: undefined };
107
127
  }
108
128
  }
@@ -0,0 +1,128 @@
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 { Event } from '@theia/core';
18
+ import { JSONValue } from '@theia/core/shared/@lumino/coreutils';
19
+ import { PreferenceInspection, PreferenceScope } from '@theia/core/lib/common/preferences';
20
+
21
+ export const AiConfigurationService = Symbol('AiConfigurationService');
22
+
23
+ /**
24
+ * A {@link PreferenceInspection} augmented with a derived, workspace-trust-aware source scope.
25
+ */
26
+ export interface AiConfigurationInspection<T extends JSONValue = JSONValue> extends PreferenceInspection<T> {
27
+ /**
28
+ * The narrowest scope (honoring workspace trust) in which a value is explicitly set, i.e. the
29
+ * scope that determines the effective {@link PreferenceInspection.value}. Walked in precedence
30
+ * order `Folder -> Workspace -> User`; `undefined` when only the default value applies. In an
31
+ * untrusted workspace the folder/workspace scopes are ignored, so `sourceScope` is either
32
+ * `User` or `undefined`.
33
+ */
34
+ sourceScope?: PreferenceScope;
35
+ }
36
+
37
+ /**
38
+ * Payload of {@link AiConfigurationService.onDidChange}.
39
+ */
40
+ export interface AiConfigurationChange {
41
+ /**
42
+ * The preference key that changed. When the change is caused by a workspace-trust transition
43
+ * (which can change the effective value of any trust-gated key) this is `undefined` and
44
+ * {@link affects} returns `true` for every resource. Listeners tracking a specific key should
45
+ * re-query the value when `preferenceName` is `undefined` or equals their key.
46
+ */
47
+ readonly preferenceName?: string;
48
+ /**
49
+ * Tests whether the given resource is affected by the change.
50
+ * @param resourceUri the uri of the resource to test.
51
+ */
52
+ affects(resourceUri?: string): boolean;
53
+ /**
54
+ * Tests whether the given preference key is affected by this change, treating a workspace-trust
55
+ * transition (where {@link preferenceName} is `undefined`) as affecting every trust-gated key.
56
+ * Prefer this over comparing {@link preferenceName} directly so listeners do not silently miss
57
+ * trust transitions.
58
+ * @param preferenceName the preference key to test.
59
+ */
60
+ affectsPreference(preferenceName: string): boolean;
61
+ }
62
+
63
+ /**
64
+ * Framework API for reading and writing AI-related (`ai-features.*`) preferences.
65
+ *
66
+ * `AiConfigurationService` wraps the core `PreferenceService` and is the intended extension point
67
+ * for adopters and extensions that configure AI features: prefer it over talking to
68
+ * `PreferenceService` directly for `ai-features.*` keys. Routing all AI configuration through this
69
+ * seam keeps consumers insulated from future changes to how AI preferences are stored.
70
+ *
71
+ * Reads ({@link get}, {@link inspect}) are **workspace-trust-aware**: workspace and folder scope
72
+ * values are suppressed while the workspace is untrusted (failing closed until the trust state
73
+ * resolves), matching how AI features read preferences today. Writes ({@link set}, {@link update})
74
+ * are never gated by trust.
75
+ */
76
+ export interface AiConfigurationService {
77
+ /**
78
+ * Resolves once the underlying preference service and the initial workspace-trust state are
79
+ * ready. Await this before the first read for a deterministic, trust-aware result.
80
+ */
81
+ readonly ready: Promise<void>;
82
+
83
+ /**
84
+ * Retrieves the effective, trust-aware value for the given preference.
85
+ *
86
+ * @param key the preference identifier.
87
+ * @param defaultValue the value to return when no value is stored.
88
+ * @param resourceUri the uri of the resource for which the preference is read.
89
+ */
90
+ get<T>(key: string, defaultValue?: T, resourceUri?: string): T | undefined;
91
+
92
+ /**
93
+ * Writes `value` to the given scope. Maps to `PreferenceService.set`.
94
+ *
95
+ * @param key the preference identifier.
96
+ * @param value the new value (must be JSON-serializable). `undefined` clears the value in the given scope.
97
+ * @param scope the scope to write to. For {@link PreferenceScope.Folder} a `resourceUri` is required.
98
+ * @param resourceUri the uri of the resource for which the preference is stored.
99
+ */
100
+ set(key: string, value: unknown, scope: PreferenceScope, resourceUri?: string): Promise<void>;
101
+
102
+ /**
103
+ * "Smart write": picks the scope so that the effective value becomes `value`. Maps to
104
+ * `PreferenceService.updateValue`.
105
+ *
106
+ * @param key the preference identifier.
107
+ * @param value the value to apply (must be JSON-serializable). `undefined` resets the preference to its default value.
108
+ * @param resourceUri the uri of the resource to which the change applies.
109
+ */
110
+ update(key: string, value: unknown, resourceUri?: string): Promise<void>;
111
+
112
+ /**
113
+ * Retrieves the per-scope values for the given preference, enriched with a derived,
114
+ * trust-aware {@link AiConfigurationInspection.sourceScope} and effective
115
+ * {@link PreferenceInspection.value}.
116
+ *
117
+ * @param key the preference identifier.
118
+ * @param resourceUri the uri of the resource for which the preference is inspected.
119
+ */
120
+ inspect<T extends JSONValue>(key: string, resourceUri?: string): AiConfigurationInspection<T> | undefined;
121
+
122
+ /**
123
+ * Fires when a `ai-features.*` preference changes or when the workspace-trust state transitions
124
+ * (which can change the effective value of trust-gated keys). The effective value is not carried
125
+ * on the event; listeners re-query it via {@link get}/{@link inspect}.
126
+ */
127
+ onDidChange: Event<AiConfigurationChange>;
128
+ }
@@ -24,7 +24,7 @@ import {
24
24
  NOTIFICATION_TYPE_DESCRIPTIONS,
25
25
  NotificationType
26
26
  } from './notification-types';
27
- import { ReasoningSettings } from './language-model';
27
+ import { ReasoningSettings, SERVER_SIDE_COMPACTION_TOKEN_THRESHOLD_MINIMUM } from './language-model';
28
28
  import { PreferenceSchema } from '@theia/core/lib/common/preferences/preference-schema';
29
29
 
30
30
  export const AI_CORE_PREFERENCES_TITLE = nls.localize('theia/ai-core/preferences/title', 'AI Features');
@@ -34,11 +34,31 @@ export const PREFERENCE_NAME_REASONING = 'ai-features.reasoning.defaults';
34
34
  export const PREFERENCE_NAME_MAX_RETRIES = 'ai-features.modelSettings.maxRetries';
35
35
  export const PREFERENCE_NAME_DEFAULT_NOTIFICATION_TYPE = 'ai-features.notifications.default';
36
36
  export const PREFERENCE_NAME_SKILL_DIRECTORIES = 'ai-features.skills.skillDirectories';
37
+ export const PREFERENCE_NAME_SERVER_SIDE_COMPACTION = 'ai-features.chat.serverSideCompaction';
38
+ export const PREFERENCE_NAME_SERVER_SIDE_COMPACTION_TOKEN_THRESHOLD = 'ai-features.chat.serverSideCompactionTokenThreshold';
37
39
 
38
40
  export const LANGUAGE_MODEL_ALIASES_PREFERENCE = 'ai-features.languageModelAliases';
39
41
 
40
42
  export const aiCorePreferenceSchema: PreferenceSchema = {
41
43
  properties: {
44
+ [PREFERENCE_NAME_SERVER_SIDE_COMPACTION]: {
45
+ title: AI_CORE_PREFERENCES_TITLE,
46
+ type: 'boolean',
47
+ description: nls.localize('theia/ai/core/serverSideCompaction/description',
48
+ 'Enable provider-native server-side compaction by default for chat requests. When on, models that support it ' +
49
+ 'may automatically summarize older turns once the conversation grows past their own threshold, letting long ' +
50
+ 'sessions continue past the context limit. Each provider offers its own override, and individual chat sessions ' +
51
+ 'can override this in their session settings. Models without support ignore it.'),
52
+ default: true,
53
+ },
54
+ [PREFERENCE_NAME_SERVER_SIDE_COMPACTION_TOKEN_THRESHOLD]: {
55
+ title: AI_CORE_PREFERENCES_TITLE,
56
+ type: 'integer',
57
+ minimum: SERVER_SIDE_COMPACTION_TOKEN_THRESHOLD_MINIMUM,
58
+ description: nls.localize('theia/ai/core/serverSideCompactionTokenThreshold/description',
59
+ 'Default input-token threshold at which provider-native server-side compaction should run. Providers and individual chat sessions ' +
60
+ 'can override this value. When unset, the provider chooses its default threshold. If set, the value must be at least 50,000 tokens.'),
61
+ },
42
62
  [PREFERENCE_NAME_PROMPT_TEMPLATES]: {
43
63
  title: AI_CORE_PREFERENCES_TITLE,
44
64
  description: nls.localize('theia/ai/core/promptTemplates/description',
@@ -239,6 +259,8 @@ export interface AICoreConfiguration {
239
259
  [PREFERENCE_NAME_MAX_RETRIES]: number | undefined;
240
260
  [PREFERENCE_NAME_DEFAULT_NOTIFICATION_TYPE]: NotificationType | undefined;
241
261
  [PREFERENCE_NAME_SKILL_DIRECTORIES]: string[] | undefined;
262
+ [PREFERENCE_NAME_SERVER_SIDE_COMPACTION]: boolean | undefined;
263
+ [PREFERENCE_NAME_SERVER_SIDE_COMPACTION_TOKEN_THRESHOLD]: number | undefined;
242
264
  }
243
265
 
244
266
  export interface RequestSetting {
@@ -31,6 +31,7 @@ export * from './protocol';
31
31
  export * from './today-variable-contribution';
32
32
  export * from './variable-service';
33
33
  export * from './settings-service';
34
+ export * from './ai-configuration-service';
34
35
  export * from './language-model-service';
35
36
  export * from './token-usage-service';
36
37
  export * from './ai-variable-resource';
@@ -15,7 +15,19 @@
15
15
  // *****************************************************************************
16
16
 
17
17
  import { expect } from 'chai';
18
- import { isModelMatching, isToolCallContent, LanguageModel, LanguageModelSelector } from './language-model';
18
+ import {
19
+ DefaultLanguageModelRegistryImpl,
20
+ isCompactionResponsePart,
21
+ isLanguageModelStreamResponsePart,
22
+ isModelMatching,
23
+ isToolCallContent,
24
+ LanguageModel,
25
+ LanguageModelSelector,
26
+ resolveCompactionDefault,
27
+ resolveCompactionTokenThreshold,
28
+ resolveCompactionTokenThresholdDefault,
29
+ resolveServerSideCompaction
30
+ } from './language-model';
19
31
 
20
32
  describe('isModelMatching', () => {
21
33
  it('returns false with one of two parameter mismatches', () => {
@@ -163,3 +175,88 @@ describe('isToolCallContent', () => {
163
175
  });
164
176
 
165
177
  });
178
+
179
+ describe('DefaultLanguageModelRegistryImpl', () => {
180
+ function createRegistry(): DefaultLanguageModelRegistryImpl {
181
+ const registry = new DefaultLanguageModelRegistryImpl();
182
+ // No DI container in this unit test, so resolve the internal `initialized` gate manually.
183
+ (registry as unknown as { markInitialized: () => void }).markInitialized();
184
+ return registry;
185
+ }
186
+
187
+ function model(id: string): LanguageModel {
188
+ return { id, status: { status: 'ready' } } as LanguageModel;
189
+ }
190
+
191
+ it('getLanguageModels returns a fresh array so consumers can detect changes by reference', async () => {
192
+ const registry = createRegistry();
193
+ registry.addLanguageModels([model('a')]);
194
+
195
+ const first = await registry.getLanguageModels();
196
+ expect(first).to.have.length(1);
197
+
198
+ registry.addLanguageModels([model('b')]);
199
+ const second = await registry.getLanguageModels();
200
+
201
+ // A new snapshot must be a different array reference (React memoization relies on this).
202
+ expect(second).to.not.equal(first);
203
+ // The earlier snapshot must not be mutated in place by later registry changes.
204
+ expect(first.map(m => m.id)).to.deep.equal(['a']);
205
+ expect(second.map(m => m.id)).to.deep.equal(['a', 'b']);
206
+ });
207
+
208
+ it('reflects removals in a fresh snapshot without mutating an earlier one', async () => {
209
+ const registry = createRegistry();
210
+ registry.addLanguageModels([model('a'), model('b')]);
211
+ const before = await registry.getLanguageModels();
212
+
213
+ registry.removeLanguageModels(['a']);
214
+ const after = await registry.getLanguageModels();
215
+
216
+ expect(before.map(m => m.id)).to.deep.equal(['a', 'b']);
217
+ expect(after.map(m => m.id)).to.deep.equal(['b']);
218
+ });
219
+ });
220
+
221
+ describe('compaction contract', () => {
222
+ it('recognizes a compaction response part', () => {
223
+ const part = { compaction: { provider: 'anthropic', data: { foo: 1 } } };
224
+ expect(isCompactionResponsePart(part)).to.equal(true);
225
+ expect(isLanguageModelStreamResponsePart(part)).to.equal(true);
226
+ });
227
+ it('rejects a non-compaction part', () => {
228
+ expect(isCompactionResponsePart({ content: 'hi' })).to.equal(false);
229
+ // eslint-disable-next-line no-null/no-null
230
+ expect(isCompactionResponsePart(null)).to.equal(false);
231
+ expect(isCompactionResponsePart(undefined)).to.equal(false);
232
+ // eslint-disable-next-line no-null/no-null
233
+ expect(isCompactionResponsePart({ compaction: null })).to.equal(false);
234
+ expect(isCompactionResponsePart({ compaction: { provider: 42 } })).to.equal(false);
235
+ });
236
+ it('resolves a model default from the global preference and the per-provider override', () => {
237
+ expect(resolveCompactionDefault(true, 'default')).to.equal(true);
238
+ expect(resolveCompactionDefault(false, 'default')).to.equal(false);
239
+ expect(resolveCompactionDefault(false, 'enabled')).to.equal(true);
240
+ expect(resolveCompactionDefault(true, 'disabled')).to.equal(false);
241
+ });
242
+ it('resolves compaction token thresholds with session, provider, global precedence', () => {
243
+ expect(resolveCompactionTokenThresholdDefault(undefined, undefined)).to.equal(undefined);
244
+ expect(resolveCompactionTokenThresholdDefault(100_000, undefined)).to.equal(100_000);
245
+ expect(resolveCompactionTokenThresholdDefault(100_000, 200_000)).to.equal(200_000);
246
+ expect(resolveCompactionTokenThreshold(undefined, undefined)).to.equal(undefined);
247
+ expect(resolveCompactionTokenThreshold(200_000, undefined)).to.equal(200_000);
248
+ expect(resolveCompactionTokenThreshold(200_000, { tokenThreshold: 300_000 })).to.equal(300_000);
249
+ });
250
+ it('resolves server-side compaction with capability gate and session-wins precedence', () => {
251
+ // capability gate
252
+ expect(resolveServerSideCompaction(false, true, { enabled: true })).to.equal(false);
253
+ expect(resolveServerSideCompaction(undefined, true, undefined)).to.equal(false);
254
+ // no per-session setting -> model default
255
+ expect(resolveServerSideCompaction(true, true, undefined)).to.equal(true);
256
+ expect(resolveServerSideCompaction(true, false, undefined)).to.equal(false);
257
+ expect(resolveServerSideCompaction(true, true, {})).to.equal(true);
258
+ // explicit per-session setting wins over the model default (which already folds in the per-provider override)
259
+ expect(resolveServerSideCompaction(true, false, { enabled: true })).to.equal(true);
260
+ expect(resolveServerSideCompaction(true, true, { enabled: false })).to.equal(false);
261
+ });
262
+ });