From bd85e92f9e97561ccdd777b8c4f9ff302a3c8675 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:19:20 -0700 Subject: [PATCH 1/5] Move the /troubleshoot built-in skill into the agent host --- .../common/agentHostSkillInvocation.ts | 25 +++ .../agentHost/common/agentHostTroubleshoot.ts | 94 +++++++++ .../meta/agentCompletionAttachmentMeta.ts | 37 +++- .../common/meta/agentSessionReferenceMeta.ts | 61 ++++++ .../node/agentHostFileCompletionProvider.ts | 6 + ...tHostSessionReferenceCompletionProvider.ts | 145 +++++++++++++ .../agentHost/node/copilot/copilotAgent.ts | 11 + .../node/copilot/copilotAgentSession.ts | 36 +++- .../node/copilot/copilotBuiltinSkills.ts | 66 ++++++ .../node/copilot/copilotSessionLauncher.ts | 7 +- .../copilotSlashCommandCompletionProvider.ts | 46 ++++ .../copilot}/skills/troubleshoot/SKILL.md | 0 .../common/agentHostSkillInvocation.test.ts | 34 +++ .../test/common/agentHostTroubleshoot.test.ts | 61 ++++++ ...SessionReferenceCompletionProvider.test.ts | 104 +++++++++ .../test/node/copilotBuiltinSkills.test.ts | 25 +++ ...ilotSlashCommandCompletionProvider.test.ts | 55 +++++ .../chat/browser/agentHostInputCompletions.ts | 31 ++- .../contrib/chat/browser/newChatInput.ts | 6 - .../browser/sessionReferenceCompletions.ts | 197 ------------------ .../sessions/browser/sessionReference.ts | 65 ------ .../browser/sessionsManagementService.ts | 77 +------ .../agentHost/agentHostSessionHandler.ts | 15 ++ .../input/editor/agentHostInputCompletions.ts | 21 +- .../editor/agentHostInputCompletionsBase.ts | 17 +- .../input/editor/chatInputCompletionUtils.ts | 15 +- .../common/attachments/chatVariableEntries.ts | 7 + .../chat/common/chatSessionsService.ts | 22 +- 28 files changed, 925 insertions(+), 361 deletions(-) create mode 100644 src/vs/platform/agentHost/common/agentHostSkillInvocation.ts create mode 100644 src/vs/platform/agentHost/common/agentHostTroubleshoot.ts create mode 100644 src/vs/platform/agentHost/common/meta/agentSessionReferenceMeta.ts create mode 100644 src/vs/platform/agentHost/node/agentHostSessionReferenceCompletionProvider.ts create mode 100644 src/vs/platform/agentHost/node/copilot/copilotBuiltinSkills.ts rename src/vs/{sessions => platform/agentHost/node/copilot}/skills/troubleshoot/SKILL.md (100%) create mode 100644 src/vs/platform/agentHost/test/common/agentHostSkillInvocation.test.ts create mode 100644 src/vs/platform/agentHost/test/common/agentHostTroubleshoot.test.ts create mode 100644 src/vs/platform/agentHost/test/node/agentHostSessionReferenceCompletionProvider.test.ts create mode 100644 src/vs/platform/agentHost/test/node/copilotBuiltinSkills.test.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/sessionReferenceCompletions.ts delete mode 100644 src/vs/sessions/services/sessions/browser/sessionReference.ts diff --git a/src/vs/platform/agentHost/common/agentHostSkillInvocation.ts b/src/vs/platform/agentHost/common/agentHostSkillInvocation.ts new file mode 100644 index 0000000000000..6aad05d0f1c00 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostSkillInvocation.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Builds the message that invokes a bundled skill. + * + * A skill loaded into the harness runtime (via its skill directory) is invoked + * by a message that asks the model to use the `skill` tool to load and follow + * it. So a `/` built-in skill request is rewritten into this canonical + * phrasing - the same contract the Copilot SDK reference client uses. + * + * Harness-agnostic and pure so every harness (and the unit tests) share one + * definition of the invocation contract. + * + * @param skillName The bundled skill's name (its folder / `/` command). + * @param userInstructions Optional free text the user typed after the command, + * forwarded as additional context for the skill. + */ +export function buildSkillInvocationPrompt(skillName: string, userInstructions?: string): string { + const base = `Use the skill tool to invoke the '${skillName}' skill, then follow the skill's instructions.`; + const trimmed = userInstructions?.trim(); + return trimmed ? `${base}\n\nAdditional context from the user:\n${trimmed}` : base; +} diff --git a/src/vs/platform/agentHost/common/agentHostTroubleshoot.ts b/src/vs/platform/agentHost/common/agentHostTroubleshoot.ts new file mode 100644 index 0000000000000..dec6836ab0e5d --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostTroubleshoot.ts @@ -0,0 +1,94 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../base/common/uri.js'; +import { AgentSession } from './agentService.js'; +import { buildSkillInvocationPrompt } from './agentHostSkillInvocation.js'; +import { readSessionReferenceAttachmentMeta } from './meta/agentSessionReferenceMeta.js'; +import type { MessageAttachment } from './state/protocol/state.js'; + +/** + * The name of the built-in troubleshoot skill and the leading slash-command the + * user types (`/troubleshoot`). The same command name is used across every + * harness (each bundles its own `troubleshoot` skill describing its own logs), + * so the send-path can recognize a troubleshoot request harness-agnostically. + */ +export const TROUBLESHOOT_SKILL_NAME = 'troubleshoot'; + +/** + * Result of {@link augmentTroubleshootRequest}: the rewritten prompt and the + * attachments to forward to the model. + */ +export interface ITroubleshootRequest { + readonly prompt: string; + readonly attachments: readonly MessageAttachment[] | undefined; +} + +/** + * Rewrites a `/troubleshoot` request into a message that invokes the built-in + * `troubleshoot` skill: + * + * Use the skill tool to invoke the 'troubleshoot' skill, then follow the + * skill's instructions. + * + * Session log: + * + * An explicit `#session` reference pins the referenced session's log path (and + * its marker attachments are dropped, consumed here rather than forwarded). A + * bare `/troubleshoot` injects no path, so the skill continues the session + * already established earlier in the conversation (its sticky-reference rule), + * or self-discovers the current session on a first call. The user's free text + * is forwarded as context only for a bare request, since with a reference the + * trailing text is the marker title rather than an instruction. + * + * Harness-agnostic: each harness supplies `resolveSessionLogPath`, mapping a raw + * session id to that harness's on-disk log (Copilot CLI's `events.jsonl`, + * Claude's transcript, etc.), keeping this a pure, unit-testable function. + */ +export function augmentTroubleshootRequest( + userInstructions: string, + attachments: readonly MessageAttachment[] | undefined, + resolveSessionLogPath: (sessionId: string) => string, +): ITroubleshootRequest { + const referencedIds: string[] = []; + const remaining: MessageAttachment[] = []; + for (const attachment of attachments ?? []) { + const meta = readSessionReferenceAttachmentMeta(attachment._meta); + if (meta) { + // Resolve the raw session id from the resource the same way the + // current session id is derived, so it matches the on-disk session + // folder regardless of the meta's own id. + try { + referencedIds.push(AgentSession.id(URI.parse(meta.sessionResource))); + } catch { + // Ignore an unparseable reference. + } + } else { + remaining.push(attachment); + } + } + + const parts = [buildSkillInvocationPrompt(TROUBLESHOOT_SKILL_NAME)]; + // Pin the log path only for an explicit `#session` reference. A bare + // `/troubleshoot` injects no path so the skill continues the session already + // established earlier in the conversation, or self-discovers the current one. + if (referencedIds.length) { + const logPaths = Array.from(new Set(referencedIds.map(resolveSessionLogPath))); + if (logPaths.length) { + parts.push(`Session log: ${logPaths.join(', ')}`); + } + } + // Forward the user's free text only when it is a genuine instruction. With a + // `#session` reference the trailing text is the reference title, so forwarding + // it would just re-ask the title. + const instructions = referencedIds.length ? '' : userInstructions.trim(); + if (instructions) { + parts.push(`Additional context from the user:\n${instructions}`); + } + + // Drop the `#session` markers; forward any other (real) attachments. + const nextAttachments = referencedIds.length ? remaining : attachments; + return { prompt: parts.join('\n\n'), attachments: nextAttachments }; +} diff --git a/src/vs/platform/agentHost/common/meta/agentCompletionAttachmentMeta.ts b/src/vs/platform/agentHost/common/meta/agentCompletionAttachmentMeta.ts index 92c98a3677ce1..23e2908e6d258 100644 --- a/src/vs/platform/agentHost/common/meta/agentCompletionAttachmentMeta.ts +++ b/src/vs/platform/agentHost/common/meta/agentCompletionAttachmentMeta.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type { SimpleMessageAttachment } from '../state/protocol/state.js'; +import { readSessionReferenceAttachmentMeta } from './agentSessionReferenceMeta.js'; /** * Well-known typed views over a `SimpleMessageAttachment`'s `_meta` bag as @@ -44,6 +45,27 @@ export interface ISkillCompletionAttachmentMeta { readonly description?: string; } +/** + * Top-level `_meta` key carrying a display-only, human-readable date for a + * `#session` completion (e.g. last-modified time), shown as the completion's + * description. Kept separate from the session-reference contract so it does not + * affect send-time resolution. + */ +export const SESSION_REFERENCE_DISPLAY_DATE_META_KEY = 'sessionReferenceDisplayDate'; + +/** + * The `_meta` shape attached to a `completions` result that resolves to a + * `#session` reference (another agent session to attach as context). + */ +export interface ISessionReferenceCompletionAttachmentMeta { + /** The referenced session's resource URI as a string. */ + readonly sessionResource: string; + /** The referenced session's id. */ + readonly sessionID: string; + /** Optional human-readable date shown as the completion's description. */ + readonly date?: string; +} + /** * A typed, discriminated view over the well-known `completions` attachment * `_meta` variants. The `kind` discriminant is computed by @@ -52,7 +74,8 @@ export interface ISkillCompletionAttachmentMeta { */ export type CompletionAttachmentMeta = | ({ readonly kind: 'command' } & ICommandCompletionAttachmentMeta) - | ({ readonly kind: 'skill' } & ISkillCompletionAttachmentMeta); + | ({ readonly kind: 'skill' } & ISkillCompletionAttachmentMeta) + | ({ readonly kind: 'sessionReference' } & ISessionReferenceCompletionAttachmentMeta); /** * Reads the well-known `completions` attachment `_meta` keys, classifying the @@ -65,6 +88,18 @@ export function readCompletionAttachmentMeta(attachment: SimpleMessageAttachment if (!meta || typeof meta !== 'object' || Array.isArray(meta)) { return undefined; } + // A `#session` reference carries its data under a dedicated nested key rather + // than a flat discriminator, so classify it first. + const sessionRef = readSessionReferenceAttachmentMeta(meta); + if (sessionRef) { + const date = meta[SESSION_REFERENCE_DISPLAY_DATE_META_KEY]; + return { + kind: 'sessionReference', + sessionResource: sessionRef.sessionResource, + sessionID: sessionRef.sessionID, + ...(typeof date === 'string' ? { date } : {}), + }; + } if (typeof meta['command'] === 'string') { return { kind: 'command', diff --git a/src/vs/platform/agentHost/common/meta/agentSessionReferenceMeta.ts b/src/vs/platform/agentHost/common/meta/agentSessionReferenceMeta.ts new file mode 100644 index 0000000000000..85a63a2639cc5 --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/agentSessionReferenceMeta.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Well-known `_meta` key carried by a `#session` reference message attachment. + * A session reference lets the user point the built-in `/troubleshoot` skill at + * a session **other than** the current one; the harness reads this meta at send + * time to resolve the referenced session's on-disk event log. + * + * Kept in the platform layer so both the harness (which produces the `#session` + * completion and consumes it at send time) and the workbench (which renders the + * attachment pill and restores it on reload) share one contract. + */ +export const AgentHostSessionReferenceAttachmentMetadataKey = 'vscode.agentHost.sessionReference'; + +/** + * Typed view over the {@link AgentHostSessionReferenceAttachmentMetadataKey} + * payload on a session-reference attachment's `_meta` bag. + */ +export interface IAgentHostSessionReferenceAttachmentMetadata { + /** The referenced session's resource URI, serialized via `URI.toString()`. */ + readonly sessionResource: string; + /** The referenced session's id (the `chatSessionResourceToId` of the resource). */ + readonly sessionID: string; +} + +/** + * Builds the `_meta` bag for a session-reference attachment. Producers (the + * `#session` completion provider) MUST go through this so the shape stays in + * lock-step with {@link readSessionReferenceAttachmentMeta}. + */ +export function toSessionReferenceAttachmentMeta(metadata: IAgentHostSessionReferenceAttachmentMetadata): Record { + return { + [AgentHostSessionReferenceAttachmentMetadataKey]: { + sessionResource: metadata.sessionResource, + sessionID: metadata.sessionID, + } satisfies IAgentHostSessionReferenceAttachmentMetadata, + }; +} + +/** + * Reads the {@link IAgentHostSessionReferenceAttachmentMetadata} from an + * attachment's `_meta` bag, returning `undefined` when the bag is missing or + * malformed. + */ +export function readSessionReferenceAttachmentMeta(meta: Record | undefined): IAgentHostSessionReferenceAttachmentMetadata | undefined { + if (!meta || typeof meta !== 'object' || Array.isArray(meta)) { + return undefined; + } + const raw = meta[AgentHostSessionReferenceAttachmentMetadataKey]; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return undefined; + } + const typed = raw as Partial; + if (typeof typed.sessionResource !== 'string' || typeof typed.sessionID !== 'string') { + return undefined; + } + return { sessionResource: typed.sessionResource, sessionID: typed.sessionID }; +} diff --git a/src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts b/src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts index 1b4ca1aaef932..1306e3172d2ad 100644 --- a/src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts @@ -12,6 +12,7 @@ import { URI } from '../../../base/common/uri.js'; import { CompletionItem, CompletionItemKind, CompletionsParams } from '../common/state/protocol/commands.js'; import { MessageAttachmentKind } from '../common/state/protocol/state.js'; import { CompletionTriggerCharacter, IAgentHostCompletionItemProvider } from './agentHostCompletions.js'; +import { isSessionReferenceToken } from './agentHostSessionReferenceCompletionProvider.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; import { AgentHostWorkspaceFiles } from './agentHostWorkspaceFiles.js'; @@ -124,6 +125,11 @@ export class AgentHostFileCompletionProvider implements IAgentHostCompletionItem if (!at) { return []; } + // Cede `#session…` tokens to the session-reference provider so file + // matches don't get mixed into the session list. + if (at.triggerChar === CompletionTriggerCharacter.Hash && isSessionReferenceToken(at.token)) { + return []; + } let files: readonly URI[]; try { diff --git a/src/vs/platform/agentHost/node/agentHostSessionReferenceCompletionProvider.ts b/src/vs/platform/agentHost/node/agentHostSessionReferenceCompletionProvider.ts new file mode 100644 index 0000000000000..a31885947e703 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostSessionReferenceCompletionProvider.ts @@ -0,0 +1,145 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { AgentSession, type AgentProvider, type IAgentSessionMetadata } from '../common/agentService.js'; +import { toSessionReferenceAttachmentMeta } from '../common/meta/agentSessionReferenceMeta.js'; +import { SESSION_REFERENCE_DISPLAY_DATE_META_KEY } from '../common/meta/agentCompletionAttachmentMeta.js'; +import { CompletionItem, CompletionItemKind, CompletionsParams } from '../common/state/protocol/commands.js'; +import { MessageAttachmentKind } from '../common/state/protocol/state.js'; +import { CompletionTriggerCharacter, IAgentHostCompletionItemProvider } from './agentHostCompletions.js'; +import { extractAtToken } from './agentHostFileCompletionProvider.js'; + +/** Token the user types after `#` to reference another session. */ +const SESSION_TOKEN = 'session'; + +/** + * Whether a `#`-token is heading toward a `#session` reference — any non-empty + * prefix of `session` (so it matches as soon as `#s` is typed) or the full + * `#session:`. Shared with the file-reference provider so it can cede + * these tokens (and not mix file matches into the session list). + */ +export function isSessionReferenceToken(token: string): boolean { + const typed = token.toLowerCase(); + return typed.length > 0 && (SESSION_TOKEN.startsWith(typed) || typed.startsWith(SESSION_TOKEN)); +} + +/** + * Provides `#session` completions for a harness — one item per other session of + * the same provider on this host. Accepting an item inserts an inline + * `#session:` reference and attaches the referenced session as context; + * the harness resolves it to the session's log at send time (see the built-in + * `/troubleshoot` skill). + * + * Harness-agnostic: a harness registers one instance with its own provider id + * and a `listSessions` accessor. Runs in the agent host so the capability is + * identical across every client (workbench chat, Agents window) and works + * standalone/remote — the host owns the session list and the logs. Only fires + * for the owning provider's sessions once the user starts typing `#session`. + */ +export class AgentHostSessionReferenceCompletionProvider implements IAgentHostCompletionItemProvider { + readonly kinds: ReadonlySet<CompletionItemKind> = new Set([CompletionItemKind.UserMessage]); + readonly triggerCharacters = [CompletionTriggerCharacter.Hash] as const; + + /** + * How long a resolved session list is reused before it is re-fetched. The + * `#`-token completion path re-queries the host on every keystroke (see + * `AgentHostInputCompletionsBase`), which would otherwise call + * {@link _listSessions} - a client round-trip plus per-session metadata + * reads - once per character. The list changes rarely relative to typing + * speed, so a brief cache collapses a burst of keystrokes into one call + * while staying fresh enough for the picker. + */ + private static readonly _sessionsCacheTtlMs = 2500; + + /** + * Cached session list scoped to this instance (one per {@link IAgent}, so + * per host - a local and a remote agent never share this). `settled` gates + * TTL expiry: an in-flight fetch is always reused (dedupe), and the TTL is + * measured from when the fetch resolved. + */ + private _sessionsCache?: { at: number; readonly value: Promise<readonly IAgentSessionMetadata[]>; settled: boolean }; + + constructor( + private readonly _providerId: AgentProvider, + private readonly _listSessions: () => Promise<readonly IAgentSessionMetadata[]>, + private readonly _now: () => number = () => Date.now(), + ) { } + + async provideCompletionItems(params: CompletionsParams, token: CancellationToken): Promise<readonly CompletionItem[]> { + if (AgentSession.provider(params.channel) !== this._providerId) { + return []; + } + const at = extractAtToken(params.text, params.offset); + if (!at || at.triggerChar !== CompletionTriggerCharacter.Hash) { + return []; + } + // Participate once the token is heading toward `#session` — any non-empty + // prefix of `session` (so items appear as soon as `#s` is typed) or the + // full `#session:<filter>`. A bare `#` is left to the file-reference + // provider so we don't pollute it with the whole session list. + if (!isSessionReferenceToken(at.token)) { + return []; + } + + const sessions = await this._listSessionsCached(); + if (token.isCancellationRequested) { + return []; + } + + const currentId = AgentSession.id(params.channel); + return sessions + .filter(session => AgentSession.id(session.session) !== currentId) + .sort((a, b) => b.modifiedTime - a.modifiedTime) + .map(session => this._toCompletionItem(session, at.rangeStart, at.rangeEnd)); + } + + /** + * Fetches the session list, reusing an in-flight fetch or a resolved result + * within {@link _sessionsCacheTtlMs}. A rejected fetch is not cached, so the + * next keystroke retries. + */ + private _listSessionsCached(): Promise<readonly IAgentSessionMetadata[]> { + const cache = this._sessionsCache; + if (cache && (!cache.settled || this._now() - cache.at < AgentHostSessionReferenceCompletionProvider._sessionsCacheTtlMs)) { + return cache.value; + } + const value = this._listSessions(); + const entry: { at: number; readonly value: Promise<readonly IAgentSessionMetadata[]>; settled: boolean } = { at: this._now(), value, settled: false }; + this._sessionsCache = entry; + value.then( + () => { entry.at = this._now(); entry.settled = true; }, + () => { if (this._sessionsCache === entry) { this._sessionsCache = undefined; } }, + ); + return value; + } + + private _toCompletionItem(session: IAgentSessionMetadata, rangeStart: number, rangeEnd: number): CompletionItem { + // Collapse whitespace so the inline `#session:<title>` reference stays on + // one line even when the title contains newlines. + const title = (session.summary ?? '').replace(/\s+/g, ' ').trim() || 'Untitled session'; + const insertText = `${CompletionTriggerCharacter.Hash}${SESSION_TOKEN}:${title} `; + return { + insertText, + rangeStart, + rangeEnd, + attachment: { + type: MessageAttachmentKind.Simple, + label: title, + displayKind: 'sessionReference', + modelRepresentation: `Referenced chat session: ${title}`, + _meta: { + ...toSessionReferenceAttachmentMeta({ + sessionResource: session.session.toString(), + sessionID: AgentSession.id(session.session), + }), + // Display-only: shown as the completion's description (like the + // local session picker), ignored by send-time resolution. + [SESSION_REFERENCE_DISPLAY_DATE_META_KEY]: new Date(session.modifiedTime).toLocaleString(), + }, + }, + }; + } +} diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index e097babecb914..1a802f6ac622d 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -68,6 +68,8 @@ import { isAgentHostTelemetryService } from '../agentHostTelemetryService.js'; import { ICopilotApiService, type IRestrictedTelemetryContext } from '../shared/copilotApiService.js'; import { AgentHostGitHubTelemetryRouter } from '../agentHostGitHubTelemetryRouter.js'; import { CopilotSlashCommandCompletionProvider, ICopilotRuntimeSlashCommandQueryOptions } from './copilotSlashCommandCompletionProvider.js'; +import { AgentHostSessionReferenceCompletionProvider } from '../agentHostSessionReferenceCompletionProvider.js'; +import { BUILTIN_SKILLS } from './copilotBuiltinSkills.js'; import { DiscoveredType, SessionCustomizationDiscovery, areDiscoveredDirectoriesEqual, type IDiscoveredDirectory } from './sessionCustomizationDiscovery.js'; import { COPILOT_INTEGRATION_ID } from '../../../endpoint/common/licenseAgreement.js'; import { getAppNodeModulesPath } from '../appNodeModules.js'; @@ -425,10 +427,19 @@ export class CopilotAgent extends Disposable implements IAgent { isRubberDuckEnabled: () => this._isRubberDuckEnabled(), getRuntimeSlashCommands: (sessionId, options) => this._getRuntimeSlashCommands(sessionId, options), getSessionCustomizations: (sessionId) => this.getSessionCustomizations(AgentSession.uri(this.id, sessionId)), + getBuiltinSkills: () => BUILTIN_SKILLS, }, RUNTIME_SLASH_COMMAND_COMPLETION_WAIT_MS, ))); + // `#session` reference completions: one item per other Copilot CLI session + // on this host, used to point the built-in `/troubleshoot` skill at another + // session. Driven by the harness so it works identically across clients and + // standalone/remote. + this._register(completions.registerProvider(new AgentHostSessionReferenceCompletionProvider(this.id, + () => this.listSessions(), + ))); + // Restart the CLI client when a setting baked into the client/subprocess at // startup changes, disposing any active sessions. These values are applied in // `_ensureClient`, so they only take effect on the next client start. diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 889eb4d6e0376..7b1ab2bdcd176 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -49,6 +49,9 @@ import { AgentHostTelemetryReporter } from '../agentHostTelemetryReporter.js'; import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; import { buildCopilotSystemNotification } from './copilotSystemNotification.js'; import { parseLeadingSlashCommand } from './copilotSlashCommandCompletionProvider.js'; +import { augmentTroubleshootRequest, TROUBLESHOOT_SKILL_NAME } from '../../common/agentHostTroubleshoot.js'; +import { buildSkillInvocationPrompt } from '../../common/agentHostSkillInvocation.js'; +import { isBuiltinSkill } from './copilotBuiltinSkills.js'; import type { IUnsandboxedCommandConfirmationRequest, ShellManager } from './copilotShellTools.js'; import { buildSandboxConfigForSdk, type ISdkSandboxConfig } from './sandboxConfigForSdk.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; @@ -1412,8 +1415,26 @@ export class CopilotAgentSession extends Disposable { } } - const sdkAttachments = attachments?.length - ? (await Promise.all(attachments.map(a => this._toSdkAttachment(a)))).filter(isDefined) + // A bundled built-in skill request (`/<name>`) is rewritten into a + // deterministic skill invocation: the runtime does not reliably auto-run + // a skill from a bare `/<name>`, so we ask the model to use the `skill` + // tool (see `buildSkillInvocationPrompt`). `/troubleshoot` additionally + // resolves the on-disk session log(s) to analyze - `#session` reference + // attachments target other sessions, else the current session - and its + // `#session` markers are consumed here, not forwarded to the model. + let effectiveAttachments = attachments; + if (slashCommand && isBuiltinSkill(slashCommand.command)) { + if (slashCommand.command === TROUBLESHOOT_SKILL_NAME) { + const augmented = augmentTroubleshootRequest(slashCommand.rest, attachments, id => this._sessionEventsLogPath(id)); + prompt = augmented.prompt; + effectiveAttachments = augmented.attachments; + } else { + prompt = buildSkillInvocationPrompt(slashCommand.command, slashCommand.rest); + } + } + + const sdkAttachments = effectiveAttachments?.length + ? (await Promise.all(effectiveAttachments.map(a => this._toSdkAttachment(a)))).filter(isDefined) : undefined; if (sdkAttachments?.length) { this._logService.trace(`[Copilot:${this.sessionId}] Attachments: ${JSON.stringify(sdkAttachments.map(a => ({ type: a.type })))}`); @@ -1426,6 +1447,17 @@ export class CopilotAgentSession extends Disposable { this._logService.info(`[Copilot:${this.sessionId}] session.send() returned`); } + /** + * Resolves the on-disk `events.jsonl` path for a Copilot CLI session, + * `<sessionStateDir>/<sessionId>/events.jsonl`. The path is host-local (the + * agent runs where its logs live), so the built-in `/troubleshoot` skill can + * read it directly whether the host is local or remote. + */ + private _sessionEventsLogPath(sessionId: string): string { + const sessionStateDir = getCopilotCLISessionStateDir(this._environmentService.userHome.fsPath); + return join(sessionStateDir, sessionId, 'events.jsonl'); + } + async hasRuntimeSlashCommand(command: string): Promise<boolean> { try { return !!(await this._slashCommandProvider.resolveSlashCommand(command)); diff --git a/src/vs/platform/agentHost/node/copilot/copilotBuiltinSkills.ts b/src/vs/platform/agentHost/node/copilot/copilotBuiltinSkills.ts new file mode 100644 index 0000000000000..d65caa52efcde --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/copilotBuiltinSkills.ts @@ -0,0 +1,66 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { FileAccess } from '../../../../base/common/network.js'; +import { joinPath } from '../../../../base/common/resources.js'; +import { localize } from '../../../../nls.js'; +import { TROUBLESHOOT_SKILL_NAME } from '../../common/agentHostTroubleshoot.js'; + +/** + * Root directory of the Copilot harness's built-in skills, bundled next to + * this module (`<out>/vs/platform/agentHost/node/copilot/skills`). Each skill + * lives in its own `<name>/SKILL.md` subfolder. + */ +const BUILTIN_SKILLS_ROOT = FileAccess.asFileUri('vs/platform/agentHost/node/copilot/skills'); + +/** + * A built-in skill bundled with the Copilot harness. Adding a new one is: + * 1. create `skills/<name>/SKILL.md` next to this module, and + * 2. add an entry here. + * Nothing else in the plumbing is skill-specific: the folder is fed to the + * SDK's `skillDirectories` (so the runtime loads it), the manifest is surfaced + * by the slash-command completion provider (so it appears in `/` completions + * immediately), and the send path rewrites `/<name>` into a deterministic skill + * invocation (see `buildSkillInvocationPrompt`). A skill that also needs + * host-side request rewriting (as `/troubleshoot` does for its session log) + * layers that behavior on top in the send path. + */ +export interface IBuiltinSkill { + /** Folder name under {@link BUILTIN_SKILLS_ROOT} and the `/<name>` command. */ + readonly name: string; + /** + * User-facing description shown in completions / the customization list, + * resolved lazily so `localize()` runs at call time rather than module-init. + */ + readonly description: () => string; +} + +/** + * The registry of built-in skills bundled with the Copilot harness. This is the + * single place to declare them; the directory-loading and discovery plumbing + * below is fully generic and iterates this list. + */ +export const BUILTIN_SKILLS: readonly IBuiltinSkill[] = [ + { name: TROUBLESHOOT_SKILL_NAME, description: () => localize('copilot.builtin.troubleshoot', "(Built-In) Investigate unexpected behavior in the current Copilot CLI session by analyzing its session log.") }, +]; + +/** + * Returns one on-disk directory per built-in skill (the folder containing each + * `SKILL.md`), in the shape the Copilot SDK's `skillDirectories` session config + * expects. + * + * They are bundled with the agent host, so they resolve to a real path on + * whichever machine the host runs on - which is why the built-in skills work + * without VS Code and over a remote connection. + */ +export function getBuiltinSkillDirectories(): string[] { + return BUILTIN_SKILLS.map(skill => joinPath(BUILTIN_SKILLS_ROOT, skill.name).fsPath); +} + +/** Whether `name` is a bundled built-in skill's `/<name>` command. */ +export function isBuiltinSkill(name: string): boolean { + return BUILTIN_SKILLS.some(skill => skill.name === name); +} + diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 334c0f5ab399f..a4bb7620ac038 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -22,6 +22,7 @@ import type { ActiveClientToolSet } from '../activeClientState.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import { ShellManager, createShellTools, type IUnsandboxedCommandConfirmationRequest } from './copilotShellTools.js'; import { toSdkHooks, toSdkInstructionDirectories, toSdkMcpServers, toSdkMcpServersFromConfigMap, toSdkSessionCustomAgents, toSdkSkillDirectories } from './copilotPluginConverters.js'; +import { getBuiltinSkillDirectories } from './copilotBuiltinSkills.js'; import { buildSandboxConfigForSdk, type ISdkSandboxConfig } from './sandboxConfigForSdk.js'; import type { ITypedPermissionRequest } from './copilotToolDisplay.js'; import type { ICopilotPluginInfo } from './copilotAgent.js'; @@ -551,7 +552,11 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { // by name, so the selected agent is force-included (see `toSdkSessionCustomAgents`). const pluginsWithoutDirs = plugins.filter(p => !p.pluginDir || p.pluginDir.scheme !== Schemas.file); const customAgents = await toSdkSessionCustomAgents(plugins, plan.resolvedAgentName, this._fileService); - const skillDirectories = toSdkSkillDirectories(pluginsWithoutDirs.flatMap(p => p.skills)); + // Include the harness's built-in skills (e.g. `/troubleshoot`) so the SDK + // loads them alongside the user's discovered skills. These are bundled + // with the agent host, so they resolve on whichever machine the host runs + // on — making the built-in skills available without VS Code and remotely. + const skillDirectories = [...toSdkSkillDirectories(pluginsWithoutDirs.flatMap(p => p.skills)), ...getBuiltinSkillDirectories()]; const instructionDirectories = toSdkInstructionDirectories(plugins.flatMap(p => p.instructions)); const model = plan.kind === 'create' ? plan.model : plan.fallback.model; // Client tools (browser tools, tasks, etc.) are addressed by the name the diff --git a/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts b/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts index e0ca22079f371..49741e0d5b6cb 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts @@ -11,6 +11,7 @@ import { toCommandCompletionAttachmentMeta } from '../../common/meta/agentComple import { CompletionTriggerCharacter, IAgentHostCompletionItemProvider } from '../agentHostCompletions.js'; import { extractLeadingSlashToken, extractWhitespaceDelimitedSlashToken } from '../agentHostSlashCompletion.js'; import { SYNCED_CUSTOMIZATION_SCHEME } from '../../common/agentHostFileSystemService.js'; +import type { IBuiltinSkill } from './copilotBuiltinSkills.js'; import type { CopilotSession } from '@github/copilot-sdk'; const HIDDEN_RUNTIME_COMMANDS = new Set<string>(['agent', 'app', 'changelog', 'context', 'copy', 'exit', 'extensions', 'feedback', 'help', 'ide', 'instructions', 'login', 'logout', 'mcp', 'model', 'new', 'plugin', 'rename', 'restart', 'resume', 'sandbox', 'session', 'settings', 'skills', 'statusline', 'streamer-mode', 'subagents', 'tasks', 'terminal-setup', 'theme', 'undo', 'update', 'user', 'voice', 'worktree', 'autopilot', 'yolo']); @@ -30,6 +31,13 @@ export interface ICopilotSlashCommandSessionInfo { /** Runtime slash commands discovered from the SDK session. */ getRuntimeSlashCommands?(sessionId: string, options?: ICopilotRuntimeSlashCommandQueryOptions): Promise<readonly ICopilotRuntimeSlashCommandInfo[]>; getSessionCustomizations: (session: string) => Promise<readonly Customization[]>; + /** + * The harness's built-in skills (e.g. `/troubleshoot`), surfaced as slash + * completions directly from the manifest so they appear immediately - even + * for a brand-new session, before the SDK lists them as runtime commands. + * Omitted in contexts (such as tests) that don't exercise built-ins. + */ + getBuiltinSkills?(): readonly IBuiltinSkill[]; } export interface ICopilotRuntimeSlashCommandQueryOptions { @@ -109,6 +117,13 @@ export class CopilotSlashCommandCompletionProvider implements IAgentHostCompleti private async _getKnownSkills(sessionId: string) { const knownCommands = new Set<string>(); + // The harness's built-in skills (e.g. `/troubleshoot`) are surfaced + // directly by this provider (see below), so treat them as known: this + // dedupes the runtime `skill` copy the SDK reports once a session has + // materialized, keeping a single entry across the session lifetime. + for (const skill of this._sessionInfo.getBuiltinSkills?.() ?? []) { + knownCommands.add(skill.name); + } const customizations = await this._sessionInfo.getSessionCustomizations(sessionId) ?? []; for (const c of customizations) { if (c.type === CustomizationType.McpServer || !c.enabled || !c.children) { @@ -207,6 +222,37 @@ export class CopilotSlashCommandCompletionProvider implements IAgentHostCompleti }); } + // Surface the harness's built-in skills (e.g. `/troubleshoot`) as slash + // completions directly from the manifest, so they appear immediately - + // even for a brand-new session, before the SDK lists them as runtime + // commands. These are plain completion items (not customizations), so + // they are not claimed by the workbench prompt-file machinery; dispatch + // remains text-side via `parseLeadingSlashCommand`. + for (const skill of this._sessionInfo.getBuiltinSkills?.() ?? []) { + if (addedAliases.has(skill.name)) { + continue; + } + if (typed.length > 0 && !skill.name.toLowerCase().startsWith(typedLower)) { + continue; + } + const insertText = `/${skill.name} `; + const description = skill.description(); + addedAliases.add(skill.name); + completionItems.push({ + insertText, + rangeStart, + rangeEnd, + attachment: { + type: MessageAttachmentKind.Simple, + label: insertText, + _meta: toCommandCompletionAttachmentMeta({ + command: skill.name, + description, + }), + }, + }); + } + return completionItems.sort((a, b) => a.insertText.localeCompare(b.insertText)); } } diff --git a/src/vs/sessions/skills/troubleshoot/SKILL.md b/src/vs/platform/agentHost/node/copilot/skills/troubleshoot/SKILL.md similarity index 100% rename from src/vs/sessions/skills/troubleshoot/SKILL.md rename to src/vs/platform/agentHost/node/copilot/skills/troubleshoot/SKILL.md diff --git a/src/vs/platform/agentHost/test/common/agentHostSkillInvocation.test.ts b/src/vs/platform/agentHost/test/common/agentHostSkillInvocation.test.ts new file mode 100644 index 0000000000000..9b85f2be60fa8 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/agentHostSkillInvocation.test.ts @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { buildSkillInvocationPrompt } from '../../common/agentHostSkillInvocation.js'; + +suite('agentHostSkillInvocation - buildSkillInvocationPrompt', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('asks the model to use the skill tool for the named skill', () => { + assert.strictEqual( + buildSkillInvocationPrompt('review'), + `Use the skill tool to invoke the 'review' skill, then follow the skill's instructions.`, + ); + }); + + test('appends trimmed user instructions as additional context', () => { + assert.strictEqual( + buildSkillInvocationPrompt('review', ' focus on error handling '), + `Use the skill tool to invoke the 'review' skill, then follow the skill's instructions.\n\nAdditional context from the user:\nfocus on error handling`, + ); + }); + + test('omits the context block for empty instructions', () => { + assert.strictEqual( + buildSkillInvocationPrompt('review', ' '), + `Use the skill tool to invoke the 'review' skill, then follow the skill's instructions.`, + ); + }); +}); diff --git a/src/vs/platform/agentHost/test/common/agentHostTroubleshoot.test.ts b/src/vs/platform/agentHost/test/common/agentHostTroubleshoot.test.ts new file mode 100644 index 0000000000000..985473cdfd4d1 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/agentHostTroubleshoot.test.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { AgentSession } from '../../common/agentService.js'; +import { augmentTroubleshootRequest } from '../../common/agentHostTroubleshoot.js'; +import { toSessionReferenceAttachmentMeta } from '../../common/meta/agentSessionReferenceMeta.js'; +import { MessageAttachmentKind, type MessageAttachment } from '../../common/state/protocol/state.js'; + +suite('agentHostTroubleshoot - augmentTroubleshootRequest', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const PROVIDER = 'copilotcli'; + const resolvePath = (id: string) => `/state/${id}/log.jsonl`; + + function sessionReferenceAttachment(sessionId: string): MessageAttachment { + const resource = AgentSession.uri(PROVIDER, sessionId); + return { + type: MessageAttachmentKind.Simple, + label: `session ${sessionId}`, + _meta: toSessionReferenceAttachmentMeta({ sessionResource: resource.toString(), sessionID: sessionId }), + }; + } + + function plainAttachment(label: string): MessageAttachment { + return { type: MessageAttachmentKind.Simple, label }; + } + + const SKILL_BASE = `Use the skill tool to invoke the 'troubleshoot' skill, then follow the skill's instructions.`; + + test('omits the log path for a bare request and forwards the free text as context', () => { + const result = augmentTroubleshootRequest('why slow', undefined, resolvePath); + assert.deepStrictEqual(result, { + prompt: `${SKILL_BASE}\n\nAdditional context from the user:\nwhy slow`, + attachments: undefined, + }); + }); + + test('targets a referenced session, drops the marker, and ignores the marker title text', () => { + const attachments = [sessionReferenceAttachment('other')]; + const result = augmentTroubleshootRequest('how many tests does util have', attachments, resolvePath); + assert.deepStrictEqual(result, { + prompt: `${SKILL_BASE}\n\nSession log: /state/other/log.jsonl`, + attachments: [], + }); + }); + + test('keeps non-marker attachments and dedupes multiple references', () => { + const keep = plainAttachment('notes.txt'); + const attachments = [sessionReferenceAttachment('a'), keep, sessionReferenceAttachment('b'), sessionReferenceAttachment('a')]; + const result = augmentTroubleshootRequest('#session:a #session:b', attachments, resolvePath); + assert.deepStrictEqual(result, { + prompt: `${SKILL_BASE}\n\nSession log: /state/a/log.jsonl, /state/b/log.jsonl`, + attachments: [keep], + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostSessionReferenceCompletionProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostSessionReferenceCompletionProvider.test.ts new file mode 100644 index 0000000000000..e35032b26211d --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostSessionReferenceCompletionProvider.test.ts @@ -0,0 +1,104 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { AgentSession, type IAgentSessionMetadata } from '../../common/agentService.js'; +import { readSessionReferenceAttachmentMeta } from '../../common/meta/agentSessionReferenceMeta.js'; +import { CompletionItemKind, type CompletionsParams } from '../../common/state/protocol/commands.js'; +import { MessageAttachmentKind } from '../../common/state/protocol/state.js'; +import { AgentHostSessionReferenceCompletionProvider } from '../../node/agentHostSessionReferenceCompletionProvider.js'; + +suite('AgentHostSessionReferenceCompletionProvider', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const PROVIDER = 'copilotcli'; + + function session(id: string, summary: string, modifiedTime: number): IAgentSessionMetadata { + return { session: AgentSession.uri(PROVIDER, id), startTime: 0, modifiedTime, summary }; + } + + function params(text: string, channelId = 'current'): CompletionsParams { + return { kind: CompletionItemKind.UserMessage, channel: AgentSession.uri(PROVIDER, channelId).toString(), text, offset: text.length }; + } + + function create(sessions: readonly IAgentSessionMetadata[]): AgentHostSessionReferenceCompletionProvider { + return new AgentHostSessionReferenceCompletionProvider(PROVIDER, async () => sessions); + } + + test('lists other sessions newest-first, excluding the current one', async () => { + const provider = create([ + session('a', 'Session A', 20), + session('current', 'Current', 99), + session('b', 'Session B', 30), + ]); + const items = await provider.provideCompletionItems(params('#session:'), CancellationToken.None); + assert.deepStrictEqual(items.map(i => i.insertText), ['#session:Session B ', '#session:Session A ']); + const first = items[0].attachment; + assert.strictEqual(first.type, MessageAttachmentKind.Simple); + assert.strictEqual(first.displayKind, 'sessionReference'); + assert.deepStrictEqual(readSessionReferenceAttachmentMeta(first._meta), { + sessionResource: AgentSession.uri(PROVIDER, 'b').toString(), + sessionID: 'b', + }); + }); + + test('does not fire until the token targets sessions', async () => { + const provider = create([session('a', 'Session A', 20)]); + assert.deepStrictEqual(await provider.provideCompletionItems(params('#fi'), CancellationToken.None), []); + assert.deepStrictEqual(await provider.provideCompletionItems(params('#'), CancellationToken.None), []); + }); + + test('fires for any non-empty prefix of the session token', async () => { + const provider = create([session('a', 'Session A', 20)]); + const forS = await provider.provideCompletionItems(params('#s'), CancellationToken.None); + const forSession = await provider.provideCompletionItems(params('#session'), CancellationToken.None); + assert.strictEqual(forS.length, 1); + assert.strictEqual(forSession.length, 1); + }); + + test('ignores sessions from a different provider', async () => { + const provider = create([session('a', 'Session A', 20)]); + const p: CompletionsParams = { kind: CompletionItemKind.UserMessage, channel: AgentSession.uri('claude', 'x').toString(), text: '#session:', offset: 9 }; + assert.deepStrictEqual(await provider.provideCompletionItems(p, CancellationToken.None), []); + }); + + test('falls back to a placeholder title for an untitled session', async () => { + const provider = create([session('a', ' ', 20)]); + const items = await provider.provideCompletionItems(params('#session:'), CancellationToken.None); + assert.strictEqual(items[0].insertText, '#session:Untitled session '); + }); + + suite('listSessions caching', () => { + test('reuses one fetch across a burst of keystrokes within the TTL', async () => { + let calls = 0; + const provider = new AgentHostSessionReferenceCompletionProvider(PROVIDER, async () => { calls++; return [session('a', 'A', 1)]; }, () => 0); + await provider.provideCompletionItems(params('#s'), CancellationToken.None); + await provider.provideCompletionItems(params('#se'), CancellationToken.None); + await provider.provideCompletionItems(params('#session'), CancellationToken.None); + assert.strictEqual(calls, 1); + }); + + test('re-fetches once the TTL has elapsed', async () => { + let calls = 0; + let now = 0; + const provider = new AgentHostSessionReferenceCompletionProvider(PROVIDER, async () => { calls++; return [session('a', 'A', 1)]; }, () => now); + await provider.provideCompletionItems(params('#s'), CancellationToken.None); + now = 3000; + await provider.provideCompletionItems(params('#s'), CancellationToken.None); + assert.strictEqual(calls, 2); + }); + + test('does not cache a rejected fetch', async () => { + let calls = 0; + const provider = new AgentHostSessionReferenceCompletionProvider(PROVIDER, async () => { calls++; throw new Error('boom'); }, () => 0); + await assert.rejects(provider.provideCompletionItems(params('#s'), CancellationToken.None)); + await assert.rejects(provider.provideCompletionItems(params('#s'), CancellationToken.None)); + assert.strictEqual(calls, 2); + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/copilotBuiltinSkills.test.ts b/src/vs/platform/agentHost/test/node/copilotBuiltinSkills.test.ts new file mode 100644 index 0000000000000..15e8bae69d852 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/copilotBuiltinSkills.test.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { TROUBLESHOOT_SKILL_NAME } from '../../common/agentHostTroubleshoot.js'; +import { getBuiltinSkillDirectories, isBuiltinSkill } from '../../node/copilot/copilotBuiltinSkills.js'; + +suite('copilotBuiltinSkills', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('exposes one skill directory per built-in skill', () => { + const dirs = getBuiltinSkillDirectories(); + assert.strictEqual(dirs.length, 1); + assert.ok(dirs[0].replace(/\\/g, '/').endsWith(`/skills/${TROUBLESHOOT_SKILL_NAME}`)); + }); + + test('recognizes built-in skill names', () => { + assert.strictEqual(isBuiltinSkill(TROUBLESHOOT_SKILL_NAME), true); + assert.strictEqual(isBuiltinSkill('not-a-builtin'), false); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts b/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts index 5f80507c5a3ea..007807168bdc7 100644 --- a/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts @@ -587,4 +587,59 @@ suite('CopilotSlashCommandCompletionProvider', () => { assert.deepStrictEqual(items.map(i => i.insertText), ['/other-skill ']); }); }); + + suite('built-in skills', () => { + const session = 'copilotcli:/abc'; + const builtins = [ + { name: 'troubleshoot', description: () => 'Investigate this session' }, + ]; + + function createProvider(runtimeCommands: readonly ICopilotRuntimeSlashCommandInfo[] = []): CopilotSlashCommandCompletionProvider { + return new CopilotSlashCommandCompletionProvider('copilotcli', { + isRubberDuckEnabled: () => true, + getRuntimeSlashCommands: async () => runtimeCommands, + getSessionCustomizations: async () => [], + getBuiltinSkills: () => builtins, + }); + } + + async function run(provider: CopilotSlashCommandCompletionProvider, text: string, offset = text.length) { + return provider.provideCompletionItems({ kind: CompletionItemKind.UserMessage, channel: session, text, offset }, CancellationToken.None); + } + + test('surfaces built-in skills for a lone slash before the session materializes', async () => { + const items = await run(createProvider(), '/'); + assert.deepStrictEqual(items.map(item => ({ insertText: item.insertText, type: item.attachment?.type, meta: item.attachment?._meta })), [ + { + insertText: '/troubleshoot ', + type: MessageAttachmentKind.Simple, + meta: { + command: 'troubleshoot', + description: 'Investigate this session', + }, + }, + ]); + }); + + test('filters built-in skills by the typed prefix', async () => { + assert.deepStrictEqual((await run(createProvider(), '/t')).map(i => i.insertText), ['/troubleshoot ']); + assert.deepStrictEqual((await run(createProvider(), '/z')).map(i => i.insertText), []); + }); + + test('surfaces built-in skills for an in-message slash token', async () => { + const items = await run(createProvider(), 'use /'); + assert.deepStrictEqual(items.map(i => i.insertText), ['/troubleshoot ']); + }); + + test('dedupes the runtime skill copy the SDK reports once materialized', async () => { + const provider = createProvider([ + { name: 'troubleshoot', description: 'Runtime troubleshoot', kind: 'skill', allowDuringAgentExecution: true }, + ]); + const items = await run(provider, '/'); + // A single entry sourced from the built-in manifest, not the runtime copy. + assert.deepStrictEqual(items.map(item => ({ insertText: item.insertText, meta: item.attachment?._meta })), [ + { insertText: '/troubleshoot ', meta: { command: 'troubleshoot', description: 'Investigate this session' } }, + ]); + }); + }); }); diff --git a/src/vs/sessions/contrib/chat/browser/agentHostInputCompletions.ts b/src/vs/sessions/contrib/chat/browser/agentHostInputCompletions.ts index 7294624ec5adf..5a100811738af 100644 --- a/src/vs/sessions/contrib/chat/browser/agentHostInputCompletions.ts +++ b/src/vs/sessions/contrib/chat/browser/agentHostInputCompletions.ts @@ -222,13 +222,7 @@ export class AgentHostInputCompletionHandler extends AgentHostInputCompletionsBa ); } - protected override _resolveContext(model: ITextModel, scheme: string): { sessionResource: URI; context: void } | undefined { - // For a `/troubleshoot` request, `#` references target sessions (served - // by the `#session` provider); suppress host-supplied completions (e.g. - // the host's `#file` list) so only sessions are offered. - if (/^\s*\/troubleshoot\b/.test(model.getValue())) { - return undefined; - } + protected override _resolveContext(_model: ITextModel, scheme: string): { sessionResource: URI; context: void } | undefined { const session = this._sessionsService.activeSession.get(); if (!session) { return undefined; @@ -292,7 +286,28 @@ export class AgentHostInputCompletionHandler extends AgentHostInputCompletionsBa }, }; } - default: { + case 'sessionReference': { + const referenceText = item.insertText.trimEnd(); + const entry = toAgentHostCompletionVariableEntry(AgentHostCompletionReferenceKind.SessionReference, referenceText, attachment.sessionResource, attachment._meta); + return { + label: { label: attachment.displayName ?? referenceText, description: attachment.description }, + insertText: item.insertText, + filterText: item.insertText, + range: replaceRange, + kind: CompletionItemKind.Text, + command: { + id: ADD_REFERENCE_COMMAND, + title: '', + arguments: [{ + handler: this, + entry, + insertText: referenceText, + range: this._toOffsetRange(replaceRange.replace, referenceText), + } satisfies IReferenceArg], + }, + }; + } + case 'resource': { const label = attachment.displayName ?? item.insertText; const description = attachment.uri.path; const kind = attachment.isDirectory ? CompletionItemKind.Folder : CompletionItemKind.File; diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index 7cb4353b18a49..3b8d6c9e54c56 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -59,7 +59,6 @@ import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultS import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { SlashCommandHandler } from './slashCommands.js'; import { VariableCompletionHandler } from './variableCompletions.js'; -import { SessionReferenceCompletionHandler } from './sessionReferenceCompletions.js'; import { AgentHostInputCompletionHandler } from './agentHostInputCompletions.js'; import { IChatModelInputState } from '../../../../workbench/contrib/chat/common/model/chatModel.js'; import { IChatRequestVariableEntry, isExplicitFileOrImageVariableEntry, toFileVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; @@ -618,11 +617,6 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation VariableCompletionHandler, this._editor, this._contextAttachments, () => this.options.getContextFolderUri(), )); - // Session reference completions (#session) - this._register(this.instantiationService.createInstance( - SessionReferenceCompletionHandler, this._editor, this._contextAttachments, - )); - this._agentHostInputCompletionHandler = this._register(this.instantiationService.createInstance( AgentHostInputCompletionHandler, this._editor, this._contextAttachments, )); diff --git a/src/vs/sessions/contrib/chat/browser/sessionReferenceCompletions.ts b/src/vs/sessions/contrib/chat/browser/sessionReferenceCompletions.ts deleted file mode 100644 index 90c4bdc3b9d4a..0000000000000 --- a/src/vs/sessions/contrib/chat/browser/sessionReferenceCompletions.ts +++ /dev/null @@ -1,197 +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 { CancellationToken } from '../../../../base/common/cancellation.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { CodeEditorWidget } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; -import { Position } from '../../../../editor/common/core/position.js'; -import { Range } from '../../../../editor/common/core/range.js'; -import { getWordAtText } from '../../../../editor/common/core/wordHelper.js'; -import { CompletionContext, CompletionItem, CompletionItemKind, CompletionList } from '../../../../editor/common/languages.js'; -import { IModelDeltaDecoration, ITextModel } from '../../../../editor/common/model.js'; -import { IEditorDecorationsCollection } from '../../../../editor/common/editorCommon.js'; -import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; -import { localize } from '../../../../nls.js'; -import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; -import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; -import { getCopilotCliSessionRawId } from '../../../../workbench/contrib/chat/browser/copilotCliEventsUri.js'; -import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; -import { ISession } from '../../../services/sessions/common/session.js'; -import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { createSessionReferenceVariableEntry } from '../../../services/sessions/browser/sessionReference.js'; -import { NewChatContextAttachments } from './newChatContextAttachments.js'; - -const VARIABLE_LEADER = '#'; -const SESSION_TOKEN = 'session'; - -/** - * Command ID run when a `#session` completion item is accepted: attaches the - * chosen session as a context reference and decorates its inline text. - */ -const ADD_SESSION_REFERENCE_COMMAND = 'sessions.chat.addSessionReference'; - -interface IReferenceArg { - readonly handler: SessionReferenceCompletionHandler; - readonly entry: IChatRequestVariableEntry; - readonly referenceText: string; -} - -CommandsRegistry.registerCommand(ADD_SESSION_REFERENCE_COMMAND, (_accessor, arg: IReferenceArg) => { - arg.handler.acceptSessionReference(arg.entry, arg.referenceText); -}); - -/** - * Provides `#session` completions in the sessions new-chat input — one item per - * Copilot CLI session (active, past, or archived). Accepting an item inserts an - * inline `#session:<title>` reference (like `#file:`) and adds the session as a - * context attachment; the sessions management service later resolves referenced - * sessions to their event-log paths for the `/troubleshoot` skill. Both Enter - * and Tab accept, since these are native editor suggestions. - */ -export class SessionReferenceCompletionHandler extends Disposable { - - private static readonly _wordPattern = /#[^\s]*/g; // MUST use g-flag - private static readonly _className = 'sessions-variable-reference'; - - private readonly _decorations: IEditorDecorationsCollection; - - /** Inline `#session:<title>` reference texts present in the editor, for decoration. */ - private readonly _referenceTexts = new Set<string>(); - - constructor( - private readonly _editor: CodeEditorWidget, - private readonly _contextAttachments: NewChatContextAttachments, - @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, - @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, - @ISessionsService private readonly sessionsService: ISessionsService, - ) { - super(); - this._decorations = this._editor.createDecorationsCollection(); - this._registerSessionCompletions(); - this._registerDecorations(); - } - - private _registerSessionCompletions(): void { - const uri = this._editor.getModel()?.uri; - if (!uri) { - return; - } - - this._register(this.languageFeaturesService.completionProvider.register({ scheme: uri.scheme, hasAccessToAllModels: true }, { - _debugDisplayName: 'sessionsVariableSession', - triggerCharacters: [VARIABLE_LEADER], - provideCompletionItems: (model: ITextModel, position: Position, _context: CompletionContext, _token: CancellationToken): CompletionList | null => { - const varWord = getWordAtText(position.column, SessionReferenceCompletionHandler._wordPattern, model.getLineContent(position.lineNumber), 0); - if (!varWord || !varWord.word.startsWith(VARIABLE_LEADER)) { - return null; - } - - // Participate while the typed token could still become `#session` - // (empty, a prefix of `session`, or already past it). Bail only when - // it definitely can't, so we don't fight unrelated `#` providers. - const typed = varWord.word.slice(VARIABLE_LEADER.length).toLowerCase(); - if (typed.length > 0 && !SESSION_TOKEN.startsWith(typed) && !typed.startsWith(SESSION_TOKEN)) { - return null; - } - const replace = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, varWord.endColumn); - const insert = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, position.column); - - // Keep `incomplete` so the editor re-queries as the user types towards - // `#session`; a sibling provider returning a complete list (e.g. the - // host's `#` file completions) would otherwise suppress re-querying. A - // bare `#` yields no items yet (e.g. in `/troubleshoot #`). - const suggestions = typed.length === 0 ? [] : this._collectSessionItems({ insert, replace }); - return { suggestions, incomplete: true }; - } - })); - } - - /** Attaches the chosen session and decorates its inline reference text. */ - acceptSessionReference(entry: IChatRequestVariableEntry, referenceText: string): void { - this._contextAttachments.addAttachments(entry); - this._referenceTexts.add(referenceText); - this._updateDecorations(); - } - - private _collectSessionItems(range: { insert: Range; replace: Range }): CompletionItem[] { - const activeResource = this.sessionsService.activeSession.get()?.resource; - const activeResourceStr = activeResource?.toString(); - - // Only Copilot CLI sessions have a readable event log; include active, - // past, and archived. Resolve the id once, then newest first. - const sessions = this.sessionsManagementService.getSessions() - .map(session => ({ session, rawId: getCopilotCliSessionRawId(session.resource) })) - .filter((entry): entry is { session: ISession; rawId: string } => entry.rawId !== undefined) - .filter(entry => { - // Scope to the active session's host: each host has a distinct - // resource scheme (local `agent-host-copilotcli` vs remote - // `remote-<authority>-copilotcli`), so a session whose events.jsonl - // lives on another machine — and thus can't be read by the skill - // running on the active host — is excluded. When there is no active - // session we can't determine the host, so don't filter. - return !activeResource || entry.session.resource.scheme === activeResource.scheme; - }) - .sort((a, b) => b.session.updatedAt.get().getTime() - a.session.updatedAt.get().getTime()); - - return sessions.map(({ session, rawId }, index) => { - const title = session.title.get() || localize('untitledSession', "Untitled session"); - // Collapse whitespace so the inline `#session:<title>` reference (and its - // decoration) stays on one line even if the title contains newlines. - const referenceTitle = title.replace(/\s+/g, ' ').trim() || localize('untitledSession', "Untitled session"); - const isActive = activeResourceStr === session.resource.toString(); - const date = session.updatedAt.get().toLocaleString(); - const description = isActive ? localize('currentSessionLabel', "{0} (current)", date) : date; - const referenceText = `${VARIABLE_LEADER}${SESSION_TOKEN}:${referenceTitle}`; - const entry = createSessionReferenceVariableEntry(rawId, referenceTitle, session.resource); - return { - label: { label: referenceTitle, description }, - // Include the leading `#` so the typed `#session` word matches - // (Monaco filters against the word including the trigger char). - filterText: `${VARIABLE_LEADER}${SESSION_TOKEN} ${referenceTitle}`, - // Insert the inline reference, replacing the typed `#session…` token. - insertText: `${referenceText} `, - range, - kind: CompletionItemKind.Reference, - sortText: String(index).padStart(4, '0'), - command: { - id: ADD_SESSION_REFERENCE_COMMAND, - title: '', - arguments: [{ handler: this, entry, referenceText } satisfies IReferenceArg], - }, - } satisfies CompletionItem; - }); - } - - // --- Decorations --- - - private _registerDecorations(): void { - this._register(this._editor.onDidChangeModelContent(() => this._updateDecorations())); - this._updateDecorations(); - } - - private _updateDecorations(): void { - const model = this._editor.getModel(); - if (!model || this._referenceTexts.size === 0) { - this._decorations.set([]); - return; - } - - const value = model.getValue(); - const decos: IModelDeltaDecoration[] = []; - for (const referenceText of this._referenceTexts) { - let index = value.indexOf(referenceText); - while (index !== -1) { - const startPos = model.getPositionAt(index); - const endPos = model.getPositionAt(index + referenceText.length); - decos.push({ - range: Range.fromPositions(startPos, endPos), - options: { description: 'sessions-session-reference', inlineClassName: SessionReferenceCompletionHandler._className }, - }); - index = value.indexOf(referenceText, index + referenceText.length); - } - } - this._decorations.set(decos); - } -} diff --git a/src/vs/sessions/services/sessions/browser/sessionReference.ts b/src/vs/sessions/services/sessions/browser/sessionReference.ts deleted file mode 100644 index c04cef5828669..0000000000000 --- a/src/vs/sessions/services/sessions/browser/sessionReference.ts +++ /dev/null @@ -1,65 +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 { URI } from '../../../../base/common/uri.js'; -import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; - -/** - * Marker value carried by a session-reference attachment. Used so the - * `#session` completion can attach a chosen session as a context pill, and the - * sessions management service can later resolve it back to a session resource - * (e.g. to inject the session's event-log path for `/troubleshoot`). - * - * The value is intentionally NOT a {@link URI} or `Location` so the attachment - * renders with its display title rather than the session resource's basename. - */ -interface ISessionReferenceVariableValue { - readonly sessionReference: true; - /** The referenced session's resource URI, serialized via {@link URI.toString}. */ - readonly sessionResource: string; -} - -/** - * Whether the given attachment is a `#session` reference produced by - * {@link createSessionReferenceVariableEntry}. - */ -export function isSessionReferenceVariableEntry(entry: IChatRequestVariableEntry): boolean { - const value = entry.value; - return !!value - && typeof value === 'object' - && (value as ISessionReferenceVariableValue).sessionReference === true - && typeof (value as ISessionReferenceVariableValue).sessionResource === 'string'; -} - -/** - * Resolves the referenced session's resource URI from a session-reference - * attachment, or `undefined` if the entry is not a session reference. - */ -export function getSessionReferenceResource(entry: IChatRequestVariableEntry): URI | undefined { - if (!isSessionReferenceVariableEntry(entry)) { - return undefined; - } - try { - return URI.parse((entry.value as ISessionReferenceVariableValue).sessionResource); - } catch { - return undefined; - } -} - -/** - * Builds a context attachment that references another session by its resource. - * - * @param rawId The session's raw id, used to form a stable attachment id. - * @param name The display title shown on the attachment pill. - * @param sessionResource The referenced session's resource URI. - */ -export function createSessionReferenceVariableEntry(rawId: string, name: string, sessionResource: URI): IChatRequestVariableEntry { - return { - kind: 'generic', - id: `session:${rawId}`, - name, - value: { sessionReference: true, sessionResource: sessionResource.toString() } satisfies ISessionReferenceVariableValue, - }; -} diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index ad0808b5091a8..93342fd6b6f9a 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -11,16 +11,10 @@ import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../. import { IObservable, observableValue } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { agentHostAuthority } from '../../../../platform/agentHost/common/agentHostUri.js'; -import { IRemoteAgentHostService } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { ChatAgentLocation } from '../../../../workbench/contrib/chat/common/constants.js'; import { IChatWidgetHistoryService } from '../../../../workbench/contrib/chat/common/widget/chatWidgetHistoryService.js'; -import { buildHostLocalEventsPath, getCopilotCliSessionRawId } from '../../../../workbench/contrib/chat/browser/copilotCliEventsUri.js'; -import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; -import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; -import { getSessionReferenceResource } from './sessionReference.js'; import { ICreateNewChatInSessionOptions, ICreateNewSessionOptions, IProviderSessionType, ISendRequestOptions, ISendRequestSentEvent, ISessionsChangeEvent, ISessionsManagementService } from '../common/sessionsManagement.js'; import { ISessionsProvidersChangeEvent, ISessionsProvidersService } from './sessionsProvidersService.js'; import { IDeleteChatOptions, ISessionChangeEvent, ISessionsProvider } from '../common/sessionsProvider.js'; @@ -91,8 +85,6 @@ export class SessionsManagementService extends Disposable implements ISessionsMa @IChatService private readonly chatService: IChatService, @IChatWidgetHistoryService private readonly chatWidgetHistoryService: IChatWidgetHistoryService, @IStorageService private readonly storageService: IStorageService, - @IPathService private readonly pathService: IPathService, - @IRemoteAgentHostService private readonly remoteAgentHostService: IRemoteAgentHostService, ) { super(); @@ -453,63 +445,6 @@ export class SessionsManagementService extends Disposable implements ISessionsMa return provider.forkChat(session.sessionId, sourceChat, turnId); } - /** - * For a `/troubleshoot` request, strip any `#session` marker attachments and - * append a `Session log:` line with the resolved host-local `events.jsonl` - * path(s) — the referenced sessions if present, otherwise the current one. - * Returns `options` unchanged when there is nothing to do. - */ - private _augmentOptionsForTroubleshoot(session: ISession, options: ISendRequestOptions): ISendRequestOptions { - // Separate any `#session` reference attachments from the real context. - const referencedResources: URI[] = []; - let remainingAttachments: IChatRequestVariableEntry[] | undefined; - if (options.attachedContext?.length) { - const remaining: IChatRequestVariableEntry[] = []; - for (const entry of options.attachedContext) { - const referenced = getSessionReferenceResource(entry); - if (referenced) { - referencedResources.push(referenced); - } else { - remaining.push(entry); - } - } - if (referencedResources.length) { - remainingAttachments = remaining; - } - } - - const isTroubleshoot = /^\s*\/troubleshoot\b/.test(options.query); - if (!isTroubleshoot && referencedResources.length === 0) { - return options; - } - - // Drop the reference attachments (only meaningful to us, not the model). - let result = options; - if (remainingAttachments) { - result = { ...result, attachedContext: remainingAttachments.length ? remainingAttachments : undefined }; - } - if (!isTroubleshoot) { - return result; - } - - // Resolve the target session(s): referenced ones if present, else the - // current session. - const targets = referencedResources.length - ? referencedResources - : (getCopilotCliSessionRawId(session.resource) ? [session.resource] : []); - const userHome = this.pathService.userHome({ preferLocal: true }); - const getConnection = (authority: string) => this.remoteAgentHostService.connections.find(c => agentHostAuthority(c.address) === authority); - const eventPaths = Array.from(new Set( - targets - .map(resource => buildHostLocalEventsPath(resource, userHome, getConnection)) - .filter((path): path is string => !!path) - )); - if (eventPaths.length === 0) { - return result; - } - return { ...result, query: `${result.query}\n\nSession log: ${eventPaths.join(', ')}` }; - } - async sendNewChatRequest(session: ISession, options: ISendRequestOptions): Promise<void> { // The session is graduating into the list (being sent), // so the provider keeps owning it — just drop the pointer, do not delete. @@ -544,12 +479,11 @@ export class SessionsManagementService extends Disposable implements ISessionsMa // Ask the provider to create the new chat, then send the request. const chat = await provider.createNewChat(session.sessionId, options.query); - const sendOptions = this._augmentOptionsForTroubleshoot(session, options); const chatResourceKey = chat.resource.toString(); this._pendingSendChatResources.add(chatResourceKey); let updatedSession: ISession; try { - updatedSession = await provider.sendRequest(session.sessionId, chat.resource, sendOptions); + updatedSession = await provider.sendRequest(session.sessionId, chat.resource, options); } finally { this._pendingSendChatResources.delete(chatResourceKey); } @@ -641,12 +575,11 @@ export class SessionsManagementService extends Disposable implements ISessionsMa // Suppress the `chatService.onDidSubmitRequest` mirror for this send so // `_onDidSendRequest` is not fired twice for providers that dispatch // through `chatService.sendRequest` (see the mirror in the constructor). - const sendOptions = this._augmentOptionsForTroubleshoot(session, options); const chatResourceKey = chat.resource.toString(); this._pendingSendChatResources.add(chatResourceKey); let updatedSession: ISession; try { - updatedSession = await provider.sendRequest(session.sessionId, chat.resource, sendOptions); + updatedSession = await provider.sendRequest(session.sessionId, chat.resource, options); } finally { this._pendingSendChatResources.delete(chatResourceKey); } @@ -684,12 +617,11 @@ export class SessionsManagementService extends Disposable implements ISessionsMa // send pair to keep the sent chat active in the visible slot. this._onWillSendRequest.fire(session); - const sendOptions = this._augmentOptionsForTroubleshoot(session, options); const chatResourceKey = chat.resource.toString(); this._pendingSendChatResources.add(chatResourceKey); let updatedSession: ISession; try { - updatedSession = await provider.sendRequest(session.sessionId, chat.resource, sendOptions); + updatedSession = await provider.sendRequest(session.sessionId, chat.resource, options); } finally { this._pendingSendChatResources.delete(chatResourceKey); } @@ -708,12 +640,11 @@ export class SessionsManagementService extends Disposable implements ISessionsMa * visible slot into the sent chat. Errors are propagated to the caller. */ private async _sendRequestInBackground(provider: ISessionsProvider, session: ISession, chat: IChat, options: ISendRequestOptions): Promise<void> { - const sendOptions = this._augmentOptionsForTroubleshoot(session, options); const chatResourceKey = chat.resource.toString(); this._pendingSendChatResources.add(chatResourceKey); let updatedSession: ISession; try { - updatedSession = await provider.sendRequest(session.sessionId, chat.resource, sendOptions); + updatedSession = await provider.sendRequest(session.sessionId, chat.resource, options); } finally { this._pendingSendChatResources.delete(chatResourceKey); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index f1c525b91f2d8..ea9dc9313f2e5 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -881,6 +881,15 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC ...(attachment._meta !== undefined && { _meta: attachment._meta }), }); } + if (completionMeta?.kind === 'sessionReference') { + return this._createCompletionItem(raw, text, { + kind: 'sessionReference', + sessionResource: URI.parse(completionMeta.sessionResource), + ...(attachment.label !== undefined ? { displayName: attachment.label } : {}), + ...(completionMeta.date !== undefined ? { description: completionMeta.date } : {}), + ...(attachment._meta !== undefined && { _meta: attachment._meta }), + }); + } return undefined; } case MessageAttachmentKind.Resource: { @@ -4404,6 +4413,12 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (agentHostCompletionKind === AgentHostCompletionReferenceKind.Skill) { return this._toSimpleAttachment(v.name, undefined, v._meta, 'skill', referenceRange); } + if (agentHostCompletionKind === AgentHostCompletionReferenceKind.SessionReference) { + // A harness-driven `#session` completion. The `_meta` carries the + // referenced session's resource; the harness resolves it (e.g. to + // its event log for `/troubleshoot`) at send time. + return this._toSimpleAttachment(v.name, undefined, v._meta, AgentHostSessionReferenceAttachmentDisplayKind, referenceRange); + } return undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/agentHostInputCompletions.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/agentHostInputCompletions.ts index 8a268d479faab..06273bff6a56a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/agentHostInputCompletions.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/agentHostInputCompletions.ts @@ -154,7 +154,21 @@ export class AgentHostInputCompletions extends AgentHostInputCompletionsBase<ICh }, }; } - default: { + case 'sessionReference': { + return { + label: { label: attachment.displayName ?? item.insertText.trimEnd(), description: attachment.description }, + insertText: item.insertText, + filterText: item.insertText, + range: replaceRange, + kind: CompletionItemKind.Text, + command: { + id: AgentHostInputCompletions.addReferenceCommand, + title: '', + arguments: [AgentHostReferenceArgument.forSessionReference(widget, attachment.sessionResource, attachment.displayName, AgentHostInputCompletions._insertedTokenRange(replaceRange, item.insertText), attachment._meta)], + }, + }; + } + case 'resource': { const label = attachment.displayName ?? item.insertText; const description = attachment.uri.path; return { @@ -207,6 +221,11 @@ class AgentHostReferenceArgument { const entry = toAgentHostCompletionVariableEntry(AgentHostCompletionReferenceKind.Command, description ?? command, command, _meta); return new AgentHostReferenceArgument(widget, entry.id, entry.value, description, false, false, range, _meta); } + + static forSessionReference(widget: IChatWidget, sessionResource: URI, displayName: string | undefined, range: Range, _meta: Record<string, unknown> | undefined): AgentHostReferenceArgument { + const entry = toAgentHostCompletionVariableEntry(AgentHostCompletionReferenceKind.SessionReference, displayName ?? sessionResource.toString(), sessionResource, _meta); + return new AgentHostReferenceArgument(widget, entry.id, entry.value, displayName, false, false, range, _meta); + } } Registry.as<IWorkbenchContributionsRegistry>(WorkbenchExtensions.Workbench).registerWorkbenchContribution(AgentHostInputCompletions, LifecyclePhase.Eventually); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/agentHostInputCompletionsBase.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/agentHostInputCompletionsBase.ts index bf9e06cf0da03..70795c663e2be 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/agentHostInputCompletionsBase.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/agentHostInputCompletionsBase.ts @@ -13,7 +13,8 @@ import { ITextModel } from '../../../../../../../editor/common/model.js'; import { LanguageFilter } from '../../../../../../../editor/common/languageSelector.js'; import { ILanguageFeaturesService } from '../../../../../../../editor/common/services/languageFeatures.js'; import { IChatInputCompletionItem, IChatSessionsService } from '../../../../common/chatSessionsService.js'; -import { isAtTriggerCharacterToken } from './chatInputCompletionUtils.js'; +import { chatVariableLeader } from '../../../../common/requestParser/chatParserTypes.js'; +import { getTriggerCharacterAtToken } from './chatInputCompletionUtils.js'; /** * Shared plumbing for Monaco completion providers that delegate to an @@ -82,7 +83,8 @@ export abstract class AgentHostInputCompletionsBase<TContext, TRegData = void> e // this gate Monaco re-invokes the provider on every keystroke // (for filtering / incomplete-result refresh), which would // produce an RPC round-trip per character. - if (!isAtTriggerCharacterToken(model, position, triggerCharacters)) { + const triggerChar = getTriggerCharacterAtToken(model, position, triggerCharacters); + if (triggerChar === undefined) { return null; } @@ -102,7 +104,16 @@ export abstract class AgentHostInputCompletionsBase<TContext, TRegData = void> e for (const item of result.items) { suggestions.push(this._buildItem(position, item, ctx.context)); } - return { suggestions }; + // `#` references are token-dependent in a way client-side filtering can't + // reproduce — e.g. `#session:` yields no items until `session` is typed, + // and file matches change per keystroke — so mark the list incomplete to + // re-query the host as the user types. `/` slash commands and `@` mentions + // return a stable list Monaco can filter, and marking those incomplete + // keeps the suggest widget aggressively open (breaking Enter-to-submit), + // so only `#` is incomplete. The trigger-token gate above bounds any + // re-query to while the cursor is inside a trigger-led token. + const incomplete = triggerChar === chatVariableLeader; + return { suggestions, incomplete }; } /** diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletionUtils.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletionUtils.ts index e26a2f7eb9dd5..03ac8b2a6cbad 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletionUtils.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletionUtils.ts @@ -64,8 +64,17 @@ export function isEmptyUpToCompletionWord(model: ITextModel, rangeResult: IChatC * when the user is actively editing a trigger-led token. */ export function isAtTriggerCharacterToken(model: ITextModel, position: Position, triggerCharacters: readonly string[]): boolean { + return getTriggerCharacterAtToken(model, position, triggerCharacters) !== undefined; +} + +/** + * Returns the trigger character leading the token at `position` (the run from + * the last whitespace, or start-of-line, up to the cursor) when that token + * starts with one of `triggerCharacters`; otherwise `undefined`. + */ +export function getTriggerCharacterAtToken(model: ITextModel, position: Position, triggerCharacters: readonly string[]): string | undefined { if (triggerCharacters.length === 0) { - return false; + return undefined; } const line = model.getLineContent(position.lineNumber); const beforeCursor = line.slice(0, position.column - 1); @@ -74,7 +83,7 @@ export function isAtTriggerCharacterToken(model: ITextModel, position: Position, const wsIdx = beforeCursor.search(/\s\S*$/); const token = wsIdx >= 0 ? beforeCursor.slice(wsIdx + 1) : beforeCursor; if (token.length === 0) { - return false; + return undefined; } - return triggerCharacters.includes(token[0]); + return triggerCharacters.includes(token[0]) ? token[0] : undefined; } diff --git a/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts b/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts index 87a78984c21a6..138b7e5c421df 100644 --- a/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts +++ b/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts @@ -14,6 +14,7 @@ import { IOffsetRange } from '../../../../../editor/common/core/ranges/offsetRan import { isLocation, Location, SymbolKind } from '../../../../../editor/common/languages.js'; import { localize } from '../../../../../nls.js'; import { MarkerSeverity, IMarker } from '../../../../../platform/markers/common/markers.js'; +import { readSessionReferenceAttachmentMeta } from '../../../../../platform/agentHost/common/meta/agentSessionReferenceMeta.js'; import { ISCMHistoryItem } from '../../../scm/common/history.js'; import { IChatContentReference } from '../chatService/chatService.js'; import { IChatRequestVariableValue } from './chatVariables.js'; @@ -112,6 +113,7 @@ export interface IRestorablePasteAttachment { export const enum AgentHostCompletionReferenceKind { Skill = 'skill', Command = 'command', + SessionReference = 'sessionReference', } export interface IAgentHostCompletionVariableValue { @@ -129,6 +131,8 @@ function agentHostCompletionVariableId(kind: AgentHostCompletionReferenceKind, r return reference.toString(); case AgentHostCompletionReferenceKind.Command: return 'agent-host-command:' + reference.toString(); + case AgentHostCompletionReferenceKind.SessionReference: + return 'agent-host-session:' + reference.toString(); } } @@ -148,6 +152,8 @@ export function toAgentHostCompletionVariableEntryFromMetadata(kind: AgentHostCo return toAgentHostCompletionVariableEntry(kind, name, typeof _meta?.uri === 'string' ? _meta.uri : undefined, _meta); case AgentHostCompletionReferenceKind.Command: return toAgentHostCompletionVariableEntry(kind, name, typeof _meta?.command === 'string' ? _meta.command : undefined, _meta); + case AgentHostCompletionReferenceKind.SessionReference: + return toAgentHostCompletionVariableEntry(kind, name, readSessionReferenceAttachmentMeta(_meta)?.sessionResource, _meta); } } @@ -171,6 +177,7 @@ export function getAgentHostCompletionReferenceKindFromValue(value: IChatRequest switch (record.kind) { case AgentHostCompletionReferenceKind.Skill: case AgentHostCompletionReferenceKind.Command: + case AgentHostCompletionReferenceKind.SessionReference: return record.kind; } return undefined; diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 8ea6f0577a29e..85669615c6a9c 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -486,7 +486,7 @@ export interface IChatInputCompletionItem { readonly start?: IPosition; readonly end?: IPosition; /** Attachment associated with the item. */ - readonly attachment: IChatInputCompletionResourceAttachment | IChatInputCompletionCommandAttachment | IChatInputCompletionSkillAttachment; + readonly attachment: IChatInputCompletionResourceAttachment | IChatInputCompletionCommandAttachment | IChatInputCompletionSkillAttachment | IChatInputCompletionSessionReferenceAttachment; } /** @@ -538,6 +538,26 @@ export interface IChatInputCompletionSkillAttachment { readonly _meta?: Record<string, unknown>; } +/** + * Session-reference attachment associated with a completion item — a + * `#session` reference to another agent session. The workbench adds it to the + * input's variable model when the item is accepted; the harness later resolves + * it (e.g. to the session's event log for `/troubleshoot`). + */ +export interface IChatInputCompletionSessionReferenceAttachment { + readonly kind: 'sessionReference'; + readonly sessionResource: URI; + readonly displayName?: string; + /** Human-readable description (e.g. the session's date) shown in the completion. */ + readonly description?: string; + /** + * Implementation-defined metadata that MUST be preserved by the + * workbench when the accepted completion is sent back as part of a + * user message attachment. + */ + readonly _meta?: Record<string, unknown>; +} + /** * Result of {@link IChatSessionContentProvider.provideChatInputCompletions}. */ From 4bb7b551220c7d0a3664e8b3812a89ca1b3a9d62 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:10:52 -0700 Subject: [PATCH 2/5] Feedback updates --- build/gulpfile.reh.ts | 4 ++ build/gulpfile.vscode.ts | 3 + build/next/index.ts | 7 ++ .../agentHost/common/agentHostTroubleshoot.ts | 25 +++++-- .../node/agentHostCompletionTokens.ts | 65 +++++++++++++++++++ .../node/agentHostFileCompletionProvider.ts | 45 +------------ ...tHostSessionReferenceCompletionProvider.ts | 16 +---- .../node/copilot/copilotAgentSession.ts | 9 +++ .../copilotSlashCommandCompletionProvider.ts | 50 +++++++------- .../test/common/agentHostTroubleshoot.test.ts | 24 +++++-- .../agentHostFileCompletionProvider.test.ts | 3 +- ...SessionReferenceCompletionProvider.test.ts | 9 +++ .../agentHost/test/node/copilotAgent.test.ts | 7 +- ...ilotSlashCommandCompletionProvider.test.ts | 7 +- 14 files changed, 174 insertions(+), 100 deletions(-) create mode 100644 src/vs/platform/agentHost/node/agentHostCompletionTokens.ts diff --git a/build/gulpfile.reh.ts b/build/gulpfile.reh.ts index a40ff6f6db1eb..ddd345f47e6ad 100644 --- a/build/gulpfile.reh.ts +++ b/build/gulpfile.reh.ts @@ -82,6 +82,10 @@ const serverResourceIncludes = [ 'out-build/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish', 'out-build/vs/workbench/contrib/terminal/common/scripts/psreadline/**', + // Agent host built-in skills (bundled, harness-owned) - the agent host runs + // on the server for remote sessions, so the SKILL.md files must ship here too. + 'out-build/vs/platform/agentHost/node/copilot/skills/**/SKILL.md', + ]; const serverResourceExcludes = [ diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index f3669ff6c17ce..58fc9feedb371 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -104,6 +104,9 @@ const vscodeResourceIncludes = [ 'out-build/vs/sessions/prompts/*.prompt.md', 'out-build/vs/sessions/skills/**/SKILL.md', + // Agent host built-in skills (bundled, harness-owned) + 'out-build/vs/platform/agentHost/node/copilot/skills/**/SKILL.md', + // Extensions 'out-build/vs/workbench/contrib/extensions/browser/media/{theme-icon.png,language-icon.svg}', 'out-build/vs/workbench/services/extensionManagement/common/media/*.{svg,png}', diff --git a/build/next/index.ts b/build/next/index.ts index 7e0ef886f2148..5327190756c31 100644 --- a/build/next/index.ts +++ b/build/next/index.ts @@ -296,6 +296,9 @@ const desktopResourcePatterns = [ // Sessions - built-in prompts and skills 'vs/sessions/prompts/*.prompt.md', 'vs/sessions/skills/**/SKILL.md', + + // Agent host built-in skills (bundled, harness-owned) + 'vs/platform/agentHost/node/copilot/skills/**/SKILL.md', ]; // Resources for server target (minimal - no UI) @@ -323,6 +326,10 @@ const serverResourcePatterns = [ 'vs/workbench/contrib/terminal/common/scripts/psreadline/*.ps1xml', 'vs/workbench/contrib/terminal/common/scripts/psreadline/net6plus/*.dll', 'vs/workbench/contrib/terminal/common/scripts/psreadline/netstd/*.dll', + + // Agent host built-in skills (bundled, harness-owned) - the agent host runs + // on the server for remote sessions, so the SKILL.md files must ship here too. + 'vs/platform/agentHost/node/copilot/skills/**/SKILL.md', ]; // Resources for server-web target (server + web UI) diff --git a/src/vs/platform/agentHost/common/agentHostTroubleshoot.ts b/src/vs/platform/agentHost/common/agentHostTroubleshoot.ts index dec6836ab0e5d..685052453ca42 100644 --- a/src/vs/platform/agentHost/common/agentHostTroubleshoot.ts +++ b/src/vs/platform/agentHost/common/agentHostTroubleshoot.ts @@ -39,9 +39,11 @@ export interface ITroubleshootRequest { * its marker attachments are dropped, consumed here rather than forwarded). A * bare `/troubleshoot` injects no path, so the skill continues the session * already established earlier in the conversation (its sticky-reference rule), - * or self-discovers the current session on a first call. The user's free text - * is forwarded as context only for a bare request, since with a reference the - * trailing text is the marker title rather than an instruction. + * or self-discovers the current session on a first call. The user's genuine + * free text is always forwarded as context, with the inserted `#session:<title>` + * marker text stripped out so only a real question (if any) survives - e.g. + * `/troubleshoot #session:Build why was the test skipped?` keeps "why was the + * test skipped?". * * Harness-agnostic: each harness supplies `resolveSessionLogPath`, mapping a raw * session id to that harness's on-disk log (Copilot CLI's `events.jsonl`, @@ -54,6 +56,7 @@ export function augmentTroubleshootRequest( ): ITroubleshootRequest { const referencedIds: string[] = []; const remaining: MessageAttachment[] = []; + const markerLabels: string[] = []; for (const attachment of attachments ?? []) { const meta = readSessionReferenceAttachmentMeta(attachment._meta); if (meta) { @@ -65,6 +68,9 @@ export function augmentTroubleshootRequest( } catch { // Ignore an unparseable reference. } + // Remember the inserted marker text (`#session:<label>`) so it can be + // stripped from the user's free text below. + markerLabels.push(attachment.label); } else { remaining.push(attachment); } @@ -80,10 +86,15 @@ export function augmentTroubleshootRequest( parts.push(`Session log: ${logPaths.join(', ')}`); } } - // Forward the user's free text only when it is a genuine instruction. With a - // `#session` reference the trailing text is the reference title, so forwarding - // it would just re-ask the title. - const instructions = referencedIds.length ? '' : userInstructions.trim(); + // Forward the user's genuine free text, stripping the inserted + // `#session:<title>` marker(s) - their target is conveyed via the log path + // above, so what remains is any real question the user typed alongside the + // reference (e.g. "#session:Build why was the test skipped?"). + let instructions = userInstructions; + for (const label of markerLabels) { + instructions = instructions.replace(`#session:${label}`, ' '); + } + instructions = instructions.trim(); if (instructions) { parts.push(`Additional context from the user:\n${instructions}`); } diff --git a/src/vs/platform/agentHost/node/agentHostCompletionTokens.ts b/src/vs/platform/agentHost/node/agentHostCompletionTokens.ts new file mode 100644 index 0000000000000..b74aaccf4f7e6 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostCompletionTokens.ts @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CompletionTriggerCharacter } from './agentHostCompletions.js'; + +/** Token the user types after `#` to reference another session. */ +export const SESSION_TOKEN = 'session'; + +/** + * Result of {@link extractAtToken}. + */ +export interface IAtToken { + readonly token: string; + readonly triggerChar: string; + readonly rangeStart: number; + readonly rangeEnd: number; +} + +/** + * Walk back from `offset` to find the most recent `@`/`#` that is preceded by + * whitespace (or start-of-string) and not interrupted by whitespace. Returns + * the substring after the trigger together with the range to replace, or + * `undefined` if no such token is being typed at `offset`. + * + * Shared by the file- and session-reference completion providers (and exported + * for unit testing) so it lives in a neutral module rather than either provider. + */ +export function extractAtToken(text: string, offset: number): IAtToken | undefined { + if (offset < 0 || offset > text.length) { + return undefined; + } + for (let i = offset - 1; i >= 0; i--) { + const ch = text.charCodeAt(i); + // whitespace terminates the search + if (ch === 0x20 /* space */ || ch === 0x09 /* tab */ || ch === 0x0a /* \n */ || ch === 0x0d /* \r */) { + return undefined; + } + if (text[i] === CompletionTriggerCharacter.File || text[i] === CompletionTriggerCharacter.Hash) { + // The trigger character must be at start-of-input or preceded by whitespace. + if (i > 0) { + const prev = text.charCodeAt(i - 1); + const prevIsWs = prev === 0x20 || prev === 0x09 || prev === 0x0a || prev === 0x0d; + if (!prevIsWs) { + return undefined; + } + } + return { token: text.slice(i + 1, offset), triggerChar: text[i], rangeStart: i, rangeEnd: offset }; + } + } + return undefined; +} + +/** + * Whether a `#`-token is heading toward a `#session` reference - a non-empty + * prefix of `session` (so it matches as soon as `#s` is typed) or the completed + * `session:<filter>` form. Deliberately does NOT match tokens that merely start + * with `session` (e.g. `#sessions`, `#sessionManager`), so the file-reference + * provider only cedes genuine session references. Shared so both providers agree. + */ +export function isSessionReferenceToken(token: string): boolean { + const typed = token.toLowerCase(); + return typed.length > 0 && (SESSION_TOKEN.startsWith(typed) || typed.startsWith(`${SESSION_TOKEN}:`)); +} diff --git a/src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts b/src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts index 1306e3172d2ad..e731f33243b0c 100644 --- a/src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts @@ -12,56 +12,13 @@ import { URI } from '../../../base/common/uri.js'; import { CompletionItem, CompletionItemKind, CompletionsParams } from '../common/state/protocol/commands.js'; import { MessageAttachmentKind } from '../common/state/protocol/state.js'; import { CompletionTriggerCharacter, IAgentHostCompletionItemProvider } from './agentHostCompletions.js'; -import { isSessionReferenceToken } from './agentHostSessionReferenceCompletionProvider.js'; +import { extractAtToken, isSessionReferenceToken } from './agentHostCompletionTokens.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; import { AgentHostWorkspaceFiles } from './agentHostWorkspaceFiles.js'; /** Maximum number of completion items returned per call. */ const MAX_RESULTS = 50; -/** - * Result of {@link extractAtToken}. - */ -interface IAtToken { - readonly token: string; - readonly triggerChar: string; - readonly rangeStart: number; - readonly rangeEnd: number; -} - -/** - * Walk back from `offset` to find the most recent `@` that is preceded by - * whitespace (or start-of-string) and not interrupted by whitespace. Returns - * the substring after `@` together with the range to replace, or `undefined` - * if no `@`-token is being typed at `offset`. - * - * Exported for unit testing. - */ -export function extractAtToken(text: string, offset: number): IAtToken | undefined { - if (offset < 0 || offset > text.length) { - return undefined; - } - for (let i = offset - 1; i >= 0; i--) { - const ch = text.charCodeAt(i); - // whitespace terminates the search - if (ch === 0x20 /* space */ || ch === 0x09 /* tab */ || ch === 0x0a /* \n */ || ch === 0x0d /* \r */) { - return undefined; - } - if (text[i] === CompletionTriggerCharacter.File || text[i] === CompletionTriggerCharacter.Hash) { - // The trigger character must be at start-of-input or preceded by whitespace. - if (i > 0) { - const prev = text.charCodeAt(i - 1); - const prevIsWs = prev === 0x20 || prev === 0x09 || prev === 0x0a || prev === 0x0d; - if (!prevIsWs) { - return undefined; - } - } - return { token: text.slice(i + 1, offset), triggerChar: text[i], rangeStart: i, rangeEnd: offset }; - } - } - return undefined; -} - /** * Item-accessor that exposes a {@link URI} as basename / parent-directory / * relative path for the {@link scoreItemFuzzy} family. diff --git a/src/vs/platform/agentHost/node/agentHostSessionReferenceCompletionProvider.ts b/src/vs/platform/agentHost/node/agentHostSessionReferenceCompletionProvider.ts index a31885947e703..2c327bd216ac0 100644 --- a/src/vs/platform/agentHost/node/agentHostSessionReferenceCompletionProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostSessionReferenceCompletionProvider.ts @@ -10,21 +10,7 @@ import { SESSION_REFERENCE_DISPLAY_DATE_META_KEY } from '../common/meta/agentCom import { CompletionItem, CompletionItemKind, CompletionsParams } from '../common/state/protocol/commands.js'; import { MessageAttachmentKind } from '../common/state/protocol/state.js'; import { CompletionTriggerCharacter, IAgentHostCompletionItemProvider } from './agentHostCompletions.js'; -import { extractAtToken } from './agentHostFileCompletionProvider.js'; - -/** Token the user types after `#` to reference another session. */ -const SESSION_TOKEN = 'session'; - -/** - * Whether a `#`-token is heading toward a `#session` reference — any non-empty - * prefix of `session` (so it matches as soon as `#s` is typed) or the full - * `#session:<filter>`. Shared with the file-reference provider so it can cede - * these tokens (and not mix file matches into the session list). - */ -export function isSessionReferenceToken(token: string): boolean { - const typed = token.toLowerCase(); - return typed.length > 0 && (SESSION_TOKEN.startsWith(typed) || typed.startsWith(SESSION_TOKEN)); -} +import { extractAtToken, isSessionReferenceToken, SESSION_TOKEN } from './agentHostCompletionTokens.js'; /** * Provides `#session` completions for a harness — one item per other session of diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 7b1ab2bdcd176..83a865ae196a3 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -296,6 +296,15 @@ function elicitationAnswerToFieldValue(field: ElicitationSchemaField, answer: Ch } function getCopilotCLISessionStateDir(userHome: string): string { + // The CLI stores per-session state under `<root>/session-state`, where `<root>` + // is `$COPILOT_HOME` when set (see CopilotAgent's config-root resolver), else + // `~/.copilot` - honoring `$XDG_STATE_HOME` (as `<xdg>/.copilot`) only when + // `COPILOT_HOME` is unset. Kept in lock-step so troubleshooting reads the same + // directory session creation/import writes to. + const copilotHome = process.env['COPILOT_HOME']; + if (copilotHome) { + return join(copilotHome, 'session-state'); + } const xdgHome = process.env['XDG_STATE_HOME']; return xdgHome ? join(xdgHome, SESSION_STATE_DIRECTORY) : join(userHome, SESSION_STATE_DIRECTORY); } diff --git a/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts b/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts index 49741e0d5b6cb..3bf98a38627bf 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts @@ -227,30 +227,34 @@ export class CopilotSlashCommandCompletionProvider implements IAgentHostCompleti // even for a brand-new session, before the SDK lists them as runtime // commands. These are plain completion items (not customizations), so // they are not claimed by the workbench prompt-file machinery; dispatch - // remains text-side via `parseLeadingSlashCommand`. - for (const skill of this._sessionInfo.getBuiltinSkills?.() ?? []) { - if (addedAliases.has(skill.name)) { - continue; - } - if (typed.length > 0 && !skill.name.toLowerCase().startsWith(typedLower)) { - continue; + // remains text-side via `parseLeadingSlashCommand`, whose regex requires + // the `/` at offset 0 - so only advertise built-ins for a leading token, + // never a mid-message `use /...` token that could not be dispatched. + if (!returnJustSkills) { + for (const skill of this._sessionInfo.getBuiltinSkills?.() ?? []) { + if (addedAliases.has(skill.name)) { + continue; + } + if (typed.length > 0 && !skill.name.toLowerCase().startsWith(typedLower)) { + continue; + } + const insertText = `/${skill.name} `; + const description = skill.description(); + addedAliases.add(skill.name); + completionItems.push({ + insertText, + rangeStart, + rangeEnd, + attachment: { + type: MessageAttachmentKind.Simple, + label: insertText, + _meta: toCommandCompletionAttachmentMeta({ + command: skill.name, + description, + }), + }, + }); } - const insertText = `/${skill.name} `; - const description = skill.description(); - addedAliases.add(skill.name); - completionItems.push({ - insertText, - rangeStart, - rangeEnd, - attachment: { - type: MessageAttachmentKind.Simple, - label: insertText, - _meta: toCommandCompletionAttachmentMeta({ - command: skill.name, - description, - }), - }, - }); } return completionItems.sort((a, b) => a.insertText.localeCompare(b.insertText)); diff --git a/src/vs/platform/agentHost/test/common/agentHostTroubleshoot.test.ts b/src/vs/platform/agentHost/test/common/agentHostTroubleshoot.test.ts index 985473cdfd4d1..ab82f244ff00c 100644 --- a/src/vs/platform/agentHost/test/common/agentHostTroubleshoot.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostTroubleshoot.test.ts @@ -17,11 +17,12 @@ suite('agentHostTroubleshoot - augmentTroubleshootRequest', () => { const PROVIDER = 'copilotcli'; const resolvePath = (id: string) => `/state/${id}/log.jsonl`; - function sessionReferenceAttachment(sessionId: string): MessageAttachment { + function sessionReferenceAttachment(sessionId: string, title: string = sessionId): MessageAttachment { const resource = AgentSession.uri(PROVIDER, sessionId); return { type: MessageAttachmentKind.Simple, - label: `session ${sessionId}`, + // The label mirrors the inserted `#session:<title>` marker text. + label: title, _meta: toSessionReferenceAttachmentMeta({ sessionResource: resource.toString(), sessionID: sessionId }), }; } @@ -40,19 +41,28 @@ suite('agentHostTroubleshoot - augmentTroubleshootRequest', () => { }); }); - test('targets a referenced session, drops the marker, and ignores the marker title text', () => { - const attachments = [sessionReferenceAttachment('other')]; - const result = augmentTroubleshootRequest('how many tests does util have', attachments, resolvePath); + test('targets a referenced session and strips the marker when it is the only text', () => { + const attachments = [sessionReferenceAttachment('other', 'Other Session')]; + const result = augmentTroubleshootRequest('#session:Other Session', attachments, resolvePath); assert.deepStrictEqual(result, { prompt: `${SKILL_BASE}\n\nSession log: /state/other/log.jsonl`, attachments: [], }); }); + test('keeps a genuine question typed alongside a reference', () => { + const attachments = [sessionReferenceAttachment('build', 'Build')]; + const result = augmentTroubleshootRequest('#session:Build why was the test skipped?', attachments, resolvePath); + assert.deepStrictEqual(result, { + prompt: `${SKILL_BASE}\n\nSession log: /state/build/log.jsonl\n\nAdditional context from the user:\nwhy was the test skipped?`, + attachments: [], + }); + }); + test('keeps non-marker attachments and dedupes multiple references', () => { const keep = plainAttachment('notes.txt'); - const attachments = [sessionReferenceAttachment('a'), keep, sessionReferenceAttachment('b'), sessionReferenceAttachment('a')]; - const result = augmentTroubleshootRequest('#session:a #session:b', attachments, resolvePath); + const attachments = [sessionReferenceAttachment('a', 'A'), keep, sessionReferenceAttachment('b', 'B'), sessionReferenceAttachment('a', 'A')]; + const result = augmentTroubleshootRequest('#session:A #session:B', attachments, resolvePath); assert.deepStrictEqual(result, { prompt: `${SKILL_BASE}\n\nSession log: /state/a/log.jsonl, /state/b/log.jsonl`, attachments: [keep], diff --git a/src/vs/platform/agentHost/test/node/agentHostFileCompletionProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostFileCompletionProvider.test.ts index 6563db6c9143e..564c63ddfba1f 100644 --- a/src/vs/platform/agentHost/test/node/agentHostFileCompletionProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostFileCompletionProvider.test.ts @@ -14,7 +14,8 @@ import { CompletionItemKind } from '../../common/state/protocol/commands.js'; import { MessageAttachmentKind, type MessageAttachment } from '../../common/state/protocol/state.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { AgentHostCompletions, CompletionTriggerCharacter } from '../../node/agentHostCompletions.js'; -import { AgentHostFileCompletionProvider, extractAtToken } from '../../node/agentHostFileCompletionProvider.js'; +import { AgentHostFileCompletionProvider } from '../../node/agentHostFileCompletionProvider.js'; +import { extractAtToken } from '../../node/agentHostCompletionTokens.js'; import { AgentHostWorkspaceFiles } from '../../node/agentHostWorkspaceFiles.js'; class FakeWorkspaceFiles extends AgentHostWorkspaceFiles { diff --git a/src/vs/platform/agentHost/test/node/agentHostSessionReferenceCompletionProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostSessionReferenceCompletionProvider.test.ts index e35032b26211d..5e51bd93a9db3 100644 --- a/src/vs/platform/agentHost/test/node/agentHostSessionReferenceCompletionProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostSessionReferenceCompletionProvider.test.ts @@ -61,6 +61,15 @@ suite('AgentHostSessionReferenceCompletionProvider', () => { assert.strictEqual(forSession.length, 1); }); + test('does not fire for tokens that merely start with "session"', async () => { + const provider = create([session('a', 'Session A', 20)]); + for (const token of ['#sessions', '#sessionManager', '#session.ts']) { + assert.deepStrictEqual(await provider.provideCompletionItems(params(token), CancellationToken.None), [], token); + } + // The completed `#session:` form still fires. + assert.strictEqual((await provider.provideCompletionItems(params('#session:'), CancellationToken.None)).length, 1); + }); + test('ignores sessions from a different provider', async () => { const provider = create([session('a', 'Session A', 20)]); const p: CompletionsParams = { kind: CompletionItemKind.UserMessage, channel: AgentSession.uri('claude', 'x').toString(), text: '#session:', offset: 9 }; diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 554c548d739d4..ee9104ad2b632 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -48,6 +48,7 @@ import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js import { AgentHostCompletions, IAgentHostCompletions } from '../../node/agentHostCompletions.js'; import { COPILOT_AGENT_HOST_SYSTEM_MESSAGE, CopilotAgent, CopilotSessionEntry, migrateEnablementKeys, rebaseUnder } from '../../node/copilot/copilotAgent.js'; import { COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS } from '../../node/copilot/prompts/systemMessage.js'; +import { getBuiltinSkillDirectories } from '../../node/copilot/copilotBuiltinSkills.js'; import { NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; import { IAgentHostReviewService, NULL_REVIEW_SERVICE } from '../../common/agentHostReviewService.js'; import { IAgentHostGitHubEndpointService } from '../../node/agentHostGitHubEndpointService.js'; @@ -4452,10 +4453,14 @@ suite('CopilotAgent', () => { } assert.ok(capturedConfig, 'the SDK createSession must run during materialization'); + // The host-bundled built-in skills (e.g. `/troubleshoot`) are always + // appended and legitimately resolve outside the worktree; exclude them + // so this asserts only the worktree-anchoring of workspace skills. + const builtinSkillDirs = new Set(getBuiltinSkillDirectories()); assert.deepStrictEqual( { workingDirectory: capturedConfig.workingDirectory, - skillDirectories: capturedConfig.skillDirectories, + skillDirectories: capturedConfig.skillDirectories?.filter(dir => !builtinSkillDirs.has(dir)), instructionDirectories: capturedConfig.instructionDirectories, }, { diff --git a/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts b/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts index 007807168bdc7..2a0cc933a96a7 100644 --- a/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts @@ -626,9 +626,12 @@ suite('CopilotSlashCommandCompletionProvider', () => { assert.deepStrictEqual((await run(createProvider(), '/z')).map(i => i.insertText), []); }); - test('surfaces built-in skills for an in-message slash token', async () => { + test('does not surface built-in skills for an in-message slash token', async () => { + // A mid-message `use /...` token cannot be dispatched via + // `parseLeadingSlashCommand` (needs `/` at offset 0), so built-ins are + // only advertised for a leading token. const items = await run(createProvider(), 'use /'); - assert.deepStrictEqual(items.map(i => i.insertText), ['/troubleshoot ']); + assert.deepStrictEqual(items.map(i => i.insertText), []); }); test('dedupes the runtime skill copy the SDK reports once materialized', async () => { From 999e62ffa49ea63e9f1326223a29df456a1d3daa Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Sat, 18 Jul 2026 08:46:39 -0700 Subject: [PATCH 3/5] Test update --- .../agentHost/node/copilot/copilotBuiltinSkills.ts | 11 +++++++---- .../agentHost/test/node/copilotBuiltinSkills.test.ts | 11 +++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotBuiltinSkills.ts b/src/vs/platform/agentHost/node/copilot/copilotBuiltinSkills.ts index d65caa52efcde..54fc6b74835fc 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotBuiltinSkills.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotBuiltinSkills.ts @@ -5,6 +5,7 @@ import { FileAccess } from '../../../../base/common/network.js'; import { joinPath } from '../../../../base/common/resources.js'; +import type { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { TROUBLESHOOT_SKILL_NAME } from '../../common/agentHostTroubleshoot.js'; @@ -12,8 +13,9 @@ import { TROUBLESHOOT_SKILL_NAME } from '../../common/agentHostTroubleshoot.js'; * Root directory of the Copilot harness's built-in skills, bundled next to * this module (`<out>/vs/platform/agentHost/node/copilot/skills`). Each skill * lives in its own `<name>/SKILL.md` subfolder. - */ -const BUILTIN_SKILLS_ROOT = FileAccess.asFileUri('vs/platform/agentHost/node/copilot/skills'); +function builtinSkillsRoot(): URI { + return FileAccess.asFileUri('vs/platform/agentHost/node/copilot/skills'); +} /** * A built-in skill bundled with the Copilot harness. Adding a new one is: @@ -28,7 +30,7 @@ const BUILTIN_SKILLS_ROOT = FileAccess.asFileUri('vs/platform/agentHost/node/cop * layers that behavior on top in the send path. */ export interface IBuiltinSkill { - /** Folder name under {@link BUILTIN_SKILLS_ROOT} and the `/<name>` command. */ + /** Folder name under {@link builtinSkillsRoot} and the `/<name>` command. */ readonly name: string; /** * User-facing description shown in completions / the customization list, @@ -56,7 +58,8 @@ export const BUILTIN_SKILLS: readonly IBuiltinSkill[] = [ * without VS Code and over a remote connection. */ export function getBuiltinSkillDirectories(): string[] { - return BUILTIN_SKILLS.map(skill => joinPath(BUILTIN_SKILLS_ROOT, skill.name).fsPath); + const root = builtinSkillsRoot(); + return BUILTIN_SKILLS.map(skill => joinPath(root, skill.name).fsPath); } /** Whether `name` is a bundled built-in skill's `/<name>` command. */ diff --git a/src/vs/platform/agentHost/test/node/copilotBuiltinSkills.test.ts b/src/vs/platform/agentHost/test/node/copilotBuiltinSkills.test.ts index 15e8bae69d852..354baf505466e 100644 --- a/src/vs/platform/agentHost/test/node/copilotBuiltinSkills.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotBuiltinSkills.test.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { existsSync } from 'fs'; +import { join } from '../../../../base/common/path.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { TROUBLESHOOT_SKILL_NAME } from '../../common/agentHostTroubleshoot.js'; import { getBuiltinSkillDirectories, isBuiltinSkill } from '../../node/copilot/copilotBuiltinSkills.js'; @@ -18,6 +20,15 @@ suite('copilotBuiltinSkills', () => { assert.ok(dirs[0].replace(/\\/g, '/').endsWith(`/skills/${TROUBLESHOOT_SKILL_NAME}`)); }); + test('each resolved directory contains a bundled SKILL.md', () => { + // Exercises `builtinSkillsRoot()` end-to-end: `FileAccess.asFileUri` must + // resolve to a real on-disk folder, and the bundled `SKILL.md` must live + // there (guards the resolution + the build's resource copy in dev/`out`). + for (const dir of getBuiltinSkillDirectories()) { + assert.ok(existsSync(join(dir, 'SKILL.md')), `expected SKILL.md under ${dir}`); + } + }); + test('recognizes built-in skill names', () => { assert.strictEqual(isBuiltinSkill(TROUBLESHOOT_SKILL_NAME), true); assert.strictEqual(isBuiltinSkill('not-a-builtin'), false); From 6627178c2f1f38271ec8adc1971af1f167514535 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Sat, 18 Jul 2026 09:02:51 -0700 Subject: [PATCH 4/5] Test fix --- src/vs/platform/agentHost/node/copilot/copilotBuiltinSkills.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/platform/agentHost/node/copilot/copilotBuiltinSkills.ts b/src/vs/platform/agentHost/node/copilot/copilotBuiltinSkills.ts index 54fc6b74835fc..46d6ba5d16f84 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotBuiltinSkills.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotBuiltinSkills.ts @@ -13,6 +13,7 @@ import { TROUBLESHOOT_SKILL_NAME } from '../../common/agentHostTroubleshoot.js'; * Root directory of the Copilot harness's built-in skills, bundled next to * this module (`<out>/vs/platform/agentHost/node/copilot/skills`). Each skill * lives in its own `<name>/SKILL.md` subfolder. + */ function builtinSkillsRoot(): URI { return FileAccess.asFileUri('vs/platform/agentHost/node/copilot/skills'); } From c12dd98f5aac2ca17ab2bb210b8da047d497fea6 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:16:42 -0700 Subject: [PATCH 5/5] Few updates --- .../common/agentHostSkillInvocation.ts | 25 --------- .../agentHost/common/agentHostTroubleshoot.ts | 36 ++++++++----- .../node/copilot/copilotAgentSession.ts | 53 +++++++++++++++---- .../node/copilot/copilotBuiltinSkills.ts | 9 ++-- .../common/agentHostSkillInvocation.test.ts | 34 ------------ .../test/common/agentHostTroubleshoot.test.ts | 10 ++-- 6 files changed, 75 insertions(+), 92 deletions(-) delete mode 100644 src/vs/platform/agentHost/common/agentHostSkillInvocation.ts delete mode 100644 src/vs/platform/agentHost/test/common/agentHostSkillInvocation.test.ts diff --git a/src/vs/platform/agentHost/common/agentHostSkillInvocation.ts b/src/vs/platform/agentHost/common/agentHostSkillInvocation.ts deleted file mode 100644 index 6aad05d0f1c00..0000000000000 --- a/src/vs/platform/agentHost/common/agentHostSkillInvocation.ts +++ /dev/null @@ -1,25 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Builds the message that invokes a bundled skill. - * - * A skill loaded into the harness runtime (via its skill directory) is invoked - * by a message that asks the model to use the `skill` tool to load and follow - * it. So a `/<name>` built-in skill request is rewritten into this canonical - * phrasing - the same contract the Copilot SDK reference client uses. - * - * Harness-agnostic and pure so every harness (and the unit tests) share one - * definition of the invocation contract. - * - * @param skillName The bundled skill's name (its folder / `/<name>` command). - * @param userInstructions Optional free text the user typed after the command, - * forwarded as additional context for the skill. - */ -export function buildSkillInvocationPrompt(skillName: string, userInstructions?: string): string { - const base = `Use the skill tool to invoke the '${skillName}' skill, then follow the skill's instructions.`; - const trimmed = userInstructions?.trim(); - return trimmed ? `${base}\n\nAdditional context from the user:\n${trimmed}` : base; -} diff --git a/src/vs/platform/agentHost/common/agentHostTroubleshoot.ts b/src/vs/platform/agentHost/common/agentHostTroubleshoot.ts index 685052453ca42..78c29dfb58182 100644 --- a/src/vs/platform/agentHost/common/agentHostTroubleshoot.ts +++ b/src/vs/platform/agentHost/common/agentHostTroubleshoot.ts @@ -5,7 +5,6 @@ import { URI } from '../../../base/common/uri.js'; import { AgentSession } from './agentService.js'; -import { buildSkillInvocationPrompt } from './agentHostSkillInvocation.js'; import { readSessionReferenceAttachmentMeta } from './meta/agentSessionReferenceMeta.js'; import type { MessageAttachment } from './state/protocol/state.js'; @@ -18,23 +17,30 @@ import type { MessageAttachment } from './state/protocol/state.js'; export const TROUBLESHOOT_SKILL_NAME = 'troubleshoot'; /** - * Result of {@link augmentTroubleshootRequest}: the rewritten prompt and the - * attachments to forward to the model. + * Result of {@link augmentTroubleshootRequest}: the skill invocation context and + * the attachments to forward to the model. */ export interface ITroubleshootRequest { - readonly prompt: string; + /** + * The skill `input` (context only): the resolved session log line and any + * genuine user question, WITHOUT the skill-invocation wrapper. Passed as the + * `input` argument to the runtime's native `commands.invoke`, which wraps it + * in the canonical invocation message itself. + */ + readonly input: string; readonly attachments: readonly MessageAttachment[] | undefined; } /** - * Rewrites a `/troubleshoot` request into a message that invokes the built-in - * `troubleshoot` skill: - * - * Use the skill tool to invoke the 'troubleshoot' skill, then follow the - * skill's instructions. + * Builds the `input` (context) for a `/troubleshoot` request, forwarded to the + * built-in `troubleshoot` skill via the runtime's native `commands.invoke` + * (which wraps it in the canonical skill-invocation message): * * Session log: <path(s)> * + * Additional context from the user: + * <question> + * * An explicit `#session` reference pins the referenced session's log path (and * its marker attachments are dropped, consumed here rather than forwarded). A * bare `/troubleshoot` injects no path, so the skill continues the session @@ -76,14 +82,17 @@ export function augmentTroubleshootRequest( } } - const parts = [buildSkillInvocationPrompt(TROUBLESHOOT_SKILL_NAME)]; + // Build the skill `input` (context only). The skill-invocation wrapper is + // added by the runtime when it resolves the skill via `commands.invoke` - + // not here. + const contextParts: string[] = []; // Pin the log path only for an explicit `#session` reference. A bare // `/troubleshoot` injects no path so the skill continues the session already // established earlier in the conversation, or self-discovers the current one. if (referencedIds.length) { const logPaths = Array.from(new Set(referencedIds.map(resolveSessionLogPath))); if (logPaths.length) { - parts.push(`Session log: ${logPaths.join(', ')}`); + contextParts.push(`Session log: ${logPaths.join(', ')}`); } } // Forward the user's genuine free text, stripping the inserted @@ -96,10 +105,11 @@ export function augmentTroubleshootRequest( } instructions = instructions.trim(); if (instructions) { - parts.push(`Additional context from the user:\n${instructions}`); + contextParts.push(`Additional context from the user:\n${instructions}`); } + const input = contextParts.join('\n\n'); // Drop the `#session` markers; forward any other (real) attachments. const nextAttachments = referencedIds.length ? remaining : attachments; - return { prompt: parts.join('\n\n'), attachments: nextAttachments }; + return { input, attachments: nextAttachments }; } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 83a865ae196a3..5aedbf42dbc4c 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -50,7 +50,6 @@ import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; import { buildCopilotSystemNotification } from './copilotSystemNotification.js'; import { parseLeadingSlashCommand } from './copilotSlashCommandCompletionProvider.js'; import { augmentTroubleshootRequest, TROUBLESHOOT_SKILL_NAME } from '../../common/agentHostTroubleshoot.js'; -import { buildSkillInvocationPrompt } from '../../common/agentHostSkillInvocation.js'; import { isBuiltinSkill } from './copilotBuiltinSkills.js'; import type { IUnsandboxedCommandConfirmationRequest, ShellManager } from './copilotShellTools.js'; import { buildSandboxConfigForSdk, type ISdkSandboxConfig } from './sandboxConfigForSdk.js'; @@ -1424,22 +1423,26 @@ export class CopilotAgentSession extends Disposable { } } - // A bundled built-in skill request (`/<name>`) is rewritten into a - // deterministic skill invocation: the runtime does not reliably auto-run - // a skill from a bare `/<name>`, so we ask the model to use the `skill` - // tool (see `buildSkillInvocationPrompt`). `/troubleshoot` additionally - // resolves the on-disk session log(s) to analyze - `#session` reference - // attachments target other sessions, else the current session - and its - // `#session` markers are consumed here, not forwarded to the model. + // A bundled built-in skill request (`/<name>`) is dispatched by asking the + // runtime to invoke the loaded, user-invocable skill via `commands.invoke`: + // the runtime resolves the skill (loading it on demand) and returns an + // `agent-prompt` whose text is the canonical skill-invocation message it + // builds itself, keeping the phrasing in sync with the CLI. `/troubleshoot` + // additionally resolves the on-disk session log(s) to analyze - `#session` + // reference attachments target other sessions, else the current session - + // passed as the skill `input`; its `#session` markers are consumed here, + // not forwarded to the model. let effectiveAttachments = attachments; if (slashCommand && isBuiltinSkill(slashCommand.command)) { + let skillInput: string; if (slashCommand.command === TROUBLESHOOT_SKILL_NAME) { const augmented = augmentTroubleshootRequest(slashCommand.rest, attachments, id => this._sessionEventsLogPath(id)); - prompt = augmented.prompt; + skillInput = augmented.input; effectiveAttachments = augmented.attachments; } else { - prompt = buildSkillInvocationPrompt(slashCommand.command, slashCommand.rest); + skillInput = slashCommand.rest; } + prompt = await this._invokeBuiltinSkillCommand(slashCommand.command, skillInput); } const sdkAttachments = effectiveAttachments?.length @@ -1456,6 +1459,36 @@ export class CopilotAgentSession extends Disposable { this._logService.info(`[Copilot:${this.sessionId}] session.send() returned`); } + /** + * Invokes a bundled built-in skill via the runtime's native + * `commands.invoke` RPC. The runtime resolves the loaded, user-invocable + * skill by name (loading skills on demand) and returns an `agent-prompt` + * result whose `prompt` is the canonical skill-invocation message it builds + * itself - so the phrasing stays in sync with the CLI. + * + * @param name The built-in skill's `/<name>` command. + * @param input Free-text context forwarded to the skill (the runtime appends + * it after the invocation message). + * @throws when the invocation fails or the runtime does not return an + * `agent-prompt` (a built-in skill is expected to always produce one). + */ + private async _invokeBuiltinSkillCommand(name: string, input: string): Promise<string> { + let result: CopilotCommandInvocationResult; + try { + result = await this._wrapper.session.rpc.commands.invoke({ + name, + ...(input.length > 0 ? { input } : {}), + }); + } catch (err) { + this._logService.error(err, `[Copilot:${this.sessionId}] rpc.commands.invoke(${name}) failed`); + throw err; + } + if (result.kind !== 'agent-prompt') { + throw new Error(`commands.invoke('${name}') returned kind '${result.kind}', expected 'agent-prompt'`); + } + return result.prompt; + } + /** * Resolves the on-disk `events.jsonl` path for a Copilot CLI session, * `<sessionStateDir>/<sessionId>/events.jsonl`. The path is host-local (the diff --git a/src/vs/platform/agentHost/node/copilot/copilotBuiltinSkills.ts b/src/vs/platform/agentHost/node/copilot/copilotBuiltinSkills.ts index 46d6ba5d16f84..c984b044697bb 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotBuiltinSkills.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotBuiltinSkills.ts @@ -25,10 +25,11 @@ function builtinSkillsRoot(): URI { * Nothing else in the plumbing is skill-specific: the folder is fed to the * SDK's `skillDirectories` (so the runtime loads it), the manifest is surfaced * by the slash-command completion provider (so it appears in `/` completions - * immediately), and the send path rewrites `/<name>` into a deterministic skill - * invocation (see `buildSkillInvocationPrompt`). A skill that also needs - * host-side request rewriting (as `/troubleshoot` does for its session log) - * layers that behavior on top in the send path. + * immediately), and the send path dispatches `/<name>` to the runtime's native + * `commands.invoke` (which resolves the skill and returns its invocation + * message). A skill that also needs host-side request rewriting (as + * `/troubleshoot` does for its session log) layers that behavior on top in the + * send path. */ export interface IBuiltinSkill { /** Folder name under {@link builtinSkillsRoot} and the `/<name>` command. */ diff --git a/src/vs/platform/agentHost/test/common/agentHostSkillInvocation.test.ts b/src/vs/platform/agentHost/test/common/agentHostSkillInvocation.test.ts deleted file mode 100644 index 9b85f2be60fa8..0000000000000 --- a/src/vs/platform/agentHost/test/common/agentHostSkillInvocation.test.ts +++ /dev/null @@ -1,34 +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 assert from 'assert'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { buildSkillInvocationPrompt } from '../../common/agentHostSkillInvocation.js'; - -suite('agentHostSkillInvocation - buildSkillInvocationPrompt', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('asks the model to use the skill tool for the named skill', () => { - assert.strictEqual( - buildSkillInvocationPrompt('review'), - `Use the skill tool to invoke the 'review' skill, then follow the skill's instructions.`, - ); - }); - - test('appends trimmed user instructions as additional context', () => { - assert.strictEqual( - buildSkillInvocationPrompt('review', ' focus on error handling '), - `Use the skill tool to invoke the 'review' skill, then follow the skill's instructions.\n\nAdditional context from the user:\nfocus on error handling`, - ); - }); - - test('omits the context block for empty instructions', () => { - assert.strictEqual( - buildSkillInvocationPrompt('review', ' '), - `Use the skill tool to invoke the 'review' skill, then follow the skill's instructions.`, - ); - }); -}); diff --git a/src/vs/platform/agentHost/test/common/agentHostTroubleshoot.test.ts b/src/vs/platform/agentHost/test/common/agentHostTroubleshoot.test.ts index ab82f244ff00c..2430a1a6c934f 100644 --- a/src/vs/platform/agentHost/test/common/agentHostTroubleshoot.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostTroubleshoot.test.ts @@ -31,12 +31,10 @@ suite('agentHostTroubleshoot - augmentTroubleshootRequest', () => { return { type: MessageAttachmentKind.Simple, label }; } - const SKILL_BASE = `Use the skill tool to invoke the 'troubleshoot' skill, then follow the skill's instructions.`; - test('omits the log path for a bare request and forwards the free text as context', () => { const result = augmentTroubleshootRequest('why slow', undefined, resolvePath); assert.deepStrictEqual(result, { - prompt: `${SKILL_BASE}\n\nAdditional context from the user:\nwhy slow`, + input: `Additional context from the user:\nwhy slow`, attachments: undefined, }); }); @@ -45,7 +43,7 @@ suite('agentHostTroubleshoot - augmentTroubleshootRequest', () => { const attachments = [sessionReferenceAttachment('other', 'Other Session')]; const result = augmentTroubleshootRequest('#session:Other Session', attachments, resolvePath); assert.deepStrictEqual(result, { - prompt: `${SKILL_BASE}\n\nSession log: /state/other/log.jsonl`, + input: `Session log: /state/other/log.jsonl`, attachments: [], }); }); @@ -54,7 +52,7 @@ suite('agentHostTroubleshoot - augmentTroubleshootRequest', () => { const attachments = [sessionReferenceAttachment('build', 'Build')]; const result = augmentTroubleshootRequest('#session:Build why was the test skipped?', attachments, resolvePath); assert.deepStrictEqual(result, { - prompt: `${SKILL_BASE}\n\nSession log: /state/build/log.jsonl\n\nAdditional context from the user:\nwhy was the test skipped?`, + input: `Session log: /state/build/log.jsonl\n\nAdditional context from the user:\nwhy was the test skipped?`, attachments: [], }); }); @@ -64,7 +62,7 @@ suite('agentHostTroubleshoot - augmentTroubleshootRequest', () => { const attachments = [sessionReferenceAttachment('a', 'A'), keep, sessionReferenceAttachment('b', 'B'), sessionReferenceAttachment('a', 'A')]; const result = augmentTroubleshootRequest('#session:A #session:B', attachments, resolvePath); assert.deepStrictEqual(result, { - prompt: `${SKILL_BASE}\n\nSession log: /state/a/log.jsonl, /state/b/log.jsonl`, + input: `Session log: /state/a/log.jsonl, /state/b/log.jsonl`, attachments: [keep], }); });