@theia/ai-core 1.71.0-next.8 → 1.71.0

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 (79) hide show
  1. package/lib/browser/ai-activation-service.d.ts +9 -0
  2. package/lib/browser/ai-activation-service.d.ts.map +1 -1
  3. package/lib/browser/ai-activation-service.js +5 -0
  4. package/lib/browser/ai-activation-service.js.map +1 -1
  5. package/lib/browser/ai-core-frontend-module.d.ts.map +1 -1
  6. package/lib/browser/ai-core-frontend-module.js +5 -1
  7. package/lib/browser/ai-core-frontend-module.js.map +1 -1
  8. package/lib/browser/ai-settings-service.d.ts +2 -0
  9. package/lib/browser/ai-settings-service.d.ts.map +1 -1
  10. package/lib/browser/ai-settings-service.js +8 -1
  11. package/lib/browser/ai-settings-service.js.map +1 -1
  12. package/lib/browser/frontend-language-model-alias-registry.d.ts +2 -0
  13. package/lib/browser/frontend-language-model-alias-registry.d.ts.map +1 -1
  14. package/lib/browser/frontend-language-model-alias-registry.js +18 -9
  15. package/lib/browser/frontend-language-model-alias-registry.js.map +1 -1
  16. package/lib/browser/frontend-language-model-service.d.ts +6 -4
  17. package/lib/browser/frontend-language-model-service.d.ts.map +1 -1
  18. package/lib/browser/frontend-language-model-service.js +34 -16
  19. package/lib/browser/frontend-language-model-service.js.map +1 -1
  20. package/lib/browser/frontend-prompt-customization-service.d.ts +7 -0
  21. package/lib/browser/frontend-prompt-customization-service.d.ts.map +1 -1
  22. package/lib/browser/frontend-prompt-customization-service.js +39 -13
  23. package/lib/browser/frontend-prompt-customization-service.js.map +1 -1
  24. package/lib/browser/product-name-variable-contribution.d.ts +9 -0
  25. package/lib/browser/product-name-variable-contribution.d.ts.map +1 -0
  26. package/lib/browser/product-name-variable-contribution.js +49 -0
  27. package/lib/browser/product-name-variable-contribution.js.map +1 -0
  28. package/lib/browser/product-name-variable-contribution.spec.d.ts +2 -0
  29. package/lib/browser/product-name-variable-contribution.spec.d.ts.map +1 -0
  30. package/lib/browser/product-name-variable-contribution.spec.js +50 -0
  31. package/lib/browser/product-name-variable-contribution.spec.js.map +1 -0
  32. package/lib/browser/skill-prompt-coordinator.d.ts +1 -1
  33. package/lib/browser/skill-prompt-coordinator.d.ts.map +1 -1
  34. package/lib/browser/skill-prompt-coordinator.js +3 -1
  35. package/lib/browser/skill-prompt-coordinator.js.map +1 -1
  36. package/lib/browser/skill-service.d.ts +5 -0
  37. package/lib/browser/skill-service.d.ts.map +1 -1
  38. package/lib/browser/skill-service.js +6 -0
  39. package/lib/browser/skill-service.js.map +1 -1
  40. package/lib/browser/skills-variable-contribution.spec.js +2 -1
  41. package/lib/browser/skills-variable-contribution.spec.js.map +1 -1
  42. package/lib/browser/trust-aware-preference-reader.d.ts +38 -0
  43. package/lib/browser/trust-aware-preference-reader.d.ts.map +1 -0
  44. package/lib/browser/trust-aware-preference-reader.js +114 -0
  45. package/lib/browser/trust-aware-preference-reader.js.map +1 -0
  46. package/lib/browser/trust-aware-preference-reader.spec.d.ts +2 -0
  47. package/lib/browser/trust-aware-preference-reader.spec.d.ts.map +1 -0
  48. package/lib/browser/trust-aware-preference-reader.spec.js +251 -0
  49. package/lib/browser/trust-aware-preference-reader.spec.js.map +1 -0
  50. package/lib/common/ai-core-preferences.d.ts +5 -7
  51. package/lib/common/ai-core-preferences.d.ts.map +1 -1
  52. package/lib/common/ai-core-preferences.js +34 -37
  53. package/lib/common/ai-core-preferences.js.map +1 -1
  54. package/lib/common/language-model.d.ts +25 -4
  55. package/lib/common/language-model.d.ts.map +1 -1
  56. package/lib/common/language-model.js.map +1 -1
  57. package/lib/common/settings-service.d.ts +7 -1
  58. package/lib/common/settings-service.d.ts.map +1 -1
  59. package/lib/node/backend-language-model-registry.d.ts.map +1 -1
  60. package/lib/node/backend-language-model-registry.js +1 -0
  61. package/lib/node/backend-language-model-registry.js.map +1 -1
  62. package/package.json +12 -12
  63. package/src/browser/ai-activation-service.ts +16 -0
  64. package/src/browser/ai-core-frontend-module.ts +6 -1
  65. package/src/browser/ai-settings-service.ts +10 -1
  66. package/src/browser/frontend-language-model-alias-registry.ts +17 -9
  67. package/src/browser/frontend-language-model-service.ts +36 -20
  68. package/src/browser/frontend-prompt-customization-service.ts +38 -15
  69. package/src/browser/product-name-variable-contribution.spec.ts +68 -0
  70. package/src/browser/product-name-variable-contribution.ts +47 -0
  71. package/src/browser/skill-prompt-coordinator.ts +4 -1
  72. package/src/browser/skill-service.ts +10 -0
  73. package/src/browser/skills-variable-contribution.spec.ts +2 -1
  74. package/src/browser/trust-aware-preference-reader.spec.ts +304 -0
  75. package/src/browser/trust-aware-preference-reader.ts +108 -0
  76. package/src/common/ai-core-preferences.ts +38 -41
  77. package/src/common/language-model.ts +28 -4
  78. package/src/common/settings-service.ts +7 -1
  79. package/src/node/backend-language-model-registry.ts +1 -0
