mirror of
https://github.com/microsoft/vscode.git
synced 2026-05-31 00:10:04 +08:00
Support dynamic prompt variables (#4742)
* wip * fixes * update * update * updates * updates * clean * clean * clean * fix tests * update * fix test
This commit is contained in:
@@ -30,7 +30,7 @@ Consult the reference docs for templates, domain examples, advanced frontmatter
|
||||
| Custom Agents | `*.agent.md` | `.github/agents/` | [Link](./references/agents.md) |
|
||||
| Skills | `SKILL.md` | `.github/skills/<name>/`, `.agents/skills/<name>/`, `.claude/skills/<name>/` | [Link](./references/skills.md) |
|
||||
|
||||
**User-level**: `{{USER_PROMPTS_FOLDER}}/` (*.prompt.md, *.instructions.md, *.agent.md; not skills)
|
||||
**User-level**: `{{VSCODE_USER_PROMPTS_FOLDER}}/` (*.prompt.md, *.instructions.md, *.agent.md; not skills)
|
||||
Customizations roam with user's settings sync
|
||||
|
||||
## Creation Process
|
||||
@@ -43,7 +43,7 @@ Follow these steps when creating any customization file.
|
||||
|
||||
Ask the user where they want the customization:
|
||||
- **Workspace**: For project-specific, team-shared customizations → `.github/` folder
|
||||
- **User profile**: For personal, cross-workspace customizations → `{{USER_PROMPTS_FOLDER}}/`
|
||||
- **User profile**: For personal, cross-workspace customizations → `{{VSCODE_USER_PROMPTS_FOLDER}}/`
|
||||
|
||||
### 2. Choose the Right Primitive
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';
|
||||
import { ILogService } from '../../../platform/log/common/logService';
|
||||
import { BaseSkillProvider } from './baseSkillProvider';
|
||||
|
||||
const USER_PROMPTS_FOLDER_PLACEHOLDER = '{{USER_PROMPTS_FOLDER}}';
|
||||
|
||||
/**
|
||||
* Provides the built-in agent-customization skill that teaches agents
|
||||
* how to work with VS Code's customization system (instructions, prompts, agents, skills).
|
||||
*/
|
||||
export class AgentCustomizationSkillProvider extends BaseSkillProvider {
|
||||
|
||||
private cachedContent: Uint8Array | undefined;
|
||||
|
||||
constructor(
|
||||
@ILogService logService: ILogService,
|
||||
@IVSCodeExtensionContext extensionContext: IVSCodeExtensionContext,
|
||||
) {
|
||||
super(logService, extensionContext, 'agent-customization');
|
||||
}
|
||||
|
||||
private getUserPromptsFolder(): string {
|
||||
const globalStorageUri = this.extensionContext.globalStorageUri;
|
||||
const userFolderUri = vscode.Uri.joinPath(globalStorageUri, '..', '..');
|
||||
const userPromptsFolderUri = vscode.Uri.joinPath(userFolderUri, 'prompts');
|
||||
|
||||
return userPromptsFolderUri.fsPath;
|
||||
}
|
||||
|
||||
protected override processTemplate(templateContent: string): string {
|
||||
const userPromptsFolder = this.getUserPromptsFolder();
|
||||
this.logService.trace(`[AgentCustomizationSkillProvider] Injected user prompts folder: ${userPromptsFolder}`);
|
||||
return templateContent.replace(USER_PROMPTS_FOLDER_PLACEHOLDER, userPromptsFolder);
|
||||
}
|
||||
|
||||
protected override async getSkillContentBytes(): Promise<Uint8Array> {
|
||||
if (this.cachedContent) {
|
||||
return this.cachedContent;
|
||||
}
|
||||
|
||||
this.cachedContent = await super.getSkillContentBytes();
|
||||
return this.cachedContent;
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { SKILL_FILENAME } from '../../../platform/customInstructions/common/promptTypes';
|
||||
import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';
|
||||
import { ILogService } from '../../../platform/log/common/logService';
|
||||
import { Disposable } from '../../../util/vs/base/common/lifecycle';
|
||||
import { registerDynamicSkillFolder } from './skillFsProviderHelper';
|
||||
|
||||
/**
|
||||
* Base class for skill providers that serve a template-based SKILL.md with placeholder replacements.
|
||||
*
|
||||
* Handles constructor registration with the dynamic skill folder filesystem,
|
||||
* template loading from `assets/prompts/skills/<folderName>/SKILL.md`,
|
||||
* encoding, error handling, and the `provideSkills` contract.
|
||||
*
|
||||
* Subclasses implement {@link processTemplate} to perform their own placeholder replacements.
|
||||
*/
|
||||
export abstract class BaseSkillProvider extends Disposable implements vscode.ChatSkillProvider {
|
||||
|
||||
protected readonly skillContentUri: vscode.Uri;
|
||||
private readonly _skillFolderName: string;
|
||||
|
||||
constructor(
|
||||
protected readonly logService: ILogService,
|
||||
protected readonly extensionContext: IVSCodeExtensionContext,
|
||||
skillFolderName: string,
|
||||
) {
|
||||
super();
|
||||
this._skillFolderName = skillFolderName;
|
||||
|
||||
const registration = registerDynamicSkillFolder(
|
||||
this.extensionContext,
|
||||
skillFolderName,
|
||||
() => this.getSkillContentBytes(),
|
||||
);
|
||||
this.skillContentUri = registration.skillUri;
|
||||
this._register(registration.disposable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the raw template string with placeholder replacements.
|
||||
* Called each time the skill content is requested (unless the subclass caches).
|
||||
*/
|
||||
protected abstract processTemplate(templateContent: string): string | Promise<string>;
|
||||
|
||||
protected async getSkillContentBytes(): Promise<Uint8Array> {
|
||||
try {
|
||||
const skillTemplateUri = vscode.Uri.joinPath(
|
||||
this.extensionContext.extensionUri,
|
||||
'assets',
|
||||
'prompts',
|
||||
'skills',
|
||||
this._skillFolderName,
|
||||
SKILL_FILENAME,
|
||||
);
|
||||
|
||||
const templateBytes = await vscode.workspace.fs.readFile(skillTemplateUri);
|
||||
const templateContent = new TextDecoder().decode(templateBytes);
|
||||
const processedContent = await this.processTemplate(templateContent);
|
||||
return new TextEncoder().encode(processedContent);
|
||||
} catch (error) {
|
||||
this.logService.error(`[${this.constructor.name}] Error reading skill template: ${error}`);
|
||||
return new Uint8Array();
|
||||
}
|
||||
}
|
||||
|
||||
async provideSkills(_context: unknown, token: vscode.CancellationToken): Promise<vscode.ChatResource[]> {
|
||||
if (token.isCancellationRequested) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{ uri: this.skillContentUri }];
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';
|
||||
import { Emitter } from '../../../util/vs/base/common/event';
|
||||
|
||||
const SKILL_FILENAME = 'SKILL.md';
|
||||
const SKILL_SCHEME = 'copilot-skill';
|
||||
|
||||
interface IDynamicSkillFolder {
|
||||
readonly folderName: string;
|
||||
readonly provideSkillContentBytes: () => Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
class SkillFsProvider implements vscode.FileSystemProvider {
|
||||
|
||||
private readonly dynamicSkills = new Map<string, IDynamicSkillFolder>();
|
||||
|
||||
private readonly _onDidChangeFile = new Emitter<vscode.FileChangeEvent[]>();
|
||||
readonly onDidChangeFile = this._onDidChangeFile.event;
|
||||
|
||||
constructor(
|
||||
private readonly extensionContext: IVSCodeExtensionContext,
|
||||
) {
|
||||
}
|
||||
|
||||
public registerDynamicSkill(dynamicSkill: IDynamicSkillFolder): vscode.Disposable {
|
||||
this.dynamicSkills.set(dynamicSkill.folderName, dynamicSkill);
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
this.dynamicSkills.delete(dynamicSkill.folderName);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
watch(_uri: vscode.Uri, _options: { readonly recursive: boolean; readonly excludes: readonly string[] }): vscode.Disposable {
|
||||
return { dispose: () => { } };
|
||||
}
|
||||
|
||||
async stat(uri: vscode.Uri): Promise<vscode.FileStat> {
|
||||
const parsed = this.parseSkillUri(uri);
|
||||
if (!parsed) {
|
||||
throw vscode.FileSystemError.FileNotFound(uri);
|
||||
}
|
||||
|
||||
if (parsed.kind === 'root') {
|
||||
return {
|
||||
type: vscode.FileType.Directory,
|
||||
ctime: 0,
|
||||
mtime: 0,
|
||||
size: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (parsed.kind === 'folder') {
|
||||
if (!this.dynamicSkills.has(parsed.folderName)) {
|
||||
throw vscode.FileSystemError.FileNotFound(uri);
|
||||
}
|
||||
|
||||
return {
|
||||
type: vscode.FileType.Directory,
|
||||
ctime: 0,
|
||||
mtime: 0,
|
||||
size: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const dynamicSkill = this.dynamicSkills.get(parsed.folderName);
|
||||
if (!dynamicSkill) {
|
||||
throw vscode.FileSystemError.FileNotFound(uri);
|
||||
}
|
||||
|
||||
if (parsed.relativePath === SKILL_FILENAME) {
|
||||
const content = await dynamicSkill.provideSkillContentBytes();
|
||||
return {
|
||||
type: vscode.FileType.File,
|
||||
ctime: 0,
|
||||
mtime: Date.now(),
|
||||
size: content.length,
|
||||
};
|
||||
}
|
||||
|
||||
const assetUri = this.toAssetUri(parsed.folderName, parsed.relativePath);
|
||||
return vscode.workspace.fs.stat(assetUri);
|
||||
}
|
||||
|
||||
async readDirectory(uri: vscode.Uri): Promise<[string, vscode.FileType][]> {
|
||||
const parsed = this.parseSkillUri(uri);
|
||||
if (!parsed) {
|
||||
throw vscode.FileSystemError.FileNotFound(uri);
|
||||
}
|
||||
|
||||
if (parsed.kind === 'root') {
|
||||
return [...this.dynamicSkills.keys()].map(folderName => [folderName, vscode.FileType.Directory]);
|
||||
}
|
||||
|
||||
if (parsed.kind === 'folder') {
|
||||
if (!this.dynamicSkills.has(parsed.folderName)) {
|
||||
throw vscode.FileSystemError.FileNotFound(uri);
|
||||
}
|
||||
|
||||
const assetFolderUri = this.toAssetUri(parsed.folderName, '');
|
||||
try {
|
||||
return await vscode.workspace.fs.readDirectory(assetFolderUri);
|
||||
} catch {
|
||||
return [[SKILL_FILENAME, vscode.FileType.File]];
|
||||
}
|
||||
}
|
||||
|
||||
const dynamicSkill = this.dynamicSkills.get(parsed.folderName);
|
||||
if (!dynamicSkill) {
|
||||
throw vscode.FileSystemError.FileNotFound(uri);
|
||||
}
|
||||
|
||||
const assetFolderUri = this.toAssetUri(parsed.folderName, parsed.relativePath);
|
||||
return vscode.workspace.fs.readDirectory(assetFolderUri);
|
||||
}
|
||||
|
||||
createDirectory(_uri: vscode.Uri): void {
|
||||
throw vscode.FileSystemError.NoPermissions('Readonly file system');
|
||||
}
|
||||
|
||||
async readFile(uri: vscode.Uri): Promise<Uint8Array> {
|
||||
const parsed = this.parseSkillUri(uri);
|
||||
if (!parsed || parsed.kind !== 'file') {
|
||||
throw vscode.FileSystemError.FileNotFound(uri);
|
||||
}
|
||||
|
||||
const dynamicSkill = this.dynamicSkills.get(parsed.folderName);
|
||||
if (!dynamicSkill) {
|
||||
throw vscode.FileSystemError.FileNotFound(uri);
|
||||
}
|
||||
|
||||
if (parsed.relativePath === SKILL_FILENAME) {
|
||||
return dynamicSkill.provideSkillContentBytes();
|
||||
}
|
||||
|
||||
const assetUri = this.toAssetUri(parsed.folderName, parsed.relativePath);
|
||||
return vscode.workspace.fs.readFile(assetUri);
|
||||
}
|
||||
|
||||
writeFile(_uri: vscode.Uri, _content: Uint8Array, _options: { readonly create: boolean; readonly overwrite: boolean }): void {
|
||||
throw vscode.FileSystemError.NoPermissions('Readonly file system');
|
||||
}
|
||||
|
||||
delete(_uri: vscode.Uri, _options: { readonly recursive: boolean }): void {
|
||||
throw vscode.FileSystemError.NoPermissions('Readonly file system');
|
||||
}
|
||||
|
||||
rename(_oldUri: vscode.Uri, _newUri: vscode.Uri, _options: { readonly overwrite: boolean }): void {
|
||||
throw vscode.FileSystemError.NoPermissions('Readonly file system');
|
||||
}
|
||||
|
||||
private toAssetUri(folderName: string, relativePath: string): vscode.Uri {
|
||||
const segments = relativePath.split('/').filter(Boolean);
|
||||
return vscode.Uri.joinPath(
|
||||
this.extensionContext.extensionUri,
|
||||
'assets',
|
||||
'prompts',
|
||||
'skills',
|
||||
folderName,
|
||||
...segments,
|
||||
);
|
||||
}
|
||||
|
||||
private parseSkillUri(uri: vscode.Uri):
|
||||
| { kind: 'root' }
|
||||
| { kind: 'folder'; folderName: string }
|
||||
| { kind: 'file'; folderName: string; relativePath: string }
|
||||
| undefined {
|
||||
if (uri.scheme !== SKILL_SCHEME) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const segments = uri.path.split('/').filter(Boolean);
|
||||
if (segments.length === 0) {
|
||||
return { kind: 'root' };
|
||||
}
|
||||
|
||||
if (segments.length === 1) {
|
||||
return { kind: 'folder', folderName: segments[0] };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'file',
|
||||
folderName: segments[0],
|
||||
relativePath: segments.slice(1).join('/'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let sharedFsProvider:
|
||||
| {
|
||||
provider: SkillFsProvider;
|
||||
registration: vscode.Disposable;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
function getOrCreateSharedFsProvider(
|
||||
extensionContext: IVSCodeExtensionContext,
|
||||
): SkillFsProvider {
|
||||
if (!sharedFsProvider) {
|
||||
const provider = new SkillFsProvider(extensionContext);
|
||||
const registration = vscode.workspace.registerFileSystemProvider(SKILL_SCHEME, provider, { isReadonly: true });
|
||||
sharedFsProvider = { provider, registration };
|
||||
}
|
||||
|
||||
return sharedFsProvider.provider;
|
||||
}
|
||||
|
||||
export function registerDynamicSkillFolder(
|
||||
extensionContext: IVSCodeExtensionContext,
|
||||
folderName: string,
|
||||
provideSkillContentBytes: () => Promise<Uint8Array>,
|
||||
): { readonly skillUri: vscode.Uri; readonly disposable: vscode.Disposable } {
|
||||
const provider = getOrCreateSharedFsProvider(extensionContext);
|
||||
const disposable = provider.registerDynamicSkill({
|
||||
folderName,
|
||||
provideSkillContentBytes,
|
||||
});
|
||||
|
||||
return {
|
||||
skillUri: vscode.Uri.from({ scheme: SKILL_SCHEME, path: `/${folderName}/${SKILL_FILENAME}` }),
|
||||
disposable,
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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[] {
|
||||
|
||||
@@ -47,7 +47,8 @@ export class CopilotCLISessionOptions {
|
||||
private readonly mcpServers?: SessionOptions['mcpServers'];
|
||||
private readonly copilotUrl?: string;
|
||||
private readonly skillLocations?: Uri[];
|
||||
constructor(options: { model?: string; workspaceInfo: IWorkspaceInfo; mcpServers?: SessionOptions['mcpServers']; agent?: SweCustomAgent; customAgents?: SweCustomAgent[]; copilotUrl?: string; skillLocations?: Uri[] }, private readonly logService: ILogService) {
|
||||
private readonly systemMessage?: SessionOptions['systemMessage'];
|
||||
constructor(options: { model?: string; workspaceInfo: IWorkspaceInfo; mcpServers?: SessionOptions['mcpServers']; agent?: SweCustomAgent; customAgents?: SweCustomAgent[]; copilotUrl?: string; skillLocations?: Uri[]; systemMessage?: SessionOptions['systemMessage'] }, private readonly logService: ILogService) {
|
||||
this.workspaceInfo = options.workspaceInfo;
|
||||
this.model = options.model;
|
||||
this.mcpServers = options.mcpServers;
|
||||
@@ -55,6 +56,7 @@ export class CopilotCLISessionOptions {
|
||||
this.customAgents = options.customAgents;
|
||||
this.copilotUrl = options.copilotUrl;
|
||||
this.skillLocations = options.skillLocations;
|
||||
this.systemMessage = options.systemMessage;
|
||||
}
|
||||
|
||||
public get agentName(): string | undefined {
|
||||
@@ -98,6 +100,9 @@ export class CopilotCLISessionOptions {
|
||||
if (this.copilotUrl) {
|
||||
allOptions.copilotUrl = this.copilotUrl;
|
||||
}
|
||||
if (this.systemMessage) {
|
||||
allOptions.systemMessage = this.systemMessage;
|
||||
}
|
||||
allOptions.sessionCapabilities = new Set(['plan-mode', 'memory', 'cli-documentation', 'ask-user', 'interactive-mode', 'system-notifications']);
|
||||
return allOptions as Readonly<SessionOptions>;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as fs from 'node:fs/promises';
|
||||
import { devNull, EOL } from 'node:os';
|
||||
import { createInterface } from 'node:readline';
|
||||
import type { ChatRequest, ChatSessionItem } from 'vscode';
|
||||
import { IChatDebugFileLoggerService } from '../../../../platform/chat/common/chatDebugFileLoggerService';
|
||||
import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';
|
||||
import { INativeEnvService } from '../../../../platform/env/common/envService';
|
||||
import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext';
|
||||
@@ -32,6 +33,7 @@ import { URI } from '../../../../util/vs/base/common/uri';
|
||||
import { generateUuid } from '../../../../util/vs/base/common/uuid';
|
||||
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { ChatRequestTurn2, ChatResponseTurn2, ChatSessionStatus, Uri } from '../../../../vscodeTypes';
|
||||
import { IPromptVariablesService } from '../../../prompt/node/promptVariablesService';
|
||||
import { IAgentSessionsWorkspace } from '../../common/agentSessionsWorkspace';
|
||||
import { IChatSessionMetadataStore } from '../../common/chatSessionMetadataStore';
|
||||
import { IChatSessionWorkspaceFolderService } from '../../common/chatSessionWorkspaceFolderService';
|
||||
@@ -47,7 +49,6 @@ import { CopilotCliBridgeSpanProcessor } from './copilotCliBridgeSpanProcessor';
|
||||
import { CopilotCLISession, ICopilotCLISession } from './copilotcliSession';
|
||||
import { ICopilotCLISkills } from './copilotCLISkills';
|
||||
import { ICopilotCLIMCPHandler, McpServerMappings } from './mcpHandler';
|
||||
import { IChatDebugFileLoggerService } from '../../../../platform/chat/common/chatDebugFileLoggerService';
|
||||
|
||||
const COPILOT_CLI_WORKSPACE_JSON_FILE_KEY = 'github.copilot.cli.workspaceSessionFile';
|
||||
|
||||
@@ -84,8 +85,8 @@ export interface ICopilotCLISessionService {
|
||||
renameSession(sessionId: string, title: string): Promise<void>;
|
||||
|
||||
// Session wrapper tracking
|
||||
getSession(options: { sessionId: string; model?: string; workspaceInfo: IWorkspaceInfo; readonly: boolean; agent?: SweCustomAgent; mcpServerMappings?: McpServerMappings }, token: CancellationToken): Promise<IReference<ICopilotCLISession> | undefined>;
|
||||
createSession(options: { model?: string; workspaceInfo: IWorkspaceInfo; agent?: SweCustomAgent; sessionId?: string; mcpServerMappings?: McpServerMappings }, token: CancellationToken): Promise<IReference<ICopilotCLISession>>;
|
||||
getSession(options: { sessionId: string; model?: string; workspaceInfo: IWorkspaceInfo; readonly: boolean; agent?: SweCustomAgent; debugTargetSessionIds?: readonly string[]; mcpServerMappings?: McpServerMappings }, token: CancellationToken): Promise<IReference<ICopilotCLISession> | undefined>;
|
||||
createSession(options: { model?: string; workspaceInfo: IWorkspaceInfo; agent?: SweCustomAgent; sessionId?: string; debugTargetSessionIds?: readonly string[]; mcpServerMappings?: McpServerMappings }, token: CancellationToken): Promise<IReference<ICopilotCLISession>>;
|
||||
forkSession(sessionId: string, requestId: string | undefined, options: { workspaceInfo: IWorkspaceInfo }, token: CancellationToken): Promise<string>;
|
||||
tryGetPartialSesionHistory(sessionId: string): Promise<readonly (ChatRequestTurn2 | ChatResponseTurn2)[] | undefined>;
|
||||
}
|
||||
@@ -146,6 +147,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
|
||||
@IChatSessionWorkspaceFolderService private readonly workspaceFolderService: IChatSessionWorkspaceFolderService,
|
||||
@IChatSessionWorktreeService private readonly worktreeManager: IChatSessionWorktreeService,
|
||||
@IOTelService private readonly _otelService: IOTelService,
|
||||
@IPromptVariablesService private readonly _promptVariablesService: IPromptVariablesService,
|
||||
@IChatDebugFileLoggerService private readonly _debugFileLogger: IChatDebugFileLoggerService,
|
||||
) {
|
||||
super();
|
||||
@@ -511,11 +513,11 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
|
||||
}
|
||||
}
|
||||
|
||||
public async createSession({ model, workspaceInfo, agent, sessionId, mcpServerMappings }: { model?: string; workspaceInfo: IWorkspaceInfo; agent?: SweCustomAgent; sessionId?: string; mcpServerMappings?: McpServerMappings }, token: CancellationToken): Promise<RefCountedSession> {
|
||||
public async createSession({ model, workspaceInfo, agent, sessionId, debugTargetSessionIds, mcpServerMappings }: { model?: string; workspaceInfo: IWorkspaceInfo; agent?: SweCustomAgent; sessionId?: string; debugTargetSessionIds?: readonly string[]; mcpServerMappings?: McpServerMappings }, token: CancellationToken): Promise<RefCountedSession> {
|
||||
const { mcpConfig: mcpServers, disposable: mcpGateway } = await this.mcpHandler.loadMcpConfig();
|
||||
try {
|
||||
const copilotUrl = this.configurationService.getConfig(ConfigKey.Shared.DebugOverrideProxyUrl) || undefined;
|
||||
const options = await this.createSessionsOptions({ model, workspaceInfo, mcpServers, agent, copilotUrl });
|
||||
const options = await this.createSessionsOptions({ model, workspaceInfo, mcpServers, agent, copilotUrl, sessionId, debugTargetSessionIds });
|
||||
const sessionManager = await raceCancellationError(this.getSessionManager(), token);
|
||||
const sdkSession = await sessionManager.createSession({ ...options.toSessionOptions(mcpServerMappings), sessionId });
|
||||
this._newSessionIds.delete(sdkSession.sessionId);
|
||||
@@ -629,15 +631,17 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
|
||||
return false;
|
||||
}
|
||||
|
||||
protected async createSessionsOptions(options: { model?: string; workspaceInfo: IWorkspaceInfo; mcpServers?: SessionOptions['mcpServers']; agent: SweCustomAgent | undefined; copilotUrl?: string }, readonly?: boolean): Promise<CopilotCLISessionOptions> {
|
||||
protected async createSessionsOptions(options: { model?: string; workspaceInfo: IWorkspaceInfo; mcpServers?: SessionOptions['mcpServers']; agent: SweCustomAgent | undefined; copilotUrl?: string; sessionId?: string; debugTargetSessionIds?: readonly string[] }, readonly?: boolean): Promise<CopilotCLISessionOptions> {
|
||||
const [customAgents, skillLocations] = await Promise.all([
|
||||
this.agents.getAgents(),
|
||||
readonly ? Promise.resolve([]) : this.copilotCLISkills.getSkillsLocations(),
|
||||
]);
|
||||
return new CopilotCLISessionOptions({ ...options, customAgents, skillLocations }, this.logService);
|
||||
const variablesContext = this._promptVariablesService.buildTemplateVariablesContext(options.sessionId, options.debugTargetSessionIds);
|
||||
const systemMessage = variablesContext ? { mode: 'append' as const, content: variablesContext } : undefined;
|
||||
return new CopilotCLISessionOptions({ ...options, customAgents, skillLocations, systemMessage }, this.logService);
|
||||
}
|
||||
|
||||
public async getSession({ sessionId, model, workspaceInfo, readonly, agent, mcpServerMappings }: { sessionId: string; model?: string; workspaceInfo: IWorkspaceInfo; readonly: boolean; agent?: SweCustomAgent; mcpServerMappings?: McpServerMappings }, token: CancellationToken): Promise<RefCountedSession | undefined> {
|
||||
public async getSession({ sessionId, model, workspaceInfo, readonly, agent, debugTargetSessionIds, mcpServerMappings }: { sessionId: string; model?: string; workspaceInfo: IWorkspaceInfo; readonly: boolean; agent?: SweCustomAgent; debugTargetSessionIds?: readonly string[]; mcpServerMappings?: McpServerMappings }, token: CancellationToken): Promise<RefCountedSession | undefined> {
|
||||
// https://github.com/microsoft/vscode/issues/276573
|
||||
const lock = this.sessionMutexForGetSession.get(sessionId) ?? new Mutex();
|
||||
this.sessionMutexForGetSession.set(sessionId, lock);
|
||||
@@ -666,7 +670,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
|
||||
]);
|
||||
try {
|
||||
const copilotUrl = this.configurationService.getConfig(ConfigKey.Shared.DebugOverrideProxyUrl) || undefined;
|
||||
const options = await this.createSessionsOptions({ model, agent, workspaceInfo, mcpServers, copilotUrl }, readonly);
|
||||
const options = await this.createSessionsOptions({ model, agent, workspaceInfo, mcpServers, copilotUrl, sessionId, debugTargetSessionIds }, readonly);
|
||||
|
||||
const sdkSession = await sessionManager.getSession({ ...options.toSessionOptions(mcpServerMappings), sessionId }, !readonly);
|
||||
if (!sdkSession) {
|
||||
@@ -711,7 +715,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
|
||||
]);
|
||||
|
||||
const copilotUrl = this.configurationService.getConfig(ConfigKey.Shared.DebugOverrideProxyUrl) || undefined;
|
||||
const options = await this.createSessionsOptions({ workspaceInfo, mcpServers: undefined, copilotUrl, agent: undefined }, false);
|
||||
const options = await this.createSessionsOptions({ workspaceInfo, mcpServers: undefined, copilotUrl, agent: undefined, sessionId: newSessionId }, false);
|
||||
|
||||
const sdkSession = await sessionManager.getSession({ ...options.toSessionOptions(), sessionId: newSessionId }, false);
|
||||
if (!sdkSession) {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { mock } from '../../../../../util/common/test/simpleMock';
|
||||
import { DisposableStore, IReference, toDisposable } from '../../../../../util/vs/base/common/lifecycle';
|
||||
import { URI } from '../../../../../util/vs/base/common/uri';
|
||||
import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { NullPromptVariablesService } from '../../../../prompt/node/promptVariablesService';
|
||||
import { createExtensionUnitTestingServices } from '../../../../test/node/services';
|
||||
import { IAgentSessionsWorkspace } from '../../../common/agentSessionsWorkspace';
|
||||
import { IChatPromptFileService } from '../../../common/chatPromptFileService';
|
||||
@@ -155,7 +156,7 @@ describe('CopilotCLISessionService', () => {
|
||||
const configurationService = accessor.get(IConfigurationService);
|
||||
const nullMcpServer = disposables.add(new NullMcpService());
|
||||
const titleService = new NullCustomSessionTitleService();
|
||||
service = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), cliAgents, workspaceService, titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullChatDebugFileLoggerService()));
|
||||
service = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), cliAgents, workspaceService, titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService()));
|
||||
manager = await service.getSessionManager() as unknown as MockCliSdkSessionManager;
|
||||
});
|
||||
|
||||
@@ -314,7 +315,7 @@ describe('CopilotCLISessionService', () => {
|
||||
return undefined;
|
||||
}
|
||||
}();
|
||||
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullChatDebugFileLoggerService()));
|
||||
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService()));
|
||||
|
||||
await mkdir(sessionDir.fsPath, { recursive: true });
|
||||
await writeNodeFile(join(sessionDir.fsPath, 'events.jsonl'), [
|
||||
@@ -349,7 +350,7 @@ describe('CopilotCLISessionService', () => {
|
||||
const delegationService = new class extends mock<IChatDelegationSummaryService>() {
|
||||
override extractPrompt(): { prompt: string; reference: never } | undefined { return undefined; }
|
||||
}();
|
||||
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullChatDebugFileLoggerService()));
|
||||
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService()));
|
||||
|
||||
await mkdir(sessionDir.fsPath, { recursive: true });
|
||||
const eventsFilePath = join(sessionDir.fsPath, 'events.jsonl');
|
||||
@@ -418,7 +419,7 @@ describe('CopilotCLISessionService', () => {
|
||||
return undefined;
|
||||
}
|
||||
}();
|
||||
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullChatDebugFileLoggerService()));
|
||||
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService()));
|
||||
const partialManager = await partialService.getSessionManager() as unknown as MockCliSdkSessionManager;
|
||||
|
||||
const session = new MockCliSdkSession(sessionId, new Date('2024-01-01T00:00:00.000Z'));
|
||||
@@ -460,7 +461,7 @@ describe('CopilotCLISessionService', () => {
|
||||
const delegationService = new class extends mock<IChatDelegationSummaryService>() {
|
||||
override extractPrompt(): { prompt: string; reference: never } | undefined { return undefined; }
|
||||
}();
|
||||
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullChatDebugFileLoggerService()));
|
||||
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService()));
|
||||
const partialManager = await partialService.getSessionManager() as unknown as MockCliSdkSessionManager;
|
||||
|
||||
// Session has a summary with '<' (which forces the session-load fallback path)
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -31,6 +31,7 @@ import { sep } from '../../../../util/vs/base/common/path';
|
||||
import { URI } from '../../../../util/vs/base/common/uri';
|
||||
import { IInstantiationService, ServicesAccessor } from '../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { LanguageModelTextPart, LanguageModelToolResult2 } from '../../../../vscodeTypes';
|
||||
import { NullPromptVariablesService } from '../../../prompt/node/promptVariablesService';
|
||||
import { ChatSummarizerProvider } from '../../../prompt/node/summarizer';
|
||||
import { createExtensionUnitTestingServices } from '../../../test/node/services';
|
||||
import { MockChatResponseStream, TestChatRequest } from '../../../test/node/testHelpers';
|
||||
@@ -374,7 +375,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
|
||||
}
|
||||
} as unknown as IInstantiationService;
|
||||
customSessionTitleService = new CustomSessionTitleService(new MockExtensionContext() as unknown as IVSCodeExtensionContext, accessor.get(IInstantiationService), logService, new MockChatSessionMetadataStore());
|
||||
sessionService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, mcpHandler, new NullCopilotCLIAgents(), workspaceService, customSessionTitleService, accessor.get(IConfigurationService), new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), { _serviceBrand: undefined, isAgentSessionsWorkspace: false } as IAgentSessionsWorkspace, workspaceFolderService, worktree, new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullChatDebugFileLoggerService()));
|
||||
sessionService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, mcpHandler, new NullCopilotCLIAgents(), workspaceService, customSessionTitleService, accessor.get(IConfigurationService), new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), { _serviceBrand: undefined, isAgentSessionsWorkspace: false } as IAgentSessionsWorkspace, workspaceFolderService, worktree, new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService()));
|
||||
|
||||
manager = await sessionService.getSessionManager() as unknown as MockCliSdkSessionManager;
|
||||
contentProvider = new class extends mock<CopilotCLIChatSessionContentProvider>() {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import type * as vscode from 'vscode';
|
||||
import { sessionResourceToId } from '../../../platform/chat/common/chatDebugFileLoggerService';
|
||||
import { URI } from '../../../util/vs/base/common/uri';
|
||||
|
||||
export interface PromptVariable {
|
||||
@@ -148,3 +149,25 @@ export function isSessionReferenceScheme(scheme: string): boolean {
|
||||
export function isSessionReference(variable: PromptVariable): variable is PromptVariable & { value: vscode.Uri } {
|
||||
return URI.isUri(variable.value) && isSessionReferenceScheme(variable.value.scheme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the attributes for rendering a session reference as an `<attachment>` tag.
|
||||
* Callers can pass the result to `<Tag name='attachment' attrs={...} />`.
|
||||
*/
|
||||
export function sessionReferenceAttachmentAttrs(variable: PromptVariable & { value: vscode.Uri }): Record<string, string> {
|
||||
const attrs: Record<string, string> = {};
|
||||
if (variable.uniqueName) {
|
||||
attrs.id = `${variable.uniqueName} (${sessionResourceToId(variable.value)})`;
|
||||
}
|
||||
attrs.filePath = variable.value.toString();
|
||||
return attrs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract debug-target session IDs from chat prompt references.
|
||||
* Returns `undefined` when no session references are present.
|
||||
*/
|
||||
export function extractDebugTargetSessionIds(references: readonly vscode.ChatPromptReference[]): readonly string[] | undefined {
|
||||
const sessionRefs = references.filter(ref => URI.isUri(ref.value) && isSessionReferenceScheme(ref.value.scheme));
|
||||
return sessionRefs.length > 0 ? sessionRefs.map(ref => sessionResourceToId(ref.value as URI)) : undefined;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type * as vscode from 'vscode';
|
||||
import { ChatFetchResponseType, ChatLocation } from '../../../platform/chat/common/commonTypes';
|
||||
import { getTextPart, roleToString } from '../../../platform/chat/common/globalStringUtils';
|
||||
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
|
||||
import { ICustomInstructionsService } from '../../../platform/customInstructions/common/customInstructionsService';
|
||||
import { isAutoModel } from '../../../platform/endpoint/node/autoChatEndpoint';
|
||||
import { ILanguageDiagnosticsService } from '../../../platform/languages/common/languageDiagnosticsService';
|
||||
import { IChatEndpoint } from '../../../platform/networking/common/networking';
|
||||
@@ -18,7 +19,7 @@ import { isNotebookCellOrNotebookChatInput } from '../../../util/common/notebook
|
||||
import { URI } from '../../../util/vs/base/common/uri';
|
||||
import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { isBYOKModel } from '../../byok/node/openAIEndpoint';
|
||||
import { Intent, agentsToCommands } from '../../common/constants';
|
||||
import { EXTENSION_ID, Intent, agentsToCommands } from '../../common/constants';
|
||||
import { DiagnosticsTelemetryData, findDiagnosticsTelemetry } from '../../inlineChat/node/diagnosticsTelemetry';
|
||||
import { InteractionOutcome } from '../../inlineChat/node/promptCraftingTypes';
|
||||
import { AgentIntent } from '../../intents/node/agentIntent';
|
||||
@@ -211,7 +212,7 @@ const builtinSlashCommands = new Set(
|
||||
Object.values(agentsToCommands).flatMap(commands => commands ? Object.keys(commands) : [])
|
||||
);
|
||||
|
||||
function getSlashCommandForTelemetry(request: vscode.ChatRequest): string {
|
||||
function getSlashCommandForTelemetry(request: vscode.ChatRequest, customInstructionsService: ICustomInstructionsService): string {
|
||||
const command = request.command;
|
||||
if (!command) {
|
||||
return '';
|
||||
@@ -222,9 +223,19 @@ function getSlashCommandForTelemetry(request: vscode.ChatRequest): string {
|
||||
return command;
|
||||
}
|
||||
|
||||
// Built-in skills (copilot-skill:// URIs) are safe to send as plain text
|
||||
// Built-in skills (extension-provided) are safe to send as plain text
|
||||
for (const ref of request.references) {
|
||||
if (URI.isUri(ref.value) && ref.value.scheme === 'copilot-skill') {
|
||||
if (!URI.isUri(ref.value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const extensionSkillInfo = customInstructionsService.getExtensionSkillInfo(ref.value);
|
||||
if (extensionSkillInfo?.extensionId === EXTENSION_ID && extensionSkillInfo.skillName === command) {
|
||||
return command;
|
||||
}
|
||||
|
||||
const extensionPromptFileInfo = customInstructionsService.getExtensionPromptFileInfo(ref.value);
|
||||
if (extensionPromptFileInfo?.extensionId === EXTENSION_ID && extensionPromptFileInfo.uri.path.toLowerCase().endsWith(`/${command.toLowerCase()}.prompt.md`)) {
|
||||
return command;
|
||||
}
|
||||
}
|
||||
@@ -597,6 +608,7 @@ export class PanelChatTelemetry extends ChatTelemetry<IDocumentContext | undefin
|
||||
repoInfoTelemetry: RepoInfoTelemetry,
|
||||
@ITelemetryService telemetryService: ITelemetryService,
|
||||
@IConfigurationService private readonly _configurationService: IConfigurationService,
|
||||
@ICustomInstructionsService private readonly _customInstructionsService: ICustomInstructionsService,
|
||||
) {
|
||||
super(ChatLocation.Panel,
|
||||
sessionId,
|
||||
@@ -734,7 +746,7 @@ export class PanelChatTelemetry extends ChatTelemetry<IDocumentContext | undefin
|
||||
mode: this._getModeNameForTelemetry(),
|
||||
parentRequestId: this._request.parentRequestId,
|
||||
vscodeRequestId: this._request.id,
|
||||
slashCommand: getSlashCommandForTelemetry(this._request)
|
||||
slashCommand: getSlashCommandForTelemetry(this._request, this._customInstructionsService)
|
||||
} satisfies RequestPanelTelemetryProperties, {
|
||||
turn: this._conversation.turns.length,
|
||||
round: roundIndex,
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Raw } from '@vscode/prompt-tsx';
|
||||
import type { ChatRequest, ChatResponseReferencePart, ChatResponseStream, ChatResult, LanguageModelToolInformation, Progress } from 'vscode';
|
||||
import { IAuthenticationService } from '../../../platform/authentication/common/authentication';
|
||||
import { IAuthenticationChatUpgradeService } from '../../../platform/authentication/common/authenticationUpgrade';
|
||||
import { sessionResourceToId } from '../../../platform/chat/common/chatDebugFileLoggerService';
|
||||
import { IChatHookService, UserPromptSubmitHookInput, UserPromptSubmitHookOutput } from '../../../platform/chat/common/chatHookService';
|
||||
import { CanceledResult, ChatFetchResponseType, ChatLocation, ChatResponse, getErrorDetailsFromChatFetchError } from '../../../platform/chat/common/commonTypes';
|
||||
import { IConversationOptions } from '../../../platform/chat/common/conversationOptions';
|
||||
@@ -38,7 +37,6 @@ import { Iterable } from '../../../util/vs/base/common/iterator';
|
||||
import { DisposableStore } from '../../../util/vs/base/common/lifecycle';
|
||||
import { mixin } from '../../../util/vs/base/common/objects';
|
||||
import { assertType, Mutable } from '../../../util/vs/base/common/types';
|
||||
import { URI } from '../../../util/vs/base/common/uri';
|
||||
import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { ChatResponseMarkdownPart, ChatResponseProgressPart, ChatResponseTextEditPart, LanguageModelToolResult2 } from '../../../vscodeTypes';
|
||||
import { CodeBlocksMetadata, CodeBlockTrackingChatResponseStream } from '../../codeBlocks/node/codeBlockProcessor';
|
||||
@@ -51,7 +49,7 @@ import { SummarizedConversationHistoryMetadata } from '../../prompts/node/agent/
|
||||
import { normalizeToolSchema } from '../../tools/common/toolSchemaNormalizer';
|
||||
import { ToolCallCancelledError } from '../../tools/common/toolsService';
|
||||
import { IToolGrouping, IToolGroupingService } from '../../tools/common/virtualTools/virtualToolTypes';
|
||||
import { ChatVariablesCollection, isSessionReferenceScheme } from '../common/chatVariablesCollection';
|
||||
import { ChatVariablesCollection } from '../common/chatVariablesCollection';
|
||||
import { AnthropicTokenUsageMetadata, Conversation, getUniqueReferences, GlobalContextMessageMetadata, IResultMetadata, RenderedUserMessageMetadata, RequestDebugInformation, ResponseStreamParticipant, Turn, TurnStatus } from '../common/conversation';
|
||||
import { IBuildPromptContext, IToolCallRound } from '../common/intents';
|
||||
import { isToolCallLimitCancellation, ISwitchToAutoOnRateLimitConfirmation } from '../common/specialRequestTypes';
|
||||
@@ -140,8 +138,6 @@ export class DefaultIntentRequestHandler {
|
||||
// This enables explicit linking between the parent's runSubagent tool call and the subagent trajectory.
|
||||
// For main requests, use the VS Code chat sessionId directly as the trajectory session ID.
|
||||
const isSubagent = !!this.request.subAgentInvocationId;
|
||||
const sessionRefs = this.request.references.filter(ref => URI.isUri(ref.value) && isSessionReferenceScheme(ref.value.scheme));
|
||||
const debugTargetSessionIds = sessionRefs.length > 0 ? sessionRefs.map(ref => sessionResourceToId(ref.value as URI)) : undefined;
|
||||
const capturingToken = new CapturingToken(
|
||||
this.request.prompt,
|
||||
'comment',
|
||||
@@ -152,7 +148,6 @@ export class DefaultIntentRequestHandler {
|
||||
// For subagents, link back to the parent session
|
||||
isSubagent ? this.request.sessionId : undefined,
|
||||
isSubagent ? `runSubagent-${this.request.subAgentName ?? 'default'}` : undefined,
|
||||
debugTargetSessionIds,
|
||||
);
|
||||
const resultDetails = await this._requestLogger.captureInvocation(capturingToken, () => this.runWithToolCalling(intentInvocation));
|
||||
|
||||
|
||||
@@ -13,6 +13,14 @@ export interface IPromptVariablesService {
|
||||
readonly _serviceBrand: undefined;
|
||||
resolveVariablesInPrompt(message: string, variables: readonly ChatPromptReference[]): Promise<{ message: string }>;
|
||||
resolveToolReferencesInPrompt(message: string, toolReferences: readonly ChatLanguageModelToolReference[]): Promise<string>;
|
||||
|
||||
/**
|
||||
* Builds a context string describing resolved template variables for
|
||||
* injection into system prompts. This allows skills that reference
|
||||
* `{{VARIABLE_NAME}}` placeholders to have their values available
|
||||
* via session context.
|
||||
*/
|
||||
buildTemplateVariablesContext(sessionId: string | undefined, debugTargetSessionIds?: readonly string[]): string;
|
||||
}
|
||||
|
||||
export class NullPromptVariablesService implements IPromptVariablesService {
|
||||
@@ -25,4 +33,8 @@ export class NullPromptVariablesService implements IPromptVariablesService {
|
||||
async resolveToolReferencesInPrompt(message: string, toolReferences: readonly ChatLanguageModelToolReference[]): Promise<string> {
|
||||
return message;
|
||||
}
|
||||
|
||||
buildTemplateVariablesContext(): string {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,58 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import type { ChatLanguageModelToolReference, ChatPromptReference } from 'vscode';
|
||||
import * as vscode from 'vscode';
|
||||
import { IChatDebugFileLoggerService } from '../../../platform/chat/common/chatDebugFileLoggerService';
|
||||
import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';
|
||||
import { IPromptPathRepresentationService } from '../../../platform/prompts/common/promptPathRepresentationService';
|
||||
import { getToolName } from '../../tools/common/toolNames';
|
||||
import { IPromptVariablesService } from '../node/promptVariablesService';
|
||||
|
||||
/**
|
||||
* Known template variables that can be resolved at runtime.
|
||||
* Each entry maps a placeholder name (without the `{{ }}` delimiters) to a
|
||||
* resolver that produces the replacement string, or `undefined` if the
|
||||
* variable cannot be resolved in the current context.
|
||||
*/
|
||||
type VariableResolver = (sessionId: string | undefined, debugTargetSessionIds: readonly string[] | undefined) => string | undefined;
|
||||
|
||||
export class PromptVariablesServiceImpl implements IPromptVariablesService {
|
||||
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private readonly _resolvers: ReadonlyMap<string, VariableResolver>;
|
||||
|
||||
constructor(
|
||||
@IChatDebugFileLoggerService private readonly chatDebugFileLoggerService: IChatDebugFileLoggerService,
|
||||
@IPromptPathRepresentationService private readonly promptPathRepresentationService: IPromptPathRepresentationService,
|
||||
@IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext,
|
||||
) {
|
||||
this._resolvers = new Map<string, VariableResolver>([
|
||||
['VSCODE_USER_PROMPTS_FOLDER', () => {
|
||||
const globalStorageUri = this.extensionContext.globalStorageUri;
|
||||
const userFolderUri = vscode.Uri.joinPath(globalStorageUri, '..', '..');
|
||||
const userPromptsFolderUri = vscode.Uri.joinPath(userFolderUri, 'prompts');
|
||||
return userPromptsFolderUri.fsPath;
|
||||
}],
|
||||
['VSCODE_TARGET_SESSION_LOG', (sessionId, debugTargetSessionIds) => {
|
||||
if (debugTargetSessionIds && debugTargetSessionIds.length > 0) {
|
||||
return debugTargetSessionIds.map(id => {
|
||||
const sessionDir = this.chatDebugFileLoggerService.getSessionDir(id);
|
||||
return sessionDir ? this.promptPathRepresentationService.getFilePath(sessionDir) : undefined;
|
||||
}).filter((path): path is string => path !== undefined).join(', ');
|
||||
}
|
||||
if (!sessionId) {
|
||||
return undefined;
|
||||
}
|
||||
const sessionDir = this.chatDebugFileLoggerService.getSessionDir(sessionId);
|
||||
if (!sessionDir) {
|
||||
return undefined;
|
||||
}
|
||||
return this.promptPathRepresentationService.getFilePath(sessionDir);
|
||||
}],
|
||||
]);
|
||||
}
|
||||
|
||||
async resolveVariablesInPrompt(message: string, variables: ChatPromptReference[]): Promise<{ message: string }> {
|
||||
for (const variable of this._reverseSortRefsWithRange(variables)) {
|
||||
message = message.slice(0, variable.range[0]) + `[#${variable.name}](#${variable.name}-context)` + message.slice(variable.range[1]);
|
||||
@@ -37,6 +82,25 @@ export class PromptVariablesServiceImpl implements IPromptVariablesService {
|
||||
return message;
|
||||
}
|
||||
|
||||
buildTemplateVariablesContext(sessionId: string | undefined, debugTargetSessionIds?: readonly string[]): string {
|
||||
const entries: [string, string][] = [];
|
||||
for (const [name, resolve] of this._resolvers) {
|
||||
const value = resolve(sessionId, debugTargetSessionIds);
|
||||
if (value !== undefined) {
|
||||
entries.push([name, value]);
|
||||
}
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const lines = entries.map(([name, value]) => `- ${name}: ${value}`);
|
||||
return [
|
||||
'The following template variables are available for this session:',
|
||||
...lines,
|
||||
'When a skill or instruction references {{VSCODE_VARIABLE_NAME}}, substitute the corresponding value above.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
private _reverseSortRefsWithRange<T extends { range?: [number, number] }>(refs: T[]): (T & { range: [number, number] })[] {
|
||||
const refsWithRange = refs.filter(ref => !!ref.range) as (T & { range: [number, number] })[];
|
||||
return refsWithRange.sort((a, b) => b.range[0] - a.range[0]);
|
||||
|
||||
@@ -3,14 +3,42 @@
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { beforeEach, describe, expect, test } from 'vitest';
|
||||
import type { ChatLanguageModelToolReference, ChatPromptReference } from 'vscode';
|
||||
import { ITestingServicesAccessor } from '../../../../platform/test/node/services';
|
||||
import { IChatDebugFileLoggerService, NullChatDebugFileLoggerService } from '../../../../platform/chat/common/chatDebugFileLoggerService';
|
||||
import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext';
|
||||
import { MockExtensionContext } from '../../../../platform/test/node/extensionContext';
|
||||
import { ITestingServicesAccessor, TestingServiceCollection } from '../../../../platform/test/node/services';
|
||||
import { URI } from '../../../../util/vs/base/common/uri';
|
||||
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { Uri } from '../../../../vscodeTypes';
|
||||
import { createExtensionUnitTestingServices } from '../../../test/node/services';
|
||||
import { PromptVariablesServiceImpl } from '../promptVariablesService';
|
||||
|
||||
class MockChatDebugFileLoggerService extends NullChatDebugFileLoggerService {
|
||||
private readonly _sessionDirs = new Map<string, URI>();
|
||||
|
||||
setSessionDir(sessionId: string, dir: URI): void {
|
||||
this._sessionDirs.set(sessionId, dir);
|
||||
}
|
||||
|
||||
override getSessionDir(sessionId: string): URI | undefined {
|
||||
return this._sessionDirs.get(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
function createServicesWithLogger(mockLogger?: MockChatDebugFileLoggerService): { testingServiceCollection: TestingServiceCollection; mockLogger: MockChatDebugFileLoggerService } {
|
||||
const logger = mockLogger ?? new MockChatDebugFileLoggerService();
|
||||
const testingServiceCollection = createExtensionUnitTestingServices();
|
||||
// Provide a globalStorageUri so VSCODE_USER_PROMPTS_FOLDER can resolve
|
||||
const ctx = new MockExtensionContext(join(tmpdir(), 'copilot-test-globalStorage'));
|
||||
testingServiceCollection.define(IVSCodeExtensionContext, ctx as any);
|
||||
testingServiceCollection.define(IChatDebugFileLoggerService, logger);
|
||||
return { testingServiceCollection, mockLogger: logger };
|
||||
}
|
||||
|
||||
describe('PromptVariablesServiceImpl', () => {
|
||||
let accessor: ITestingServicesAccessor;
|
||||
let service: PromptVariablesServiceImpl;
|
||||
@@ -71,4 +99,113 @@ describe('PromptVariablesServiceImpl', () => {
|
||||
const rewritten = await service.resolveToolReferencesInPrompt(out, []);
|
||||
expect(rewritten).toBe(msg);
|
||||
});
|
||||
|
||||
describe('buildTemplateVariablesContext', () => {
|
||||
test('returns empty string when no session id and no debug target session ids are given', () => {
|
||||
// Default NullChatDebugFileLoggerService returns undefined for every getSessionDir,
|
||||
// so VSCODE_TARGET_SESSION_LOG resolves to undefined.
|
||||
// VSCODE_USER_PROMPTS_FOLDER always resolves, so build a fresh service with the default null logger.
|
||||
const { testingServiceCollection } = createServicesWithLogger();
|
||||
const acc = testingServiceCollection.createTestingAccessor();
|
||||
const svc = acc.get(IInstantiationService).createInstance(PromptVariablesServiceImpl);
|
||||
const result = svc.buildTemplateVariablesContext(undefined);
|
||||
// Only VSCODE_USER_PROMPTS_FOLDER should be present
|
||||
expect(result).toContain('VSCODE_USER_PROMPTS_FOLDER');
|
||||
expect(result).not.toContain('VSCODE_TARGET_SESSION_LOG');
|
||||
});
|
||||
|
||||
test('resolves single sessionId to session log path', () => {
|
||||
const mockLogger = new MockChatDebugFileLoggerService();
|
||||
mockLogger.setSessionDir('session-1', URI.file('/logs/session-1'));
|
||||
const { testingServiceCollection } = createServicesWithLogger(mockLogger);
|
||||
const acc = testingServiceCollection.createTestingAccessor();
|
||||
const svc = acc.get(IInstantiationService).createInstance(PromptVariablesServiceImpl);
|
||||
|
||||
const result = svc.buildTemplateVariablesContext('session-1');
|
||||
expect(result).toContain('VSCODE_TARGET_SESSION_LOG');
|
||||
expect(result).toContain('/logs/session-1');
|
||||
});
|
||||
|
||||
test('prioritizes debugTargetSessionIds over sessionId', () => {
|
||||
const mockLogger = new MockChatDebugFileLoggerService();
|
||||
mockLogger.setSessionDir('session-1', URI.file('/logs/session-1'));
|
||||
mockLogger.setSessionDir('target-1', URI.file('/logs/target-1'));
|
||||
const { testingServiceCollection } = createServicesWithLogger(mockLogger);
|
||||
const acc = testingServiceCollection.createTestingAccessor();
|
||||
const svc = acc.get(IInstantiationService).createInstance(PromptVariablesServiceImpl);
|
||||
|
||||
const result = svc.buildTemplateVariablesContext('session-1', ['target-1']);
|
||||
expect(result).toContain('/logs/target-1');
|
||||
// session-1 should NOT appear because debugTargetSessionIds takes precedence
|
||||
expect(result).not.toContain('/logs/session-1');
|
||||
});
|
||||
|
||||
test('formats multiple debugTargetSessionIds as comma-separated paths', () => {
|
||||
const mockLogger = new MockChatDebugFileLoggerService();
|
||||
mockLogger.setSessionDir('target-1', URI.file('/logs/target-1'));
|
||||
mockLogger.setSessionDir('target-2', URI.file('/logs/target-2'));
|
||||
const { testingServiceCollection } = createServicesWithLogger(mockLogger);
|
||||
const acc = testingServiceCollection.createTestingAccessor();
|
||||
const svc = acc.get(IInstantiationService).createInstance(PromptVariablesServiceImpl);
|
||||
|
||||
const result = svc.buildTemplateVariablesContext(undefined, ['target-1', 'target-2']);
|
||||
expect(result).toContain('VSCODE_TARGET_SESSION_LOG');
|
||||
expect(result).toContain('/logs/target-1');
|
||||
expect(result).toContain('/logs/target-2');
|
||||
// Both paths joined with comma
|
||||
expect(result).toMatch(/\/logs\/target-1, \/logs\/target-2/);
|
||||
});
|
||||
|
||||
test('skips debugTargetSessionIds whose session dirs are missing', () => {
|
||||
const mockLogger = new MockChatDebugFileLoggerService();
|
||||
// Only target-2 has a session dir; target-1 does not
|
||||
mockLogger.setSessionDir('target-2', URI.file('/logs/target-2'));
|
||||
const { testingServiceCollection } = createServicesWithLogger(mockLogger);
|
||||
const acc = testingServiceCollection.createTestingAccessor();
|
||||
const svc = acc.get(IInstantiationService).createInstance(PromptVariablesServiceImpl);
|
||||
|
||||
const result = svc.buildTemplateVariablesContext(undefined, ['target-1', 'target-2']);
|
||||
expect(result).toContain('/logs/target-2');
|
||||
expect(result).not.toContain('target-1');
|
||||
});
|
||||
|
||||
test('includes VSCODE_TARGET_SESSION_LOG with empty value when all debugTargetSessionIds have missing dirs', () => {
|
||||
const mockLogger = new MockChatDebugFileLoggerService();
|
||||
// No session dirs set at all
|
||||
const { testingServiceCollection } = createServicesWithLogger(mockLogger);
|
||||
const acc = testingServiceCollection.createTestingAccessor();
|
||||
const svc = acc.get(IInstantiationService).createInstance(PromptVariablesServiceImpl);
|
||||
|
||||
const result = svc.buildTemplateVariablesContext(undefined, ['no-such-session']);
|
||||
// The resolver returns '' (empty string) when all dirs are missing, not undefined,
|
||||
// so the variable is still present in the output with an empty value.
|
||||
expect(result).toContain('VSCODE_TARGET_SESSION_LOG');
|
||||
expect(result).toMatch(/VSCODE_TARGET_SESSION_LOG:\s*$/m);
|
||||
});
|
||||
|
||||
test('includes VSCODE_USER_PROMPTS_FOLDER derived from global storage URI', () => {
|
||||
const { testingServiceCollection } = createServicesWithLogger();
|
||||
const acc = testingServiceCollection.createTestingAccessor();
|
||||
const svc = acc.get(IInstantiationService).createInstance(PromptVariablesServiceImpl);
|
||||
|
||||
const result = svc.buildTemplateVariablesContext(undefined);
|
||||
expect(result).toContain('VSCODE_USER_PROMPTS_FOLDER');
|
||||
// The path should end with /prompts
|
||||
expect(result).toMatch(/prompts/);
|
||||
});
|
||||
|
||||
test('returns empty string when sessionId has no session dir and no debugTargetSessionIds', () => {
|
||||
const mockLogger = new MockChatDebugFileLoggerService();
|
||||
// session-missing has no dir registered
|
||||
const { testingServiceCollection } = createServicesWithLogger(mockLogger);
|
||||
const acc = testingServiceCollection.createTestingAccessor();
|
||||
const svc = acc.get(IInstantiationService).createInstance(PromptVariablesServiceImpl);
|
||||
|
||||
const result = svc.buildTemplateVariablesContext('session-missing');
|
||||
// VSCODE_USER_PROMPTS_FOLDER still resolves
|
||||
expect(result).toContain('VSCODE_USER_PROMPTS_FOLDER');
|
||||
// But session log should not be present
|
||||
expect(result).not.toContain('VSCODE_TARGET_SESSION_LOG');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { BasePromptElementProps, Chunk, Document, PromptElement, PromptPiece, PromptPieceChild, PromptSizing, Raw, SystemMessage, TokenLimit, UserMessage } from '@vscode/prompt-tsx';
|
||||
import type { ChatRequestEditedFileEvent, LanguageModelToolInformation, NotebookEditor, TaskDefinition, TextEditor } from 'vscode';
|
||||
import { sessionResourceToId } from '../../../../platform/chat/common/chatDebugFileLoggerService';
|
||||
import { ChatLocation } from '../../../../platform/chat/common/commonTypes';
|
||||
import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';
|
||||
import { ICustomInstructionsService } from '../../../../platform/customInstructions/common/customInstructionsService';
|
||||
@@ -25,7 +26,7 @@ import { URI } from '../../../../util/vs/base/common/uri';
|
||||
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { ChatRequestEditedFileEventKind, Position, Range } from '../../../../vscodeTypes';
|
||||
import { GenericBasePromptElementProps } from '../../../context/node/resolvers/genericPanelIntentInvocation';
|
||||
import { ChatVariablesCollection, isCustomizationsIndex } from '../../../prompt/common/chatVariablesCollection';
|
||||
import { ChatVariablesCollection, extractDebugTargetSessionIds, isCustomizationsIndex } from '../../../prompt/common/chatVariablesCollection';
|
||||
import { getGlobalContextCacheKey, GlobalContextMessageMetadata, RenderedUserMessageMetadata, Turn } from '../../../prompt/common/conversation';
|
||||
import { InternalToolReference } from '../../../prompt/common/intents';
|
||||
import { IPromptVariablesService } from '../../../prompt/node/promptVariablesService';
|
||||
@@ -114,6 +115,10 @@ export class AgentPrompt extends PromptElement<AgentPromptProps> {
|
||||
</SystemMessage>
|
||||
</>;
|
||||
const isAutopilot = this.props.promptContext.request?.permissionLevel === 'autopilot';
|
||||
const sessionResource = this.props.promptContext.request?.sessionResource;
|
||||
const sessionId = sessionResource ? sessionResourceToId(sessionResource) : undefined;
|
||||
const debugTargetSessionIds = extractDebugTargetSessionIds([...this.props.promptContext.chatVariables].map(v => v.reference));
|
||||
const templateVariablesContext = this.promptVariablesService.buildTemplateVariablesContext(sessionId, debugTargetSessionIds);
|
||||
const baseInstructions = <>
|
||||
{!omitBaseAgentInstructions && baseAgentInstructions}
|
||||
{await this.getAgentCustomInstructions()}
|
||||
@@ -121,6 +126,7 @@ export class AgentPrompt extends PromptElement<AgentPromptProps> {
|
||||
When you have fully completed the task, call the task_complete tool to signal that you are done.<br />
|
||||
IMPORTANT: Before calling task_complete, you MUST provide a brief text summary of what was accomplished in your message. The task is not complete until both the summary and the task_complete call are present.
|
||||
</SystemMessage>}
|
||||
{templateVariablesContext.length > 0 && <SystemMessage>{templateVariablesContext}</SystemMessage>}
|
||||
<UserMessage>
|
||||
{await this.getOrCreateGlobalAgentContext(this.props.endpoint)}
|
||||
</UserMessage>
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Schemas } from '../../../../util/vs/base/common/network';
|
||||
import { URI } from '../../../../util/vs/base/common/uri';
|
||||
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { ChatReferenceBinaryData, ChatRequest, FileType } from '../../../../vscodeTypes';
|
||||
import { ChatVariablesCollection, isPromptFile, PromptVariable } from '../../../prompt/common/chatVariablesCollection';
|
||||
import { ChatVariablesCollection, isPromptFile, isSessionReference, PromptVariable, sessionReferenceAttachmentAttrs } from '../../../prompt/common/chatVariablesCollection';
|
||||
import { renderPromptElement } from '../base/promptRenderer';
|
||||
import { Tag } from '../base/tag';
|
||||
import { SummarizedDocumentLineNumberStyle } from '../inline/summarizedDocument/implementation';
|
||||
@@ -133,6 +133,10 @@ export async function generateUserPrompt(request: ChatRequest, prompt: string |
|
||||
async function renderResourceVariables(chatVariables: ChatVariablesCollection, fileSystemService: IFileSystemService, promptPathRepresentationService: IPromptPathRepresentationService): Promise<PromptElement[]> {
|
||||
const elements: PromptElement[] = [];
|
||||
await Promise.all(Array.from(chatVariables).map(async variable => {
|
||||
if (isSessionReference(variable)) {
|
||||
elements.push(<Tag name='attachment' attrs={sessionReferenceAttachmentAttrs(variable)} />);
|
||||
return;
|
||||
}
|
||||
if (variable.value instanceof ChatReferenceBinaryData) {
|
||||
if (variable.value.reference) {
|
||||
const attrs: Record<string, string> = {};
|
||||
|
||||
@@ -30,7 +30,7 @@ import { IInstantiationService } from '../../../../util/vs/platform/instantiatio
|
||||
import { DiagnosticSeverity } from '../../../../util/vs/workbench/api/common/extHostTypes/diagnostic';
|
||||
import { ChatReferenceBinaryData, ChatReferenceDiagnostic, LanguageModelToolResult2, Range, Uri } from '../../../../vscodeTypes';
|
||||
import { GenericBasePromptElementProps } from '../../../context/node/resolvers/genericPanelIntentInvocation';
|
||||
import { ChatVariablesCollection, isCustomizationsIndex, isInstructionFile, isPromptFile, PromptVariable } from '../../../prompt/common/chatVariablesCollection';
|
||||
import { ChatVariablesCollection, isCustomizationsIndex, isInstructionFile, isPromptFile, isSessionReference, PromptVariable, sessionReferenceAttachmentAttrs } from '../../../prompt/common/chatVariablesCollection';
|
||||
import { InternalToolReference } from '../../../prompt/common/intents';
|
||||
import { ToolName } from '../../../tools/common/toolNames';
|
||||
import { normalizeToolSchema } from '../../../tools/common/toolSchemaNormalizer';
|
||||
@@ -241,6 +241,11 @@ export async function renderChatVariables(chatVariables: ChatVariablesCollection
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isSessionReference(variable)) {
|
||||
elements.push(<Tag name='attachment' attrs={sessionReferenceAttachmentAttrs(variable)} />);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (URI.isUri(variableValue) || isLocation(variableValue)) {
|
||||
const uri = 'uri' in variableValue ? variableValue.uri : variableValue;
|
||||
|
||||
|
||||
@@ -5,13 +5,10 @@
|
||||
|
||||
import { BasePromptElementProps, PromptElement, PromptReference, PromptSizing } from '@vscode/prompt-tsx';
|
||||
import type { ChatLanguageModelToolReference } from 'vscode';
|
||||
import { IChatDebugFileLoggerService } from '../../../../platform/chat/common/chatDebugFileLoggerService';
|
||||
import { IIgnoreService } from '../../../../platform/ignore/common/ignoreService';
|
||||
import { ILogService } from '../../../../platform/log/common/logService';
|
||||
import { IPromptPathRepresentationService } from '../../../../platform/prompts/common/promptPathRepresentationService';
|
||||
import { getCurrentCapturingToken } from '../../../../platform/requestLogger/node/requestLogger';
|
||||
import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
|
||||
import { joinPath } from '../../../../util/vs/base/common/resources';
|
||||
import { URI } from '../../../../util/vs/base/common/uri';
|
||||
import { PromptVariable } from '../../../prompt/common/chatVariablesCollection';
|
||||
import { IPromptVariablesService } from '../../../prompt/node/promptVariablesService';
|
||||
@@ -32,7 +29,6 @@ export class PromptFile extends PromptElement<PromptFileProps, void> {
|
||||
@IPromptPathRepresentationService private readonly promptPathRepresentationService: IPromptPathRepresentationService,
|
||||
@IIgnoreService private readonly ignoreService: IIgnoreService,
|
||||
@IWorkspaceService private readonly workspaceService: IWorkspaceService,
|
||||
@IChatDebugFileLoggerService private readonly chatDebugFileLoggerService: IChatDebugFileLoggerService,
|
||||
) {
|
||||
super(props);
|
||||
}
|
||||
@@ -76,18 +72,7 @@ export class PromptFile extends PromptElement<PromptFileProps, void> {
|
||||
bodyOffset = match.index! + match[0].length;
|
||||
}
|
||||
}
|
||||
let bodyContent = content.substring(bodyOffset);
|
||||
|
||||
// Replace session log placeholder for troubleshoot skill
|
||||
if (fileUri.scheme === 'copilot-skill' && fileUri.path.includes('/troubleshoot/') && bodyContent.includes('{{CURRENT_SESSION_LOG}}')) {
|
||||
const token = getCurrentCapturingToken();
|
||||
const sessionIds = token?.debugTargetSessionIds ?? (token?.chatSessionId ? [token.chatSessionId] : []);
|
||||
const logDir = this.chatDebugFileLoggerService.debugLogsDir;
|
||||
if (sessionIds.length > 0 && logDir) {
|
||||
const sessionLogDirs = sessionIds.map(id => this.promptPathRepresentationService.getFilePath(joinPath(logDir, id)));
|
||||
bodyContent = bodyContent.replaceAll('{{CURRENT_SESSION_LOG}}', () => sessionLogDirs.join(', '));
|
||||
}
|
||||
}
|
||||
const bodyContent = content.substring(bodyOffset);
|
||||
|
||||
return bodyContent;
|
||||
} catch (e) {
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import * as l10n from '@vscode/l10n';
|
||||
import { BasePromptElementProps, PromptElement, PromptElementProps, PromptReference } from '@vscode/prompt-tsx';
|
||||
import type * as vscode from 'vscode';
|
||||
import { IChatDebugFileLoggerService, sessionResourceToId } from '../../../platform/chat/common/chatDebugFileLoggerService';
|
||||
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
|
||||
import { ObjectJsonSchema } from '../../../platform/configuration/common/jsonSchema';
|
||||
import { ICustomInstructionsService } from '../../../platform/customInstructions/common/customInstructionsService';
|
||||
@@ -21,11 +20,10 @@ import { ITelemetryService } from '../../../platform/telemetry/common/telemetry'
|
||||
import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
|
||||
import { getCachedSha256Hash } from '../../../util/common/crypto';
|
||||
import { clamp } from '../../../util/vs/base/common/numbers';
|
||||
import { dirname, extUriBiasedIgnorePathCase, joinPath } from '../../../util/vs/base/common/resources';
|
||||
import { dirname, extUriBiasedIgnorePathCase } from '../../../util/vs/base/common/resources';
|
||||
import { URI } from '../../../util/vs/base/common/uri';
|
||||
import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { LanguageModelPromptTsxPart, LanguageModelToolResult, Location, MarkdownString, Range } from '../../../vscodeTypes';
|
||||
import { isSessionReferenceScheme } from '../../prompt/common/chatVariablesCollection';
|
||||
import { IBuildPromptContext } from '../../prompt/common/intents';
|
||||
import { renderPromptElementJSON } from '../../prompts/node/base/promptRenderer';
|
||||
import { BinaryFileHexdump, hexdumpIfBinary } from '../../prompts/node/panel/binaryFileHexdump';
|
||||
@@ -129,7 +127,6 @@ export class ReadFileTool implements ICopilotTool<ReadFileParams> {
|
||||
@IExperimentationService private readonly experimentationService: IExperimentationService,
|
||||
@ICustomInstructionsService private readonly customInstructionsService: ICustomInstructionsService,
|
||||
@IFileSystemService private readonly fileSystemService: IFileSystemService,
|
||||
@IChatDebugFileLoggerService private readonly chatDebugFileLoggerService: IChatDebugFileLoggerService,
|
||||
) { }
|
||||
|
||||
async invoke(options: vscode.LanguageModelToolInvocationOptions<ReadFileParams>, token: vscode.CancellationToken) {
|
||||
@@ -329,25 +326,7 @@ export class ReadFileTool implements ICopilotTool<ReadFileParams> {
|
||||
return this.workspaceService.openNotebookDocumentAndSnapshot(uri, this.alternativeNotebookContent.getFormat(this._promptContext?.request?.model));
|
||||
}
|
||||
|
||||
const snapshot = TextDocumentSnapshot.create(await this.workspaceService.openTextDocument(uri));
|
||||
|
||||
// Replace the session log placeholder for the troubleshoot skill
|
||||
if (uri.scheme === 'copilot-skill' && uri.path.includes('/troubleshoot/')) {
|
||||
const sessionRefs = this._promptContext?.request?.references?.filter(ref => URI.isUri(ref.value) && isSessionReferenceScheme(ref.value.scheme)) ?? [];
|
||||
const sessionResources = sessionRefs.length > 0
|
||||
? sessionRefs.map(ref => ref.value as URI)
|
||||
: (this._promptContext?.request?.sessionResource ? [this._promptContext.request.sessionResource] : []);
|
||||
if (sessionResources.length > 0) {
|
||||
const logDir = this.chatDebugFileLoggerService.debugLogsDir;
|
||||
if (logDir) {
|
||||
const sessionLogDirs = sessionResources.map(res => this.promptPathRepresentationService.getFilePath(joinPath(logDir, sessionResourceToId(res))));
|
||||
const replaced = snapshot.getText().replaceAll('{{CURRENT_SESSION_LOG}}', () => sessionLogDirs.join(', '));
|
||||
return TextDocumentSnapshot.fromNewText(replaced, snapshot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
return TextDocumentSnapshot.create(await this.workspaceService.openTextDocument(uri));
|
||||
}
|
||||
|
||||
private async sendReadFileTelemetry(outcome: string, options: Pick<vscode.LanguageModelToolInvocationOptions<ReadFileParams>, 'model' | 'chatRequestId' | 'input'>, { start, end, truncated }: IParamRanges, uri: URI | undefined) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -206,7 +206,10 @@ export async function assertFileOkForTool(accessor: ServicesAccessor, uri: URI,
|
||||
}
|
||||
|
||||
async function isExternalInstructionsFile(normalizedUri: URI, customInstructionsService: ICustomInstructionsService, buildPromptContext?: IBuildPromptContext): Promise<boolean> {
|
||||
if (normalizedUri.scheme === 'vscode-chat-internal' || normalizedUri.scheme === 'copilot-skill') {
|
||||
if (normalizedUri.scheme === 'vscode-chat-internal') {
|
||||
return true;
|
||||
}
|
||||
if (customInstructionsService.getExtensionSkillInfo(normalizedUri)) {
|
||||
return true;
|
||||
}
|
||||
if (buildPromptContext) {
|
||||
|
||||
@@ -109,8 +109,8 @@ export class NullChatDebugFileLoggerService implements IChatDebugFileLoggerServi
|
||||
async startSession(): Promise<void> { }
|
||||
async endSession(): Promise<void> { }
|
||||
async flush(): Promise<void> { }
|
||||
getLogPath(): URI | undefined { return undefined; }
|
||||
getSessionDir(): URI | undefined { return undefined; }
|
||||
getLogPath(_sessionId?: string): URI | undefined { return undefined; }
|
||||
getSessionDir(_sessionId?: string): URI | undefined { return undefined; }
|
||||
getActiveSessionIds(): string[] { return []; }
|
||||
isDebugLogUri(): boolean { return false; }
|
||||
getSessionDirForResource(): URI | undefined { return undefined; }
|
||||
|
||||
@@ -52,6 +52,7 @@ export const ICustomInstructionsService = createServiceIdentifier<ICustomInstruc
|
||||
export interface IExtensionPromptFile {
|
||||
uri: URI;
|
||||
type: PromptsType;
|
||||
extensionId?: string;
|
||||
}
|
||||
|
||||
export interface ICustomInstructionsService {
|
||||
@@ -77,7 +78,8 @@ export interface ICustomInstructionsService {
|
||||
*/
|
||||
refreshExtensionPromptFiles(): Promise<void>;
|
||||
/** Gets skill info for extension-contributed skill files */
|
||||
getExtensionSkillInfo(uri: URI): { skillName: string; skillFolderUri: URI } | undefined;
|
||||
getExtensionSkillInfo(uri: URI): { skillName: string; skillFolderUri: URI; extensionId?: string } | undefined;
|
||||
getExtensionPromptFileInfo(uri: URI): IExtensionPromptFile | undefined;
|
||||
}
|
||||
|
||||
export interface IInstructionIndexFile {
|
||||
@@ -401,7 +403,7 @@ export class CustomInstructionsService extends Disposable implements ICustomInst
|
||||
});
|
||||
}
|
||||
|
||||
public getExtensionSkillInfo(uri: URI): { skillName: string; skillFolderUri: URI } | undefined {
|
||||
public getExtensionSkillInfo(uri: URI): { skillName: string; skillFolderUri: URI; extensionId?: string } | undefined {
|
||||
if (!this._extensionPromptFilesCache) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -410,13 +412,17 @@ export class CustomInstructionsService extends Disposable implements ICustomInst
|
||||
const skillFolderUri = extUriBiasedIgnorePathCase.dirname(file.uri);
|
||||
if (extUriBiasedIgnorePathCase.isEqualOrParent(uri, skillFolderUri)) {
|
||||
const skillName = extUriBiasedIgnorePathCase.basename(skillFolderUri);
|
||||
return { skillName, skillFolderUri };
|
||||
return { skillName, skillFolderUri, extensionId: file.extensionId };
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public getExtensionPromptFileInfo(uri: URI): IExtensionPromptFile | undefined {
|
||||
return this._extensionPromptFilesCache?.find(file => extUriBiasedIgnorePathCase.isEqual(file.uri, uri));
|
||||
}
|
||||
|
||||
public parseInstructionIndexFile(content: string): InstructionIndexFile {
|
||||
return new InstructionIndexFile(content, this.promptPathRepresentationService);
|
||||
}
|
||||
|
||||
@@ -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[],
|
||||
) { }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,7 +246,7 @@ async function registerChatServices(testingServiceCollection: TestingServiceColl
|
||||
override async monitorSessionFiles() {
|
||||
// Override to do nothing in tests
|
||||
}
|
||||
protected override async createSessionsOptions(options: { model?: string; workingDirectory?: Uri; workspaceInfo: IWorkspaceInfo; mcpServers?: SessionOptions['mcpServers'] }): Promise<CopilotCLISessionOptions> {
|
||||
protected override async createSessionsOptions(options: { model?: string; workingDirectory?: Uri; workspaceInfo: IWorkspaceInfo; mcpServers?: SessionOptions['mcpServers']; sessionId?: string; debugTargetSessionIds?: readonly string[] }): Promise<CopilotCLISessionOptions> {
|
||||
const testOptionsProvider = this.instantiationService.invokeFunction((accessor) => accessor.get(ITestSessionOptionsProvider));
|
||||
const overrideOptions = await testOptionsProvider.getOptions();
|
||||
const sessionOptions = new TestCopilotCLISessionOptions(options, this.logService, overrideOptions);
|
||||
|
||||
Reference in New Issue
Block a user