@theia/ai-core 1.74.0-next.5 → 1.74.0-next.53

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 (82) 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 +11 -16
  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/frontend-prompt-customization-service.d.ts +38 -0
  25. package/lib/browser/frontend-prompt-customization-service.d.ts.map +1 -1
  26. package/lib/browser/frontend-prompt-customization-service.js +189 -76
  27. package/lib/browser/frontend-prompt-customization-service.js.map +1 -1
  28. package/lib/browser/frontend-prompt-customization-service.spec.js +103 -0
  29. package/lib/browser/frontend-prompt-customization-service.spec.js.map +1 -1
  30. package/lib/browser/index.d.ts +1 -0
  31. package/lib/browser/index.d.ts.map +1 -1
  32. package/lib/browser/index.js +1 -0
  33. package/lib/browser/index.js.map +1 -1
  34. package/lib/browser/trust-aware-preference-reader.d.ts +13 -1
  35. package/lib/browser/trust-aware-preference-reader.d.ts.map +1 -1
  36. package/lib/browser/trust-aware-preference-reader.js +21 -2
  37. package/lib/browser/trust-aware-preference-reader.js.map +1 -1
  38. package/lib/browser/trust-aware-preference-reader.spec.js +26 -0
  39. package/lib/browser/trust-aware-preference-reader.spec.js.map +1 -1
  40. package/lib/common/ai-configuration-service.d.ts +104 -0
  41. package/lib/common/ai-configuration-service.d.ts.map +1 -0
  42. package/lib/common/ai-configuration-service.js +20 -0
  43. package/lib/common/ai-configuration-service.js.map +1 -0
  44. package/lib/common/ai-core-preferences.d.ts +4 -0
  45. package/lib/common/ai-core-preferences.d.ts.map +1 -1
  46. package/lib/common/ai-core-preferences.js +20 -1
  47. package/lib/common/ai-core-preferences.js.map +1 -1
  48. package/lib/common/index.d.ts +1 -0
  49. package/lib/common/index.d.ts.map +1 -1
  50. package/lib/common/index.js +1 -0
  51. package/lib/common/index.js.map +1 -1
  52. package/lib/common/language-model.d.ts +56 -2
  53. package/lib/common/language-model.d.ts.map +1 -1
  54. package/lib/common/language-model.js +53 -3
  55. package/lib/common/language-model.js.map +1 -1
  56. package/lib/common/language-model.spec.js +75 -0
  57. package/lib/common/language-model.spec.js.map +1 -1
  58. package/lib/common/prompt-service.d.ts +13 -3
  59. package/lib/common/prompt-service.d.ts.map +1 -1
  60. package/lib/common/prompt-service.js.map +1 -1
  61. package/lib/node/backend-language-model-registry.d.ts.map +1 -1
  62. package/lib/node/backend-language-model-registry.js +1 -0
  63. package/lib/node/backend-language-model-registry.js.map +1 -1
  64. package/package.json +11 -11
  65. package/src/browser/ai-configuration-service-impl.spec.ts +254 -0
  66. package/src/browser/ai-configuration-service-impl.ts +120 -0
  67. package/src/browser/ai-core-frontend-module.ts +7 -1
  68. package/src/browser/ai-settings-service.ts +8 -16
  69. package/src/browser/frontend-language-model-alias-registry.ts +12 -16
  70. package/src/browser/frontend-language-model-service.ts +5 -6
  71. package/src/browser/frontend-prompt-customization-service.spec.ts +130 -1
  72. package/src/browser/frontend-prompt-customization-service.ts +204 -84
  73. package/src/browser/index.ts +1 -0
  74. package/src/browser/trust-aware-preference-reader.spec.ts +29 -0
  75. package/src/browser/trust-aware-preference-reader.ts +23 -3
  76. package/src/common/ai-configuration-service.ts +128 -0
  77. package/src/common/ai-core-preferences.ts +23 -1
  78. package/src/common/index.ts +1 -0
  79. package/src/common/language-model.spec.ts +98 -1
  80. package/src/common/language-model.ts +107 -4
  81. package/src/common/prompt-service.ts +14 -3
  82. package/src/node/backend-language-model-registry.ts +1 -0