@@ -18,6 +18,7 @@ import { DisposableCollection, URI, Event, Emitter, nls } from '@theia/core';
18
18
  import { OpenerService } from '@theia/core/lib/browser';
19
19
  import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
20
20
  import { PromptFragmentCustomizationService, CustomAgentDescription, CustomizedPromptFragment, CommandPromptFragmentMetadata } from '../common';
21
+ import { ConfigurableInMemoryResources } from '../common/configurable-in-memory-resources';
21
22
  import { BinaryBuffer } from '@theia/core/lib/common/buffer';
22
23
  import { FileService } from '@theia/filesystem/lib/browser/file-service';
23
24
  import { FileChangesEvent } from '@theia/filesystem/lib/common/files';
@@ -132,6 +133,9 @@ export class DefaultPromptFragmentCustomizationService implements PromptFragment
132
133
  @inject(OpenerService)
133
134
  protected readonly openerService: OpenerService;
134
135
 
136
+ @inject(ConfigurableInMemoryResources)
137
+ protected readonly inMemoryResources: ConfigurableInMemoryResources;
138
+
135
139
  /** Stores URI strings of template files from directories currently being monitored for changes. */
136
140
  protected trackedTemplateURIs = new Set<string>();
137
141
 
@@ -852,11 +856,38 @@ export class DefaultPromptFragmentCustomizationService implements PromptFragment
852
856
  */
