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>
This commit is contained in:
Don Jayamanne
2026-05-15 08:01:25 +10:00
committed by GitHub
parent cab6b4c995
commit cb855bd361
9 changed files with 634 additions and 10 deletions

View File

@@ -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<IAgentHostFileSystemService>('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);

View File

@@ -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.
*

View File

@@ -61,6 +61,10 @@ export class AgentPluginManager implements IAgentPluginManager {
this._maxPlugins = maxPlugins;
}
get basePath(): URI {
return this._basePath;
}
async syncCustomizations(
clientId: string,
customizations: CustomizationRef[],

View File

@@ -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<void>;
constructor(
workingDirectory: URI,
userHome: URI,
private readonly _resolvePlugin: (uri: URI) => Promise<IParsedPlugin | undefined>,
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<void> {
return this._settled;
}
currentResolved(): IResolvedCustomization | undefined {
return this._resolved;
}
private async _refresh(): Promise<void> {
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<void>());
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<string, SessionDiscoveredEntry>();
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<readonly IParsedPlugin[]> {
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);
}

View File

@@ -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 (`<skillDir>/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<void>());
readonly onDidChange: Event<void> = this._onDidChange.event;
private readonly _watchers = this._register(new DisposableStore());
private readonly _refreshDelayer = this._register(new Delayer<void>(REFRESH_DEBOUNCE_MS));
private readonly _rootUris: readonly URI[];
private _cached: Promise<readonly IDiscoveredFile[]> | 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<readonly IDiscoveredFile[]> {
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<readonly IDiscoveredFile[]> {
this._watchers.clear();
const seen = new ResourceMap<IDiscoveredFile>();
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<IDiscoveredFile>): Promise<void> {
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,
};

View File

@@ -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 `<agentPluginManager.basePath>/host-discovery/<hash>/`.
*
* 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<IBundleResult | undefined> {
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 `<skillName>/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,
},
};
}
}

View File

@@ -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<ISyncedCustomization[]> {
return [];
}

View File

@@ -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<IAgentPluginManager>);
});
teardown(() => {
disposables.clear();
});
ensureNoDisposablesAreLeakedInTestSuite();
async function seed(path: string, content = ''): Promise<URI> {
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());
});
});

View File

@@ -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<IAgentHostFileSystemService>('agentHostFileSystemService');