mirror of
https://github.com/microsoft/vscode.git
synced 2026-07-10 16:58:55 -05:00
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:
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -61,6 +61,10 @@ export class AgentPluginManager implements IAgentPluginManager {
|
||||
this._maxPlugins = maxPlugins;
|
||||
}
|
||||
|
||||
get basePath(): URI {
|
||||
return this._basePath;
|
||||
}
|
||||
|
||||
async syncCustomizations(
|
||||
clientId: string,
|
||||
customizations: CustomizationRef[],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
137
src/vs/platform/agentHost/node/copilot/sessionPluginBundler.ts
Normal file
137
src/vs/platform/agentHost/node/copilot/sessionPluginBundler.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user