853
857
  protected async editTemplate(id: string, defaultContent?: string): Promise<void> {
854
858
  const editorUri = await this.getTemplateURI(id);
855
- if (!(await this.fileService.exists(editorUri))) {
856
- await this.fileService.createFile(editorUri, BinaryBuffer.fromString(defaultContent ?? ''));
859
+ if (await this.fileService.exists(editorUri)) {
860
+ const openHandler = await this.openerService.getOpener(editorUri);
861
+ openHandler.open(editorUri);
862
+ } else {
863
+ await this.openInMemoryTemplate(editorUri, defaultContent ?? '');
864
+ }
865
+ }
866
+
867
+ /**
868
+ * Opens an in-memory resource with the given content, without creating a file on disk.
869
+ * The file is only created when the user saves in the editor.
870
+ */
871
+ protected async openInMemoryTemplate(templateUri: URI, defaultContent: string): Promise<void> {
872
+ try {
873
+ this.inMemoryResources.resolve(templateUri);
874
+ } catch {
875
+ const resource = this.inMemoryResources.add(templateUri, {
876
+ contents: defaultContent,
877
+ initiallyDirty: false,
878
+ onSave: async (contents: string) => {
879
+ const dirUri = templateUri.parent;
880
+ if (!(await this.fileService.exists(dirUri))) {
881
+ await this.fileService.createFolder(dirUri);
882
+ }
883
+ await this.fileService.createFile(templateUri, BinaryBuffer.fromString(contents), { overwrite: true });
884
+ resource.dispose();
885
+ }
886
+ });
857
887
  }
858
- const openHandler = await this.openerService.getOpener(editorUri);
859
- openHandler.open(editorUri);
888
+
889
+ const openHandler = await this.openerService.getOpener(templateUri);
890
+ openHandler.open(templateUri);
860
891
  }
861
892
 
862
893
  async removePromptFragmentCustomization(id: string, customizationId: string): Promise<void> {
@@ -978,18 +1009,10 @@ export class DefaultPromptFragmentCustomizationService implements PromptFragment
978
1009
  const openHandler = await this.openerService.getOpener(uri);
979
1010
  openHandler.open(uri);
980
1011
  } else {
981
- // Create a new built-in customization
982
- // Get the template URI in the main templates directory (priority 1)
1012
+ // Open the built-in content without creating a file on disk.
1013
+ // The file will only be created when the user saves.
983
1014
  const templateUri = await this.getTemplateURI(id);
984
-
985
- // If template doesn't exist, create it with default content
986
- if (!(await this.fileService.exists(templateUri))) {
987
- await this.fileService.createFile(templateUri, BinaryBuffer.fromString(defaultContent));
988
- }
989
-
990
- // Open the template in the editor
991
- const openHandler = await this.openerService.getOpener(templateUri);
992
- openHandler.open(templateUri);
1015
+ await this.openInMemoryTemplate(templateUri, defaultContent);
993
1016
  }
994
1017
  }
995
1018
 