@@ -23,9 +23,10 @@ FrontendApplicationConfigProvider.set({});
23
23
  import 'reflect-metadata';
24
24
 
25
25
  import { expect } from 'chai';
26
- import { dump } from 'js-yaml';
26
+ import { dump, load } from 'js-yaml';
27
27
  import URI from '@theia/core/lib/common/uri';
28
28
  import { parseTemplateWithMetadata, ParsedTemplate } from './prompttemplate-parser';
29
+ import { serializeFrontmatter } from '../common/frontmatter';
29
30
  import {
30
31
  CustomizationSource, DefaultPromptFragmentCustomizationService, CUSTOM_AGENTS_DIRECTORY, CUSTOM_AGENT_FILE_NAME
31
32
  } from './frontend-prompt-customization-service';
@@ -518,6 +519,12 @@ describe('DefaultPromptFragmentCustomizationService - customAgents.yml migration
518
519
  protected override getTemplatesDirectoryURI(): Promise<URI> {
519
520
  return Promise.resolve(this.templatesDir);
520
521
  }
522
+ testParse(raw: string): unknown {
523
+ return this.parseCustomAgentsYaml(raw);
524
+ }
525
+ testUnfold(raw: string): string {
526
+ return this.unfoldPromptBlockScalars(raw);
527
+ }
521
528
  }
522
529
 
523
530
  const scope = new URI('file:///ws/.prompts');
@@ -617,6 +624,128 @@ describe('DefaultPromptFragmentCustomizationService - customAgents.yml migration
617
624
  // ...and because a backup already exists, the yaml is left untouched for inspection.
618
625
  expect(await fileService.exists(yamlURI)).to.be.true;
619
626
  });
