Support dynamic prompt variables (#4742)

* wip

* fixes

* update

* update

* updates

* updates

* clean

* clean

* clean

* fix tests

* update

* fix test
This commit is contained in:
Paul
2026-03-27 14:55:42 -07:00
committed by GitHub
parent 76b20f52bf
commit 41b7ef8514
33 changed files with 355 additions and 816 deletions

View File

@@ -30,7 +30,7 @@ Consult the reference docs for templates, domain examples, advanced frontmatter
| Custom Agents | `*.agent.md` | `.github/agents/` | [Link](./references/agents.md) |
| Skills | `SKILL.md` | `.github/skills/<name>/`, `.agents/skills/<name>/`, `.claude/skills/<name>/` | [Link](./references/skills.md) |
**User-level**: `{{USER_PROMPTS_FOLDER}}/` (*.prompt.md, *.instructions.md, *.agent.md; not skills)
**User-level**: `{{VSCODE_USER_PROMPTS_FOLDER}}/` (*.prompt.md, *.instructions.md, *.agent.md; not skills)
Customizations roam with user's settings sync
## Creation Process
@@ -43,7 +43,7 @@ Follow these steps when creating any customization file.
Ask the user where they want the customization:
- **Workspace**: For project-specific, team-shared customizations → `.github/` folder
- **User profile**: For personal, cross-workspace customizations → `{{USER_PROMPTS_FOLDER}}/`
- **User profile**: For personal, cross-workspace customizations → `{{VSCODE_USER_PROMPTS_FOLDER}}/`
### 2. Choose the Right Primitive

View File

@@ -20,7 +20,7 @@ Base conclusions on evidence from logs. Do not guess.
## Data Source
{{DEBUG_LOG_RUNTIME_CONTEXT}}
- Target session log directory/directories for analysis: `{{VSCODE_TARGET_SESSION_LOG}}`
Use direct debug log files written by Copilot Chat:

View File

@@ -6177,6 +6177,12 @@
{
"path": "./assets/prompts/skills/get-search-view-results/SKILL.md",
"when": "config.github.copilot.chat.getSearchViewResultsSkill.enabled"
},
{
"path": "./assets/prompts/skills/troubleshoot/SKILL.md"
},
{
"path": "./assets/prompts/skills/agent-customization/SKILL.md"
}
],
"terminal": {

View File

@@ -1,50 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';
import { ILogService } from '../../../platform/log/common/logService';
import { BaseSkillProvider } from './baseSkillProvider';
const USER_PROMPTS_FOLDER_PLACEHOLDER = '{{USER_PROMPTS_FOLDER}}';
/**
* Provides the built-in agent-customization skill that teaches agents
* how to work with VS Code's customization system (instructions, prompts, agents, skills).
*/
export class AgentCustomizationSkillProvider extends BaseSkillProvider {
private cachedContent: Uint8Array | undefined;
constructor(
@ILogService logService: ILogService,
@IVSCodeExtensionContext extensionContext: IVSCodeExtensionContext,
) {
super(logService, extensionContext, 'agent-customization');
}
private getUserPromptsFolder(): string {
const globalStorageUri = this.extensionContext.globalStorageUri;
const userFolderUri = vscode.Uri.joinPath(globalStorageUri, '..', '..');
const userPromptsFolderUri = vscode.Uri.joinPath(userFolderUri, 'prompts');
return userPromptsFolderUri.fsPath;
}
protected override processTemplate(templateContent: string): string {
const userPromptsFolder = this.getUserPromptsFolder();
this.logService.trace(`[AgentCustomizationSkillProvider] Injected user prompts folder: ${userPromptsFolder}`);
return templateContent.replace(USER_PROMPTS_FOLDER_PLACEHOLDER, userPromptsFolder);
}
protected override async getSkillContentBytes(): Promise<Uint8Array> {
if (this.cachedContent) {
return this.cachedContent;
}
this.cachedContent = await super.getSkillContentBytes();
return this.cachedContent;
}
}

View File

@@ -1,78 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { SKILL_FILENAME } from '../../../platform/customInstructions/common/promptTypes';
import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';
import { ILogService } from '../../../platform/log/common/logService';
import { Disposable } from '../../../util/vs/base/common/lifecycle';
import { registerDynamicSkillFolder } from './skillFsProviderHelper';
/**
* Base class for skill providers that serve a template-based SKILL.md with placeholder replacements.
*
* Handles constructor registration with the dynamic skill folder filesystem,
* template loading from `assets/prompts/skills/<folderName>/SKILL.md`,
* encoding, error handling, and the `provideSkills` contract.
*
* Subclasses implement {@link processTemplate} to perform their own placeholder replacements.
*/
export abstract class BaseSkillProvider extends Disposable implements vscode.ChatSkillProvider {
protected readonly skillContentUri: vscode.Uri;
private readonly _skillFolderName: string;
constructor(
protected readonly logService: ILogService,
protected readonly extensionContext: IVSCodeExtensionContext,
skillFolderName: string,
) {
super();
this._skillFolderName = skillFolderName;
const registration = registerDynamicSkillFolder(
this.extensionContext,
skillFolderName,
() => this.getSkillContentBytes(),
);
this.skillContentUri = registration.skillUri;
this._register(registration.disposable);
}
/**
* Process the raw template string with placeholder replacements.
* Called each time the skill content is requested (unless the subclass caches).
*/
protected abstract processTemplate(templateContent: string): string | Promise<string>;
protected async getSkillContentBytes(): Promise<Uint8Array> {
try {
const skillTemplateUri = vscode.Uri.joinPath(
this.extensionContext.extensionUri,
'assets',
'prompts',
'skills',
this._skillFolderName,
SKILL_FILENAME,
);
const templateBytes = await vscode.workspace.fs.readFile(skillTemplateUri);
const templateContent = new TextDecoder().decode(templateBytes);
const processedContent = await this.processTemplate(templateContent);
return new TextEncoder().encode(processedContent);
} catch (error) {
this.logService.error(`[${this.constructor.name}] Error reading skill template: ${error}`);
return new Uint8Array();
}
}
async provideSkills(_context: unknown, token: vscode.CancellationToken): Promise<vscode.ChatResource[]> {
if (token.isCancellationRequested) {
return [];
}
return [{ uri: this.skillContentUri }];
}
}

View File

@@ -9,14 +9,12 @@ import { Disposable, MutableDisposable } from '../../../util/vs/base/common/life
import { SyncDescriptor } from '../../../util/vs/platform/instantiation/common/descriptors';
import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';
import { IExtensionContribution } from '../../common/contributions';
import { AgentCustomizationSkillProvider } from './agentCustomizationSkillProvider';
import { AskAgentProvider } from './askAgentProvider';
import { EditModeAgentProvider } from './editModeAgentProvider';
import { ExploreAgentProvider } from './exploreAgentProvider';
import { GitHubOrgCustomAgentProvider } from './githubOrgCustomAgentProvider';
import { GitHubOrgInstructionsProvider } from './githubOrgInstructionsProvider';
import { PlanAgentProvider } from './planAgentProvider';
import { TroubleshootSkillProvider } from './troubleshootSkillProvider';
export class PromptFileContribution extends Disposable implements IExtensionContribution {
readonly id = 'PromptFiles';
@@ -76,15 +74,5 @@ export class PromptFileContribution extends Disposable implements IExtensionCont
this._register(vscode.chat.registerInstructionsProvider(githubOrgInstructionsProvider));
}
}
// Register skill provider for built-in agent customization skill
if ('registerSkillProvider' in vscode.chat) {
const agentCustomizationSkillProvider: vscode.ChatSkillProvider = instantiationService.createInstance(new SyncDescriptor(AgentCustomizationSkillProvider));
this._register(vscode.chat.registerSkillProvider(agentCustomizationSkillProvider));
// Enablement is controlled in core
const troubleshootSkillProvider: vscode.ChatSkillProvider = instantiationService.createInstance(new SyncDescriptor(TroubleshootSkillProvider));
this._register(vscode.chat.registerSkillProvider(troubleshootSkillProvider));
}
}
}

View File

