diff --git a/build/gulpfile.reh.ts b/build/gulpfile.reh.ts index a40ff6f6db1eb1..ddd345f47e6add 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 f3669ff6c17ce8..58fc9feedb371b 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 7e0ef886f2148f..5327190756c318 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 new file mode 100644 index 00000000000000..78c29dfb58182f --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostTroubleshoot.ts @@ -0,0 +1,115 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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 skill invocation context and + * the attachments to forward to the model. + */ +export interface ITroubleshootRequest { + /** + * 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; +} + +/** + * 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: + * + * Additional context from the user: + * + * + * 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 genuine + * free text is always forwarded as context, with the inserted `#session:` + * 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`, + * 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[] = []; + const markerLabels: string[] = []; + 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. + } + // 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); + } + } + + // 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) { + contextParts.push(`Session log: ${logPaths.join(', ')}`); + } + } + // 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) { + 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 { input, attachments: nextAttachments }; +} diff --git a/src/vs/platform/agentHost/common/meta/agentCompletionAttachmentMeta.ts b/src/vs/platform/agentHost/common/meta/agentCompletionAttachmentMeta.ts index 92c98a3677ce1d..23e2908e6d2586 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 00000000000000..85a63a2639cc58 --- /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<string, unknown> { + 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<string, unknown> | 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<IAgentHostSessionReferenceAttachmentMetadata>; + 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/agentHostCompletionTokens.ts b/src/vs/platform/agentHost/node/agentHostCompletionTokens.ts new file mode 100644 index 00000000000000..b74aaccf4f7e6b --- /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 1b4ca1aaef9328..e731f33243b0c4 100644 --- a/src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts @@ -12,55 +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 { 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. @@ -124,6 +82,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 00000000000000..2c327bd216ac0d --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostSessionReferenceCompletionProvider.ts @@ -0,0 +1,131 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { 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, isSessionReferenceToken, SESSION_TOKEN } from './agentHostCompletionTokens.js'; + +/** + * 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:<title>` 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 e097babecb914e..1a802f6ac622d8 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 889eb4d6e0376e..5aedbf42dbc4c3 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -49,6 +49,8 @@ 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 { 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'; @@ -293,6 +295,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); } @@ -1412,8 +1423,30 @@ 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 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)); + skillInput = augmented.input; + effectiveAttachments = augmented.attachments; + } else { + skillInput = slashCommand.rest; + } + prompt = await this._invokeBuiltinSkillCommand(slashCommand.command, skillInput); + } + + 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 +1459,47 @@ 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 + * 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 00000000000000..c984b044697bb5 --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/copilotBuiltinSkills.ts @@ -0,0 +1,71 @@ +/*--------------------------------------------------------------------------------------------- + * 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 type { URI } from '../../../../base/common/uri.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. + */ +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: + * 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 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. */ + 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[] { + const root = builtinSkillsRoot(); + return BUILTIN_SKILLS.map(skill => joinPath(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 334c0f5ab399f9..a4bb7620ac038c 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 e0ca22079f371a..3bf98a38627bf0 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,41 @@ 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`, 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, + }), + }, + }); + } + } + 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/agentHostTroubleshoot.test.ts b/src/vs/platform/agentHost/test/common/agentHostTroubleshoot.test.ts new file mode 100644 index 00000000000000..2430a1a6c934f7 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/agentHostTroubleshoot.test.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * 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, title: string = sessionId): MessageAttachment { + const resource = AgentSession.uri(PROVIDER, sessionId); + return { + type: MessageAttachmentKind.Simple, + // The label mirrors the inserted `#session:<title>` marker text. + label: title, + _meta: toSessionReferenceAttachmentMeta({ sessionResource: resource.toString(), sessionID: sessionId }), + }; + } + + function plainAttachment(label: string): MessageAttachment { + return { type: MessageAttachmentKind.Simple, label }; + } + + 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, { + input: `Additional context from the user:\nwhy slow`, + attachments: undefined, + }); + }); + + 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, { + input: `Session 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, { + input: `Session 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', 'A'), keep, sessionReferenceAttachment('b', 'B'), sessionReferenceAttachment('a', 'A')]; + const result = augmentTroubleshootRequest('#session:A #session:B', attachments, resolvePath); + assert.deepStrictEqual(result, { + input: `Session 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 6563db6c9143e7..564c63ddfba1fb 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 new file mode 100644 index 00000000000000..5e51bd93a9db32 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostSessionReferenceCompletionProvider.test.ts @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * 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('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 }; + 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/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 554c548d739d48..ee9104ad2b6322 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/copilotBuiltinSkills.test.ts b/src/vs/platform/agentHost/test/node/copilotBuiltinSkills.test.ts new file mode 100644 index 00000000000000..354baf505466e2 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/copilotBuiltinSkills.test.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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'; + +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('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); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts b/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts index 5f80507c5a3ea7..2a0cc933a96a77 100644 --- a/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts @@ -587,4 +587,62 @@ 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('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), []); + }); + + 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 7294624ec5adfa..5a100811738af4 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 7cb4353b18a496..3b8d6c9e54c56c 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 90c4bdc3b9d4ad..00000000000000 --- 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 c04cef58286692..00000000000000 --- 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 ad0808b5091a80..93342fd6b6f9aa 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 f1c525b91f2d81..ea9dc9313f2e5e 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 8a268d479faabd..06273bff6a56af 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 bf9e06cf0da03b..70795c663e2bef 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 e26a2f7eb9dd56..03ac8b2a6cbade 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 87a78984c21a6b..138b7e5c421dfc 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 8ea6f0577a29ec..85669615c6a9cc 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}. */