627
+
628
+ describe('folded block-scalar prompts', () => {
629
+ // A folded YAML scalar (`>-`) collapses single newlines into spaces, which merges markdown
630
+ // headings into the paragraph below them. Migration must recover the original line breaks.
631
+ const foldedYaml =
632
+ '- id: rel\n' +
633
+ ' name: Rel\n' +
634
+ ' description: Release agent\n' +
635
+ ' defaultLLM: default/code\n' +
636
+ ' prompt: >-\n' +
637
+ ' ## Task\n' +
638
+ ' Do the thing.\n' +
639
+ '\n' +
640
+ ' ## Workflow\n' +
641
+ ' Step one.\n';
642
+
643
+ it('migrates a folded prompt with its headings on separate lines', async () => {
644
+ fileService.write(yamlURI, foldedYaml);
645
+
646
+ const [report] = await service.migrateCustomAgentsYaml();
647
+
648
+ expect(report.migrated).to.equal(1);
649
+ const content = fileService.content(agentMd('rel'))!;
650
+ // js-yaml would have folded these into '## Task Do the thing.'; the fix keeps them split.
651
+ expect(content).to.contain('## Task\nDo the thing.');
652
+ expect(content).to.contain('## Workflow\nStep one.');
653
+ expect(content).to.not.contain('## Task Do the thing.');
654
+ });
655
+
656
+ it('loads an un-migrated folded prompt with separated headings at runtime', async () => {
657
+ fileService.write(yamlURI, foldedYaml);
658
+
659
+ const [agent] = await service.getCustomAgents();
660
+
661
+ expect(agent.prompt).to.contain('## Task\nDo the thing.');
662
+ expect(agent.prompt).to.not.contain('## Task Do the thing.');
663
+ });
664
+
665
+ it('leaves plain and quoted prompt values untouched', () => {
666
+ const plain = 'prompt: just a short prompt';
667
+ const quoted = 'prompt: "> not a header"';
668
+ expect(service.testUnfold(plain)).to.equal(plain);
669
+ expect(service.testUnfold(quoted)).to.equal(quoted);
670
+ // Folded headers (with or without indicators) become literal.
671
+ expect(service.testUnfold('prompt: >-')).to.equal('prompt: |-');
672
+ expect(service.testUnfold('prompt: >2-')).to.equal('prompt: |2-');
673
+ });
674
+ });
675
+
676
+ describe('correcting already-migrated agents', () => {
677
+ // Reproduces the bytes the v1.73.0 migration wrote: parse the backup the old (folded) way and
678
+ // serialize it with the same frontmatter format the migration used.
679
+ const buggyAgentMd = (raw: string, id: string): string => {
680
+ const entry = (load(raw) as Array<{ id: string; name: string; description: string; prompt: string; defaultLLM: string }>).find(e => e.id === id)!;
681
+ return serializeFrontmatter({ name: entry.name, description: entry.description, defaultLLM: entry.defaultLLM }, entry.prompt);
682
+ };
683
+ const foldedYaml =
684
+ '- id: rel\n' +
685
+ ' name: Rel\n' +
686
+ ' description: Release agent\n' +
687
+ ' defaultLLM: default/code\n' +
688
+ ' prompt: >-\n' +
689
+ ' ## Task\n' +
690
+ ' Do the thing.\n';
691
+
692
+ it('rewrites an untouched agent.md to recover folded headings', async () => {
693
+ // Simulate the post-v1.73.0 state: yaml already backed up, agent.md written with merged headings.
694
+ fileService.write(backupURI, foldedYaml);
695
+ fileService.write(agentMd('rel'), buggyAgentMd(foldedYaml, 'rel'));
696
+
697
+ const [report] = await service.migrateCustomAgentsYaml();
698
+
699
+ expect(report.corrected).to.equal(1);
700
+ expect(fileService.content(agentMd('rel'))!).to.contain('## Task\nDo the thing.');
701
+ });
702
+
703
+ it('does not touch an agent.md the user edited after migration', async () => {
704
+ fileService.write(backupURI, foldedYaml);
705
+ const edited = buggyAgentMd(foldedYaml, 'rel') + '\n\nManually added section.';
706
+ fileService.write(agentMd('rel'), edited);
707
+
708
+ const reports = await service.migrateCustomAgentsYaml();
709
+
710
+ // Nothing is migrated and the edited file is not eligible for correction, so no report.
711
+ expect(reports).to.have.lengthOf(0);
712
+ expect(fileService.content(agentMd('rel'))).to.equal(edited);
713
+ });
714
+
715
+ it('reports nothing to do when the backup prompt has no folded headings', async () => {
716
+ fileService.write(backupURI, dump(agents));
717
+ fileService.write(agentMd('foo'), buggyAgentMd(dump(agents), 'foo'));
718
+
719
+ const reports = await service.migrateCustomAgentsYaml();
720
+
721
+ // Nothing to migrate and nothing correctable -> no report for the scope.
722
+ expect(reports).to.have.lengthOf(0);
723
+ });
724
+ });
725
+
726
+ describe('hasPendingCustomAgentMigration', () => {
727
+ it('is true while a legacy customAgents.yml exists', async () => {
728
+ fileService.write(yamlURI, dump(agents));
729
+ expect(await service.hasPendingCustomAgentMigration()).to.be.true;
730
+ });
731
+
732
+ it('is true when a backup has correctable agents', async () => {
733
+ const foldedYaml =
734
+ '- id: rel\n name: Rel\n description: d\n defaultLLM: default/code\n prompt: >-\n ## Task\n Body.\n';
735
+ fileService.write(backupURI, foldedYaml);
736
+ const entry = (load(foldedYaml) as Array<{ name: string; description: string; prompt: string; defaultLLM: string }>)[0];
737
+ fileService.write(agentMd('rel'), serializeFrontmatter({ name: entry.name, description: entry.description, defaultLLM: entry.defaultLLM }, entry.prompt));
738
+
739
+ expect(await service.hasPendingCustomAgentMigration()).to.be.true;
740
+ });
741
+
742
+ it('is false once everything is migrated and correct', async () => {
743
+ fileService.write(yamlURI, dump(agents));
744
+ await service.migrateCustomAgentsYaml();
745
+
746
+ expect(await service.hasPendingCustomAgentMigration()).to.be.false;
747
+ });
748
+ });
620
749
  });