@@ -0,0 +1,68 @@
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 { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
18
+
19
+ let disableJSDOM = enableJSDOM();
20
+
21
+ import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
22
+ FrontendApplicationConfigProvider.set({ applicationName: 'Test IDE' });
23
+
24
+ import 'reflect-metadata';
25
+
26
+ import { expect } from 'chai';
27
+ import { ProductNameVariableContribution, PRODUCT_NAME_VARIABLE } from './product-name-variable-contribution';
28
+
29
+ disableJSDOM();
30
+
31
+ describe('ProductNameVariableContribution', () => {
32
+ before(() => {
33
+ disableJSDOM = enableJSDOM();
34
+ FrontendApplicationConfigProvider.set({ applicationName: 'Test IDE' });
35
+ });
36
+ after(() => disableJSDOM());
37
+
38
+ let contribution: ProductNameVariableContribution;
39
+
40
+ beforeEach(() => {
41
+ contribution = new ProductNameVariableContribution();
42
+ });
43
+
44
+ it('should resolve to the configured application name', async () => {
45
+ const result = await contribution.resolve(
46
+ { variable: PRODUCT_NAME_VARIABLE },
47
+ {}
48
+ );
49
+ expect(result).to.not.be.undefined;
50
+ expect(result!.value).to.equal('Test IDE');
51
+ });
52
+
53
+ it('should return undefined for unknown variables', async () => {
54
+ const result = await contribution.resolve(
55
+ { variable: { id: 'other', name: 'other', description: 'other' } },
56
+ {}
57
+ );
58
+ expect(result).to.be.undefined;
59
+ });
60
+
61
+ it('should return a positive priority from canResolve', async () => {
62
+ const priority = await contribution.canResolve(
63
+ { variable: PRODUCT_NAME_VARIABLE },
64
+ {}
65
+ );
66
+ expect(priority).to.equal(1);
67
+ });
68
+ });
@@ -0,0 +1,47 @@
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 { MaybePromise, nls } from '@theia/core';
18
+ import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
19
+ import { injectable } from '@theia/core/shared/inversify';
20
+ import { AIVariable, AIVariableContribution, AIVariableContext, AIVariableResolutionRequest, AIVariableResolver, AIVariableService, ResolvedAIVariable } from '../common';
21
+
22
+ export const PRODUCT_NAME_VARIABLE: AIVariable = {
23
+ id: 'product-name-provider',
24
+ name: 'productName',
25
+ description: nls.localize('theia/ai/core/productNameVariable/description', 'The name of the product/application the user is working with'),
26
+ };
27
+
28
+ @injectable()
29
+ export class ProductNameVariableContribution implements AIVariableContribution, AIVariableResolver {
30
+ registerVariables(service: AIVariableService): void {
31
+ service.registerResolver(PRODUCT_NAME_VARIABLE, this);
32
+ }
33
+
34
+ canResolve(request: AIVariableResolutionRequest, context: AIVariableContext): MaybePromise<number> {
35
+ if (request.variable.name === PRODUCT_NAME_VARIABLE.name) {
36
+ return 1;
37
+ }
38
+ return -1;
39
+ }
40
+
41
+ async resolve(request: AIVariableResolutionRequest, context: AIVariableContext): Promise<ResolvedAIVariable | undefined> {
42
+ if (request.variable.name === PRODUCT_NAME_VARIABLE.name) {
43
+ return { variable: request.variable, value: FrontendApplicationConfigProvider.get().applicationName };
44
+ }
45
+ return undefined;
46
+ }
47
+ }
@@ -30,7 +30,10 @@ export class SkillPromptCoordinator implements FrontendApplicationContribution {
30
30
 
31
31
  protected registeredSkillCommands = new Set<string>();
32
32
 
33
- onStart(): void {
33
+ async onStart(): Promise<void> {
34
+ // Wait for skills to be loaded before registering commands
35
+ await this.skillService.ready;
36
+
34
37
  // Register initial skills
35
38
  this.updateSkillCommands();
36
39
 
@@ -15,6 +15,7 @@
15
15
  // *****************************************************************************
16
16
 
17
17
  import { inject, injectable, named, postConstruct } from '@theia/core/shared/inversify';
18
+ import { Deferred } from '@theia/core/lib/common/promise-util';
18
19
  import { DisposableCollection, Emitter, Event, ILogger, URI } from '@theia/core';
19
20
  import { Path } from '@theia/core/lib/common/path';
20
21
  import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
@@ -37,6 +38,9 @@ export interface SkillService {
37
38
 
38
39
  /** Event fired when skills change */
39
40
  readonly onSkillsChanged: Event<void>;
41
+
42
+ /** Promise that resolves when initial skill loading is complete */
43
+ readonly ready: Promise<void>;
40
44
  }
41
45
 
42
46
  @injectable()
@@ -68,6 +72,11 @@ export class DefaultSkillService implements SkillService {
68
72
 
69
73
  protected updateDebounceTimeout: ReturnType<typeof setTimeout> | undefined;
70
74
 
75
+ protected _ready = new Deferred<void>();
76
+ get ready(): Promise<void> {
77
+ return this._ready.promise;
78
+ }
79
+
71
80
  @postConstruct()
72
81
  protected init(): void {
73
82
  this.fileService.onDidFilesChange(async (event: FileChangesEvent) => {
@@ -113,6 +122,7 @@ export class DefaultSkillService implements SkillService {
113
122
  // Wait for workspace to be ready before initial update
114
123
  this.workspaceService.ready.then(() => {
115
124
  this.update().then(() => {
125
+ this._ready.resolve();
116
126
  // Only after initial update, start listening for changes
117
127
  this.lastSkillDirectoriesValue = JSON.stringify(this.preferences[PREFERENCE_NAME_SKILL_DIRECTORIES]);
118
128
 
@@ -52,7 +52,8 @@ describe('SkillsVariableContribution', () => {
52
52
  skillService = {
53
53
  getSkills: sinon.stub(),
54
54
  getSkill: sinon.stub(),
55
- onSkillsChanged: sinon.stub() as unknown as typeof skillService.onSkillsChanged
55
+ onSkillsChanged: sinon.stub() as unknown as typeof skillService.onSkillsChanged,
56
+ ready: sinon.stub() as unknown as typeof skillService.ready
56
57
  };
57
58
 
58
59
  container.bind(SkillService).toConstantValue(skillService as unknown as SkillService);
@@ -0,0 +1,304 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
18
+ const disableJSDOM = enableJSDOM();
19
+ import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
20
+ FrontendApplicationConfigProvider.set({});
21
+
22
+ import { expect } from 'chai';
23
+ import { Container } from '@theia/core/shared/inversify';
24
+ import { Emitter, Event } from '@theia/core';
25
+ import { Deferred } from '@theia/core/lib/common/promise-util';
26
+ import { PreferenceService } from '@theia/core/lib/common/preferences';
27
+ import { WorkspaceTrustService } from '@theia/workspace/lib/browser/workspace-trust-service';
28
+ import { TrustAwarePreferenceReader } from './trust-aware-preference-reader';
29
+
30
+ disableJSDOM();
31
+
32
+ interface InspectResult<T> {
33
+ defaultValue?: T;
34
+ globalValue?: T;
35
+ workspaceValue?: T;
36
+ workspaceFolderValue?: T;
37
+ }
38
+
39
+ class StubPreferenceService {
40
+ inspectResult: InspectResult<unknown> | undefined;
41
+ effectiveValue: unknown;
42
+
43
+ get<T>(_preferenceName: string, fallback?: T, _resourceUri?: string): T | undefined {
44
+ return ((this.effectiveValue as T | undefined) ?? fallback);
45
+ }
46
+
47
+ inspect<T>(_preferenceName: string, _resourceUri?: string): InspectResult<T> | undefined {
48
+ return this.inspectResult as InspectResult<T> | undefined;
49
+ }
50
+ }
51
+
52
+ class StubWorkspaceTrustService {
53
+ readonly trustDeferred = new Deferred<boolean>();
54
+ protected readonly emitter = new Emitter<boolean>();
55
+ readonly onDidChangeWorkspaceTrust: Event<boolean> = this.emitter.event;
56
+
57
+ getWorkspaceTrust(): Promise<boolean> {
58
+ return this.trustDeferred.promise;
59
+ }
60
+
61
+ fireTrustChange(trusted: boolean): void {
62
+ this.emitter.fire(trusted);
63
+ }
64
+ }
65
+
66
+ const PREFERENCE_NAME = 'some.preference';
67
+
68
+ describe('TrustAwarePreferenceReader', () => {
69
+ let preferences: StubPreferenceService;
70
+ let trust: StubWorkspaceTrustService;
71
+ let reader: TrustAwarePreferenceReader;
72
+
73
+ beforeEach(() => {
74
+ preferences = new StubPreferenceService();
75
+ trust = new StubWorkspaceTrustService();
76
+
77
+ const container = new Container();
78
+ container.bind(PreferenceService).toConstantValue(preferences as unknown as PreferenceService);
79
+ container.bind(WorkspaceTrustService).toConstantValue(trust as unknown as WorkspaceTrustService);
80
+ container.bind(TrustAwarePreferenceReader).toSelf().inSingletonScope();
81
+
82
+ reader = container.get(TrustAwarePreferenceReader);
83
+ });
84
+
85
+ describe('fail-closed default', () => {
86
+ it('returns only user/default/fallback before ready resolves', () => {
87
+ preferences.effectiveValue = 'workspace';
88
+ preferences.inspectResult = {
89
+ defaultValue: 'default',
90
+ globalValue: 'user',
91
+ workspaceValue: 'workspace'
92
+ };
93
+ const value = reader.get<string>(PREFERENCE_NAME, 'fallback');
94
+ expect(value).to.equal('user');
95
+ });
96
+
97
+ it('returns defaultValue when only workspace value exists before ready', () => {
98
+ preferences.effectiveValue = 'workspace';
99
+ preferences.inspectResult = {
100
+ defaultValue: 'default',
101
+ workspaceValue: 'workspace'
102
+ };
103
+ const value = reader.get<string>(PREFERENCE_NAME, 'fallback');
104
+ expect(value).to.equal('default');
105
+ });
106
+
107
+ it('returns fallback when nothing is set before ready', () => {
108
+ preferences.effectiveValue = undefined;
109
+ preferences.inspectResult = {};
110
+ const value = reader.get<string>(PREFERENCE_NAME, 'fallback');
111
+ expect(value).to.equal('fallback');
112
+ });
113
+ });
114
+
115
+ describe('after ready resolves with trusted = true', () => {
116
+ beforeEach(async () => {
117
+ trust.trustDeferred.resolve(true);
118
+ await reader.ready;
119
+ });
120
+
121
+ it('delegates to preferences.get and returns the workspace value', () => {
122
+ preferences.effectiveValue = 'workspace';
123
+ preferences.inspectResult = {
124
+ defaultValue: 'default',
125
+ globalValue: 'user',
126
+ workspaceValue: 'workspace'
127
+ };
128
+ const value = reader.get<string>(PREFERENCE_NAME, 'fallback');
129
+ expect(value).to.equal('workspace');
130
+ });
131
+ });
132
+
133
+ describe('after ready resolves with trusted = false', () => {
134
+ beforeEach(async () => {
135
+ trust.trustDeferred.resolve(false);
136
+ await reader.ready;
137
+ });
138
+
139
+ it('returns globalValue when both global and workspace values exist', () => {
140
+ preferences.effectiveValue = 'workspace';
141
+ preferences.inspectResult = {
142
+ defaultValue: 'default',
143
+ globalValue: 'user',
144
+ workspaceValue: 'workspace'
145
+ };
146
+ const value = reader.get<string>(PREFERENCE_NAME, 'fallback');
147
+ expect(value).to.equal('user');
148
+ });
149
+
150
+ it('falls back to defaultValue when only workspace value exists', () => {
151
+ preferences.effectiveValue = 'workspace';
152
+ preferences.inspectResult = {
153
+ defaultValue: 'default',
154
+ workspaceValue: 'workspace'
155
+ };
156
+ const value = reader.get<string>(PREFERENCE_NAME, 'fallback');
157
+ expect(value).to.equal('default');
158
+ });
159
+
160
+ it('returns the supplied fallback when nothing is set', () => {
161
+ preferences.effectiveValue = undefined;
162
+ preferences.inspectResult = {};
163
+ const value = reader.get<string>(PREFERENCE_NAME, 'fallback');
164
+ expect(value).to.equal('fallback');
165
+ });
166
+ });
167
+
168
+ describe('onDidChangeTrust', () => {
169
+ beforeEach(async () => {
170
+ trust.trustDeferred.resolve(false);
171
+ await reader.ready;
172
+ });
173
+
174
+ it('fires with the new value when the underlying service emits a change', () => {
175
+ const received: boolean[] = [];
176
+ reader.onDidChangeTrust(value => received.push(value));
177
+
178
+ trust.fireTrustChange(true);
179
+ expect(received).to.deep.equal([true]);
180
+ });
181
+
182
+ it('does not fire when the value is unchanged', () => {
183
+ const received: boolean[] = [];
184
+ reader.onDidChangeTrust(value => received.push(value));
185
+
186
+ trust.fireTrustChange(false);
187
+ expect(received).to.deep.equal([]);
188
+ });
189
+
190
+ it('has updated the cached trusted flag by the time listeners run', () => {
191
+ preferences.effectiveValue = 'workspace';
192
+ preferences.inspectResult = {
193
+ defaultValue: 'default',
194
+ globalValue: 'user',
195
+ workspaceValue: 'workspace'
196
+ };
197
+
198
+ let observedDuringListener: string | undefined;
199
+ reader.onDidChangeTrust(() => {
200
+ observedDuringListener = reader.get<string>(PREFERENCE_NAME, 'fallback');
201
+ });
202
+
203
+ trust.fireTrustChange(true);
204
+ expect(observedDuringListener).to.equal('workspace');
205
+ });
206
+ });
207
+
208
+ describe('ready', () => {
209
+ it('does not resolve until the initial getWorkspaceTrust promise resolves', async () => {
210
+ let resolved = false;
211
+ reader.ready.then(() => { resolved = true; });
212
+
213
+ // Allow microtasks to settle without resolving the deferred.
214
+ await Promise.resolve();
215
+ expect(resolved).to.equal(false);
216
+
217
+ trust.trustDeferred.resolve(true);
218
+ await reader.ready;
219
+ expect(resolved).to.equal(true);
220
+ });
221
+
222
+ it('resolves via an early change event when it arrives before the initial promise', async () => {
223
+ const received: boolean[] = [];
224
+ reader.onDidChangeTrust(value => received.push(value));
225
+
226
+ trust.fireTrustChange(true);
227
+ await reader.ready;
228
+
229
+ // The first signal acts as the initial resolution and must not
230
+ // be reported as a change event.
231
+ expect(received).to.deep.equal([]);
232
+
233
+ preferences.effectiveValue = 'workspace';
234
+ preferences.inspectResult = {
235
+ defaultValue: 'default',
236
+ globalValue: 'user',
237
+ workspaceValue: 'workspace'
238
+ };
239
+ expect(reader.get<string>(PREFERENCE_NAME, 'fallback')).to.equal('workspace');
240
+ });
241
+ });
242
+
243
+ describe('race between initial promise and change event', () => {
244
+ it('ignores a stale getWorkspaceTrust resolution after a change event has initialised the reader', async () => {
245
+ const received: boolean[] = [];
246
+ reader.onDidChangeTrust(value => received.push(value));
247
+
248
+ // Change event arrives first and initialises the reader.
249
+ trust.fireTrustChange(false);
250
+ // The (now stale) initial promise resolves to a different value.
251
+ trust.trustDeferred.resolve(true);
252
+
253
+ await reader.ready;
254
+ // Allow the stale .then callback to run.
255
+ await Promise.resolve();
256
+ await Promise.resolve();
257
+
258
+ preferences.effectiveValue = 'workspace';
259
+ preferences.inspectResult = {
260
+ defaultValue: 'default',
261
+ globalValue: 'user',
262
+ workspaceValue: 'workspace'
263
+ };
264
+
265
+ // Cached trust must still reflect the change event (false), not
266
+ // the stale promise resolution (true).
267
+ expect(reader.get<string>(PREFERENCE_NAME, 'fallback')).to.equal('user');
268
+ // No change event should have been fired for the initial value
269
+ // and none for the stale resolution either.
270
+ expect(received).to.deep.equal([]);
271
+ });
272
+
273
+ it('ignores a stale getWorkspaceTrust rejection after a change event has initialised the reader', async () => {
274
+ const received: boolean[] = [];
275
+ reader.onDidChangeTrust(value => received.push(value));
276
+
277
+ // Avoid an unhandled rejection warning for the (now stale) initial promise.
278
+ trust.getWorkspaceTrust().catch(() => { /* expected */ });
279
+
280
+ // Change event arrives first and initialises the reader.
281
+ trust.fireTrustChange(true);
282
+ // The (now stale) initial promise rejects.
283
+ trust.trustDeferred.reject(new Error('stale failure'));
284
+
285
+ // ready must resolve (not reject) because the event already initialised it.
286
+ await reader.ready;
287
+ // Allow the stale rejection callback to run.
288
+ await Promise.resolve();
289
+ await Promise.resolve();
290
+
291
+ preferences.effectiveValue = 'workspace';
292
+ preferences.inspectResult = {
293
+ defaultValue: 'default',
294
+ globalValue: 'user',
295
+ workspaceValue: 'workspace'
296
+ };
297
+
298
+ // Cached trust must reflect the change event (true).
299
+ expect(reader.get<string>(PREFERENCE_NAME, 'fallback')).to.equal('workspace');
300
+ // No change event should have been fired for the initial value.
301
+ expect(received).to.deep.equal([]);
302
+ });
303
+ });
304
+ });
@@ -0,0 +1,108 @@
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 { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
18
+ import { JSONValue } from '@theia/core/shared/@lumino/coreutils';
19
+ import { Emitter, Event } from '@theia/core';
20
+ import { Deferred } from '@theia/core/lib/common/promise-util';
21
+ import { PreferenceService } from '@theia/core/lib/common/preferences';
22
+ import { WorkspaceTrustService } from '@theia/workspace/lib/browser/workspace-trust-service';
23
+
24
+ /**
25
+ * Helper for reading AI preferences that must ignore workspace/folder scopes
26
+ * when the current workspace is not trusted.
27
+ *
28
+ * Writes should continue to go through `PreferenceService` directly; trust only
29
+ * affects reads.
30
+ */
31
+ @injectable()
32
+ export class TrustAwarePreferenceReader {
33
+
34
+ @inject(PreferenceService)
35
+ protected readonly preferences: PreferenceService;
36
+
37
+ @inject(WorkspaceTrustService)
38
+ protected readonly trust: WorkspaceTrustService;
39
+
40
+ /**
41
+ * Cached trust flag updated via `onDidChangeWorkspaceTrust`. Defaults to
42
+ * `false` (fail closed): until the initial trust state has resolved, reads
43
+ * return only user/default scope values. Asynchronous callers should
44
+ * `await ready` before their first read.
45
+ */
46
+ protected trusted = false;
47
+
48
+ protected readonly _ready = new Deferred<void>();
49
+
50
+ /**
51
+ * Resolves once the initial workspace trust state has been resolved.
52
+ * Callers that need a deterministic trust state before their first read can
53
+ * await this promise.
54
+ */
55
+ get ready(): Promise<void> {
56
+ return this._ready.promise;
57
+ }
58
+
59
+ protected readonly onDidChangeTrustEmitter = new Emitter<boolean>();
60
+ readonly onDidChangeTrust: Event<boolean> = this.onDidChangeTrustEmitter.event;
61
+
62
+ @postConstruct()
63
+ protected init(): void {
64
+ let initialized = false;
65
+
66
+ this.trust.onDidChangeWorkspaceTrust(t => {
67
+ if (!initialized) {
68
+ initialized = true;
69
+ this.trusted = t;
70
+ this._ready.resolve();
71
+ return;
72
+ }
73
+ if (this.trusted === t) {
74
+ return;
75
+ }
76
+ this.trusted = t;
77
+ this.onDidChangeTrustEmitter.fire(t);
78
+ });
79
+
80
+ this.trust.getWorkspaceTrust().then(t => {
81
+ if (initialized) {
82
+ return;
83
+ }
84
+ initialized = true;
85
+ this.trusted = t;
86
+ this._ready.resolve();
87
+ }, err => {
88
+ if (initialized) {
89
+ return;
90
+ }
91
+ initialized = true;
92
+ this._ready.reject(err);
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Reads the preference, ignoring workspace/folder scopes when the workspace
98
+ * is untrusted. Returns `globalValue ?? defaultValue ?? fallback`.
99
+ */
100
+ get<T>(preferenceName: string, fallback?: T, resourceUri?: string): T | undefined {
101
+ if (this.trusted) {
102
+ return this.preferences.get<T>(preferenceName, fallback, resourceUri);
103
+ }
104
+ const inspection = this.preferences.inspect<JSONValue>(preferenceName, resourceUri);
105
+ const value = inspection?.globalValue ?? inspection?.defaultValue;
106
+ return (value as T | undefined) ?? fallback;
107
+ }
108
+ }