@@ -1,230 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';
import { Emitter } from '../../../util/vs/base/common/event';
const SKILL_FILENAME = 'SKILL.md';
const SKILL_SCHEME = 'copilot-skill';
interface IDynamicSkillFolder {
readonly folderName: string;
readonly provideSkillContentBytes: () => Promise<Uint8Array>;
}
class SkillFsProvider implements vscode.FileSystemProvider {
private readonly dynamicSkills = new Map<string, IDynamicSkillFolder>();
private readonly _onDidChangeFile = new Emitter<vscode.FileChangeEvent[]>();
readonly onDidChangeFile = this._onDidChangeFile.event;
constructor(
private readonly extensionContext: IVSCodeExtensionContext,
) {
}
public registerDynamicSkill(dynamicSkill: IDynamicSkillFolder): vscode.Disposable {
this.dynamicSkills.set(dynamicSkill.folderName, dynamicSkill);
return {
dispose: () => {
this.dynamicSkills.delete(dynamicSkill.folderName);
}
};
}
watch(_uri: vscode.Uri, _options: { readonly recursive: boolean; readonly excludes: readonly string[] }): vscode.Disposable {
return { dispose: () => { } };
}
async stat(uri: vscode.Uri): Promise<vscode.FileStat> {
const parsed = this.parseSkillUri(uri);
if (!parsed) {
throw vscode.FileSystemError.FileNotFound(uri);
}
if (parsed.kind === 'root') {
return {
type: vscode.FileType.Directory,
ctime: 0,
mtime: 0,
size: 0,
};
}
if (parsed.kind === 'folder') {
if (!this.dynamicSkills.has(parsed.folderName)) {
throw vscode.FileSystemError.FileNotFound(uri);
}
return {
type: vscode.FileType.Directory,
ctime: 0,
mtime: 0,
size: 0,
};
}
const dynamicSkill = this.dynamicSkills.get(parsed.folderName);
if (!dynamicSkill) {
throw vscode.FileSystemError.FileNotFound(uri);
}
if (parsed.relativePath === SKILL_FILENAME) {
const content = await dynamicSkill.provideSkillContentBytes();
return {
type: vscode.FileType.File,
ctime: 0,
mtime: Date.now(),
size: content.length,
};
}
const assetUri = this.toAssetUri(parsed.folderName, parsed.relativePath);
return vscode.workspace.fs.stat(assetUri);
}
async readDirectory(uri: vscode.Uri): Promise<[string, vscode.FileType][]> {
const parsed = this.parseSkillUri(uri);
if (!parsed) {
throw vscode.FileSystemError.FileNotFound(uri);
}
if (parsed.kind === 'root') {
return [...this.dynamicSkills.keys()].map(folderName => [folderName, vscode.FileType.Directory]);
}
if (parsed.kind === 'folder') {
if (!this.dynamicSkills.has(parsed.folderName)) {
throw vscode.FileSystemError.FileNotFound(uri);
}
const assetFolderUri = this.toAssetUri(parsed.folderName, '');
try {
return await vscode.workspace.fs.readDirectory(assetFolderUri);
} catch {
return [[SKILL_FILENAME, vscode.FileType.File]];
}
}
const dynamicSkill = this.dynamicSkills.get(parsed.folderName);
if (!dynamicSkill) {
throw vscode.FileSystemError.FileNotFound(uri);
}
const assetFolderUri = this.toAssetUri(parsed.folderName, parsed.relativePath);
return vscode.workspace.fs.readDirectory(assetFolderUri);
}
createDirectory(_uri: vscode.Uri): void {
throw vscode.FileSystemError.NoPermissions('Readonly file system');
}
async readFile(uri: vscode.Uri): Promise<Uint8Array> {
const parsed = this.parseSkillUri(uri);
if (!parsed || parsed.kind !== 'file') {
throw vscode.FileSystemError.FileNotFound(uri);
}
const dynamicSkill = this.dynamicSkills.get(parsed.folderName);
if (!dynamicSkill) {
throw vscode.FileSystemError.FileNotFound(uri);
}
if (parsed.relativePath === SKILL_FILENAME) {
return dynamicSkill.provideSkillContentBytes();
}
const assetUri = this.toAssetUri(parsed.folderName, parsed.relativePath);
return vscode.workspace.fs.readFile(assetUri);
}
writeFile(_uri: vscode.Uri, _content: Uint8Array, _options: { readonly create: boolean; readonly overwrite: boolean }): void {
throw vscode.FileSystemError.NoPermissions('Readonly file system');
}
delete(_uri: vscode.Uri, _options: { readonly recursive: boolean }): void {
throw vscode.FileSystemError.NoPermissions('Readonly file system');
}
rename(_oldUri: vscode.Uri, _newUri: vscode.Uri, _options: { readonly overwrite: boolean }): void {
throw vscode.FileSystemError.NoPermissions('Readonly file system');
}
private toAssetUri(folderName: string, relativePath: string): vscode.Uri {
const segments = relativePath.split('/').filter(Boolean);
return vscode.Uri.joinPath(
this.extensionContext.extensionUri,
'assets',
'prompts',
'skills',
folderName,
...segments,
);
}
private parseSkillUri(uri: vscode.Uri):
| { kind: 'root' }
| { kind: 'folder'; folderName: string }
| { kind: 'file'; folderName: string; relativePath: string }
| undefined {
if (uri.scheme !== SKILL_SCHEME) {
return undefined;
}
const segments = uri.path.split('/').filter(Boolean);
if (segments.length === 0) {
return { kind: 'root' };
}
if (segments.length === 1) {
return { kind: 'folder', folderName: segments[0] };
}
return {
kind: 'file',
folderName: segments[0],
relativePath: segments.slice(1).join('/'),
};
}
}
let sharedFsProvider:
| {
provider: SkillFsProvider;
registration: vscode.Disposable;
}
| undefined;
function getOrCreateSharedFsProvider(
extensionContext: IVSCodeExtensionContext,
): SkillFsProvider {
if (!sharedFsProvider) {
const provider = new SkillFsProvider(extensionContext);
const registration = vscode.workspace.registerFileSystemProvider(SKILL_SCHEME, provider, { isReadonly: true });
sharedFsProvider = { provider, registration };
}
return sharedFsProvider.provider;
}
export function registerDynamicSkillFolder(
extensionContext: IVSCodeExtensionContext,
folderName: string,
provideSkillContentBytes: () => Promise<Uint8Array>,
): { readonly skillUri: vscode.Uri; readonly disposable: vscode.Disposable } {
const provider = getOrCreateSharedFsProvider(extensionContext);
const disposable = provider.registerDynamicSkill({
folderName,
provideSkillContentBytes,
});
return {
skillUri: vscode.Uri.from({ scheme: SKILL_SCHEME, path: `/${folderName}/${SKILL_FILENAME}` }),
disposable,
};
}

View File

@@ -1,44 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';
import { ILogService } from '../../../platform/log/common/logService';
import { BaseSkillProvider } from './baseSkillProvider';
const RUNTIME_CONTEXT_PLACEHOLDER = '{{DEBUG_LOG_RUNTIME_CONTEXT}}';
export class TroubleshootSkillProvider extends BaseSkillProvider {
constructor(
@ILogService logService: ILogService,
@IVSCodeExtensionContext extensionContext: IVSCodeExtensionContext,
) {
super(logService, extensionContext, 'troubleshoot');
}
private getRuntimeContext(): string {
const lines: string[] = [];
lines.push('## Runtime Log Context');
lines.push('');
// Provide the debug-logs directory path so the agent can find log files.
// The {{CURRENT_SESSION_LOG}} placeholder may be resolved earlier during prompt
// rendering (for example by PromptFile.getBodyContent) or later by the read_file
// tool, which has access to the correct session context.
const storageUri = this.extensionContext.storageUri;
if (storageUri) {
lines.push('- Session log directories: `{{CURRENT_SESSION_LOG}}`');
lines.push('- If multiple directories are listed, compare the sessions to identify common issues and differences.');
} else {
lines.push('- Debug-logs directory: unavailable in this environment. Abort now and tell the user that troubleshooting is only available if a workspace is open.');
}
return lines.join('\n');
}
protected override processTemplate(templateContent: string): string {
return templateContent.replace(RUNTIME_CONTEXT_PLACEHOLDER, this.getRuntimeContext());
}
}

View File

