From cb855bd361ce4d54a0c81190c3effdde8a6aa7ac Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Fri, 15 May 2026 08:01:25 +1000 Subject: [PATCH] feat: implement session customization discovery and bundling for agent host (#316405) * feat: implement session customization discovery and bundling for agent host * Avoid duplicate global file-change listeners in customization discovery Co-authored-by: DonJayamanne <1948812+DonJayamanne@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .../common/agentHostFileSystemService.ts | 29 ++- .../agentHost/common/agentPluginManager.ts | 8 + .../agentHost/node/agentPluginManager.ts | 4 + .../agentHost/node/copilot/copilotAgent.ts | 125 ++++++++++- .../copilot/sessionCustomizationDiscovery.ts | 199 ++++++++++++++++++ .../node/copilot/sessionPluginBundler.ts | 137 ++++++++++++ .../agentHost/test/node/copilotAgent.test.ts | 2 + .../sessionCustomizationDiscovery.test.ts | 131 ++++++++++++ .../common/agentHostFileSystemService.ts | 9 +- 9 files changed, 634 insertions(+), 10 deletions(-) create mode 100644 src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts create mode 100644 src/vs/platform/agentHost/node/copilot/sessionPluginBundler.ts create mode 100644 src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts diff --git a/src/vs/platform/agentHost/common/agentHostFileSystemService.ts b/src/vs/platform/agentHost/common/agentHostFileSystemService.ts index ddff1d24824..9df00e2270d 100644 --- a/src/vs/platform/agentHost/common/agentHostFileSystemService.ts +++ b/src/vs/platform/agentHost/common/agentHostFileSystemService.ts @@ -5,6 +5,7 @@ import { Disposable, IDisposable } from '../../../base/common/lifecycle.js'; import { IFileService } from '../../files/common/files.js'; +import { InMemoryFileSystemProvider } from '../../files/common/inMemoryFilesystemProvider.js'; import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { ILabelService } from '../../label/common/label.js'; @@ -13,6 +14,14 @@ import { AGENT_HOST_LABEL_FORMATTER, AGENT_HOST_SCHEME } from './agentHostUri.js export type { IRemoteFilesystemConnection } from './agentHostFileSystemProvider.js'; +/** + * Scheme used for the in-memory plugin filesystem backing synced customizations. + * + * URIs under this scheme are served by a registered {@link InMemoryFileSystemProvider} + * and are reachable by the agent host via `fetchContent`. + */ +export const SYNCED_CUSTOMIZATION_SCHEME = 'vscode-synced-customization'; + export const IAgentHostFileSystemService = createDecorator('agentHostFileSystemService'); export interface IAgentHostFileSystemService { @@ -23,27 +32,43 @@ export interface IAgentHostFileSystemService { * `vscode-agent-host://[authority]/…` URIs resolve through this connection. */ registerAuthority(authority: string, connection: IRemoteFilesystemConnection): IDisposable; + + /** + * Ensures the in-memory filesystem provider for synced customizations + * (`vscode-synced-customization:` scheme) is registered. Safe to call + * multiple times — only the first call registers the provider. + */ + ensureSyncedCustomizationProvider(): void; } class AgentHostFileSystemService extends Disposable implements IAgentHostFileSystemService { declare readonly _serviceBrand: undefined; private readonly _fsProvider: AgentHostFileSystemProvider; + private _syncedCustomizationProviderRegistered = false; constructor( - @IFileService fileService: IFileService, + @IFileService private readonly _fileService: IFileService, @ILabelService labelService: ILabelService, ) { super(); this._fsProvider = this._register(new AgentHostFileSystemProvider()); - this._register(fileService.registerProvider(AGENT_HOST_SCHEME, this._fsProvider)); + this._register(_fileService.registerProvider(AGENT_HOST_SCHEME, this._fsProvider)); this._register(labelService.registerFormatter(AGENT_HOST_LABEL_FORMATTER)); } registerAuthority(authority: string, connection: IRemoteFilesystemConnection): IDisposable { return this._fsProvider.registerAuthority(authority, connection); } + + ensureSyncedCustomizationProvider(): void { + if (!this._syncedCustomizationProviderRegistered) { + this._syncedCustomizationProviderRegistered = true; + const provider = this._register(new InMemoryFileSystemProvider()); + this._register(this._fileService.registerProvider(SYNCED_CUSTOMIZATION_SCHEME, provider)); + } + } } registerSingleton(IAgentHostFileSystemService, AgentHostFileSystemService, InstantiationType.Delayed); diff --git a/src/vs/platform/agentHost/common/agentPluginManager.ts b/src/vs/platform/agentHost/common/agentPluginManager.ts index 035c530f986..eee3e7505bb 100644 --- a/src/vs/platform/agentHost/common/agentPluginManager.ts +++ b/src/vs/platform/agentHost/common/agentPluginManager.ts @@ -29,6 +29,14 @@ export interface ISyncedCustomization { export interface IAgentPluginManager { readonly _serviceBrand: undefined; + /** + * Root directory under which all agent plugin data is materialized. + * Exposed so other host-side components can carve out sibling + * directories for their own bundles (e.g. session-discovered + * customizations) without having to thread `userDataPath` separately. + */ + readonly basePath: URI; + /** * Syncs a set of client-provided customization refs to local storage. * diff --git a/src/vs/platform/agentHost/node/agentPluginManager.ts b/src/vs/platform/agentHost/node/agentPluginManager.ts index 07ad50cdef6..6d6fb46a8d2 100644 --- a/src/vs/platform/agentHost/node/agentPluginManager.ts +++ b/src/vs/platform/agentHost/node/agentPluginManager.ts @@ -61,6 +61,10 @@ export class AgentPluginManager implements IAgentPluginManager { this._maxPlugins = maxPlugins; } + get basePath(): URI { + return this._basePath; + } + async syncCustomizations( clientId: string, customizations: CustomizationRef[], diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 432bbc4d145..ecc53ba892a 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -41,6 +41,8 @@ import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitP import { parsedPluginsEqual, toSdkCustomAgents, toSdkHooks, toSdkMcpServers, toSdkSkillDirectories } from './copilotPluginConverters.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import { ShellManager, createShellTools } from './copilotShellTools.js'; +import { SessionCustomizationDiscovery } from './sessionCustomizationDiscovery.js'; +import { SessionPluginBundler } from './sessionPluginBundler.js'; interface ICreatedWorktree { readonly repositoryRoot: URI; @@ -1688,6 +1690,80 @@ interface IResolvedCustomization { readonly plugin?: IParsedPlugin; } +/** + * A per-working-directory bundle of customizations the agent host + * discovered itself from disk (workspace + user-home conventions). + * + * Owns a {@link SessionCustomizationDiscovery} (filesystem scan + + * watchers) and a {@link SessionPluginBundler} (in-memory synthetic + * Open Plugin under the `vscode-synced-customization:` scheme). + * + * Refreshes itself when the discovery fires `onDidChange`. The owning + * {@link PluginController} is notified via the supplied `onDidRefresh` + * callback so it can re-fire its own change event and (indirectly) cause + * sessions to pick up the new bundle through the existing + * `isOutdated` snapshot path. + */ +class SessionDiscoveredEntry extends Disposable { + + private readonly _discovery: SessionCustomizationDiscovery; + private readonly _bundler: SessionPluginBundler; + + private _resolved: IResolvedCustomization | undefined; + private _settled: Promise; + + constructor( + workingDirectory: URI, + userHome: URI, + private readonly _resolvePlugin: (uri: URI) => Promise, + private readonly _onDidRefresh: () => void, + private readonly _logService: ILogService, + instantiationService: IInstantiationService, + ) { + super(); + this._discovery = this._register(instantiationService.createInstance(SessionCustomizationDiscovery, workingDirectory, userHome)); + this._bundler = this._register(instantiationService.createInstance(SessionPluginBundler, workingDirectory)); + this._settled = this._refresh(); + this._register(this._discovery.onDidChange(() => { + this._settled = this._refresh().finally(() => this._onDidRefresh()); + })); + } + + whenSettled(): Promise { + return this._settled; + } + + currentResolved(): IResolvedCustomization | undefined { + return this._resolved; + } + + private async _refresh(): Promise { + try { + const files = await this._discovery.files(); + const bundleResult = await this._bundler.bundle(files); + if (!bundleResult) { + this._resolved = undefined; + return; + } + const pluginDir = URI.parse(bundleResult.ref.uri); + const plugin = await this._resolvePlugin(pluginDir); + this._resolved = { + customization: { + customization: bundleResult.ref, + enabled: true, + status: plugin ? CustomizationStatus.Loaded : CustomizationStatus.Error, + statusMessage: plugin ? undefined : localize('copilotAgent.pluginParseError', "Error parsing plugin."), + }, + pluginDir, + plugin, + }; + } catch (err) { + this._logService.warn(`[Copilot:SessionDiscoveredEntry] Discovery/bundle failed: ${err instanceof Error ? err.message : String(err)}`); + this._resolved = undefined; + } + } +} + class PluginController extends Disposable { private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange = this._onDidChange.event; @@ -1701,11 +1777,19 @@ class PluginController extends Disposable { private _hostRevision = 0; private _lastAppliedRefs: readonly CustomizationRef[] = []; + /** + * Per-working-directory bundles built from on-disk discovery + * (workspace + user-home conventions). Lazily created on first access + * by {@link _getOrCreateSessionEntry}; lifetime tied to this controller. + */ + private readonly _sessionDiscovered = new Map(); + constructor( @IAgentPluginManager private readonly _pluginManager: IAgentPluginManager, @ILogService private readonly _logService: ILogService, @IFileService private readonly _fileService: IFileService, @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); @@ -1716,21 +1800,36 @@ class PluginController extends Disposable { })); } + override dispose(): void { + for (const entry of this._sessionDiscovered.values()) { + entry.dispose(); + } + this._sessionDiscovered.clear(); + super.dispose(); + } + public getConfiguredHostCustomizations(): readonly CustomizationRef[] { return this._hostCustomizations.map(item => item.customization.customization); } public getSessionCustomizations(directory: URI | undefined): readonly SessionCustomization[] { - return [ + const result: SessionCustomization[] = [ ...this._hostCustomizations.map(item => this._applyEnablement(item.customization)), ...this._clientCustomizations.map(item => this._applyEnablement(item.customization)), ]; + const entry = directory ? this._getOrCreateSessionEntry(directory) : undefined; + const sessionResolved = entry?.currentResolved(); + if (sessionResolved) { + result.push(this._applyEnablement(sessionResolved.customization)); + } + return result; } /** * Returns the current parsed plugins, awaiting any pending sync. */ public async getAppliedPlugins(directory: URI | undefined): Promise { + const entry = directory ? this._getOrCreateSessionEntry(directory) : undefined; const [host, client] = await Promise.all([ this._hostSync.catch(err => { this._logService.warn('[Copilot:PluginController] Host customization update failed', err); @@ -1740,8 +1839,14 @@ class PluginController extends Disposable { this._logService.warn('[Copilot:PluginController] Customization sync failed', err); return this._clientCustomizations; }), + entry?.whenSettled(), ]); + const sessionResolved = entry?.currentResolved(); + const sessionPlugins: IParsedPlugin[] = sessionResolved?.plugin && this._isEnabled(sessionResolved.customization) + ? [sessionResolved.plugin] + : []; + return [ ...host.filter(item => !!item.plugin @@ -1751,9 +1856,27 @@ class PluginController extends Disposable { !!item.plugin && this._isEnabled(item.customization) ).map(item => item.plugin!), + ...sessionPlugins, ]; } + private _getOrCreateSessionEntry(directory: URI): SessionDiscoveredEntry { + const key = directory.toString(); + let entry = this._sessionDiscovered.get(key); + if (!entry) { + entry = new SessionDiscoveredEntry( + directory, + URI.file(this._getUserHome()), + uri => this._tryParsePlugin(uri), + () => this._onDidChange.fire(), + this._logService, + this._instantiationService, + ); + this._sessionDiscovered.set(key, entry); + } + return entry; + } + public setEnabled(pluginProtocolUri: string, enabled: boolean) { this._enablement.set(pluginProtocolUri, enabled); } diff --git a/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts b/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts new file mode 100644 index 00000000000..bf47b0e6b1b --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts @@ -0,0 +1,199 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Delayer } from '../../../../base/common/async.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { ResourceMap } from '../../../../base/common/map.js'; +import { joinPath } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IFileService } from '../../../files/common/files.js'; +import { ILogService } from '../../../log/common/log.js'; + +/** + * The kinds of customizations the agent host discovers from disk. + * + * Re-declared on the platform side so this module has no dependency on the + * workbench-side `PromptsType` enum. + */ +export const enum DiscoveredType { + Agent = 'agent', + Skill = 'skill', + Instruction = 'instruction', +} + +export interface IDiscoveredFile { + readonly uri: URI; + readonly type: DiscoveredType; +} + +const AGENT_FILE_SUFFIX = '.agent.md'; +const INSTRUCTION_FILE_SUFFIX = '.instructions.md'; +const SKILL_FILENAME = 'SKILL.md'; + +interface ISearchRoot { + readonly path: readonly string[]; + readonly type: DiscoveredType; +} + +/** + * Builds the list of search roots for a given working directory and user home. + * Skills require a depth-2 scan (`/SKILL.md`); agents and instructions + * are flat single-directory scans. + */ +function getSearchRoots(workingDirectory: URI, userHome: URI): { workspace: ISearchRoot[]; user: ISearchRoot[] } { + return { + workspace: [ + { path: ['.github', 'agents'], type: DiscoveredType.Agent }, + { path: ['.agents', 'agents'], type: DiscoveredType.Agent }, + { path: ['.claude', 'agents'], type: DiscoveredType.Agent }, + { path: ['.github', 'skills'], type: DiscoveredType.Skill }, + { path: ['.agents', 'skills'], type: DiscoveredType.Skill }, + { path: ['.claude', 'skills'], type: DiscoveredType.Skill }, + { path: ['.github', 'instructions'], type: DiscoveredType.Instruction }, + ], + user: [ + { path: ['.copilot', 'agents'], type: DiscoveredType.Agent }, + { path: ['.agents', 'skills'], type: DiscoveredType.Skill }, + ], + }; +} + +const REFRESH_DEBOUNCE_MS = 100; + +/** + * Discovers customization files (`.agent.md`, `SKILL.md`, `.instructions.md`) + * under well-known directories of the session's working directory and the + * user's home, and emits {@link onDidChange} when any of those directories + * change on disk. + * + * The first call to {@link files} performs the scan; subsequent calls return + * the cached result until a watcher fires (or until {@link refresh} is + * invoked). Watchers are recreated on each scan so directories that did not + * exist initially get picked up once they appear. + * + * Workspace roots take precedence over user-home roots when the same URI is + * discovered through multiple paths (de-duped by URI). + */ +export class SessionCustomizationDiscovery extends Disposable { + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + private readonly _watchers = this._register(new DisposableStore()); + private readonly _refreshDelayer = this._register(new Delayer(REFRESH_DEBOUNCE_MS)); + private readonly _rootUris: readonly URI[]; + + private _cached: Promise | undefined; + + constructor( + private readonly _workingDirectory: URI, + private readonly _userHome: URI, + @IFileService private readonly _fileService: IFileService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + const { workspace, user } = getSearchRoots(this._workingDirectory, this._userHome); + this._rootUris = [ + ...workspace.map(root => joinPath(this._workingDirectory, ...root.path)), + ...user.map(root => joinPath(this._userHome, ...root.path)), + ]; + this._register(this._fileService.onDidFilesChange(e => { + if (this._rootUris.some(rootUri => e.affects(rootUri))) { + this._scheduleRefresh(); + } + })); + } + + files(): Promise { + if (!this._cached) { + this._cached = this._scan(); + } + return this._cached; + } + + /** + * Forces the next call to {@link files} to re-scan and re-attach watchers. + * Does not emit {@link onDidChange} on its own — callers that explicitly + * refresh own that responsibility. + */ + refresh(): void { + this._cached = undefined; + this._watchers.clear(); + } + + private _scheduleRefresh(): void { + this._refreshDelayer.trigger(() => { + this.refresh(); + this._onDidChange.fire(); + }).catch(() => { /* delayer cancelled on dispose */ }); + } + + private async _scan(): Promise { + this._watchers.clear(); + const seen = new ResourceMap(); + const { workspace, user } = getSearchRoots(this._workingDirectory, this._userHome); + + // Workspace first so it wins on URI conflicts. + await Promise.all([ + ...workspace.map(root => this._scanRoot(this._workingDirectory, root, seen)), + ...user.map(root => this._scanRoot(this._userHome, root, seen)), + ]); + + return [...seen.values()]; + } + + private async _scanRoot(base: URI, root: ISearchRoot, seen: ResourceMap): Promise { + const rootUri = joinPath(base, ...root.path); + let stat; + try { + stat = await this._fileService.resolve(rootUri, { resolveMetadata: false }); + } catch { + // Root does not exist (or is unreadable) — nothing to discover or watch. + return; + } + if (!stat.isDirectory || !stat.children) { + return; + } + + // Only watch roots that exist; recursive: true so we pick up edits to + // files inside skill subdirectories. + try { + this._watchers.add(this._fileService.watch(rootUri, { recursive: true, excludes: [] })); + } catch (err) { + this._logService.warn(`[SessionCustomizationDiscovery] Failed to watch '${rootUri.toString()}': ${err instanceof Error ? err.message : String(err)}`); + } + + for (const child of stat.children) { + if (root.type === DiscoveredType.Skill) { + if (child.isDirectory) { + const skillFile = joinPath(child.resource, SKILL_FILENAME); + try { + const skillStat = await this._fileService.resolve(skillFile, { resolveMetadata: false }); + if (skillStat.isFile && !seen.has(skillFile)) { + seen.set(skillFile, { uri: skillFile, type: DiscoveredType.Skill }); + } + } catch { + // SKILL.md missing — skip this skill directory. + } + } + } else if (child.isFile) { + const name = child.name.toLowerCase(); + const suffix = root.type === DiscoveredType.Agent ? AGENT_FILE_SUFFIX : INSTRUCTION_FILE_SUFFIX; + if (name.endsWith(suffix) && !seen.has(child.resource)) { + seen.set(child.resource, { uri: child.resource, type: root.type }); + } + } + } + } +} + +// Test-only helpers — exported as `_internal` to discourage production use. +export const _internal = { + AGENT_FILE_SUFFIX, + INSTRUCTION_FILE_SUFFIX, + SKILL_FILENAME, + getSearchRoots, +}; diff --git a/src/vs/platform/agentHost/node/copilot/sessionPluginBundler.ts b/src/vs/platform/agentHost/node/copilot/sessionPluginBundler.ts new file mode 100644 index 00000000000..6e4849c2612 --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/sessionPluginBundler.ts @@ -0,0 +1,137 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { hash } from '../../../../base/common/hash.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { basename, dirname } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IFileService } from '../../../files/common/files.js'; +import { IAgentPluginManager } from '../../common/agentPluginManager.js'; +import type { CustomizationRef } from '../../common/state/sessionState.js'; +import type { URI as ProtocolURI } from '../../common/state/protocol/state.js'; +import { DiscoveredType, type IDiscoveredFile } from './sessionCustomizationDiscovery.js'; + +const DISPLAY_NAME = 'VS Code Synced Data'; +const HOST_DISCOVERY_DIR = 'host-discovery'; + +const MANIFEST_CONTENT = JSON.stringify({ + name: DISPLAY_NAME, + description: 'Customization data discovered from this workspace and your home directory', +}, null, '\t'); + +/** + * Maps a {@link DiscoveredType} to the plugin sub-directory under which that + * component type lives in the Open Plugin format. + */ +function pluginDirForType(type: DiscoveredType): string { + switch (type) { + case DiscoveredType.Agent: return 'agents'; + case DiscoveredType.Skill: return 'skills'; + case DiscoveredType.Instruction: return 'rules'; + } +} + +interface IBundleResult { + readonly ref: CustomizationRef; +} + +/** + * Bundles host-discovered customization files into an Open Plugin layout + * on real disk under `/host-discovery//`. + * + * Writing to a real directory (rather than an in-memory provider) is + * required because the Copilot SDK subprocess receives skill directories + * and hook commands as on-disk paths via `fsPath`, and because the + * workbench fetches files through the agent-host filesystem bridge — + * neither of which can read a host-side in-memory FS. + * + * The directory is namespaced by a hash of the working directory so + * concurrent sessions on different folders don't collide. Repeated + * `bundle()` calls with identical content reuse the prior bundle (nonce + * match) and skip the rewrite. + */ +export class SessionPluginBundler extends Disposable { + + private readonly _rootUri: URI; + private _lastNonce: string | undefined; + + constructor( + workingDirectory: URI, + @IFileService private readonly _fileService: IFileService, + @IAgentPluginManager pluginManager: IAgentPluginManager, + ) { + super(); + const authority = `host-${hash(workingDirectory.toString())}`; + this._rootUri = URI.joinPath(pluginManager.basePath, HOST_DISCOVERY_DIR, authority); + } + + get rootUri(): URI { + return this._rootUri; + } + + get lastNonce(): string | undefined { + return this._lastNonce; + } + + /** + * Bundles the given files into the on-disk plugin directory. + * + * Overwrites any previous bundle for this working directory. Returns a + * {@link CustomizationRef} pointing at the on-disk plugin root with a + * content-based nonce, or `undefined` when there are no files. + */ + async bundle(files: readonly IDiscoveredFile[]): Promise { + if (files.length === 0) { + return undefined; + } + + try { + await this._fileService.del(this._rootUri, { recursive: true }); + } catch { + // Directory may not exist on first bundle. + } + + const manifestUri = URI.joinPath(this._rootUri, '.plugin', 'plugin.json'); + await this._fileService.writeFile(manifestUri, VSBuffer.fromString(MANIFEST_CONTENT)); + + const hashParts: string[] = []; + + for (const file of files) { + const dir = pluginDirForType(file.type); + const fileName = basename(file.uri); + + let destUri: URI; + let hashKey: string; + if (file.type === DiscoveredType.Skill) { + // Skills are conventionally `/SKILL.md`. Preserve the + // containing directory name so multiple skills don't collide. + const skillDirName = basename(dirname(file.uri)); + destUri = URI.joinPath(this._rootUri, dir, skillDirName, fileName); + hashKey = `${dir}/${skillDirName}/${fileName}`; + } else { + destUri = URI.joinPath(this._rootUri, dir, fileName); + hashKey = `${dir}/${fileName}`; + } + + const content = await this._fileService.readFile(file.uri); + await this._fileService.writeFile(destUri, content.value); + hashParts.push(`${hashKey}:${content.value.toString()}`); + } + + hashParts.sort(); + const nonce = String(hash(hashParts.join('\n'))); + this._lastNonce = nonce; + + return { + ref: { + uri: this._rootUri.toString() as ProtocolURI, + displayName: DISPLAY_NAME, + description: `${files.length} customization(s) discovered for this session`, + nonce, + }, + }; + } +} diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index c281ab359fa..86e579322d1 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -41,6 +41,8 @@ import { createNullSessionDataService } from '../common/sessionTestHelpers.js'; class TestAgentPluginManager implements IAgentPluginManager { declare readonly _serviceBrand: undefined; + readonly basePath = URI.from({ scheme: 'inmemory', path: '/agentPlugins' }); + async syncCustomizations(_clientId: string, _customizations: CustomizationRef[], _progress?: (status: SessionCustomization[]) => void): Promise { return []; } diff --git a/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts b/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts new file mode 100644 index 00000000000..6f767eb449f --- /dev/null +++ b/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts @@ -0,0 +1,131 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { FileService } from '../../../files/common/fileService.js'; +import { IFileService } from '../../../files/common/files.js'; +import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; +import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../log/common/log.js'; +import { IAgentPluginManager } from '../../common/agentPluginManager.js'; +import { DiscoveredType, SessionCustomizationDiscovery } from '../../node/copilot/sessionCustomizationDiscovery.js'; +import { SessionPluginBundler } from '../../node/copilot/sessionPluginBundler.js'; + +suite('SessionCustomizationDiscovery + SessionPluginBundler', () => { + + const disposables = new DisposableStore(); + let fileService: FileService; + let instantiationService: TestInstantiationService; + let workspace: URI; + let userHome: URI; + let pluginBasePath: URI; + + setup(() => { + fileService = disposables.add(new FileService(new NullLogService())); + const memFs = disposables.add(new InMemoryFileSystemProvider()); + disposables.add(fileService.registerProvider(Schemas.inMemory, memFs)); + + instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(IFileService, fileService); + instantiationService.stub(ILogService, new NullLogService()); + + workspace = URI.from({ scheme: Schemas.inMemory, path: '/workspace' }); + userHome = URI.from({ scheme: Schemas.inMemory, path: '/home' }); + pluginBasePath = URI.from({ scheme: Schemas.inMemory, path: '/agentPlugins' }); + instantiationService.stub(IAgentPluginManager, { basePath: pluginBasePath } as Partial); + }); + + teardown(() => { + disposables.clear(); + }); + ensureNoDisposablesAreLeakedInTestSuite(); + + async function seed(path: string, content = ''): Promise { + const uri = URI.from({ scheme: Schemas.inMemory, path }); + await fileService.writeFile(uri, VSBuffer.fromString(content)); + return uri; + } + + test('discovers agents, skills, and instructions across workspace and home roots', async () => { + const wsAgent = await seed('/workspace/.github/agents/foo.agent.md', 'agent body'); + const wsSkill = await seed('/workspace/.github/skills/bar/SKILL.md', 'skill body'); + const wsInstr = await seed('/workspace/.github/instructions/baz.instructions.md', 'instr body'); + const userAgent = await seed('/home/.copilot/agents/qux.agent.md', 'user agent'); + const userSkill = await seed('/home/.agents/skills/zap/SKILL.md', 'user skill'); + // Noise that should not be picked up + await seed('/workspace/.github/agents/not-an-agent.txt', 'ignored'); + + const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); + const files = await discovery.files(); + + assert.deepStrictEqual([...files].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString())), [ + { uri: userAgent, type: DiscoveredType.Agent }, + { uri: userSkill, type: DiscoveredType.Skill }, + { uri: wsAgent, type: DiscoveredType.Agent }, + { uri: wsInstr, type: DiscoveredType.Instruction }, + { uri: wsSkill, type: DiscoveredType.Skill }, + ].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString()))); + }); + + test('bundles discovered files into the synthetic plugin tree', async () => { + await seed('/workspace/.github/agents/foo.agent.md', 'agent body'); + await seed('/workspace/.github/skills/bar/SKILL.md', 'skill body'); + await seed('/workspace/.github/instructions/baz.instructions.md', 'instr body'); + + const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); + const bundler = disposables.add(instantiationService.createInstance(SessionPluginBundler, workspace)); + const files = await discovery.files(); + const result = await bundler.bundle(files); + + assert.ok(result); + assert.strictEqual(result.ref.displayName, 'VS Code Synced Data'); + assert.ok(result.ref.nonce); + + const root = bundler.rootUri; + const manifest = await fileService.readFile(URI.joinPath(root, '.plugin', 'plugin.json')); + assert.match(manifest.value.toString(), /"name": "VS Code Synced Data"/); + + const agent = await fileService.readFile(URI.joinPath(root, 'agents', 'foo.agent.md')); + assert.strictEqual(agent.value.toString(), 'agent body'); + + const skill = await fileService.readFile(URI.joinPath(root, 'skills', 'bar', 'SKILL.md')); + assert.strictEqual(skill.value.toString(), 'skill body'); + + const instr = await fileService.readFile(URI.joinPath(root, 'rules', 'baz.instructions.md')); + assert.strictEqual(instr.value.toString(), 'instr body'); + }); + + test('returns undefined when no files were discovered', async () => { + const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); + const bundler = disposables.add(instantiationService.createInstance(SessionPluginBundler, workspace)); + const files = await discovery.files(); + const result = await bundler.bundle(files); + assert.strictEqual(result, undefined); + }); + + test('produces a stable nonce for identical content', async () => { + await seed('/workspace/.github/agents/foo.agent.md', 'agent body'); + await seed('/workspace/.github/skills/bar/SKILL.md', 'skill body'); + + const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); + const bundler = disposables.add(instantiationService.createInstance(SessionPluginBundler, workspace)); + const first = await bundler.bundle(await discovery.files()); + const second = await bundler.bundle(await discovery.files()); + assert.ok(first && second); + assert.strictEqual(first.ref.nonce, second.ref.nonce); + }); + + test('different working directories produce different bundle authorities', async () => { + const otherWorkspace = URI.from({ scheme: Schemas.inMemory, path: '/other-workspace' }); + const a = disposables.add(instantiationService.createInstance(SessionPluginBundler, workspace)); + const b = disposables.add(instantiationService.createInstance(SessionPluginBundler, otherWorkspace)); + assert.notStrictEqual(a.rootUri.toString(), b.rootUri.toString()); + }); +}); diff --git a/src/vs/workbench/services/agentHost/common/agentHostFileSystemService.ts b/src/vs/workbench/services/agentHost/common/agentHostFileSystemService.ts index 6f5217141be..28ef4e28796 100644 --- a/src/vs/workbench/services/agentHost/common/agentHostFileSystemService.ts +++ b/src/vs/workbench/services/agentHost/common/agentHostFileSystemService.ts @@ -5,6 +5,7 @@ import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; import { AgentHostFileSystemProvider, type IRemoteFilesystemConnection } from '../../../../platform/agentHost/common/agentHostFileSystemProvider.js'; +import { SYNCED_CUSTOMIZATION_SCHEME } from '../../../../platform/agentHost/common/agentHostFileSystemService.js'; import { AGENT_HOST_LABEL_FORMATTER, AGENT_HOST_SCHEME } from '../../../../platform/agentHost/common/agentHostUri.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { InMemoryFileSystemProvider } from '../../../../platform/files/common/inMemoryFilesystemProvider.js'; @@ -12,13 +13,7 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { ILabelService } from '../../../../platform/label/common/label.js'; -/** - * Scheme used for the in-memory plugin filesystem backing synced customizations. - * - * URIs under this scheme are served by a registered {@link InMemoryFileSystemProvider} - * and are reachable by the agent host via `fetchContent`. - */ -export const SYNCED_CUSTOMIZATION_SCHEME = 'vscode-synced-customization'; +export { SYNCED_CUSTOMIZATION_SCHEME }; export const IAgentHostFileSystemService = createDecorator('agentHostFileSystemService');