621
750
 
622
751
  describe('DefaultPromptFragmentCustomizationService - custom agent scopes', () => {
@@ -1669,7 +1669,7 @@ export class DefaultPromptFragmentCustomizationService implements PromptFragment
1669
1669
 
1670
1670
  try {
1671
1671
  const fileContent = await this.fileService.read(customAgentYamlUri, { encoding: 'utf-8' });
1672
- const doc = load(fileContent.value);
1672
+ const doc = this.parseCustomAgentsYaml(fileContent.value);
1673
1673
 
1674
1674
  if (!Array.isArray(doc) || !doc.every(entry => CustomAgentDescription.is(entry))) {
1675
1675
  this.logger.debug(`Invalid customAgents.yml file content in ${directoryURI.toString()}`);
@@ -1736,6 +1736,47 @@ export class DefaultPromptFragmentCustomizationService implements PromptFragment
1736
1736
  return fileURI;
1737
1737
  }
1738
1738
 
1739
+ /**
1740
+ * Parse a `customAgents.yml` document, preserving newlines in folded-block-scalar prompts.
1741
+ *
1742
+ * `js-yaml` folds the single newlines of a folded scalar (`>` / `>-`) into spaces, which merges
1743
+ * a markdown heading (`## Task`) into the paragraph below it. Markdown is newline-sensitive, so
1744
+ * we re-interpret folded `prompt:` scalars as literal (`|`) before parsing. Only the block-scalar
1745
+ * header is rewritten; quoted, plain and already-literal values are left untouched.
1746
+ *
1747
+ * Known limitation: prompts stored as a plain or quoted multi-line scalar (rare) still fold.
1748
+ */
1749
+ protected parseCustomAgentsYaml(content: string): unknown {
1750
+ return load(this.unfoldPromptBlockScalars(content));
1751
+ }
1752
+
1753
+ /**
1754
+ * Rewrite a folded `prompt:` block-scalar header (`>`, `>-`, `>+`, `>2-`, ...) to its literal
1755
+ * equivalent (`|`...). The lookahead ensures only a block-scalar header is matched (indentation
1756
+ * and chomping indicators plus an optional trailing comment), so plain/quoted prompt values and
1757
+ * any `>` inside the prompt body are never touched.
1758
+ */
1759
+ protected unfoldPromptBlockScalars(content: string): string {
1760
+ return content.replace(/^([ \t]*prompt:[ \t]*)>(?=[-+0-9]*[ \t]*(?:#.*)?$)/mg, '$1|');
1761
+ }
1762
+
1763
+ /**
1764
+ * Returns `true` if migration would write anything: a scope still holds a legacy
1765
+ * `customAgents.yml`, or a previously migrated scope has `agent.md` files that can be corrected
1766
+ * (see {@link computeScopeCorrections}). Read-only; used to decide whether to prompt the user.
1767
+ */
1768
+ async hasPendingCustomAgentMigration(): Promise<boolean> {
1769
+ for (const scope of await this.getCustomAgentScopes()) {
1770
+ if (await this.fileService.exists(scope.resolve('customAgents.yml'))) {
1771
+ return true;
1772
+ }
1773
+ if ((await this.computeScopeCorrections(scope)).length > 0) {
1774
+ return true;
1775
+ }
1776
+ }
1777
+ return false;
1778
+ }
1779
+
1739
1780
  /**
1740
1781
  * Auto-migrate every legacy `customAgents.yml` reachable from the configured scopes to the new
1741
1782
  * `agents/<id>/agent.md` layout. The original content is never deleted:
@@ -1769,7 +1810,7 @@ export class DefaultPromptFragmentCustomizationService implements PromptFragment
1769
1810
  reports.push(report);
1770
1811
  }
1771
1812
  }
1772
- if (reports.some(r => r.migrated > 0)) {
1813
+ if (reports.some(r => r.migrated > 0 || r.corrected > 0)) {
1773
1814
  this.onDidChangeCustomAgentsEmitter.fire();
1774
1815
  }
1775
1816
  return reports;
@@ -1777,116 +1818,193 @@ export class DefaultPromptFragmentCustomizationService implements PromptFragment
1777
1818
 
1778
1819
  protected async migrateSingleScope(scopeDir: URI): Promise<MigrationReport | undefined> {
1779
1820
  const yamlURI = scopeDir.resolve('customAgents.yml');
1780
- if (!(await this.fileService.exists(yamlURI))) {
1781
- return undefined;
1782
- }
1783
- this.logger.info(`Migrating custom agents from ${yamlURI.toString()}`);
1784
- let entries: CustomAgentDescription[];
1785
- try {
1786
- const fileContent = await this.fileService.read(yamlURI, { encoding: 'utf-8' });
1787
- const doc = load(fileContent.value);
1788
- if (!Array.isArray(doc) || !doc.every(CustomAgentDescription.is)) {
1789
- this.logger.warn(`Skipping migration of ${yamlURI.toString()}: file content is not a valid CustomAgentDescription[]`);
1790
- return { scope: scopeDir, yamlURI, migrated: 0, alreadyPresent: 0, failed: 0, yamlBackedUp: false, promptOverridesMigrated: 0 };
1791
- }
1792
- entries = doc;
1793
- } catch (e) {
1794
- this.logger.warn(`Skipping migration of ${yamlURI.toString()}: ${e.message}`);
1795
- return { scope: scopeDir, yamlURI, migrated: 0, alreadyPresent: 0, failed: 0, yamlBackedUp: false, promptOverridesMigrated: 0 };
1796
- }
1821
+ const backupURI = scopeDir.resolve('customAgents.yml.bak');
1822
+ const yamlExists = await this.fileService.exists(yamlURI);
1797
1823
 
1798
1824
  let migrated = 0;
1799
1825
  let alreadyPresent = 0;
1800
1826
  let failed = 0;
1801
1827
  let promptOverridesMigrated = 0;
1828
+ let yamlBackedUp = false;
1802
1829
 
1803
- // A previous failed migration may have created `agents/<id>/` directories without an
1804
- // `agent.md` inside. Remove those empty placeholders so this run can recreate them cleanly.
1805
- await this.cleanupEmptyAgentFolders(scopeDir);
1830
+ const emptyReport = (): MigrationReport => ({
1831
+ scope: scopeDir, yamlURI, migrated: 0, alreadyPresent: 0, failed: 0,
1832
+ yamlBackedUp: false, promptOverridesMigrated: 0, corrected: 0
1833
+ });
1806
1834
 
1807
- // Snapshot scope-root template files once so we can match per agent without re-listing.
1808
- const scopeRootTemplateFiles = await this.listScopeRootPromptTemplates(scopeDir);
1835
+ if (yamlExists) {
1836
+ this.logger.info(`Migrating custom agents from ${yamlURI.toString()}`);
1837
+ let entries: CustomAgentDescription[];
1838
+ try {
1839
+ const fileContent = await this.fileService.read(yamlURI, { encoding: 'utf-8' });
1840
+ const doc = this.parseCustomAgentsYaml(fileContent.value);
1841
+ if (!Array.isArray(doc) || !doc.every(CustomAgentDescription.is)) {
1842
+ this.logger.warn(`Skipping migration of ${yamlURI.toString()}: file content is not a valid CustomAgentDescription[]`);
1843
+ return emptyReport();
1844
+ }
1845
+ entries = doc;
1846
+ } catch (e) {
1847
+ this.logger.warn(`Skipping migration of ${yamlURI.toString()}: ${e.message}`);
1848
+ return emptyReport();
1849
+ }
1809
1850
 
1810
- // Prompt-fragment files are matched by agent *name* (the fragment id is `<name>_prompt`)
1811
- // while folders are keyed by the unique *id*. When two entries share a name, only the first
1812
- // can own the name-based files, so track seen names and skip the move for the duplicates.
1813
- const seenNames = new Set<string>();
1851
+ // A previous failed migration may have created `agents/<id>/` directories without an
1852
+ // `agent.md` inside. Remove those empty placeholders so this run can recreate them cleanly.
1853
+ await this.cleanupEmptyAgentFolders(scopeDir);
1814
1854
 
1815
- for (const entry of entries) {
1816
- const agentFolderURI = scopeDir.resolve(CUSTOM_AGENTS_DIRECTORY).resolve(entry.id);
1817
- const targetURI = agentFolderURI.resolve(CUSTOM_AGENT_FILE_NAME);
1818
- if (await this.fileService.exists(targetURI)) {
1819
- alreadyPresent++;
1820
- } else {
1821
- try {
1822
- await this.fileService.createFile(
1823
- targetURI,
1824
- BinaryBuffer.fromString(serializeCustomAgentFile(entry)),
1825
- { overwrite: true }
1855
+ // Snapshot scope-root template files once so we can match per agent without re-listing.
1856
+ const scopeRootTemplateFiles = await this.listScopeRootPromptTemplates(scopeDir);
1857
+
1858
+ // Prompt-fragment files are matched by agent *name* (the fragment id is `<name>_prompt`)
1859
+ // while folders are keyed by the unique *id*. When two entries share a name, only the first
1860
+ // can own the name-based files, so track seen names and skip the move for the duplicates.
1861
+ const seenNames = new Set<string>();
1862
+
1863
+ for (const entry of entries) {
1864
+ const agentFolderURI = scopeDir.resolve(CUSTOM_AGENTS_DIRECTORY).resolve(entry.id);
1865
+ const targetURI = agentFolderURI.resolve(CUSTOM_AGENT_FILE_NAME);
1866
+ if (await this.fileService.exists(targetURI)) {
1867
+ alreadyPresent++;
1868
+ } else {
1869
+ try {
1870
+ await this.fileService.createFile(
1871
+ targetURI,
1872
+ BinaryBuffer.fromString(serializeCustomAgentFile(entry)),
1873
+ { overwrite: true }
1874
+ );
1875
+ migrated++;
1876
+ } catch (e) {
1877
+ this.logger.warn(`Failed to migrate agent '${entry.id}' from ${yamlURI.toString()}: ${e?.message ?? e}`, e);
1878
+ failed++;
1879
+ continue;
1880
+ }
1881
+ }
1882
+
1883
+ if (seenNames.has(entry.name)) {
1884
+ this.logger.warn(
1885
+ `Multiple custom agents in ${yamlURI.toString()} share the name '${entry.name}'; ` +
1886
+ 'prompt fragment files were migrated to the first matching agent only.'
1826
1887
  );
1827
- migrated++;
1828
- } catch (e) {
1829
- this.logger.warn(`Failed to migrate agent '${entry.id}' from ${yamlURI.toString()}: ${e?.message ?? e}`, e);
1830
- failed++;
1831
1888
  continue;
1832
1889
  }
1890
+ seenNames.add(entry.name);
1891
+
1892
+ // Move any scope-root .prompttemplate files that look like they belong to this agent
1893
+ // (filename stem starts with `<name>_prompt`, followed by EOF or a separator).
1894
+ // They become variants under `<scope>/agents/<id>/`.
1895
+ for (const file of scopeRootTemplateFiles) {
1896
+ if (!matchesAgentPromptPrefix(file.stem, entry.name)) {
1897
+ continue;
1898
+ }
1899
+ const destURI = agentFolderURI.resolve(file.uri.path.base);
1900
+ if (await this.fileService.exists(destURI)) {
1901
+ continue;
1902
+ }
1903
+ try {
1904
+ await this.fileService.move(file.uri, destURI);
1905
+ promptOverridesMigrated++;
1906
+ } catch (e) {
1907
+ this.logger.warn(`Failed to move prompt fragment ${file.uri.toString()} into ${destURI.toString()}: ${e?.message ?? e}`);
1908
+ }
1909
+ }
1833
1910
  }
1834
1911
 
1835
- if (seenNames.has(entry.name)) {
1836
- this.logger.warn(
1837
- `Multiple custom agents in ${yamlURI.toString()} share the name '${entry.name}'; ` +
1838
- 'prompt fragment files were migrated to the first matching agent only.'
1839
- );
1840
- continue;
1841
- }
1842
- seenNames.add(entry.name);
1843
-
1844
- // Move any scope-root .prompttemplate files that look like they belong to this agent
1845
- // (filename stem starts with `<name>_prompt`, followed by EOF or a separator).
1846
- // They become variants under `<scope>/agents/<id>/`.
1847
- for (const file of scopeRootTemplateFiles) {
1848
- if (!matchesAgentPromptPrefix(file.stem, entry.name)) {
1849
- continue;
1850
- }
1851
- const destURI = agentFolderURI.resolve(file.uri.path.base);
1852
- if (await this.fileService.exists(destURI)) {
1853
- continue;
1912
+ if (failed === 0) {
1913
+ try {
1914
+ await this.fileService.move(yamlURI, backupURI, { overwrite: true });
1915
+ yamlBackedUp = true;
1916
+ } catch (e) {
1917
+ this.logger.warn(`Migrated ${migrated} agents but failed to back up ${yamlURI.toString()}: ${e.message}`);
1854
1918
  }
1919
+ } else {
1855
1920
  try {
1856
- await this.fileService.move(file.uri, destURI);
1857
- promptOverridesMigrated++;
1921
+ if (!(await this.fileService.exists(backupURI))) {
1922
+ await this.fileService.move(yamlURI, backupURI);
1923
+ yamlBackedUp = true;
1924
+ }
1858
1925
  } catch (e) {
1859
- this.logger.warn(`Failed to move prompt fragment ${file.uri.toString()} into ${destURI.toString()}: ${e?.message ?? e}`);
1926
+ this.logger.warn(`Failed to back up ${yamlURI.toString()} to ${backupURI.toString()}: ${e.message}`);
1860
1927
  }
1861
1928
  }
1862
1929
  }
1863
1930
 
1864
- let yamlBackedUp = false;
1865
- const backupURI = scopeDir.resolve('customAgents.yml.bak');
1866
- if (failed === 0) {
1931
+ // Correct any `agent.md` left with merged headings by an earlier (v1.73.0) migration. Anything
1932
+ // just migrated above is already correct, so only stale files from a previous run are rewritten.
1933
+ let corrected = 0;
1934
+ for (const { uri, content } of await this.computeScopeCorrections(scopeDir)) {
1867
1935
  try {
1868
- await this.fileService.move(yamlURI, backupURI, { overwrite: true });
1869
- yamlBackedUp = true;
1936
+ await this.fileService.createFile(uri, BinaryBuffer.fromString(content), { overwrite: true });
1937
+ corrected++;
1870
1938
  } catch (e) {
1871
- this.logger.warn(`Migrated ${migrated} agents but failed to back up ${yamlURI.toString()}: ${e.message}`);
1872
- }
1873
- } else {
1874
- try {
1875
- if (!(await this.fileService.exists(backupURI))) {
1876
- await this.fileService.move(yamlURI, backupURI);
1877
- yamlBackedUp = true;
1878
- }
1879
- } catch (e) {
1880
- this.logger.warn(`Failed to back up ${yamlURI.toString()} to ${backupURI.toString()}: ${e.message}`);
1939
+ this.logger.warn(`Failed to correct migrated agent file ${uri.toString()}: ${e?.message ?? e}`);
1881
1940
  }
1882
1941
  }
1883
1942
 
1943
+ if (!yamlExists && corrected === 0) {
1944
+ return undefined; // nothing to migrate and nothing to correct in this scope
1945
+ }
1946
+
1884
1947
  this.logger.info(
1885
- `Migration done for ${yamlURI.toString()}: ` +
1886
- `migrated=${migrated}, alreadyPresent=${alreadyPresent}, failed=${failed}, ` +
1887
- `yamlBackedUp=${yamlBackedUp}, promptOverridesMigrated=${promptOverridesMigrated}`
1948
+ `Migration done for ${scopeDir.toString()}: migrated=${migrated}, alreadyPresent=${alreadyPresent}, ` +
1949
+ `failed=${failed}, yamlBackedUp=${yamlBackedUp}, promptOverridesMigrated=${promptOverridesMigrated}, corrected=${corrected}`
1888
1950
  );
1889
- return { scope: scopeDir, yamlURI, migrated, alreadyPresent, failed, yamlBackedUp, promptOverridesMigrated };
1951
+ return { scope: scopeDir, yamlURI, migrated, alreadyPresent, failed, yamlBackedUp, promptOverridesMigrated, corrected };
1952
+ }
1953
+
1954
+ /**
1955
+ * Compute the `agent.md` rewrites needed to recover headings folded by an earlier (v1.73.0)
1956
+ * migration. The backup (`customAgents.yml.bak`) is the source that migration used: parsing it
1957
+ * the old (folded) way reproduces the exact bytes it wrote, and parsing it the new (literal) way
1958
+ * produces the corrected content. Only files still byte-identical to the buggy output are eligible,
1959
+ * so files the user edited (and already-correct files) are left untouched. Read-only; used both to
1960
+ * detect pending work and to perform the rewrite.
1961
+ */
1962
+ protected async computeScopeCorrections(scopeDir: URI): Promise<Array<{ uri: URI; content: string }>> {
1963
+ const backupURI = scopeDir.resolve('customAgents.yml.bak');
1964
+ if (!(await this.fileService.exists(backupURI))) {
1965
+ return [];
1966
+ }
1967
+ let oldEntries: unknown;
1968
+ let newEntries: unknown;
1969
+ try {
1970
+ const raw = (await this.fileService.read(backupURI, { encoding: 'utf-8' })).value;
1971
+ oldEntries = load(raw); // reproduces the folded (buggy) output
1972
+ newEntries = this.parseCustomAgentsYaml(raw); // produces the heading-preserving fix
1973
+ } catch {
1974
+ return [];
1975
+ }
1976
+ if (!Array.isArray(oldEntries) || !oldEntries.every(CustomAgentDescription.is)
1977
+ || !Array.isArray(newEntries) || !newEntries.every(CustomAgentDescription.is)) {
1978
+ return [];
1979
+ }
1980
+ const fixedById = new Map((newEntries as CustomAgentDescription[]).map(e => [e.id, e] as const));
1981
+ const corrections: Array<{ uri: URI; content: string }> = [];
1982
+ for (const oldEntry of oldEntries as CustomAgentDescription[]) {
1983
+ const fixed = fixedById.get(oldEntry.id);
1984
+ if (!fixed) {
1985
+ continue;
1986
+ }
1987
+ const buggyContent = serializeCustomAgentFile(oldEntry);
1988
+ const fixedContent = serializeCustomAgentFile(fixed);
1989
+ if (buggyContent === fixedContent) {
1990
+ continue; // prompt had no folded headings to recover
1991
+ }
1992
+ const agentMdURI = scopeDir.resolve(CUSTOM_AGENTS_DIRECTORY).resolve(oldEntry.id).resolve(CUSTOM_AGENT_FILE_NAME);
1993
+ if (!(await this.fileService.exists(agentMdURI))) {
1994
+ continue;
1995
+ }
1996
+ let current: string;
1997
+ try {
1998
+ current = (await this.fileService.read(agentMdURI, { encoding: 'utf-8' })).value;
1999
+ } catch {
2000
+ continue;
2001
+ }
2002
+ // Only rewrite files untouched since migration; current bytes still matching the buggy output.
2003
+ if (current === buggyContent) {
2004
+ corrections.push({ uri: agentMdURI, content: fixedContent });
2005
+ }
2006
+ }
2007
+ return corrections;
1890
2008
  }
1891
2009
 
1892
2010
  /**
@@ -1923,6 +2041,8 @@ export interface MigrationReport {
1923
2041
  yamlBackedUp: boolean;
1924
2042
  /** Number of scope-root prompt customization files (`<name>_prompt.prompttemplate`) folded into agent.md and deleted. */
1925
2043
  promptOverridesMigrated: number;
2044
+ /** Number of already-migrated `agent.md` files rewritten to recover folded headings. */
2045
+ corrected: number;
1926
2046
  }
1927
2047
 
1928
2048
  /**
@@ -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';
@@ -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
  }