@@ -345,7 +345,20 @@ export class ChatDebugFileLoggerService extends Disposable implements IChatDebug
}
getSessionDir(sessionId: string): URI | undefined {
return this._activeSessions.get(sessionId)?.sessionDir;
// If active, use the stored sessionDir (already points to parent dir for children)
const active = this._activeSessions.get(sessionId);
if (active) {
return active.sessionDir;
}
// If known as a child, resolve to the parent's directory
const childInfo = this._childSessionMap.get(sessionId);
if (childInfo) {
const dir = this._getDebugLogsDir();
return dir ? URI.joinPath(dir, childInfo.parentSessionId) : undefined;
}
// Unknown session — construct the default path (assuming it's a parent)
const dir = this._getDebugLogsDir();
return dir ? URI.joinPath(dir, sessionId) : undefined;
}
getActiveSessionIds(): string[] {

View File

@@ -47,7 +47,8 @@ export class CopilotCLISessionOptions {
private readonly mcpServers?: SessionOptions['mcpServers'];
private readonly copilotUrl?: string;
private readonly skillLocations?: Uri[];
constructor(options: { model?: string; workspaceInfo: IWorkspaceInfo; mcpServers?: SessionOptions['mcpServers']; agent?: SweCustomAgent; customAgents?: SweCustomAgent[]; copilotUrl?: string; skillLocations?: Uri[] }, private readonly logService: ILogService) {
private readonly systemMessage?: SessionOptions['systemMessage'];
constructor(options: { model?: string; workspaceInfo: IWorkspaceInfo; mcpServers?: SessionOptions['mcpServers']; agent?: SweCustomAgent; customAgents?: SweCustomAgent[]; copilotUrl?: string; skillLocations?: Uri[]; systemMessage?: SessionOptions['systemMessage'] }, private readonly logService: ILogService) {
this.workspaceInfo = options.workspaceInfo;
this.model = options.model;
this.mcpServers = options.mcpServers;
@@ -55,6 +56,7 @@ export class CopilotCLISessionOptions {
this.customAgents = options.customAgents;
this.copilotUrl = options.copilotUrl;
this.skillLocations = options.skillLocations;
this.systemMessage = options.systemMessage;
}
public get agentName(): string | undefined {
@@ -98,6 +100,9 @@ export class CopilotCLISessionOptions {
if (this.copilotUrl) {
allOptions.copilotUrl = this.copilotUrl;
}
if (this.systemMessage) {
allOptions.systemMessage = this.systemMessage;
}
allOptions.sessionCapabilities = new Set(['plan-mode', 'memory', 'cli-documentation', 'ask-user', 'interactive-mode', 'system-notifications']);
return allOptions as Readonly<SessionOptions>;
}

View File

@@ -10,6 +10,7 @@ import * as fs from 'node:fs/promises';
import { devNull, EOL } from 'node:os';
import { createInterface } from 'node:readline';
import type { ChatRequest, ChatSessionItem } from 'vscode';
import { IChatDebugFileLoggerService } from '../../../../platform/chat/common/chatDebugFileLoggerService';
import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';
import { INativeEnvService } from '../../../../platform/env/common/envService';
import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext';
@@ -32,6 +33,7 @@ import { URI } from '../../../../util/vs/base/common/uri';
import { generateUuid } from '../../../../util/vs/base/common/uuid';
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
import { ChatRequestTurn2, ChatResponseTurn2, ChatSessionStatus, Uri } from '../../../../vscodeTypes';
import { IPromptVariablesService } from '../../../prompt/node/promptVariablesService';
import { IAgentSessionsWorkspace } from '../../common/agentSessionsWorkspace';
import { IChatSessionMetadataStore } from '../../common/chatSessionMetadataStore';
import { IChatSessionWorkspaceFolderService } from '../../common/chatSessionWorkspaceFolderService';
@@ -47,7 +49,6 @@ import { CopilotCliBridgeSpanProcessor } from './copilotCliBridgeSpanProcessor';
import { CopilotCLISession, ICopilotCLISession } from './copilotcliSession';
import { ICopilotCLISkills } from './copilotCLISkills';
import { ICopilotCLIMCPHandler, McpServerMappings } from './mcpHandler';
import { IChatDebugFileLoggerService } from '../../../../platform/chat/common/chatDebugFileLoggerService';
const COPILOT_CLI_WORKSPACE_JSON_FILE_KEY = 'github.copilot.cli.workspaceSessionFile';
@@ -84,8 +85,8 @@ export interface ICopilotCLISessionService {
renameSession(sessionId: string, title: string): Promise<void>;
// Session wrapper tracking
getSession(options: { sessionId: string; model?: string; workspaceInfo: IWorkspaceInfo; readonly: boolean; agent?: SweCustomAgent; mcpServerMappings?: McpServerMappings }, token: CancellationToken): Promise<IReference<ICopilotCLISession> | undefined>;
createSession(options: { model?: string; workspaceInfo: IWorkspaceInfo; agent?: SweCustomAgent; sessionId?: string; mcpServerMappings?: McpServerMappings }, token: CancellationToken): Promise<IReference<ICopilotCLISession>>;
getSession(options: { sessionId: string; model?: string; workspaceInfo: IWorkspaceInfo; readonly: boolean; agent?: SweCustomAgent; debugTargetSessionIds?: readonly string[]; mcpServerMappings?: McpServerMappings }, token: CancellationToken): Promise<IReference<ICopilotCLISession> | undefined>;
createSession(options: { model?: string; workspaceInfo: IWorkspaceInfo; agent?: SweCustomAgent; sessionId?: string; debugTargetSessionIds?: readonly string[]; mcpServerMappings?: McpServerMappings }, token: CancellationToken): Promise<IReference<ICopilotCLISession>>;
forkSession(sessionId: string, requestId: string | undefined, options: { workspaceInfo: IWorkspaceInfo }, token: CancellationToken): Promise<string>;
tryGetPartialSesionHistory(sessionId: string): Promise<readonly (ChatRequestTurn2 | ChatResponseTurn2)[] | undefined>;
}
@@ -146,6 +147,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
@IChatSessionWorkspaceFolderService private readonly workspaceFolderService: IChatSessionWorkspaceFolderService,
@IChatSessionWorktreeService private readonly worktreeManager: IChatSessionWorktreeService,
@IOTelService private readonly _otelService: IOTelService,
@IPromptVariablesService private readonly _promptVariablesService: IPromptVariablesService,
@IChatDebugFileLoggerService private readonly _debugFileLogger: IChatDebugFileLoggerService,
) {
super();
@@ -511,11 +513,11 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
}
}
public async createSession({ model, workspaceInfo, agent, sessionId, mcpServerMappings }: { model?: string; workspaceInfo: IWorkspaceInfo; agent?: SweCustomAgent; sessionId?: string; mcpServerMappings?: McpServerMappings }, token: CancellationToken): Promise<RefCountedSession> {
public async createSession({ model, workspaceInfo, agent, sessionId, debugTargetSessionIds, mcpServerMappings }: { model?: string; workspaceInfo: IWorkspaceInfo; agent?: SweCustomAgent; sessionId?: string; debugTargetSessionIds?: readonly string[]; mcpServerMappings?: McpServerMappings }, token: CancellationToken): Promise<RefCountedSession> {
const { mcpConfig: mcpServers, disposable: mcpGateway } = await this.mcpHandler.loadMcpConfig();
try {
const copilotUrl = this.configurationService.getConfig(ConfigKey.Shared.DebugOverrideProxyUrl) || undefined;
const options = await this.createSessionsOptions({ model, workspaceInfo, mcpServers, agent, copilotUrl });
const options = await this.createSessionsOptions({ model, workspaceInfo, mcpServers, agent, copilotUrl, sessionId, debugTargetSessionIds });
const sessionManager = await raceCancellationError(this.getSessionManager(), token);
const sdkSession = await sessionManager.createSession({ ...options.toSessionOptions(mcpServerMappings), sessionId });
this._newSessionIds.delete(sdkSession.sessionId);
@@ -629,15 +631,17 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
return false;
}
protected async createSessionsOptions(options: { model?: string; workspaceInfo: IWorkspaceInfo; mcpServers?: SessionOptions['mcpServers']; agent: SweCustomAgent | undefined; copilotUrl?: string }, readonly?: boolean): Promise<CopilotCLISessionOptions> {
protected async createSessionsOptions(options: { model?: string; workspaceInfo: IWorkspaceInfo; mcpServers?: SessionOptions['mcpServers']; agent: SweCustomAgent | undefined; copilotUrl?: string; sessionId?: string; debugTargetSessionIds?: readonly string[] }, readonly?: boolean): Promise<CopilotCLISessionOptions> {
const [customAgents, skillLocations] = await Promise.all([
this.agents.getAgents(),
readonly ? Promise.resolve([]) : this.copilotCLISkills.getSkillsLocations(),
]);
return new CopilotCLISessionOptions({ ...options, customAgents, skillLocations }, this.logService);
const variablesContext = this._promptVariablesService.buildTemplateVariablesContext(options.sessionId, options.debugTargetSessionIds);
const systemMessage = variablesContext ? { mode: 'append' as const, content: variablesContext } : undefined;
return new CopilotCLISessionOptions({ ...options, customAgents, skillLocations, systemMessage }, this.logService);
}
public async getSession({ sessionId, model, workspaceInfo, readonly, agent, mcpServerMappings }: { sessionId: string; model?: string; workspaceInfo: IWorkspaceInfo; readonly: boolean; agent?: SweCustomAgent; mcpServerMappings?: McpServerMappings }, token: CancellationToken): Promise<RefCountedSession | undefined> {
public async getSession({ sessionId, model, workspaceInfo, readonly, agent, debugTargetSessionIds, mcpServerMappings }: { sessionId: string; model?: string; workspaceInfo: IWorkspaceInfo; readonly: boolean; agent?: SweCustomAgent; debugTargetSessionIds?: readonly string[]; mcpServerMappings?: McpServerMappings }, token: CancellationToken): Promise<RefCountedSession | undefined> {
// https://github.com/microsoft/vscode/issues/276573
const lock = this.sessionMutexForGetSession.get(sessionId) ?? new Mutex();
this.sessionMutexForGetSession.set(sessionId, lock);
@@ -666,7 +670,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
]);
try {
const copilotUrl = this.configurationService.getConfig(ConfigKey.Shared.DebugOverrideProxyUrl) || undefined;
const options = await this.createSessionsOptions({ model, agent, workspaceInfo, mcpServers, copilotUrl }, readonly);
const options = await this.createSessionsOptions({ model, agent, workspaceInfo, mcpServers, copilotUrl, sessionId, debugTargetSessionIds }, readonly);
const sdkSession = await sessionManager.getSession({ ...options.toSessionOptions(mcpServerMappings), sessionId }, !readonly);
if (!sdkSession) {
@@ -711,7 +715,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
]);
const copilotUrl = this.configurationService.getConfig(ConfigKey.Shared.DebugOverrideProxyUrl) || undefined;
const options = await this.createSessionsOptions({ workspaceInfo, mcpServers: undefined, copilotUrl, agent: undefined }, false);
const options = await this.createSessionsOptions({ workspaceInfo, mcpServers: undefined, copilotUrl, agent: undefined, sessionId: newSessionId }, false);
const sdkSession = await sessionManager.getSession({ ...options.toSessionOptions(), sessionId: newSessionId }, false);
if (!sdkSession) {

View File

@@ -24,6 +24,7 @@ import { mock } from '../../../../../util/common/test/simpleMock';
import { DisposableStore, IReference, toDisposable } from '../../../../../util/vs/base/common/lifecycle';
import { URI } from '../../../../../util/vs/base/common/uri';
import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';
import { NullPromptVariablesService } from '../../../../prompt/node/promptVariablesService';
import { createExtensionUnitTestingServices } from '../../../../test/node/services';
import { IAgentSessionsWorkspace } from '../../../common/agentSessionsWorkspace';
import { IChatPromptFileService } from '../../../common/chatPromptFileService';
@@ -155,7 +156,7 @@ describe('CopilotCLISessionService', () => {
const configurationService = accessor.get(IConfigurationService);
const nullMcpServer = disposables.add(new NullMcpService());
const titleService = new NullCustomSessionTitleService();
service = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), cliAgents, workspaceService, titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullChatDebugFileLoggerService()));
service = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), cliAgents, workspaceService, titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService()));
manager = await service.getSessionManager() as unknown as MockCliSdkSessionManager;
});
@@ -314,7 +315,7 @@ describe('CopilotCLISessionService', () => {
return undefined;
}
}();
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullChatDebugFileLoggerService()));
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService()));
await mkdir(sessionDir.fsPath, { recursive: true });
await writeNodeFile(join(sessionDir.fsPath, 'events.jsonl'), [
@@ -349,7 +350,7 @@ describe('CopilotCLISessionService', () => {
const delegationService = new class extends mock<IChatDelegationSummaryService>() {
override extractPrompt(): { prompt: string; reference: never } | undefined { return undefined; }
}();
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullChatDebugFileLoggerService()));
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService()));
await mkdir(sessionDir.fsPath, { recursive: true });
const eventsFilePath = join(sessionDir.fsPath, 'events.jsonl');
@@ -418,7 +419,7 @@ describe('CopilotCLISessionService', () => {
return undefined;
}
}();
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullChatDebugFileLoggerService()));
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService()));
const partialManager = await partialService.getSessionManager() as unknown as MockCliSdkSessionManager;
const session = new MockCliSdkSession(sessionId, new Date('2024-01-01T00:00:00.000Z'));
@@ -460,7 +461,7 @@ describe('CopilotCLISessionService', () => {
const delegationService = new class extends mock<IChatDelegationSummaryService>() {
override extractPrompt(): { prompt: string; reference: never } | undefined { return undefined; }
}();
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullChatDebugFileLoggerService()));
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService()));
const partialManager = await partialService.getSessionManager() as unknown as MockCliSdkSessionManager;
// Session has a summary with '<' (which forces the session-load fallback path)

View File

@@ -30,7 +30,7 @@ import { relative } from '../../../util/vs/base/common/path';
import { basename, dirname, extUri, isEqual } from '../../../util/vs/base/common/resources';
import { URI } from '../../../util/vs/base/common/uri';
import { EXTENSION_ID } from '../../common/constants';
import { ChatVariablesCollection, isPromptFile } from '../../prompt/common/chatVariablesCollection';
import { ChatVariablesCollection, extractDebugTargetSessionIds, isPromptFile } from '../../prompt/common/chatVariablesCollection';
import { IChatSessionMetadataStore, StoredModeInstructions } from '../common/chatSessionMetadataStore';
import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService';
import { IChatSessionWorktreeCheckpointService } from '../common/chatSessionWorktreeCheckpointService';
@@ -1594,10 +1594,11 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
const model = options.model;
const agent = options.agent;
const debugTargetSessionIds = extractDebugTargetSessionIds(request.references);
const mcpServerMappings = buildMcpServerMappings(request.tools);
const session = isNewSession ?
await this.sessionService.createSession({ sessionId, model, workspaceInfo, agent, mcpServerMappings }, token) :
await this.sessionService.getSession({ sessionId, model, workspaceInfo, agent, readonly: false, mcpServerMappings }, token);
await this.sessionService.createSession({ sessionId, model, workspaceInfo, agent, debugTargetSessionIds, mcpServerMappings }, token) :
await this.sessionService.getSession({ sessionId, model, workspaceInfo, agent, readonly: false, debugTargetSessionIds, mcpServerMappings }, token);
// TODO @DonJayamanne We need to refresh to add this new session, but we need a label.
// So when creating a session we need a dummy label (or an initial prompt).

View File

@@ -31,7 +31,7 @@ import { relative } from '../../../util/vs/base/common/path';
import { basename, dirname, extUri, isEqual } from '../../../util/vs/base/common/resources';
import { URI } from '../../../util/vs/base/common/uri';
import { EXTENSION_ID } from '../../common/constants';
import { ChatVariablesCollection, isPromptFile } from '../../prompt/common/chatVariablesCollection';
import { ChatVariablesCollection, extractDebugTargetSessionIds, isPromptFile } from '../../prompt/common/chatVariablesCollection';
import { IToolsService } from '../../tools/common/toolsService';
import { IChatSessionMetadataStore, StoredModeInstructions } from '../common/chatSessionMetadataStore';
import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService';
@@ -1666,10 +1666,11 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
const model = options.model;
const agent = options.agent;
const debugTargetSessionIds = extractDebugTargetSessionIds(request.references);
const mcpServerMappings = buildMcpServerMappings(request.tools);
const session = isNewSession ?
await this.sessionService.createSession({ model, workspaceInfo, agent, mcpServerMappings }, token) :
await this.sessionService.getSession({ sessionId: id, model, workspaceInfo, readonly: false, agent, mcpServerMappings }, token);
await this.sessionService.createSession({ model, workspaceInfo, agent, debugTargetSessionIds, mcpServerMappings }, token) :
await this.sessionService.getSession({ sessionId: id, model, workspaceInfo, readonly: false, agent, debugTargetSessionIds, mcpServerMappings }, token);
this.sessionItemProvider.notifySessionsChange();
// TODO @DonJayamanne We need to refresh to add this new session, but we need a label.
// So when creating a session we need a dummy label (or an initial prompt).

