From 41b7ef8514b5ca7143634fb2d8742d1ac12f48cb Mon Sep 17 00:00:00 2001 From: Paul Date: Fri, 27 Mar 2026 14:55:42 -0700 Subject: [PATCH] Support dynamic prompt variables (#4742) * wip * fixes * update * update * updates * updates * clean * clean * clean * fix tests * update * fix test --- .../skills/agent-customization/SKILL.md | 4 +- .../prompts/skills/troubleshoot/SKILL.md | 2 +- extensions/copilot/package.json | 6 + .../agentCustomizationSkillProvider.ts | 50 --- .../agents/vscode-node/baseSkillProvider.ts | 78 ----- .../agents/vscode-node/promptFileContrib.ts | 12 - .../vscode-node/skillFsProviderHelper.ts | 230 ------------- .../vscode-node/troubleshootSkillProvider.ts | 44 --- .../vscode-node/chatDebugFileLoggerService.ts | 15 +- .../copilotcli/node/copilotCli.ts | 7 +- .../node/copilotcliSessionService.ts | 24 +- .../test/copilotCliSessionService.spec.ts | 11 +- .../vscode-node/copilotCLIChatSessions.ts | 7 +- .../copilotCLIChatSessionsContribution.ts | 7 +- .../copilotCLIChatSessionParticipant.spec.ts | 3 +- .../prompt/common/chatVariablesCollection.ts | 23 ++ .../prompt/node/chatParticipantTelemetry.ts | 22 +- .../node/defaultIntentRequestHandler.ts | 7 +- .../prompt/node/promptVariablesService.ts | 12 + .../vscode-node/promptVariablesService.ts | 64 ++++ .../test/promptVariablesService.spec.ts | 139 +++++++- .../prompts/node/agent/agentPrompt.tsx | 8 +- .../prompts/node/agent/copilotCLIPrompt.tsx | 6 +- .../prompts/node/panel/chatVariables.tsx | 7 +- .../prompts/node/panel/promptFile.tsx | 17 +- .../src/extension/tools/node/readFileTool.tsx | 25 +- .../tools/node/test/readFile.spec.tsx | 308 ------------------ .../src/extension/tools/node/toolUtils.ts | 5 +- .../chat/common/chatDebugFileLoggerService.ts | 4 +- .../common/customInstructionsService.ts | 12 +- .../requestLogger/common/capturingToken.ts | 6 - .../common/testCustomInstructionsService.ts | 4 + extensions/copilot/test/e2e/cli.stest.ts | 2 +- 33 files changed, 355 insertions(+), 816 deletions(-) delete mode 100644 extensions/copilot/src/extension/agents/vscode-node/agentCustomizationSkillProvider.ts delete mode 100644 extensions/copilot/src/extension/agents/vscode-node/baseSkillProvider.ts delete mode 100644 extensions/copilot/src/extension/agents/vscode-node/skillFsProviderHelper.ts delete mode 100644 extensions/copilot/src/extension/agents/vscode-node/troubleshootSkillProvider.ts diff --git a/extensions/copilot/assets/prompts/skills/agent-customization/SKILL.md b/extensions/copilot/assets/prompts/skills/agent-customization/SKILL.md index 19a69e5e27f..ede08805ce8 100644 --- a/extensions/copilot/assets/prompts/skills/agent-customization/SKILL.md +++ b/extensions/copilot/assets/prompts/skills/agent-customization/SKILL.md @@ -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//`, `.agents/skills//`, `.claude/skills//` | [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 diff --git a/extensions/copilot/assets/prompts/skills/troubleshoot/SKILL.md b/extensions/copilot/assets/prompts/skills/troubleshoot/SKILL.md index 08ea3f2f0c0..08acea63a13 100644 --- a/extensions/copilot/assets/prompts/skills/troubleshoot/SKILL.md +++ b/extensions/copilot/assets/prompts/skills/troubleshoot/SKILL.md @@ -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: diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 766d1b780c0..3e4253cd86d 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -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": { diff --git a/extensions/copilot/src/extension/agents/vscode-node/agentCustomizationSkillProvider.ts b/extensions/copilot/src/extension/agents/vscode-node/agentCustomizationSkillProvider.ts deleted file mode 100644 index 99ec7ef4a9d..00000000000 --- a/extensions/copilot/src/extension/agents/vscode-node/agentCustomizationSkillProvider.ts +++ /dev/null @@ -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 { - if (this.cachedContent) { - return this.cachedContent; - } - - this.cachedContent = await super.getSkillContentBytes(); - return this.cachedContent; - } -} diff --git a/extensions/copilot/src/extension/agents/vscode-node/baseSkillProvider.ts b/extensions/copilot/src/extension/agents/vscode-node/baseSkillProvider.ts deleted file mode 100644 index a93760245d1..00000000000 --- a/extensions/copilot/src/extension/agents/vscode-node/baseSkillProvider.ts +++ /dev/null @@ -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//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; - - protected async getSkillContentBytes(): Promise { - 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 { - if (token.isCancellationRequested) { - return []; - } - - return [{ uri: this.skillContentUri }]; - } -} diff --git a/extensions/copilot/src/extension/agents/vscode-node/promptFileContrib.ts b/extensions/copilot/src/extension/agents/vscode-node/promptFileContrib.ts index 758c7046091..59d4cc81e73 100644 --- a/extensions/copilot/src/extension/agents/vscode-node/promptFileContrib.ts +++ b/extensions/copilot/src/extension/agents/vscode-node/promptFileContrib.ts @@ -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)); - } } } diff --git a/extensions/copilot/src/extension/agents/vscode-node/skillFsProviderHelper.ts b/extensions/copilot/src/extension/agents/vscode-node/skillFsProviderHelper.ts deleted file mode 100644 index 9b7eb809496..00000000000 --- a/extensions/copilot/src/extension/agents/vscode-node/skillFsProviderHelper.ts +++ /dev/null @@ -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; -} - -class SkillFsProvider implements vscode.FileSystemProvider { - - private readonly dynamicSkills = new Map(); - - private readonly _onDidChangeFile = new Emitter(); - 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 { - 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 { - 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, -): { 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, - }; -} diff --git a/extensions/copilot/src/extension/agents/vscode-node/troubleshootSkillProvider.ts b/extensions/copilot/src/extension/agents/vscode-node/troubleshootSkillProvider.ts deleted file mode 100644 index 1ea77a0d18f..00000000000 --- a/extensions/copilot/src/extension/agents/vscode-node/troubleshootSkillProvider.ts +++ /dev/null @@ -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()); - } -} diff --git a/extensions/copilot/src/extension/chat/vscode-node/chatDebugFileLoggerService.ts b/extensions/copilot/src/extension/chat/vscode-node/chatDebugFileLoggerService.ts index 6dcb1380661..713d080d8b4 100644 --- a/extensions/copilot/src/extension/chat/vscode-node/chatDebugFileLoggerService.ts +++ b/extensions/copilot/src/extension/chat/vscode-node/chatDebugFileLoggerService.ts @@ -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[] { diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts index a5c92409f8c..26a82c7455d 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts @@ -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; } diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index cd3484cfd11..11e42778d1d 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -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; // Session wrapper tracking - getSession(options: { sessionId: string; model?: string; workspaceInfo: IWorkspaceInfo; readonly: boolean; agent?: SweCustomAgent; mcpServerMappings?: McpServerMappings }, token: CancellationToken): Promise | undefined>; - createSession(options: { model?: string; workspaceInfo: IWorkspaceInfo; agent?: SweCustomAgent; sessionId?: string; mcpServerMappings?: McpServerMappings }, token: CancellationToken): Promise>; + getSession(options: { sessionId: string; model?: string; workspaceInfo: IWorkspaceInfo; readonly: boolean; agent?: SweCustomAgent; debugTargetSessionIds?: readonly string[]; mcpServerMappings?: McpServerMappings }, token: CancellationToken): Promise | undefined>; + createSession(options: { model?: string; workspaceInfo: IWorkspaceInfo; agent?: SweCustomAgent; sessionId?: string; debugTargetSessionIds?: readonly string[]; mcpServerMappings?: McpServerMappings }, token: CancellationToken): Promise>; forkSession(sessionId: string, requestId: string | undefined, options: { workspaceInfo: IWorkspaceInfo }, token: CancellationToken): Promise; tryGetPartialSesionHistory(sessionId: string): Promise; } @@ -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 { + 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 { 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 { + protected async createSessionsOptions(options: { model?: string; workspaceInfo: IWorkspaceInfo; mcpServers?: SessionOptions['mcpServers']; agent: SweCustomAgent | undefined; copilotUrl?: string; sessionId?: string; debugTargetSessionIds?: readonly string[] }, readonly?: boolean): Promise { 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 { + 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 { // 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) { diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts index 7dcbd535646..73b20f42822 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts @@ -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() { 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() { 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) diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index 3b2cbe9561b..03a868177a4 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -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). diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index 9bde71107aa..6a320f537c0 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -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). diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts index 32df89132a1..e3ad6d01f83 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts @@ -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() { diff --git a/extensions/copilot/src/extension/prompt/common/chatVariablesCollection.ts b/extensions/copilot/src/extension/prompt/common/chatVariablesCollection.ts index 1a531c8a980..ab0b1fd06f3 100644 --- a/extensions/copilot/src/extension/prompt/common/chatVariablesCollection.ts +++ b/extensions/copilot/src/extension/prompt/common/chatVariablesCollection.ts @@ -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 `` tag. + * Callers can pass the result to ``. + */ +export function sessionReferenceAttachmentAttrs(variable: PromptVariable & { value: vscode.Uri }): Record { + const attrs: Record = {}; + 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; +} diff --git a/extensions/copilot/src/extension/prompt/node/chatParticipantTelemetry.ts b/extensions/copilot/src/extension/prompt/node/chatParticipantTelemetry.ts index f2a51a3a135..a824dbe6de6 100644 --- a/extensions/copilot/src/extension/prompt/node/chatParticipantTelemetry.ts +++ b/extensions/copilot/src/extension/prompt/node/chatParticipantTelemetry.ts @@ -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 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)); diff --git a/extensions/copilot/src/extension/prompt/node/promptVariablesService.ts b/extensions/copilot/src/extension/prompt/node/promptVariablesService.ts index 1c507d0774a..56702e7b6b1 100644 --- a/extensions/copilot/src/extension/prompt/node/promptVariablesService.ts +++ b/extensions/copilot/src/extension/prompt/node/promptVariablesService.ts @@ -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; + + /** + * 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 { return message; } + + buildTemplateVariablesContext(): string { + return ''; + } } diff --git a/extensions/copilot/src/extension/prompt/vscode-node/promptVariablesService.ts b/extensions/copilot/src/extension/prompt/vscode-node/promptVariablesService.ts index 1c9fbb7c6a4..796a28faa6f 100644 --- a/extensions/copilot/src/extension/prompt/vscode-node/promptVariablesService.ts +++ b/extensions/copilot/src/extension/prompt/vscode-node/promptVariablesService.ts @@ -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; + + constructor( + @IChatDebugFileLoggerService private readonly chatDebugFileLoggerService: IChatDebugFileLoggerService, + @IPromptPathRepresentationService private readonly promptPathRepresentationService: IPromptPathRepresentationService, + @IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext, + ) { + this._resolvers = new Map([ + ['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(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]); diff --git a/extensions/copilot/src/extension/prompt/vscode-node/test/promptVariablesService.spec.ts b/extensions/copilot/src/extension/prompt/vscode-node/test/promptVariablesService.spec.ts index 48aa15c0781..16ed10ffd94 100644 --- a/extensions/copilot/src/extension/prompt/vscode-node/test/promptVariablesService.spec.ts +++ b/extensions/copilot/src/extension/prompt/vscode-node/test/promptVariablesService.spec.ts @@ -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(); + + 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'); + }); + }); }); diff --git a/extensions/copilot/src/extension/prompts/node/agent/agentPrompt.tsx b/extensions/copilot/src/extension/prompts/node/agent/agentPrompt.tsx index fe4b2e99515..4a4947a31d4 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/agentPrompt.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/agentPrompt.tsx @@ -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 { ; 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 { When you have fully completed the task, call the task_complete tool to signal that you are done.
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. } + {templateVariablesContext.length > 0 && {templateVariablesContext}} {await this.getOrCreateGlobalAgentContext(this.props.endpoint)} diff --git a/extensions/copilot/src/extension/prompts/node/agent/copilotCLIPrompt.tsx b/extensions/copilot/src/extension/prompts/node/agent/copilotCLIPrompt.tsx index cef8d12e0d3..9c214403951 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/copilotCLIPrompt.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/copilotCLIPrompt.tsx @@ -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 { const elements: PromptElement[] = []; await Promise.all(Array.from(chatVariables).map(async variable => { + if (isSessionReference(variable)) { + elements.push(); + return; + } if (variable.value instanceof ChatReferenceBinaryData) { if (variable.value.reference) { const attrs: Record = {}; diff --git a/extensions/copilot/src/extension/prompts/node/panel/chatVariables.tsx b/extensions/copilot/src/extension/prompts/node/panel/chatVariables.tsx index a6c0af2d6fb..b0385fbb500 100644 --- a/extensions/copilot/src/extension/prompts/node/panel/chatVariables.tsx +++ b/extensions/copilot/src/extension/prompts/node/panel/chatVariables.tsx @@ -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(); + continue; + } + if (URI.isUri(variableValue) || isLocation(variableValue)) { const uri = 'uri' in variableValue ? variableValue.uri : variableValue; diff --git a/extensions/copilot/src/extension/prompts/node/panel/promptFile.tsx b/extensions/copilot/src/extension/prompts/node/panel/promptFile.tsx index dccc4fa8e5e..947a7ec48a7 100644 --- a/extensions/copilot/src/extension/prompts/node/panel/promptFile.tsx +++ b/extensions/copilot/src/extension/prompts/node/panel/promptFile.tsx @@ -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 { @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 { 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) { diff --git a/extensions/copilot/src/extension/tools/node/readFileTool.tsx b/extensions/copilot/src/extension/tools/node/readFileTool.tsx index 388b63bea47..fc71ce7d45c 100644 --- a/extensions/copilot/src/extension/tools/node/readFileTool.tsx +++ b/extensions/copilot/src/extension/tools/node/readFileTool.tsx @@ -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 { @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, token: vscode.CancellationToken) { @@ -329,25 +326,7 @@ export class ReadFileTool implements ICopilotTool { 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, 'model' | 'chatRequestId' | 'input'>, { start, end, truncated }: IParamRanges, uri: URI | undefined) { diff --git a/extensions/copilot/src/extension/tools/node/test/readFile.spec.tsx b/extensions/copilot/src/extension/tools/node/test/readFile.spec.tsx index af486363116..3a5fd107e9e 100644 --- a/extensions/copilot/src/extension/tools/node/test/readFile.spec.tsx +++ b/extensions/copilot/src/extension/tools/node/test/readFile.spec.tsx @@ -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(); - }); - }); }); diff --git a/extensions/copilot/src/extension/tools/node/toolUtils.ts b/extensions/copilot/src/extension/tools/node/toolUtils.ts index a4d50c73a42..3ac118b4ed4 100644 --- a/extensions/copilot/src/extension/tools/node/toolUtils.ts +++ b/extensions/copilot/src/extension/tools/node/toolUtils.ts @@ -206,7 +206,10 @@ export async function assertFileOkForTool(accessor: ServicesAccessor, uri: URI, } async function isExternalInstructionsFile(normalizedUri: URI, customInstructionsService: ICustomInstructionsService, buildPromptContext?: IBuildPromptContext): Promise { - 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) { diff --git a/extensions/copilot/src/platform/chat/common/chatDebugFileLoggerService.ts b/extensions/copilot/src/platform/chat/common/chatDebugFileLoggerService.ts index 4c8faa6e873..c9da2e1d418 100644 --- a/extensions/copilot/src/platform/chat/common/chatDebugFileLoggerService.ts +++ b/extensions/copilot/src/platform/chat/common/chatDebugFileLoggerService.ts @@ -109,8 +109,8 @@ export class NullChatDebugFileLoggerService implements IChatDebugFileLoggerServi async startSession(): Promise { } async endSession(): Promise { } async flush(): Promise { } - 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; } diff --git a/extensions/copilot/src/platform/customInstructions/common/customInstructionsService.ts b/extensions/copilot/src/platform/customInstructions/common/customInstructionsService.ts index d8626d78d62..56efae39580 100644 --- a/extensions/copilot/src/platform/customInstructions/common/customInstructionsService.ts +++ b/extensions/copilot/src/platform/customInstructions/common/customInstructionsService.ts @@ -52,6 +52,7 @@ export const ICustomInstructionsService = createServiceIdentifier; /** 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); } diff --git a/extensions/copilot/src/platform/requestLogger/common/capturingToken.ts b/extensions/copilot/src/platform/requestLogger/common/capturingToken.ts index 2fc8f1aa8c2..67e5fd65a95 100644 --- a/extensions/copilot/src/platform/requestLogger/common/capturingToken.ts +++ b/extensions/copilot/src/platform/requestLogger/common/capturingToken.ts @@ -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[], ) { } } diff --git a/extensions/copilot/src/platform/test/common/testCustomInstructionsService.ts b/extensions/copilot/src/platform/test/common/testCustomInstructionsService.ts index c9effe9280a..607acddc2a7 100644 --- a/extensions/copilot/src/platform/test/common/testCustomInstructionsService.ts +++ b/extensions/copilot/src/platform/test/common/testCustomInstructionsService.ts @@ -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; + } } diff --git a/extensions/copilot/test/e2e/cli.stest.ts b/extensions/copilot/test/e2e/cli.stest.ts index 72e898e4276..93bfbb6e782 100644 --- a/extensions/copilot/test/e2e/cli.stest.ts +++ b/extensions/copilot/test/e2e/cli.stest.ts @@ -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 { + protected override async createSessionsOptions(options: { model?: string; workingDirectory?: Uri; workspaceInfo: IWorkspaceInfo; mcpServers?: SessionOptions['mcpServers']; sessionId?: string; debugTargetSessionIds?: readonly string[] }): Promise { const testOptionsProvider = this.instantiationService.invokeFunction((accessor) => accessor.get(ITestSessionOptionsProvider)); const overrideOptions = await testOptionsProvider.getOptions(); const sessionOptions = new TestCopilotCLISessionOptions(options, this.logService, overrideOptions);