View File

@@ -31,6 +31,7 @@ import { sep } from '../../../../util/vs/base/common/path';
import { URI } from '../../../../util/vs/base/common/uri';
import { IInstantiationService, ServicesAccessor } from '../../../../util/vs/platform/instantiation/common/instantiation';
import { LanguageModelTextPart, LanguageModelToolResult2 } from '../../../../vscodeTypes';
import { NullPromptVariablesService } from '../../../prompt/node/promptVariablesService';
import { ChatSummarizerProvider } from '../../../prompt/node/summarizer';
import { createExtensionUnitTestingServices } from '../../../test/node/services';
import { MockChatResponseStream, TestChatRequest } from '../../../test/node/testHelpers';
@@ -374,7 +375,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
}
} as unknown as IInstantiationService;
customSessionTitleService = new CustomSessionTitleService(new MockExtensionContext() as unknown as IVSCodeExtensionContext, accessor.get(IInstantiationService), logService, new MockChatSessionMetadataStore());
sessionService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, mcpHandler, new NullCopilotCLIAgents(), workspaceService, customSessionTitleService, accessor.get(IConfigurationService), new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), { _serviceBrand: undefined, isAgentSessionsWorkspace: false } as IAgentSessionsWorkspace, workspaceFolderService, worktree, new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullChatDebugFileLoggerService()));
sessionService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, mcpHandler, new NullCopilotCLIAgents(), workspaceService, customSessionTitleService, accessor.get(IConfigurationService), new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), { _serviceBrand: undefined, isAgentSessionsWorkspace: false } as IAgentSessionsWorkspace, workspaceFolderService, worktree, new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService()));
manager = await sessionService.getSessionManager() as unknown as MockCliSdkSessionManager;
contentProvider = new class extends mock<CopilotCLIChatSessionContentProvider>() {

View File

@@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import type * as vscode from 'vscode';
import { sessionResourceToId } from '../../../platform/chat/common/chatDebugFileLoggerService';
import { URI } from '../../../util/vs/base/common/uri';
export interface PromptVariable {
@@ -148,3 +149,25 @@ export function isSessionReferenceScheme(scheme: string): boolean {
export function isSessionReference(variable: PromptVariable): variable is PromptVariable & { value: vscode.Uri } {
return URI.isUri(variable.value) && isSessionReferenceScheme(variable.value.scheme);
}
/**
* Build the attributes for rendering a session reference as an `<attachment>` tag.
* Callers can pass the result to `<Tag name='attachment' attrs={...} />`.
*/
export function sessionReferenceAttachmentAttrs(variable: PromptVariable & { value: vscode.Uri }): Record<string, string> {
const attrs: Record<string, string> = {};
if (variable.uniqueName) {
attrs.id = `${variable.uniqueName} (${sessionResourceToId(variable.value)})`;
}
attrs.filePath = variable.value.toString();
return attrs;
}
/**
* Extract debug-target session IDs from chat prompt references.
* Returns `undefined` when no session references are present.
*/
export function extractDebugTargetSessionIds(references: readonly vscode.ChatPromptReference[]): readonly string[] | undefined {
const sessionRefs = references.filter(ref => URI.isUri(ref.value) && isSessionReferenceScheme(ref.value.scheme));
return sessionRefs.length > 0 ? sessionRefs.map(ref => sessionResourceToId(ref.value as URI)) : undefined;
}

View File

@@ -8,6 +8,7 @@ import type * as vscode from 'vscode';
import { ChatFetchResponseType, ChatLocation } from '../../../platform/chat/common/commonTypes';
import { getTextPart, roleToString } from '../../../platform/chat/common/globalStringUtils';
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
import { ICustomInstructionsService } from '../../../platform/customInstructions/common/customInstructionsService';
import { isAutoModel } from '../../../platform/endpoint/node/autoChatEndpoint';
import { ILanguageDiagnosticsService } from '../../../platform/languages/common/languageDiagnosticsService';
import { IChatEndpoint } from '../../../platform/networking/common/networking';
@@ -18,7 +19,7 @@ import { isNotebookCellOrNotebookChatInput } from '../../../util/common/notebook
import { URI } from '../../../util/vs/base/common/uri';
import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';
import { isBYOKModel } from '../../byok/node/openAIEndpoint';
import { Intent, agentsToCommands } from '../../common/constants';
import { EXTENSION_ID, Intent, agentsToCommands } from '../../common/constants';
import { DiagnosticsTelemetryData, findDiagnosticsTelemetry } from '../../inlineChat/node/diagnosticsTelemetry';
import { InteractionOutcome } from '../../inlineChat/node/promptCraftingTypes';
import { AgentIntent } from '../../intents/node/agentIntent';
@@ -211,7 +212,7 @@ const builtinSlashCommands = new Set(
Object.values(agentsToCommands).flatMap(commands => commands ? Object.keys(commands) : [])
);
function getSlashCommandForTelemetry(request: vscode.ChatRequest): string {
function getSlashCommandForTelemetry(request: vscode.ChatRequest, customInstructionsService: ICustomInstructionsService): string {
const command = request.command;
if (!command) {
return '';
@@ -222,9 +223,19 @@ function getSlashCommandForTelemetry(request: vscode.ChatRequest): string {
return command;
}
// Built-in skills (copilot-skill:// URIs) are safe to send as plain text
// Built-in skills (extension-provided) are safe to send as plain text
for (const ref of request.references) {
if (URI.isUri(ref.value) && ref.value.scheme === 'copilot-skill') {
if (!URI.isUri(ref.value)) {
continue;
}
const extensionSkillInfo = customInstructionsService.getExtensionSkillInfo(ref.value);
if (extensionSkillInfo?.extensionId === EXTENSION_ID && extensionSkillInfo.skillName === command) {
return command;
}
const extensionPromptFileInfo = customInstructionsService.getExtensionPromptFileInfo(ref.value);
if (extensionPromptFileInfo?.extensionId === EXTENSION_ID && extensionPromptFileInfo.uri.path.toLowerCase().endsWith(`/${command.toLowerCase()}.prompt.md`)) {
return command;
}
}
@@ -597,6 +608,7 @@ export class PanelChatTelemetry extends ChatTelemetry<IDocumentContext | undefin
repoInfoTelemetry: RepoInfoTelemetry,
@ITelemetryService telemetryService: ITelemetryService,
@IConfigurationService private readonly _configurationService: IConfigurationService,
@ICustomInstructionsService private readonly _customInstructionsService: ICustomInstructionsService,
) {
super(ChatLocation.Panel,
sessionId,
@@ -734,7 +746,7 @@ export class PanelChatTelemetry extends ChatTelemetry<IDocumentContext | undefin
mode: this._getModeNameForTelemetry(),
parentRequestId: this._request.parentRequestId,
vscodeRequestId: this._request.id,
slashCommand: getSlashCommandForTelemetry(this._request)
slashCommand: getSlashCommandForTelemetry(this._request, this._customInstructionsService)
} satisfies RequestPanelTelemetryProperties, {
turn: this._conversation.turns.length,
round: roundIndex,

View File

@@ -8,7 +8,6 @@ import { Raw } from '@vscode/prompt-tsx';
import type { ChatRequest, ChatResponseReferencePart, ChatResponseStream, ChatResult, LanguageModelToolInformation, Progress } from 'vscode';
import { IAuthenticationService } from '../../../platform/authentication/common/authentication';
import { IAuthenticationChatUpgradeService } from '../../../platform/authentication/common/authenticationUpgrade';
import { sessionResourceToId } from '../../../platform/chat/common/chatDebugFileLoggerService';
import { IChatHookService, UserPromptSubmitHookInput, UserPromptSubmitHookOutput } from '../../../platform/chat/common/chatHookService';
import { CanceledResult, ChatFetchResponseType, ChatLocation, ChatResponse, getErrorDetailsFromChatFetchError } from '../../../platform/chat/common/commonTypes';
import { IConversationOptions } from '../../../platform/chat/common/conversationOptions';
@@ -38,7 +37,6 @@ import { Iterable } from '../../../util/vs/base/common/iterator';
import { DisposableStore } from '../../../util/vs/base/common/lifecycle';
import { mixin } from '../../../util/vs/base/common/objects';
import { assertType, Mutable } from '../../../util/vs/base/common/types';
import { URI } from '../../../util/vs/base/common/uri';
import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';
import { ChatResponseMarkdownPart, ChatResponseProgressPart, ChatResponseTextEditPart, LanguageModelToolResult2 } from '../../../vscodeTypes';
import { CodeBlocksMetadata, CodeBlockTrackingChatResponseStream } from '../../codeBlocks/node/codeBlockProcessor';
@@ -51,7 +49,7 @@ import { SummarizedConversationHistoryMetadata } from '../../prompts/node/agent/
import { normalizeToolSchema } from '../../tools/common/toolSchemaNormalizer';
import { ToolCallCancelledError } from '../../tools/common/toolsService';
import { IToolGrouping, IToolGroupingService } from '../../tools/common/virtualTools/virtualToolTypes';
import { ChatVariablesCollection, isSessionReferenceScheme } from '../common/chatVariablesCollection';
import { ChatVariablesCollection } from '../common/chatVariablesCollection';
import { AnthropicTokenUsageMetadata, Conversation, getUniqueReferences, GlobalContextMessageMetadata, IResultMetadata, RenderedUserMessageMetadata, RequestDebugInformation, ResponseStreamParticipant, Turn, TurnStatus } from '../common/conversation';
import { IBuildPromptContext, IToolCallRound } from '../common/intents';
import { isToolCallLimitCancellation, ISwitchToAutoOnRateLimitConfirmation } from '../common/specialRequestTypes';
@@ -140,8 +138,6 @@ export class DefaultIntentRequestHandler {
// This enables explicit linking between the parent's runSubagent tool call and the subagent trajectory.
// For main requests, use the VS Code chat sessionId directly as the trajectory session ID.
const isSubagent = !!this.request.subAgentInvocationId;
const sessionRefs = this.request.references.filter(ref => URI.isUri(ref.value) && isSessionReferenceScheme(ref.value.scheme));
const debugTargetSessionIds = sessionRefs.length > 0 ? sessionRefs.map(ref => sessionResourceToId(ref.value as URI)) : undefined;
const capturingToken = new CapturingToken(
this.request.prompt,
'comment',
@@ -152,7 +148,6 @@ export class DefaultIntentRequestHandler {
// For subagents, link back to the parent session
isSubagent ? this.request.sessionId : undefined,
isSubagent ? `runSubagent-${this.request.subAgentName ?? 'default'}` : undefined,
debugTargetSessionIds,
);
const resultDetails = await this._requestLogger.captureInvocation(capturingToken, () => this.runWithToolCalling(intentInvocation));

View File

@@ -13,6 +13,14 @@ export interface IPromptVariablesService {
readonly _serviceBrand: undefined;
resolveVariablesInPrompt(message: string, variables: readonly ChatPromptReference[]): Promise<{ message: string }>;
resolveToolReferencesInPrompt(message: string, toolReferences: readonly ChatLanguageModelToolReference[]): Promise<string>;
/**
* Builds a context string describing resolved template variables for
* injection into system prompts. This allows skills that reference
* `{{VARIABLE_NAME}}` placeholders to have their values available
* via session context.
*/
buildTemplateVariablesContext(sessionId: string | undefined, debugTargetSessionIds?: readonly string[]): string;
}
export class NullPromptVariablesService implements IPromptVariablesService {
@@ -25,4 +33,8 @@ export class NullPromptVariablesService implements IPromptVariablesService {
async resolveToolReferencesInPrompt(message: string, toolReferences: readonly ChatLanguageModelToolReference[]): Promise<string> {
return message;
}
buildTemplateVariablesContext(): string {
return '';
}
}

View File

@@ -4,13 +4,58 @@
*--------------------------------------------------------------------------------------------*/
import type { ChatLanguageModelToolReference, ChatPromptReference } from 'vscode';
import * as vscode from 'vscode';
import { IChatDebugFileLoggerService } from '../../../platform/chat/common/chatDebugFileLoggerService';
import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';
import { IPromptPathRepresentationService } from '../../../platform/prompts/common/promptPathRepresentationService';
import { getToolName } from '../../tools/common/toolNames';
import { IPromptVariablesService } from '../node/promptVariablesService';
/**
* Known template variables that can be resolved at runtime.
* Each entry maps a placeholder name (without the `{{ }}` delimiters) to a
* resolver that produces the replacement string, or `undefined` if the
* variable cannot be resolved in the current context.
*/
type VariableResolver = (sessionId: string | undefined, debugTargetSessionIds: readonly string[] | undefined) => string | undefined;
export class PromptVariablesServiceImpl implements IPromptVariablesService {
declare readonly _serviceBrand: undefined;
private readonly _resolvers: ReadonlyMap<string, VariableResolver>;
constructor(
@IChatDebugFileLoggerService private readonly chatDebugFileLoggerService: IChatDebugFileLoggerService,
@IPromptPathRepresentationService private readonly promptPathRepresentationService: IPromptPathRepresentationService,
@IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext,
) {
this._resolvers = new Map<string, VariableResolver>([
['VSCODE_USER_PROMPTS_FOLDER', () => {
const globalStorageUri = this.extensionContext.globalStorageUri;
const userFolderUri = vscode.Uri.joinPath(globalStorageUri, '..', '..');
const userPromptsFolderUri = vscode.Uri.joinPath(userFolderUri, 'prompts');
return userPromptsFolderUri.fsPath;
}],
['VSCODE_TARGET_SESSION_LOG', (sessionId, debugTargetSessionIds) => {
if (debugTargetSessionIds && debugTargetSessionIds.length > 0) {
return debugTargetSessionIds.map(id => {
const sessionDir = this.chatDebugFileLoggerService.getSessionDir(id);
return sessionDir ? this.promptPathRepresentationService.getFilePath(sessionDir) : undefined;
}).filter((path): path is string => path !== undefined).join(', ');
}
if (!sessionId) {
return undefined;
}
const sessionDir = this.chatDebugFileLoggerService.getSessionDir(sessionId);
if (!sessionDir) {
return undefined;
}
return this.promptPathRepresentationService.getFilePath(sessionDir);
}],
]);
}
async resolveVariablesInPrompt(message: string, variables: ChatPromptReference[]): Promise<{ message: string }> {
for (const variable of this._reverseSortRefsWithRange(variables)) {
message = message.slice(0, variable.range[0]) + `[#${variable.name}](#${variable.name}-context)` + message.slice(variable.range[1]);
@@ -37,6 +82,25 @@ export class PromptVariablesServiceImpl implements IPromptVariablesService {
return message;
}
buildTemplateVariablesContext(sessionId: string | undefined, debugTargetSessionIds?: readonly string[]): string {
const entries: [string, string][] = [];
for (const [name, resolve] of this._resolvers) {
const value = resolve(sessionId, debugTargetSessionIds);
if (value !== undefined) {
entries.push([name, value]);
}
}
if (entries.length === 0) {
return '';
}
const lines = entries.map(([name, value]) => `- ${name}: ${value}`);
return [
'The following template variables are available for this session:',
...lines,
'When a skill or instruction references {{VSCODE_VARIABLE_NAME}}, substitute the corresponding value above.',
].join('\n');
}
private _reverseSortRefsWithRange<T extends { range?: [number, number] }>(refs: T[]): (T & { range: [number, number] })[] {
const refsWithRange = refs.filter(ref => !!ref.range) as (T & { range: [number, number] })[];
return refsWithRange.sort((a, b) => b.range[0] - a.range[0]);

View File

@@ -3,14 +3,42 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { tmpdir } from 'os';
import { join } from 'path';
import { beforeEach, describe, expect, test } from 'vitest';
import type { ChatLanguageModelToolReference, ChatPromptReference } from 'vscode';
import { ITestingServicesAccessor } from '../../../../platform/test/node/services';
import { IChatDebugFileLoggerService, NullChatDebugFileLoggerService } from '../../../../platform/chat/common/chatDebugFileLoggerService';
import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext';
import { MockExtensionContext } from '../../../../platform/test/node/extensionContext';
import { ITestingServicesAccessor, TestingServiceCollection } from '../../../../platform/test/node/services';
import { URI } from '../../../../util/vs/base/common/uri';
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
import { Uri } from '../../../../vscodeTypes';
import { createExtensionUnitTestingServices } from '../../../test/node/services';
import { PromptVariablesServiceImpl } from '../promptVariablesService';
class MockChatDebugFileLoggerService extends NullChatDebugFileLoggerService {
private readonly _sessionDirs = new Map<string, URI>();
setSessionDir(sessionId: string, dir: URI): void {
this._sessionDirs.set(sessionId, dir);
}
override getSessionDir(sessionId: string): URI | undefined {
return this._sessionDirs.get(sessionId);
}
}
function createServicesWithLogger(mockLogger?: MockChatDebugFileLoggerService): { testingServiceCollection: TestingServiceCollection; mockLogger: MockChatDebugFileLoggerService } {
const logger = mockLogger ?? new MockChatDebugFileLoggerService();
const testingServiceCollection = createExtensionUnitTestingServices();
// Provide a globalStorageUri so VSCODE_USER_PROMPTS_FOLDER can resolve
const ctx = new MockExtensionContext(join(tmpdir(), 'copilot-test-globalStorage'));
testingServiceCollection.define(IVSCodeExtensionContext, ctx as any);
testingServiceCollection.define(IChatDebugFileLoggerService, logger);
return { testingServiceCollection, mockLogger: logger };
}
describe('PromptVariablesServiceImpl', () => {
let accessor: ITestingServicesAccessor;
let service: PromptVariablesServiceImpl;
@@ -71,4 +99,113 @@ describe('PromptVariablesServiceImpl', () => {
const rewritten = await service.resolveToolReferencesInPrompt(out, []);
expect(rewritten).toBe(msg);
});
describe('buildTemplateVariablesContext', () => {
test('returns empty string when no session id and no debug target session ids are given', () => {
// Default NullChatDebugFileLoggerService returns undefined for every getSessionDir,
// so VSCODE_TARGET_SESSION_LOG resolves to undefined.
// VSCODE_USER_PROMPTS_FOLDER always resolves, so build a fresh service with the default null logger.
const { testingServiceCollection } = createServicesWithLogger();
const acc = testingServiceCollection.createTestingAccessor();
const svc = acc.get(IInstantiationService).createInstance(PromptVariablesServiceImpl);
const result = svc.buildTemplateVariablesContext(undefined);
// Only VSCODE_USER_PROMPTS_FOLDER should be present
expect(result).toContain('VSCODE_USER_PROMPTS_FOLDER');
expect(result).not.toContain('VSCODE_TARGET_SESSION_LOG');
});
test('resolves single sessionId to session log path', () => {
const mockLogger = new MockChatDebugFileLoggerService();
mockLogger.setSessionDir('session-1', URI.file('/logs/session-1'));
const { testingServiceCollection } = createServicesWithLogger(mockLogger);
const acc = testingServiceCollection.createTestingAccessor();
const svc = acc.get(IInstantiationService).createInstance(PromptVariablesServiceImpl);
const result = svc.buildTemplateVariablesContext('session-1');
expect(result).toContain('VSCODE_TARGET_SESSION_LOG');
expect(result).toContain('/logs/session-1');
});
test('prioritizes debugTargetSessionIds over sessionId', () => {
const mockLogger = new MockChatDebugFileLoggerService();
mockLogger.setSessionDir('session-1', URI.file('/logs/session-1'));
mockLogger.setSessionDir('target-1', URI.file('/logs/target-1'));
const { testingServiceCollection } = createServicesWithLogger(mockLogger);
const acc = testingServiceCollection.createTestingAccessor();
const svc = acc.get(IInstantiationService).createInstance(PromptVariablesServiceImpl);
const result = svc.buildTemplateVariablesContext('session-1', ['target-1']);
expect(result).toContain('/logs/target-1');
// session-1 should NOT appear because debugTargetSessionIds takes precedence
expect(result).not.toContain('/logs/session-1');
});
test('formats multiple debugTargetSessionIds as comma-separated paths', () => {
const mockLogger = new MockChatDebugFileLoggerService();
mockLogger.setSessionDir('target-1', URI.file('/logs/target-1'));
mockLogger.setSessionDir('target-2', URI.file('/logs/target-2'));
const { testingServiceCollection } = createServicesWithLogger(mockLogger);
const acc = testingServiceCollection.createTestingAccessor();
const svc = acc.get(IInstantiationService).createInstance(PromptVariablesServiceImpl);
const result = svc.buildTemplateVariablesContext(undefined, ['target-1', 'target-2']);
expect(result).toContain('VSCODE_TARGET_SESSION_LOG');
expect(result).toContain('/logs/target-1');
expect(result).toContain('/logs/target-2');
// Both paths joined with comma
expect(result).toMatch(/\/logs\/target-1, \/logs\/target-2/);
});
test('skips debugTargetSessionIds whose session dirs are missing', () => {
const mockLogger = new MockChatDebugFileLoggerService();
// Only target-2 has a session dir; target-1 does not
mockLogger.setSessionDir('target-2', URI.file('/logs/target-2'));
const { testingServiceCollection } = createServicesWithLogger(mockLogger);
const acc = testingServiceCollection.createTestingAccessor();
const svc = acc.get(IInstantiationService).createInstance(PromptVariablesServiceImpl);
const result = svc.buildTemplateVariablesContext(undefined, ['target-1', 'target-2']);
expect(result).toContain('/logs/target-2');
expect(result).not.toContain('target-1');
});
test('includes VSCODE_TARGET_SESSION_LOG with empty value when all debugTargetSessionIds have missing dirs', () => {
const mockLogger = new MockChatDebugFileLoggerService();
// No session dirs set at all
const { testingServiceCollection } = createServicesWithLogger(mockLogger);
const acc = testingServiceCollection.createTestingAccessor();
const svc = acc.get(IInstantiationService).createInstance(PromptVariablesServiceImpl);
const result = svc.buildTemplateVariablesContext(undefined, ['no-such-session']);
// The resolver returns '' (empty string) when all dirs are missing, not undefined,
// so the variable is still present in the output with an empty value.
expect(result).toContain('VSCODE_TARGET_SESSION_LOG');
expect(result).toMatch(/VSCODE_TARGET_SESSION_LOG:\s*$/m);
});
test('includes VSCODE_USER_PROMPTS_FOLDER derived from global storage URI', () => {
const { testingServiceCollection } = createServicesWithLogger();
const acc = testingServiceCollection.createTestingAccessor();
const svc = acc.get(IInstantiationService).createInstance(PromptVariablesServiceImpl);
const result = svc.buildTemplateVariablesContext(undefined);
expect(result).toContain('VSCODE_USER_PROMPTS_FOLDER');
// The path should end with /prompts
expect(result).toMatch(/prompts/);
});
test('returns empty string when sessionId has no session dir and no debugTargetSessionIds', () => {
const mockLogger = new MockChatDebugFileLoggerService();
// session-missing has no dir registered
const { testingServiceCollection } = createServicesWithLogger(mockLogger);
const acc = testingServiceCollection.createTestingAccessor();
const svc = acc.get(IInstantiationService).createInstance(PromptVariablesServiceImpl);
const result = svc.buildTemplateVariablesContext('session-missing');
// VSCODE_USER_PROMPTS_FOLDER still resolves
expect(result).toContain('VSCODE_USER_PROMPTS_FOLDER');
// But session log should not be present
expect(result).not.toContain('VSCODE_TARGET_SESSION_LOG');
});
});
});

View File

@@ -5,6 +5,7 @@
import { BasePromptElementProps, Chunk, Document, PromptElement, PromptPiece, PromptPieceChild, PromptSizing, Raw, SystemMessage, TokenLimit, UserMessage } from '@vscode/prompt-tsx';
import type { ChatRequestEditedFileEvent, LanguageModelToolInformation, NotebookEditor, TaskDefinition, TextEditor } from 'vscode';
import { sessionResourceToId } from '../../../../platform/chat/common/chatDebugFileLoggerService';
import { ChatLocation } from '../../../../platform/chat/common/commonTypes';
import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';
import { ICustomInstructionsService } from '../../../../platform/customInstructions/common/customInstructionsService';
@@ -25,7 +26,7 @@ import { URI } from '../../../../util/vs/base/common/uri';
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
import { ChatRequestEditedFileEventKind, Position, Range } from '../../../../vscodeTypes';
import { GenericBasePromptElementProps } from '../../../context/node/resolvers/genericPanelIntentInvocation';
import { ChatVariablesCollection, isCustomizationsIndex } from '../../../prompt/common/chatVariablesCollection';
import { ChatVariablesCollection, extractDebugTargetSessionIds, isCustomizationsIndex } from '../../../prompt/common/chatVariablesCollection';
import { getGlobalContextCacheKey, GlobalContextMessageMetadata, RenderedUserMessageMetadata, Turn } from '../../../prompt/common/conversation';
import { InternalToolReference } from '../../../prompt/common/intents';
import { IPromptVariablesService } from '../../../prompt/node/promptVariablesService';
@@ -114,6 +115,10 @@ export class AgentPrompt extends PromptElement<AgentPromptProps> {
</SystemMessage>
</>;
const isAutopilot = this.props.promptContext.request?.permissionLevel === 'autopilot';
const sessionResource = this.props.promptContext.request?.sessionResource;
const sessionId = sessionResource ? sessionResourceToId(sessionResource) : undefined;
const debugTargetSessionIds = extractDebugTargetSessionIds([...this.props.promptContext.chatVariables].map(v => v.reference));
const templateVariablesContext = this.promptVariablesService.buildTemplateVariablesContext(sessionId, debugTargetSessionIds);
const baseInstructions = <>
{!omitBaseAgentInstructions && baseAgentInstructions}
{await this.getAgentCustomInstructions()}
@@ -121,6 +126,7 @@ export class AgentPrompt extends PromptElement<AgentPromptProps> {
When you have fully completed the task, call the task_complete tool to signal that you are done.<br />
IMPORTANT: Before calling task_complete, you MUST provide a brief text summary of what was accomplished in your message. The task is not complete until both the summary and the task_complete call are present.
</SystemMessage>}
{templateVariablesContext.length > 0 && <SystemMessage>{templateVariablesContext}</SystemMessage>}
<UserMessage>
{await this.getOrCreateGlobalAgentContext(this.props.endpoint)}
</UserMessage>

View File

@@ -15,7 +15,7 @@ import { Schemas } from '../../../../util/vs/base/common/network';
import { URI } from '../../../../util/vs/base/common/uri';
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
import { ChatReferenceBinaryData, ChatRequest, FileType } from '../../../../vscodeTypes';
import { ChatVariablesCollection, isPromptFile, PromptVariable } from '../../../prompt/common/chatVariablesCollection';
import { ChatVariablesCollection, isPromptFile, isSessionReference, PromptVariable, sessionReferenceAttachmentAttrs } from '../../../prompt/common/chatVariablesCollection';
import { renderPromptElement } from '../base/promptRenderer';
import { Tag } from '../base/tag';
import { SummarizedDocumentLineNumberStyle } from '../inline/summarizedDocument/implementation';
@@ -133,6 +133,10 @@ export async function generateUserPrompt(request: ChatRequest, prompt: string |
async function renderResourceVariables(chatVariables: ChatVariablesCollection, fileSystemService: IFileSystemService, promptPathRepresentationService: IPromptPathRepresentationService): Promise<PromptElement[]> {
const elements: PromptElement[] = [];
await Promise.all(Array.from(chatVariables).map(async variable => {
if (isSessionReference(variable)) {
elements.push(<Tag name='attachment' attrs={sessionReferenceAttachmentAttrs(variable)} />);
return;
}
if (variable.value instanceof ChatReferenceBinaryData) {
if (variable.value.reference) {
const attrs: Record<string, string> = {};

View File

@@ -30,7 +30,7 @@ import { IInstantiationService } from '../../../../util/vs/platform/instantiatio
import { DiagnosticSeverity } from '../../../../util/vs/workbench/api/common/extHostTypes/diagnostic';
import { ChatReferenceBinaryData, ChatReferenceDiagnostic, LanguageModelToolResult2, Range, Uri } from '../../../../vscodeTypes';
import { GenericBasePromptElementProps } from '../../../context/node/resolvers/genericPanelIntentInvocation';
import { ChatVariablesCollection, isCustomizationsIndex, isInstructionFile, isPromptFile, PromptVariable } from '../../../prompt/common/chatVariablesCollection';
import { ChatVariablesCollection, isCustomizationsIndex, isInstructionFile, isPromptFile, isSessionReference, PromptVariable, sessionReferenceAttachmentAttrs } from '../../../prompt/common/chatVariablesCollection';
import { InternalToolReference } from '../../../prompt/common/intents';
import { ToolName } from '../../../tools/common/toolNames';
import { normalizeToolSchema } from '../../../tools/common/toolSchemaNormalizer';
@@ -241,6 +241,11 @@ export async function renderChatVariables(chatVariables: ChatVariablesCollection
continue;
}
if (isSessionReference(variable)) {
elements.push(<Tag name='attachment' attrs={sessionReferenceAttachmentAttrs(variable)} />);
continue;
}
if (URI.isUri(variableValue) || isLocation(variableValue)) {
const uri = 'uri' in variableValue ? variableValue.uri : variableValue;

View File

@@ -5,13 +5,10 @@
import { BasePromptElementProps, PromptElement, PromptReference, PromptSizing } from '@vscode/prompt-tsx';
import type { ChatLanguageModelToolReference } from 'vscode';
import { IChatDebugFileLoggerService } from '../../../../platform/chat/common/chatDebugFileLoggerService';
import { IIgnoreService } from '../../../../platform/ignore/common/ignoreService';
import { ILogService } from '../../../../platform/log/common/logService';
import { IPromptPathRepresentationService } from '../../../../platform/prompts/common/promptPathRepresentationService';
import { getCurrentCapturingToken } from '../../../../platform/requestLogger/node/requestLogger';
import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
import { joinPath } from '../../../../util/vs/base/common/resources';
import { URI } from '../../../../util/vs/base/common/uri';
import { PromptVariable } from '../../../prompt/common/chatVariablesCollection';
import { IPromptVariablesService } from '../../../prompt/node/promptVariablesService';
@@ -32,7 +29,6 @@ export class PromptFile extends PromptElement<PromptFileProps, void> {
@IPromptPathRepresentationService private readonly promptPathRepresentationService: IPromptPathRepresentationService,
@IIgnoreService private readonly ignoreService: IIgnoreService,
@IWorkspaceService private readonly workspaceService: IWorkspaceService,
@IChatDebugFileLoggerService private readonly chatDebugFileLoggerService: IChatDebugFileLoggerService,
) {
super(props);
}
@@ -76,18 +72,7 @@ export class PromptFile extends PromptElement<PromptFileProps, void> {
bodyOffset = match.index! + match[0].length;
}
}
let bodyContent = content.substring(bodyOffset);
// Replace session log placeholder for troubleshoot skill
if (fileUri.scheme === 'copilot-skill' && fileUri.path.includes('/troubleshoot/') && bodyContent.includes('{{CURRENT_SESSION_LOG}}')) {
const token = getCurrentCapturingToken();
const sessionIds = token?.debugTargetSessionIds ?? (token?.chatSessionId ? [token.chatSessionId] : []);
const logDir = this.chatDebugFileLoggerService.debugLogsDir;
if (sessionIds.length > 0 && logDir) {
const sessionLogDirs = sessionIds.map(id => this.promptPathRepresentationService.getFilePath(joinPath(logDir, id)));
bodyContent = bodyContent.replaceAll('{{CURRENT_SESSION_LOG}}', () => sessionLogDirs.join(', '));
}
}
const bodyContent = content.substring(bodyOffset);
return bodyContent;
} catch (e) {

View File

@@ -5,7 +5,6 @@
import * as l10n from '@vscode/l10n';
import { BasePromptElementProps, PromptElement, PromptElementProps, PromptReference } from '@vscode/prompt-tsx';
import type * as vscode from 'vscode';
import { IChatDebugFileLoggerService, sessionResourceToId } from '../../../platform/chat/common/chatDebugFileLoggerService';
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
import { ObjectJsonSchema } from '../../../platform/configuration/common/jsonSchema';
import { ICustomInstructionsService } from '../../../platform/customInstructions/common/customInstructionsService';
@@ -21,11 +20,10 @@ import { ITelemetryService } from '../../../platform/telemetry/common/telemetry'
import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
import { getCachedSha256Hash } from '../../../util/common/crypto';
import { clamp } from '../../../util/vs/base/common/numbers';
import { dirname, extUriBiasedIgnorePathCase, joinPath } from '../../../util/vs/base/common/resources';
import { dirname, extUriBiasedIgnorePathCase } from '../../../util/vs/base/common/resources';
import { URI } from '../../../util/vs/base/common/uri';
import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';
import { LanguageModelPromptTsxPart, LanguageModelToolResult, Location, MarkdownString, Range } from '../../../vscodeTypes';
import { isSessionReferenceScheme } from '../../prompt/common/chatVariablesCollection';
import { IBuildPromptContext } from '../../prompt/common/intents';
import { renderPromptElementJSON } from '../../prompts/node/base/promptRenderer';
import { BinaryFileHexdump, hexdumpIfBinary } from '../../prompts/node/panel/binaryFileHexdump';
@@ -129,7 +127,6 @@ export class ReadFileTool implements ICopilotTool<ReadFileParams> {
@IExperimentationService private readonly experimentationService: IExperimentationService,
@ICustomInstructionsService private readonly customInstructionsService: ICustomInstructionsService,
@IFileSystemService private readonly fileSystemService: IFileSystemService,
@IChatDebugFileLoggerService private readonly chatDebugFileLoggerService: IChatDebugFileLoggerService,
) { }
async invoke(options: vscode.LanguageModelToolInvocationOptions<ReadFileParams>, token: vscode.CancellationToken) {
@@ -329,25 +326,7 @@ export class ReadFileTool implements ICopilotTool<ReadFileParams> {
return this.workspaceService.openNotebookDocumentAndSnapshot(uri, this.alternativeNotebookContent.getFormat(this._promptContext?.request?.model));
}
const snapshot = TextDocumentSnapshot.create(await this.workspaceService.openTextDocument(uri));
// Replace the session log placeholder for the troubleshoot skill
if (uri.scheme === 'copilot-skill' && uri.path.includes('/troubleshoot/')) {
const sessionRefs = this._promptContext?.request?.references?.filter(ref => URI.isUri(ref.value) && isSessionReferenceScheme(ref.value.scheme)) ?? [];
const sessionResources = sessionRefs.length > 0
? sessionRefs.map(ref => ref.value as URI)
: (this._promptContext?.request?.sessionResource ? [this._promptContext.request.sessionResource] : []);
if (sessionResources.length > 0) {
const logDir = this.chatDebugFileLoggerService.debugLogsDir;
if (logDir) {
const sessionLogDirs = sessionResources.map(res => this.promptPathRepresentationService.getFilePath(joinPath(logDir, sessionResourceToId(res))));
const replaced = snapshot.getText().replaceAll('{{CURRENT_SESSION_LOG}}', () => sessionLogDirs.join(', '));
return TextDocumentSnapshot.fromNewText(replaced, snapshot);
}
}
}
return snapshot;
return TextDocumentSnapshot.create(await this.workspaceService.openTextDocument(uri));
}
private async sendReadFileTelemetry(outcome: string, options: Pick<vscode.LanguageModelToolInvocationOptions<ReadFileParams>, 'model' | 'chatRequestId' | 'input'>, { start, end, truncated }: IParamRanges, uri: URI | undefined) {

View File

@@ -4,18 +4,15 @@
*--------------------------------------------------------------------------------------------*/
import { afterAll, beforeAll, expect, suite, test } from 'vitest';
import { IChatDebugFileLoggerService } from '../../../../platform/chat/common/chatDebugFileLoggerService';
import { ICustomInstructionsService } from '../../../../platform/customInstructions/common/customInstructionsService';
import { IFileSystemService } from '../../../../platform/filesystem/common/fileSystemService';
import { MockFileSystemService } from '../../../../platform/filesystem/node/test/mockFileSystemService';
import { IPromptPathRepresentationService } from '../../../../platform/prompts/common/promptPathRepresentationService';
import { MockCustomInstructionsService } from '../../../../platform/test/common/testCustomInstructionsService';
import { ITestingServicesAccessor } from '../../../../platform/test/node/services';
import { TestWorkspaceService } from '../../../../platform/test/node/testWorkspaceService';
import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
import { createTextDocumentData } from '../../../../util/common/test/shims/textDocument';
import { CancellationToken } from '../../../../util/vs/base/common/cancellation';
import { dirname } from '../../../../util/vs/base/common/resources';
import { URI } from '../../../../util/vs/base/common/uri';
import { SyncDescriptor } from '../../../../util/vs/platform/instantiation/common/descriptors';
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
@@ -712,309 +709,4 @@ suite('ReadFile', () => {
testAccessor.dispose();
});
});
suite('troubleshoot skill session log replacement', () => {
test('replaces {{CURRENT_SESSION_LOG}} placeholder for troubleshoot skill URI', async () => {
const skillUri = URI.from({ scheme: 'copilot-skill', path: '/troubleshoot/SKILL.md' });
const skillContent = '---\nname: troubleshoot\n---\n\nLog dir: `{{CURRENT_SESSION_LOG}}`\nMore content here.';
const skillDoc = createTextDocumentData(skillUri, skillContent, 'markdown').document;
const expectedLogDir = URI.file('/mock/storage/debug-logs/session-abc');
const services = createExtensionUnitTestingServices();
services.define(IWorkspaceService, new SyncDescriptor(
TestWorkspaceService,
[[URI.file('/workspace')], [skillDoc]]
));
services.define(IChatDebugFileLoggerService, {
_serviceBrand: undefined,
startSession: async () => { },
endSession: async () => { },
flush: async () => { },
getLogPath: () => undefined,
getSessionDir: () => undefined,
getActiveSessionIds: () => [],
isDebugLogUri: () => false,
getSessionDirForResource: () => expectedLogDir,
setModelSnapshot: () => { },
debugLogsDir: dirname(expectedLogDir),
} satisfies IChatDebugFileLoggerService);
const testAccessor = services.createTestingAccessor();
const readFileTool = testAccessor.get(IInstantiationService).createInstance(ReadFileTool);
const promptPathRepresentationService = testAccessor.get(IPromptPathRepresentationService);
// Set up prompt context with a sessionResource
await readFileTool.resolveInput(
{ filePath: skillUri.toString(), startLine: 1, endLine: 100 },
{ query: '', history: [], chatVariables: { *[Symbol.iterator]() { } } as never, request: { sessionResource: URI.parse('vscode-chat-session://local/c2Vzc2lvbi1hYmM=') } } as never,
);
const input: IReadFileParamsV2 = { filePath: skillUri.toString() };
const result = await readFileTool.invoke(
{ input, toolInvocationToken: null as never },
CancellationToken.None
);
const text = await toolResultToString(testAccessor, result);
expect(text).toContain(promptPathRepresentationService.getFilePath(expectedLogDir));
expect(text).not.toContain('{{CURRENT_SESSION_LOG}}');
testAccessor.dispose();
});
test('leaves placeholder unreplaced when no sessionResource is set', async () => {
const skillUri = URI.from({ scheme: 'copilot-skill', path: '/troubleshoot/SKILL.md' });
const skillContent = '---\nname: troubleshoot\n---\n\nLog dir: `{{CURRENT_SESSION_LOG}}`';
const skillDoc = createTextDocumentData(skillUri, skillContent, 'markdown').document;
const services = createExtensionUnitTestingServices();
services.define(IWorkspaceService, new SyncDescriptor(
TestWorkspaceService,
[[URI.file('/workspace')], [skillDoc]]
));
const testAccessor = services.createTestingAccessor();
const readFileTool = testAccessor.get(IInstantiationService).createInstance(ReadFileTool);
const input: IReadFileParamsV2 = { filePath: skillUri.toString() };
const result = await readFileTool.invoke(
{ input, toolInvocationToken: null as never },
CancellationToken.None
);
const text = await toolResultToString(testAccessor, result);
expect(text).toContain('{{CURRENT_SESSION_LOG}}');
testAccessor.dispose();
});
test('uses attached session reference instead of sessionResource', async () => {
const skillUri = URI.from({ scheme: 'copilot-skill', path: '/troubleshoot/SKILL.md' });
const skillContent = '---\nname: troubleshoot\n---\n\nLog dir: `{{CURRENT_SESSION_LOG}}`\nMore content here.';
const skillDoc = createTextDocumentData(skillUri, skillContent, 'markdown').document;
const debugLogsDir = URI.file('/mock/storage/debug-logs');
const services = createExtensionUnitTestingServices();
services.define(IWorkspaceService, new SyncDescriptor(
TestWorkspaceService,
[[URI.file('/workspace')], [skillDoc]]
));
services.define(IChatDebugFileLoggerService, {
_serviceBrand: undefined,
startSession: async () => { },
endSession: async () => { },
flush: async () => { },
getLogPath: () => undefined,
getSessionDir: () => undefined,
getActiveSessionIds: () => [],
isDebugLogUri: () => false,
getSessionDirForResource: () => undefined,
setModelSnapshot: () => { },
debugLogsDir,
} satisfies IChatDebugFileLoggerService);
const testAccessor = services.createTestingAccessor();
const readFileTool = testAccessor.get(IInstantiationService).createInstance(ReadFileTool);
const promptPathRepresentationService = testAccessor.get(IPromptPathRepresentationService);
// Attach a session reference URI — this should be preferred over sessionResource
const attachedSessionUri = URI.parse('vscode-chat-session://local/YXR0YWNoZWQtc2Vzc2lvbg=='); // base64 of 'attached-session'
const currentSessionUri = URI.parse('vscode-chat-session://local/Y3VycmVudC1zZXNzaW9u'); // base64 of 'current-session'
await readFileTool.resolveInput(
{ filePath: skillUri.toString(), startLine: 1, endLine: 100 },
{
query: '', history: [], chatVariables: { *[Symbol.iterator]() { } } as never,
request: {
sessionResource: currentSessionUri,
references: [{ id: attachedSessionUri.toString(), name: 'Test Session', value: attachedSessionUri }],
},
} as never,
);
const input: IReadFileParamsV2 = { filePath: skillUri.toString() };
const result = await readFileTool.invoke(
{ input, toolInvocationToken: null as never },
CancellationToken.None
);
const text = await toolResultToString(testAccessor, result);
const expectedDir = promptPathRepresentationService.getFilePath(URI.joinPath(debugLogsDir, 'attached-session'));
expect(text).toContain(expectedDir);
expect(text).not.toContain('current-session');
expect(text).not.toContain('{{CURRENT_SESSION_LOG}}');
testAccessor.dispose();
});
test('uses multiple session references when attached', async () => {
const skillUri = URI.from({ scheme: 'copilot-skill', path: '/troubleshoot/SKILL.md' });
const skillContent = '---\nname: troubleshoot\n---\n\nLog dir: `{{CURRENT_SESSION_LOG}}`';
const skillDoc = createTextDocumentData(skillUri, skillContent, 'markdown').document;
const debugLogsDir = URI.file('/mock/storage/debug-logs');
const services = createExtensionUnitTestingServices();
services.define(IWorkspaceService, new SyncDescriptor(
TestWorkspaceService,
[[URI.file('/workspace')], [skillDoc]]
));
services.define(IChatDebugFileLoggerService, {
_serviceBrand: undefined,
startSession: async () => { },
endSession: async () => { },
flush: async () => { },
getLogPath: () => undefined,
getSessionDir: () => undefined,
getActiveSessionIds: () => [],
isDebugLogUri: () => false,
getSessionDirForResource: () => undefined,
setModelSnapshot: () => { },
debugLogsDir,
} satisfies IChatDebugFileLoggerService);
const testAccessor = services.createTestingAccessor();
const readFileTool = testAccessor.get(IInstantiationService).createInstance(ReadFileTool);
const promptPathRepresentationService = testAccessor.get(IPromptPathRepresentationService);
const sessionUri1 = URI.parse('vscode-chat-session://local/c2Vzc2lvbi0x'); // base64 of 'session-1'
const sessionUri2 = URI.parse('vscode-chat-session://local/c2Vzc2lvbi0y'); // base64 of 'session-2'
await readFileTool.resolveInput(
{ filePath: skillUri.toString(), startLine: 1, endLine: 100 },
{
query: '', history: [], chatVariables: { *[Symbol.iterator]() { } } as never,
request: {
sessionResource: URI.parse('vscode-chat-session://local/Y3VycmVudA=='),
references: [
{ id: sessionUri1.toString(), name: 'Session 1', value: sessionUri1 },
{ id: sessionUri2.toString(), name: 'Session 2', value: sessionUri2 },
],
},
} as never,
);
const input: IReadFileParamsV2 = { filePath: skillUri.toString() };
const result = await readFileTool.invoke(
{ input, toolInvocationToken: null as never },
CancellationToken.None
);
const text = await toolResultToString(testAccessor, result);
expect(text).toContain(promptPathRepresentationService.getFilePath(URI.joinPath(debugLogsDir, 'session-1')));
expect(text).toContain(promptPathRepresentationService.getFilePath(URI.joinPath(debugLogsDir, 'session-2')));
expect(text).not.toContain('{{CURRENT_SESSION_LOG}}');
testAccessor.dispose();
});
test('ignores non-session references in the references array', async () => {
const skillUri = URI.from({ scheme: 'copilot-skill', path: '/troubleshoot/SKILL.md' });
const skillContent = '---\nname: troubleshoot\n---\n\nLog dir: `{{CURRENT_SESSION_LOG}}`';
const skillDoc = createTextDocumentData(skillUri, skillContent, 'markdown').document;
const debugLogsDir = URI.file('/mock/storage/debug-logs');
const services = createExtensionUnitTestingServices();
services.define(IWorkspaceService, new SyncDescriptor(
TestWorkspaceService,
[[URI.file('/workspace')], [skillDoc]]
));
services.define(IChatDebugFileLoggerService, {
_serviceBrand: undefined,
startSession: async () => { },
endSession: async () => { },
flush: async () => { },
getLogPath: () => undefined,
getSessionDir: () => undefined,
getActiveSessionIds: () => [],
isDebugLogUri: () => false,
getSessionDirForResource: () => undefined,
setModelSnapshot: () => { },
debugLogsDir,
} satisfies IChatDebugFileLoggerService);
const testAccessor = services.createTestingAccessor();
const readFileTool = testAccessor.get(IInstantiationService).createInstance(ReadFileTool);
const promptPathRepresentationService = testAccessor.get(IPromptPathRepresentationService);
const currentSessionUri = URI.parse('vscode-chat-session://local/Y3VycmVudC1zZXNzaW9u'); // base64 of 'current-session'
await readFileTool.resolveInput(
{ filePath: skillUri.toString(), startLine: 1, endLine: 100 },
{
query: '', history: [], chatVariables: { *[Symbol.iterator]() { } } as never,
request: {
sessionResource: currentSessionUri,
references: [
// Non-session references (file, instructions) should be ignored
{ id: 'vscode.prompt.file__some-file', name: 'some-file', value: URI.file('/some/file.md') },
{ id: 'vscode.instructions.file.root__some-instructions', name: 'instructions', value: URI.file('/some/instructions.md') },
],
},
} as never,
);
const input: IReadFileParamsV2 = { filePath: skillUri.toString() };
const result = await readFileTool.invoke(
{ input, toolInvocationToken: null as never },
CancellationToken.None
);
const text = await toolResultToString(testAccessor, result);
// Should fall back to sessionResource since no session references were attached
const expectedDir = promptPathRepresentationService.getFilePath(URI.joinPath(debugLogsDir, 'current-session'));
expect(text).toContain(expectedDir);
expect(text).not.toContain('{{CURRENT_SESSION_LOG}}');
testAccessor.dispose();
});
test('does not replace placeholder for non-troubleshoot skill URIs', async () => {
const otherSkillUri = URI.from({ scheme: 'copilot-skill', path: '/other-skill/SKILL.md' });
const content = 'Some content with {{CURRENT_SESSION_LOG}} placeholder';
const doc = createTextDocumentData(otherSkillUri, content, 'markdown').document;
const services = createExtensionUnitTestingServices();
services.define(IWorkspaceService, new SyncDescriptor(
TestWorkspaceService,
[[URI.file('/workspace')], [doc]]
));
services.define(IChatDebugFileLoggerService, {
_serviceBrand: undefined,
startSession: async () => { },
endSession: async () => { },
flush: async () => { },
getLogPath: () => undefined,
getSessionDir: () => undefined,
getActiveSessionIds: () => [],
isDebugLogUri: () => false,
getSessionDirForResource: () => URI.file('/should/not/appear'),
setModelSnapshot: () => { },
debugLogsDir: URI.file('/should/not/appear'),
} satisfies IChatDebugFileLoggerService);
const testAccessor = services.createTestingAccessor();
const readFileTool = testAccessor.get(IInstantiationService).createInstance(ReadFileTool);
await readFileTool.resolveInput(
{ filePath: otherSkillUri.toString(), startLine: 1, endLine: 100 },
{ query: '', history: [], chatVariables: { *[Symbol.iterator]() { } } as never, request: { sessionResource: URI.parse('vscode-chat-session://local/c2Vzc2lvbi1hYmM=') } } as never,
);
const input: IReadFileParamsV2 = { filePath: otherSkillUri.toString() };
const result = await readFileTool.invoke(
{ input, toolInvocationToken: null as never },
CancellationToken.None
);
const text = await toolResultToString(testAccessor, result);
expect(text).toContain('{{CURRENT_SESSION_LOG}}');
expect(text).not.toContain('/should/not/appear');
testAccessor.dispose();
});
});
});

View File

@@ -206,7 +206,10 @@ export async function assertFileOkForTool(accessor: ServicesAccessor, uri: URI,
}
async function isExternalInstructionsFile(normalizedUri: URI, customInstructionsService: ICustomInstructionsService, buildPromptContext?: IBuildPromptContext): Promise<boolean> {
if (normalizedUri.scheme === 'vscode-chat-internal' || normalizedUri.scheme === 'copilot-skill') {
if (normalizedUri.scheme === 'vscode-chat-internal') {
return true;
}
if (customInstructionsService.getExtensionSkillInfo(normalizedUri)) {
return true;
}
if (buildPromptContext) {

View File

@@ -109,8 +109,8 @@ export class NullChatDebugFileLoggerService implements IChatDebugFileLoggerServi
async startSession(): Promise<void> { }
async endSession(): Promise<void> { }
async flush(): Promise<void> { }
getLogPath(): URI | undefined { return undefined; }
getSessionDir(): URI | undefined { return undefined; }
getLogPath(_sessionId?: string): URI | undefined { return undefined; }
getSessionDir(_sessionId?: string): URI | undefined { return undefined; }
getActiveSessionIds(): string[] { return []; }
isDebugLogUri(): boolean { return false; }
getSessionDirForResource(): URI | undefined { return undefined; }

View File

@@ -52,6 +52,7 @@ export const ICustomInstructionsService = createServiceIdentifier<ICustomInstruc
export interface IExtensionPromptFile {
uri: URI;
type: PromptsType;
extensionId?: string;
}
export interface ICustomInstructionsService {
@@ -77,7 +78,8 @@ export interface ICustomInstructionsService {
*/
refreshExtensionPromptFiles(): Promise<void>;
/** Gets skill info for extension-contributed skill files */
getExtensionSkillInfo(uri: URI): { skillName: string; skillFolderUri: URI } | undefined;
getExtensionSkillInfo(uri: URI): { skillName: string; skillFolderUri: URI; extensionId?: string } | undefined;
getExtensionPromptFileInfo(uri: URI): IExtensionPromptFile | undefined;
}
export interface IInstructionIndexFile {
@@ -401,7 +403,7 @@ export class CustomInstructionsService extends Disposable implements ICustomInst
});
}
public getExtensionSkillInfo(uri: URI): { skillName: string; skillFolderUri: URI } | undefined {
public getExtensionSkillInfo(uri: URI): { skillName: string; skillFolderUri: URI; extensionId?: string } | undefined {
if (!this._extensionPromptFilesCache) {
return undefined;
}
@@ -410,13 +412,17 @@ export class CustomInstructionsService extends Disposable implements ICustomInst
const skillFolderUri = extUriBiasedIgnorePathCase.dirname(file.uri);
if (extUriBiasedIgnorePathCase.isEqualOrParent(uri, skillFolderUri)) {
const skillName = extUriBiasedIgnorePathCase.basename(skillFolderUri);
return { skillName, skillFolderUri };
return { skillName, skillFolderUri, extensionId: file.extensionId };
}
}
}
return undefined;
}
public getExtensionPromptFileInfo(uri: URI): IExtensionPromptFile | undefined {
return this._extensionPromptFilesCache?.find(file => extUriBiasedIgnorePathCase.isEqual(file.uri, uri));
}
public parseInstructionIndexFile(content: string): InstructionIndexFile {
return new InstructionIndexFile(content, this.promptPathRepresentationService);
}

View File

@@ -44,11 +44,5 @@ export class CapturingToken {
* Used to name the child log file within the parent session's directory.
*/
public readonly debugLogLabel?: string,
/**
* Optional override session IDs for debug log resolution.
* When set (e.g., from attached session references), the troubleshoot skill
* targets these sessions' logs instead of the current chat session.
*/
public readonly debugTargetSessionIds?: readonly string[],
) { }
}

View File

@@ -125,4 +125,8 @@ export class MockCustomInstructionsService implements ICustomInstructionsService
getExtensionSkillInfo(uri: URI): { skillName: string; skillFolderUri: URI } | undefined {
return this.extensionSkillInfos.get(uri.toString());
}
getExtensionPromptFileInfo(_uri: URI): undefined {
return undefined;
}
}

View File

@@ -246,7 +246,7 @@ async function registerChatServices(testingServiceCollection: TestingServiceColl
override async monitorSessionFiles() {
// Override to do nothing in tests
}
protected override async createSessionsOptions(options: { model?: string; workingDirectory?: Uri; workspaceInfo: IWorkspaceInfo; mcpServers?: SessionOptions['mcpServers'] }): Promise<CopilotCLISessionOptions> {
protected override async createSessionsOptions(options: { model?: string; workingDirectory?: Uri; workspaceInfo: IWorkspaceInfo; mcpServers?: SessionOptions['mcpServers']; sessionId?: string; debugTargetSessionIds?: readonly string[] }): Promise<CopilotCLISessionOptions> {
const testOptionsProvider = this.instantiationService.invokeFunction((accessor) => accessor.get(ITestSessionOptionsProvider));
const overrideOptions = await testOptionsProvider.getOptions();
const sessionOptions = new TestCopilotCLISessionOptions(options, this.logService, overrideOptions);