From 0609d9dd9d02624ec274f538fbf43480684dff57 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 16:46:15 +0000 Subject: [PATCH 01/27] Polish context size picker wording Co-authored-by: eli-w-king <201316543+eli-w-king@users.noreply.github.com> --- .../conversation/vscode-node/languageModelAccess.ts | 4 ++-- .../contrib/chat/browser/widget/input/chatModelPicker.ts | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts index 92e2903d2a5e8..c659f2c1e4ee1 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts @@ -85,11 +85,11 @@ function getContextSizeOptions(endpoint: IChatEndpoint): { value: number; descri const hasLongContextSurcharge = !!pricing.longContext; return [ - { value: defaultMax, description: vscode.l10n.t('Default pricing'), isDefault: true }, + { value: defaultMax, description: vscode.l10n.t('Default'), isDefault: true }, { value: fullMax, description: hasLongContextSurcharge - ? vscode.l10n.t('Longer sessions (higher cost)') + ? vscode.l10n.t('Longer sessions') : vscode.l10n.t('Longer sessions without compaction'), isDefault: false, }, diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts index 3815430750590..bf957394dddea 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -1317,10 +1317,7 @@ export class ModelPickerWidget extends Disposable { for (let index = 0; index < enumValues.length; index++) { const value = enumValues[index]; const label = enumItemLabels?.[index] ?? formatTokenCount(Number(value)); - const isDefault = value === config.schema.default; - const displayLabel = isDefault - ? localize('models.tokensDefault', "{0} (default)", label) - : label; + const displayLabel = label; const description = config.schema.enumDescriptions?.[index]; items.push({ item: { From d06e5bba8c161511e07721a9d4cd22dd22871f40 Mon Sep 17 00:00:00 2001 From: Harald Kirschner Date: Fri, 29 May 2026 11:30:49 -0700 Subject: [PATCH 02/27] feat: add /research slash command to agent host (#318861) * feat: add /research slash command to agent host Add /research as a recognized slash command alongside /plan and /compact. The command passes through to the Copilot CLI runtime which handles topic validation, research directory creation, and orchestration prompt injection via its built-in /research handler. Changes: - copilotSlashCommandCompletionProvider: add 'research' to type union, command list, regex, description, and insertText (with trailing space) - copilotAgentSession.send: pass /research through to session.send() so the runtime recognizes and handles it - Tests: add /research parsing, filtering, and completion tests * fix: update JSDoc to include /research in command list --- .../node/copilot/copilotAgentSession.ts | 3 +++ .../copilotSlashCommandCompletionProvider.ts | 16 +++++++----- ...ilotSlashCommandCompletionProvider.test.ts | 26 ++++++++++++++++--- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 02a0ae3489ef3..8479c2a218ee2 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -796,6 +796,9 @@ export class CopilotAgentSession extends Disposable { } return; } + if (slashCommand?.command === 'research') { + prompt = slashCommand.rest ? `/research ${slashCommand.rest}` : '/research'; + } if (slashCommand?.command === 'plan') { mode = 'plan'; prompt = slashCommand.rest; diff --git a/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts b/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts index dff508494c6b2..d8de98b2d229d 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts @@ -15,13 +15,14 @@ import { extractLeadingSlashToken } from '../agentHostSlashCompletion.js'; * Slash-command name and the token we surface to the user / round-trip on * the {@link MessageAttachmentKind.Simple} attachment's `_meta`. */ -export type CopilotSlashCommandName = 'plan' | 'compact'; +export type CopilotSlashCommandName = 'plan' | 'compact' | 'research'; -const COMMANDS: readonly CopilotSlashCommandName[] = ['plan', 'compact']; +const COMMANDS: readonly CopilotSlashCommandName[] = ['plan', 'compact', 'research']; function getCommandDescription(command: CopilotSlashCommandName): string { switch (command) { case 'plan': return localize('copilotSlashCommand.plan.description', "Create an implementation plan before coding"); case 'compact': return localize('copilotSlashCommand.compact.description', "Free up context by compacting the conversation history"); + case 'research': return localize('copilotSlashCommand.research.description', "Run deep research on a topic using search and web sources"); } } /** @@ -47,12 +48,13 @@ export interface IParsedLeadingSlashCommand { /** * Parses a Copilot CLI slash command at the very start of `prompt`. * - * The command must be `/plan` or `/compact`, followed either by end-of-input - * or by at least one whitespace character. `/compact-hello`, `/plans`, or a - * leading-space `/compact` all return `undefined`. Match is case-sensitive. + * The command must be `/plan`, `/compact`, or `/research`, followed either by + * end-of-input or by at least one whitespace character. `/compact-hello`, + * `/plans`, or a leading-space `/compact` all return `undefined`. Match is + * case-sensitive. */ export function parseLeadingSlashCommand(prompt: string): IParsedLeadingSlashCommand | undefined { - const match = /^\/(plan|compact)(?:$|\s+([\s\S]*))/.exec(prompt); + const match = /^\/(plan|compact|research)(?:$|\s+([\s\S]*))/.exec(prompt); if (!match) { return undefined; } @@ -104,7 +106,7 @@ export class CopilotSlashCommandCompletionProvider implements IAgentHostCompleti continue; } items.push({ - insertText: command === 'plan' ? '/' + command + ' ' : '/' + command, + insertText: command === 'compact' ? '/' + command : '/' + command + ' ', rangeStart: 0, rangeEnd: leading.rangeEnd, attachment: { diff --git a/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts b/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts index 855270c6a9949..8f87a96ba29c4 100644 --- a/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts @@ -23,6 +23,14 @@ suite('CopilotSlashCommandCompletionProvider', () => { assert.deepStrictEqual(parseLeadingSlashCommand('/compact'), { command: 'compact', rest: '' }); }); + test('matches lone /research', () => { + assert.deepStrictEqual(parseLeadingSlashCommand('/research'), { command: 'research', rest: '' }); + }); + + test('captures trailing text after a space for /research', () => { + assert.deepStrictEqual(parseLeadingSlashCommand('/research How does React work?'), { command: 'research', rest: 'How does React work?' }); + }); + test('captures trailing text after a space', () => { assert.deepStrictEqual(parseLeadingSlashCommand('/plan build a hello world'), { command: 'plan', rest: 'build a hello world' }); }); @@ -66,9 +74,9 @@ suite('CopilotSlashCommandCompletionProvider', () => { assert.deepStrictEqual(items, []); }); - test('returns both items for lone "/"', async () => { + test('returns all items for lone "/"', async () => { const items = await run('/'); - assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/compact']); + assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/compact', '/research ']); }); test('filters to /plan when "/p" typed', async () => { @@ -81,6 +89,11 @@ suite('CopilotSlashCommandCompletionProvider', () => { assert.deepStrictEqual(items.map(i => i.insertText), ['/compact']); }); + test('filters to /research when "/r" typed', async () => { + const items = await run('/r'); + assert.deepStrictEqual(items.map(i => i.insertText), ['/research ']); + }); + test('returns nothing when /word does not match any command prefix', async () => { const items = await run('/zz'); assert.deepStrictEqual(items, []); @@ -121,6 +134,13 @@ suite('CopilotSlashCommandCompletionProvider', () => { description: 'Free up context by compacting the conversation history', }, }, + { + type: MessageAttachmentKind.Simple, + meta: { + command: 'research', + description: 'Run deep research on a topic using search and web sources', + }, + }, ]); }); @@ -129,7 +149,7 @@ suite('CopilotSlashCommandCompletionProvider', () => { const items = await gated.provideCompletionItems({ kind: CompletionItemKind.UserMessage, channel: session, text: '/', offset: 1, }, CancellationToken.None); - assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ']); + assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/research ']); }); test('passes raw session id (no scheme/slash) to hasHistory', async () => { From 5dcafbd69b77747dc17d5f8a243970698a67b93f Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Fri, 29 May 2026 11:32:55 -0700 Subject: [PATCH 03/27] agentHost: batch AHP log writes and skip URI deep clone (#318864) * agentHost: batch AHP log writes and skip URI deep clone Two perf fixes for AhpJsonlLogger, the per-message JSONL transport logger. CPU traces of the agents window under heavy AHP traffic showed ~23% of wall time in MajorGC, dominated by VSBuffer allocations from renderer-side writeFile IPCs and the recursive _replaceUris deep clone applied to every message before JSON.stringify. - log() now appends to a pending buffer list and schedules a single drain on the write queue. While a writeFile is in flight, all subsequent log() calls accumulate and land in the next drain via VSBuffer.concat, capped at 1 MiB per write. Rotation is still checked per entry, and flush()/ordering semantics are preserved via a _drainScheduled invariant. - stringifyAhpLogEntry() now uses a JSON.stringify replacer instead of a tree-walking deep clone. URI.toJSON() stamps the output with $mid: MarshalledId.Uri, which the replacer detects (guarded by isUriComponents) and rewrites to the canonical URI string. This removes per-message allocation of a clone of the entire message payload. Adds tests for batching, flush ordering across drains, and URI replacer edge cases (nested URIs, raw UriComponents from prior toJSON, URI-shaped objects without $mid, and non-URI objects that happen to carry $mid: 1). (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: tighten batching test to assert exactly one write Addresses review feedback: the previous `writeCount < messageCount` assertion would pass even with a near-pathological 49-writes-for-50-logs regression. All 50 log() calls are queued synchronously and must land in the very first drain, so assert writeCount === 1. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/common/ahpJsonlLogger.ts | 108 +++++++++++----- .../test/common/ahpJsonlLogger.test.ts | 120 +++++++++++++++++- 2 files changed, 198 insertions(+), 30 deletions(-) diff --git a/src/vs/platform/agentHost/common/ahpJsonlLogger.ts b/src/vs/platform/agentHost/common/ahpJsonlLogger.ts index 54c6259920a23..04d046e245d66 100644 --- a/src/vs/platform/agentHost/common/ahpJsonlLogger.ts +++ b/src/vs/platform/agentHost/common/ahpJsonlLogger.ts @@ -5,8 +5,9 @@ import { VSBuffer } from '../../../base/common/buffer.js'; import { Disposable } from '../../../base/common/lifecycle.js'; +import { MarshalledId } from '../../../base/common/marshallingIds.js'; import { joinPath } from '../../../base/common/resources.js'; -import { URI } from '../../../base/common/uri.js'; +import { isUriComponents, URI, UriComponents } from '../../../base/common/uri.js'; import { IFileService, IFileStatWithMetadata } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; @@ -23,6 +24,11 @@ export interface IAhpJsonlLoggerOptions { const AHP_LOG_DIR = 'ahp'; const DEFAULT_MAX_FILE_SIZE_BYTES = 75 * 1024 * 1024; const DEFAULT_MAX_FILES = 5; +// Cap the size of any single coalesced writeFile to avoid producing huge +// concatenated VSBuffers (which would just create the GC pressure we're +// trying to avoid). 1 MiB strikes a balance between amortizing IPC overhead +// and keeping per-write allocations modest. +const MAX_BATCH_BYTES = 1024 * 1024; export class AhpJsonlLogger extends Disposable { @@ -34,6 +40,8 @@ export class AhpJsonlLogger extends Disposable { private _currentSize = 0; private _segment = 0; private _queue = Promise.resolve(); + private _pending: VSBuffer[] = []; + private _drainScheduled = false; private _folderCreated: Promise | undefined; constructor( @@ -67,17 +75,37 @@ export class AhpJsonlLogger extends Disposable { } }; const line = `${stringifyAhpLogEntry(entry)}\n`; - const buffer = VSBuffer.fromString(line); - this._queue = this._queue.then(() => this._appendLine(buffer)).catch(error => { - this._logService.error('[AHPLog] Failed to write transport log', error); - }); + this._pending.push(VSBuffer.fromString(line)); + this._scheduleDrain(); } async flush(): Promise { + // Pending entries always have a drain scheduled (see _scheduleDrain), so + // awaiting the queue is sufficient to flush everything submitted before + // this call. await this._queue; } - private async _appendLine(buffer: VSBuffer): Promise { + private _scheduleDrain(): void { + if (this._drainScheduled) { + return; + } + this._drainScheduled = true; + this._queue = this._queue.then(() => this._drainPending()).catch(error => { + this._logService.error('[AHPLog] Failed to write transport log', error); + }); + } + + private async _drainPending(): Promise { + // Clear the scheduled flag before snapshotting _pending so that any log() + // calls happening during the awaits below will schedule a fresh drain. + this._drainScheduled = false; + if (this._pending.length === 0) { + return; + } + const buffers = this._pending; + this._pending = []; + // Create folder once and memoize to avoid repeated filesystem calls if (!this._folderCreated) { this._folderCreated = this._fileService.createFolder(this._directory); @@ -86,11 +114,37 @@ export class AhpJsonlLogger extends Disposable { if (this._currentSize === 0) { this._currentSize = await this._getFileSize(this._currentFile); } - if (this._currentSize > 0 && this._currentSize + buffer.byteLength > this._maxFileSizeBytes) { - await this._rotate(); + + // Coalesce buffers into chunks, respecting both file-rotation size and the + // per-write batch cap. Rotation is checked per-entry to preserve the + // invariant that we don't exceed maxFileSizeBytes once a file has data. + let chunk: VSBuffer[] = []; + let chunkSize = 0; + const flushChunk = async () => { + if (chunk.length === 0) { + return; + } + const combined = chunk.length === 1 ? chunk[0] : VSBuffer.concat(chunk, chunkSize); + await this._fileService.writeFile(this._currentFile, combined, { append: true }); + this._currentSize += combined.byteLength; + chunk = []; + chunkSize = 0; + }; + + for (const buffer of buffers) { + const totalInFile = this._currentSize + chunkSize; + if (totalInFile > 0 && totalInFile + buffer.byteLength > this._maxFileSizeBytes) { + await flushChunk(); + await this._rotate(); + } else if (chunkSize > 0 && chunkSize + buffer.byteLength > MAX_BATCH_BYTES) { + // Same file but the batch is getting too large; flush early to + // avoid creating an oversized concatenated VSBuffer. + await flushChunk(); + } + chunk.push(buffer); + chunkSize += buffer.byteLength; } - await this._fileService.writeFile(this._currentFile, buffer, { append: true }); - this._currentSize += buffer.byteLength; + await flushChunk(); } private async _rotate(): Promise { @@ -127,29 +181,25 @@ export function getAhpLogByteLength(text: string): number { } export function stringifyAhpLogEntry(value: unknown): string { - return JSON.stringify(_replaceUris(value)); + return JSON.stringify(value, _ahpReplacer); } /** - * Recursively replaces {@link URI} instances with their string form before - * handing the value to JSON.stringify. A replacer function is NOT sufficient - * because JSON.stringify calls toJSON() on an object *before* invoking the - * replacer, so the replacer would receive a plain UriComponents object rather - * than the original URI instance, and URI.isUri() would return false for it. + * JSON.stringify replacer that converts URI values to their canonical string + * form. `URI.prototype.toJSON()` runs before this replacer is invoked and + * produces a {@link UriComponents}-shaped object stamped with + * `$mid: MarshalledId.Uri`, which we detect here to round-trip back through + * {@link URI.revive}. This avoids the expensive deep-clone tree walk that + * would otherwise be required to find every URI in a message payload. */ -function _replaceUris(value: unknown): unknown { - if (URI.isUri(value)) { - return value.toString(); - } - if (Array.isArray(value)) { - return value.map(_replaceUris); - } - if (value !== null && typeof value === 'object') { - const result: Record = {}; - for (const key of Object.keys(value)) { - result[key] = _replaceUris((value as Record)[key]); - } - return result; +function _ahpReplacer(this: unknown, _key: string, value: unknown): unknown { + if ( + value + && typeof value === 'object' + && (value as { $mid?: number }).$mid === MarshalledId.Uri + && isUriComponents(value) + ) { + return URI.revive(value as UriComponents).toString(); } return value; } diff --git a/src/vs/platform/agentHost/test/common/ahpJsonlLogger.test.ts b/src/vs/platform/agentHost/test/common/ahpJsonlLogger.test.ts index f90ebcdf98872..6f46d3f3e4ee5 100644 --- a/src/vs/platform/agentHost/test/common/ahpJsonlLogger.test.ts +++ b/src/vs/platform/agentHost/test/common/ahpJsonlLogger.test.ts @@ -8,9 +8,10 @@ import { basename, dirname, joinPath } from '../../../../base/common/resources.j import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { FileService } from '../../../files/common/fileService.js'; +import { IFileWriteOptions } from '../../../files/common/files.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; import { NullLogService } from '../../../log/common/log.js'; -import { AhpJsonlLogger, getAhpLogByteLength } from '../../common/ahpJsonlLogger.js'; +import { AhpJsonlLogger, getAhpLogByteLength, stringifyAhpLogEntry } from '../../common/ahpJsonlLogger.js'; suite('AhpJsonlLogger', () => { @@ -147,4 +148,121 @@ suite('AhpJsonlLogger', () => { rootsAreJsonRpc: true, }); }); + + test('coalesces synchronously queued log calls into a single write', async () => { + const fileService = store.add(new FileService(new NullLogService())); + const provider = store.add(new RecordingInMemoryFileSystemProvider()); + store.add(fileService.registerProvider('file', provider)); + + const logger = store.add(new AhpJsonlLogger( + { logsHome: URI.file('/logs'), connectionId: 'batched', transport: 'websocket' }, + fileService, + new NullLogService(), + )); + + const messageCount = 50; + for (let i = 0; i < messageCount; i++) { + logger.log({ jsonrpc: '2.0', id: i, result: { ok: true } }, 's2c'); + } + await logger.flush(); + + const content = (await fileService.readFile(logger.resource)).value.toString(); + const lines = content.split('\n').filter(Boolean); + const ids = lines.map(line => JSON.parse(line).id); + + // All 50 log() calls are queued synchronously, so they all land in the + // first drain and must be coalesced into exactly one writeFile. + assert.deepStrictEqual({ + lineCount: lines.length, + idsInOrder: ids, + writeCount: provider.writeCount, + }, { + lineCount: messageCount, + idsInOrder: Array.from({ length: messageCount }, (_, i) => i), + writeCount: 1, + }); + }); + + test('flush waits for batched writes and ordering is preserved across drains', async () => { + const fileService = store.add(new FileService(new NullLogService())); + store.add(fileService.registerProvider('file', store.add(new InMemoryFileSystemProvider()))); + + const logger = store.add(new AhpJsonlLogger( + { logsHome: URI.file('/logs'), connectionId: 'flush-order', transport: 'websocket' }, + fileService, + new NullLogService(), + )); + + // Submit a batch, partially flush, then submit another batch interleaved + // with the flush — ordering must be preserved. + logger.log({ jsonrpc: '2.0', id: 1, result: 'a' }, 's2c'); + logger.log({ jsonrpc: '2.0', id: 2, result: 'b' }, 's2c'); + const firstFlush = logger.flush(); + logger.log({ jsonrpc: '2.0', id: 3, result: 'c' }, 's2c'); + await firstFlush; + logger.log({ jsonrpc: '2.0', id: 4, result: 'd' }, 's2c'); + await logger.flush(); + + const content = (await fileService.readFile(logger.resource)).value.toString(); + const ids = content.split('\n').filter(Boolean).map(line => JSON.parse(line).id); + assert.deepStrictEqual(ids, [1, 2, 3, 4]); + }); + + suite('stringifyAhpLogEntry', () => { + + test('serialises a top-level URI as its string form', () => { + const uri = URI.parse('file:///tmp/example.txt'); + const result = JSON.parse(stringifyAhpLogEntry({ uri })); + assert.strictEqual(result.uri, uri.toString()); + }); + + test('serialises URIs nested in arrays and objects', () => { + const a = URI.parse('file:///a'); + const b = URI.parse('https://example.com/b?x=1'); + const c = URI.parse('untitled:Untitled-1'); + const payload = { + items: [a, { nested: b }, [c]], + }; + const result = JSON.parse(stringifyAhpLogEntry(payload)); + assert.deepStrictEqual(result, { + items: [a.toString(), { nested: b.toString() }, [c.toString()]], + }); + }); + + test('round-trips raw UriComponents marked with $mid', () => { + const uri = URI.parse('vscode://example/path'); + const components = uri.toJSON(); + // Simulate a value that came back over IPC and was never revived + const result = JSON.parse(stringifyAhpLogEntry({ uri: components })); + assert.strictEqual(result.uri, uri.toString()); + }); + + test('leaves URI-shaped objects without $mid as plain objects', () => { + // A user payload that happens to have URI-like fields but is not a + // URI must not be silently rewritten. + const payload = { + scheme: 'not-a-uri', + path: '/something', + }; + const result = JSON.parse(stringifyAhpLogEntry(payload)); + assert.deepStrictEqual(result, payload); + }); + + test('does not misidentify non-URI objects that carry $mid: 1', () => { + // $mid is only safely a URI marker when the object also has the + // UriComponents shape (scheme: string). Non-conforming payloads + // must pass through unchanged. + const payload = { $mid: 1, label: 'not a uri' }; + const result = JSON.parse(stringifyAhpLogEntry(payload)); + assert.deepStrictEqual(result, payload); + }); + }); }); + +class RecordingInMemoryFileSystemProvider extends InMemoryFileSystemProvider { + writeCount = 0; + override async writeFile(resource: URI, content: Uint8Array, opts: IFileWriteOptions): Promise { + this.writeCount++; + return super.writeFile(resource, content, opts); + } +} From 43f897105404c13a9b9134a9f7d34dc4bbb8e04a Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Fri, 29 May 2026 11:53:43 -0700 Subject: [PATCH 04/27] remove policy codeowners (#319038) --- .github/CODEOWNERS | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 345012c059ef4..6f935309dc690 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -10,9 +10,6 @@ .github/workflows/pr.yml @lszomoru @alexdima @joaomoreno @TylerLeonhardt @rzhao271 @Yoyokrazy .github/workflows/telemetry.yml @lramos15 @lszomoru @alexdima @joaomoreno -# Ensure those that manage generated policy are aware of changes -build/lib/policies/policyData.jsonc @joshspicer @rebornix @alexdima @joaomoreno @pwang347 @sandy081 - # VS Code API # Ensure the API team is aware of changes to the vscode-dts file # this is only about the final API, not about proposed API changes From 5b0f4fc7ed8b25641763ada5c5b50067957a002f Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Fri, 29 May 2026 12:04:44 -0700 Subject: [PATCH 05/27] Include http in Xaa Issuer (#319041) so that you can use localhost testing. --- src/vs/workbench/api/browser/mainThreadMcp.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/api/browser/mainThreadMcp.ts b/src/vs/workbench/api/browser/mainThreadMcp.ts index a6f6a54e3c213..26a9a97b3054f 100644 --- a/src/vs/workbench/api/browser/mainThreadMcp.ts +++ b/src/vs/workbench/api/browser/mainThreadMcp.ts @@ -343,10 +343,10 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { try { parsed = URI.parse(configuredIssuer); } catch { - throw new Error(nls.localize('mcp.enterpriseManaged.issuerInvalid', "Enterprise-managed MCP authentication requires `mcp.enterpriseManagedAuth.idp.issuer` to be a valid `https://` URL; got '{0}'.", configuredIssuer)); + throw new Error(nls.localize('mcp.enterpriseManaged.issuerInvalid', "Enterprise-managed MCP authentication requires `mcp.enterpriseManagedAuth.idp.issuer` to be a valid URL; got '{0}'.", configuredIssuer)); } - if (parsed.scheme !== 'https') { - throw new Error(nls.localize('mcp.enterpriseManaged.issuerNotHttps', "Enterprise-managed MCP authentication requires `mcp.enterpriseManagedAuth.idp.issuer` to use the `https` scheme; got '{0}'.", configuredIssuer)); + if (parsed.scheme !== 'https' && parsed.scheme !== 'http') { + throw new Error(nls.localize('mcp.enterpriseManaged.issuerNotHttp', "Enterprise-managed MCP authentication requires `mcp.enterpriseManagedAuth.idp.issuer` to use the `https` or `http` scheme; got '{0}'.", configuredIssuer)); } return parsed; } From 841d3b6bbe4dfb2e879cffd4f00d08be18556630 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Fri, 29 May 2026 22:18:35 +0200 Subject: [PATCH 06/27] Agents - fix disposable leak in the account widget (#319046) * Agents - fix disposable leak in the account widget * Fix mobiile titlebar part as well --- src/vs/sessions/browser/parts/mobile/mobileTitlebarPart.ts | 2 +- .../contrib/accountMenu/browser/account.contribution.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/browser/parts/mobile/mobileTitlebarPart.ts b/src/vs/sessions/browser/parts/mobile/mobileTitlebarPart.ts index 5f03862e4a8e9..a365928e775ab 100644 --- a/src/vs/sessions/browser/parts/mobile/mobileTitlebarPart.ts +++ b/src/vs/sessions/browser/parts/mobile/mobileTitlebarPart.ts @@ -315,7 +315,7 @@ export class MobileTitlebarPart extends Disposable { this.renderAccountState(); const info = await resolveAccountInfo(this.defaultAccountService, this.authenticationService); - if (requestId !== this.accountRequestCounter) { + if (requestId !== this.accountRequestCounter || this._store.isDisposed) { return; } diff --git a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts index 7309bc45986c1..530789e03b13a 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts +++ b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts @@ -230,7 +230,7 @@ class TitleBarAccountWidget extends BaseActionViewItem { this.renderState(); const info = await resolveAccountInfo(this.defaultAccountService, this.authenticationService); - if (requestId !== this.accountRequestCounter) { + if (requestId !== this.accountRequestCounter || this._store.isDisposed) { return; } From ef7fbda165bab1039e62fd97165d378a1462a8a6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 20:24:59 +0000 Subject: [PATCH 07/27] build(deps-dev): bump tmp from 0.2.4 to 0.2.6 in /test/integration/browser (#319036) build(deps-dev): bump tmp in /test/integration/browser Bumps [tmp](https://github.com/raszi/node-tmp) from 0.2.4 to 0.2.6. - [Changelog](https://github.com/raszi/node-tmp/blob/master/CHANGELOG.md) - [Commits](https://github.com/raszi/node-tmp/compare/v0.2.4...v0.2.6) --- updated-dependencies: - dependency-name: tmp dependency-version: 0.2.6 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- test/integration/browser/package-lock.json | 8 ++++---- test/integration/browser/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/test/integration/browser/package-lock.json b/test/integration/browser/package-lock.json index e395ce64cb9f1..1f44058fcea8e 100644 --- a/test/integration/browser/package-lock.json +++ b/test/integration/browser/package-lock.json @@ -13,7 +13,7 @@ "@types/rimraf": "^2.0.4", "@types/tmp": "0.1.0", "rimraf": "^2.6.1", - "tmp": "0.2.4", + "tmp": "0.2.6", "tree-kill": "1.2.2", "vscode-uri": "^3.0.2" } @@ -179,9 +179,9 @@ } }, "node_modules/tmp": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.4.tgz", - "integrity": "sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ==", + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.6.tgz", + "integrity": "sha512-5sJPdPjfI5Kx+qbrDesxkglRBxW//g7hCsqspEjwkewGvBMGIKMOTKzLt1hFVJzyadba3lDUN20O9qhvbQUSTA==", "dev": true, "license": "MIT", "engines": { diff --git a/test/integration/browser/package.json b/test/integration/browser/package.json index c86ee3a278cbc..b48e2564bf9c7 100644 --- a/test/integration/browser/package.json +++ b/test/integration/browser/package.json @@ -11,7 +11,7 @@ "@types/rimraf": "^2.0.4", "@types/tmp": "0.1.0", "rimraf": "^2.6.1", - "tmp": "0.2.4", + "tmp": "0.2.6", "tree-kill": "1.2.2", "vscode-uri": "^3.0.2" } From 3633ce072870313414062364737a795bd8ded927 Mon Sep 17 00:00:00 2001 From: Paul Date: Fri, 29 May 2026 13:26:44 -0700 Subject: [PATCH 08/27] Support model hovers in cloud model picker (#318657) --- extensions/copilot/package-lock.json | 8 +- extensions/copilot/package.json | 2 +- .../copilotcli/node/copilotCli.ts | 35 ++------ .../copilotCloudSessionsProvider.ts | 56 ++++++++++-- .../common/languageModelAccess.ts | 86 ++++++++++++++++++- .../test/languageModelAccess.test.ts | 86 ++++++++++++++++++- .../platform/endpoint/node/chatEndpoint.ts | 65 ++------------ src/typings/copilot-api.d.ts | 18 ++++ .../browser/modelPicker.ts | 57 +++++++++++- .../chatSessionPickerActionItem.ts | 50 +++++++++++ .../browser/widget/input/chatModelPicker.ts | 2 +- .../chat/common/chatSessionsService.ts | 26 ++++++ .../vscode.proposed.chatSessionsProvider.d.ts | 42 +++++++++ 13 files changed, 431 insertions(+), 102 deletions(-) diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index 5ad41da2f2bba..04de8280581a9 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -32,7 +32,7 @@ "@opentelemetry/sdk-trace-node": "^2.5.1", "@opentelemetry/semantic-conventions": "^1.39.0", "@sinclair/typebox": "^0.34.41", - "@vscode/copilot-api": "^0.4.2", + "@vscode/copilot-api": "^0.4.3", "@vscode/extension-telemetry": "^1.5.1", "@vscode/l10n": "^0.0.18", "@vscode/prompt-tsx": "^0.4.0-alpha.8", @@ -6929,9 +6929,9 @@ } }, "node_modules/@vscode/copilot-api": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@vscode/copilot-api/-/copilot-api-0.4.2.tgz", - "integrity": "sha512-pnX2wi9Wc3umrNSodMGOMKTVDLHzXIqtSJptISdzNZ1dJkdhPZUtKSjKf4jjHS/c+LYNRf8Tzl0kXszrl8wmfw==", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@vscode/copilot-api/-/copilot-api-0.4.3.tgz", + "integrity": "sha512-mhbygbaXlrldMO4rKjGdu+V3T0VD9j1Nmq2RvDWpMFP2l911sojGZNMGCjSwEYOuAZl3Dcycr1csT7mPMRiK1A==", "license": "SEE LICENSE" }, "node_modules/@vscode/debugadapter": { diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index ecedf3954c0eb..1267e2329fefe 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -6921,7 +6921,7 @@ "@opentelemetry/sdk-trace-node": "^2.5.1", "@opentelemetry/semantic-conventions": "^1.39.0", "@sinclair/typebox": "^0.34.41", - "@vscode/copilot-api": "^0.4.2", + "@vscode/copilot-api": "^0.4.3", "@vscode/extension-telemetry": "^1.5.1", "@vscode/l10n": "^0.0.18", "@vscode/prompt-tsx": "^0.4.0-alpha.8", diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts index 953fa06b3edc8..113533b158436 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts @@ -26,7 +26,7 @@ import { IInstantiationService } from '../../../../util/vs/platform/instantiatio import { ensureNodePtyShim } from './nodePtyShim'; import { ensureRipgrepShim } from './ripgrepShim'; import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; -import { getModelCapabilitiesDescription } from '../../../conversation/common/languageModelAccess'; +import { getModelCapabilitiesDescription, normalizeTokenPrices } from '../../../conversation/common/languageModelAccess'; export const COPILOT_CLI_REASONING_EFFORT_PROPERTY = 'reasoningEffort'; const COPILOT_CLI_MODEL_MEMENTO_KEY = 'github.copilot.cli.sessionModel'; @@ -160,16 +160,15 @@ export class CopilotCLIModels extends Disposable implements ICopilotCLIModels { try { const models = await getAvailableModels(authInfo); return models.map(model => { - const tokenPrices = model.billing?.token_prices; - const normalizedPricing = normalizeTokenPricing(tokenPrices); + const pricing = normalizeTokenPrices(model.billing?.token_prices); return { id: model.id, name: model.name, multiplier: model.billing?.multiplier, priceCategory: model.model_picker_price_category, - inputCost: normalizedPricing?.inputPrice, - outputCost: normalizedPricing?.outputPrice, - cacheCost: normalizedPricing?.cachePrice, + inputCost: pricing?.default.inputPrice, + outputCost: pricing?.default.outputPrice, + cacheCost: pricing?.default.cachePrice, maxInputTokens: model.capabilities.limits.max_prompt_tokens, maxOutputTokens: model.capabilities.limits.max_output_tokens, maxContextWindowTokens: model.capabilities.limits.max_context_window_tokens, @@ -192,7 +191,7 @@ export class CopilotCLIModels extends Disposable implements ICopilotCLIModels { const provider: vscode.LanguageModelChatProvider = { onDidChangeLanguageModelChatInformation: this._onDidChange.event, provideLanguageModelChatInformation: async (_options, _token) => { - return this._resolvedModelInfos?? []; + return this._resolvedModelInfos ?? []; }, provideLanguageModelChatResponse: async (_model, _messages, _options, _progress, _token) => { // Implemented via chat participants. @@ -633,25 +632,3 @@ export function isEnabledForCopilotCLI(customization: { sessionTypes?: readonly const sessionTypes = customization.sessionTypes; return sessionTypes === undefined || sessionTypes.includes('copilotcli') || false; } - -const AIC_DIVISOR = 1_000_000_000; -const TOKENS_PER_MILLION = 1_000_000; - -/** - * Converts raw billing token prices (nano-AICs with a batch_size) into - * normalized AICs per million tokens, matching the normalization in - * chatEndpoint.ts for non-CLI models. - */ -function normalizeTokenPricing(tokenPrices: { input_price?: number; output_price?: number; cache_price?: number; batch_size?: number } | undefined): { inputPrice: number; outputPrice: number; cachePrice: number | undefined } | undefined { - if (!tokenPrices || tokenPrices.input_price === undefined || tokenPrices.output_price === undefined) { - return undefined; - } - const batchSize = tokenPrices.batch_size ?? TOKENS_PER_MILLION; - const scale = TOKENS_PER_MILLION / batchSize; - return { - inputPrice: (tokenPrices.input_price / AIC_DIVISOR) * scale, - outputPrice: (tokenPrices.output_price / AIC_DIVISOR) * scale, - cachePrice: tokenPrices.cache_price !== undefined ? (tokenPrices.cache_price / AIC_DIVISOR) * scale : undefined, - }; -} - diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts index 30eb06cebb867..a0fb0312c46b9 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts @@ -17,6 +17,7 @@ import { IGitExtensionService } from '../../../platform/git/common/gitExtensionS import { GithubRepoId, IGitService } from '../../../platform/git/common/gitService'; import { derivePullRequestState, PullRequestSearchItem, SessionInfo } from '../../../platform/github/common/githubAPI'; import { AuthOptions, CCAEnabledResult, IGithubRepositoryService, IOctoKitService } from '../../../platform/github/common/githubService'; +import { getModelCapabilitiesDescription, normalizeTokenPrices } from '../../conversation/common/languageModelAccess'; import { ILogService } from '../../../platform/log/common/logService'; import { emitCloudSessionInvokeEvent } from '../../../platform/otel/common/genAiEvents'; import { GenAiMetrics } from '../../../platform/otel/common/genAiMetrics'; @@ -996,13 +997,56 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C if (models.status === 'fulfilled' && models.value.length > 0) { const isUBB = !!this._authenticationService.copilotToken?.isUsageBasedBilling; - const modelItems: vscode.ChatSessionProviderOptionItem[] = models.value.map(model => ({ - id: model.id, - name: model.name, - ...(!isUBB && model.billing?.multiplier !== undefined ? { description: `${model.billing.multiplier}x` } : {}), - })); + + const modelItems: vscode.ChatSessionProviderOptionItem[] = models.value.map(model => { + const limits = model.capabilities?.limits; + const multiplier = model.billing?.multiplier; + const pricing = normalizeTokenPrices(model.billing?.token_prices); + const family = model.capabilities?.family ?? model.id; + const tooltip = getModelCapabilitiesDescription({ name: model.name, family }); + + return { + id: model.id, + name: model.name, + ...(!isUBB && multiplier !== undefined ? { description: `${multiplier}x` } : {}), + tooltip, + modelMetadata: { + name: model.name, + id: model.id, + vendor: model.vendor, + version: model.version, + family, + tooltip, + multiplierNumeric: multiplier, + pricing: !isUBB && multiplier !== undefined ? `${multiplier}x` : undefined, + maxInputTokens: limits?.max_prompt_tokens ?? 0, + maxOutputTokens: limits?.max_output_tokens ?? 0, + inputCost: pricing?.default.inputPrice, + outputCost: pricing?.default.outputPrice, + cacheCost: pricing?.default.cachePrice, + longContextInputCost: pricing?.longContext?.inputPrice, + longContextOutputCost: pricing?.longContext?.outputPrice, + longContextCacheCost: pricing?.longContext?.cachePrice, + priceCategory: model.model_picker_price_category, + capabilities: { + vision: model.capabilities?.supports?.vision ?? false, + toolCalling: model.capabilities?.supports?.tool_calls ?? false, + }, + }, + }; + }); if (!models.value.find(m => m.id === DEFAULT_MODEL_ID)) { - modelItems.unshift({ id: DEFAULT_MODEL_ID, name: vscode.l10n.t('Auto'), description: vscode.l10n.t('Automatically select the best model') }); + modelItems.unshift({ + id: DEFAULT_MODEL_ID, + name: vscode.l10n.t('Auto'), + description: vscode.l10n.t('Automatically select the best model'), + tooltip: vscode.l10n.t('Automatically select the best model'), + modelMetadata: { + name: vscode.l10n.t('Auto'), + id: DEFAULT_MODEL_ID, + tooltip: vscode.l10n.t('Automatically select the best model'), + }, + }); } optionGroups.push({ id: MODELS_OPTION_GROUP_ID, diff --git a/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts index d6e53c189c745..c0ff74794b3bb 100644 --- a/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts +++ b/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts @@ -63,7 +63,7 @@ export function buildReasoningEffortSchemaProperty(effortLevels: readonly string * Returns a description of the model's capabilities and intended use cases. * This is shown in the rich hover when selecting models. */ -export function getModelCapabilitiesDescription(endpoint: IChatEndpoint | LanguageModelChatInformation): string | undefined { +export function getModelCapabilitiesDescription(endpoint: IChatEndpoint | LanguageModelChatInformation | { name: string; family: string }): string | undefined { const name = endpoint.name.toLowerCase(); const family = endpoint.family.toLowerCase(); @@ -138,3 +138,87 @@ export function formatPricingLabel(pricing: IChatEndpointTokenPricing): string { formatAicPrice(pricing.default.outputPrice), ); } + +const TOKENS_PER_MILLION = 1_000_000; +const NANO_AIU_DIVISOR = 1_000_000_000; + +/** + * Raw token prices from the CAPI billing response. Supports both the tiered + * format (API 2026-06-01+, prices in AIUs) and the legacy flat format + * (pre-2026-06-01, prices in nano-AIUs). + */ +export interface IRawTokenPrices { + batch_size?: number; + input_price?: number; + cache_price?: number; + output_price?: number; + default?: { input_price?: number; cache_price?: number; output_price?: number; context_max?: number }; + long_context?: { input_price?: number; cache_price?: number; output_price?: number; context_max?: number }; +} + +export interface INormalizedPriceTier { + readonly inputPrice: number; + readonly outputPrice: number; + readonly cachePrice: number | undefined; + readonly contextMax?: number; +} + +export interface INormalizedTokenPricing { + readonly default: INormalizedPriceTier; + readonly longContext?: INormalizedPriceTier; +} + +/** + * Converts raw billing token prices into normalized AICs (credits) per million tokens. + * Handles both tiered (AIU) and legacy flat (nano-AIU) formats. + * + * When a `long_context` tier is present but its prices match the `default` tier, + * it is omitted from the result. + */ +export function normalizeTokenPrices(tokenPrices: IRawTokenPrices | undefined): INormalizedTokenPricing | undefined { + if (!tokenPrices) { + return undefined; + } + const batchSize = tokenPrices.batch_size ?? TOKENS_PER_MILLION; + const scale = TOKENS_PER_MILLION / batchSize; + const defaultTier = tokenPrices.default; + + if (defaultTier && defaultTier.input_price !== undefined && defaultTier.output_price !== undefined) { + // Tiered format (API 2026-06-01+): values are in AIUs + const normalized: INormalizedPriceTier = { + inputPrice: defaultTier.input_price * scale, + outputPrice: defaultTier.output_price * scale, + cachePrice: defaultTier.cache_price !== undefined ? defaultTier.cache_price * scale : undefined, + contextMax: defaultTier.context_max, + }; + let longContext: INormalizedPriceTier | undefined; + const lc = tokenPrices.long_context; + if (lc && lc.input_price !== undefined && lc.output_price !== undefined) { + const lcNormalized: INormalizedPriceTier = { + inputPrice: lc.input_price * scale, + outputPrice: lc.output_price * scale, + cachePrice: lc.cache_price !== undefined ? lc.cache_price * scale : undefined, + contextMax: lc.context_max, + }; + // Only include long-context tier when prices differ from default + if (lcNormalized.inputPrice !== normalized.inputPrice + || lcNormalized.outputPrice !== normalized.outputPrice + || lcNormalized.cachePrice !== normalized.cachePrice) { + longContext = lcNormalized; + } + } + return { default: normalized, longContext }; + } + + // Legacy flat format (pre-2026-06-01): values are in nano-AIUs + if (tokenPrices.input_price === undefined || tokenPrices.output_price === undefined) { + return undefined; + } + return { + default: { + inputPrice: (tokenPrices.input_price / NANO_AIU_DIVISOR) * scale, + outputPrice: (tokenPrices.output_price / NANO_AIU_DIVISOR) * scale, + cachePrice: tokenPrices.cache_price !== undefined ? (tokenPrices.cache_price / NANO_AIU_DIVISOR) * scale : undefined, + }, + }; +} diff --git a/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts b/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts index 4d7364f8dde81..d8f5f0ac2d2a8 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts @@ -25,7 +25,7 @@ import { Event } from '../../../../util/vs/base/common/event'; import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; import { createExtensionTestingServices } from '../../../test/vscode-node/services'; import { buildUtilityAliasModelInfo, CopilotLanguageModelWrapper, LanguageModelAccess } from '../languageModelAccess'; -import { buildReasoningEffortSchemaProperty, pickDefaultReasoningEffort } from '../../common/languageModelAccess'; +import { buildReasoningEffortSchemaProperty, normalizeTokenPrices, pickDefaultReasoningEffort } from '../../common/languageModelAccess'; suite('CopilotLanguageModelWrapper', () => { @@ -386,3 +386,87 @@ suite('reasoning effort schema', () => { assert.strictEqual(prop.group, 'navigation'); }); }); + +suite('normalizeTokenPrices', () => { + test('returns undefined for undefined input', () => { + assert.strictEqual(normalizeTokenPrices(undefined), undefined); + }); + + test('returns undefined when flat fields are missing', () => { + assert.strictEqual(normalizeTokenPrices({ batch_size: 1_000_000 }), undefined); + assert.strictEqual(normalizeTokenPrices({ input_price: 100 }), undefined); + }); + + test('converts legacy flat nano-AIU prices to credits per 1M tokens', () => { + const result = normalizeTokenPrices({ + batch_size: 1_000_000, + input_price: 3_000_000_000, + output_price: 15_000_000_000, + cache_price: 375_000_000, + }); + assert.ok(result); + assert.strictEqual(result.default.inputPrice, 3); + assert.strictEqual(result.default.outputPrice, 15); + assert.strictEqual(result.default.cachePrice, 0.375); + assert.strictEqual(result.longContext, undefined); + }); + + test('scales legacy prices when batch_size differs from 1M', () => { + const result = normalizeTokenPrices({ + batch_size: 500_000, + input_price: 1_500_000_000, + output_price: 7_500_000_000, + }); + assert.ok(result); + assert.strictEqual(result.default.inputPrice, 3); + assert.strictEqual(result.default.outputPrice, 15); + assert.strictEqual(result.default.cachePrice, undefined); + }); + + test('defaults batch_size to 1M when missing', () => { + const result = normalizeTokenPrices({ + input_price: 1_000_000_000, + output_price: 2_000_000_000, + }); + assert.ok(result); + assert.strictEqual(result.default.inputPrice, 1); + assert.strictEqual(result.default.outputPrice, 2); + }); + + test('converts tiered AIU prices to credits per 1M tokens', () => { + const result = normalizeTokenPrices({ + batch_size: 1_000_000, + default: { input_price: 3, output_price: 15, cache_price: 0.375 }, + }); + assert.ok(result); + assert.strictEqual(result.default.inputPrice, 3); + assert.strictEqual(result.default.outputPrice, 15); + assert.strictEqual(result.default.cachePrice, 0.375); + assert.strictEqual(result.longContext, undefined); + }); + + test('includes long-context tier when present', () => { + const result = normalizeTokenPrices({ + batch_size: 1_000_000, + default: { input_price: 3, output_price: 15, cache_price: 0.375 }, + long_context: { input_price: 6, output_price: 30, cache_price: 0.75 }, + }); + assert.ok(result); + assert.strictEqual(result.default.inputPrice, 3); + assert.strictEqual(result.longContext?.inputPrice, 6); + assert.strictEqual(result.longContext?.outputPrice, 30); + assert.strictEqual(result.longContext?.cachePrice, 0.75); + }); + + test('tiered format takes precedence over flat fields', () => { + const result = normalizeTokenPrices({ + batch_size: 1_000_000, + input_price: 999_999_999, + output_price: 999_999_999, + default: { input_price: 3, output_price: 15 }, + }); + assert.ok(result); + assert.strictEqual(result.default.inputPrice, 3); + assert.strictEqual(result.default.outputPrice, 15); + }); +}); diff --git a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts index 783cb0e5db466..0826bc04f462f 100644 --- a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts +++ b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts @@ -19,7 +19,7 @@ import { ILogService } from '../../log/common/logService'; import { isAnthropicContextEditingEnabled, isExtendedCacheTtlEnabled } from '../../networking/common/anthropic'; import { FinishedCallback, getRequestId, ICopilotToolCall, OptionalChatRequestParams } from '../../networking/common/fetch'; import { IFetcherService, Response } from '../../networking/common/fetcherService'; -import { createCapiRequestBody, IChatEndpoint, IChatEndpointTokenPricing, ICreateEndpointBodyOptions, IEndpointBody, IMakeChatRequestOptions, InteractionTypeOverride, ITokenPriceTier } from '../../networking/common/networking'; +import { createCapiRequestBody, IChatEndpoint, IChatEndpointTokenPricing, ICreateEndpointBodyOptions, IEndpointBody, IMakeChatRequestOptions, InteractionTypeOverride } from '../../networking/common/networking'; import { CAPIChatMessage, ChatCompletion, FinishedCompletionReason, RawMessageConversionCallback } from '../../networking/common/openai'; import { prepareChatCompletionForReturn } from '../../networking/node/chatStream'; import { IChatWebSocketManager } from '../../networking/node/chatWebSocketManager'; @@ -31,7 +31,8 @@ import { ITokenizerProvider } from '../../tokenizer/node/tokenizer'; import { ICAPIClientService } from '../common/capiClient'; import { getModelCapabilityOverride, isAnthropicFamily, isGeminiFamily, modelSupportsContextEditing, modelSupportsToolSearch } from '../common/chatModelCapabilities'; import { IDomainService } from '../common/domainService'; -import { CustomModel, IChatModelInformation, IModelTokenPriceTier, IModelTokenPrices, ModelSupportedEndpoint } from '../common/endpointProvider'; +import { CustomModel, IChatModelInformation, ModelSupportedEndpoint } from '../common/endpointProvider'; +import { normalizeTokenPrices } from '../../../extension/conversation/common/languageModelAccess'; import { createMessagesRequestBody, processResponseFromMessagesEndpoint } from './messagesApi'; import { createResponsesRequestBody, getResponsesApiCompactionThreshold, processResponseFromChatEndpoint } from './responsesApi'; import { filterHistoryImages } from './imageLimits'; @@ -112,60 +113,6 @@ export async function defaultNonStreamChatResponseProcessor(response: Response, return AsyncIterableObject.fromArray(completions); } -const TOKENS_PER_MILLION = 1_000_000; - -/** - * Normalizes a single raw price tier into AICs per million tokens. - * - * Prices in the tiered structure (`default` / `long_context`) are already - * denominated in AIUs, so no nano-AIU conversion is needed — only the - * batch_size scaling is applied. - */ -function normalizePriceTier(tier: IModelTokenPriceTier, scale: number): ITokenPriceTier { - return { - inputPrice: tier.input_price * scale, - outputPrice: tier.output_price * scale, - cacheReadTokenPrice: tier.cache_price * scale, - contextMax: tier.context_max, - }; -} - -function areTierPricesEqual(a: ITokenPriceTier, b: ITokenPriceTier): boolean { - return a.inputPrice === b.inputPrice - && a.outputPrice === b.outputPrice - && a.cacheReadTokenPrice === b.cacheReadTokenPrice; -} - -/** - * Converts raw billing token prices into normalized AICs per million tokens. - * - * The tiered pricing structure (`default` / `long_context`) uses AIU values - * directly, scaled to per-million-token rates based on batch_size. - * - * The optional `long_context` tier is included only when its rates differ - * from the `default` tier. - */ -function normalizeTokenPricing(tokenPrices: IModelTokenPrices | undefined): IChatEndpointTokenPricing | undefined { - if (!tokenPrices) { - return undefined; - } - const scale = TOKENS_PER_MILLION / tokenPrices.batch_size; - const defaultTier = normalizePriceTier(tokenPrices.default, scale); - - let longContext: ITokenPriceTier | undefined; - if (tokenPrices.long_context) { - const lcTier = normalizePriceTier(tokenPrices.long_context, scale); - if (!areTierPricesEqual(defaultTier, lcTier)) { - longContext = lcTier; - } - } - - return { - default: defaultTier, - longContext, - }; -} - export class ChatEndpoint implements IChatEndpoint { private readonly _maxTokens: number; private readonly _maxOutputTokens: number; @@ -222,7 +169,11 @@ export class ChatEndpoint implements IChatEndpoint { this.isPremium = modelMetadata.billing?.is_premium; this.multiplier = modelMetadata.billing?.multiplier; this.restrictedToSkus = modelMetadata.billing?.restricted_to; - this.tokenPricing = normalizeTokenPricing(modelMetadata.billing?.token_prices); + const normalized = normalizeTokenPrices(modelMetadata.billing?.token_prices); + this.tokenPricing = normalized ? { + default: { inputPrice: normalized.default.inputPrice, outputPrice: normalized.default.outputPrice, cacheReadTokenPrice: normalized.default.cachePrice ?? 0, contextMax: normalized.default.contextMax }, + longContext: normalized.longContext ? { inputPrice: normalized.longContext.inputPrice, outputPrice: normalized.longContext.outputPrice, cacheReadTokenPrice: normalized.longContext.cachePrice ?? 0, contextMax: normalized.longContext.contextMax } : undefined, + } : undefined; this.priceCategory = modelMetadata.model_picker_price_category; this.isFallback = modelMetadata.is_chat_fallback; this.supportsToolCalls = !!modelMetadata.capabilities.supports.tool_calls; diff --git a/src/typings/copilot-api.d.ts b/src/typings/copilot-api.d.ts index 26bc0327db5b5..aa124913d3f91 100644 --- a/src/typings/copilot-api.d.ts +++ b/src/typings/copilot-api.d.ts @@ -88,10 +88,27 @@ declare module '@vscode/copilot-api' { makeRequest(requestOptions: MakeRequestOptions, requestMetadata: RequestMetadata): Promise; } + interface CCAModelTokenPriceTier { + input_price?: number; + cache_price?: number; + output_price?: number; + context_max?: number; + } + + interface CCAModelTokenPrices { + batch_size?: number; + input_price?: number; + cache_price?: number; + output_price?: number; + default?: CCAModelTokenPriceTier; + long_context?: CCAModelTokenPriceTier; + } + interface CCAModelBilling { is_premium: boolean; multiplier: number; restricted_to: string[]; + token_prices?: CCAModelTokenPrices; } interface CCAModelVisionLimits { @@ -138,6 +155,7 @@ declare module '@vscode/copilot-api' { is_chat_fallback: boolean; model_picker_category: string; model_picker_enabled: boolean; + model_picker_price_category?: string; name: string; object: string; policy: CCAModelPolicy; diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/modelPicker.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/modelPicker.ts index f5e0613bb76b2..e84c54806eb27 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/modelPicker.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/modelPicker.ts @@ -11,9 +11,13 @@ import { Disposable, DisposableStore } from '../../../../../base/common/lifecycl import { autorun } from '../../../../../base/common/observable.js'; import { localize } from '../../../../../nls.js'; import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; -import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../../platform/actionWidget/browser/actionList.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListItemHover } from '../../../../../platform/actionWidget/browser/actionList.js'; +import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; -import { IChatSessionProviderOptionItem, IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; +import { IChatSessionProviderOptionItem, IChatSessionProviderOptionModelMetadata, IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { getModelHoverContent } from '../../../../../workbench/contrib/chat/browser/widget/input/chatModelPicker.js'; +import { IChatEntitlementService } from '../../../../../workbench/services/chat/common/chatEntitlementService.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { CopilotChatSessionsProvider, RemoteNewSession } from './copilotChatSessionsProvider.js'; @@ -26,6 +30,8 @@ interface IModelItem { readonly id: string; readonly name: string; readonly description?: string; + readonly tooltip?: string; + readonly modelMetadata?: IChatSessionProviderOptionModelMetadata; } /** @@ -58,6 +64,8 @@ export class CloudModelPicker extends Disposable { @IChatSessionsService chatSessionsService: IChatSessionsService, @ITelemetryService private readonly telemetryService: ITelemetryService, @INewChatModelPickerService private readonly newChatModelPickerService: INewChatModelPickerService, + @IOpenerService private readonly openerService: IOpenerService, + @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, ) { super(); this._register(this.newChatModelPickerService.registerModelPicker(() => this._showPicker())); @@ -139,6 +147,8 @@ export class CloudModelPicker extends Disposable { id: item.id, name: item.name, description: item.description, + tooltip: item.tooltip, + modelMetadata: item.modelMetadata, })); // Select the session's current value, or the default, or the first @@ -202,9 +212,52 @@ export class CloudModelPicker extends Disposable { label: model.name, group: { title: '', icon: this._selectedModel?.id === model.id ? Codicon.check : Codicon.blank }, item: model, + hover: this._buildModelHover(model), })); } + private _buildModelHover(model: IModelItem): IActionListItemHover | undefined { + if (model.modelMetadata) { + const isUBB = !!this.chatEntitlementService.quotas.usageBasedBilling; + const syntheticModel = { + identifier: model.id, + metadata: { + extension: new ExtensionIdentifier(''), + name: model.modelMetadata.name, + id: model.modelMetadata.id, + vendor: model.modelMetadata.vendor ?? '', + version: model.modelMetadata.version ?? '', + family: model.modelMetadata.family ?? '', + tooltip: model.modelMetadata.tooltip, + pricing: model.modelMetadata.pricing, + multiplierNumeric: model.modelMetadata.multiplierNumeric, + inputCost: model.modelMetadata.inputCost, + outputCost: model.modelMetadata.outputCost, + cacheCost: model.modelMetadata.cacheCost, + longContextInputCost: model.modelMetadata.longContextInputCost, + longContextOutputCost: model.modelMetadata.longContextOutputCost, + longContextCacheCost: model.modelMetadata.longContextCacheCost, + priceCategory: model.modelMetadata.priceCategory, + maxInputTokens: model.modelMetadata.maxInputTokens ?? 0, + maxOutputTokens: model.modelMetadata.maxOutputTokens ?? 0, + capabilities: model.modelMetadata.capabilities ? { + vision: model.modelMetadata.capabilities.vision, + toolCalling: model.modelMetadata.capabilities.toolCalling, + } : undefined, + isDefaultForLocation: {}, + }, + }; + const hover = getModelHoverContent(syntheticModel, this.openerService, isUBB); + if (hover) { + return { content: hover.element, disposable: hover.disposable }; + } + } + if (model.tooltip) { + return { content: model.tooltip }; + } + return undefined; + } + private _selectModel(item: IModelItem): void { this._selectedModel = item; this._updateTriggerLabel(); diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts index e2043fc4877be..c656bc972f4eb 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts @@ -22,6 +22,11 @@ import { localize } from '../../../../../nls.js'; import { URI } from '../../../../../base/common/uri.js'; import { IChatInputPickerOptions } from '../widget/input/chatInputPickerActionItem.js'; import { autorun } from '../../../../../base/common/observable.js'; +import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; +import { IActionListItemHover } from '../../../../../platform/actionWidget/browser/actionList.js'; +import { getModelHoverContent } from '../widget/input/chatModelPicker.js'; +import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; export interface IChatSessionPickerDelegate { @@ -50,6 +55,8 @@ export class ChatSessionPickerActionItem extends ActionWidgetDropdownActionViewI @IKeybindingService keybindingService: IKeybindingService, @ICommandService protected readonly commandService: ICommandService, @ITelemetryService telemetryService: ITelemetryService, + @IOpenerService private readonly openerService: IOpenerService, + @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, ) { const { group, item } = initialState; const actionWithLabel: IAction = { @@ -116,6 +123,7 @@ export class ChatSessionPickerActionItem extends ActionWidgetDropdownActionViewI description: optionItem.description, tooltip: optionItem.description ?? optionItem.name, label: optionItem.name, + hover: this._buildOptionHover(optionItem), run: () => { this.delegate.setOption(optionItem); } @@ -151,6 +159,48 @@ export class ChatSessionPickerActionItem extends ActionWidgetDropdownActionViewI return actions; } + private _buildOptionHover(optionItem: IChatSessionProviderOptionItem): IActionListItemHover | undefined { + if (optionItem.modelMetadata) { + const isUBB = !!this.chatEntitlementService.quotas.usageBasedBilling; + const syntheticModel = { + identifier: optionItem.id, + metadata: { + extension: new ExtensionIdentifier(''), + name: optionItem.modelMetadata.name, + id: optionItem.modelMetadata.id, + vendor: optionItem.modelMetadata.vendor ?? '', + version: optionItem.modelMetadata.version ?? '', + family: optionItem.modelMetadata.family ?? '', + tooltip: optionItem.modelMetadata.tooltip, + pricing: optionItem.modelMetadata.pricing, + multiplierNumeric: optionItem.modelMetadata.multiplierNumeric, + inputCost: optionItem.modelMetadata.inputCost, + outputCost: optionItem.modelMetadata.outputCost, + cacheCost: optionItem.modelMetadata.cacheCost, + longContextInputCost: optionItem.modelMetadata.longContextInputCost, + longContextOutputCost: optionItem.modelMetadata.longContextOutputCost, + longContextCacheCost: optionItem.modelMetadata.longContextCacheCost, + priceCategory: optionItem.modelMetadata.priceCategory, + maxInputTokens: optionItem.modelMetadata.maxInputTokens ?? 0, + maxOutputTokens: optionItem.modelMetadata.maxOutputTokens ?? 0, + capabilities: optionItem.modelMetadata.capabilities ? { + vision: optionItem.modelMetadata.capabilities.vision, + toolCalling: optionItem.modelMetadata.capabilities.toolCalling, + } : undefined, + isDefaultForLocation: {}, + }, + }; + const hover = getModelHoverContent(syntheticModel, this.openerService, isUBB); + if (hover) { + return { content: hover.element, disposable: hover.disposable }; + } + } + if (optionItem.tooltip) { + return { content: optionItem.tooltip }; + } + return undefined; + } + /** * Creates a disabled action for a locked option. */ diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts index bf957394dddea..dbc5e39884b8c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -1381,7 +1381,7 @@ export class ModelPickerWidget extends Disposable { } -function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentifier, openerService: IOpenerService, isUBB?: boolean): { element: HTMLElement; disposable: DisposableStore } | undefined { +export function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentifier, openerService: IOpenerService, isUBB?: boolean): { element: HTMLElement; disposable: DisposableStore } | undefined { const isAuto = isAutoModel(model); const container = dom.$('.chat-model-hover'); const disposables = new DisposableStore(); diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 5d01108ec916b..a3b893449aa86 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -31,6 +31,30 @@ export interface IChatSessionCommandContribution { readonly when?: string; } +export interface IChatSessionProviderOptionModelMetadata { + readonly name: string; + readonly id: string; + readonly vendor?: string; + readonly version?: string; + readonly family?: string; + readonly tooltip?: string; + readonly pricing?: string; + readonly multiplierNumeric?: number; + readonly inputCost?: number; + readonly outputCost?: number; + readonly cacheCost?: number; + readonly longContextInputCost?: number; + readonly longContextOutputCost?: number; + readonly longContextCacheCost?: number; + readonly priceCategory?: string; + readonly maxInputTokens?: number; + readonly maxOutputTokens?: number; + readonly capabilities?: { + readonly vision?: boolean; + readonly toolCalling?: boolean; + }; +} + export interface IChatSessionProviderOptionItem { readonly id: string; readonly name: string; @@ -40,6 +64,8 @@ export interface IChatSessionProviderOptionItem { readonly icon?: ThemeIcon; readonly default?: boolean; readonly slashCommand?: string; + readonly tooltip?: string; + readonly modelMetadata?: IChatSessionProviderOptionModelMetadata; // [key: string]: any; } diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index 9f12fbe5db15c..556d4d580b25f 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -678,6 +678,48 @@ declare module 'vscode' { * unique across the provider's groups; on conflict, the first declared wins. */ readonly slashCommand?: string; + + /** + * Optional tooltip content shown in a hover panel when the user focuses or + * hovers over this item in the picker. Supports markdown formatting. + */ + readonly tooltip?: string; + + /** + * Optional model metadata for this option item. When present, the picker + * renders a rich hover with model name, pricing, context size, and capabilities + * instead of a plain text tooltip. + */ + readonly modelMetadata?: ChatSessionProviderOptionModelMetadata; + } + + /** + * Metadata describing a language model, used to render rich hover content + * in option group pickers. Fields mirror {@link LanguageModelChatInformation} + * so the core can reuse its standard model hover rendering. + */ + export interface ChatSessionProviderOptionModelMetadata { + readonly name: string; + readonly id: string; + readonly vendor?: string; + readonly version?: string; + readonly family?: string; + readonly tooltip?: string; + readonly pricing?: string; + readonly multiplierNumeric?: number; + readonly inputCost?: number; + readonly outputCost?: number; + readonly cacheCost?: number; + readonly longContextInputCost?: number; + readonly longContextOutputCost?: number; + readonly longContextCacheCost?: number; + readonly priceCategory?: string; + readonly maxInputTokens?: number; + readonly maxOutputTokens?: number; + readonly capabilities?: { + readonly vision?: boolean; + readonly toolCalling?: boolean; + }; } /** From f4adf708bd2f36edc421519f31636cddee13563b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaqu=C3=ADn=20Ruales?= <1588988+jruales@users.noreply.github.com> Date: Fri, 29 May 2026 14:00:55 -0700 Subject: [PATCH 09/27] Improvements to Area/FullPage Screenshot to Chat (#318932) --- .../browserView/common/browserView.ts | 8 +++ .../browserView/electron-main/browserView.ts | 54 +++++++++++++++---- .../features/browserEditorChatFeatures.ts | 4 +- 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index befa490c12f8a..2f78b1533b3e4 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -101,7 +101,15 @@ export interface IBrowserViewBounds { } export interface IBrowserViewCaptureScreenshotOptions { + /** + * JPEG quality from 0-100. Only applies when `format` is `'jpeg'`. + */ quality?: number; + /** + * Encoding for the captured image. Defaults to `'jpeg'`. + * `'png'` is lossless (no compression artifacts) at the cost of a larger buffer. + */ + format?: 'jpeg' | 'png'; screenRect?: IBrowserViewRect; pageRect?: IBrowserViewRect; /** diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index 7e5cdbaaea28a..aaa11833469b6 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { WebContentsView, webContents } from 'electron'; +import { screen, WebContentsView, webContents } from 'electron'; import { Disposable } from '../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { VSBuffer } from '../../../base/common/buffer.js'; @@ -52,6 +52,14 @@ export class BrowserView extends Disposable { private static readonly MAX_CONSOLE_LOG_ENTRIES = 1000; private readonly _consoleLogs: string[] = []; + /** + * Resize a full-page screenshot so its largest dimension never exceeds this many pixels. A very tall + * or wide page would otherwise request an enormous bitmap, which is costly to allocate/encode and + * can stress the browser process. We downscale via `scale` (rather than cropping) so the whole page + * still fits in the result. + */ + private static readonly MAX_FULL_PAGE_SCREENSHOT_DIMENSION = 2576; + private readonly _onDidNavigate = this._register(new Emitter()); readonly onDidNavigate: Event = this._onDidNavigate.event; @@ -612,9 +620,10 @@ export class BrowserView extends Disposable { } const quality = options?.quality ?? 80; + const format = options?.format ?? 'jpeg'; if (options?.fullPage && !options.screenRect && !options.pageRect) { - return this._captureFullPageScreenshot(quality); + return this._captureFullPageScreenshot(format, quality); } if (options?.pageRect) { @@ -634,7 +643,7 @@ export class BrowserView extends Disposable { const image = await this._view.webContents.capturePage(options?.screenRect, { stayHidden: true }); - const buffer = image.toJPEG(quality); + const buffer = format === 'png' ? image.toPNG() : image.toJPEG(quality); const screenshot = VSBuffer.wrap(buffer); // Only update _lastScreenshot if capturing the full view if (!options?.screenRect) { @@ -644,7 +653,7 @@ export class BrowserView extends Disposable { } // Capture a screenshot of the full scrollable document (beyond the viewport) via CDP. - private async _captureFullPageScreenshot(quality: number): Promise { + private async _captureFullPageScreenshot(format: 'jpeg' | 'png', quality: number): Promise { const metrics = await this.debugger.sendCommand('Page.getLayoutMetrics') as { cssContentSize?: { width: number; height: number } }; // Size in CSS pixels const size = metrics.cssContentSize; @@ -652,22 +661,35 @@ export class BrowserView extends Disposable { throw new Error('Page.getLayoutMetrics did not return a cssContentSize'); } const zoomFactor = this._view.webContents.getZoomFactor(); + const clipWidth = size.width * zoomFactor; + const clipHeight = size.height * zoomFactor; + // CDP renders the screenshot at device pixels, so the output bitmap dimensions are roughly + // `clip.width * scale * devicePixelRatio`. Divide by DPR here so `MAX_FULL_PAGE_SCREENSHOT_DIMENSION` + // is an upper bound on the final image pixel size (not just the CSS-pixel clip size). + // We read the DPR from the display hosting the view's window (rather than evaluating + // `window.devicePixelRatio` in the page) so this works without a renderer round-trip and + // while the page is paused at a breakpoint. Fall back to the primary display if no host + // window can be resolved (e.g. during teardown). + const hostWindow = this._hostWindow; + const display = hostWindow ? screen.getDisplayMatching(hostWindow.getBounds()) : screen.getPrimaryDisplay(); + const devicePixelRatio = display.scaleFactor; + const maxClipDimension = BrowserView.MAX_FULL_PAGE_SCREENSHOT_DIMENSION / Math.max(devicePixelRatio, 1); + const scale = Math.min(1, maxClipDimension / Math.max(clipWidth, clipHeight)); try { const result = await this.debugger.sendCommand('Page.captureScreenshot', { - format: 'jpeg', - quality, + format, + ...(format === 'jpeg' ? { quality } : {}), captureBeyondViewport: true, // In theory, `clip` defaults to the full area when not explicitly passed, but in practice it doesn't work when // the zoom level isn't 100, because it doesn't multiply the width and height by zoomFactor like we do here. // Setting the clip explicitly, we can multiply by zoomFactor and thus work around this Chromium bug. // Note that even with this workaround, we often see that the page isn't fully captured and might repeat // visual content from the top at the bottom, instead of showing the bottom of the page. - // - Sidenote: Setting the scale here to be zoomFactor or 1/zoomFactor has strange effects and doesn't solve the issue. // - Another sidenote: Currently the scrollbar width isn't accounted for. If a scrollbar exists, we should add the // vertical scrollbar's width and horizontal scrollbar's height to the clip dimensions, since the image is currently // clipped by that amount (this also happens when no clip parameter is provided; ideally it should be fixed upstream // in Chromium). - clip: { x: 0, y: 0, width: size.width * zoomFactor, height: size.height * zoomFactor, scale: 1 } + clip: { x: 0, y: 0, width: clipWidth, height: clipHeight, scale } }) as { data: string }; return VSBuffer.wrap(Buffer.from(result.data, 'base64')); } finally { @@ -685,11 +707,14 @@ export class BrowserView extends Disposable { const WAIT_TIMEOUT_MS = 100; try { await Promise.race([ - this._view.webContents.executeJavaScript('new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))'), + this.debugger.sendCommand('Runtime.evaluate', { + expression: 'new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))', + awaitPromise: true + }), new Promise(resolve => setTimeout(resolve, WAIT_TIMEOUT_MS)) ]); } catch { - // `executeJavaScript` can throw if the page navigates while we're waiting; + // `Runtime.evaluate` can throw if the page navigates while we're waiting; // just proceed in that case. } } @@ -782,6 +807,15 @@ export class BrowserView extends Disposable { return this._currentWindow?.win ?? undefined; } + /** + * The Electron window that currently hosts this view, if any. Before `layout()` is first + * called this is the owner window; after that it's whichever window the view was last moved + * to. Returns `undefined` if no host window can be resolved (e.g. during teardown). + */ + private get _hostWindow(): Electron.BrowserWindow | undefined { + return this._currentWindow?.win ?? this._ownerWindow.win ?? undefined; + } + override dispose(): void { if (this._isDisposed) { return; diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts index 1c9c5f277f57c..6a7aac8ad0931 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts @@ -537,7 +537,7 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { } try { - const screenshotBuffer = await model.captureScreenshot({ quality: 80, fullPage: true }); + const screenshotBuffer = await model.captureScreenshot({ fullPage: true, format: 'png' }); if (!await this._confirmContentAttachmentRisk(model.url)) { return; @@ -549,7 +549,7 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { fullName: localize('browserFullPageScreenshot', 'Browser Full Page Screenshot'), kind: 'image', value: screenshotBuffer.buffer, - mimeType: 'image/jpeg', + mimeType: 'image/png', }]; if (!await this._attachToChat(toAttach)) { From 0669c96bdc8383d67567950349e328fc9e2f0e14 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Fri, 29 May 2026 14:01:34 -0700 Subject: [PATCH 10/27] Fix system notification command rendering as literal backtick (#318601) (#319052) * Fix system notification command rendering as literal backtick (#318601) Terminal background-execution system notification labels wrapped the raw command in a single-backtick inline code span. Multi-line commands contain blank lines, which break an inline code span and cause the leading backtick to render literally instead of as code. Reuse the existing safe pattern (buildCommandDisplayText to collapse newlines and truncate, appendEscapedMarkdownInlineCode to fence safely) so the command always renders as inline code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Simplify command display to first line + ellipsis (#318601) Replace newline-to-space collapsing in buildCommandDisplayText with first-line-only behavior: keep only the first line and append an ellipsis when the command spans multiple lines (or when the first line itself exceeds 80 characters). This is cleaner for UI labels than joining multi-line commands into a single long line of spaces. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Scope first-line truncation to system notification site Revert the change to buildCommandDisplayText (it's used by other callers that expect the prior collapse-newlines behavior). Do the first-line + ellipsis truncation locally in _registerCompletionNotification where the system notification label is built. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Extract buildCompletionNotificationCommand helper + tests (#318601) Move the first-line + ellipsis logic into a small exported helper next to the call site so it can be tested directly. Restore the trim/escape- artifact cleanup and 80-character truncation by running the first line through buildCommandDisplayText (which is a no-op for newlines once the input is already a single line). (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use horizontal ellipsis character in completion notification Replace the three-dot "..." with the proper horizontal ellipsis character (U+2026) in buildCompletionNotificationCommand. Updated the truncation path to slice to 79 chars + 1-char ellipsis so the total length remains 80. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/tools/runInTerminalTool.ts | 34 +++++++- .../test/browser/runInTerminalHelpers.test.ts | 80 ++++++++++++++++++- 2 files changed, 109 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index 8e034151d4500..361faaa378729 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -9,7 +9,7 @@ import { CancellationToken, CancellationTokenSource } from '../../../../../../ba import { Codicon } from '../../../../../../base/common/codicons.js'; import { CancellationError } from '../../../../../../base/common/errors.js'; import { Event } from '../../../../../../base/common/event.js'; -import { escapeMarkdownSyntaxTokens, MarkdownString, type IMarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { appendEscapedMarkdownInlineCode, escapeMarkdownSyntaxTokens, MarkdownString, type IMarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; import { getMediaMime } from '../../../../../../base/common/mime.js'; @@ -474,6 +474,28 @@ const telemetryIgnoredSequences = [ const altBufferMessage = '\n' + localize('runInTerminalTool.altBufferMessage', "The command opened the alternate buffer."); +/** + * Builds the short, single-line command string used in the SYSTEM NOTIFICATION + * label for background terminal completion (#318601). Keeps only the first line + * of the command (stripping common escape artifacts) and appends a horizontal + * ellipsis (`…`) when content is dropped — either because the command spans + * multiple lines or the first line itself is longer than 80 characters. + * + * Multi-line commands (with blank lines) used to break the surrounding inline + * code span; callers must additionally wrap the result with + * {@link appendEscapedMarkdownInlineCode} when interpolating into markdown. + */ +export function buildCompletionNotificationCommand(command: string): string { + const firstNewline = command.search(/\r|\n/); + const hasMoreLines = firstNewline !== -1; + const firstLine = hasMoreLines ? command.substring(0, firstNewline) : command; + const normalized = normalizeTerminalCommandForDisplay(firstLine); + if (normalized.length > 80) { + return normalized.substring(0, 79) + '…'; + } + return hasMoreLines ? normalized + '…' : normalized; +} + export class RunInTerminalTool extends Disposable implements IToolImpl { @@ -2489,6 +2511,10 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { return; } + // Build a single-line, safely-fenced inline code representation of the + // command for use in the system notification label (#318601). + const commandDisplay = appendEscapedMarkdownInlineCode(buildCompletionNotificationCommand(commandName)); + // Acquire a reference to the ChatModel so it stays alive while we wait // for the background terminal to complete. Without this, the model can // be disposed if the user navigates away, and sendRequest would throw. @@ -2630,7 +2656,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { ...sendOptions, queue: ChatRequestQueueKind.Steering, isSystemInitiated: true, - systemInitiatedLabel: localize('terminalAssessingOutput', "`{0}` may need input", commandName), + systemInitiatedLabel: localize('terminalAssessingOutput', "{0} may need input", commandDisplay), terminalExecutionId: termId, }).catch(e => { this._logService.warn(`RunInTerminalTool: Failed to send input-needed notification for terminal ${termId}`, e); @@ -2684,7 +2710,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { ...sendOptions, queue: ChatRequestQueueKind.Steering, isSystemInitiated: true, - systemInitiatedLabel: localize('terminalCommandCompleted', "`{0}` completed", commandName), + systemInitiatedLabel: localize('terminalCommandCompleted', "{0} completed", commandDisplay), terminalExecutionId: termId, }).catch(e => { this._logService.warn(`RunInTerminalTool: Failed to send completion notification for terminal ${termId}`, e); @@ -2752,7 +2778,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { ...sendOptions, queue: ChatRequestQueueKind.Steering, isSystemInitiated: true, - systemInitiatedLabel: localize('terminalProcessExited', "`{0}` terminal exited", commandName), + systemInitiatedLabel: localize('terminalProcessExited', "{0} terminal exited", commandDisplay), terminalExecutionId: termId, }).catch(e => { this._logService.warn(`RunInTerminalTool: Failed to send terminal-exited notification for terminal ${termId}`, e); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/runInTerminalHelpers.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/runInTerminalHelpers.test.ts index 1bd6a1debbf6d..af5566205d339 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/runInTerminalHelpers.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/runInTerminalHelpers.test.ts @@ -4,7 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { ok, strictEqual } from 'assert'; -import { generateAutoApproveActions, TRUNCATION_MESSAGE, dedupeRules, isPowerShell, truncateOutputKeepingTail, extractCdPrefix, normalizeTerminalCommandForDisplay, normalizeCommandForExecution, isMultilineCommand } from '../../browser/runInTerminalHelpers.js'; +import * as marked from '../../../../../../base/common/marked/marked.js'; +import { appendEscapedMarkdownInlineCode } from '../../../../../../base/common/htmlContent.js'; +import { generateAutoApproveActions, TRUNCATION_MESSAGE, dedupeRules, isPowerShell, truncateOutputKeepingTail, extractCdPrefix, normalizeTerminalCommandForDisplay, normalizeCommandForExecution, isMultilineCommand, buildCommandDisplayText } from '../../browser/runInTerminalHelpers.js'; +import { buildCompletionNotificationCommand } from '../../browser/tools/runInTerminalTool.js'; import { OperatingSystem } from '../../../../../../base/common/platform.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { ConfigurationTarget } from '../../../../../../platform/configuration/common/configuration.js'; @@ -569,3 +572,78 @@ suite('isMultilineCommand', () => { }); }); +suite('buildCommandDisplayText', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('should collapse newlines (including blank lines) to spaces', () => { + strictEqual(buildCommandDisplayText('echo a\n\necho b'), 'echo a echo b'); + strictEqual(buildCommandDisplayText('echo a\r\necho b'), 'echo a echo b'); + }); + + test('should truncate long commands to 80 characters', () => { + const long = 'a'.repeat(200); + const result = buildCommandDisplayText(long); + strictEqual(result.length, 80); + ok(result.endsWith('...')); + }); + + // Regression test for #318601: system notification labels used to wrap the + // raw command in a single-backtick inline code span. Multi-line commands + // (which contain blank lines) broke the code span and rendered the leading + // backtick literally. The command must be collapsed to a single line and + // safely fenced so it always renders as inline code. + test('multi-line command renders as inline code (not a literal backtick)', () => { + const opts: marked.MarkedOptions = { gfm: true, breaks: true }; + const render = (value: string) => marked.parser(marked.lexer(value, opts), opts); + + const multilineCommand = 'rm -rf .playwright-cli/\n\nmore text'; + const label = appendEscapedMarkdownInlineCode(buildCommandDisplayText(multilineCommand)) + ' completed'; + const html = render(label); + + ok(html.includes(''), `expected a code span, got: ${html}`); + ok(!/

`/.test(html), `expected no literal leading backtick, got: ${html}`); + }); +}); + +suite('buildCompletionNotificationCommand', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('leaves single-line commands unchanged', () => { + strictEqual(buildCompletionNotificationCommand('echo hello'), 'echo hello'); + }); + + test('keeps only the first line and appends a horizontal ellipsis for multi-line commands', () => { + strictEqual(buildCompletionNotificationCommand('echo a\necho b'), 'echo a…'); + strictEqual(buildCompletionNotificationCommand('echo a\n\necho b'), 'echo a…'); + strictEqual(buildCompletionNotificationCommand('echo a\r\necho b'), 'echo a…'); + strictEqual(buildCompletionNotificationCommand('echo a\recho b'), 'echo a…'); + }); + + test('truncates a long first line to 80 characters using a single horizontal ellipsis', () => { + const longFirstLine = 'a'.repeat(200); + const multiLine = longFirstLine + '\nignored'; + const result = buildCompletionNotificationCommand(multiLine); + strictEqual(result.length, 80); + ok(result.endsWith('…'), `expected ellipsis suffix, got: ${result}`); + ok(!result.endsWith('……'), `expected single ellipsis suffix, got: ${result}`); + }); + + test('strips escape artifacts from the first line', () => { + strictEqual(buildCompletionNotificationCommand('echo \\"hi\\"\necho ignored'), 'echo "hi"…'); + }); + + // Regression test for #318601: the final label must render as inline code + // (no literal backticks) when fed to the markdown renderer wrapped with + // `appendEscapedMarkdownInlineCode`. + test('result renders as inline code when wrapped with appendEscapedMarkdownInlineCode', () => { + const opts: marked.MarkedOptions = { gfm: true, breaks: true }; + const render = (value: string) => marked.parser(marked.lexer(value, opts), opts); + + const multilineCommand = 'rm -rf .playwright-cli/\n\nmore text'; + const label = appendEscapedMarkdownInlineCode(buildCompletionNotificationCommand(multilineCommand)) + ' completed'; + const html = render(label); + + ok(html.includes(''), `expected a code span, got: ${html}`); + ok(!/

`/.test(html), `expected no literal leading backtick, got: ${html}`); + }); +}); From e281de6a2ef41d35d554f6f4642f32c1acc3e337 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 21:02:00 +0000 Subject: [PATCH 11/27] build(deps): bump tar from 0.4.45 to 0.4.46 in /cli (#319053) Bumps [tar](https://github.com/composefs/tar-rs) from 0.4.45 to 0.4.46. - [Release notes](https://github.com/composefs/tar-rs/releases) - [Commits](https://github.com/composefs/tar-rs/compare/0.4.45...0.4.46) --- updated-dependencies: - dependency-name: tar dependency-version: 0.4.46 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- cli/Cargo.lock | 4 ++-- cli/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 160bcb5b573a7..2bf6b039ba62e 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -3032,9 +3032,9 @@ dependencies = [ [[package]] name = "tar" -version = "0.4.45" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" dependencies = [ "filetime", "libc", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index a8f1ba4c34e9d..6670a2ed84d7a 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -54,7 +54,7 @@ cfg-if = "1.0.0" pin-project = "1.1.0" console = "0.15.7" bytes = "1.11.1" -tar = "0.4.45" +tar = "0.4.46" local-ip-address = "0.6" ahp = "0.1" ahp-types = "0.1" From b24c5e38423bb29df7a02a30f8ac282c72308eb3 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Fri, 29 May 2026 14:03:37 -0700 Subject: [PATCH 12/27] policy: enterprise managed_settings for Copilot clients (#318623) * chat plugins: add policy-backed enabledPlugins / marketplaces / strictMarketplaces settings Adds three new chat.plugins.* settings, each policy-backed: - chat.plugins.enabledPlugins (policy: objectChatEnabledPlugins) mapping plugin IDs (`@`) to enable/disable. - chat.plugins.marketplaces (policy: array ofChatPluginMarketplaces) marketplace references (GitHub shorthand or Git URI). User entries survive alongside policy entries. - chat.plugins.strictMarketplaces (policy: ChatStrictMarketplaces) boolean restricting trust to listed marketplaces only. All three are gated on `tags: ['experimental']`. Consumers (plugin discovery, install, URL handler, marketplace service, quick-pick action) now read via `inspect()` so default + user + policy layers all flow through. A shared `readConfiguredMarketplaces` helper in marketplaceReference.ts dedups the inspect pattern across 5 sites. Adds three matching fields to IPolicyData so the policy framework has slots to fill in once the wiring lands; until then they're undefined and behave like an empty policy (no-op). Plugin discovery now distinguishes filesystem-path entries (removable from UI) from enterprise plugin IDs (non-removable) via a single shared loop; `IAgentPlugin.remove` is optional accordingly. build/lib/policies/policyData.jsonc regenerated for the new policy keys. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * policy: implement ADR-002 enterprise managed_settings fetch & policy wiring Wires the previously-added chat.plugins.* policy slots to the new `/copilot_internal/managed_settings` endpoint on the authenticated Copilot host. Core behavior in DefaultAccountProvider: - Fetches managed_settings alongside entitlements; shares the 1-hour cache used by other account-policy fetches. - Silent fallback to local-only policy on any non-2xx, network error, parse error, or missing managedSettingsUrl. - Rate-limit-aware: backs off all /copilot_internal/* calls when the endpoint signals 429, 403 + X-RateLimit-Remaining: 0, or any non-2xx with Retry-After. - adaptManagedSettings flattens the API's structured extraKnownMarketplaces map into the existing string-array shape that chat.plugins.marketplaces consumes; tolerates malformed entries and unknown response keys (forward-compatible). - Telemetry: emits `defaultaccount:managedSettings:fetch` (owner: joshspicer) with an `outcome` bucket (ok / no-response / parse-error / status:NNN) and a `rateLimitBackoffActive` flag. Surface area: - IDefaultAccountProvider/Service expose managedSettingsFetchStatus and managedSettingsFetchedAt; ManagedSettingsFetchStatus is a named union. - Developer: Policy Diagnostics shows a Managed Settings section with the URL status, last-fetched timestamp, and a JSON dump of the applied managed-settings policy slice. - product.json adds a managedSettingsUrl key (populated via distro). Refactor: `readHeader` and `retryAfterFromHeaders` are moved to `platform/request/common/request.ts` so githubRepoFetcher.ts and this new code share one implementation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * bump distro to 36d906669669f12466c6912bd65d9eeb47c6522d Pulls in managedSettingsUrl from microsoft/vscode-distro#1422. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * update policyData * policy: address PR review feedback - Restore historical default for chat.plugins.marketplaces (['github/copilot-plugins', 'github/awesome-copilot#marketplace']) so existing users don't lose the two built-in marketplaces on update. Regenerate policyData.jsonc accordingly. - Seed _managedSettingsFetchStatus = 'ok' on cache-hit so Policy Diagnostics reports the applied state after a process restart that warm-starts from cached policyData (instead of stuck at 'not yet fetched'). - Scope the @ ID-resolution rule to the enterprise ChatEnabledPlugins setting only. User-typed entries in chat.pluginLocations that happen to contain '@' are now treated as filesystem paths, as a user would expect, not silently rewritten to ~/.copilot/installed-plugins///. Split _resolvePluginPath into a path-only resolver and a dedicated _resolveEnterprisePluginId. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * policy: revert unnecessary _pluginLocationsConfig refactor chat.pluginLocations has no policy slot, so observableConfigValue (which uses getValue() under the hood) is functionally equivalent to the hand-rolled inspect() version. Reverting reduces diff thechurn inspect-based observable is now used only for _enterpriseEnabledPluginsConfig where the default+user+policy merge actually matters. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * policy: split managed marketplaces into dedicated policy-only setting Adds chat.plugins.extraMarketplaces (ChatExtraMarketplaces policy, included: false so it's hidden from the Settings UI). This receives the 'extraKnownMarketplaces' payload from the managed_settings API. Restores chat.plugins.marketplaces to its pre-PR shape: no policy slot, no inspect()-juggling required in consumers, no risk of accidentally clobbering user data. Users write to chat.plugins.marketplaces; the enterprise writes to chat.plugins.extraMarketplaces; the effective set is the union. Consumer simplifications: - readConfiguredMarketplaces returns { userValues, extraValues, two getValue() reads, no inspect() needed.effectiveValues } - Write-back is now just [...userValues, refValue] in all three sites. - 'Manage Plugin Marketplaces' still surfaces the 'managed by enterprise policy' badge by checking ref membership in extraValues. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * policy: tidy managed_settings code paths - fetchMarketplacePlugins: drop the over-engineered pre-dedup-by-string; parseMarketplaceReferences already dedups by canonical id. - agentPluginServiceImpl: pass source.remove directly to _toPlugin instead of wrapping in a null-asserted closure. - adaptManagedSettings: use a Set for flatten-and-dedup (insertion order is preserved). - getDefaultAccountFromAuthenticatedSessions: spread merge instead of three explicit field assignments. - developerActions: collapse the 'ok' branch into the catch-all backtick wrap; same behavior, less code. - marketplaceReference.ts: tighter JSDoc on IConfiguredMarketplaces. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * policy: enforce ChatEnabledPlugins and strict-marketplace gates at discovery Previously the enterprise-managed policy values were delivered into the policy framework but not a plugin already installed locallyenforced (e.g. via the marketplace discovery path) would remain active even when the policy excluded it or strict-marketplace mode rejected its source. Adds policy enforcement on AgentPluginService.plugins, applied after discovery dedup/sort and gated by two observables: - ChatEnabledPlugins policy: when set, filters the surfaced plugin set to only those whose '@' ID appears in the policy map with value true. Plugins without a marketplace provenance (filesystem entries from chat.pluginLocations) are unaffected. - ChatStrictMarketplaces: when on, filters out plugins whose source marketplace is not trusted. Trust is sourced ONLY from chat.plugins.extraMarketplaces (the policy-only user-setslot) entries in chat.plugins.marketplaces do NOT grant trust under strict mode. This matches the ADR-002 semantics: strict mode hands full marketplace control to the enterprise. Also updates the chat.plugins.strictMarketplaces description text to match the new behavior (was still pointing at the user setting). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * policy: extract managed_settings adapter to dedicated helper Moves IManagedSettingsResponse and adaptManagedSettings out of defaultAccount.ts and into a new managedSettings.ts in the same folder. Adapter is a pure transformation function with no service dependencies, so it belongs in its own file alongside the HTTP/wiring code. Renames the test file to managedSettings.test.ts to match what it actually tests and tightens the suite name. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * policy: tidy enforcement filter and sync strict-marketplace policy description Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * policy: show policy-blocked plugins as disabled instead of hiding them Blocked plugins (ChatEnabledPlugins / strict marketplaces) now stay visible but are forced disabled via their enablement observable, and the enable affordance notifies the user instead of re-enabling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * policy: enforce enabledPlugins/strictMarketplaces for Copilot-CLI plugins CLI-installed plugins under `~/.copilot/installed-plugins///` have no `fromMarketplace` metadata, so they previously bypassed enterprise policy. Derive their identity from the install-path bucket (matching the convention used by `_resolveEnterprisePluginId`) so enabledPlugins gating applies, and add a bucket-name heuristic for strict marketplaces. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * log raw managed_settings response at trace level Helps debug schema drift / unknown server fields that get dropped by adaptManagedSettings(). Trace-only so it's off by default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * improve managed_settings warning for missing repo/url When a github source is missing 'repo' or a git source is missing 'url', emit a specific warning naming the missing field instead of the misleading 'unknown source type' message. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * preserve marketplace name through managed_settings policy delivery The managed_settings adapter previously flattened extraKnownMarketplaces entries to bare "/" or "" strings, losing the marketplace name. That broke enabledPlugins matching because plugin IDs are keyed as "@" but our parsed reference's displayLabel was derived from the URL/repo instead. Changes: - adapter now emits { name, source } objects preserving the full shape - IPolicyData.extraKnownMarketplaces accepts string | object entries - parseMarketplaceReferences gains object-handling, using name as displayLabel - workspacePluginSettingsService shares the object parser - policy schema relaxed to allow object items Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * policy: clarify chat.plugins.enabledPlugins description The previous 'Merged with entries from chat.pluginLocations' was misleading: the two settings use different key namespaces (plugin IDs vs filesystem paths) and the enabledPlugins policy also acts as an allowlist that gates marketplace-discovered not a symmetric merge.plugins Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * policy: add description for chat.plugins.extraMarketplaces The setting was missing a markdownDescription, so the Settings UI card rendered empty when shown under 'Managed by organization'. Also updated the policy localization to mention the new { name, source } object form. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * policy: shorten chat.plugins.extraMarketplaces description Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * policy: drop policy name from extraMarketplaces description Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * policy: re-fetch plugin marketplaces when ExtraMarketplaces policy changes pluginMarketplaceService.onDidChangeMarketplaces only listened for PluginsEnabled and PluginMarketplaces config changes, so the ExtraMarketplaces values delivered by the ChatExtraMarketplaces policy never triggered a the union was stale until the next user editrefetch to chat.plugins.marketplaces or a workspace-trust change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * policy: extract IExtraKnownMarketplaceEntry to base/common/managedSettings Move the enterprise-managed marketplace entry type out of defaultAccount.ts into a dedicated managedSettings.ts so the type lives alongside other managed-settings-specific code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * policy: cleanup pass - Sync policyData.jsonc ChatExtraMarketplaces description with the source declaration in chat.shared.contribution.ts (object-form entries were missing from the policy artifact). - Reorder Event import in agentPluginServiceImpl.ts to keep base/common imports alphabetical. - Fix stale doc reference (COPILOT_CLI_INSTALLED_PLUGINS_DIR -> the function it actually mirrors). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * policy: accept host-only git URLs in extraKnownMarketplaces ADR-002 describes the `git` source `url` as a free-form `(string)` the example happens to be a full clone URL, but the schema doesn't require a repo path. Our marketplace-URI parser was rejecting host-only HTTPS endpoints (e.g. `https://plugins.internal.example.com`), so enterprise policy entries with marketplace-registry-style URLs were silently dropped before they ever reached the UI. Relax `parseUriMarketplaceReference` to accept host-only URLs and treat them as a marketplace endpoint identified by host alone. The canonical id becomes `git:/` so distinct hosts still dedupe correctly. Existing path-aware behavior is preserved unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * policy: fix string entry guard in extraKnownMarketplaces policy.value; fix test cloneUrl expectation - Handle string-typed entries in extraKnownMarketplaces (IPolicyData allows string | IExtraKnownMarketplaceEntry) - Fix test expectation: URI.parse normalizes host-only URLs to include trailing slash Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * policy: read extraMarketplaces dict and convert to nested entry shape The setting schema is now `{ [name]: url-or-shorthand }` (object), so readConfiguredMarketplaces must convert each entry to the nested IExtraMarketplaceObjectEntry shape that parseMarketplaceReferences expects. Uses a regex to detect GitHub shorthand (owner/repo[#ref]) vs URI. TypeError in CI: 'extraValues is not iterable' on [...userValues, ...extraValues]. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * policy: extract extraKnownMarketplacesToConfigDict helper + add regression tests for Settings Editor display Extract the policy.value conversion for ChatExtraMarketplaces out of chat.shared.contribution.ts into a reusable, unit-testable helper. The helper converts the IExtraKnownMarketplaceEntry[] policy payload into the { [name]: url-or-shorthand } dict that: - the Settings Editor's ComplexObject renderer can display inline as key/value rows (instead of just 'Edit in settings.json'), and - readConfiguredMarketplaces reverses back into IExtraMarketplaceObjectEntry[] so parseMarketplaceReferences preserves displayLabel = name. Tests added: undefined owner/repo owner/repo#ref raw URL (+ optional #ref) parseMarketplaceReferences flow (the regression test that catches the 'extraValues is not iterable' bug we just hit in CI) - schema-shape: chat.plugins.extraMarketplaces is registered with type=object + additionalProperties.type=['string'], the exact shape the Settings Editor requires to render as ComplexObject Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * policy: stop spurious 'invalid marketplace entry' warnings for object-form entries url dict, policy entries always reach the marketplace fetcher as IExtraMarketplaceObjectEntry objects (not strings). The validation loop was only accepting strings, producing a 'Ignoring invalid marketplace entry: [object Object]' debug log for every valid policy entry. Validate using parseMarketplaceObjectEntry for object values so the warning fires only for genuinely-unparseable entries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * policy: drop schema-shape test that double-registered chat contribution commands The schema-shape test for chat.plugins.extraMarketplaces imported the full chat.shared.contribution module to populate the configuration registry. This re-registered commands (already registered by the workbench under test), producing 'Cannot register two commands with the same id: workbench.action.chat.markHelpful' and cascading disposable leaks in unrelated suites (EditorService, WorkingCopyBackupTracker). The other 5 tests (extraKnownMarketplacesToConfigDict + end-to-end round trip) cover the actual behavior that broke; the schema shape is exercised implicitly by the round-trip test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * policy: normalize github.com URI/SSH refs to the GitHub shorthand canonical id Plugin marketplace trust under strict mode compares canonicalId. A plugin discovered from 'https://github.com/microsoft/vscode-team-kit.git' was being blocked even though 'microsoft/vscode-team-kit' was in the trusted list, because the URI parser produced 'git:github.com/microsoft/vscode-team-kit.git' while the shorthand parser produced 'github:microsoft/vscode-team-kit'. When parseUriMarketplaceReference / parseScpMarketplaceReference detect a github.com authority, emit the same canonical id form the shorthand parser uses so all three forms (shorthand, https URI, SCP) collapse to a single trusted reference. Existing dedup test now expects 1 entry instead of 2; ref-distinction test collapses the https+#ref entry with its shorthand sibling. Added a focused regression test asserting all four forms produce identical canonical ids. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * update policy * fix dupe policy export --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build/lib/policies/policyData.jsonc | 45 +++ package.json | 2 +- product.json | 3 +- src/vs/base/common/defaultAccount.ts | 26 ++ src/vs/base/common/managedSettings.ts | 12 + src/vs/base/common/product.ts | 1 + .../inlineCompletions/test/browser/utils.ts | 2 + .../standalone/browser/standaloneServices.ts | 2 + .../defaultAccount/common/defaultAccount.ts | 17 ++ src/vs/platform/request/common/request.ts | 28 ++ src/vs/sessions/test/web.test.ts | 2 + .../browser/actions/developerActions.ts | 25 ++ .../chat/browser/actions/chatPluginActions.ts | 26 +- .../chat/browser/agentPluginActions.ts | 40 ++- .../agentPluginEditor/agentPluginEditor.ts | 12 +- .../aiCustomizationManagement.contribution.ts | 4 +- .../browser/aiCustomization/mcpListWidget.ts | 2 +- .../chat/browser/chat.shared.contribution.ts | 77 ++++- .../contrib/chat/browser/githubRepoFetcher.ts | 22 +- .../chat/browser/pluginInstallService.ts | 8 +- .../contrib/chat/browser/pluginUrlHandler.ts | 8 +- .../contrib/chat/common/constants.ts | 3 + .../chat/common/plugins/agentPluginService.ts | 11 +- .../common/plugins/agentPluginServiceImpl.ts | 289 +++++++++++++++--- .../common/plugins/marketplaceReference.ts | 223 +++++++++++--- .../plugins/pluginMarketplaceService.ts | 32 +- .../plugins/workspacePluginSettingsService.ts | 75 +---- .../plugins/pluginInstallService.test.ts | 6 + .../plugins/pluginMarketplaceService.test.ts | 157 +++++++++- .../accounts/browser/defaultAccount.ts | 187 +++++++++++- .../accounts/browser/managedSettings.ts | 82 +++++ .../test/browser/managedSettings.test.ts | 131 ++++++++ .../test/browser/accountPolicyService.test.ts | 2 + .../browser/multiplexPolicyService.test.ts | 2 + .../browser/componentFixtures/fixtureUtils.ts | 2 + 35 files changed, 1338 insertions(+), 228 deletions(-) create mode 100644 src/vs/base/common/managedSettings.ts create mode 100644 src/vs/workbench/services/accounts/browser/managedSettings.ts create mode 100644 src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index c6b3fb35ced92..46e4da2cf367a 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -53,6 +53,21 @@ "default": {}, "included": false }, + { + "key": "chat.plugins.extraMarketplaces", + "name": "ChatExtraMarketplaces", + "category": "InteractiveSession", + "minimumVersion": "1.122", + "localization": { + "description": { + "key": "chat.plugins.extraMarketplaces.policy", + "value": "Additional plugin marketplaces to query. Keys are marketplace names; values are GitHub shorthand (`owner/repo[#ref]`) or Git URIs (`[#ref]`)." + } + }, + "type": "object", + "default": {}, + "included": false + }, { "key": "chat.mcp.gallery.serviceUrl", "name": "McpGalleryServiceUrl", @@ -222,6 +237,36 @@ "default": true, "included": true }, + { + "key": "chat.plugins.enabledPlugins", + "name": "ChatEnabledPlugins", + "category": "InteractiveSession", + "minimumVersion": "1.122", + "localization": { + "description": { + "key": "chat.plugins.enabledPlugins.policy", + "value": "Plugin enablement. Keys are plugin IDs in `@` form; values enable or disable the plugin." + } + }, + "type": "object", + "default": {}, + "included": true + }, + { + "key": "chat.plugins.strictMarketplaces", + "name": "ChatStrictMarketplaces", + "category": "InteractiveSession", + "minimumVersion": "1.122", + "localization": { + "description": { + "key": "chat.plugins.strictMarketplaces.policy", + "value": "Only trust marketplaces supplied via enterprise policy; plugins from any other marketplace will not load." + } + }, + "type": "boolean", + "default": false, + "included": true + }, { "key": "chat.agent.enabled", "name": "ChatAgentMode", diff --git a/package.json b/package.json index 9a21c4517b6ed..b1bbba47158cd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.122.0", - "distro": "8c72533c6dd351f5f8f785eff758768c032523e0", + "distro": "36d906669669f12466c6912bd65d9eeb47c6522d", "author": { "name": "Microsoft Corporation" }, diff --git a/product.json b/product.json index 23af9cc6c3340..e7f7e936cdb1b 100644 --- a/product.json +++ b/product.json @@ -145,7 +145,8 @@ "completionsEnablementSetting": "github.copilot.enable", "nextEditSuggestionsSetting": "github.copilot.nextEditSuggestions.enabled", "tokenEntitlementUrl": "https://api.github.com/copilot_internal/v2/token", - "mcpRegistryDataUrl": "https://api.github.com/copilot/mcp_registry" + "mcpRegistryDataUrl": "https://api.github.com/copilot/mcp_registry", + "managedSettingsUrl": "https://api.github.com/copilot_internal/managed_settings" }, "trustedExtensionAuthAccess": { "github": [ diff --git a/src/vs/base/common/defaultAccount.ts b/src/vs/base/common/defaultAccount.ts index f916a3799e769..4f82a0f675161 100644 --- a/src/vs/base/common/defaultAccount.ts +++ b/src/vs/base/common/defaultAccount.ts @@ -3,6 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { IExtraKnownMarketplaceEntry } from './managedSettings.js'; + export interface IQuotaSnapshotData { readonly overage_count: number; readonly overage_permitted: boolean; @@ -54,6 +56,30 @@ export interface IPolicyData { readonly cloud_session_storage_enabled?: boolean; readonly mcpRegistryUrl?: string; readonly mcpAccess?: 'allow_all' | 'registry_only'; + + /** + * Enterprise-managed plugin enablement, delivered via the Copilot + * `managed_settings` API. Keys are plugin IDs in `@` + * form; values are explicit enable/disable. Consumers that read + * `chat.pluginLocations` should merge these with user-supplied path-keyed + * entries via `IConfigurationService.inspect()`. + */ + readonly enabledPlugins?: Readonly>; + + /** + * Enterprise-managed marketplace references, delivered via the Copilot + * `managed_settings` API. Each entry preserves the marketplace `name` + * (used as `displayLabel` so that `enabledPlugins["plugin@"]` keys + * resolve) plus the original `source` discriminator. Legacy string entries + * are still accepted for forward/backward compatibility. + */ + readonly extraKnownMarketplaces?: readonly (string | IExtraKnownMarketplaceEntry)[]; + + /** + * Enterprise-managed strict-marketplace flag. When true, only marketplaces + * listed in `extraKnownMarketplaces` (plus the user's own) are trusted. + */ + readonly strictKnownMarketplaces?: boolean; } export interface ICopilotTokenInfo { diff --git a/src/vs/base/common/managedSettings.ts b/src/vs/base/common/managedSettings.ts new file mode 100644 index 0000000000000..3803cc87ba5a9 --- /dev/null +++ b/src/vs/base/common/managedSettings.ts @@ -0,0 +1,12 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * A single enterprise-managed marketplace entry, preserving the marketplace + * name (used as `displayLabel`) and the original `source` discriminator. + */ +export type IExtraKnownMarketplaceEntry = + | { readonly name: string; readonly source: { readonly source: 'github'; readonly repo: string; readonly ref?: string } } + | { readonly name: string; readonly source: { readonly source: 'git'; readonly url: string; readonly ref?: string } }; diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index 6627394014ba3..4944cc096fa1c 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -401,6 +401,7 @@ export interface IDefaultChatAgent { readonly entitlementSignupLimitedUrl: string; readonly tokenEntitlementUrl: string; readonly mcpRegistryDataUrl: string; + readonly managedSettingsUrl: string; readonly chatQuotaExceededContext: string; readonly completionsQuotaExceededContext: string; diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts index 919937fe70918..dfd6b9991db09 100644 --- a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts @@ -293,6 +293,8 @@ export async function withAsyncTestCodeEditorAndInlineCompletionsModel( currentDefaultAccount: null, copilotTokenInfo: null, onDidChangeCopilotTokenInfo: Event.None, + managedSettingsFetchStatus: null, + managedSettingsFetchedAt: null, getDefaultAccount: async () => null, setDefaultAccountProvider: () => { }, getDefaultAccountAuthenticationProvider: () => { return { id: 'mockProvider', name: 'Mock Provider', enterprise: false }; }, diff --git a/src/vs/editor/standalone/browser/standaloneServices.ts b/src/vs/editor/standalone/browser/standaloneServices.ts index aa9f0c542484a..09100764ebe1b 100644 --- a/src/vs/editor/standalone/browser/standaloneServices.ts +++ b/src/vs/editor/standalone/browser/standaloneServices.ts @@ -1129,6 +1129,8 @@ class StandaloneDefaultAccountService implements IDefaultAccountService { readonly currentDefaultAccount: IDefaultAccount | null = null; readonly copilotTokenInfo = null; readonly onDidChangeCopilotTokenInfo: Event = Event.None; + readonly managedSettingsFetchStatus: null = null; + readonly managedSettingsFetchedAt: null = null; async getDefaultAccount(): Promise { return null; diff --git a/src/vs/platform/defaultAccount/common/defaultAccount.ts b/src/vs/platform/defaultAccount/common/defaultAccount.ts index c6ef5e29f0f38..8b3814aedb0e9 100644 --- a/src/vs/platform/defaultAccount/common/defaultAccount.ts +++ b/src/vs/platform/defaultAccount/common/defaultAccount.ts @@ -16,6 +16,17 @@ export const GitHubPaths = { copilotUpgrade: 'github-copilot/upgrade?utm_source=vscode', } as const; +/** + * Outcome of the last `/copilot_internal/managed_settings` fetch. + * - A numeric HTTP status code indicates the server responded with that code. + * - `'ok'`: response parsed and adapted successfully (including an empty `{}` body). + * - `'no-url'`: no `managedSettingsUrl` configured in product.json. + * - `'no-response'`: network error, all sessions rejected, or active rate-limit backoff. + * - `'parse-error'`: response received but JSON parsing failed. + * - `null`: never fetched. + */ +export type ManagedSettingsFetchStatus = number | 'ok' | 'no-url' | 'no-response' | 'parse-error' | null; + export interface IDefaultAccountProvider { readonly defaultAccount: IDefaultAccount | null; readonly onDidChangeDefaultAccount: Event; @@ -23,6 +34,9 @@ export interface IDefaultAccountProvider { readonly onDidChangePolicyData: Event; readonly copilotTokenInfo: ICopilotTokenInfo | null; readonly onDidChangeCopilotTokenInfo: Event; + readonly managedSettingsFetchStatus: ManagedSettingsFetchStatus; + /** Timestamp (ms) of the last managed-settings fetch, or `null` if never fetched. */ + readonly managedSettingsFetchedAt: number | null; getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider; /** @@ -49,6 +63,9 @@ export interface IDefaultAccountService { readonly currentDefaultAccount: IDefaultAccount | null; readonly copilotTokenInfo: ICopilotTokenInfo | null; readonly onDidChangeCopilotTokenInfo: Event; + readonly managedSettingsFetchStatus: ManagedSettingsFetchStatus; + /** Timestamp (ms) of the last managed-settings fetch, or `null` if never fetched. */ + readonly managedSettingsFetchedAt: number | null; getDefaultAccount(): Promise; getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider; setDefaultAccountProvider(provider: IDefaultAccountProvider): void; diff --git a/src/vs/platform/request/common/request.ts b/src/vs/platform/request/common/request.ts index 8db0214ed89d8..81c6ad5c502ab 100644 --- a/src/vs/platform/request/common/request.ts +++ b/src/vs/platform/request/common/request.ts @@ -134,6 +134,34 @@ export function isServerError(context: IRequestContext): boolean { return !!context.res.statusCode && context.res.statusCode >= 500 && context.res.statusCode < 600; } +/** + * Reads a header value from an {@link IHeaders} map, tolerating array-shaped + * values and case-insensitive lookups. + */ +export function readHeader(headers: IHeaders | undefined, name: string): string | undefined { + if (!headers) { + return undefined; + } + const value = headers[name] ?? headers[name.toLowerCase()]; + if (Array.isArray(value)) { + return value[0]; + } + return value; +} + +/** + * Parses the `Retry-After` header as a number of seconds. Returns `undefined` + * if absent or not a finite positive number. The HTTP-date form is not parsed. + */ +export function retryAfterFromHeaders(headers: IHeaders | undefined): number | undefined { + const value = readHeader(headers, 'retry-after'); + if (!value) { + return undefined; + } + const parsed = parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; +} + export function hasNoContent(context: IRequestContext): boolean { return context.res.statusCode === 204; } diff --git a/src/vs/sessions/test/web.test.ts b/src/vs/sessions/test/web.test.ts index cc1c5e1812d42..687dd85ca28ec 100644 --- a/src/vs/sessions/test/web.test.ts +++ b/src/vs/sessions/test/web.test.ts @@ -131,6 +131,8 @@ class MockDefaultAccountService implements IDefaultAccountService { readonly currentDefaultAccount: IDefaultAccount | null = MOCK_ACCOUNT; readonly copilotTokenInfo: ICopilotTokenInfo | null = null; readonly onDidChangeCopilotTokenInfo = Event.None; + readonly managedSettingsFetchStatus: null = null; + readonly managedSettingsFetchedAt: null = null; async getDefaultAccount(): Promise { return MOCK_ACCOUNT; } getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider { return MOCK_ACCOUNT.authenticationProvider; } diff --git a/src/vs/workbench/browser/actions/developerActions.ts b/src/vs/workbench/browser/actions/developerActions.ts index c0e1300be4a66..cdc59743913f5 100644 --- a/src/vs/workbench/browser/actions/developerActions.ts +++ b/src/vs/workbench/browser/actions/developerActions.ts @@ -780,6 +780,31 @@ class PolicyDiagnosticsAction extends Action2 { content += `*Error retrieving account policy gate info: ${error}*\n\n`; } + content += '## Managed Settings\n\n'; + try { + const policyData = defaultAccountService.policyData; + + content += '| Property | Value |\n'; + content += '|----------|-------|\n'; + const fetchStatus = defaultAccountService.managedSettingsFetchStatus; + const fetchStatusDisplay = fetchStatus === null ? '*not yet fetched*' : `\`${fetchStatus}\``; + content += `| Last fetch | ${fetchStatusDisplay} |\n`; + const fetchedAt = defaultAccountService.managedSettingsFetchedAt; + content += `| Fetched at | ${fetchedAt ? new Date(fetchedAt).toLocaleString() : '*n/a*'} |\n`; + content += '\n'; + + const managedSettingsData = { + enabledPlugins: policyData?.enabledPlugins, + extraKnownMarketplaces: policyData?.extraKnownMarketplaces, + strictKnownMarketplaces: policyData?.strictKnownMarketplaces, + }; + content += '```json\n'; + content += JSON.stringify(managedSettingsData, null, 2); + content += '\n```\n\n'; + } catch (error) { + content += `*Error rendering managed settings diagnostics: ${error}*\n\n`; + } + content += '## Policy-Controlled Settings\n\n'; const policyConfigurations = configurationRegistry.getPolicyConfigurations(); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatPluginActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatPluginActions.ts index 19aff2cff013b..18e2f668c8c4c 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatPluginActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatPluginActions.ts @@ -12,13 +12,14 @@ import { IConfigurationService } from '../../../../../platform/configuration/com import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { INotificationService, Severity } from '../../../../../platform/notification/common/notification.js'; import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; import { IExtensionsWorkbenchService } from '../../../extensions/common/extensions.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatConfiguration } from '../../common/constants.js'; import { IAgentPluginRepositoryService } from '../../common/plugins/agentPluginRepositoryService.js'; import { IPluginInstallService } from '../../common/plugins/pluginInstallService.js'; -import { type IMarketplaceReference, MarketplaceReferenceKind, parseMarketplaceReference, parseMarketplaceReferences } from '../../common/plugins/pluginMarketplaceService.js'; +import { type IMarketplaceReference, MarketplaceReferenceKind, parseMarketplaceReference, parseMarketplaceReferences, readConfiguredMarketplaces } from '../../common/plugins/pluginMarketplaceService.js'; import { InstalledAgentPluginsViewId } from '../chat.js'; import { CHAT_CATEGORY, CHAT_CONFIG_MENU_ID } from './chatActions.js'; @@ -139,6 +140,7 @@ class InstallFromSourceAction extends Action2 { interface IMarketplaceQuickPickItem extends IQuickPickItem { readonly reference: IMarketplaceReference; + readonly managedByPolicy: boolean; } class ManagePluginMarketplacesAction extends Action2 { @@ -172,9 +174,11 @@ class ManagePluginMarketplacesAction extends Action2 { const extensionsWorkbenchService = accessor.get(IExtensionsWorkbenchService); const commandService = accessor.get(ICommandService); const fileService = accessor.get(IFileService); + const notificationService = accessor.get(INotificationService); - const configuredRefs = configurationService.getValue(ChatConfiguration.PluginMarketplaces) ?? []; - const refs = parseMarketplaceReferences(configuredRefs); + const { userValues, extraValues, effectiveValues } = readConfiguredMarketplaces(configurationService); + const refs = parseMarketplaceReferences(effectiveValues); + const policyCanonicalIds = new Set(parseMarketplaceReferences(extraValues).map(r => r.canonicalId)); if (refs.length === 0) { quickInputService.pick([], { placeHolder: localize('noMarketplaces', "No plugin marketplaces configured") }); @@ -186,8 +190,11 @@ class ManagePluginMarketplacesAction extends Action2 { label: ref.displayLabel, description: ref.kind === MarketplaceReferenceKind.LocalFileUri ? localize('localMarketplace', "Local") - : ref.cloneUrl, + : policyCanonicalIds.has(ref.canonicalId) + ? localize('managedMarketplace', "{0} (managed by enterprise policy)", ref.cloneUrl) + : ref.cloneUrl, reference: ref, + managedByPolicy: policyCanonicalIds.has(ref.canonicalId), })); const selected = await quickInputService.pick(items, { @@ -230,8 +237,15 @@ class ManagePluginMarketplacesAction extends Action2 { await commandService.executeCommand('revealFileInOS', repoUri); break; case 'removeMarketplace': { - const currentValues = configurationService.getValue(ChatConfiguration.PluginMarketplaces) ?? []; - const updated = currentValues.filter(v => typeof v === 'string' && v.trim() !== ref.rawValue); + if (selected.managedByPolicy) { + notificationService.notify({ + severity: Severity.Warning, + message: localize('removeManagedMarketplace', "Enterprise policy manages '{0}', so it can't be removed here.", ref.displayLabel), + }); + return; + } + + const updated = userValues.filter(v => typeof v === 'string' && v.trim() !== ref.rawValue); await configurationService.updateValue(ChatConfiguration.PluginMarketplaces, updated); break; } diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginActions.ts b/src/vs/workbench/contrib/chat/browser/agentPluginActions.ts index 29d788865e8a0..569ad24eb89ee 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginActions.ts @@ -10,6 +10,7 @@ import { ActionWithDropdownActionViewItem, IActionWithDropdownActionViewItemOpti import { IContextMenuProvider } from '../../../../base/browser/contextmenu.js'; import { localize } from '../../../../nls.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkspaceContextService, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; @@ -46,7 +47,7 @@ export class InstallPluginAction extends Action { export class UninstallPluginAction extends Action { constructor(plugin: IAgentPlugin) { super('agentPlugin.uninstall', localize('uninstall', "Uninstall"), 'extension-action label uninstall', true, - () => { plugin.remove(); return Promise.resolve(); }); + () => { plugin.remove?.(); return Promise.resolve(); }); } } @@ -81,6 +82,25 @@ export class OpenPluginReadmeAction extends Action { //#region Context menu +/** Whether the plugin is blocked by enterprise policy and cannot be enabled by the user. */ +export function isPluginPolicyBlocked(plugin: IAgentPlugin): boolean { + return plugin.policyBlocked?.get() === true; +} + +/** Notifies the user that a plugin is managed by their organization and cannot be enabled. */ +export function notifyPluginPolicyBlocked(notificationService: INotificationService, pluginName: string): void { + notificationService.warn(localize('pluginPolicyBlocked', "The plugin \"{0}\" has been disabled by your organization and cannot be enabled.", pluginName)); +} + +/** + * An "Enable" action for a policy-blocked plugin: instead of enabling, it + * explains via a notification that the plugin is managed by the organization. + */ +export function createPolicyBlockedEnableAction(plugin: IAgentPlugin, notificationService: INotificationService): Action { + return new Action('agentPlugin.enableBlocked', localize('enable', "Enable"), undefined, true, + () => { notifyPluginPolicyBlocked(notificationService, plugin.label); return Promise.resolve(); }); +} + /** * Builds the standard context menu action groups for an installed plugin. */ @@ -89,13 +109,17 @@ export function getInstalledPluginContextMenuActions(plugin: IAgentPlugin, insta const agentPluginService = accessor.get(IAgentPluginService); const workspaceService = accessor.get(IWorkspaceContextService); const groups: IAction[][] = []; - groups.push(buildEnablementContextMenuGroup( - plugin.enablement.get(), - plugin.uri.toString(), - agentPluginService.enablementModel, - workspaceService, - 'agentPlugin', - )); + if (isPluginPolicyBlocked(plugin)) { + groups.push([createPolicyBlockedEnableAction(plugin, accessor.get(INotificationService))]); + } else { + groups.push(buildEnablementContextMenuGroup( + plugin.enablement.get(), + plugin.uri.toString(), + agentPluginService.enablementModel, + workspaceService, + 'agentPlugin', + )); + } groups.push([ instantiationService.createInstance(OpenPluginFolderAction, plugin), instantiationService.createInstance(OpenPluginReadmeAction, joinPath(plugin.uri, 'README.md')), diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts b/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts index 660391439d16a..8abd1df838f6c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts @@ -24,6 +24,7 @@ import { IFileService } from '../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { IRequestService, asText } from '../../../../../platform/request/common/request.js'; import { IStorageService } from '../../../../../platform/storage/common/storage.js'; @@ -42,7 +43,7 @@ import { AgentPluginEditorInput } from './agentPluginEditorInput.js'; import { AgentPluginItemKind, IAgentPluginItem, IInstalledPluginItem } from './agentPluginItems.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { EnablementStatusWidget, pluginEnablementLabels } from '../enablementStatusWidget.js'; -import { InstallPluginAction, UninstallPluginAction, createEnablePluginDropDown, createDisablePluginDropDown, EnablementDropDownAction, EnablementDropdownActionViewItem } from '../agentPluginActions.js'; +import { InstallPluginAction, UninstallPluginAction, createEnablePluginDropDown, createDisablePluginDropDown, createPolicyBlockedEnableAction, isPluginPolicyBlocked, EnablementDropDownAction, EnablementDropdownActionViewItem } from '../agentPluginActions.js'; import './media/agentPluginEditor.css'; interface IAgentPluginEditorTemplate { @@ -331,8 +332,13 @@ export class AgentPluginEditor extends EditorPane { } } - actions.push(createEnablePluginDropDown(item.plugin, this.agentPluginService.enablementModel, workspaceService)); - actions.push(createDisablePluginDropDown(item.plugin, this.agentPluginService.enablementModel, workspaceService)); + if (isPluginPolicyBlocked(item.plugin)) { + const notificationService = this.instantiationService.invokeFunction(a => a.get(INotificationService)); + actions.push(createPolicyBlockedEnableAction(item.plugin, notificationService)); + } else { + actions.push(createEnablePluginDropDown(item.plugin, this.agentPluginService.enablementModel, workspaceService)); + actions.push(createDisablePluginDropDown(item.plugin, this.agentPluginService.enablementModel, workspaceService)); + } actions.push(new UninstallPluginAction(item.plugin)); return actions; } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts index 9fe6c38cc06bb..161c52890aa70 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -314,7 +314,7 @@ registerAction2(class extends Action2 { type: 'question', }); if (result.confirmed) { - plugin.remove(); + plugin.remove?.(); } } return; @@ -548,7 +548,7 @@ registerAction2(class extends Action2 { type: 'question', }); if (result.confirmed) { - plugin.remove(); + plugin.remove?.(); } } }); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index b6514bcb7bbcd..ab091a3545a46 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -1087,7 +1087,7 @@ export class McpListWidget extends Disposable { type: 'question', }); if (result.confirmed) { - plugin.remove(); + plugin.remove?.(); } } )); diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 8d72fdbfe8fd3..3cdf7d3b510d8 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -166,7 +166,7 @@ import { ToolResultCompressorService } from './tools/toolResultCompressorService import { AgentPluginService, ConfiguredAgentPluginDiscovery, CopilotCliAgentPluginDiscovery, ExtensionAgentPluginDiscovery, MarketplaceAgentPluginDiscovery } from '../common/plugins/agentPluginServiceImpl.js'; import { IAgentPluginRepositoryService } from '../common/plugins/agentPluginRepositoryService.js'; import { IPluginInstallService } from '../common/plugins/pluginInstallService.js'; -import { IPluginMarketplaceService, PluginMarketplaceService } from '../common/plugins/pluginMarketplaceService.js'; +import { extraKnownMarketplacesToConfigDict, IPluginMarketplaceService, PluginMarketplaceService } from '../common/plugins/pluginMarketplaceService.js'; import { WorkspacePluginSettingsService, IWorkspacePluginSettingsService } from '../common/plugins/workspacePluginSettingsService.js'; import { AgentPluginRecommendations } from './claudePluginRecommendations.js'; import { AgentPluginEditor } from './agentPluginEditor/agentPluginEditor.js'; @@ -924,6 +924,25 @@ configurationRegistry.registerConfiguration({ scope: ConfigurationScope.MACHINE, tags: ['experimental'], }, + [ChatConfiguration.EnabledPlugins]: { + type: 'object', + additionalProperties: { type: 'boolean' }, + markdownDescription: nls.localize('chat.plugins.enabledPlugins', "Enterprise-managed plugin enablement. Keys are plugin IDs in `@` form (resolved to Copilot CLI install paths); values enable (`true`) or disable (`false`) the plugin. Discovered alongside the path-keyed entries in {0}. When set by policy, also restricts which marketplace-discovered plugins are allowed to load (only IDs mapped to `true` here pass the gate).", `\`#${ChatConfiguration.PluginLocations}#\``), + scope: ConfigurationScope.APPLICATION, + tags: ['experimental'], + policy: { + name: 'ChatEnabledPlugins', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.122', + value: (policyData) => policyData.enabledPlugins ? JSON.stringify(policyData.enabledPlugins) : undefined, + localization: { + description: { + key: 'chat.plugins.enabledPlugins.policy', + value: nls.localize('chat.plugins.enabledPlugins.policy', "Plugin enablement. Keys are plugin IDs in `@` form; values enable or disable the plugin."), + } + }, + }, + }, [ChatConfiguration.PluginMarketplaces]: { type: 'array', items: { @@ -934,6 +953,62 @@ configurationRegistry.registerConfiguration({ scope: ConfigurationScope.APPLICATION, tags: ['experimental'], }, + [ChatConfiguration.ExtraMarketplaces]: { + // Policy-only delivery slot for enterprise-managed marketplace entries (via the + // `ChatExtraMarketplaces` policy). Consumers union this with `chat.plugins.marketplaces`. + // + // Stored as a `{ [name]: url-or-shorthand }` object so that: + // - The Settings Editor (ComplexObject renderer) can display entries inline when + // managed by policy, rather than only showing "Edit in settings.json". + // - Marketplace names are preserved for `enabledPlugins["plugin@"]` resolution. + // + // `additionalProperties: { type: ['string'] }` uses the single-element array form of + // JSON Schema's `type` keyword (equivalent to `type: 'string'`) to trigger VS Code's + // ComplexObject renderer, which shows key-value rows inline and hides the + // "Edit in settings.json" link when the value is managed by policy. + type: 'object', + additionalProperties: { type: ['string'] as ['string'] }, + default: {}, + scope: ConfigurationScope.APPLICATION, + included: false, + tags: ['experimental'], + markdownDescription: nls.localize('chat.plugins.extraMarketplaces', "Enterprise-managed additional plugin marketplaces. Unioned with {0}.", `\`#${ChatConfiguration.PluginMarketplaces}#\``), + policy: { + name: 'ChatExtraMarketplaces', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.122', + value: (policyData) => { + const obj = extraKnownMarketplacesToConfigDict(policyData.extraKnownMarketplaces); + return obj ? JSON.stringify(obj) : undefined; + }, + localization: { + description: { + key: 'chat.plugins.extraMarketplaces.policy', + value: nls.localize('chat.plugins.extraMarketplaces.policy', "Additional plugin marketplaces to query. Keys are marketplace names; values are GitHub shorthand (`owner/repo[#ref]`) or Git URIs (`[#ref]`)."), + } + }, + }, + }, + [ChatConfiguration.StrictMarketplaces]: { + type: 'boolean', + markdownDescription: nls.localize('chat.plugins.strictMarketplaces', "When enabled, only marketplaces supplied via enterprise policy are trusted. Plugins from any other marketplace will not load."), + default: false, + restricted: true, + scope: ConfigurationScope.APPLICATION, + tags: ['experimental'], + policy: { + name: 'ChatStrictMarketplaces', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.122', + value: (policyData) => policyData.strictKnownMarketplaces, + localization: { + description: { + key: 'chat.plugins.strictMarketplaces.policy', + value: nls.localize('chat.plugins.strictMarketplaces.policy', "Only trust marketplaces supplied via enterprise policy; plugins from any other marketplace will not load."), + } + }, + }, + }, [ChatConfiguration.AgentEnabled]: { type: 'boolean', description: nls.localize('chat.agent.enabled.description', "When enabled, agent mode can be activated from chat and tools in agentic contexts with side effects can be used."), diff --git a/src/vs/workbench/contrib/chat/browser/githubRepoFetcher.ts b/src/vs/workbench/contrib/chat/browser/githubRepoFetcher.ts index ef815ed75f28f..aad5288d54c39 100644 --- a/src/vs/workbench/contrib/chat/browser/githubRepoFetcher.ts +++ b/src/vs/workbench/contrib/chat/browser/githubRepoFetcher.ts @@ -12,7 +12,7 @@ import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { IRequestService, asJson, isClientError, isSuccess } from '../../../../platform/request/common/request.js'; +import { IRequestService, asJson, isClientError, isSuccess, readHeader, retryAfterFromHeaders } from '../../../../platform/request/common/request.js'; /** * GitHub `owner/repo` parsed from a clone URL. Only `https://github.com/...` URLs @@ -153,26 +153,6 @@ function isRateLimited(headers: Record | return readHeader(headers, 'retry-after') !== undefined; } -function retryAfterFromHeaders(headers: Record | undefined): number | undefined { - if (!headers) { - return undefined; - } - const value = readHeader(headers, 'retry-after'); - if (!value) { - return undefined; - } - const parsed = parseInt(value, 10); - return Number.isFinite(parsed) ? parsed : undefined; -} - -function readHeader(headers: Record, name: string): string | undefined { - const value = headers[name] ?? headers[name.toLowerCase()]; - if (Array.isArray(value)) { - return value[0]; - } - return value; -} - /** * Fetches the file tree of a GitHub repository at the given SHA and writes * each blob into {@link targetDir} via {@link IFileService}. diff --git a/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts b/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts index 2bd74cc35388f..ec8c55f1ad625 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts @@ -20,7 +20,7 @@ import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickin import { IAgentPluginRepositoryService } from '../common/plugins/agentPluginRepositoryService.js'; import { ChatConfiguration } from '../common/constants.js'; import { IPluginInstallService, IInstallPluginFromSourceOptions, IInstallPluginFromSourceResult, IUpdateAllPluginsOptions, IUpdateAllPluginsResult } from '../common/plugins/pluginInstallService.js'; -import { IMarketplacePlugin, IMarketplaceReference, IPluginMarketplaceService, MarketplaceReferenceKind, MarketplaceType, hasSourceChanged, parseMarketplaceReference, parseMarketplaceReferences, PluginSourceKind } from '../common/plugins/pluginMarketplaceService.js'; +import { IMarketplacePlugin, IMarketplaceReference, IPluginMarketplaceService, MarketplaceReferenceKind, MarketplaceType, hasSourceChanged, parseMarketplaceReference, parseMarketplaceReferences, PluginSourceKind, readConfiguredMarketplaces } from '../common/plugins/pluginMarketplaceService.js'; export class PluginInstallService implements IPluginInstallService { declare readonly _serviceBrand: undefined; @@ -233,12 +233,12 @@ export class PluginInstallService implements IPluginInstallService { } private _addMarketplaceToConfig(reference: IMarketplaceReference) { - const currentValues = this._configurationService.getValue(ChatConfiguration.PluginMarketplaces) ?? []; - const existingRefs = parseMarketplaceReferences(currentValues); + const { userValues, effectiveValues } = readConfiguredMarketplaces(this._configurationService); + const existingRefs = parseMarketplaceReferences(effectiveValues); if (existingRefs.some(r => r.canonicalId === reference.canonicalId)) { return; } - return this._configurationService.updateValue(ChatConfiguration.PluginMarketplaces, [...currentValues, reference.rawValue]); + return this._configurationService.updateValue(ChatConfiguration.PluginMarketplaces, [...userValues, reference.rawValue]); } async updatePlugin(plugin: IMarketplacePlugin, silent?: boolean): Promise { diff --git a/src/vs/workbench/contrib/chat/browser/pluginUrlHandler.ts b/src/vs/workbench/contrib/chat/browser/pluginUrlHandler.ts index eccdd67ed8f24..54beb11dc687a 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginUrlHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginUrlHandler.ts @@ -21,7 +21,7 @@ import { IExtensionsWorkbenchService } from '../../extensions/common/extensions. import { AgentPluginEditorInput } from './agentPluginEditor/agentPluginEditorInput.js'; import { AgentPluginItemKind, IMarketplacePluginItem } from './agentPluginEditor/agentPluginItems.js'; import { ChatConfiguration } from '../common/constants.js'; -import { MarketplaceReferenceKind, parseMarketplaceReference, parseMarketplaceReferences } from '../common/plugins/marketplaceReference.js'; +import { MarketplaceReferenceKind, parseMarketplaceReference, parseMarketplaceReferences, readConfiguredMarketplaces } from '../common/plugins/marketplaceReference.js'; import { IPluginInstallService } from '../common/plugins/pluginInstallService.js'; /** @@ -184,12 +184,12 @@ export class PluginUrlHandler extends Disposable implements IWorkbenchContributi return true; } - const existing = this._configurationService.getValue(ChatConfiguration.PluginMarketplaces) ?? []; - const existingRefs = parseMarketplaceReferences(existing); + const { userValues, effectiveValues } = readConfiguredMarketplaces(this._configurationService); + const existingRefs = parseMarketplaceReferences(effectiveValues); if (!existingRefs.some(e => e.canonicalId === ref.canonicalId)) { await this._configurationService.updateValue( ChatConfiguration.PluginMarketplaces, - [...existing, refValue], + [...userValues, refValue], ConfigurationTarget.USER, ); } diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index a65f25b888761..abb0024f1c0df 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -15,6 +15,9 @@ export enum ChatConfiguration { PluginsEnabled = 'chat.plugins.enabled', PluginLocations = 'chat.pluginLocations', PluginMarketplaces = 'chat.plugins.marketplaces', + ExtraMarketplaces = 'chat.plugins.extraMarketplaces', + StrictMarketplaces = 'chat.plugins.strictMarketplaces', + EnabledPlugins = 'chat.plugins.enabledPlugins', AgentEnabled = 'chat.agent.enabled', PlanAgentDefaultModel = 'chat.planAgent.defaultModel', ExploreAgentDefaultModel = 'chat.exploreAgent.defaultModel', diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts index 35b78139444a9..72746dd7f1af6 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts @@ -35,8 +35,15 @@ export interface IAgentPlugin { /** Human-readable display name for the plugin. */ readonly label: string; readonly enablement: IObservable; - /** Removes this plugin from its discovery source (config or installed storage). */ - remove(): void; + /** + * When `true`, the plugin is blocked by enterprise policy. It remains + * visible (shown as disabled) but its contributions are inactive and the + * user cannot re-enable it. Folded into {@link enablement} so all gating + * consumers honor it automatically. + */ + readonly policyBlocked?: IObservable; + /** Removes this plugin from its discovery source (config or installed storage). Undefined for policy-managed plugins that cannot be removed by the user. */ + remove?(): void; readonly hooks: IObservable; readonly commands: IObservable; readonly skills: IObservable; diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index 7052dbbec6f7d..c114aba6974c8 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -4,13 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { RunOnceScheduler } from '../../../../../base/common/async.js'; +import { Event } from '../../../../../base/common/event.js'; import { Iterable } from '../../../../../base/common/iterator.js'; import { parse as parseJSONC } from '../../../../../base/common/json.js'; import { untildify } from '../../../../../base/common/labels.js'; import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; import { ResourceSet } from '../../../../../base/common/map.js'; import { equals } from '../../../../../base/common/objects.js'; -import { autorun, derived, derivedOpts, IObservable, ObservablePromise, observableSignal, observableValue } from '../../../../../base/common/observable.js'; +import { autorun, derived, derivedOpts, IObservable, ISettableObservable, ITransaction, observableFromEvent, observableSignalFromEvent, ObservablePromise, observableSignal, observableValue, transaction } from '../../../../../base/common/observable.js'; import { posix, win32 @@ -48,11 +49,12 @@ import { Extensions, IExtensionFeaturesRegistry, IExtensionFeatureTableRenderer, import * as extensionsRegistry from '../../../../services/extensions/common/extensionsRegistry.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; import { ChatConfiguration } from '../constants.js'; -import { EnablementModel, IEnablementModel } from '../enablement.js'; +import { ContributionEnablementState, EnablementModel, IEnablementModel } from '../enablement.js'; import { HookType } from '../promptSyntax/hookTypes.js'; import { IAgentPluginRepositoryService } from './agentPluginRepositoryService.js'; import { agentPluginDiscoveryRegistry, IAgentPlugin, IAgentPluginDiscovery, IAgentPluginHook, IAgentPluginInstruction, IAgentPluginMcpServerDefinition, IAgentPluginService } from './agentPluginService.js'; import { IMarketplacePlugin, IPluginMarketplaceService } from './pluginMarketplaceService.js'; +import { type IMarketplaceReference, parseMarketplaceReferences, readConfiguredMarketplaces } from './marketplaceReference.js'; // Re-export shared helpers so existing consumers (including tests) continue to work. export { shellQuotePluginRootInCommand, resolveMcpServersMap, convertBareEnvVarsToVsCodeSyntax } from '../../../../../platform/agentPlugins/common/pluginParsers.js'; @@ -96,6 +98,8 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic @IInstantiationService instantiationService: IInstantiationService, @IConfigurationService configurationService: IConfigurationService, @IStorageService storageService: IStorageService, + @IPluginMarketplaceService pluginMarketplaceService: IPluginMarketplaceService, + @ILogService logService: ILogService, ) { super(); @@ -111,6 +115,18 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic discovery.start(this.enablementModel); } + // Policy-driven enforcement, applied after discovery so that enterprise + // policy is honored regardless of which discovery source surfaces a + // plugin (local paths, marketplace, CLI install dir). + const enabledPluginsPolicy = observableFromEvent(this, + Event.filter(configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(ChatConfiguration.EnabledPlugins)), + () => configurationService.inspect>(ChatConfiguration.EnabledPlugins).policyValue, + ); + const strictMarketplaces = observableConfigValue(ChatConfiguration.StrictMarketplaces, false, configurationService); + // Re-evaluate marketplace trust when the policy-delivered extra + // marketplaces change (consulted under strict mode). + const extraMarketplacesChanged = observableSignalFromEvent('extraMarketplaces', + Event.filter(configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(ChatConfiguration.ExtraMarketplaces))); this.plugins = derived(read => { if (!pluginsEnabled.read(read)) { @@ -118,6 +134,69 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic } return this._dedupeAndSort(discoveries.flatMap(d => d.plugins.read(read))); }); + + // Mark policy-blocked plugins rather than hiding them: a blocked plugin + // stays visible (shown as disabled) but its `enablement` is forced to + // disabled (see `_toPlugin`), so it is inactive and cannot be re-enabled. + this._register(autorun(reader => { + const plugins = this.plugins.read(reader); + const policy = enabledPluginsPolicy.read(reader); + const strict = strictMarketplaces.read(reader); + extraMarketplacesChanged.read(reader); + const trustedExtras = strict + ? parseMarketplaceReferences(readConfiguredMarketplaces(configurationService).extraValues) + : []; + transaction(tx => { + for (const plugin of plugins) { + setPolicyBlocked(plugin, this._isBlockedByPolicy(plugin, policy, strict, trustedExtras, pluginMarketplaceService, logService), tx); + } + }); + })); + } + + /** + * Determines whether a plugin is blocked by enterprise policy: + * - If `chat.plugins.enabledPlugins` (policy-managed via `ChatEnabledPlugins`) + * is set, only plugins whose ID appears with value `true` are allowed. + * - If `chat.plugins.strictMarketplaces` is on, only plugins from a + * marketplace listed in `chat.plugins.extraMarketplaces` are allowed. + * + * Plugins without a marketplace provenance (e.g. user-configured filesystem + * paths from `chat.pluginLocations`) are never blocked — they are user-side + * concerns outside the enterprise enforcement boundary. Copilot-CLI-installed + * plugins under `~/.copilot/installed-plugins///` are + * gated using their install-path identity even when they lack rich + * marketplace metadata. + */ + private _isBlockedByPolicy( + plugin: IAgentPlugin, + enabledPluginsPolicy: Record | undefined, + strictMarketplaces: boolean, + trustedExtras: readonly IMarketplaceReference[], + pluginMarketplaceService: IPluginMarketplaceService, + logService: ILogService, + ): boolean { + const identity = getPolicyIdentity(plugin); + if (!identity) { + return false; + } + const pluginId = `${identity.name}@${identity.marketplace}`; + if (enabledPluginsPolicy && Object.keys(enabledPluginsPolicy).length > 0) { + if (enabledPluginsPolicy[pluginId] !== true) { + logService.debug(`[AgentPluginService] Plugin '${pluginId}' blocked — not enabled by ChatEnabledPlugins policy`); + return true; + } + } + if (strictMarketplaces) { + const trusted = identity.marketplaceReference + ? pluginMarketplaceService.isMarketplaceTrusted(identity.marketplaceReference) + : isCliBucketTrusted(identity.marketplace, trustedExtras); + if (!trusted) { + logService.debug(`[AgentPluginService] Plugin '${pluginId}' blocked — marketplace not trusted under strict mode`); + return true; + } + } + return false; } private _dedupeAndSort(plugins: readonly IAgentPlugin[]): readonly IAgentPlugin[] { @@ -138,7 +217,90 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic } } -type PluginEntry = IAgentPlugin; +/** + * A discovered plugin. Extends the public {@link IAgentPlugin} with a settable + * `policyBlocked` observable that the service writes to when enterprise policy + * blocks the plugin. + */ +interface PluginEntry extends IAgentPlugin { + readonly policyBlocked: ISettableObservable; +} + +/** + * Marks a plugin as blocked (or unblocked) by enterprise policy. Safe to call + * for any {@link IAgentPlugin}; entries without a settable observable (e.g. test + * doubles) are ignored. + */ +function setPolicyBlocked(plugin: IAgentPlugin, blocked: boolean, tx: ITransaction): void { + const obs = plugin.policyBlocked as ISettableObservable | undefined; + if (obs && typeof obs.set === 'function') { + obs.set(blocked, tx); + } +} + +/** + * The policy-relevant identity of a plugin, used to gate against + * `chat.plugins.enabledPlugins` and `chat.plugins.strictMarketplaces`. Returns + * `undefined` for plugins that aren't subject to enterprise policy (e.g. + * user-configured filesystem entries, extension-contributed plugins). + */ +interface IPolicyIdentity { + readonly name: string; + readonly marketplace: string; + readonly marketplaceReference?: IMarketplaceReference; +} + +/** Path fragment that identifies a Copilot-CLI-installed plugin. Mirrored by `_resolveEnterprisePluginId`. */ +const COPILOT_CLI_INSTALL_PATH_FRAGMENT = '/.copilot/installed-plugins/'; + +function getPolicyIdentity(plugin: IAgentPlugin): IPolicyIdentity | undefined { + const m = plugin.fromMarketplace; + if (m) { + return { name: m.name, marketplace: m.marketplace, marketplaceReference: m.marketplaceReference }; + } + // Copilot-CLI-installed plugins live at `~/.copilot/installed-plugins///`. + // We honor enterprise policy for those by deriving the identity from the install path, + // using the same convention as `_resolveEnterprisePluginId`. The reserved `_direct` bucket + // means "not from a marketplace" and is therefore not gated. + if (plugin.uri.scheme !== 'file') { + return undefined; + } + const idx = plugin.uri.path.indexOf(COPILOT_CLI_INSTALL_PATH_FRAGMENT); + if (idx === -1) { + return undefined; + } + const segments = plugin.uri.path.slice(idx + COPILOT_CLI_INSTALL_PATH_FRAGMENT.length).split('/').filter(s => s.length > 0); + if (segments.length !== 2) { + return undefined; + } + const [marketplace, name] = segments; + if (marketplace === '_direct') { + return undefined; + } + return { name, marketplace }; +} + +/** + * Under strict mode, decide whether a Copilot-CLI-installed plugin's bucket + * name (the `` segment of its install path) corresponds to a + * trusted marketplace listed in `chat.plugins.extraMarketplaces`. Uses + * heuristics covering common CLI bucket-naming conventions (GitHub repo name, + * shorthand `owner/repo`, raw reference value, canonical id tail). + */ +function isCliBucketTrusted(bucket: string, trustedExtras: readonly IMarketplaceReference[]): boolean { + return trustedExtras.some(ref => { + if (ref.githubRepo && ref.githubRepo.split('/').pop() === bucket) { + return true; + } + if (ref.displayLabel === bucket || ref.displayLabel.endsWith(`/${bucket}`)) { + return true; + } + if (ref.canonicalId.endsWith(`/${bucket}`) || ref.canonicalId.endsWith(`/${bucket}.git`)) { + return true; + } + return false; + }); +} /** * Minimal shape of a parsed plugin manifest. Known fields are typed; unknown @@ -162,8 +324,8 @@ interface IPluginSource { readonly fromMarketplace: IMarketplacePlugin | undefined; /** Repository root that serves as the boundary for component path resolution. */ readonly repositoryUri?: URI; - /** Called when remove is invoked on the plugin */ - remove(): void; + /** Called when remove is invoked on the plugin; absent for policy-managed plugins */ + remove?(): void; } /** @@ -218,7 +380,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements if (!seenPluginUris.has(key)) { seenPluginUris.add(key); const format = await detectPluginFormat(source.uri, this._fileService); - plugins.push(await this._toPlugin(source.uri, format, source.fromMarketplace, source.repositoryUri, () => source.remove())); + plugins.push(await this._toPlugin(source.uri, format, source.fromMarketplace, source.repositoryUri, source.remove)); } } @@ -237,7 +399,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements } } - private async _toPlugin(uri: URI, format: IPluginFormatConfig, fromMarketplace: IMarketplacePlugin | undefined, repositoryUri: URI | undefined, removeCallback: () => void): Promise { + private async _toPlugin(uri: URI, format: IPluginFormatConfig, fromMarketplace: IMarketplacePlugin | undefined, repositoryUri: URI | undefined, removeCallback: (() => void) | undefined): Promise { const key = uri.toString(); const existing = this._pluginEntries.get(key); if (existing) { @@ -250,7 +412,12 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements } const store = new DisposableStore(); - const enablement = derived(r => this._enablementModel.readEnabled(key, r)); + // Set by the service when enterprise policy blocks this plugin; when set, + // the plugin is forced disabled regardless of the user's enablement choice. + const policyBlocked = observableValue('policyBlocked', false); + const enablement = derived(r => policyBlocked.read(r) + ? ContributionEnablementState.DisabledProfile + : this._enablementModel.readEnabled(key, r)); // Read the manifest up front so its `name` field can be used in the // plugin label (for direct installs that have no marketplace metadata). @@ -348,6 +515,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements uri, label: fromMarketplace?.name ?? manifestName ?? basename(uri), enablement, + policyBlocked, remove: removeCallback, hooks, commands, @@ -493,6 +661,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements export class ConfiguredAgentPluginDiscovery extends AbstractAgentPluginDiscovery { private readonly _pluginLocationsConfig: IObservable>; + private readonly _enterpriseEnabledPluginsConfig: IObservable>; constructor( @IConfigurationService private readonly _configurationService: IConfigurationService, @@ -504,6 +673,17 @@ export class ConfiguredAgentPluginDiscovery extends AbstractAgentPluginDiscovery ) { super(fileService, pathService, logService, workspaceContextService); this._pluginLocationsConfig = observableConfigValue>(ChatConfiguration.PluginLocations, {}, _configurationService); + // Enterprise-managed plugin-ID entries (delivered via the `ChatEnabledPlugins` policy). + // These are plugin IDs in `@` form, distinct from filesystem paths. + // Read via `inspect()` so user-set entries survive when the policy is also set — + // `getValue()` alone would surface only the policy value. + this._enterpriseEnabledPluginsConfig = observableFromEvent(this, + Event.filter(this._configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(ChatConfiguration.EnabledPlugins)), + () => { + const inspected = this._configurationService.inspect>(ChatConfiguration.EnabledPlugins); + return { ...inspected.defaultValue, ...inspected.userValue, ...inspected.policyValue }; + }, + ); } public override start(enablementModel: IEnablementModel): void { @@ -511,6 +691,7 @@ export class ConfiguredAgentPluginDiscovery extends AbstractAgentPluginDiscovery const scheduler = this._register(new RunOnceScheduler(() => this._refreshPlugins(), 0)); this._register(autorun(reader => { this._pluginLocationsConfig.read(reader); + this._enterpriseEnabledPluginsConfig.read(reader); scheduler.schedule(); })); scheduler.schedule(); @@ -518,59 +699,77 @@ export class ConfiguredAgentPluginDiscovery extends AbstractAgentPluginDiscovery protected override async _discoverPluginSources(): Promise { const sources: IPluginSource[] = []; - const config = this._pluginLocationsConfig.get(); const userHome = await this._getUserHome(); - for (const [path, enabled] of Object.entries(config)) { - if (!path.trim() || enabled === false) { + // User-configured filesystem paths in `chat.pluginLocations` — removable + // by re-writing the user setting. Filesystem-only; an entry that happens + // to look like `name@marketplace` is treated as a relative path, not an ID. + for (const [key, enabled] of Object.entries(this._pluginLocationsConfig.get())) { + const trimmed = key.trim(); + if (!trimmed || enabled === false) { continue; } + for (const resource of this._resolvePluginPath(trimmed, userHome)) { + await this._addPluginSource(sources, resource, 'plugin path', () => this._removePluginPath(key)); + } + } - const resources = this._resolvePluginPath(path.trim(), userHome); - for (const resource of resources) { - let stat; - try { - stat = await this._fileService.resolve(resource); - } catch { - this._logService.debug(`[ConfiguredAgentPluginDiscovery] Could not resolve plugin path: ${resource.toString()}`); - continue; - } - - if (!stat.isDirectory) { - this._logService.debug(`[ConfiguredAgentPluginDiscovery] Plugin path is not a directory: ${resource.toString()}`); - continue; - } - - const fromMarketplace = this._pluginMarketplaceService.getMarketplacePluginMetadata(stat.resource); - const configKey = path; - sources.push({ - uri: stat.resource, - fromMarketplace, - remove: () => this._removePluginPath(configKey), - }); + // Enterprise-managed plugin IDs in `chat.plugins.enabledPlugins` (delivered + // via the `ChatEnabledPlugins` policy) — IDs of the form + // `@`, resolved to the Copilot CLI install convention. + // Non-removable from the UI (enterprise-managed). + for (const [key, enabled] of Object.entries(this._enterpriseEnabledPluginsConfig.get())) { + const trimmed = key.trim(); + if (!trimmed || enabled === false) { + continue; + } + const resource = this._resolveEnterprisePluginId(trimmed, userHome); + if (!resource) { + this._logService.debug(`[ConfiguredAgentPluginDiscovery] Skipping enterprise plugin entry that is not in @ form: ${trimmed}`); + continue; } + await this._addPluginSource(sources, resource, 'enterprise plugin path'); } return sources; } + private async _addPluginSource(sources: IPluginSource[], resource: URI, label: string, remove?: () => void): Promise { + let stat; + try { + stat = await this._fileService.resolve(resource); + } catch { + this._logService.debug(`[ConfiguredAgentPluginDiscovery] Could not resolve ${label}: ${resource.toString()}`); + return; + } + + if (!stat.isDirectory) { + this._logService.debug(`[ConfiguredAgentPluginDiscovery] ${label} is not a directory: ${resource.toString()}`); + return; + } + + sources.push({ + uri: stat.resource, + fromMarketplace: this._pluginMarketplaceService.getMarketplacePluginMetadata(stat.resource), + remove, + }); + } + private async _getUserHome(): Promise { const userHome = await this._pathService.userHome(); return userHome.scheme === 'file' ? userHome.fsPath : userHome.path; } /** - * Resolves a plugin path to one or more resource URIs. Supports: - * - Absolute paths (used directly) - * - Tilde paths (expanded to user home directory) - * - Relative paths (resolved against each workspace folder) + * Resolves a user-configured plugin path to one or more resource URIs. + * Supports absolute paths, tilde paths (expanded to user home), and + * workspace-relative paths. */ private _resolvePluginPath(path: string, userHome: string): URI[] { if (path.startsWith('~')) { path = untildify(path, userHome); } - // Handle absolute paths if (win32.isAbsolute(path) || posix.isAbsolute(path)) { return [URI.file(path)]; } @@ -580,6 +779,20 @@ export class ConfiguredAgentPluginDiscovery extends AbstractAgentPluginDiscovery ); } + /** + * Resolves an enterprise plugin ID of the form `@` to + * the Copilot CLI install convention `~/.copilot/installed-plugins///`. + * Returns `undefined` for anything that doesn't match the ID shape. + */ + private _resolveEnterprisePluginId(id: string, userHome: string): URI | undefined { + const idMatch = id.match(/^([^@/\\~]+)@([^@/\\~]+)$/); + if (!idMatch) { + return undefined; + } + const [, plugin, marketplace] = idMatch; + return URI.file(`${userHome}/.copilot/installed-plugins/${marketplace}/${plugin}`); + } + /** * Removes a plugin path from `chat.pluginLocations` in the most specific * config target where the key is defined. diff --git a/src/vs/workbench/contrib/chat/common/plugins/marketplaceReference.ts b/src/vs/workbench/contrib/chat/common/plugins/marketplaceReference.ts index 6a1995778246a..6dcf8c67a3913 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/marketplaceReference.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/marketplaceReference.ts @@ -4,6 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../../../../base/common/uri.js'; +import { IExtraKnownMarketplaceEntry } from '../../../../../base/common/managedSettings.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ChatConfiguration } from '../constants.js'; export const enum MarketplaceReferenceKind { GitHubShorthand = 'githubShorthand', @@ -23,20 +26,89 @@ export interface IMarketplaceReference { readonly localRepositoryUri?: URI; } +/** + * The two configuration layers behind plugin marketplaces: + * - `userValues` — what's stored at {@link ChatConfiguration.PluginMarketplaces} + * (default + user). Writable by the user. + * - `extraValues` — what's delivered via the `ChatExtraMarketplaces` enterprise + * policy into {@link ChatConfiguration.ExtraMarketplaces}. Read-only. + * + * Entries may be strings (`/` or git URIs) or, in the policy case, + * {@link IExtraMarketplaceObjectEntry} objects — both shapes flow through + * {@link parseMarketplaceReferences}. + */ +export interface IConfiguredMarketplaces { + readonly userValues: readonly unknown[]; + readonly extraValues: readonly unknown[]; + readonly effectiveValues: readonly unknown[]; +} + +/** Shorthand-or-URI regex used to detect GitHub `owner/repo[#ref]` entries. */ +const _githubShorthandRe = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:#.+)?$/; + +/** + * Converts the {@link IExtraKnownMarketplaceEntry} array delivered by the + * `ChatExtraMarketplaces` policy into the `{ [name]: url-or-shorthand }` dict + * stored on the `chat.plugins.extraMarketplaces` setting. + * + * The dict shape is what the Settings Editor's ComplexObject renderer can + * display inline as key/value rows. {@link readConfiguredMarketplaces} reverses + * this conversion so {@link parseMarketplaceReferences} keeps producing + * `displayLabel = name` (required for `enabledPlugins["plugin@"]` keys). + * + * Plain-string entries (allowed by the policy schema but unnamed) are stored + * with the value used as both key and value so they survive the round-trip + * intact. + */ +export function extraKnownMarketplacesToConfigDict(entries: readonly (string | IExtraKnownMarketplaceEntry)[] | undefined): Record | undefined { + if (!entries?.length) { + return undefined; + } + const obj: Record = {}; + for (const entry of entries) { + if (typeof entry === 'string') { + obj[entry] = entry; + } else { + const s = entry.source; + const base = s.source === 'github' ? s.repo : s.url; + obj[entry.name] = s.ref ? `${base}#${s.ref}` : base; + } + } + return obj; +} + +export function readConfiguredMarketplaces(configurationService: IConfigurationService): IConfiguredMarketplaces { + const userValues = configurationService.getValue<(string | object)[]>(ChatConfiguration.PluginMarketplaces) ?? []; + + // `ChatExtraMarketplaces` is stored as `{ [name]: url-or-shorthand }` when delivered by + // policy. Convert each entry to the nested IExtraMarketplaceObjectEntry shape so that + // parseMarketplaceReferences can set displayLabel = name (critical for enabledPlugins keys). + const extraObj = configurationService.getValue>(ChatConfiguration.ExtraMarketplaces) ?? {}; + const extraValues: IExtraMarketplaceObjectEntry[] = Object.entries(extraObj).map(([name, src]) => { + const isGithubShorthand = _githubShorthandRe.test(src); + return isGithubShorthand + ? { name, source: { source: 'github' as const, repo: src } } + : { name, source: { source: 'git' as const, url: src } }; + }); + + return { + userValues, + extraValues, + effectiveValues: [...userValues, ...extraValues], + }; +} + export function parseMarketplaceReferences(values: readonly unknown[]): IMarketplaceReference[] { const byCanonicalId = new Map(); for (const value of values) { - if (typeof value !== 'string') { - continue; + let parsed: IMarketplaceReference | undefined; + if (typeof value === 'string') { + parsed = parseMarketplaceReference(value); + } else if (value && typeof value === 'object') { + parsed = parseMarketplaceObjectEntry(value as IExtraMarketplaceObjectEntry); } - - const parsed = parseMarketplaceReference(value); - if (!parsed) { - continue; - } - - if (!byCanonicalId.has(parsed.canonicalId)) { + if (parsed && !byCanonicalId.has(parsed.canonicalId)) { byCanonicalId.set(parsed.canonicalId, parsed); } } @@ -44,6 +116,64 @@ export function parseMarketplaceReferences(values: readonly unknown[]): IMarketp return [...byCanonicalId.values()]; } +/** + * Object-form marketplace entry shape, as delivered via the enterprise + * `managed_settings` policy or `.github/copilot/settings.json` workspace + * file. `name` (when present) is used as the marketplace's `displayLabel` + * so that `enabledPlugins["plugin@name"]` keys match consistently. + * + * Both the nested form (`source: { source, repo|url }`) and the flat form + * (`source: 'github', repo: ...`) are accepted. + */ +export interface IExtraMarketplaceObjectEntry { + readonly name?: string; + readonly source?: string | { readonly source?: string; readonly repo?: string; readonly url?: string; readonly ref?: string }; + readonly repo?: string; + readonly url?: string; + readonly ref?: string; +} + +export function parseMarketplaceObjectEntry(entry: IExtraMarketplaceObjectEntry): IMarketplaceReference | undefined { + let sourceType: string | undefined; + let repo: string | undefined; + let url: string | undefined; + let ref: string | undefined; + + if (entry.source && typeof entry.source === 'object') { + const nested = entry.source; + sourceType = nested.source; + repo = nested.repo; + url = nested.url; + ref = nested.ref; + } else { + sourceType = entry.source; + repo = entry.repo; + url = entry.url; + ref = entry.ref; + } + + let parsed: IMarketplaceReference | undefined; + if (sourceType === 'github' && typeof repo === 'string') { + parsed = parseMarketplaceReference(appendMarketplaceRef(repo, ref)); + } else if (sourceType === 'git' && typeof url === 'string') { + parsed = parseMarketplaceReference(appendMarketplaceRef(url, ref)); + } + + if (parsed && typeof entry.name === 'string' && entry.name.length > 0) { + parsed = { ...parsed, displayLabel: entry.name }; + } + return parsed; +} + +function appendMarketplaceRef(value: string, ref: string | undefined): string { + if (!ref) { + return value; + } + const fragmentIndex = value.indexOf('#'); + const base = fragmentIndex === -1 ? value : value.slice(0, fragmentIndex); + return `${base}#${ref}`; +} + /** * Merges two sets of marketplace references, deduplicating by canonical ID. * The first set takes precedence when IDs collide. @@ -130,29 +260,52 @@ function parseUriMarketplaceReference(rawValue: string): IMarketplaceReference | return undefined; } - const normalizedPath = normalizeGitRepoPath(uri.path); - if (!normalizedPath) { - return undefined; - } const ref = uri.fragment || undefined; const cloneUri = uri.fragment ? uri.with({ fragment: '' }) : uri; + const sanitizedAuthority = sanitizePathSegment(uri.authority.toLowerCase()); + const trimmedPath = uri.path.replace(/\/+/g, '/').replace(/\/+$/g, '').replace(/^\/+/, ''); + + // Host-only marketplace endpoint (e.g. `https://plugins.internal.example.com`). + // The ADR allows any string for `git.url`, so a URL without a repo path is + // treated as a marketplace registry endpoint identified by host alone. + if (!trimmedPath) { + return { + rawValue, + displayLabel: rawValue, + cloneUrl: cloneUri.toString(), + canonicalId: appendRefSuffix(`git:${uri.authority.toLowerCase()}/`, ref), + cacheSegments: [sanitizedAuthority, ...getRefCacheSegments(ref)], + kind: MarketplaceReferenceKind.GitUri, + ref, + }; + } const gitSuffix = '.git'; - const sanitizedAuthority = sanitizePathSegment(uri.authority.toLowerCase()); - const pathHasGitSuffix = normalizedPath.toLowerCase().endsWith(gitSuffix); - const pathWithoutGit = pathHasGitSuffix ? normalizedPath.slice(1, normalizedPath.length - gitSuffix.length) : normalizedPath.slice(1); + const pathHasGitSuffix = trimmedPath.toLowerCase().endsWith(gitSuffix); + const pathWithoutGit = pathHasGitSuffix ? trimmedPath.slice(0, trimmedPath.length - gitSuffix.length) : trimmedPath; const pathSegments = pathWithoutGit.split('/').map(sanitizePathSegment); // Always normalize the canonical path to include .git so that URLs with and without the suffix deduplicate. - const canonicalPath = pathHasGitSuffix ? normalizedPath.slice(1).toLowerCase() : `${normalizedPath.slice(1).toLowerCase()}${gitSuffix}`; + const canonicalPath = pathHasGitSuffix ? trimmedPath.toLowerCase() : `${trimmedPath.toLowerCase()}${gitSuffix}`; // Extract githubRepo for GitHub URLs so the editor can render a clickable link const githubRepo = extractGitHubRepo(uri.authority, pathWithoutGit); + // Normalize github.com//[.git] URLs to the same canonical id + // the shorthand parser emits, so policy trust comparisons (which match by + // canonicalId) treat both forms as the same marketplace. + let canonicalId: string; + if (githubRepo) { + const [owner, repo] = githubRepo.split('/'); + canonicalId = getGitHubCanonicalId(owner, repo, ref); + } else { + canonicalId = appendRefSuffix(`git:${uri.authority.toLowerCase()}/${canonicalPath}`, ref); + } + return { rawValue, displayLabel: rawValue, cloneUrl: cloneUri.toString(), - canonicalId: appendRefSuffix(`git:${uri.authority.toLowerCase()}/${canonicalPath}`, ref), + canonicalId, cacheSegments: [sanitizedAuthority, ...pathSegments, ...getRefCacheSegments(ref)], kind: MarketplaceReferenceKind.GitUri, ref, @@ -178,11 +331,21 @@ function parseScpMarketplaceReference(rawValue: string): IMarketplaceReference | const pathSegments = pathWithoutGit.split('/').map(sanitizePathSegment); const githubRepo = extractGitHubRepo(authority, pathWithoutGit); + // Normalize git@github.com:/.git to the same canonical id the + // shorthand parser emits (see parseUriMarketplaceReference for rationale). + let canonicalId: string; + if (githubRepo) { + const [owner, repo] = githubRepo.split('/'); + canonicalId = getGitHubCanonicalId(owner, repo, ref); + } else { + canonicalId = appendRefSuffix(`git:${authority.toLowerCase()}/${pathWithGit.toLowerCase()}`, ref); + } + return { rawValue, displayLabel: rawValue, cloneUrl: `${match[1]}@${authority}:${pathWithGit}`, - canonicalId: appendRefSuffix(`git:${authority.toLowerCase()}/${pathWithGit.toLowerCase()}`, ref), + canonicalId, cacheSegments: [sanitizePathSegment(authority.toLowerCase()), ...pathSegments, ...getRefCacheSegments(ref)], kind: MarketplaceReferenceKind.GitUri, ref, @@ -190,28 +353,6 @@ function parseScpMarketplaceReference(rawValue: string): IMarketplaceReference | }; } -/** - * Normalizes a Git repository path and validates that it has at least two segments - * (i.e., at least one owner/repo pair below the root). Accepts paths with or without - * a `.git` suffix — the suffix is preserved in the returned value so callers can decide - * how to treat it. - */ -function normalizeGitRepoPath(path: string): string | undefined { - const gitSuffix = '.git'; - const trimmed = path.replace(/\/+/g, '/').replace(/\/+$/g, ''); - - const withLeadingSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; - // Strip .git suffix (if present) only for the purposes of validating path depth. - const pathWithoutGit = withLeadingSlash.toLowerCase().endsWith(gitSuffix) - ? withLeadingSlash.slice(1, withLeadingSlash.length - gitSuffix.length) - : withLeadingSlash.slice(1); - if (!pathWithoutGit || !pathWithoutGit.includes('/')) { - return undefined; - } - - return withLeadingSlash; -} - function extractGitHubRepo(authority: string, pathWithoutGit: string): string | undefined { if (authority.toLowerCase() !== 'github.com') { return undefined; diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index af9437fc73d2f..d9fdab4cc3846 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -28,11 +28,11 @@ import { IAgentPluginRepositoryService } from './agentPluginRepositoryService.js import { FileBackedInstalledPluginsStore, IStoredInstalledPlugin } from './fileBackedInstalledPluginsStore.js'; import { IWorkspacePluginSettingsService } from './workspacePluginSettingsService.js'; import { IWorkspaceTrustManagementService } from '../../../../../platform/workspace/common/workspaceTrust.js'; -import { type IMarketplaceReference, deduplicateMarketplaceReferences, MarketplaceReferenceKind, parseMarketplaceReference, parseMarketplaceReferences } from './marketplaceReference.js'; +import { type IMarketplaceReference, deduplicateMarketplaceReferences, MarketplaceReferenceKind, parseMarketplaceObjectEntry, parseMarketplaceReference, parseMarketplaceReferences, readConfiguredMarketplaces } from './marketplaceReference.js'; // Re-export marketplace reference types for downstream consumers. -export { deduplicateMarketplaceReferences, MarketplaceReferenceKind, parseMarketplaceReference, parseMarketplaceReferences } from './marketplaceReference.js'; -export type { IMarketplaceReference } from './marketplaceReference.js'; +export { deduplicateMarketplaceReferences, extraKnownMarketplacesToConfigDict, MarketplaceReferenceKind, parseMarketplaceReference, parseMarketplaceReferences, readConfiguredMarketplaces } from './marketplaceReference.js'; +export type { IConfiguredMarketplaces, IMarketplaceReference } from './marketplaceReference.js'; export const enum MarketplaceType { Copilot = 'copilot', @@ -367,7 +367,7 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke this.onDidChangeMarketplaces = Event.any( Event.filter( _configurationService.onDidChangeConfiguration, - e => e.affectsConfiguration(ChatConfiguration.PluginsEnabled) || e.affectsConfiguration(ChatConfiguration.PluginMarketplaces), + e => e.affectsConfiguration(ChatConfiguration.PluginsEnabled) || e.affectsConfiguration(ChatConfiguration.PluginMarketplaces) || e.affectsConfiguration(ChatConfiguration.ExtraMarketplaces), ) as Event as Event, Event.fromObservableLight(this._workspacePluginSettingsService.extraMarketplaces), Event.map(this._workspaceTrustService.onDidChangeTrust, () => { }), @@ -412,8 +412,11 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke return []; } - const configuredRefs = this._configurationService.getValue(ChatConfiguration.PluginMarketplaces) ?? []; - const configRefs = parseMarketplaceReferences(configuredRefs); + // Effective set: user-facing `chat.plugins.marketplaces` (default + user) + // unioned with the enterprise policy-only `chat.plugins.extraMarketplaces`. + // `parseMarketplaceReferences` dedupes by canonical id. + const { effectiveValues } = readConfiguredMarketplaces(this._configurationService); + const configRefs = parseMarketplaceReferences(effectiveValues); // Merge marketplace references from Claude workspace settings. // Workspace-defined refs take precedence (are primary) so that their @@ -427,8 +430,11 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke allRefs = configRefs; } - for (const value of configuredRefs) { - if (typeof value !== 'string' || !parseMarketplaceReference(value)) { + for (const value of effectiveValues) { + const parsed = typeof value === 'string' + ? parseMarketplaceReference(value) + : (value && typeof value === 'object' ? parseMarketplaceObjectEntry(value as Parameters[0]) : undefined); + if (!parsed) { this._logService.debug(`[PluginMarketplaceService] Ignoring invalid marketplace entry: ${String(value)}`); } } @@ -607,6 +613,16 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke } isMarketplaceTrusted(ref: IMarketplaceReference): boolean { + // In strict mode (`chat.plugins.strictMarketplaces`, typically enabled via the + // `ChatStrictMarketplaces` enterprise policy), trust is restricted to + // marketplaces in `chat.plugins.extraMarketplaces` — the policy-only slot. + // User-configured entries in `chat.plugins.marketplaces` do NOT grant trust + // under strict mode; that's the whole point of "strict" — the enterprise + // fully controls the allowed marketplaces. + if (this._configurationService.getValue(ChatConfiguration.StrictMarketplaces)) { + const refs = parseMarketplaceReferences(readConfiguredMarketplaces(this._configurationService).extraValues); + return refs.some(r => r.canonicalId === ref.canonicalId); + } return this._trustedMarketplacesStore.get().includes(ref.canonicalId); } diff --git a/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts b/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts index 7db6ffa8c38c8..083f59053fbef 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts @@ -14,7 +14,7 @@ import { createDecorator } from '../../../../../platform/instantiation/common/in import { ILogService } from '../../../../../platform/log/common/log.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { CLAUDE_CONFIG_FOLDER } from '../promptSyntax/config/promptFileLocations.js'; -import { IMarketplaceReference, parseMarketplaceReference } from './marketplaceReference.js'; +import { IMarketplaceReference, parseMarketplaceObjectEntry } from './marketplaceReference.js'; const SETTINGS_FILENAME = 'settings.json'; const SETTINGS_LOCAL_FILENAME = 'settings.local.json'; @@ -51,72 +51,6 @@ export interface IWorkspacePluginSettingsService { // --- Parsing helpers --------------------------------------------------------- -interface IMarketplaceSourceJson { - readonly source?: string; - readonly repo?: string; - readonly url?: string; - readonly ref?: string; - readonly path?: string; -} - -interface IExtraMarketplaceJson { - readonly source?: string | IMarketplaceSourceJson; - readonly repo?: string; - readonly url?: string; - readonly ref?: string; - readonly path?: string; -} - -/** - * Converts a single `extraKnownMarketplaces` entry into an - * {@link IMarketplaceReference} by mapping the source format to - * existing marketplace reference parsing. - */ -function marketplaceEntryToReference(entry: IExtraMarketplaceJson): IMarketplaceReference | undefined { - // Two shapes supported: - // 1. { source: "github", repo: "owner/repo" } → GitHub shorthand - // 2. { source: { source: "github", repo: "owner/repo" } } → nested - // 3. { source: "git", url: "https://..." } → Git URI - - let sourceType: string | undefined; - let repo: string | undefined; - let url: string | undefined; - let ref: string | undefined; - - if (typeof entry.source === 'object' && entry.source !== null) { - const nested = entry.source; - sourceType = nested.source; - repo = nested.repo; - url = nested.url; - ref = nested.ref; - } else { - sourceType = entry.source as string | undefined; - repo = entry.repo; - url = entry.url; - ref = entry.ref; - } - - if (sourceType === 'github' && typeof repo === 'string') { - return parseMarketplaceReference(appendMarketplaceRef(repo, ref)); - } - - if (sourceType === 'git' && typeof url === 'string') { - return parseMarketplaceReference(appendMarketplaceRef(url, ref)); - } - - return undefined; -} - -function appendMarketplaceRef(value: string, ref: string | undefined): string { - if (!ref) { - return value; - } - - const fragmentIndex = value.indexOf('#'); - const baseValue = fragmentIndex === -1 ? value : value.slice(0, fragmentIndex); - return `${baseValue}#${ref}`; -} - /** * Parses `enabledPlugins` from a JSON object. */ @@ -154,16 +88,13 @@ function parseExtraMarketplaces(json: unknown, logPrefix: string, logService: IL continue; } - const reference = marketplaceEntryToReference(value as IExtraMarketplaceJson); + const reference = parseMarketplaceObjectEntry({ ...value, name }); if (!reference) { logService.debug(`${logPrefix} Could not parse marketplace reference for: ${name}`); continue; } - // Override displayLabel with the user-chosen marketplace name so that - // fetched plugins use this name in their `marketplace` field, which is - // what `enabledPlugins` keys reference (e.g. "plugin@claude-settings"). - entries.push({ name, reference: { ...reference, displayLabel: name } }); + entries.push({ name, reference }); } return entries; diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts index b74f87d4e1e62..ebd0ea996896e 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts @@ -284,6 +284,12 @@ suite('PluginInstallService', () => { } return undefined; }, + inspect: (key: string) => { + if (key === ChatConfiguration.PluginMarketplaces) { + return { userValue: state.configuredMarketplaces, defaultValue: undefined, policyValue: undefined }; + } + return { userValue: undefined, defaultValue: undefined, policyValue: undefined }; + }, updateValue: async (key: string, value: unknown) => { if (key === ChatConfiguration.PluginMarketplaces) { state.updatedMarketplaces = value as string[]; diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts index 916b513ddc32d..c2110af4ed773 100644 --- a/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts @@ -23,7 +23,7 @@ import { IEnvironmentService } from '../../../../../../platform/environment/comm import { IExtensionsWorkbenchService } from '../../../../extensions/common/extensions.js'; import { ChatConfiguration } from '../../../common/constants.js'; import { IAgentPluginRepositoryService } from '../../../common/plugins/agentPluginRepositoryService.js'; -import { IMarketplacePlugin, IMarketplaceReference, IPluginSourceDescriptor, MarketplaceReferenceKind, MarketplaceType, PluginMarketplaceService, PluginSourceKind, getPluginSourceLabel, parseMarketplaceReference, parseMarketplaceReferences, parsePluginSource } from '../../../common/plugins/pluginMarketplaceService.js'; +import { IMarketplacePlugin, IMarketplaceReference, IPluginSourceDescriptor, MarketplaceReferenceKind, MarketplaceType, PluginMarketplaceService, PluginSourceKind, extraKnownMarketplacesToConfigDict, getPluginSourceLabel, parseMarketplaceReference, parseMarketplaceReferences, parsePluginSource, readConfiguredMarketplaces } from '../../../common/plugins/pluginMarketplaceService.js'; import { IWorkspacePluginSettingsService } from '../../../common/plugins/workspacePluginSettingsService.js'; suite('PluginMarketplaceService', () => { @@ -161,6 +161,106 @@ suite('PluginMarketplaceService', () => { assert.strictEqual(parseMarketplaceReference('git@example.com:org/repo'), undefined); }); + test('accepts host-only HTTPS marketplace endpoints (per ADR-002 git.url is any string)', () => { + const parsed = parseMarketplaceReference('https://plugins.internal.example.com'); + assert.ok(parsed); + assert.strictEqual(parsed?.kind, MarketplaceReferenceKind.GitUri); + assert.strictEqual(parsed?.cloneUrl, 'https://plugins.internal.example.com/'); + assert.strictEqual(parsed?.canonicalId, 'git:plugins.internal.example.com/'); + assert.deepStrictEqual(parsed?.cacheSegments, ['plugins.internal.example.com']); + assert.strictEqual(parsed?.githubRepo, undefined); + + // Trailing slash collapses to the host-only form. + const withSlash = parseMarketplaceReference('https://plugins.internal.example.com/'); + assert.strictEqual(withSlash?.canonicalId, 'git:plugins.internal.example.com/'); + }); + + test('readConfiguredMarketplaces converts policy dict to named marketplace entries', () => { + const configService = new TestConfigurationService({ + [ChatConfiguration.ExtraMarketplaces]: { + 'acme-internal': 'https://plugins.internal.acme.com', + 'acme-public': 'https://copilot-plugins.acme.io', + 'vscode-team-kit': 'microsoft/vscode-team-kit', + }, + }); + const { extraValues, effectiveValues } = readConfiguredMarketplaces(configService as unknown as IConfigurationService); + const refs = parseMarketplaceReferences(extraValues); + assert.strictEqual(refs.length, 3); + assert.deepStrictEqual(refs.map(r => r.displayLabel), ['acme-internal', 'acme-public', 'vscode-team-kit']); + assert.strictEqual(refs[0].kind, MarketplaceReferenceKind.GitUri); + assert.strictEqual(refs[2].kind, MarketplaceReferenceKind.GitHubShorthand); + // Effective values union user + extra + assert.strictEqual(effectiveValues.length, extraValues.length); + }); + + test('extraKnownMarketplacesToConfigDict: returns undefined for empty/missing input', () => { + assert.strictEqual(extraKnownMarketplacesToConfigDict(undefined), undefined); + assert.strictEqual(extraKnownMarketplacesToConfigDict([]), undefined); + }); + + test('extraKnownMarketplacesToConfigDict: github source becomes owner/repo shorthand', () => { + const dict = extraKnownMarketplacesToConfigDict([ + { name: 'vscode-team-kit', source: { source: 'github', repo: 'microsoft/vscode-team-kit' } }, + ]); + assert.deepStrictEqual(dict, { 'vscode-team-kit': 'microsoft/vscode-team-kit' }); + }); + + test('extraKnownMarketplacesToConfigDict: github source with ref appends #ref', () => { + const dict = extraKnownMarketplacesToConfigDict([ + { name: 'team-kit-beta', source: { source: 'github', repo: 'microsoft/vscode-team-kit', ref: 'beta' } }, + ]); + assert.deepStrictEqual(dict, { 'team-kit-beta': 'microsoft/vscode-team-kit#beta' }); + }); + + test('extraKnownMarketplacesToConfigDict: git source becomes raw URL (with optional #ref)', () => { + const dict = extraKnownMarketplacesToConfigDict([ + { name: 'acme-internal', source: { source: 'git', url: 'https://plugins.internal.acme.com' } }, + { name: 'acme-tagged', source: { source: 'git', url: 'https://git.acme.com/plugins.git', ref: 'v1' } }, + ]); + assert.deepStrictEqual(dict, { + 'acme-internal': 'https://plugins.internal.acme.com', + 'acme-tagged': 'https://git.acme.com/plugins.git#v1', + }); + }); + + test('extraKnownMarketplacesToConfigDict: end-to-end policy → config dict → readConfiguredMarketplaces → parseMarketplaceReferences', () => { + // Simulates the full ChatExtraMarketplaces policy delivery pipeline: + // 1. managed_settings response is adapted into IExtraKnownMarketplaceEntry[] + // 2. extraKnownMarketplacesToConfigDict converts to the dict shape the + // `chat.plugins.extraMarketplaces` setting stores + // 3. The policy framework serializes/deserializes that as JSON + // 4. readConfiguredMarketplaces reverses it back to nested entry shape + // 5. parseMarketplaceReferences resolves marketplace references that + // preserve `displayLabel = name` (required for `plugin@` keys) + const policyEntries = [ + { name: 'acme-internal', source: { source: 'git' as const, url: 'https://plugins.internal.acme.com' } }, + { name: 'acme-public', source: { source: 'git' as const, url: 'https://copilot-plugins.acme.io' } }, + { name: 'vscode-team-kit', source: { source: 'github' as const, repo: 'microsoft/vscode-team-kit' } }, + ]; + + const dict = extraKnownMarketplacesToConfigDict(policyEntries); + assert.ok(dict); + + // JSON round-trip mirrors what AccountPolicyService / PolicyConfiguration do. + const roundTripped = JSON.parse(JSON.stringify(dict)); + + const configService = new TestConfigurationService({ + [ChatConfiguration.ExtraMarketplaces]: roundTripped, + }); + const { extraValues } = readConfiguredMarketplaces(configService as unknown as IConfigurationService); + const refs = parseMarketplaceReferences(extraValues); + + assert.strictEqual(refs.length, 3, 'all three policy entries are surfaced as marketplace references'); + assert.deepStrictEqual( + refs.map(r => r.displayLabel), + ['acme-internal', 'acme-public', 'vscode-team-kit'], + 'displayLabel must equal the policy `name` so enabledPlugins["plugin@"] keys resolve', + ); + assert.strictEqual(refs[0].kind, MarketplaceReferenceKind.GitUri); + assert.strictEqual(refs[1].kind, MarketplaceReferenceKind.GitUri); + assert.strictEqual(refs[2].kind, MarketplaceReferenceKind.GitHubShorthand); + }); + test('parses Azure DevOps HTTPS clone URLs without .git suffix', () => { const parsed = parseMarketplaceReference('https://dev.azure.com/org/project/_git/repo'); assert.ok(parsed); @@ -179,6 +279,34 @@ suite('PluginMarketplaceService', () => { assert.strictEqual(parsed[0].canonicalId, 'git:dev.azure.com/org/project/_git/repo.git'); }); + test('github.com URI form and GitHub shorthand form share the same canonicalId (policy trust comparisons must match)', () => { + // Regression: under strictMarketplaces, isMarketplaceTrusted compares + // canonicalId. A plugin discovered from `https://github.com/microsoft/vscode-team-kit.git` + // was being blocked even though `microsoft/vscode-team-kit` was in the + // trusted list, because the URI parser produced a `git:` canonicalId + // while the shorthand parser produced a `github:` one. + const shorthand = parseMarketplaceReference('microsoft/vscode-team-kit'); + const httpsWithGit = parseMarketplaceReference('https://github.com/microsoft/vscode-team-kit.git'); + const httpsWithoutGit = parseMarketplaceReference('https://github.com/microsoft/vscode-team-kit'); + const scp = parseMarketplaceReference('git@github.com:microsoft/vscode-team-kit.git'); + assert.ok(shorthand); + assert.ok(httpsWithGit); + assert.ok(httpsWithoutGit); + assert.ok(scp); + assert.strictEqual(httpsWithGit!.canonicalId, shorthand!.canonicalId); + assert.strictEqual(httpsWithoutGit!.canonicalId, shorthand!.canonicalId); + assert.strictEqual(scp!.canonicalId, shorthand!.canonicalId); + + // All four forms should collapse to a single entry when deduplicated. + const deduped = parseMarketplaceReferences([ + 'microsoft/vscode-team-kit', + 'https://github.com/microsoft/vscode-team-kit.git', + 'https://github.com/microsoft/vscode-team-kit', + 'git@github.com:microsoft/vscode-team-kit.git', + ]); + assert.strictEqual(deduped.length, 1); + }); + test('parses HTTPS URI with trailing slash after .git', () => { const parsed = parseMarketplaceReference('https://example.com/org/repo.git/'); assert.ok(parsed); @@ -190,25 +318,37 @@ suite('PluginMarketplaceService', () => { assert.deepStrictEqual(parsed.cacheSegments, ['example.com', 'org', 'repo']); }); - test('deduplicates equivalent Git URI forms but keeps shorthand distinct', () => { + test('deduplicates github.com URI, SSH, and shorthand to the same canonical id', () => { + // All three forms refer to the same marketplace, so policy trust + // comparisons (which match by canonicalId) must collapse them. const parsed = parseMarketplaceReferences([ 'microsoft/vscode', 'https://github.com/microsoft/vscode.git', 'git@github.com:microsoft/vscode.git', ]); - assert.deepStrictEqual(parsed.map(r => r.canonicalId), [ - 'github:microsoft/vscode', - 'git:github.com/microsoft/vscode.git', - ]); + assert.strictEqual(parsed.length, 1); + assert.strictEqual(parsed[0].canonicalId, 'github:microsoft/vscode'); }); - test('parseMarketplaceReferences ignores non-string entries', () => { + test('parseMarketplaceReferences ignores invalid entries (null, numbers, malformed objects)', () => { const parsed = parseMarketplaceReferences([null, 42, {}, 'microsoft/vscode']); assert.strictEqual(parsed.length, 1); assert.strictEqual(parsed[0].canonicalId, 'github:microsoft/vscode'); }); + test('parseMarketplaceReferences accepts policy-shape objects and uses name as displayLabel', () => { + const parsed = parseMarketplaceReferences([ + { name: 'vscode-team-kit', source: { source: 'github', repo: 'microsoft/vscode-team-kit' } }, + { name: 'acme-public', source: { source: 'git', url: 'https://copilot-plugins.acme.io', ref: 'main' } }, + ]); + assert.strictEqual(parsed.length, 2); + assert.strictEqual(parsed[0].displayLabel, 'vscode-team-kit'); + assert.strictEqual(parsed[0].canonicalId, 'github:microsoft/vscode-team-kit'); + assert.strictEqual(parsed[1].displayLabel, 'acme-public'); + assert.strictEqual(parsed[1].ref, 'main'); + }); + test('treats different marketplace refs as distinct references', () => { const parsed = parseMarketplaceReferences([ 'microsoft/vscode#main', @@ -216,10 +356,11 @@ suite('PluginMarketplaceService', () => { 'https://github.com/microsoft/vscode.git#marketplace', ]); + // `https://github.com/...#marketplace` collapses with the shorthand + // (same canonical id), so we expect 2 distinct refs not 3. assert.deepStrictEqual(parsed.map(r => r.canonicalId), [ 'github:microsoft/vscode#main', 'github:microsoft/vscode#marketplace', - 'git:github.com/microsoft/vscode.git#marketplace', ]); }); }); diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index c23007c4f84ff..d65c2fb81b7b9 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -20,11 +20,11 @@ import { Action2, registerAction2 } from '../../../../platform/actions/common/ac import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; -import { IDefaultAccountProvider, IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IDefaultAccountProvider, IDefaultAccountService, ManagedSettingsFetchStatus } from '../../../../platform/defaultAccount/common/defaultAccount.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; -import { asJson, IRequestService, isClientError, isSuccess } from '../../../../platform/request/common/request.js'; +import { asJson, IRequestService, isClientError, isSuccess, readHeader, retryAfterFromHeaders } from '../../../../platform/request/common/request.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; @@ -32,6 +32,7 @@ import { AuthenticationSession, AuthenticationSessionAccount, IAuthenticationExt import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js'; import { IExtensionService } from '../../extensions/common/extensions.js'; import { IHostService } from '../../host/browser/host.js'; +import { adaptManagedSettings, IManagedSettingsResponse } from './managedSettings.js'; interface IDefaultAccountConfig { readonly preferredExtensions: string[]; @@ -51,6 +52,7 @@ interface IDefaultAccountConfig { readonly tokenEntitlementUrl: string; readonly entitlementUrl: string; readonly mcpRegistryDataUrl: string; + readonly managedSettingsUrl: string; } export const DEFAULT_ACCOUNT_SIGN_IN_COMMAND = 'workbench.actions.accounts.signIn'; @@ -64,6 +66,7 @@ const enum DefaultAccountStatus { const CONTEXT_DEFAULT_ACCOUNT_STATE = new RawContextKey('defaultAccountStatus', DefaultAccountStatus.Uninitialized); const CACHED_POLICY_DATA_KEY = 'defaultAccount.cachedPolicyData'; const ACCOUNT_DATA_POLL_INTERVAL_MS = 60 * 60 * 1000; // 1 hour +const MANAGED_SETTINGS_REQUEST_TIMEOUT_MS = 5000; interface ITokenEntitlementsResponse { token: string; @@ -107,6 +110,7 @@ function toDefaultAccountConfig(defaultChatAgent: IDefaultChatAgent): IDefaultAc entitlementUrl: defaultChatAgent.entitlementUrl, tokenEntitlementUrl: defaultChatAgent.tokenEntitlementUrl, mcpRegistryDataUrl: defaultChatAgent.mcpRegistryDataUrl, + managedSettingsUrl: defaultChatAgent.managedSettingsUrl, }; } @@ -118,6 +122,9 @@ export class DefaultAccountService extends Disposable implements IDefaultAccount get policyData(): IPolicyData | null { return this.defaultAccountProvider?.policyData ?? null; } get copilotTokenInfo(): ICopilotTokenInfo | null { return this.defaultAccountProvider?.copilotTokenInfo ?? null; } + get managedSettingsFetchStatus(): ManagedSettingsFetchStatus { return this.defaultAccountProvider?.managedSettingsFetchStatus ?? null; } + get managedSettingsFetchedAt(): number | null { return this.defaultAccountProvider?.managedSettingsFetchedAt ?? null; } + private readonly initBarrier = new Barrier(); private readonly _onDidChangeDefaultAccount = this._register(new Emitter()); @@ -214,6 +221,7 @@ interface IAccountPolicyData { readonly entitlementsFetchedAt?: number; readonly tokenEntitlementsFetchedAt?: number; readonly mcpRegistryDataFetchedAt?: number; + readonly managedSettingsFetchedAt?: number; } interface ICachedAccountData { @@ -240,6 +248,18 @@ type DefaultAccountStatusTelemetryClassification = { initial: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Indicates whether this is the initial status report.' }; }; +type ManagedSettingsFetchTelemetry = { + outcome: string; + rateLimitBackoffActive: boolean; +}; + +type ManagedSettingsFetchTelemetryClassification = { + owner: 'joshspicer'; + comment: 'Outcome of a fetch against the enterprise managed_settings endpoint. Used to detect endpoint regressions and abnormal failure rates in the wild.'; + outcome: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'High-level outcome: a numeric HTTP status (`status:NNN`), or one of `ok` / `no-response` / `parse-error`.' }; + rateLimitBackoffActive: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'True when the request was short-circuited because a prior rate-limit Retry-After window was still active.' }; +}; + class DefaultAccountProvider extends Disposable implements IDefaultAccountProvider { private _defaultAccount: IDefaultAccountData | null = null; @@ -251,6 +271,10 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid private _copilotTokenInfo: ICopilotTokenInfo | null = null; get copilotTokenInfo(): ICopilotTokenInfo | null { return this._copilotTokenInfo; } + private _managedSettingsFetchStatus: ManagedSettingsFetchStatus = null; + get managedSettingsFetchStatus(): ManagedSettingsFetchStatus { return this._managedSettingsFetchStatus; } + get managedSettingsFetchedAt(): number | null { return this._policyData?.managedSettingsFetchedAt ?? null; } + private readonly _onDidChangeDefaultAccount = this._register(new Emitter()); readonly onDidChangeDefaultAccount = this._onDidChangeDefaultAccount.event; @@ -547,9 +571,15 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid const entitlementsResult = await this.getEntitlements(sessions, accountPolicyData, options); const entitlementsData = entitlementsResult?.data; const entitlementsFetchedAt = entitlementsResult?.fetchedAt; - const tokenEntitlementsResult = entitlementsData?.chat_enabled ? await this.getTokenEntitlements(sessions, accountPolicyData, options) : undefined; + const [tokenEntitlementsResult, managedSettingsResult] = entitlementsData?.chat_enabled + ? await Promise.all([ + this.getTokenEntitlements(sessions, accountPolicyData, options), + this.getManagedSettings(sessions, accountPolicyData, options), + ]) + : [undefined, undefined]; const tokenEntitlementsFetchedAt: number | undefined = tokenEntitlementsResult?.fetchedAt; + const managedSettingsFetchedAt: number | undefined = managedSettingsResult?.fetchedAt; let mcpRegistryDataFetchedAt: number | undefined; let policyData: Mutable | undefined = accountPolicyData?.policyData ? { ...accountPolicyData.policyData } : undefined; if (entitlementsData) { @@ -572,6 +602,9 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid policyData.mcpAccess = undefined; } } + if (managedSettingsResult?.data) { + policyData = { ...(policyData ?? {}), ...managedSettingsResult.data }; + } const defaultAccount: IDefaultAccount = { authenticationProvider, @@ -582,7 +615,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid }; this.logService.debug('[DefaultAccount] Successfully created default account for provider:', authenticationProvider.id); const accountPolicyResult: IAccountPolicyData | null = policyData || entitlementsFetchedAt - ? { accountId, policyData: policyData ?? {}, entitlementsFetchedAt, tokenEntitlementsFetchedAt, mcpRegistryDataFetchedAt } + ? { accountId, policyData: policyData ?? {}, entitlementsFetchedAt, tokenEntitlementsFetchedAt, mcpRegistryDataFetchedAt, managedSettingsFetchedAt } : null; return { defaultAccount, @@ -784,9 +817,126 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid } } - private async request(url: string, type: 'GET', body: undefined, sessions: AuthenticationSession[], token: CancellationToken, callSite: string): Promise; - private async request(url: string, type: 'POST', body: object, sessions: AuthenticationSession[], token: CancellationToken, callSite: string): Promise; - private async request(url: string, type: 'GET' | 'POST', body: object | undefined, sessions: AuthenticationSession[], token: CancellationToken, callSite: string): Promise { + private async getManagedSettings(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined, options?: { forceRefresh?: boolean }): Promise<{ data: Partial | undefined; fetchedAt: number }> { + if (!options?.forceRefresh && accountPolicyData?.managedSettingsFetchedAt && !this.isDataStale(accountPolicyData.managedSettingsFetchedAt)) { + this.logService.debug('[DefaultAccount] Using last fetched managed settings data'); + // Seed status so Policy Diagnostics reflects "applied" rather than + // "not yet fetched" after a process restart that warm-starts from + // the cached policy payload. + this._managedSettingsFetchStatus = 'ok'; + return { + data: { + enabledPlugins: accountPolicyData.policyData.enabledPlugins, + extraKnownMarketplaces: accountPolicyData.policyData.extraKnownMarketplaces, + strictKnownMarketplaces: accountPolicyData.policyData.strictKnownMarketplaces, + }, + fetchedAt: accountPolicyData.managedSettingsFetchedAt, + }; + } + const data = await this.requestManagedSettings(sessions); + return { data, fetchedAt: Date.now() }; + } + + private async requestManagedSettings(sessions: AuthenticationSession[]): Promise | undefined> { + const managedSettingsUrl = this.getManagedSettingsUrl(); + if (!managedSettingsUrl) { + this.logService.debug('[DefaultAccount] No managed settings URL configured; skipping enterprise policy fetch'); + this._managedSettingsFetchStatus = 'no-url'; + return undefined; + } + + this.logService.debug('[DefaultAccount] Fetching managed settings from:', managedSettingsUrl); + const rateLimitBackoffActive = Date.now() < this._rateLimitBackoffUntil; + const response = await this.request(managedSettingsUrl, 'GET', undefined, sessions, CancellationToken.None, 'defaultAccount.managedSettings', MANAGED_SETTINGS_REQUEST_TIMEOUT_MS); + if (!response) { + this.logService.debug('[DefaultAccount] Managed settings fetch returned no response (network error, all sessions rejected, or active rate-limit backoff); falling back to local-only policy'); + this.reportManagedSettingsOutcome('no-response', rateLimitBackoffActive); + return undefined; + } + + // Any non-2xx response means "fall back to local settings only and continue + // operating normally" — silent fallback, no policy. + if (!isSuccess(response)) { + const status = response.res.statusCode ?? 0; + this.logService.warn(`[DefaultAccount] Managed settings fetch returned non-success status ${status}; falling back to local-only policy`); + this.reportManagedSettingsOutcome(status, rateLimitBackoffActive); + return undefined; + } + + try { + const data = await asJson(response); + this.logService.trace('[DefaultAccount] Managed settings raw response:', JSON.stringify(data ?? null)); + const adapted = adaptManagedSettings(data ?? {}, msg => this.logService.warn(msg)); + // An empty response (`{}`) is a successful "no policy file present" signal. + const pluginCount = adapted.enabledPlugins ? Object.keys(adapted.enabledPlugins).length : 0; + const marketplaceCount = adapted.extraKnownMarketplaces?.length ?? 0; + const strictSet = adapted.strictKnownMarketplaces !== undefined; + if (pluginCount === 0 && marketplaceCount === 0 && !strictSet) { + this.logService.debug('[DefaultAccount] Managed settings fetched (empty response — no enterprise policy file present)'); + } else { + this.logService.info('[DefaultAccount] Managed settings applied'); + this.logService.trace('[DefaultAccount] Managed settings payload:', JSON.stringify(adapted)); + } + this.reportManagedSettingsOutcome('ok', rateLimitBackoffActive); + return adapted; + } catch (error) { + this.logService.error('[DefaultAccount] Failed to parse managed settings response', getErrorMessage(error)); + this.reportManagedSettingsOutcome('parse-error', rateLimitBackoffActive); + return undefined; + } + } + + private reportManagedSettingsOutcome(status: Exclude, rateLimitBackoffActive: boolean): void { + this._managedSettingsFetchStatus = status; + this.telemetryService.publicLog2('defaultaccount:managedSettings:fetch', { + outcome: typeof status === 'number' ? `status:${status}` : status, + rateLimitBackoffActive, + }); + } + + /** + * Detects a rate-limited GitHub response. Mirrors the public-API check in + * `githubRepoFetcher.ts`: + * - Canonical `429 Too Many Requests`. + * - Primary quota exhaustion: `403` with `X-RateLimit-Remaining: 0`. + * - Secondary throttling: GitHub omits `X-RateLimit-Remaining` but sets + * `Retry-After` (on a non-2xx response). We treat any non-success status + * that carries `Retry-After` as a back-off signal. + */ + private isRateLimited(response: IRequestContext): boolean { + const status = response.res.statusCode; + if (status === 429) { + return true; + } + if (status === 403 && readHeader(response.res.headers, 'x-ratelimit-remaining') === '0') { + return true; + } + // Secondary rate limit: the server explicitly asks the client to wait, + // regardless of which non-2xx code it returned with. + if (!isSuccess(response) && readHeader(response.res.headers, 'retry-after') !== undefined) { + return true; + } + return false; + } + + private _rateLimitBackoffUntil = 0; + + private async request(url: string, type: 'GET', body: undefined, sessions: AuthenticationSession[], token: CancellationToken, callSite: string, requestTimeoutMs?: number): Promise; + private async request(url: string, type: 'POST', body: object, sessions: AuthenticationSession[], token: CancellationToken, callSite: string, requestTimeoutMs?: number): Promise; + private async request(url: string, type: 'GET' | 'POST', body: object | undefined, sessions: AuthenticationSession[], token: CancellationToken, callSite: string, requestTimeoutMs?: number): Promise { + // Rate-limit backoff: when any prior `/copilot_internal/*` request was + // throttled (429 or 403 + `X-RateLimit-Remaining: 0`), every subsequent + // request is short-circuited until the parsed `Retry-After` elapses. + // All endpoints called from here share the same host and bearer token, + // so backing off the bucket as a whole avoids piling on a server that + // has already asked us to slow down. See `githubRepoFetcher.ts` for the + // public-API analogue. + if (Date.now() < this._rateLimitBackoffUntil) { + const remainingSec = Math.ceil((this._rateLimitBackoffUntil - Date.now()) / 1000); + this.logService.debug(`[DefaultAccount] Skipping request to ${url} — rate-limit backoff active for ${remainingSec}s more`); + return undefined; + } + let lastResponse: IRequestContext | undefined; for (const session of sessions) { @@ -800,6 +950,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid url, data: type === 'POST' ? JSON.stringify(body) : undefined, disableCache: true, + timeout: requestTimeoutMs, headers: { 'Authorization': `Bearer ${session.accessToken}` }, @@ -807,6 +958,12 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid }, token); const status = response.res.statusCode; + if (this.isRateLimited(response)) { + const retryAfterSec = retryAfterFromHeaders(response.res.headers) ?? 60; + this._rateLimitBackoffUntil = Date.now() + retryAfterSec * 1000; + this.logService.warn(`[DefaultAccount] Rate limited by ${url} (status ${status}); backing off for ${retryAfterSec}s`); + return response; + } if (status === 401 || status === 404) { this.logService.debug(`[DefaultAccount] Received ${status} for URL ${url} with session ${session.id}, likely due to expired/revoked token or insufficient permissions.`, 'Trying next session if available.'); lastResponse = response; @@ -881,6 +1038,22 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return this.defaultAccountConfig.mcpRegistryDataUrl; } + private getManagedSettingsUrl(): string | undefined { + if (this.getDefaultAccountAuthenticationProvider().enterprise) { + try { + const enterpriseUrl = this.getEnterpriseUrl(); + if (!enterpriseUrl) { + return undefined; + } + return `${enterpriseUrl.protocol}//api.${enterpriseUrl.hostname}${enterpriseUrl.port ? ':' + enterpriseUrl.port : ''}/copilot_internal/managed_settings`; + } catch (error) { + this.logService.error(error); + } + } + + return this.defaultAccountConfig.managedSettingsUrl; + } + getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider { if (this.configurationService.getValue(this.defaultAccountConfig.authenticationProvider.enterpriseProviderConfig) === this.defaultAccountConfig.authenticationProvider.enterprise.id) { return { diff --git a/src/vs/workbench/services/accounts/browser/managedSettings.ts b/src/vs/workbench/services/accounts/browser/managedSettings.ts new file mode 100644 index 0000000000000..57f0c05050425 --- /dev/null +++ b/src/vs/workbench/services/accounts/browser/managedSettings.ts @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IPolicyData } from '../../../../base/common/defaultAccount.js'; +import { IExtraKnownMarketplaceEntry } from '../../../../base/common/managedSettings.js'; +import { isObject, isString } from '../../../../base/common/types.js'; + +/** + * Response shape from the Copilot `/copilot_internal/managed_settings` endpoint. + * The endpoint returns `.github/copilot/settings.json` content from the + * enterprise's source org. An empty response (`{}`) is success and means + * "no policy file present". + * + * Unknown keys are silently ignored via the index signature so the client is + * forward-compatible with future additions to the registry schema. + * + * Exported for unit-testing the {@link adaptManagedSettings} shape transformation. + */ +export interface IManagedSettingsResponse { + readonly enabledPlugins?: Record; + readonly extraKnownMarketplaces?: Record; + readonly strictKnownMarketplaces?: boolean; + /** Any unknown keys in the response are silently ignored for forward compatibility. */ + readonly [key: string]: unknown; +} + +/** + * Adapt the `managed_settings` API response into the slice of {@link IPolicyData} + * that the policy framework consumes. `extraKnownMarketplaces` is converted from + * the API's `Record` map shape to a flat array of + * {@link IExtraKnownMarketplaceEntry} objects, preserving the marketplace `name` + * (used downstream as `displayLabel` so that `enabledPlugins["plugin@"]` + * keys resolve correctly), the source discriminator, and any `ref`. + * + * Each field is validated independently at runtime — malformed or off-spec + * shapes are dropped (with an optional warning via {@link onWarn}) rather than + * throwing, so a bad enterprise settings file degrades gracefully instead of + * blocking startup. + * + * Exported for unit-testing the shape transformation independently of network I/O. + */ +export function adaptManagedSettings(response: IManagedSettingsResponse, onWarn?: (msg: string) => void): Partial { + let extraKnownMarketplaces: readonly IExtraKnownMarketplaceEntry[] | undefined; + if (isObject(response.extraKnownMarketplaces)) { + const seen = new Set(); + const entries: IExtraKnownMarketplaceEntry[] = []; + for (const [name, entry] of Object.entries(response.extraKnownMarketplaces)) { + if (!isObject(entry) || !isObject(entry.source)) { + onWarn?.(`[DefaultAccount] Skipping malformed extraKnownMarketplaces entry "${name}": expected { source: { source, repo|url } }`); + continue; + } + const src = entry.source as { source?: string; repo?: string; url?: string; ref?: string }; + let normalized: IExtraKnownMarketplaceEntry | undefined; + if (src.source === 'github' && isString(src.repo)) { + normalized = { name, source: { source: 'github', repo: src.repo, ...(src.ref ? { ref: src.ref } : {}) } }; + } else if (src.source === 'git' && isString(src.url)) { + normalized = { name, source: { source: 'git', url: src.url, ...(src.ref ? { ref: src.ref } : {}) } }; + } else if (src.source === 'github' || src.source === 'git') { + onWarn?.(`[DefaultAccount] Skipping extraKnownMarketplaces entry "${name}": source "${src.source}" requires ${src.source === 'github' ? '"repo"' : '"url"'}`); + } else { + onWarn?.(`[DefaultAccount] Skipping extraKnownMarketplaces entry "${name}": unknown source type "${src.source}"`); + } + if (normalized && !seen.has(name)) { + seen.add(name); + entries.push(normalized); + } + } + extraKnownMarketplaces = entries; + } + + return { + enabledPlugins: isObject(response.enabledPlugins) ? response.enabledPlugins as Record : undefined, + extraKnownMarketplaces, + strictKnownMarketplaces: typeof response.strictKnownMarketplaces === 'boolean' ? response.strictKnownMarketplaces : undefined, + }; +} diff --git a/src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts b/src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts new file mode 100644 index 0000000000000..0576cc6283ddc --- /dev/null +++ b/src/vs/workbench/services/accounts/test/browser/managedSettings.test.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 assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { adaptManagedSettings, IManagedSettingsResponse } from '../../browser/managedSettings.js'; + +suite('adaptManagedSettings', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('empty response yields all-undefined partial (no enterprise policy file present)', () => { + assert.deepStrictEqual(adaptManagedSettings({}), { + enabledPlugins: undefined, + extraKnownMarketplaces: undefined, + strictKnownMarketplaces: undefined, + }); + }); + + test('passes enabledPlugins through untouched (plugin-ID keys, boolean values)', () => { + const response: IManagedSettingsResponse = { + enabledPlugins: { + 'assign-issue-to-copilot@agent-skills': true, + 'my-plugin@acme': false, + }, + }; + assert.deepStrictEqual(adaptManagedSettings(response).enabledPlugins, { + 'assign-issue-to-copilot@agent-skills': true, + 'my-plugin@acme': false, + }); + }); + + test('passes strictKnownMarketplaces boolean through untouched', () => { + assert.strictEqual(adaptManagedSettings({ strictKnownMarketplaces: true }).strictKnownMarketplaces, true); + assert.strictEqual(adaptManagedSettings({ strictKnownMarketplaces: false }).strictKnownMarketplaces, false); + }); + + test('preserves marketplace name + github source shape', () => { + const result = adaptManagedSettings({ + extraKnownMarketplaces: { + 'a': { source: { source: 'github', repo: 'github/agent-skills' } }, + 'b': { source: { source: 'github', repo: 'acme/things', ref: 'main' } }, + }, + }); + assert.deepStrictEqual(result.extraKnownMarketplaces, [ + { name: 'a', source: { source: 'github', repo: 'github/agent-skills' } }, + { name: 'b', source: { source: 'github', repo: 'acme/things', ref: 'main' } }, + ]); + }); + + test('preserves marketplace name + git source shape', () => { + const result = adaptManagedSettings({ + extraKnownMarketplaces: { + 'a': { source: { source: 'git', url: 'https://example.com/repo.git' } }, + 'b': { source: { source: 'git', url: 'ssh://git@host/path.git', ref: 'v1' } }, + }, + }); + assert.deepStrictEqual(result.extraKnownMarketplaces, [ + { name: 'a', source: { source: 'git', url: 'https://example.com/repo.git' } }, + { name: 'b', source: { source: 'git', url: 'ssh://git@host/path.git', ref: 'v1' } }, + ]); + }); + + test('handles mixed github + git sources, dedups by marketplace name', () => { + const result = adaptManagedSettings({ + extraKnownMarketplaces: { + 'a': { source: { source: 'github', repo: 'a/b' } }, + 'b': { source: { source: 'git', url: 'https://example.com/r.git' } }, + }, + }); + assert.deepStrictEqual(result.extraKnownMarketplaces, [ + { name: 'a', source: { source: 'github', repo: 'a/b' } }, + { name: 'b', source: { source: 'git', url: 'https://example.com/r.git' } }, + ]); + }); + + test('handles full populated response (all three fields together)', () => { + const result = adaptManagedSettings({ + enabledPlugins: { 'p@m': true }, + extraKnownMarketplaces: { + 'a': { source: { source: 'github', repo: 'a/b', ref: 'r' } }, + }, + strictKnownMarketplaces: true, + }); + assert.deepStrictEqual(result, { + enabledPlugins: { 'p@m': true }, + extraKnownMarketplaces: [ + { name: 'a', source: { source: 'github', repo: 'a/b', ref: 'r' } }, + ], + strictKnownMarketplaces: true, + }); + }); + + test('resilience: unknown top-level keys are silently ignored', () => { + const result = adaptManagedSettings({ + enabledPlugins: { 'p@m': true }, + strictKnownMarketplaces: false, + joshsFakeSetting: true, + } as IManagedSettingsResponse); + assert.deepStrictEqual(result, { + enabledPlugins: { 'p@m': true }, + extraKnownMarketplaces: undefined, + strictKnownMarketplaces: false, + }); + }); + + test('resilience: malformed extraKnownMarketplaces entry is skipped, valid entries still processed', () => { + const warnings: string[] = []; + const result = adaptManagedSettings({ + extraKnownMarketplaces: { + 'good': { source: { source: 'github', repo: 'a/b' } }, + 'bad-no-source': {} as IManagedSettingsResponse['extraKnownMarketplaces'] extends Record ? V : never, + 'bad-unknown-type': { source: { source: 'ftp', url: 'ftp://x' } } as IManagedSettingsResponse['extraKnownMarketplaces'] extends Record ? V : never, + }, + } as IManagedSettingsResponse, msg => warnings.push(msg)); + assert.deepStrictEqual(result.extraKnownMarketplaces, [ + { name: 'good', source: { source: 'github', repo: 'a/b' } }, + ]); + assert.strictEqual(warnings.length, 2); + }); + + test('resilience: extraKnownMarketplaces as a string array (wrong format) yields empty array, no throw', () => { + const result = adaptManagedSettings({ + extraKnownMarketplaces: ['https://plugins.acme.com'] as unknown as IManagedSettingsResponse['extraKnownMarketplaces'], + } as IManagedSettingsResponse); + // Array is not an object-record — treated as missing, so yields undefined + assert.strictEqual(result.extraKnownMarketplaces, undefined); + }); +}); diff --git a/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts b/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts index cb11bec8564f4..9433ea2b03bd7 100644 --- a/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts +++ b/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts @@ -35,6 +35,8 @@ class DefaultAccountProvider implements IDefaultAccountProvider { readonly onDidChangePolicyData = Event.None; readonly copilotTokenInfo = null; readonly onDidChangeCopilotTokenInfo = Event.None; + readonly managedSettingsFetchStatus: null = null; + readonly managedSettingsFetchedAt: null = null; constructor( readonly defaultAccount: IDefaultAccount, diff --git a/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts b/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts index e3129bf521c8d..1955d38b2d997 100644 --- a/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts +++ b/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts @@ -41,6 +41,8 @@ class DefaultAccountProvider implements IDefaultAccountProvider { readonly onDidChangePolicyData = Event.None; readonly copilotTokenInfo = null; readonly onDidChangeCopilotTokenInfo = Event.None; + readonly managedSettingsFetchStatus: null = null; + readonly managedSettingsFetchedAt: null = null; constructor( readonly defaultAccount: IDefaultAccount, diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts index 082ccf44bb6b3..fb5fb389b508b 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts @@ -584,6 +584,8 @@ export function createEditorServices(disposables: DisposableStore, options?: Cre currentDefaultAccount: null, copilotTokenInfo: null, onDidChangeCopilotTokenInfo: new Emitter().event, + managedSettingsFetchStatus: null, + managedSettingsFetchedAt: null, getDefaultAccount: async () => null, getDefaultAccountAuthenticationProvider: () => ({ id: 'test', name: 'Test', scopes: [], enterprise: false }), resolveGitHubUrl: (path: string) => `https://github.com/${path}`, From ffbcb7c5905a45c88e97351ebf97232dbaa98a2c Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Fri, 29 May 2026 23:05:15 +0200 Subject: [PATCH 13/27] agentfeedback logs --- .../chat-customizations-editor/SKILL.md | 2 +- src/vs/sessions/common/sessionsTelemetry.ts | 18 -- .../browser/agentFeedbackEditorActions.ts | 24 +- .../agentFeedbackEditorWidgetContribution.ts | 4 + .../browser/agentFeedbackService.ts | 143 +++++++++-- .../nullAgentFeedbackService.contribution.ts | 10 +- .../agentFeedbackEditorWidget.fixture.ts | 4 + .../browser/sessionEditorComments.test.ts | 10 +- .../browser/sessionsTelemetry.contribution.ts | 231 +++++++++++++++++- .../browser/componentFixtures/fixtureUtils.ts | 5 + ...aiCustomizationManagementEditor.fixture.ts | 4 + 11 files changed, 386 insertions(+), 69 deletions(-) diff --git a/.github/skills/chat-customizations-editor/SKILL.md b/.github/skills/chat-customizations-editor/SKILL.md index 90ef372d674ce..47bc4034316cb 100644 --- a/.github/skills/chat-customizations-editor/SKILL.md +++ b/.github/skills/chat-customizations-editor/SKILL.md @@ -108,7 +108,7 @@ Without all three, built-in regrouping silently doesn't run and the fixture only ### Editor contribution service mocks The management editor embeds a `CodeEditorWidget`. Electron-side editor contributions (e.g., `AgentFeedbackEditorWidgetContribution`) are instantiated automatically and crash if their injected services aren't registered. The fixture must mock at minimum: -- `IAgentFeedbackService` — needs `onDidChangeFeedback`, `onDidChangeNavigation` as `Event.None` +- `IAgentFeedbackService` — needs `onDidChangeFeedback`, `onDidChangeNavigation`, `onDidAddFeedback`, `onDidConvertFeedback`, `onDidAddReply`, `onDidSubmitFeedback` as `Event.None` - `ICodeReviewService` — needs `getReviewState()` / `getPRReviewState()` returning idle observables - `IChatEditingService` — needs `editingSessionsObs` as empty observable - `IAgentSessionsService` — needs `model.sessions` as empty array diff --git a/src/vs/sessions/common/sessionsTelemetry.ts b/src/vs/sessions/common/sessionsTelemetry.ts index 95788aba68321..f1776b56eb600 100644 --- a/src/vs/sessions/common/sessionsTelemetry.ts +++ b/src/vs/sessions/common/sessionsTelemetry.ts @@ -94,24 +94,6 @@ export function logChangesViewViewModeChange(telemetryService: ITelemetryService telemetryService.publicLog2('vscodeAgents.changesView/viewModeChange', { mode }); } -type ChangesViewReviewCommentAddedEvent = { - hasExistingFeedback: boolean; - hasSuggestion: boolean; - isFromPRReview: boolean; -}; - -type ChangesViewReviewCommentAddedClassification = { - owner: 'osortega'; - comment: 'Tracks when a user adds a review comment (feedback) to a file in the Changes panel.'; - hasExistingFeedback: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether there was already feedback on this file.' }; - hasSuggestion: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the feedback includes a code suggestion.' }; - isFromPRReview: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the feedback was converted from a PR review comment.' }; -}; - -export function logChangesViewReviewCommentAdded(telemetryService: ITelemetryService, data: { hasExistingFeedback: boolean; hasSuggestion: boolean; isFromPRReview: boolean }): void { - telemetryService.publicLog2('vscodeAgents.changesView/reviewCommentAdded', data); -} - // --- Tunnel agent host discovery --- export type TunnelDiscoveryTrigger = diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts index 3a1835bb20f4d..e2adf540f91b8 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts @@ -8,11 +8,9 @@ import { localize, localize2 } from '../../../../nls.js'; import { Action2, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ContextKeyExpr, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; -import { ILogService } from '../../../../platform/log/common/log.js'; import { URI } from '../../../../base/common/uri.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; import { GroupsOrder, IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; -import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; import { IAgentFeedbackService } from './agentFeedbackService.js'; @@ -96,16 +94,8 @@ class SubmitFeedbackAction extends AgentFeedbackEditorAction { } override async runWithSession(accessor: ServicesAccessor, sessionResource: URI): Promise { - const chatWidgetService = accessor.get(IChatWidgetService); - const logService = accessor.get(ILogService); - - const widget = chatWidgetService.getWidgetBySessionResource(sessionResource); - if (!widget) { - logService.error('[AgentFeedback] Cannot submit feedback: no chat widget found for session', sessionResource.toString()); - return; - } - - await widget.acceptInput('/act-on-feedback'); + const agentFeedbackService = accessor.get(IAgentFeedbackService); + await agentFeedbackService.submitFeedback(sessionResource); } } @@ -190,8 +180,6 @@ class SubmitActiveSessionFeedbackAction extends Action2 { override async run(accessor: ServicesAccessor): Promise { const sessionManagementService = accessor.get(ISessionsManagementService); const agentFeedbackService = accessor.get(IAgentFeedbackService); - const chatWidgetService = accessor.get(IChatWidgetService); - const logService = accessor.get(ILogService); const activeSession = sessionManagementService.activeSession.get(); if (!activeSession) { @@ -204,13 +192,7 @@ class SubmitActiveSessionFeedbackAction extends Action2 { return; } - const widget = chatWidgetService.getWidgetBySessionResource(sessionResource); - if (!widget) { - logService.error('[AgentFeedback] Cannot submit feedback: no chat widget found for session', sessionResource.toString()); - return; - } - - await widget.acceptInput('/act-on-feedback'); + await agentFeedbackService.submitFeedback(sessionResource); } } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts index 25938b90370cb..89db2ca6dca2a 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts @@ -530,6 +530,7 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid const sourcePRReviewCommentId = comment.source === SessionEditorCommentSource.PRReview ? comment.sourceId : undefined; + const kind = comment.source === SessionEditorCommentSource.PRReview ? 'prReview' : 'codeReview'; const feedback = this._agentFeedbackService.addFeedback( this._sessionResource, @@ -539,6 +540,7 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid comment.suggestion, createAgentFeedbackContext(this._editor, this._codeEditorService, comment.resourceUri, comment.range), sourcePRReviewCommentId, + kind, ); this._agentFeedbackService.addReply(this._sessionResource, feedback.id, replyText); this._agentFeedbackService.setNavigationAnchor(this._sessionResource, toSessionEditorCommentId(SessionEditorCommentSource.AgentFeedback, feedback.id)); @@ -592,6 +594,7 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid const sourcePRReviewCommentId = comment.source === SessionEditorCommentSource.PRReview ? comment.sourceId : undefined; + const kind = comment.source === SessionEditorCommentSource.PRReview ? 'prReview' : 'codeReview'; const feedback = this._agentFeedbackService.addFeedback( this._sessionResource, @@ -601,6 +604,7 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid comment.suggestion, createAgentFeedbackContext(this._editor, this._codeEditorService, comment.resourceUri, comment.range), sourcePRReviewCommentId, + kind, ); this._agentFeedbackService.setNavigationAnchor(this._sessionResource, toSessionEditorCommentId(SessionEditorCommentSource.AgentFeedback, feedback.id)); if (comment.source === SessionEditorCommentSource.CodeReview) { diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts index 47daa9630f3a3..e6162447c5dd1 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts @@ -17,15 +17,19 @@ import { editingEntriesContainResource } from '../../../../workbench/contrib/cha import { changeMatchesResource, IAgentFeedbackContext } from './agentFeedbackEditorUtils.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { ICodeReviewSuggestion } from '../../codeReview/browser/codeReviewService.js'; -import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { logChangesViewReviewCommentAdded } from '../../../common/sessionsTelemetry.js'; import { ISessionFileChange } from '../../../services/sessions/common/session.js'; // --- Types -------------------------------------------------------------------- +/** + * The origin of an agent feedback item. Used to classify how the feedback + * entered the session so that telemetry can distinguish user-authored + * feedback from feedback converted out of an existing review comment. + */ +export type AgentFeedbackKind = 'user' | 'codeReview' | 'prReview'; + export interface IAgentFeedback { readonly id: string; readonly text: string; @@ -35,6 +39,8 @@ export interface IAgentFeedback { readonly suggestion?: ICodeReviewSuggestion; readonly codeSelection?: string; readonly diffHunks?: string; + /** Origin of this feedback item (user-authored, converted from code/PR review). */ + readonly kind: AgentFeedbackKind; /** When this feedback was converted from a PR review comment, the original thread ID. */ readonly sourcePRReviewCommentId?: string; /** @@ -59,6 +65,38 @@ export interface IAgentFeedbackNavigationBearing { readonly totalCount: number; } +/** Fired when a brand-new agent feedback item is added by the user. */ +export interface IAgentFeedbackAddedEvent { + readonly sessionResource: URI; + readonly feedback: IAgentFeedback; + readonly hasExistingFeedbackForFile: boolean; +} + +/** Fired when an existing PR/code-review comment is converted into agent feedback. */ +export interface IAgentFeedbackConvertedEvent { + readonly sessionResource: URI; + readonly feedback: IAgentFeedback; + readonly kind: 'codeReview' | 'prReview'; + readonly hasExistingFeedbackForFile: boolean; +} + +/** Fired when a reply is appended to an existing feedback thread. */ +export interface IAgentFeedbackReplyAddedEvent { + readonly sessionResource: URI; + readonly feedback: IAgentFeedback; + readonly replyCount: number; +} + +/** Fired when feedback items are submitted to the agent for action. */ +export interface IAgentFeedbackSubmittedEvent { + readonly sessionResource: URI; + readonly totalCount: number; + readonly userCount: number; + readonly codeReviewCount: number; + readonly prReviewCount: number; + readonly replyCount: number; +} + // --- Service Interface -------------------------------------------------------- export const IAgentFeedbackService = createDecorator('agentFeedbackService'); @@ -69,10 +107,21 @@ export interface IAgentFeedbackService { readonly onDidChangeFeedback: Event; readonly onDidChangeNavigation: Event; + /** Fired when a new user-authored feedback item is added. */ + readonly onDidAddFeedback: Event; + /** Fired when an external review comment is converted into agent feedback. */ + readonly onDidConvertFeedback: Event; + /** Fired when a reply is appended to an existing feedback thread. */ + readonly onDidAddReply: Event; + /** Fired when feedback items are submitted to the agent. */ + readonly onDidSubmitFeedback: Event; + /** - * Add a feedback item for the given session. + * Add a feedback item for the given session. {@link kind} (defaults to + * `'user'`) classifies the origin of the feedback and selects which + * lifecycle event is fired. */ - addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext, sourcePRReviewCommentId?: string): IAgentFeedback; + addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext, sourcePRReviewCommentId?: string, kind?: AgentFeedbackKind): IAgentFeedback; /** * Remove a single feedback item. @@ -128,11 +177,18 @@ export interface IAgentFeedbackService { */ clearFeedback(sessionResource: URI): void; + /** + * Submit the currently accumulated feedback for the session to the agent. + * Captures the per-kind counts before submission and fires + * {@link onDidSubmitFeedback}. + */ + submitFeedback(sessionResource: URI): Promise; + /** * Add a feedback item and then submit the feedback. Waits for the * attachment to be updated in the chat widget before submitting. */ - addFeedbackAndSubmit(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext, sourcePRReviewCommentId?: string): Promise; + addFeedbackAndSubmit(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext, sourcePRReviewCommentId?: string, kind?: AgentFeedbackKind): Promise; } // --- Implementation ----------------------------------------------------------- @@ -145,6 +201,14 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe readonly onDidChangeFeedback = this._onDidChangeFeedback.event; private readonly _onDidChangeNavigation = this._store.add(new Emitter()); readonly onDidChangeNavigation = this._onDidChangeNavigation.event; + private readonly _onDidAddFeedback = this._store.add(new Emitter()); + readonly onDidAddFeedback = this._onDidAddFeedback.event; + private readonly _onDidConvertFeedback = this._store.add(new Emitter()); + readonly onDidConvertFeedback = this._onDidConvertFeedback.event; + private readonly _onDidAddReply = this._store.add(new Emitter()); + readonly onDidAddReply = this._onDidAddReply.event; + private readonly _onDidSubmitFeedback = this._store.add(new Emitter()); + readonly onDidSubmitFeedback = this._onDidSubmitFeedback.event; /** sessionResource → feedback items */ private readonly _feedbackBySession = new Map(); @@ -157,14 +221,12 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, @IEditorService private readonly _editorService: IEditorService, @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, - @ICommandService private readonly _commandService: ICommandService, @ILogService private readonly _logService: ILogService, - @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { super(); } - addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext, sourcePRReviewCommentId?: string): IAgentFeedback { + addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext, sourcePRReviewCommentId?: string, kind: AgentFeedbackKind = 'user'): IAgentFeedback { const key = sessionResource.toString(); let feedbackItems = this._feedbackBySession.get(key); if (!feedbackItems) { @@ -172,6 +234,9 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe this._feedbackBySession.set(key, feedbackItems); } + // A sourcePRReviewCommentId implies the feedback originated from a PR review. + const effectiveKind: AgentFeedbackKind = sourcePRReviewCommentId ? 'prReview' : kind; + const feedback: IAgentFeedback = { id: generateUuid(), text, @@ -181,6 +246,7 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe suggestion, codeSelection: context?.codeSelection, diffHunks: context?.diffHunks, + kind: effectiveKind, sourcePRReviewCommentId, }; @@ -217,11 +283,11 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe this._onDidChangeFeedback.fire({ sessionResource, feedbackItems }); - logChangesViewReviewCommentAdded(this._telemetryService, { - hasExistingFeedback: hasExistingForFile, - hasSuggestion: !!suggestion, - isFromPRReview: !!sourcePRReviewCommentId, - }); + if (effectiveKind === 'user') { + this._onDidAddFeedback.fire({ sessionResource, feedback, hasExistingFeedbackForFile: hasExistingForFile }); + } else { + this._onDidConvertFeedback.fire({ sessionResource, feedback, kind: effectiveKind, hasExistingFeedbackForFile: hasExistingForFile }); + } return feedback; } @@ -282,13 +348,15 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe } const existing = feedbackItems[idx]; - const existingReplies = existing.replies ?? []; - feedbackItems[idx] = { + const newReplies = [...(existing.replies ?? []), replyText]; + const updated: IAgentFeedback = { ...existing, - replies: [...existingReplies, replyText], + replies: newReplies, }; + feedbackItems[idx] = updated; this._sessionUpdatedOrder.set(key, ++this._sessionUpdatedSequence); this._onDidChangeFeedback.fire({ sessionResource, feedbackItems }); + this._onDidAddReply.fire({ sessionResource, feedback: updated, replyCount: newReplies.length }); } getFeedback(sessionResource: URI): readonly IAgentFeedback[] { @@ -476,8 +544,8 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe this._onDidChangeFeedback.fire({ sessionResource, feedbackItems: [] }); } - async addFeedbackAndSubmit(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext, sourcePRReviewCommentId?: string): Promise { - this.addFeedback(sessionResource, resourceUri, range, text, suggestion, context, sourcePRReviewCommentId); + async addFeedbackAndSubmit(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext, sourcePRReviewCommentId?: string, kind?: AgentFeedbackKind): Promise { + this.addFeedback(sessionResource, resourceUri, range, text, suggestion, context, sourcePRReviewCommentId, kind); // Wait for the attachment contribution to update the chat widget's attachment model const widget = this._chatWidgetService.getWidgetBySessionResource(sessionResource); @@ -495,10 +563,43 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe await new Promise(resolve => setTimeout(resolve, 100)); } + await this.submitFeedback(sessionResource); + } + + async submitFeedback(sessionResource: URI): Promise { + const widget = this._chatWidgetService.getWidgetBySessionResource(sessionResource); + if (!widget) { + this._logService.error('[AgentFeedback] submitFeedback: no chat widget found for session', sessionResource.toString()); + return; + } + + const feedbackItems = this._feedbackBySession.get(sessionResource.toString()) ?? []; + let userCount = 0; + let codeReviewCount = 0; + let prReviewCount = 0; + let replyCount = 0; + for (const item of feedbackItems) { + switch (item.kind) { + case 'user': userCount++; break; + case 'codeReview': codeReviewCount++; break; + case 'prReview': prReviewCount++; break; + } + replyCount += item.replies?.length ?? 0; + } + + this._onDidSubmitFeedback.fire({ + sessionResource, + totalCount: feedbackItems.length, + userCount, + codeReviewCount, + prReviewCount, + replyCount, + }); + try { - await this._commandService.executeCommand('agentFeedbackEditor.action.submit'); + await widget.acceptInput('/act-on-feedback'); } catch (err) { - this._logService.error('[AgentFeedback] Failed to execute submit feedback command', err); + this._logService.error('[AgentFeedback] Failed to submit feedback', err); } } } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/nullAgentFeedbackService.contribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/nullAgentFeedbackService.contribution.ts index 01e861606ca3a..3799b0eb2da48 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/nullAgentFeedbackService.contribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/nullAgentFeedbackService.contribution.ts @@ -8,7 +8,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { IRange } from '../../../../editor/common/core/range.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; -import { IAgentFeedback, IAgentFeedbackChangeEvent, IAgentFeedbackNavigationBearing, IAgentFeedbackService, INavigableSessionComment } from './agentFeedbackService.js'; +import { AgentFeedbackKind, IAgentFeedback, IAgentFeedbackAddedEvent, IAgentFeedbackChangeEvent, IAgentFeedbackConvertedEvent, IAgentFeedbackNavigationBearing, IAgentFeedbackReplyAddedEvent, IAgentFeedbackService, IAgentFeedbackSubmittedEvent, INavigableSessionComment } from './agentFeedbackService.js'; import { IAgentFeedbackContext } from './agentFeedbackEditorUtils.js'; import { ICodeReviewSuggestion } from '../../codeReview/browser/codeReviewService.js'; @@ -24,14 +24,19 @@ class NullAgentFeedbackService extends Disposable implements IAgentFeedbackServi readonly onDidChangeFeedback = this._register(new Emitter()).event; readonly onDidChangeNavigation = this._register(new Emitter()).event; + readonly onDidAddFeedback = this._register(new Emitter()).event; + readonly onDidConvertFeedback = this._register(new Emitter()).event; + readonly onDidAddReply = this._register(new Emitter()).event; + readonly onDidSubmitFeedback = this._register(new Emitter()).event; - addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string, _suggestion?: ICodeReviewSuggestion, _context?: IAgentFeedbackContext, _sourcePRReviewCommentId?: string): IAgentFeedback { + addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string, _suggestion?: ICodeReviewSuggestion, _context?: IAgentFeedbackContext, _sourcePRReviewCommentId?: string, _kind?: AgentFeedbackKind): IAgentFeedback { return { id: '', text, resourceUri, range, sessionResource, + kind: 'user', }; } @@ -47,6 +52,7 @@ class NullAgentFeedbackService extends Disposable implements IAgentFeedbackServi setNavigationAnchor(): void { } getNavigationBearing(_sessionResource: URI): IAgentFeedbackNavigationBearing { return { activeIdx: -1, totalCount: 0 }; } clearFeedback(): void { } + async submitFeedback(): Promise { } async addFeedbackAndSubmit(): Promise { } } diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.fixture.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.fixture.ts index 1a4b88ad3743f..add69b6aa8bdd 100644 --- a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.fixture.ts +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.fixture.ts @@ -110,6 +110,10 @@ function createMockAgentFeedbackService(): IAgentFeedbackService { return new class extends mock() { override readonly onDidChangeFeedback = Event.None; override readonly onDidChangeNavigation = Event.None; + override readonly onDidAddFeedback = Event.None; + override readonly onDidConvertFeedback = Event.None; + override readonly onDidAddReply = Event.None; + override readonly onDidSubmitFeedback = Event.None; override addFeedback(): IAgentFeedback { throw new Error('Not implemented for fixture'); diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/sessionEditorComments.test.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/sessionEditorComments.test.ts index 4e003899579d9..4974e41adea9f 100644 --- a/src/vs/sessions/contrib/agentFeedback/test/browser/sessionEditorComments.test.ts +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/sessionEditorComments.test.ts @@ -31,8 +31,8 @@ suite('SessionEditorComments', () => { test('merges and sorts feedback and review comments by resource and range', () => { const comments = getSessionEditorComments(session, [ - { id: 'feedback-b', text: 'feedback b', resourceUri: fileB, range: new Range(8, 1, 8, 1), sessionResource: session }, - { id: 'feedback-a', text: 'feedback a', resourceUri: fileA, range: new Range(12, 1, 12, 1), sessionResource: session }, + { id: 'feedback-b', text: 'feedback b', resourceUri: fileB, range: new Range(8, 1, 8, 1), sessionResource: session, kind: 'user' }, + { id: 'feedback-a', text: 'feedback a', resourceUri: fileA, range: new Range(12, 1, 12, 1), sessionResource: session, kind: 'user' }, ], reviewState([ { id: 'review-a', uri: fileA, range: new Range(3, 1, 3, 1), body: 'review a', kind: 'issue', severity: 'warning' }, { id: 'review-b', uri: fileB, range: new Range(2, 1, 2, 1), body: 'review b', kind: 'issue', severity: 'warning' }, @@ -48,7 +48,7 @@ suite('SessionEditorComments', () => { test('groups nearby comments only within the same resource', () => { const comments = getSessionEditorComments(session, [ - { id: 'feedback-a', text: 'feedback a', resourceUri: fileA, range: new Range(10, 1, 10, 1), sessionResource: session }, + { id: 'feedback-a', text: 'feedback a', resourceUri: fileA, range: new Range(10, 1, 10, 1), sessionResource: session, kind: 'user' }, ], reviewState([ { id: 'review-a', uri: fileA, range: new Range(13, 1, 13, 1), body: 'review a', kind: 'issue', severity: 'warning' }, { id: 'review-b', uri: fileB, range: new Range(11, 1, 11, 1), body: 'review b', kind: 'issue', severity: 'warning' }, @@ -88,7 +88,7 @@ suite('SessionEditorComments', () => { test('filters resource comments and detects authored feedback presence', () => { const comments = getSessionEditorComments(session, [ - { id: 'feedback-a', text: 'feedback a', resourceUri: fileA, range: new Range(1, 1, 1, 1), sessionResource: session }, + { id: 'feedback-a', text: 'feedback a', resourceUri: fileA, range: new Range(1, 1, 1, 1), sessionResource: session, kind: 'user' }, ], reviewState([ { id: 'review-b', uri: fileB, range: new Range(2, 1, 2, 1), body: 'review b', kind: 'issue', severity: 'warning' }, ])); @@ -125,7 +125,7 @@ suite('SessionEditorComments', () => { }; const comments = getSessionEditorComments(session, [ - { id: 'feedback-a', text: 'feedback a', resourceUri: fileA, range: new Range(3, 1, 3, 1), sessionResource: session }, + { id: 'feedback-a', text: 'feedback a', resourceUri: fileA, range: new Range(3, 1, 3, 1), sessionResource: session, kind: 'user' }, ], reviewState([ { id: 'review-a', uri: fileA, range: new Range(10, 1, 10, 1), body: 'review', kind: 'issue', severity: 'warning' }, ]), prState); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTelemetry.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTelemetry.contribution.ts index 747d163358c8e..93649ad9f4182 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTelemetry.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTelemetry.contribution.ts @@ -15,6 +15,7 @@ import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uri import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; import { isChatRequestFileEntry, isImageVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { getExcludes, ISearchConfiguration, ISearchService, QueryType } from '../../../../workbench/services/search/common/search.js'; +import { IAgentFeedbackAddedEvent, IAgentFeedbackConvertedEvent, IAgentFeedbackReplyAddedEvent, IAgentFeedbackService, IAgentFeedbackSubmittedEvent } from '../../agentFeedback/browser/agentFeedbackService.js'; import { IChat, ISession, ISessionWorkspace, SessionStatus } from '../../../services/sessions/common/session.js'; import { ISendRequestSentEvent, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISendRequestOptions } from '../../../services/sessions/common/sessionsProvider.js'; @@ -47,7 +48,8 @@ export class SessionsTelemetryContribution extends Disposable implements IWorkbe @IStorageService private readonly _storageService: IStorageService, @ISearchService private readonly _searchService: ISearchService, @IConfigurationService private readonly _configurationService: IConfigurationService, - @ICommandService commandService: ICommandService + @ICommandService commandService: ICommandService, + @IAgentFeedbackService agentFeedbackService: IAgentFeedbackService, ) { super(); @@ -117,6 +119,11 @@ export class SessionsTelemetryContribution extends Disposable implements IWorkbe log(session); } })); + + this._register(agentFeedbackService.onDidAddFeedback(e => this._logFeedbackAdded(e))); + this._register(agentFeedbackService.onDidConvertFeedback(e => this._logFeedbackConverted(e))); + this._register(agentFeedbackService.onDidAddReply(e => this._logFeedbackReplyAdded(e))); + this._register(agentFeedbackService.onDidSubmitFeedback(e => this._logFeedbackSubmitted(e))); } /** @@ -258,6 +265,74 @@ export class SessionsTelemetryContribution extends Disposable implements IWorkbe }); } + private _logFeedbackAdded(e: IAgentFeedbackAddedEvent): void { + const session = this._sessionsManagementService.getSession(e.sessionResource); + if (!session) { + return; + } + const hasSuggestion = !!e.feedback.suggestion; + const hasExistingFeedbackForFile = e.hasExistingFeedbackForFile; + void this._getSessionActionPayload(session).then(payload => { + this._telemetryService.publicLog2('agents/feedbackAdded', { + ...payload, + hasSuggestion, + hasExistingFeedbackForFile, + }); + }); + } + + private _logFeedbackConverted(e: IAgentFeedbackConvertedEvent): void { + const session = this._sessionsManagementService.getSession(e.sessionResource); + if (!session) { + return; + } + const feedbackKind = e.kind; + const hasSuggestion = !!e.feedback.suggestion; + const hasExistingFeedbackForFile = e.hasExistingFeedbackForFile; + void this._getSessionActionPayload(session).then(payload => { + this._telemetryService.publicLog2('agents/feedbackConverted', { + ...payload, + feedbackKind, + hasSuggestion, + hasExistingFeedbackForFile, + }); + }); + } + + private _logFeedbackReplyAdded(e: IAgentFeedbackReplyAddedEvent): void { + const session = this._sessionsManagementService.getSession(e.sessionResource); + if (!session) { + return; + } + const feedbackKind = e.feedback.kind; + const replyCount = e.replyCount; + void this._getSessionActionPayload(session).then(payload => { + this._telemetryService.publicLog2('agents/feedbackReplyAdded', { + ...payload, + feedbackKind, + replyCount, + }); + }); + } + + private _logFeedbackSubmitted(e: IAgentFeedbackSubmittedEvent): void { + const session = this._sessionsManagementService.getSession(e.sessionResource); + if (!session) { + return; + } + const { totalCount, userCount, codeReviewCount, prReviewCount, replyCount } = e; + void this._getSessionActionPayload(session).then(payload => { + this._telemetryService.publicLog2('agents/feedbackSubmitted', { + ...payload, + totalCount, + userCount, + codeReviewCount, + prReviewCount, + replyCount, + }); + }); + } + private _getSessionActionPayload(session: ISession): Promise { const workspace = session.workspace.get(); const sessionFields = this._getSessionFields(session); @@ -933,3 +1008,157 @@ type FixCIChecksClassification = { sessionLinesAdded: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of lines added across all changed files in the session at the time of the action.' }; sessionLinesDeleted: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of lines deleted across all changed files in the session at the time of the action.' }; }; + +// --- Events: agent feedback --- + +type FeedbackAddedEvent = { + agentSessionId: string; + providerId: string; + providerType: string; + chatCount: number; + isolationKind: SessionIsolationKind; + workspaceHash: string; + hasGitRepository: boolean; + isVirtualWorkspace: boolean; + workspaceFileCount: number; + sessionFilesChanged: number; + sessionLinesAdded: number; + sessionLinesDeleted: number; + hasSuggestion: boolean; + hasExistingFeedbackForFile: boolean; +}; + +type FeedbackAddedClassification = { + owner: 'benibenj'; + comment: 'Reports when the user adds a new agent feedback comment to a session in the Agents window.'; + agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Globally unique session id (providerId:resourceUri), used to correlate events for the same session.' }; + providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The sessions provider identifier (e.g., remote agent host or local).' }; + providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; + chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; + workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; + hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; + isVirtualWorkspace: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the workspace URI uses a non-file scheme (virtual/remote).' }; + workspaceFileCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of files in the workspace (honoring user excludes); -1 if the workspace could not be scanned.' }; + sessionFilesChanged: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of files changed in the session at the time of the action.' }; + sessionLinesAdded: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of lines added across all changed files in the session at the time of the action.' }; + sessionLinesDeleted: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of lines deleted across all changed files in the session at the time of the action.' }; + hasSuggestion: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the feedback comment includes a suggested code edit.' }; + hasExistingFeedbackForFile: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session already had at least one feedback comment for the same file before this one was added.' }; +}; + +type FeedbackConvertedEvent = { + agentSessionId: string; + providerId: string; + providerType: string; + chatCount: number; + isolationKind: SessionIsolationKind; + workspaceHash: string; + hasGitRepository: boolean; + isVirtualWorkspace: boolean; + workspaceFileCount: number; + sessionFilesChanged: number; + sessionLinesAdded: number; + sessionLinesDeleted: number; + feedbackKind: 'codeReview' | 'prReview'; + hasSuggestion: boolean; + hasExistingFeedbackForFile: boolean; +}; + +type FeedbackConvertedClassification = { + owner: 'benibenj'; + comment: 'Reports when an external review comment (code review or PR review) is converted into agent feedback for a session in the Agents window.'; + agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Globally unique session id (providerId:resourceUri), used to correlate events for the same session.' }; + providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The sessions provider identifier (e.g., remote agent host or local).' }; + providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; + chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; + workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; + hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; + isVirtualWorkspace: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the workspace URI uses a non-file scheme (virtual/remote).' }; + workspaceFileCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of files in the workspace (honoring user excludes); -1 if the workspace could not be scanned.' }; + sessionFilesChanged: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of files changed in the session at the time of the action.' }; + sessionLinesAdded: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of lines added across all changed files in the session at the time of the action.' }; + sessionLinesDeleted: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of lines deleted across all changed files in the session at the time of the action.' }; + feedbackKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Origin of the converted comment: codeReview (in-product code review) or prReview (pull request review).' }; + hasSuggestion: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the converted comment includes a suggested code edit.' }; + hasExistingFeedbackForFile: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session already had at least one feedback comment for the same file before the conversion.' }; +}; + +type FeedbackReplyAddedEvent = { + agentSessionId: string; + providerId: string; + providerType: string; + chatCount: number; + isolationKind: SessionIsolationKind; + workspaceHash: string; + hasGitRepository: boolean; + isVirtualWorkspace: boolean; + workspaceFileCount: number; + sessionFilesChanged: number; + sessionLinesAdded: number; + sessionLinesDeleted: number; + feedbackKind: 'user' | 'codeReview' | 'prReview'; + replyCount: number; +}; + +type FeedbackReplyAddedClassification = { + owner: 'benibenj'; + comment: 'Reports when the user adds a reply to an existing agent feedback thread in the Agents window.'; + agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Globally unique session id (providerId:resourceUri), used to correlate events for the same session.' }; + providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The sessions provider identifier (e.g., remote agent host or local).' }; + providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; + chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; + workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; + hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; + isVirtualWorkspace: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the workspace URI uses a non-file scheme (virtual/remote).' }; + workspaceFileCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of files in the workspace (honoring user excludes); -1 if the workspace could not be scanned.' }; + sessionFilesChanged: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of files changed in the session at the time of the action.' }; + sessionLinesAdded: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of lines added across all changed files in the session at the time of the action.' }; + sessionLinesDeleted: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of lines deleted across all changed files in the session at the time of the action.' }; + feedbackKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Origin of the feedback thread that received the reply (user, codeReview, prReview).' }; + replyCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of replies on the feedback thread after the new reply was appended.' }; +}; + +type FeedbackSubmittedEvent = { + agentSessionId: string; + providerId: string; + providerType: string; + chatCount: number; + isolationKind: SessionIsolationKind; + workspaceHash: string; + hasGitRepository: boolean; + isVirtualWorkspace: boolean; + workspaceFileCount: number; + sessionFilesChanged: number; + sessionLinesAdded: number; + sessionLinesDeleted: number; + totalCount: number; + userCount: number; + codeReviewCount: number; + prReviewCount: number; + replyCount: number; +}; + +type FeedbackSubmittedClassification = { + owner: 'benibenj'; + comment: 'Reports when the user submits the accumulated agent feedback for a session in the Agents window.'; + agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Globally unique session id (providerId:resourceUri), used to correlate events for the same session.' }; + providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The sessions provider identifier (e.g., remote agent host or local).' }; + providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; + chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; + workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; + hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; + isVirtualWorkspace: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the workspace URI uses a non-file scheme (virtual/remote).' }; + workspaceFileCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of files in the workspace (honoring user excludes); -1 if the workspace could not be scanned.' }; + sessionFilesChanged: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of files changed in the session at the time of the action.' }; + sessionLinesAdded: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of lines added across all changed files in the session at the time of the action.' }; + sessionLinesDeleted: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of lines deleted across all changed files in the session at the time of the action.' }; + totalCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of feedback items being submitted.' }; + userCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of user-authored feedback items being submitted.' }; + codeReviewCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of feedback items being submitted that originated as code review comments.' }; + prReviewCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of feedback items being submitted that originated as PR review comments.' }; + replyCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of replies across all feedback items being submitted.' }; +}; diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts index 082ccf44bb6b3..c1bf59a7ea0f0 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts @@ -622,6 +622,10 @@ export function createEditorServices(disposables: DisposableStore, options?: Cre _serviceBrand: undefined, onDidChangeFeedback: Event.None, onDidChangeNavigation: Event.None, + onDidAddFeedback: Event.None, + onDidConvertFeedback: Event.None, + onDidAddReply: Event.None, + onDidSubmitFeedback: Event.None, addFeedback: () => undefined!, removeFeedback: () => { }, updateFeedback: () => { }, @@ -635,6 +639,7 @@ export function createEditorServices(disposables: DisposableStore, options?: Cre setNavigationAnchor: () => { }, getNavigationBearing: () => ({ activeIdx: -1, totalCount: 0 }), clearFeedback: () => { }, + submitFeedback: async () => { }, addFeedbackAndSubmit: async () => { }, }); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts index 05471b2fc2517..7f09f10570a41 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -303,6 +303,10 @@ function createMockAgentFeedbackService(): IAgentFeedbackService { return new class extends mock() { override readonly onDidChangeFeedback = Event.None; override readonly onDidChangeNavigation = Event.None; + override readonly onDidAddFeedback = Event.None; + override readonly onDidConvertFeedback = Event.None; + override readonly onDidAddReply = Event.None; + override readonly onDidSubmitFeedback = Event.None; override getFeedback() { return []; } override getMostRecentSessionForResource() { return undefined; } override async revealFeedback(): Promise { } From b365ecd0b8f3f3e37946c2bea2bc98bfafbb338e Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Fri, 29 May 2026 14:16:38 -0700 Subject: [PATCH 14/27] Chronicle: per-subcommand telemetry attribution for sessionStoreSql tool (#319054) chronicle: per-subcommand telemetry attribution for sessionStoreSql tool Add a 'subcommand' enum input on copilot_sessionStoreSql so each /chronicle slash command (standup, tips, cost-tips, search, improve, reindex) tags its tool calls. Plumb it through _invokeQuery/_invokeStandup/_invokeReindex and emit it on the chronicle.sqlQuery telemetry event (with 'unknown' for ad-hoc model calls). Also add command/target/success dimensions while preserving the legacy 'source' value so existing dashboards keep working. Update each chronicle-*.prompt.md to instruct the model to set the subcommand on every call. --- .../prompts/chronicle-cost-tips.prompt.md | 2 + .../prompts/chronicle-improve.prompt.md | 2 + .../prompts/chronicle-reindex.prompt.md | 2 + .../assets/prompts/chronicle-search.prompt.md | 2 + .../prompts/chronicle-standup.prompt.md | 2 + .../assets/prompts/chronicle-tips.prompt.md | 2 + extensions/copilot/package.json | 12 +++ .../tools/node/sessionStoreSqlTool.ts | 98 +++++++++++++------ 8 files changed, 90 insertions(+), 32 deletions(-) diff --git a/extensions/copilot/assets/prompts/chronicle-cost-tips.prompt.md b/extensions/copilot/assets/prompts/chronicle-cost-tips.prompt.md index 816e0fb402931..fe66163d99201 100644 --- a/extensions/copilot/assets/prompts/chronicle-cost-tips.prompt.md +++ b/extensions/copilot/assets/prompts/chronicle-cost-tips.prompt.md @@ -3,3 +3,5 @@ name: chronicle:cost-tips description: Get personalized tips to reduce token usage and Copilot cost --- Analyze my recent chat session history and give me personalized, data-grounded tips to reduce token usage and Copilot cost. Use the **chronicle** skill — it documents the `copilot_sessionStoreSql` tool, the session-store schema, and the Cost Tips workflow for finding expensive sessions, token-heavy patterns, and concrete habit changes. + +When you invoke `copilot_sessionStoreSql`, set `subcommand: "cost-tips"` on every call. diff --git a/extensions/copilot/assets/prompts/chronicle-improve.prompt.md b/extensions/copilot/assets/prompts/chronicle-improve.prompt.md index 6c4e8ee1016c6..a54c3f48898dd 100644 --- a/extensions/copilot/assets/prompts/chronicle-improve.prompt.md +++ b/extensions/copilot/assets/prompts/chronicle-improve.prompt.md @@ -3,3 +3,5 @@ name: chronicle:improve description: Improve agent instructions based on friction patterns in your session history --- Analyze my recent chat session history for friction patterns and suggest improvements to my agent instructions file. Use the **chronicle** skill — it documents the `copilot_sessionStoreSql` tool, the session-store schema, and the Improve workflow for detecting repeated failures, user corrections, and recurring friction across sessions, then proposing data-grounded additions to the project's agent instructions. + +When you invoke `copilot_sessionStoreSql`, set `subcommand: "improve"` on every call. diff --git a/extensions/copilot/assets/prompts/chronicle-reindex.prompt.md b/extensions/copilot/assets/prompts/chronicle-reindex.prompt.md index fcb1eac903bdf..712f6bda5a40d 100644 --- a/extensions/copilot/assets/prompts/chronicle-reindex.prompt.md +++ b/extensions/copilot/assets/prompts/chronicle-reindex.prompt.md @@ -3,3 +3,5 @@ name: chronicle:reindex description: Rebuild the local session index and sync to cloud --- Reindex my session store to pick up any missing sessions. Add 'force' to re-process already indexed sessions. + +When you invoke `copilot_sessionStoreSql`, set `subcommand: "reindex"`. diff --git a/extensions/copilot/assets/prompts/chronicle-search.prompt.md b/extensions/copilot/assets/prompts/chronicle-search.prompt.md index 58759c7c7b8ec..44dde61e6c812 100644 --- a/extensions/copilot/assets/prompts/chronicle-search.prompt.md +++ b/extensions/copilot/assets/prompts/chronicle-search.prompt.md @@ -3,3 +3,5 @@ name: chronicle:search description: Search recent chat sessions by keyword, file path, or PR/issue ref --- Search my Copilot session history for the query I provide — a keyword, a file path, or a PR/issue/commit ref — and list the matching sessions. Use the **chronicle** skill — it documents the `copilot_sessionStoreSql` tool, the session-store schema (the `sessions` table primary key is `id`; conversation content lives in `turns`, not on `sessions`; on local SQLite use the FTS5 `search_index` table and select `session_id` directly — never join `search_index.rowid` to `turns.rowid`), and the Search workflow including the cloud perf rules (aggregate-once via `WITH hits ... JOIN sessions`, default 90-day window on `turns`). + +When you invoke `copilot_sessionStoreSql`, set `subcommand: "search"` on every call. diff --git a/extensions/copilot/assets/prompts/chronicle-standup.prompt.md b/extensions/copilot/assets/prompts/chronicle-standup.prompt.md index 7b7504b63f8d3..c910988d6f869 100644 --- a/extensions/copilot/assets/prompts/chronicle-standup.prompt.md +++ b/extensions/copilot/assets/prompts/chronicle-standup.prompt.md @@ -3,3 +3,5 @@ name: chronicle:standup description: Generate a standup report from recent chat sessions --- Generate a standup report from my recent coding sessions. Use the **chronicle** skill — it documents the `copilot_sessionStoreSql` tool and the Standup workflow (call with `action: "standup"` to pre-fetch the last 24h of sessions, turns, files, and refs). + +When you invoke `copilot_sessionStoreSql`, set `subcommand: "standup"`. diff --git a/extensions/copilot/assets/prompts/chronicle-tips.prompt.md b/extensions/copilot/assets/prompts/chronicle-tips.prompt.md index c670418779371..d865ea582dcc8 100644 --- a/extensions/copilot/assets/prompts/chronicle-tips.prompt.md +++ b/extensions/copilot/assets/prompts/chronicle-tips.prompt.md @@ -3,3 +3,5 @@ name: chronicle:tips description: Get personalized tips based on your chat session usage patterns --- Analyze my recent chat session history and give me personalized tips to improve my workflow. Use the **chronicle** skill — it documents the `copilot_sessionStoreSql` tool, the session-store schema, and the Tips workflow for investigating usage patterns from `sessions`, `turns`, `session_files`, and `session_refs`. + +When you invoke `copilot_sessionStoreSql`, set `subcommand: "tips"` on every call. diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 1267e2329fefe..5dda5a69a524c 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -1262,6 +1262,18 @@ "description": { "type": "string", "description": "A 2-5 word summary of what this call does (e.g. 'Recent sessions overview', 'Generate standup', 'Reindex sessions')." + }, + "subcommand": { + "type": "string", + "enum": [ + "standup", + "tips", + "cost-tips", + "search", + "improve", + "reindex" + ], + "description": "The chronicle subcommand that triggered this call (e.g. 'tips' for /chronicle tips). Used for telemetry attribution only — pass this whenever the call originates from a /chronicle slash command." } }, "required": [ diff --git a/extensions/copilot/src/extension/tools/node/sessionStoreSqlTool.ts b/extensions/copilot/src/extension/tools/node/sessionStoreSqlTool.ts index 5ee961bfc901e..66c83fa826e96 100644 --- a/extensions/copilot/src/extension/tools/node/sessionStoreSqlTool.ts +++ b/extensions/copilot/src/extension/tools/node/sessionStoreSqlTool.ts @@ -59,6 +59,8 @@ export interface SessionStoreSqlParams { readonly query?: string; readonly force?: boolean; readonly description: string; + /** Originating /chronicle slash command (e.g. 'tips', 'cost-tips', 'search', 'improve'). Used for telemetry attribution only. */ + readonly subcommand?: 'standup' | 'tips' | 'cost-tips' | 'search' | 'improve' | 'reindex'; } /** Cloud SQL dialect sessions query. */ @@ -99,17 +101,18 @@ class SessionStoreSqlTool implements ICopilotTool { token: CancellationToken, ): Promise { const action = options.input.action ?? 'query'; + const subcommand = options.input.subcommand; switch (action) { case 'standup': - return this._invokeStandup(token); + return this._invokeStandup(subcommand ?? 'standup', token); case 'reindex': - return this._invokeReindex(options.input.force ?? false, token); + return this._invokeReindex(options.input.force ?? false, subcommand ?? 'reindex', token); default: - return this._invokeQuery(options.input.query ?? '', token); + return this._invokeQuery(options.input.query ?? '', subcommand, token); } } - private async _invokeQuery(rawQuery: string, token: CancellationToken): Promise { + private async _invokeQuery(rawQuery: string, subcommand: SessionStoreSqlParams['subcommand'], token: CancellationToken): Promise { // Strip trailing semicolons — models often append them const sql = rawQuery.trim().replace(/;+\s*$/, ''); @@ -120,7 +123,7 @@ class SessionStoreSqlTool implements ICopilotTool { // Security check: block mutating / side-effecting statements for (const pattern of BLOCKED_PATTERNS) { if (pattern.test(sql)) { - this._sendTelemetry('blocked', 0, 0, false, 'blocked_mutating_sql'); + this._sendTelemetry({ command: 'query', subcommand, target: 'local', blocked: true, rowCount: 0, durationMs: 0, success: false, error: 'blocked_mutating_sql' }); return new LanguageModelToolResult([ new LanguageModelTextPart('Error: Blocked SQL statement. Only SELECT or WITH queries are allowed.'), ]); @@ -131,7 +134,7 @@ class SessionStoreSqlTool implements ICopilotTool { // comments first so a comment prefix cannot smuggle a non-query past the check. const firstKeywordSrc = stripLeadingCommentsAndWhitespace(sql); if (!/^(SELECT|WITH)\b/i.test(firstKeywordSrc)) { - this._sendTelemetry('blocked', 0, 0, false, 'blocked_not_select_or_with'); + this._sendTelemetry({ command: 'query', subcommand, target: 'local', blocked: true, rowCount: 0, durationMs: 0, success: false, error: 'blocked_not_select_or_with' }); return new LanguageModelToolResult([ new LanguageModelTextPart('Error: Blocked SQL statement. Only SELECT or WITH queries are allowed.'), ]); @@ -139,7 +142,7 @@ class SessionStoreSqlTool implements ICopilotTool { // Block multiple statements — only one query per call if (sql.includes(';')) { - this._sendTelemetry('blocked', 0, 0, false, 'multiple_statements'); + this._sendTelemetry({ command: 'query', subcommand, target: 'local', blocked: true, rowCount: 0, durationMs: 0, success: false, error: 'multiple_statements' }); return new LanguageModelToolResult([ new LanguageModelTextPart('Error: Only one SQL statement per call. Remove semicolons and split into separate calls.'), ]); @@ -149,6 +152,8 @@ class SessionStoreSqlTool implements ICopilotTool { const hasCloud = this._indexingPreference.hasCloudConsent(); const startTime = Date.now(); let source = hasCloud ? 'cloud' : 'local'; + let target: 'local' | 'cloud' = hasCloud ? 'cloud' : 'local'; + let fallback = false; try { let rows: Record[]; @@ -161,13 +166,15 @@ class SessionStoreSqlTool implements ICopilotTool { if (cloudResult && 'error' in cloudResult) { // Cloud query failed — surface the error so model can fix its query - this._sendTelemetry('cloud', 0, Date.now() - startTime, false, cloudResult.error.substring(0, 100)); + this._sendTelemetry({ command: 'query', subcommand, target: 'cloud', rowCount: 0, durationMs: Date.now() - startTime, success: false, error: cloudResult.error.substring(0, 100) }); return new LanguageModelToolResult([new LanguageModelTextPart( `Error from cloud: ${cloudResult.error}\n\nReminder: Cloud uses DuckDB SQL syntax. Use \`now() - INTERVAL '1 day'\` for date math, \`ILIKE\` for text search (no FTS5/MATCH).` )]); } else if (!cloudResult) { // Auth/network failure — fall back to local source = 'local_fallback'; + target = 'local'; + fallback = true; rows = this._executeLocal(sql); } else { rows = cloudResult.rows; @@ -183,14 +190,14 @@ class SessionStoreSqlTool implements ICopilotTool { truncated = true; } - this._sendTelemetry(source, rows.length, Date.now() - startTime, true); + this._sendTelemetry({ command: 'query', subcommand, target, fallback, rowCount: rows.length, durationMs: Date.now() - startTime, success: true }); // Format as table const result = formatSqlResult(rows, truncated, source); return new LanguageModelToolResult([new LanguageModelTextPart(result)]); } catch (err) { const message = err instanceof Error ? err.message : String(err); - this._sendTelemetry(source, 0, Date.now() - startTime, false, message.substring(0, 100)); + this._sendTelemetry({ command: 'query', subcommand, target, fallback, rowCount: 0, durationMs: Date.now() - startTime, success: false, error: message.substring(0, 100) }); return new LanguageModelToolResult([new LanguageModelTextPart(`Error: ${message}`)]); } } @@ -208,8 +215,10 @@ class SessionStoreSqlTool implements ICopilotTool { * Standup action: pre-fetch last 24h sessions + turns + files + refs, * merge local/cloud, dedup, and return formatted data for the model to summarise. */ - private async _invokeStandup(_token: CancellationToken): Promise { + private async _invokeStandup(subcommand: NonNullable, _token: CancellationToken): Promise { const startTime = Date.now(); + const hadCloudConsent = this._indexingPreference.hasCloudConsent(); + const target: 'local' | 'cloud' = hadCloudConsent ? 'cloud' : 'local'; try { // Always query local SQLite (has current machine's sessions) @@ -217,7 +226,7 @@ class SessionStoreSqlTool implements ICopilotTool { // Query cloud if user has cloud consent let cloudSessions: { sessions: AnnotatedSession[]; refs: AnnotatedRef[] } = { sessions: [], refs: [] }; - if (this._indexingPreference.hasCloudConsent()) { + if (hadCloudConsent) { cloudSessions = await this._queryCloudStore(); } @@ -290,11 +299,11 @@ class SessionStoreSqlTool implements ICopilotTool { } const prompt = buildStandupPrompt(capped, cappedRefs, cappedTurns, cappedFiles); - this._sendTelemetry('standup', capped.length, Date.now() - startTime, true); + this._sendTelemetry({ command: 'standup', subcommand, target, rowCount: capped.length, durationMs: Date.now() - startTime, success: true }); return new LanguageModelToolResult([new LanguageModelTextPart(prompt)]); } catch (err) { const message = err instanceof Error ? err.message : String(err); - this._sendTelemetry('standup', 0, Date.now() - startTime, false, message.substring(0, 100)); + this._sendTelemetry({ command: 'standup', subcommand, target, rowCount: 0, durationMs: Date.now() - startTime, success: false, error: message.substring(0, 100) }); return new LanguageModelToolResult([new LanguageModelTextPart(`Error fetching standup data: ${message}`)]); } } @@ -303,8 +312,10 @@ class SessionStoreSqlTool implements ICopilotTool { * Reindex action: rebuild the local session store from debug logs, * then trigger cloud sync if enabled. */ - private async _invokeReindex(force: boolean, token: CancellationToken): Promise { + private async _invokeReindex(force: boolean, subcommand: NonNullable, token: CancellationToken): Promise { const startTime = Date.now(); + const hadCloudConsent = this._indexingPreference.hasCloudConsent(); + const target: 'local' | 'cloud' = hadCloudConsent ? 'cloud' : 'local'; try { const statsBefore = this._sessionStore.getStats(); @@ -352,11 +363,11 @@ class SessionStoreSqlTool implements ICopilotTool { } } - this._sendTelemetry('reindex', result.processed, Date.now() - startTime, true); + this._sendTelemetry({ command: 'reindex', subcommand, target, rowCount: result.processed, durationMs: Date.now() - startTime, success: true }); return new LanguageModelToolResult([new LanguageModelTextPart(lines.join('\n'))]); } catch (err) { const message = err instanceof Error ? err.message : String(err); - this._sendTelemetry('reindex', 0, Date.now() - startTime, false, message.substring(0, 100)); + this._sendTelemetry({ command: 'reindex', subcommand, target, rowCount: 0, durationMs: Date.now() - startTime, success: false, error: message.substring(0, 100) }); return new LanguageModelToolResult([new LanguageModelTextPart(`Error during reindex: ${message}`)]); } } @@ -464,33 +475,56 @@ class SessionStoreSqlTool implements ICopilotTool { } } - private _sendTelemetry(source: string, rowCount: number, durationMs: number, success: boolean, error?: string): void { + private _sendTelemetry(args: { + command: 'query' | 'standup' | 'reindex'; + subcommand?: SessionStoreSqlParams['subcommand']; + target: 'local' | 'cloud'; + blocked?: boolean; + fallback?: boolean; + rowCount: number; + durationMs: number; + success: boolean; + error?: string; + }): void { + const { command, subcommand, target, blocked, fallback, rowCount, durationMs, success, error } = args; + // Back-compat: derive the original `source` value so existing dashboards keep working. + const source = blocked + ? 'blocked' + : fallback + ? 'local_fallback' + : command === 'query' + ? target + : command; + const properties = { + command, + subcommand: subcommand ?? 'unknown', + target, + source, + success: success ? 'true' : 'false', + }; + const measurements = { rowCount, durationMs }; if (success) { /* __GDPR__ "chronicle.sqlQuery" : { "owner": "vijayu", -"comment": "Tracks session store SQL query execution and failures", -"source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Query target: local, cloud, or blocked." }, +"comment": "Tracks chronicle session-store tool invocations (query/standup/reindex) and outcomes", +"command": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Tool action invoked: query, standup, or reindex." }, +"subcommand": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Originating /chronicle slash command (standup, tips, cost-tips, search, improve, reindex) or 'unknown' for ad-hoc model calls." }, +"target": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the invocation primarily targeted the local SQLite store or the cloud session store." }, +"source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Fine-grained source: local, cloud, local_fallback, blocked, standup, or reindex (kept for back-compat)." }, +"success": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the invocation succeeded (true/false)." }, "error": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "Truncated error message." }, "rowCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Number of rows returned." }, -"durationMs": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Query duration in milliseconds." } +"durationMs": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Invocation duration in milliseconds." } } */ - this._telemetryService.sendMSFTTelemetryEvent('chronicle.sqlQuery', { - source, - }, { - rowCount, - durationMs, - }); + this._telemetryService.sendMSFTTelemetryEvent('chronicle.sqlQuery', properties, measurements); } else { this._telemetryService.sendMSFTTelemetryErrorEvent('chronicle.sqlQuery', { - source, + ...properties, error: error ?? 'unknown', - }, { - rowCount, - durationMs, - }); + }, measurements); } } From fa6f6bd3c8dd9d0e61168228842b26b623662b89 Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Fri, 29 May 2026 14:19:22 -0700 Subject: [PATCH 15/27] remove some experimental settings (#319056) --- .../browser/agentHostSessionConfigPicker.ts | 15 ++- .../browser/baseAgentHostSessionsProvider.ts | 41 +++--- .../localAgentHostSessionsProvider.test.ts | 124 ++++++++++++++---- .../browser/mobilePermissionPicker.ts | 9 +- .../browser/permissionPicker.ts | 10 +- .../agentHost/agentHostChatInputPicker.ts | 25 +++- .../chat/browser/chat.shared.contribution.ts | 10 +- .../contrib/chat/browser/chatSlashCommands.ts | 46 ++++--- .../chatContentParts/chatSuggestNextWidget.ts | 3 +- .../input/permissionPickerActionItem.ts | 51 ++++--- .../contrib/chat/common/constants.ts | 1 - 11 files changed, 206 insertions(+), 129 deletions(-) diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts index 34959d1ce3576..7b82bf2459650 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts @@ -153,9 +153,8 @@ function hasShownAutoApproveWarning(value: string): boolean { } /** - * Filters out autopilot if disabled, and marks bypass/autopilot as disabled - * if enterprise policy restricts auto-approval. Returns the filtered items - * and policy state. + * Marks bypass/autopilot as disabled if enterprise policy restricts + * auto-approval. Returns the items and policy state. */ function applyAutoApproveFiltering( items: readonly IConfigPickerItem[], @@ -165,10 +164,8 @@ function applyAutoApproveFiltering( if (property !== SessionConfigKey.AutoApprove) { return { items, policyRestricted: false }; } - const isAutopilotEnabled = configurationService.getValue(ChatConfiguration.AutopilotEnabled) !== false; const policyRestricted = configurationService.inspect(ChatConfiguration.GlobalAutoApprove).policyValue === false; - const filtered = isAutopilotEnabled ? items : items.filter(item => item.value !== 'autopilot'); - return { items: filtered, policyRestricted }; + return { items, policyRestricted }; } /** @@ -464,7 +461,11 @@ export class AgentHostSessionConfigPicker extends Disposable { provider.setSessionConfigValue(sessionId, property, item.value).catch(() => { /* best-effort */ }); }, onFilter: schema.enumDynamic - ? query => this._filterDelayer.trigger(async () => toActionItems(property, await this._getItems(provider, sessionId, property, schema, query), provider.getSessionConfig(sessionId)?.values[property])) + ? query => this._filterDelayer.trigger(async () => { + const filteredRawItems = await this._getItems(provider, sessionId, property, schema, query); + const { items: filteredItems, policyRestricted: filteredPolicyRestricted } = applyAutoApproveFiltering(filteredRawItems, property, this._configurationService); + return toActionItems(property, filteredItems, provider.getSessionConfig(sessionId)?.values[property], filteredPolicyRestricted); + }) : undefined, onHide: () => trigger.focus(), }; diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 9388880b2ccc5..5a0210a52d229 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -54,20 +54,28 @@ function isSafeSessionConfigKey(property: string): boolean { return !UNSAFE_SESSION_CONFIG_KEYS.has(property); } -function normalizeAutoApproveValue(value: unknown, policyRestricted: boolean, autopilotEnabled: boolean): ChatPermissionLevel | undefined { +function normalizeAutoApproveValue(value: unknown, policyRestricted: boolean): ChatPermissionLevel | undefined { if (typeof value !== 'string' || !KNOWN_AUTO_APPROVE_VALUES.has(value)) { return undefined; } - let normalized = value as ChatPermissionLevel; - if (!autopilotEnabled && normalized === ChatPermissionLevel.Autopilot) { - normalized = ChatPermissionLevel.AutoApprove; - } + const normalized = value as ChatPermissionLevel; if (policyRestricted && (normalized === ChatPermissionLevel.AutoApprove || normalized === ChatPermissionLevel.Autopilot)) { return ChatPermissionLevel.Default; } return normalized; } +function isAutoApprovePolicyRestricted(configurationService: IConfigurationService): boolean { + return configurationService.inspect(ChatConfiguration.GlobalAutoApprove).policyValue === false; +} + +function normalizeSessionConfigValue(property: string, value: unknown, policyRestricted: boolean): unknown { + if (property === SessionConfigKey.AutoApprove && policyRestricted && (value === ChatPermissionLevel.AutoApprove || value === ChatPermissionLevel.Autopilot)) { + return ChatPermissionLevel.Default; + } + return value; +} + // ============================================================================ // AgentHostSessionAdapter — shared adapter for local and remote sessions // ============================================================================ @@ -1387,8 +1395,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement */ protected _initialNewSessionConfig(): Record | undefined { const config = Object.create(null) as Record; - const policyRestricted = this._baseConfigurationService.inspect(ChatConfiguration.GlobalAutoApprove).policyValue === false; - const autopilotEnabled = this._baseConfigurationService.getValue(ChatConfiguration.AutopilotEnabled) !== false; + const policyRestricted = isAutoApprovePolicyRestricted(this._baseConfigurationService); // Seed session config values from the last user picks. const rememberedValues = this._storageService.getObject>(STORAGE_KEY_REMEMBERED_SESSION_CONFIG_VALUES, StorageScope.PROFILE, {}); @@ -1399,8 +1406,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } const configured = this._baseConfigurationService.getValue(ChatConfiguration.DefaultPermissionLevel); - const normalizedConfiguredAutoApprove = normalizeAutoApproveValue(configured, policyRestricted, autopilotEnabled); - const normalizedRememberedAutoApprove = normalizeAutoApproveValue(config[SessionConfigKey.AutoApprove], policyRestricted, autopilotEnabled); + const normalizedConfiguredAutoApprove = normalizeAutoApproveValue(configured, policyRestricted); + const normalizedRememberedAutoApprove = normalizeAutoApproveValue(config[SessionConfigKey.AutoApprove], policyRestricted); if (normalizedConfiguredAutoApprove) { config[SessionConfigKey.AutoApprove] = normalizedConfiguredAutoApprove; } else if (normalizedRememberedAutoApprove) { @@ -1441,8 +1448,11 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } async setSessionConfigValue(sessionId: string, property: string, value: unknown): Promise { + const policyRestricted = isAutoApprovePolicyRestricted(this._baseConfigurationService); + const normalizedValue = normalizeSessionConfigValue(property, value, policyRestricted); + // Remember config picks across sessions - if (typeof value === 'string' && isSafeSessionConfigKey(property)) { + if (typeof normalizedValue === 'string' && isSafeSessionConfigKey(property)) { const rememberedValues = this._storageService.getObject>(STORAGE_KEY_REMEMBERED_SESSION_CONFIG_VALUES, StorageScope.PROFILE, {}); const nextRememberedValues = Object.create(null) as Record; for (const [key, rememberedValue] of Object.entries(rememberedValues)) { @@ -1450,7 +1460,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement nextRememberedValues[key] = rememberedValue; } } - nextRememberedValues[property] = value; + nextRememberedValues[property] = normalizedValue; this._storageService.store(STORAGE_KEY_REMEMBERED_SESSION_CONFIG_VALUES, JSON.stringify(nextRememberedValues), StorageScope.PROFILE, StorageTarget.MACHINE); } @@ -1468,7 +1478,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } newSession.beginResolveConfigSync(); newSession.setLoading(true); - newSession.setConfigValue(property, value); + newSession.setConfigValue(property, normalizedValue); this._onDidChangeSessionConfig.fire(sessionId); await this._refreshNewSessionConfig(newSession); return; @@ -1488,7 +1498,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // Update local cache optimistically this._runningSessionConfigs.set(sessionId, { ...runningConfig, - values: { ...runningConfig.values, [property]: value }, + values: { ...runningConfig.values, [property]: normalizedValue }, }); this._onDidChangeSessionConfig.fire(sessionId); @@ -1497,7 +1507,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement const cached = rawId ? this._sessionCache.get(rawId) : undefined; if (cached && rawId) { const sessionUri = AgentSession.uri(cached.agentProvider, rawId); - const action = { type: ActionType.SessionConfigChanged as const, config: { [property]: value } }; + const action = { type: ActionType.SessionConfigChanged as const, config: { [property]: normalizedValue } }; connection.dispatch(sessionUri.toString(), action); } } @@ -1514,11 +1524,12 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // (`sessionMutable: true` and not `readOnly`), otherwise force the // current value through. This guarantees replace semantics never // alter a non-editable property even if the caller included it. + const policyRestricted = isAutoApprovePolicyRestricted(this._baseConfigurationService); const nextValues: Record = {}; for (const [key, schema] of Object.entries(runningConfig.schema.properties)) { const editable = schema.sessionMutable === true && schema.readOnly !== true; if (editable) { - nextValues[key] = values[key]; + nextValues[key] = normalizeSessionConfigValue(key, values[key], policyRestricted); } else if (Object.hasOwn(runningConfig.values, key)) { nextValues[key] = runningConfig.values[key]; } diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 10aa353c96bbe..4a9e93cc0b458 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -235,6 +235,18 @@ function createSession(id: string, opts?: { provider?: string; summary?: string; }; } +function createPolicyRestrictedConfigurationService(): TestConfigurationService { + return new class extends TestConfigurationService { + override inspect(key: string) { + const base = super.inspect(key); + if (key === 'chat.tools.global.autoApprove') { + return { ...base, policyValue: false as unknown as T }; + } + return base; + } + }(); +} + function createProvider(disposables: DisposableStore, agentHostService: MockAgentHostService, contributions = [ { type: 'agent-host-copilotcli', name: 'copilot', displayName: 'Copilot', description: 'test', icon: undefined }, ], options?: { sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; openSession?: boolean; configurationService?: IConfigurationService; activeSession?: IObservable; storageService?: IStorageService }): LocalAgentHostSessionsProvider { @@ -1065,15 +1077,7 @@ suite('LocalAgentHostSessionsProvider', () => { }); test('createNewSession clamps seeded autoApprove to default when policy disables global auto-approve', async () => { - const config = new class extends TestConfigurationService { - override inspect(key: string) { - const base = super.inspect(key); - if (key === 'chat.tools.global.autoApprove') { - return { ...base, policyValue: false as unknown as T }; - } - return base; - } - }(); + const config = createPolicyRestrictedConfigurationService(); await config.setUserConfiguration('chat.permissions.default', 'autopilot'); const provider = createProvider(disposables, agentHost, undefined, { configurationService: config }); const session = provider.createNewSession(URI.parse('file:///home/user/project'), provider.sessionTypes[0].id); @@ -1101,6 +1105,24 @@ suite('LocalAgentHostSessionsProvider', () => { ); }); + test('setSessionConfigValue clamps autoApprove to default when policy disables global auto-approve', async () => { + const storageService = disposables.add(new InMemoryStorageService()); + const config = createPolicyRestrictedConfigurationService(); + const provider = createProvider(disposables, agentHost, undefined, { configurationService: config, storageService }); + const session = provider.createNewSession(URI.parse('file:///home/user/project'), provider.sessionTypes[0].id); + await timeout(0); + + await provider.setSessionConfigValue(session.sessionId, SessionConfigKey.AutoApprove, 'autopilot'); + + assert.deepStrictEqual({ + remembered: storageService.getObject(STORAGE_KEY_REMEMBERED_SESSION_CONFIG_VALUES, StorageScope.PROFILE, {}), + forwardedToAgentHost: agentHost.resolveSessionConfigRequests.at(-1)?.config?.autoApprove, + }, { + remembered: { [SessionConfigKey.AutoApprove]: 'default' }, + forwardedToAgentHost: 'default', + }); + }); + test('createNewSession seeds remembered values and skips unsafe remembered keys', () => { const storageService = disposables.add(new InMemoryStorageService()); storageService.store(STORAGE_KEY_REMEMBERED_SESSION_CONFIG_VALUES, `{"${SessionConfigKey.Isolation}":"folder","${SessionConfigKey.Branch}":"main","__proto__":"polluted"}`, StorageScope.PROFILE, StorageTarget.MACHINE); @@ -1116,41 +1138,32 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); - test('createNewSession gives chat.permissions.default precedence over remembered autoApprove while normalizing by policy and feature flags', async () => { + test('createNewSession gives chat.permissions.default precedence over remembered autoApprove while normalizing by policy', async () => { const storageService = disposables.add(new InMemoryStorageService()); storageService.store(STORAGE_KEY_REMEMBERED_SESSION_CONFIG_VALUES, JSON.stringify({ [SessionConfigKey.AutoApprove]: 'autopilot', }), StorageScope.PROFILE, StorageTarget.MACHINE); // Case 1: policy restricts auto-approve — setting 'autoApprove' is clamped to 'default' - const policyRestrictedConfig = new class extends TestConfigurationService { - override inspect(key: string) { - const base = super.inspect(key); - if (key === 'chat.tools.global.autoApprove') { - return { ...base, policyValue: false as unknown as T }; - } - return base; - } - }(); + const policyRestrictedConfig = createPolicyRestrictedConfigurationService(); await policyRestrictedConfig.setUserConfiguration('chat.permissions.default', 'autoApprove'); const policyRestrictedProvider = createProvider(disposables, agentHost, undefined, { configurationService: policyRestrictedConfig, storageService }); policyRestrictedProvider.createNewSession(URI.parse('file:///home/user/project'), policyRestrictedProvider.sessionTypes[0].id); - // Case 2: autopilot disabled — setting 'default' wins over remembered 'autopilot' - const autopilotDisabledConfig = new TestConfigurationService(); - await autopilotDisabledConfig.setUserConfiguration('chat.permissions.default', 'default'); - await autopilotDisabledConfig.setUserConfiguration('chat.autopilot.enabled', false); - const autopilotDisabledProvider = createProvider(disposables, agentHost, undefined, { configurationService: autopilotDisabledConfig, storageService }); - autopilotDisabledProvider.createNewSession(URI.parse('file:///home/user/project'), autopilotDisabledProvider.sessionTypes[0].id); + // Case 2: configured 'default' wins over remembered 'autopilot' + const configuredDefaultConfig = new TestConfigurationService(); + await configuredDefaultConfig.setUserConfiguration('chat.permissions.default', 'default'); + const configuredDefaultProvider = createProvider(disposables, agentHost, undefined, { configurationService: configuredDefaultConfig, storageService }); + configuredDefaultProvider.createNewSession(URI.parse('file:///home/user/project'), configuredDefaultProvider.sessionTypes[0].id); // The forwarded config proves the setting took precedence over the // remembered value and was properly normalized. assert.deepStrictEqual({ policyRestricted: agentHost.resolveSessionConfigRequests.at(-2)?.config?.autoApprove, - autopilotDisabled: agentHost.resolveSessionConfigRequests.at(-1)?.config?.autoApprove, + configuredDefault: agentHost.resolveSessionConfigRequests.at(-1)?.config?.autoApprove, }, { policyRestricted: 'default', - autopilotDisabled: 'default', + configuredDefault: 'default', }); }); @@ -1715,6 +1728,63 @@ suite('LocalAgentHostSessionsProvider', () => { assert.deepStrictEqual(latest?.values, { autoApprove: 'autoApprove', isolation: 'worktree', branch: 'main' }); })); + test('running session config writes clamp autoApprove to default when policy disables global auto-approve', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + agentHost.addSession(createSession('policy-write', { summary: 'Policy Write Session' })); + const configService = createPolicyRestrictedConfigurationService(); + const provider = createProvider(disposables, agentHost, undefined, { configurationService: configService }); + provider.getSessions(); + await timeout(0); + const session = provider.getSessions().find(s => s.title.get() === 'Policy Write Session'); + assert.ok(session); + + const config: SessionConfigState = { + schema: { + type: 'object', + properties: { + autoApprove: { type: 'string', title: 'Auto Approve', enum: ['default', 'autoApprove', 'autopilot'], sessionMutable: true }, + isolation: { type: 'string', title: 'Isolation', enum: ['folder', 'worktree'], sessionMutable: true }, + }, + }, + values: { autoApprove: 'default', isolation: 'worktree' }, + }; + const fakeState: SessionState = { + summary: { resource: AgentSession.uri('copilotcli', 'policy-write').toString(), provider: 'copilotcli', title: 'Policy Write Session', status: ProtocolSessionStatus.Idle, createdAt: 0, modifiedAt: 0 }, + lifecycle: SessionLifecycle.Ready, + turns: [], + config, + }; + agentHost.setSessionState('policy-write', 'copilotcli', fakeState); + await waitForSessionConfig(provider, session!.sessionId, c => c?.values.autoApprove === 'default'); + + await provider.setSessionConfigValue(session!.sessionId, SessionConfigKey.AutoApprove, 'autopilot'); + const sessionUri = AgentSession.uri('copilotcli', 'policy-write').toString(); + const setConfigChanged = agentHost.dispatchedActions.find(d => d.action.type === ActionType.SessionConfigChanged && d.channel === sessionUri); + + agentHost.dispatchedActions.length = 0; + await provider.replaceSessionConfig(session!.sessionId, { + autoApprove: 'autoApprove', + isolation: 'folder', + }); + const replaceConfigChanged = agentHost.dispatchedActions.find(d => d.action.type === ActionType.SessionConfigChanged && d.channel === sessionUri); + + assert.deepStrictEqual({ + setAction: setConfigChanged?.action, + replaceAction: replaceConfigChanged?.action, + latestValues: provider.getSessionConfig(session!.sessionId)?.values, + }, { + setAction: { + type: ActionType.SessionConfigChanged, + config: { autoApprove: 'default' }, + }, + replaceAction: { + type: ActionType.SessionConfigChanged, + config: { autoApprove: 'default', isolation: 'folder' }, + replace: true, + }, + latestValues: { autoApprove: 'default', isolation: 'folder' }, + }); + })); + test('replaceSessionConfig is a no-op when nothing editable actually changes', () => runWithFakedTimers({ useFakeTimers: true }, async () => { agentHost.addSession(createSession('rep-2', { summary: 'No-op Session' })); const provider = createProvider(disposables, agentHost); diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/mobilePermissionPicker.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/mobilePermissionPicker.ts index fac5e7cd9cf58..0a89e7780a433 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/mobilePermissionPicker.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/mobilePermissionPicker.ts @@ -53,7 +53,6 @@ export class MobilePermissionPicker extends PermissionPicker { } const policyRestricted = this.configurationService.inspect(ChatConfiguration.GlobalAutoApprove).policyValue === false; - const isAutopilotEnabled = this.configurationService.getValue(ChatConfiguration.AutopilotEnabled) !== false; const items: IMobilePickerSheetItem[] = [ { @@ -71,17 +70,15 @@ export class MobilePermissionPicker extends PermissionPicker { checked: this._currentLevel === ChatPermissionLevel.AutoApprove, disabled: policyRestricted, }, - ]; - if (isAutopilotEnabled) { - items.push({ + { id: ChatPermissionLevel.Autopilot, label: localize('permissions.autopilot', "Autopilot (Preview)"), description: localize('permissions.autopilot.subtext', "Autonomously iterates from start to finish"), icon: Codicon.rocket, checked: this._currentLevel === ChatPermissionLevel.Autopilot, disabled: policyRestricted, - }); - } + }, + ]; items.push({ id: LEARN_MORE_ID, label: localize('permissions.learnMore', "Learn more about permissions"), diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts index 10aee72c0f107..5925c99aa7223 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts @@ -157,7 +157,6 @@ export class PermissionPicker extends Disposable { } const policyRestricted = this.configurationService.inspect(ChatConfiguration.GlobalAutoApprove).policyValue === false; - const isAutopilotEnabled = this.configurationService.getValue(ChatConfiguration.AutopilotEnabled) !== false; const items: IActionListItem[] = [ { @@ -186,10 +185,7 @@ export class PermissionPicker extends Disposable { detail: localize('permissions.autoApprove.subtext', "All tool calls are auto-approved"), disabled: policyRestricted, }, - ]; - - if (isAutopilotEnabled) { - items.push({ + { kind: ActionListItemKind.Action, group: { kind: ActionListItemKind.Header, title: '', icon: Codicon.rocket }, item: { @@ -201,8 +197,8 @@ export class PermissionPicker extends Disposable { label: localize('permissions.autopilot', "Autopilot (Preview)"), detail: localize('permissions.autopilot.subtext', "Autonomously iterates from start to finish"), disabled: policyRestricted, - }); - } + }, + ]; items.push({ kind: ActionListItemKind.Separator, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts index ff6e8cc7ab197..189a38e264dc3 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts @@ -27,8 +27,10 @@ import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../ import { IActionWidgetService } from '../../../../../../platform/actionWidget/browser/actionWidget.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; import type { IAction } from '../../../../../../base/common/actions.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import type { IChatWidget } from '../../chat.js'; +import { ChatConfiguration } from '../../../common/constants.js'; import { isUntitledChatSession } from '../../../common/model/chatUri.js'; import { IAgentHostSessionWorkingDirectoryResolver } from './agentHostSessionWorkingDirectoryResolver.js'; import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js'; @@ -72,12 +74,24 @@ function getConfigIcon(property: string, value: unknown | undefined): ThemeIcon return undefined; } -function toActionItems(property: string, items: readonly IConfigPickerItem[], currentValue: unknown | undefined): IActionListItem[] { +function isAutoApprovePolicyRestricted(configurationService: IConfigurationService): boolean { + return configurationService.inspect(ChatConfiguration.GlobalAutoApprove).policyValue === false; +} + +function normalizeConfigValue(property: string, value: string, policyRestricted: boolean): string { + if (property === SessionConfigKey.AutoApprove && policyRestricted && (value === 'autoApprove' || value === 'autopilot')) { + return 'default'; + } + return value; +} + +function toActionItems(property: string, items: readonly IConfigPickerItem[], currentValue: unknown | undefined, policyRestricted = false): IActionListItem[] { return items.map(item => ({ kind: ActionListItemKind.Action, label: item.label, description: item.description, group: { title: '', icon: getConfigIcon(property, item.value) }, + disabled: policyRestricted && property === SessionConfigKey.AutoApprove && (item.value === 'autoApprove' || item.value === 'autopilot'), item: { ...item, label: item.value === currentValue ? `${item.label} ${localize('selected', "(Selected)")}` : item.label }, })); } @@ -200,6 +214,7 @@ export class AgentHostChatInputPicker extends Disposable { @IAgentHostSessionWorkingDirectoryResolver private readonly _workingDirectoryResolver: IAgentHostSessionWorkingDirectoryResolver, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @IAgentHostUntitledProvisionalSessionService private readonly _provisional: IAgentHostUntitledProvisionalSessionService, + @IConfigurationService private readonly _configurationService: IConfigurationService, ) { super(); @@ -431,7 +446,8 @@ export class AgentHostChatInputPicker extends Disposable { return; } const currentValue = ctx.value; - const actionItems = toActionItems(this._property, items, currentValue); + const policyRestricted = isAutoApprovePolicyRestricted(this._configurationService); + const actionItems = toActionItems(this._property, items, currentValue, policyRestricted); if (this._property === ClaudeSessionConfigKey.PermissionMode || this._property === SessionConfigKey.AutoApprove) { actionItems.push({ kind: ActionListItemKind.Action, @@ -456,7 +472,7 @@ export class AgentHostChatInputPicker extends Disposable { if (!refreshed) { return []; } - return toActionItems(this._property, await this._getItems(refreshed.schema, query), refreshed.value); + return toActionItems(this._property, await this._getItems(refreshed.schema, query), refreshed.value, isAutoApprovePolicyRestricted(this._configurationService)); }) : undefined, onHide: () => trigger.focus(), @@ -534,7 +550,8 @@ export class AgentHostChatInputPicker extends Disposable { return; } - const partial = { [this._property]: value }; + const normalizedValue = normalizeConfigValue(this._property, value, isAutoApprovePolicyRestricted(this._configurationService)); + const partial = { [this._property]: normalizedValue }; if (isUntitledChatSession(sessionResource)) { // Route through the provisional service so the workbench-owned diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 3cdf7d3b510d8..81540a7c4d308 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -448,12 +448,6 @@ configurationRegistry.registerConfiguration({ scope: ConfigurationScope.APPLICATION_MACHINE, tags: ['experimental', 'advanced'], }, - [ChatConfiguration.AutopilotEnabled]: { - type: 'boolean', - markdownDescription: nls.localize('chat.autopilot.enabled', "Controls whether the Autopilot mode is available in the permissions picker. When enabled, Autopilot auto-approves all tool calls and continues until the task is done."), - default: true, - tags: ['experimental'], - }, [ChatConfiguration.PlanReviewInlineEditorEnabled]: { type: 'boolean', markdownDescription: nls.localize('chat.planReview.inlineEditor.enabled', "When enabled, the plan review widget mounts an editor inline, as opposed to in a separate editor tab."), @@ -474,7 +468,6 @@ configurationRegistry.registerConfiguration({ ], description: nls.localize('chat.permissions.default.settingDescription', "Controls the default permissions picker mode for new chat sessions. You can still change the permission mode per session, and each session remembers the permission mode that was used. If enterprise policy disables auto approval, new sessions use Default Approvals."), default: ChatPermissionLevel.Default, - tags: ['experimental'], }, [ChatConfiguration.GlobalAutoApprove]: { default: false, @@ -1142,8 +1135,7 @@ configurationRegistry.registerConfiguration({ [ChatConfiguration.ToolConfirmationCarousel]: { type: 'boolean', description: nls.localize('chat.tools.confirmationCarousel', "When enabled, multiple tool confirmations are batched into a carousel above the input."), - default: product.quality !== 'stable', - tags: ['experimental'], + default: true, }, [ChatConfiguration.ToolRiskAssessmentEnabled]: { type: 'boolean', diff --git a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts index 4ae476e6352a5..54cc1ec0e3056 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts @@ -260,30 +260,28 @@ export class ChatSlashCommandsContribution extends Disposable { }, async (_prompt, _progress, _history, _location, sessionResource) => { setPermissionLevelForSession(sessionResource, ChatPermissionLevel.Default); })); - if (configurationService.getValue(ChatConfiguration.AutopilotEnabled) !== false) { - this._store.add(slashCommandService.registerSlashCommand({ - command: 'autopilot', - detail: nls.localize('autopilot', "Set permissions to autopilot mode"), - sortText: 'z1_autopilot', - executeImmediately: true, - silent: true, - locations: [ChatAgentLocation.Chat], - sessionTypes: [SessionType.Local, SessionType.CopilotCLI], - }, async (_prompt, _progress, _history, _location, sessionResource) => { - setPermissionLevelForSession(sessionResource, ChatPermissionLevel.Autopilot); - })); - this._store.add(slashCommandService.registerSlashCommand({ - command: 'exitAutopilot', - detail: nls.localize('exitAutopilot', "Set permissions back to default"), - sortText: 'z1_exitAutopilot', - executeImmediately: true, - silent: true, - locations: [ChatAgentLocation.Chat], - sessionTypes: [SessionType.Local, SessionType.CopilotCLI], - }, async (_prompt, _progress, _history, _location, sessionResource) => { - setPermissionLevelForSession(sessionResource, ChatPermissionLevel.Default); - })); - } + this._store.add(slashCommandService.registerSlashCommand({ + command: 'autopilot', + detail: nls.localize('autopilot', "Set permissions to autopilot mode"), + sortText: 'z1_autopilot', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat], + sessionTypes: [SessionType.Local, SessionType.CopilotCLI], + }, async (_prompt, _progress, _history, _location, sessionResource) => { + setPermissionLevelForSession(sessionResource, ChatPermissionLevel.Autopilot); + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'exitAutopilot', + detail: nls.localize('exitAutopilot', "Set permissions back to default"), + sortText: 'z1_exitAutopilot', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat], + sessionTypes: [SessionType.Local, SessionType.CopilotCLI], + }, async (_prompt, _progress, _history, _location, sessionResource) => { + setPermissionLevelForSession(sessionResource, ChatPermissionLevel.Default); + })); } this._store.add(slashCommandService.registerSlashCommand({ command: 'help', diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSuggestNextWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSuggestNextWidget.ts index 5ee18850170ac..152e18d229a25 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSuggestNextWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSuggestNextWidget.ts @@ -106,9 +106,8 @@ export class ChatSuggestNextWidget extends Disposable { this.promptsContainer.removeChild(child); } - const isAutopilotEnabled = this.configurationService.getValue(ChatConfiguration.AutopilotEnabled) !== false; const isAutopilotPolicyRestricted = this.configurationService.inspect(ChatConfiguration.GlobalAutoApprove).policyValue === false; - const firstAutoSendHandoff = isAutopilotEnabled && !isAutopilotPolicyRestricted ? handoffs.find(h => h.send) : undefined; + const firstAutoSendHandoff = !isAutopilotPolicyRestricted ? handoffs.find(h => h.send) : undefined; for (const handoff of handoffs) { const promptButton = this.createPromptButton(handoff); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts index f5a65cb76bf49..400ed9550bf4b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts @@ -70,7 +70,6 @@ export class PermissionPickerActionItem extends ChatInputPickerActionViewItem { @IStorageService storageService: IStorageService, ) { const isAutoApprovePolicyRestricted = () => configurationService.inspect(ChatConfiguration.GlobalAutoApprove).policyValue === false; - const isAutopilotEnabled = () => configurationService.getValue(ChatConfiguration.AutopilotEnabled) !== false; const actionProvider: IActionWidgetDropdownActionProvider = { getActions: () => { // If the active session contributes its own permission items, surface those instead @@ -143,32 +142,30 @@ export class PermissionPickerActionItem extends ChatInputPickerActionViewItem { }, } satisfies IActionWidgetDropdownAction, ]; - if (isAutopilotEnabled()) { - actions.push({ - ...action, - id: 'chat.permissions.autopilot', - label: localize('permissions.autopilot', "Autopilot (Preview)"), - detail: localize('permissions.autopilot.subtext', "Autonomously iterates from start to finish"), - icon: ThemeIcon.fromId(Codicon.rocket.id), - checked: currentLevel === ChatPermissionLevel.Autopilot, - enabled: !policyRestricted, - tooltip: policyRestricted ? localize('permissions.autopilot.policyDisabled', "Disabled by enterprise policy") : '', - hover: { - content: policyRestricted - ? localize('permissions.autopilot.policyDescription', "Disabled by enterprise policy") - : localize('permissions.autopilot.description', "Auto-approve all tool calls and continue until the task is done"), - }, - run: async () => { - if (!await maybeConfirmElevatedPermissionLevel(ChatPermissionLevel.Autopilot, this.dialogService, storageService)) { - return; - } - delegate.setPermissionLevel(ChatPermissionLevel.Autopilot); - if (this.element) { - this.renderLabel(this.element); - } - }, - } satisfies IActionWidgetDropdownAction); - } + actions.push({ + ...action, + id: 'chat.permissions.autopilot', + label: localize('permissions.autopilot', "Autopilot (Preview)"), + detail: localize('permissions.autopilot.subtext', "Autonomously iterates from start to finish"), + icon: ThemeIcon.fromId(Codicon.rocket.id), + checked: currentLevel === ChatPermissionLevel.Autopilot, + enabled: !policyRestricted, + tooltip: policyRestricted ? localize('permissions.autopilot.policyDisabled', "Disabled by enterprise policy") : '', + hover: { + content: policyRestricted + ? localize('permissions.autopilot.policyDescription', "Disabled by enterprise policy") + : localize('permissions.autopilot.description', "Auto-approve all tool calls and continue until the task is done"), + }, + run: async () => { + if (!await maybeConfirmElevatedPermissionLevel(ChatPermissionLevel.Autopilot, this.dialogService, storageService)) { + return; + } + delegate.setPermissionLevel(ChatPermissionLevel.Autopilot); + if (this.element) { + this.renderLabel(this.element); + } + }, + } satisfies IActionWidgetDropdownAction); return actions; } }; diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index abb0024f1c0df..6bdca8d2973c6 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -72,7 +72,6 @@ export enum ChatConfiguration { ChatCustomizationHarnessSelectorEnabled = 'chat.customizations.harnessSelector.enabled', ChatCustomizationsStructuredPreviewEnabled = 'chat.customizations.structuredPreview.enabled', - AutopilotEnabled = 'chat.autopilot.enabled', PlanReviewInlineEditorEnabled = 'chat.planReview.inlineEditor.enabled', DefaultPermissionLevel = 'chat.permissions.default', ImageCarouselEnabled = 'imageCarousel.chat.enabled', From 1091536701eb83a19c67fa87eee524a583d599af Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 29 May 2026 14:27:17 -0700 Subject: [PATCH 16/27] agent host: hydrate snapshot controller for Restore Checkpoint (#319051) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * agent host: hydrate snapshot controller for Restore Checkpoint Currently the AgentHostSnapshotController never has any checkpoints to restore to when "Restore Checkpoint" is invoked, so the removed request stays visible in the chat UI. This fix wires up hydration end-to-end and simplifies the controller's bookkeeping along the way. - Seed a request-level checkpoint for every historical turn on session open, not only turns with file edits. Without this, restoreSnapshot for any turn that lacked tool calls fell through with "No checkpoint found" and _setDisabledRequests was never called. - Always populate _pendingHistoryTurns from the protocol state (previously gated on hasTurnsWithEdits), and stop using Event.once on onDidCreateModel to wait for the chat model — the once subscription was being consumed by an unrelated model created first, leaving the controller un-hydrated. Now we synchronously hydrate when the model already exists, otherwise listen until the matching session arrives. - Make ensureRequestCheckpoint advance _currentCheckpointIndex to the new checkpoint. Previously the cursor stayed put, so requestDisablement marked the in-flight request as disabled (the new checkpoint sat "forward" of the cursor) and the next call would splice it away. - Simplify to one checkpoint per request. Multiple tool calls in the same request now fold their edits into a single checkpoint via a seenToolCallIds Set, and restoreSnapshot/getSnapshotUri/ getSnapshotContents ignore the stopId parameter. canUndo/canRedo derive purely from cursor position — undo/redo is request-level, available whenever any checkpoint exists. - Add tests covering: in-flight request isn't disabled, restore of a no-edit request marks it disabled, stale forward branch is spliced on new request after restore-to-start, and multi-tool-call edits undo together. Fixes https://github.com/microsoft/vscode/issues/318251 (Commit message generated by Copilot) * agent host: address review feedback - Fold multiple tool-call edits to the same file in one request into a single net before/after pair (mergeFileEdit). Without this, _writeCheckpointContent applied duplicate writes in parallel and raced. - Refresh stale 'sentinel' wording in session handler comments. (Commit message generated by Copilot) --- .../agentHost/agentHostSessionHandler.ts | 51 ++-- .../agentHost/agentHostSnapshotController.ts | 221 ++++++++++-------- .../agentHostSnapshotController.test.ts | 107 ++++++++- 3 files changed, 265 insertions(+), 114 deletions(-) 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 3c3b30960e4e4..4c9cccffe3679 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -6,7 +6,7 @@ import { encodeBase64, VSBuffer } from '../../../../../../base/common/buffer.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { isCancellationError } from '../../../../../../base/common/errors.js'; -import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { Emitter } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Disposable, DisposableResourceMap, DisposableStore, IReference, MutableDisposable, toDisposable, type IDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; @@ -26,7 +26,7 @@ import { CompletionItemKind as AhpCompletionItemKind, type CompletionItem as Ahp import { ConfirmationOptionKind, TerminalClaimKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type SessionActiveClient } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, SessionTurnStartedAction, type ClientSessionAction, type SessionAction, type SessionInputCompletedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { buildSubagentSessionUri, getToolFileEdits, getToolSubagentContent, MessageAttachmentKind, PendingMessageKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, type ClientPluginCustomization, type ICompletedToolCall, type MarkdownResponsePart, type MessageAttachment, type ModelSelection, type ReasoningResponsePart, type RootState, type SessionInputAnswer, type SessionInputRequest, type SessionState, type ToolCallResponsePart, type ToolCallState, type Turn, type UsageInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildSubagentSessionUri, getToolSubagentContent, MessageAttachmentKind, PendingMessageKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, type ClientPluginCustomization, type ICompletedToolCall, type MarkdownResponsePart, type MessageAttachment, type ModelSelection, type ReasoningResponsePart, type RootState, type SessionInputAnswer, type SessionInputRequest, type SessionState, type ToolCallResponsePart, type ToolCallState, type Turn, type UsageInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; @@ -589,13 +589,13 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // its tool calls appear grouped under the parent widget. await this._enrichHistoryWithSubagentCalls(history, resolvedSession); - // Store turns with file edits so the editing session - // can be hydrated when it's created lazily. - const hasTurnsWithEdits = sessionState.turns.some(t => - t.responseParts.some(rp => rp.kind === ResponsePartKind.ToolCall - && rp.toolCall.status === ToolCallStatus.Completed - && getToolFileEdits(rp.toolCall).length > 0)); - if (hasTurnsWithEdits) { + // Store historical turns so the editing session can seed a + // request-level checkpoint for each turn (with file edits + // folded in) when the controller is created lazily. We seed + // for every turn — not just those with edits — so "Restore + // Checkpoint" on any historical request can find a boundary + // to navigate to. + if (sessionState.turns.length > 0) { this._pendingHistoryTurns.set(sessionResource, sessionState.turns); } @@ -673,15 +673,25 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // leaving the agent picker empty on session open. this._ensureActiveClientForMessage(resolvedSession); - // If there are historical turns with file edits, eagerly create - // the snapshot controller once the ChatModel is available so that - // restore-to-checkpoint works after session restore. + // Eagerly create the snapshot controller once the ChatModel for + // this session is available so that "Restore Checkpoint" works + // on historical turns. The model may already exist (in which + // case we run synchronously) or it may be created shortly after + // this code runs — we keep the listener alive until our session + // matches, since `Event.once` would be consumed by an unrelated + // model created first. if (this._pendingHistoryTurns.has(sessionResource)) { - session.registerDisposable(Event.once(this._chatService.onDidCreateModel)(model => { - if (isEqual(model.sessionResource, sessionResource)) { - this._ensureSnapshotController(sessionResource); - } - })); + if (this._chatService.getSession(sessionResource)) { + this._ensureSnapshotController(sessionResource); + } else { + const sub = this._chatService.onDidCreateModel(model => { + if (isEqual(model.sessionResource, sessionResource)) { + sub.dispose(); + this._ensureSnapshotController(sessionResource); + } + }); + session.registerDisposable(sub); + } } // If reconnecting to an active turn, wire up an ongoing state listener @@ -2320,11 +2330,16 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } // Hydrate from historical turns if this is the first time - // the controller is accessed for this chat session. + // the controller is accessed for this chat session. We seed a + // request-level checkpoint for every turn (not just turns with + // edits) so "Restore Checkpoint" on any historical request can + // find a boundary and mark subsequent requests as disabled via + // requestDisablement. const pendingTurns = this._pendingHistoryTurns.get(sessionResource); if (pendingTurns) { this._pendingHistoryTurns.delete(sessionResource); for (const turn of pendingTurns) { + editingSession.ensureRequestCheckpoint(turn.id); for (const rp of turn.responseParts) { if (rp.kind === ResponsePartKind.ToolCall) { editingSession.addToolCallEdits(turn.id, rp.toolCall); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSnapshotController.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSnapshotController.ts index f810bc72cc237..686bfa02af85f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSnapshotController.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSnapshotController.ts @@ -21,15 +21,15 @@ import { IChatRequestDisablement, IChatResponseModel } from '../../../common/mod import { fileEditsToExternalEdits, type IToolCallFileEdit } from './stateToProgressAdapter.js'; /** - * One checkpoint per tool call (or the sentinel checkpoint for a request - * that produced no edits). Tracks the before/after content URIs needed to - * revert / replay the edits on disk during {@link AgentHostSnapshotController.restoreSnapshot}. + * One checkpoint per request. Accumulates the before/after content URIs of + * every completed tool call's file edits so the request's edits can be + * undone/redone on disk during {@link AgentHostSnapshotController.restoreSnapshot}. */ interface IAgentHostCheckpoint { readonly requestId: string; - /** Tool-call ID, or `undefined` for the sentinel checkpoint at request start. */ - readonly undoStopId: string | undefined; readonly edits: IToolCallFileEdit[]; + /** Tool-call IDs whose edits have already been folded into `edits`. */ + readonly seenToolCallIds: Set; } /** @@ -48,8 +48,13 @@ interface IAgentHostCheckpoint { * - `entries` is always empty → the global accept/reject UI doesn't appear * - no diff computation, no multi-diff editor, no streaming-edits APIs * - * Hydrated by the session handler via {@link addToolCallEdits} as completed - * tool calls arrive. + * Undo/redo granularity is per-request: every request occupies one checkpoint + * regardless of how many tool calls it ran. The `stopId` parameters on + * {@link restoreSnapshot}, {@link getSnapshotUri}, and {@link getSnapshotContents} + * are accepted for interface compatibility but ignored. + * + * Hydrated by the session handler via {@link ensureRequestCheckpoint} and + * {@link addToolCallEdits} as turns and tool calls arrive. */ export class AgentHostSnapshotController extends Disposable implements IChatEditingSession { @@ -60,23 +65,14 @@ export class AgentHostSnapshotController extends Disposable implements IChatEdit readonly entries: IObservable = constObservable([]); readonly requestDisablement: IObservable = derivedOpts( - { equalsFn: (a, b) => a.length === b.length && a.every((v, i) => v.requestId === b[i].requestId && v.afterUndoStop === b[i].afterUndoStop) }, + { equalsFn: (a, b) => a.length === b.length && a.every((v, i) => v.requestId === b[i].requestId) }, reader => { const currentIdx = this._currentCheckpointIndex.read(reader); - if (currentIdx >= this._checkpoints.length - 1) { - return []; - } - // Disable every request whose first checkpoint sits past the current - // index. Keep the first entry per request — if that's the sentinel - // (undoStopId === undefined) the entire request is disabled. - const disabled = new Map(); + const disabled: IChatRequestDisablement[] = []; for (let i = currentIdx + 1; i < this._checkpoints.length; i++) { - const cp = this._checkpoints[i]; - if (!disabled.has(cp.requestId)) { - disabled.set(cp.requestId, cp.undoStopId); - } + disabled.push({ requestId: this._checkpoints[i].requestId }); } - return [...disabled].map(([requestId, afterUndoStop]): IChatRequestDisablement => ({ requestId, afterUndoStop })); + return disabled; }, ); @@ -102,41 +98,53 @@ export class AgentHostSnapshotController extends Disposable implements IChatEdit // ---- Hydration from protocol state -------------------------------------- /** - * Ensures a sentinel checkpoint exists for the given request. Called at the - * start of every turn so {@link requestDisablement} and {@link restoreSnapshot} - * can reference requests that produce no file edits. + * Ensures a checkpoint exists for the given request. Called at the start + * of every turn (and during history hydration) so {@link requestDisablement} + * and {@link restoreSnapshot} can reference every request, even ones that + * produce no file edits. * - * Also splices away stale checkpoints after the current index (undo branch + * Splices away stale checkpoints past the current index (undo branch * semantics) when a new request arrives after a checkpoint restore. */ ensureRequestCheckpoint(requestId: string): void { - // Splice stale checkpoints if the user restored a checkpoint + // Idempotent on existing requests. + if (this._checkpoints.some(cp => cp.requestId === requestId)) { + return; + } + + // Splice the forward branch when starting a brand-new request after + // the user restored a checkpoint. const currentIdx = this._currentCheckpointIndex.get(); if (currentIdx < this._checkpoints.length - 1) { this._checkpoints.splice(currentIdx + 1); } - // Insert sentinel for this request if it doesn't exist yet - if (!this._checkpoints.some(cp => cp.requestId === requestId)) { - this._checkpoints.push({ requestId, undoStopId: undefined, edits: [] }); - } + this._checkpoints.push({ requestId, edits: [], seenToolCallIds: new Set() }); + + // Advance the cursor to the new checkpoint. Otherwise the just-added + // request would appear in requestDisablement (it would sit forward of + // the cursor) and the chat UI would render it as a disabled turn. + transaction(tx => { + this._currentCheckpointIndex.set(this._checkpoints.length - 1, tx); + }); } /** - * Records the before/after content URIs for a completed tool call so we - * can revert/replay them later. Idempotent on `toolCallId`. + * Folds a completed tool call's file edits into the checkpoint for the + * given request. Idempotent on `toolCallId`. */ addToolCallEdits(requestId: string, tc: ToolCallState): void { if (tc.status !== ToolCallStatus.Completed) { return; } - // Deduplicate - if (this._checkpoints.some(cp => cp.undoStopId === tc.toolCallId)) { + this.ensureRequestCheckpoint(requestId); + + const cp = this._checkpoints.find(c => c.requestId === requestId); + if (!cp || cp.seenToolCallIds.has(tc.toolCallId)) { return; } - - this.ensureRequestCheckpoint(requestId); + cp.seenToolCallIds.add(tc.toolCallId); const fileEdits = fileEditsToExternalEdits(tc); if (fileEdits.length === 0) { @@ -144,62 +152,49 @@ export class AgentHostSnapshotController extends Disposable implements IChatEdit } const authority = this._connectionAuthority; - const edits: IToolCallFileEdit[] = fileEdits.map(edit => ({ - kind: edit.kind, - resource: toAgentHostUri(edit.resource, authority), - originalResource: edit.originalResource ? toAgentHostUri(edit.originalResource, authority) : undefined, - beforeContentUri: edit.beforeContentUri ? toAgentHostUri(edit.beforeContentUri, authority) : undefined, - afterContentUri: edit.afterContentUri ? toAgentHostUri(edit.afterContentUri, authority) : undefined, - undoStopId: edit.undoStopId, - diff: edit.diff, - })); - - this._checkpoints.push({ requestId, undoStopId: tc.toolCallId, edits }); - - transaction(tx => { - this._currentCheckpointIndex.set(this._checkpoints.length - 1, tx); - }); + for (const edit of fileEdits) { + const resource = toAgentHostUri(edit.resource, authority); + const entry: IToolCallFileEdit = { + kind: edit.kind, + resource, + originalResource: edit.originalResource ? toAgentHostUri(edit.originalResource, authority) : undefined, + beforeContentUri: edit.beforeContentUri ? toAgentHostUri(edit.beforeContentUri, authority) : undefined, + afterContentUri: edit.afterContentUri ? toAgentHostUri(edit.afterContentUri, authority) : undefined, + undoStopId: edit.undoStopId, + diff: edit.diff, + }; + + // Multiple tool calls in one request may touch the same file + // (e.g. create→edit, edit→delete). Fold each new edit into the + // prior one for the same resource so the checkpoint stores a + // single net before/after pair per file. Otherwise + // _writeCheckpointContent would apply duplicate writes in + // parallel and race to leave the file in an undefined state. + const existingIdx = cp.edits.findIndex(e => e.resource.toString() === resource.toString()); + if (existingIdx < 0) { + cp.edits.push(entry); + } else { + cp.edits[existingIdx] = mergeFileEdit(cp.edits[existingIdx], entry); + } + } } // ---- Snapshots ---------------------------------------------------------- - private _findCheckpointIndex(requestId: string, stopId: string | undefined): number { - if (stopId !== undefined) { - return this._checkpoints.findIndex(cp => cp.requestId === requestId && cp.undoStopId === stopId); - } - // No specific stop: find the sentinel checkpoint (undoStopId === undefined) - // for this request, which marks the request boundary. - return this._checkpoints.findIndex(cp => cp.requestId === requestId && cp.undoStopId === undefined); - } - - private _findCheckpoint(requestId: string, stopId: string | undefined): IAgentHostCheckpoint | undefined { - if (stopId !== undefined) { - const idx = this._findCheckpointIndex(requestId, stopId); - return idx >= 0 ? this._checkpoints[idx] : undefined; - } - // No specific stop: find the last non-sentinel checkpoint for this - // request (the one with actual edits). - for (let i = this._checkpoints.length - 1; i >= 0; i--) { - const cp = this._checkpoints[i]; - if (cp.requestId === requestId && cp.undoStopId !== undefined) { - return cp; - } - } - return undefined; + private _findCheckpointIndex(requestId: string): number { + return this._checkpoints.findIndex(cp => cp.requestId === requestId); } - async restoreSnapshot(requestId: string, stopId: string | undefined): Promise { + async restoreSnapshot(requestId: string, _stopId: string | undefined): Promise { return this._undoRedoSequencer.queue(async () => { - const cpIdx = this._findCheckpointIndex(requestId, stopId); + const cpIdx = this._findCheckpointIndex(requestId); if (cpIdx < 0) { - this._logService.warn(`[AgentHostSnapshotController] No checkpoint found for requestId=${requestId}${stopId ? `, stopId=${stopId}` : ''}`); + this._logService.warn(`[AgentHostSnapshotController] No checkpoint found for requestId=${requestId}`); return; } - // When stopId is undefined we found the sentinel (request boundary). - // Navigate to one before it so the request's edits are fully undone. - const targetIdx = stopId === undefined ? cpIdx - 1 : cpIdx; - + // Restore to before this request: target one slot before it. + const targetIdx = cpIdx - 1; const currentIdx = this._currentCheckpointIndex.get(); if (targetIdx < currentIdx) { // Undo forward checkpoints @@ -219,30 +214,33 @@ export class AgentHostSnapshotController extends Disposable implements IChatEdit }); } - getSnapshotUri(requestId: string, uri: URI, stopId: string | undefined): URI | undefined { - const cp = this._findCheckpoint(requestId, stopId); - if (!cp) { - return undefined; - } - const uriStr = uri.toString(); - const edit = cp.edits.find(e => e.resource.toString() === uriStr); - if (!edit) { + getSnapshotUri(requestId: string, uri: URI, _stopId: string | undefined): URI | undefined { + const cp = this._checkpoints.find(c => c.requestId === requestId); + if (!cp || !cp.edits.some(e => e.resource.toString() === uri.toString())) { return undefined; } return URI.from({ scheme: Schemas.chatEditingSnapshotScheme, path: uri.path, - query: JSON.stringify({ session: this.chatSessionResource.toString(), requestId, undoStop: stopId ?? '' }), + query: JSON.stringify({ session: this.chatSessionResource.toString(), requestId, undoStop: '' }), }); } - async getSnapshotContents(requestId: string, uri: URI, stopId: string | undefined): Promise { - const cp = this._findCheckpoint(requestId, stopId); + async getSnapshotContents(requestId: string, uri: URI, _stopId: string | undefined): Promise { + const cp = this._checkpoints.find(c => c.requestId === requestId); if (!cp) { return undefined; } const uriStr = uri.toString(); - const edit = cp.edits.find(e => e.resource.toString() === uriStr); + // Use the last edit for this file in the request — that's the + // "after-content" the diff viewer wants to display. + let edit: IToolCallFileEdit | undefined; + for (let i = cp.edits.length - 1; i >= 0; i--) { + if (cp.edits[i].resource.toString() === uriStr) { + edit = cp.edits[i]; + break; + } + } if (!edit) { return undefined; } @@ -379,3 +377,42 @@ export class AgentHostSnapshotController extends Disposable implements IChatEdit await Promise.all(ops); } } + +/** + * Combines two edits to the same file (in arrival order) into a single net + * edit. The merged entry keeps the earlier `before` snapshot and the later + * `after` snapshot, and derives a net `kind` based on whether the file + * exists at the start and end of the combined operation. + * + * A create-then-delete collapses to a no-op edit (no before, no after) — we + * still keep the entry so the file is restored to "absent" on undo, but + * `_writeCheckpointContent` will skip the write since both URIs are absent. + */ +function mergeFileEdit(prev: IToolCallFileEdit, next: IToolCallFileEdit): IToolCallFileEdit { + const startsAbsent = prev.kind === FileEditKind.Create; + const endsAbsent = next.kind === FileEditKind.Delete; + + let kind: FileEditKind; + if (startsAbsent && endsAbsent) { + kind = FileEditKind.Edit; // create+delete collapses to no-op + } else if (startsAbsent) { + kind = FileEditKind.Create; + } else if (endsAbsent) { + kind = FileEditKind.Delete; + } else { + kind = FileEditKind.Edit; + } + + return { + kind, + resource: next.resource, + // Renames within a single request are uncommon; if the second edit + // is itself a rename keep its originalResource, otherwise carry + // forward the first one. + originalResource: next.originalResource ?? prev.originalResource, + beforeContentUri: prev.beforeContentUri, + afterContentUri: next.afterContentUri, + undoStopId: prev.undoStopId, + diff: next.diff ?? prev.diff, + }; +} diff --git a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSnapshotController.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSnapshotController.test.ts index f70227405fc92..30329e2d65fb3 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSnapshotController.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSnapshotController.test.ts @@ -184,13 +184,112 @@ suite('AgentHostSnapshotController', () => { assert.deepStrictEqual(controller.requestDisablement.get().map(d => d.requestId), ['req-2']); }); - test('ensureRequestCheckpoint creates sentinel and is idempotent', () => { + test('ensureRequestCheckpoint creates a checkpoint and is idempotent', () => { const controller = createController(store, new Map()); controller.ensureRequestCheckpoint('req-1'); controller.ensureRequestCheckpoint('req-1'); - // A sentinel alone doesn't enable undo (currentIdx === -1 since - // sentinels never advance the cursor on their own). - assert.strictEqual(controller.canUndo.get(), false); + // Undo is request-level: a checkpoint exists, so we can undo it + // (even though the request produced no edits). + assert.strictEqual(controller.canUndo.get(), true); + assert.strictEqual(controller.canRedo.get(), false); + }); + + test('ensureRequestCheckpoint does not mark the current request as disabled', () => { + const controller = createController(store, new Map()); + // Simulates the start-of-turn path in the session handler: the + // checkpoint for the in-flight request must not appear in + // requestDisablement (otherwise the chat UI hides the live turn). + controller.ensureRequestCheckpoint('req-1'); + assert.deepStrictEqual(controller.requestDisablement.get(), []); + controller.ensureRequestCheckpoint('req-2'); + assert.deepStrictEqual(controller.requestDisablement.get(), []); + }); + + test('restoreSnapshot of a no-edit request marks it disabled', async () => { + const controller = createController(store, new Map()); + // Two requests, neither produced file edits — mirrors a session + // hydrated from history where intermediate turns had no tool calls. + controller.ensureRequestCheckpoint('req-1'); + controller.ensureRequestCheckpoint('req-2'); + await controller.restoreSnapshot('req-2', undefined); + assert.deepStrictEqual( + controller.requestDisablement.get().map(d => d.requestId), + ['req-2'], + ); + }); + + test('starting a new request after restore-to-start splices stale checkpoints', () => { + const before = URI.file('/snap/before-1').toString(); + const after = URI.file('/snap/after-1').toString(); + const controller = createController(store, new Map([ + [before, 'a'], [after, 'b'], [URI.file('/file.ts').toString(), 'a'], + ])); + controller.addToolCallEdits('req-1', makeToolCall({ + toolCallId: 'tc-1', filePath: '/file.ts', + beforeURI: before, afterURI: after, + })); + return controller.restoreSnapshot('req-1', undefined).then(() => { + // After restoring before req-1, the user sends a new request. + // The stale forward branch must be spliced or the new checkpoint + // would coexist with the discarded one. + controller.ensureRequestCheckpoint('req-2'); + assert.deepStrictEqual(controller.requestDisablement.get(), []); + assert.strictEqual(controller.canRedo.get(), false); + }); + }); + + test('multiple tool calls in one request share a checkpoint', async () => { + const before1 = URI.file('/snap/before-1').toString(); + const after1 = URI.file('/snap/after-1').toString(); + const before2 = URI.file('/snap/before-2').toString(); + const after2 = URI.file('/snap/after-2').toString(); + const fileA = URI.file('/a.ts').toString(); + const fileB = URI.file('/b.ts').toString(); + const contentMap = new Map([ + [before1, 'a-original'], [after1, 'a-modified'], [fileA, 'a-modified'], + [before2, 'b-original'], [after2, 'b-modified'], [fileB, 'b-modified'], + ]); + const controller = createController(store, contentMap); + controller.addToolCallEdits('req-1', makeToolCall({ + toolCallId: 'tc-1', filePath: '/a.ts', + beforeURI: before1, afterURI: after1, + })); + controller.addToolCallEdits('req-1', makeToolCall({ + toolCallId: 'tc-2', filePath: '/b.ts', + beforeURI: before2, afterURI: after2, + })); + // Restoring before req-1 undoes BOTH tool calls' edits. + await controller.restoreSnapshot('req-1', undefined); + assert.strictEqual(contentMap.get(fileA), 'a-original'); + assert.strictEqual(contentMap.get(fileB), 'b-original'); + }); + + test('multiple tool calls editing the same file collapse to one net edit', async () => { + // Two sequential edits to /file.ts within the same request: the + // second edit's after-content must win on redo, and the first + // edit's before-content must win on undo. Without merging, the + // two edits would race when applied in parallel. + const beforeA = URI.file('/snap/before-a').toString(); + const afterA = URI.file('/snap/after-a').toString(); + const beforeB = URI.file('/snap/before-b').toString(); + const afterB = URI.file('/snap/after-b').toString(); + const file = URI.file('/file.ts').toString(); + const contentMap = new Map([ + [beforeA, 'v0'], [afterA, 'v1'], + [beforeB, 'v1'], [afterB, 'v2'], + [file, 'v2'], + ]); + const controller = createController(store, contentMap); + controller.addToolCallEdits('req-1', makeToolCall({ + toolCallId: 'tc-1', filePath: '/file.ts', + beforeURI: beforeA, afterURI: afterA, + })); + controller.addToolCallEdits('req-1', makeToolCall({ + toolCallId: 'tc-2', filePath: '/file.ts', + beforeURI: beforeB, afterURI: afterB, + })); + await controller.restoreSnapshot('req-1', undefined); + assert.strictEqual(contentMap.get(file), 'v0'); }); test('hasEditsInRequest reflects added tool call edits', () => { From f834625f05990bb5c9cc9753abe6c1ef6f76c092 Mon Sep 17 00:00:00 2001 From: "vs-code-engineering[bot]" <122617954+vs-code-engineering[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 21:58:27 +0000 Subject: [PATCH 17/27] Bump version to 1.123.0 (#318253) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Federico Brancasi Co-authored-by: Anthony Kim <62267334+anthonykim1@users.noreply.github.com> --- extensions/copilot/package-lock.json | 6 +++--- extensions/copilot/package.json | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index 04de8280581a9..940a1a9678ed5 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -1,12 +1,12 @@ { "name": "copilot-chat", - "version": "0.50.0", + "version": "0.51.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "copilot-chat", - "version": "0.50.0", + "version": "0.51.0", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { @@ -144,7 +144,7 @@ "engines": { "node": ">=22.14.0", "npm": ">=9.0.0", - "vscode": "^1.122.0" + "vscode": "^1.123.0" } }, "node_modules/@ampproject/remapping": { diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 5dda5a69a524c..4e8a67e6f7219 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -2,7 +2,7 @@ "name": "copilot-chat", "displayName": "GitHub Copilot Chat", "description": "AI chat features powered by Copilot", - "version": "0.50.0", + "version": "0.51.0", "build": "1", "completionsCoreVersion": "1.378.1799", "internalLargeStorageAriaKey": "ec712b3202c5462fb6877acae7f1f9d7-c19ad55e-3e3c-4f99-984b-827f6d95bd9e-6917", @@ -22,7 +22,7 @@ "icon": "assets/copilot.png", "pricing": "Trial", "engines": { - "vscode": "^1.122.0", + "vscode": "^1.123.0", "npm": ">=9.0.0", "node": ">=22.14.0" }, diff --git a/package-lock.json b/package-lock.json index d84e0cc302c5a..9b0ad2ad0e0ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "code-oss-dev", - "version": "1.122.0", + "version": "1.123.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-oss-dev", - "version": "1.122.0", + "version": "1.123.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index b1bbba47158cd..69ed8e96d54ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "code-oss-dev", - "version": "1.122.0", + "version": "1.123.0", "distro": "36d906669669f12466c6912bd65d9eeb47c6522d", "author": { "name": "Microsoft Corporation" From 4293b057ca23d5ab8111cd8bfdcf5fce53a759de Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 29 May 2026 15:09:58 -0700 Subject: [PATCH 18/27] chat: avoid reverting model to auto when editing messages (#319063) When editing a message in inline mode, a new ChatInputPart is created for the inline editor. It would initialize its model from persisted storage (often 'auto') and on finish that selection was copied back over the user's main-input selection. Now seed the inline edit input from the edited request's modelId when it is known, and stop copying the inline input's model back to the main input on finishedEditing. Agent host requests don't carry a modelId, so in that case the inline input is left alone and the main input keeps its selection. --- .../contrib/chat/browser/widget/chatWidget.ts | 7 +++---- .../chat/browser/widget/input/chatInputPart.ts | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index a3a0498410e92..55f75663e0cb9 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -1686,6 +1686,9 @@ export class ChatWidget extends Disposable implements IChatWidget { this.createInput(this.inputContainer); this.input.setChatMode(this.inputPart.currentModeObs.get().id); this.input.setPermissionLevel(this.inputPart.currentModeInfo.permissionLevel ?? ChatPermissionLevel.Default); + if (currentElement.modelId) { + this.input.switchModelByIdentifier(currentElement.modelId); + } this.input.setEditing(true, isEditingSentRequest); this._onDidChangeActiveInputEditor.fire(); } else { @@ -1787,10 +1790,6 @@ export class ChatWidget extends Disposable implements IChatWidget { if (!isInput) { this.inputPart.setChatMode(this.input.currentModeObs.get().id); this.inputPart.setPermissionLevel(this.input.currentModeInfo.permissionLevel ?? ChatPermissionLevel.Default); - const currentModel = this.input.selectedLanguageModel.get(); - if (currentModel) { - this.inputPart.switchModel(currentModel.metadata); - } this.inputPart?.toggleChatInputOverlay(false); try { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index fbd7ad1789772..fdedf3735adff 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -850,6 +850,20 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } + /** + * Switch to a model by its identifier. Returns true if a matching model + * was found and applied. + */ + public switchModelByIdentifier(identifier: string): boolean { + const models = this.getModels(); + const model = models.find(m => m.identifier === identifier); + if (model) { + this.setCurrentLanguageModel(model); + return true; + } + return false; + } + public switchModelByQualifiedName(qualifiedModelNames: readonly string[]): boolean { const models = this.getModels(); for (const qualifiedModelName of qualifiedModelNames) { From 77c870d244f9205d8d4e1c59b1923780b9f8fc1a Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Sat, 30 May 2026 00:35:56 +0200 Subject: [PATCH 19/27] maximize and sticky logs --- src/vs/sessions/browser/parts/sessionsPart.ts | 12 +- .../browser/parts/sessionsPartService.ts | 29 ++++- .../browser/sessionsTelemetry.contribution.ts | 118 ++++++++++++++---- .../browser/sessionsManagementService.ts | 8 +- .../sessions/browser/visibleSessions.ts | 5 +- .../sessions/common/sessionsManagement.ts | 11 ++ .../test/browser/sessionNavigation.test.ts | 1 + 7 files changed, 151 insertions(+), 33 deletions(-) diff --git a/src/vs/sessions/browser/parts/sessionsPart.ts b/src/vs/sessions/browser/parts/sessionsPart.ts index 7d93881bfe572..bfc641e8983ee 100644 --- a/src/vs/sessions/browser/parts/sessionsPart.ts +++ b/src/vs/sessions/browser/parts/sessionsPart.ts @@ -222,21 +222,27 @@ export class SessionsPart extends Part { * Toggles the maximized state of the session view hosting the given session. * If the view is already maximized, exits maximized state. Otherwise maximizes * it (no-op if fewer than two non-placeholder views are present). + * + * Returns the view's maximized state after the toggle, or `undefined` when + * the call was a no-op. */ - toggleMaximizeSession(sessionId: string | undefined): void { + toggleMaximizeSession(sessionId: string | undefined): boolean | undefined { if (!this._gridWidget) { - return; + return undefined; } const slot = this._slots.find(s => s.boundSessionId === sessionId); if (!slot) { - return; + return undefined; } if (this._gridWidget.isViewMaximized(slot.view)) { this._gridWidget.exitMaximizedView(); + return false; } else if (this._slots.filter(s => s.boundSessionId !== undefined).length >= 2) { this._gridWidget.maximizeView(slot.view); slot.view.focus(); + return true; } + return undefined; } /** diff --git a/src/vs/sessions/browser/parts/sessionsPartService.ts b/src/vs/sessions/browser/parts/sessionsPartService.ts index 480aa2a8af445..86efe596a69e4 100644 --- a/src/vs/sessions/browser/parts/sessionsPartService.ts +++ b/src/vs/sessions/browser/parts/sessionsPartService.ts @@ -14,9 +14,19 @@ import { SessionView } from './sessionView.js'; import { IActiveSession, ISessionsManagementService } from '../../services/sessions/common/sessionsManagement.js'; import { autorun } from '../../../base/common/observable.js'; import { IProgressIndicator } from '../../../platform/progress/common/progress.js'; +import { Emitter, Event } from '../../../base/common/event.js'; export const ISessionsPartService = createDecorator('sessionsPartService'); +/** + * Payload for {@link ISessionsPartService.onDidToggleMaximizeSession}. + */ +export interface IToggleMaximizeSessionEvent { + readonly session: IActiveSession; + /** The session view's maximized state after the toggle. */ + readonly maximized: boolean; +} + export interface ISessionsPartService { readonly _serviceBrand: undefined; @@ -33,6 +43,13 @@ export interface ISessionsPartService { */ toggleMaximizeSession(session: IActiveSession | undefined): void; + /** + * Fires after the maximized state of a session view was toggled via + * {@link toggleMaximizeSession}. Does not fire when the call was a no-op + * (e.g. the session was not visible or fewer than two views were present). + */ + readonly onDidToggleMaximizeSession: Event; + /** * Moves keyboard focus into the chat input of the session view hosting the * given session, or into the placeholder (new-session) view when `session` @@ -66,6 +83,9 @@ export class SessionsParts extends Disposable implements ISessionsPartService { private readonly _mainPart: SessionsPart; + private readonly _onDidToggleMaximizeSession = this._register(new Emitter()); + readonly onDidToggleMaximizeSession: Event = this._onDidToggleMaximizeSession.event; + constructor( @IInstantiationService instantiationService: IInstantiationService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService @@ -100,7 +120,14 @@ export class SessionsParts extends Disposable implements ISessionsPartService { } toggleMaximizeSession(session: IActiveSession | undefined): void { - this._mainPart.toggleMaximizeSession(session?.sessionId); + if (!session) { + this._mainPart.toggleMaximizeSession(undefined); + return; + } + const maximized = this._mainPart.toggleMaximizeSession(session.sessionId); + if (maximized !== undefined) { + this._onDidToggleMaximizeSession.fire({ session, maximized }); + } } focusSession(session: IActiveSession | undefined): void { diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTelemetry.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTelemetry.contribution.ts index 93649ad9f4182..8d37a37c27d7a 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTelemetry.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTelemetry.contribution.ts @@ -19,6 +19,7 @@ import { IAgentFeedbackAddedEvent, IAgentFeedbackConvertedEvent, IAgentFeedbackR import { IChat, ISession, ISessionWorkspace, SessionStatus } from '../../../services/sessions/common/session.js'; import { ISendRequestSentEvent, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISendRequestOptions } from '../../../services/sessions/common/sessionsProvider.js'; +import { ISessionsPartService } from '../../../browser/parts/sessionsPartService.js'; const TOTAL_REQUESTS_KEY = 'agentSessions.telemetry.totalRequests'; const WORKSPACE_REQUESTS_KEY = 'agentSessions.telemetry.workspaceRequests'; @@ -50,6 +51,7 @@ export class SessionsTelemetryContribution extends Disposable implements IWorkbe @IConfigurationService private readonly _configurationService: IConfigurationService, @ICommandService commandService: ICommandService, @IAgentFeedbackService agentFeedbackService: IAgentFeedbackService, + @ISessionsPartService sessionsPartService: ISessionsPartService, ) { super(); @@ -66,6 +68,8 @@ export class SessionsTelemetryContribution extends Disposable implements IWorkbe this._register(this._sessionsManagementService.onDidDeleteSession(session => this._logSessionDeleted(session))); this._register(this._sessionsManagementService.onDidDeleteChat(session => this._logChatDeleted(session))); this._register(this._sessionsManagementService.onDidRenameChat(session => this._logChatRenamed(session))); + this._register(this._sessionsManagementService.onDidToggleSessionStickiness(e => this._logSessionStickinessToggled(e.session, e.sticky))); + this._register(sessionsPartService.onDidToggleMaximizeSession(e => this._logSessionMaximizeToggled(e.session, e.maximized))); this._register(commandService.onDidExecuteCommand(e => { // Commands fire very frequently. Match on the command id first @@ -102,9 +106,6 @@ export class SessionsTelemetryContribution extends Disposable implements IWorkbe case 'github.copilot.claude.sessions.commitAndSync': log = session => this._logCommitAndSync(session); break; - case 'agentSession.markAsDone': - log = session => this._logSessionMarkedAsDone(session); - break; case 'agentSession.restore': log = session => this._logSessionRestored(session); break; @@ -199,6 +200,24 @@ export class SessionsTelemetryContribution extends Disposable implements IWorkbe }); } + private _logSessionStickinessToggled(session: ISession, sticky: boolean): void { + void this._getSessionActionPayload(session).then(payload => { + this._telemetryService.publicLog2('agents/sessionStickinessToggled', { + ...payload, + sticky, + }); + }); + } + + private _logSessionMaximizeToggled(session: ISession, maximized: boolean): void { + void this._getSessionActionPayload(session).then(payload => { + this._telemetryService.publicLog2('agents/sessionMaximizeToggled', { + ...payload, + maximized, + }); + }); + } + private _logCreatePullRequest(session: ISession): void { void this._getSessionActionPayload(session).then(payload => { this._telemetryService.publicLog2('agents/createPullRequest', payload); @@ -247,12 +266,6 @@ export class SessionsTelemetryContribution extends Disposable implements IWorkbe }); } - private _logSessionMarkedAsDone(session: ISession): void { - void this._getSessionActionPayload(session).then(payload => { - this._telemetryService.publicLog2('agents/sessionMarkedAsDone', payload); - }); - } - private _logSessionRestored(session: ISession): void { void this._getSessionActionPayload(session).then(payload => { this._telemetryService.publicLog2('agents/sessionRestored', payload); @@ -958,23 +971,6 @@ type CommitAndSyncClassification = { sessionLinesDeleted: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of lines deleted across all changed files in the session at the time of the action.' }; }; -type SessionMarkedAsDoneClassification = { - owner: 'benibenj'; - comment: 'Reports when the user marks a session as done in the Agents window.'; - agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Globally unique session id (providerId:resourceUri), used to correlate events for the same session.' }; - providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The sessions provider identifier (e.g., remote agent host or local).' }; - providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; - chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; - isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; - workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; - hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; - isVirtualWorkspace: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the workspace URI uses a non-file scheme (virtual/remote).' }; - workspaceFileCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of files in the workspace (honoring user excludes); -1 if the workspace could not be scanned.' }; - sessionFilesChanged: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of files changed in the session at the time of the action.' }; - sessionLinesAdded: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of lines added across all changed files in the session at the time of the action.' }; - sessionLinesDeleted: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of lines deleted across all changed files in the session at the time of the action.' }; -}; - type SessionRestoredClassification = { owner: 'benibenj'; comment: 'Reports when the user restores a session in the Agents window.'; @@ -1162,3 +1158,73 @@ type FeedbackSubmittedClassification = { prReviewCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of feedback items being submitted that originated as PR review comments.' }; replyCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of replies across all feedback items being submitted.' }; }; + +// --- Events: sticky toggle / maximize toggle --- + +type SessionStickinessToggledEvent = { + agentSessionId: string; + providerId: string; + providerType: string; + chatCount: number; + isolationKind: SessionIsolationKind; + workspaceHash: string; + hasGitRepository: boolean; + isVirtualWorkspace: boolean; + workspaceFileCount: number; + sessionFilesChanged: number; + sessionLinesAdded: number; + sessionLinesDeleted: number; + sticky: boolean; +}; + +type SessionStickinessToggledClassification = { + owner: 'benibenj'; + comment: 'Reports when the user toggles a session\'s stickiness in the sessions grid in the Agents window.'; + agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Globally unique session id (providerId:resourceUri), used to correlate events for the same session.' }; + providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The sessions provider identifier (e.g., remote agent host or local).' }; + providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; + chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; + workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; + hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; + isVirtualWorkspace: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the workspace URI uses a non-file scheme (virtual/remote).' }; + workspaceFileCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of files in the workspace (honoring user excludes); -1 if the workspace could not be scanned.' }; + sessionFilesChanged: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of files changed in the session at the time of the action.' }; + sessionLinesAdded: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of lines added across all changed files in the session at the time of the action.' }; + sessionLinesDeleted: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of lines deleted across all changed files in the session at the time of the action.' }; + sticky: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session\'s stickiness state after the toggle: true when the session is now sticky, false when it was unstuck.' }; +}; + +type SessionMaximizeToggledEvent = { + agentSessionId: string; + providerId: string; + providerType: string; + chatCount: number; + isolationKind: SessionIsolationKind; + workspaceHash: string; + hasGitRepository: boolean; + isVirtualWorkspace: boolean; + workspaceFileCount: number; + sessionFilesChanged: number; + sessionLinesAdded: number; + sessionLinesDeleted: number; + maximized: boolean; +}; + +type SessionMaximizeToggledClassification = { + owner: 'benibenj'; + comment: 'Reports when the user toggles the maximized state of a session view in the sessions grid in the Agents window.'; + agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Globally unique session id (providerId:resourceUri), used to correlate events for the same session.' }; + providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The sessions provider identifier (e.g., remote agent host or local).' }; + providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; + chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; + workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; + hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; + isVirtualWorkspace: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the workspace URI uses a non-file scheme (virtual/remote).' }; + workspaceFileCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of files in the workspace (honoring user excludes); -1 if the workspace could not be scanned.' }; + sessionFilesChanged: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of files changed in the session at the time of the action.' }; + sessionLinesAdded: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of lines added across all changed files in the session at the time of the action.' }; + sessionLinesDeleted: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of lines deleted across all changed files in the session at the time of the action.' }; + maximized: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session view\'s maximized state after the toggle: true when the view is now maximized, false when it was restored.' }; +}; diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index 7b0bdb662dba3..18798eea55d2f 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -15,7 +15,7 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { ActiveSessionProviderIdContext, ActiveSessionTypeContext, IsActiveSessionArchivedContext, ActiveSessionWorkspaceIsVirtualContext, IsNewChatSessionContext } from '../../../common/contextkeys.js'; -import { ActiveSessionSupportsMultiChatContext, IActiveSession, ICreateNewSessionOptions, IProviderSessionType, ISendRequestSentEvent, ISessionsChangeEvent, ISessionsManagementService } from '../common/sessionsManagement.js'; +import { ActiveSessionSupportsMultiChatContext, IActiveSession, ICreateNewSessionOptions, IProviderSessionType, ISendRequestSentEvent, ISessionsChangeEvent, ISessionsManagementService, IToggleSessionStickinessEvent } from '../common/sessionsManagement.js'; import { ISessionsProvidersChangeEvent, ISessionsProvidersService } from './sessionsProvidersService.js'; import { ISendRequestOptions, ISessionChangeEvent, ISessionsProvider } from '../common/sessionsProvider.js'; import { IChat, ISession, ISessionWorkspace, SessionStatus, ISessionType } from '../common/session.js'; @@ -66,6 +66,9 @@ export class SessionsManagementService extends Disposable implements ISessionsMa private readonly _onDidRenameChat = this._register(new Emitter()); readonly onDidRenameChat: Event = this._onDidRenameChat.event; + private readonly _onDidToggleSessionStickiness = this._register(new Emitter()); + readonly onDidToggleSessionStickiness: Event = this._onDidToggleSessionStickiness.event; + private readonly _onDidChangeSessionTypes = this._register(new Emitter()); readonly onDidChangeSessionTypes: Event = this._onDidChangeSessionTypes.event; @@ -611,7 +614,8 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } toggleSessionStickiness(session: ISession): void { - this._visibility.toggleStickiness(session); + const sticky = this._visibility.toggleStickiness(session); + this._onDidToggleSessionStickiness.fire({ session, sticky }); } insertAt(session: ISession, targetSessionId: string, side: 'left' | 'right', activate: boolean = true): void { diff --git a/src/vs/sessions/services/sessions/browser/visibleSessions.ts b/src/vs/sessions/services/sessions/browser/visibleSessions.ts index 827c1ab2ba802..362fbd01c46ca 100644 --- a/src/vs/sessions/services/sessions/browser/visibleSessions.ts +++ b/src/vs/sessions/services/sessions/browser/visibleSessions.ts @@ -302,8 +302,10 @@ export class VisibleSessions extends Disposable { * slot when toggled. * - If the session is not currently visible, it is appended at the end as * sticky. + * + * Returns the session's stickiness state after the toggle. */ - toggleStickiness(session: ISession): void { + toggleStickiness(session: ISession): boolean { const id = session.sessionId; if (!this._visibleList.includes(id)) { this._stickyIds.add(id); @@ -319,6 +321,7 @@ export class VisibleSessions extends Disposable { } } this._refresh(undefined); + return this._stickyIds.has(id); } /** diff --git a/src/vs/sessions/services/sessions/common/sessionsManagement.ts b/src/vs/sessions/services/sessions/common/sessionsManagement.ts index 34b32b9a145cc..76b51651b4154 100644 --- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts +++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts @@ -62,6 +62,15 @@ export interface ISendRequestSentEvent { readonly options: ISendRequestOptions; } +/** + * Payload for {@link ISessionsManagementService.onDidToggleSessionStickiness}. + */ +export interface IToggleSessionStickinessEvent { + readonly session: ISession; + /** The session's stickiness state after the toggle. */ + readonly sticky: boolean; +} + /** * An active session extends {@link ISession} with the currently focused chat. */ @@ -153,6 +162,8 @@ export interface ISessionsManagementService { readonly onDidDeleteChat: Event; /** Fires after a chat was successfully renamed via {@link renameChat}. */ readonly onDidRenameChat: Event; + /** Fires after a session's stickiness was toggled via {@link toggleSessionStickiness}. */ + readonly onDidToggleSessionStickiness: Event; // -- Active Session -- diff --git a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts index ed6d7c223e481..3dba91ec80ba5 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts @@ -95,6 +95,7 @@ class MockSessionStore implements ISessionsManagementService { readonly onDidDeleteSession = Event.None; readonly onDidDeleteChat = Event.None; readonly onDidRenameChat = Event.None; + readonly onDidToggleSessionStickiness = Event.None; private readonly _sessions = new Map(); private _openedResource: URI | undefined; From 0d42e11e93a099ed1fae65672f6980c6bca4caaa Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 29 May 2026 15:43:01 -0700 Subject: [PATCH 20/27] chat (sessions): fix forking AHP chats so the new session opens with a "Forked: " title (#319064) Fixes two user-facing bugs when forking an agent-host (AHP) chat from the Agents window, and one underlying turn-id mismatch that made forks target the wrong SDK event boundary on restored sessions: - agentService: stamp "Forked: " onto the forked session's SessionSummary at create time so the sidebar (which renders from SessionSummary.title via sessionAdded / sessionSummaryChanged) shows the prefix instead of the auto-generated summary inherited from the source. Idempotent so re-forking an already-forked session does not double-prefix. - agentHostSessionHandler: read the forked title back from the freshly hydrated session state for the returned IChatSessionItem and drop the client-side SessionTitleChanged dispatch. - chatForkActions: route the contributed-session fork path through ForkConversationAction._openForkedSession so the Agents-window override applies; inlines the helper to avoid a callback hop. - localChatSessions.contribution: in the Agents-window override, wait for the forked resource to appear and then open it via sessionsManagementService.openSession for every session type (not just the local VS Code chat type) -- the previous fallback called chatWidgetService.openSession, which is a no-op in the Agents window because there is no ChatViewPane. - mapSessionEvents: when restoring a session from disk, seed the protocol turn id from the SDK envelope id (the same value setTurnEventId persists to turns.event_id). This makes the restored state.turns[].id round-trip back to the SDK boundary id that the sessions.fork / history.truncate RPCs operate on. - sessionDatabase: getNextTurnEventId now resolves the source row by either turns.id (live request_xxx) OR turns.event_id (SDK envelope id), so fork works for both freshly-dispatched and restored turn ids. Fixes https://github.com/microsoft/vscode/issues/317839 (Commit message generated by Copilot) --- .../platform/agentHost/node/agentService.ts | 12 ++++- .../node/copilot/mapSessionEvents.ts | 22 ++++++++- .../agentHost/node/sessionDatabase.ts | 12 ++++- .../test/node/copilotAgentSession.test.ts | 43 ++++++++++++++++- .../test/node/sessionDatabase.test.ts | 47 +++++++++++++++++++ .../browser/localChatSessions.contribution.ts | 12 ++--- .../chat/browser/actions/chatForkActions.ts | 38 +++++++-------- .../agentHost/agentHostSessionHandler.ts | 7 +-- 8 files changed, 158 insertions(+), 35 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 7d985a12797c7..fec7894c746b0 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -15,6 +15,7 @@ import { observableValue } from '../../../base/common/observable.js'; import { extname as resourcesExtname, isEqual, isEqualOrParent, joinPath } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; +import { localize } from '../../../nls.js'; import { FileChangeType, FileOperationError, FileOperationResult, FileSystemProviderErrorCode, IFileChange, IFileService, toFileSystemProviderErrorCode, type FileChangesEvent } from '../../files/common/files.js'; import { InstantiationService } from '../../instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; @@ -492,7 +493,16 @@ export class AgentService extends Disposable implements IAgentService { .map(t => ({ ...t, id: config!.fork!.turnIdMapping!.get(t.id) ?? generateUuid() })); } - const summary = this._buildInitialSummary(provider, session, config, created, sourceState?.summary.title ?? 'Forked Session'); + // Prefix the forked session's title so consumers (sidebar, chat + // model) can distinguish it from the source without each surface + // reinventing the convention. Avoid double-prefixing when a user + // forks an already-forked session. + const forkedTitlePrefix = localize('agentHost.forkedTitlePrefix', "Forked: "); + const sourceTitle = sourceState?.summary.title; + const forkedTitle = sourceTitle + ? (sourceTitle.startsWith(forkedTitlePrefix) ? sourceTitle : `${forkedTitlePrefix}${sourceTitle}`) + : localize('agentHost.forkedSessionFallback', "Forked Session"); + const summary = this._buildInitialSummary(provider, session, config, created, forkedTitle); const state = this._stateManager.createSession(summary); state.config = sessionConfig; state.turns = sourceTurns; diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts index f9241ae1cfda2..cf3f066ae75a2 100644 --- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts @@ -55,6 +55,13 @@ export interface ISessionEventToolComplete { export interface ISessionEventMessage { type: 'assistant.message' | 'user.message'; + /** + * SDK envelope-level event id. This is the same id `setTurnEventId` + * persists into `turns.event_id`, so using it as the protocol turn id + * keeps the live and restored ids aligned with what the SDK fork / + * truncate RPCs need. + */ + id?: string; data?: { messageId?: string; interactionId?: string; @@ -351,10 +358,15 @@ export async function mapSessionEvents( } } else { // A new top-level user message starts a new parent turn. + // Use the SDK envelope id (the same value + // `setTurnEventId` records as `event_id`) so the restored + // turn id round-trips back to the SDK boundary id that + // fork / truncate RPCs operate on. if (parentBuilder) { turns.push(finalizeTurn(parentBuilder, TurnState.Cancelled)); } - parentBuilder = newTurnBuilder(messageId, content, attachments); + const turnId = (e as ISessionEventMessage).id ?? messageId; + parentBuilder = newTurnBuilder(turnId, content, attachments); } break; } @@ -364,8 +376,14 @@ export async function mapSessionEvents( const content = d?.content ?? ''; const reasoningText = d?.reasoningText; const hasToolRequests = !!d?.toolRequests && d.toolRequests.length > 0; + // When this is the first event in a turn (no parent builder + // yet), seed the builder with the SDK envelope id so the + // turn id matches `turns.event_id` for fork/truncate + // lookups. See the matching note in the `user.message` + // branch above. + const fallbackTurnId = (e as ISessionEventMessage).id ?? messageId; const builder = targetBuilderFor(d?.parentToolCallId) - ?? (parentBuilder = newTurnBuilder(messageId, '')); + ?? (parentBuilder = newTurnBuilder(fallbackTurnId, '')); if (reasoningText) { builder.responseParts.push({ kind: ResponsePartKind.Reasoning, diff --git a/src/vs/platform/agentHost/node/sessionDatabase.ts b/src/vs/platform/agentHost/node/sessionDatabase.ts index 292765df9d5a5..90bde28e3a7e2 100644 --- a/src/vs/platform/agentHost/node/sessionDatabase.ts +++ b/src/vs/platform/agentHost/node/sessionDatabase.ts @@ -312,9 +312,19 @@ export class SessionDatabase implements ISessionDatabase { async getNextTurnEventId(turnId: string): Promise { const db = await this._ensureDb(); + // `turns.id` is the canonical turn key — either a live `request_xxx` + // dispatched by the client or, for sessions restored from disk, the + // SDK envelope id surfaced by `mapSessionEvents`. The `event_id` + // fallback covers the case where the caller asks about a turn that + // was set up live (id=`request_xxx`) but is now being referenced + // via the SDK event id, or vice versa. const row = await dbGet( db, - `SELECT event_id FROM turns WHERE rowid > (SELECT rowid FROM turns WHERE id = ?) ORDER BY rowid LIMIT 1`, + `SELECT event_id FROM turns + WHERE rowid > ( + SELECT rowid FROM turns WHERE id = ?1 OR event_id = ?1 LIMIT 1 + ) + ORDER BY rowid LIMIT 1`, [turnId], ); return row?.event_id as string | undefined ?? undefined; diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index ec6c8c710ab7a..b2e8a8b155403 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -383,7 +383,7 @@ suite('CopilotAgentSession', () => { }]; assert.deepStrictEqual(await session.getMessages(), [{ - id: 'message-1', + id: 'event-1', userMessage: { text: '/act-on-feedback', attachments: [expectedAttachment], @@ -1772,6 +1772,47 @@ suite('CopilotAgentSession', () => { { parentToolCallId: 'tc-subagent', toolCallId: 'tc-child-tool' }, ]); }); + + test('history replay seeds turn id from the SDK envelope id, matching `turns.event_id`', async () => { + // Regression test: fork / truncate look up the SDK boundary + // event id via `getNextTurnEventId(turnId)`, which keys on + // either `turns.id` (live `request_xxx`) or `turns.event_id` + // (SDK envelope id). For sessions restored from disk we want + // the restored turn id to be the SDK envelope id so that + // lookup succeeds without translation. + const { session, mockSession } = await createAgentSession(disposables); + mockSession.getEvents = async () => [ + { + type: 'user.message', + id: 'sdk-evt-user-1', + data: { interactionId: 'capi-interaction-1', content: 'first prompt' }, + }, + { + type: 'assistant.message', + id: 'sdk-evt-asst-1', + data: { messageId: 'sdk-msg-1', content: 'first response.' }, + }, + { + type: 'user.message', + id: 'sdk-evt-user-2', + data: { interactionId: 'capi-interaction-2', content: 'second prompt' }, + }, + { + type: 'assistant.message', + id: 'sdk-evt-asst-2', + data: { messageId: 'sdk-msg-2', content: 'second response.' }, + }, + ] as SessionEvent[]; + + const turns = await session.getMessages(); + assert.deepStrictEqual( + turns.map(t => ({ id: t.id, text: t.userMessage.text })), + [ + { id: 'sdk-evt-user-1', text: 'first prompt' }, + { id: 'sdk-evt-user-2', text: 'second prompt' }, + ], + ); + }); }); // ---- user input handling ---- diff --git a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts index 1298b2db45bf3..b12f658b941ba 100644 --- a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts @@ -401,6 +401,53 @@ suite('SessionDatabase', () => { }); }); + // ---- Turn event ids ------------------------------------------------- + + suite('turn event ids', () => { + + test('getNextTurnEventId returns the next turn\'s event id by `turns.id`', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + await db.createTurn('turn-1'); + await db.createTurn('turn-2'); + await db.setTurnEventId('turn-1', 'evt-1'); + await db.setTurnEventId('turn-2', 'evt-2'); + + assert.strictEqual(await db.getNextTurnEventId('turn-1'), 'evt-2'); + }); + + test('getNextTurnEventId falls back to `event_id` when the key is the SDK event id', async () => { + // Sessions restored from disk surface SDK envelope ids as the + // protocol turn id (see mapSessionEvents.ts), but `turns.id` + // was populated live with the client-side `request_xxx` id. + // The fallback lets fork / truncate resolve the boundary + // without forcing every caller to translate. + db = disposables.add(await SessionDatabase.open(':memory:')); + await db.createTurn('request_aaa'); + await db.createTurn('request_bbb'); + await db.setTurnEventId('request_aaa', 'sdk-evt-1'); + await db.setTurnEventId('request_bbb', 'sdk-evt-2'); + + assert.strictEqual(await db.getNextTurnEventId('sdk-evt-1'), 'sdk-evt-2'); + }); + + test('getNextTurnEventId returns undefined for the last turn', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + await db.createTurn('turn-1'); + await db.setTurnEventId('turn-1', 'evt-1'); + + assert.strictEqual(await db.getNextTurnEventId('turn-1'), undefined); + assert.strictEqual(await db.getNextTurnEventId('evt-1'), undefined); + }); + + test('getNextTurnEventId returns undefined for an unknown key', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + await db.createTurn('turn-1'); + await db.setTurnEventId('turn-1', 'evt-1'); + + assert.strictEqual(await db.getNextTurnEventId('does-not-exist'), undefined); + }); + }); + // ---- Dispose -------------------------------------------------------- suite('dispose', () => { diff --git a/src/vs/sessions/contrib/providers/localChatSessions/browser/localChatSessions.contribution.ts b/src/vs/sessions/contrib/providers/localChatSessions/browser/localChatSessions.contribution.ts index c9bc79984c5d7..a72d3d23c0c13 100644 --- a/src/vs/sessions/contrib/providers/localChatSessions/browser/localChatSessions.contribution.ts +++ b/src/vs/sessions/contrib/providers/localChatSessions/browser/localChatSessions.contribution.ts @@ -6,7 +6,7 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; -import { LocalChatSessionsProvider, LOCAL_SESSION_ENABLED_SETTING, LocalSessionType } from './localChatSessionsProvider.js'; +import { LocalChatSessionsProvider, LOCAL_SESSION_ENABLED_SETTING } from './localChatSessionsProvider.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../../platform/configuration/common/configurationRegistry.js'; @@ -67,12 +67,10 @@ registerAction2(class extends ForkConversationAction { return super._openForkedSession(instantiationService, parentSessionResource, forkedSessionResource); } - if (parentSession.sessionType !== LocalSessionType.id) { - return super._openForkedSession(instantiationService, parentSessionResource, forkedSessionResource); - } - - // Local sessions — wait for the forked session to appear, but - // bound the wait so a missing session does not hang forever. + // Wait for the forked session to appear, but bound the wait so a + // missing session does not hang forever. Applies to local and + // contributed (agent-host) sessions alike — both surface via + // `sessionsManagementService` in the Agents window. if (!sessionsManagementService.getSession(forkedSessionResource)) { let listener: IDisposable | undefined; const appeared = await raceTimeout(new Promise(resolve => { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts index 860e5dada9858..35fde02e75820 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts @@ -64,7 +64,7 @@ export class ForkConversationAction extends Action2 { // Check if this is a contributed session that supports forking const contentProviderSchemes = chatSessionsService.getContentProviderSchemes(); if (contentProviderSchemes.includes(getChatSessionType(sourceSessionResource))) { - return await this.forkContributedChatSession(sourceSessionResource, undefined, false, chatSessionsService, chatWidgetService); + return await this.forkContributedChatSession(sourceSessionResource, undefined, false, chatSessionsService, instantiationService); } const chatModel = chatService.getSession(sourceSessionResource); @@ -163,7 +163,7 @@ export class ForkConversationAction extends Action2 { } } } - return await this.forkContributedChatSession(sessionResource, request, true, chatSessionsService, chatWidgetService); + return await this.forkContributedChatSession(sessionResource, request, true, chatSessionsService, instantiationService); } const chatModel = chatService.getSession(sessionResource); @@ -241,14 +241,28 @@ export class ForkConversationAction extends Action2 { private pendingFork = new Map>(); - private async forkContributedChatSession(sourceSessionResource: URI, request: IChatSessionRequestHistoryItem | undefined, openForkedSessionImmediately: boolean, chatSessionsService: IChatSessionsService, chatWidgetService: IChatWidgetService) { + private async forkContributedChatSession(sourceSessionResource: URI, request: IChatSessionRequestHistoryItem | undefined, openForkedSessionImmediately: boolean, chatSessionsService: IChatSessionsService, instantiationService: IInstantiationService) { const pendingKey = `${sourceSessionResource.toString()}@${request?.id ?? 'full'}`; const pending = this.pendingFork.get(pendingKey); if (pending) { return pending; } - const forkPromise = forkContributedChatSession(sourceSessionResource, request, openForkedSessionImmediately, chatSessionsService, chatWidgetService); + const forkPromise = (async () => { + const cts = new CancellationTokenSource(); + try { + const forkedItem = await chatSessionsService.forkChatSession(sourceSessionResource, request, cts.token); + const open = () => this._openForkedSession(instantiationService, sourceSessionResource, forkedItem.resource); + if (openForkedSessionImmediately) { + await open(); + } else { + setTimeout(open, 0); + } + } finally { + cts.dispose(); + } + })(); + this.pendingFork.set(pendingKey, forkPromise); try { await forkPromise; @@ -257,19 +271,3 @@ export class ForkConversationAction extends Action2 { } } } - -async function forkContributedChatSession(sourceSessionResource: URI, request: IChatSessionRequestHistoryItem | undefined, openForkedSessionImmediately: boolean, chatSessionsService: IChatSessionsService, chatWidgetService: IChatWidgetService) { - const cts = new CancellationTokenSource(); - try { - const forkedItem = await chatSessionsService.forkChatSession(sourceSessionResource, request, cts.token); - if (openForkedSessionImmediately) { - await chatWidgetService.openSession(forkedItem.resource, ChatViewPaneTarget); - } else { - setTimeout(async () => { - await chatWidgetService.openSession(forkedItem.resource, ChatViewPaneTarget); - }, 0); - } - } finally { - cts.dispose(); - } -} 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 4c9cccffe3679..336c02ce03c62 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -2453,11 +2453,12 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const forkedResource = URI.from({ scheme: this._config.sessionType, path: `/${forkedRawId}` }); const now = Date.now(); + const forkedTitle = this._getSessionState(forkedSession.toString())?.summary.title; + const forkedLabel = forkedTitle || chatModel?.title || localize('agentHost.forkedSessionLabel', "Forked Session"); + return { resource: forkedResource, - label: chatModel?.title - ? localize('chat.forked.title', "Forked: {0}", chatModel.title) - : localize('chat.forked.fallbackTitle', "Forked Session"), + label: forkedLabel, iconPath: getAgentHostIcon(this._productService), timing: { created: now, lastRequestStarted: now, lastRequestEnded: now }, }; From 72951445351acab7423a1b23ba370e2bd483cc74 Mon Sep 17 00:00:00 2001 From: Paul Date: Fri, 29 May 2026 15:51:09 -0700 Subject: [PATCH 21/27] Suggest upgrade if out of additional budget (#319055) --- .../extension/prompt/node/chatMLFetcher.ts | 4 +- .../src/platform/chat/common/commonTypes.ts | 5 +- .../chat/test/common/commonTypes.spec.ts | 125 ++++++++++ .../chatContentParts/chatQuotaExceededPart.ts | 27 +- .../browser/chatQuotaExceededPart.test.ts | 232 ++++++++++++++++++ 5 files changed, 379 insertions(+), 14 deletions(-) create mode 100644 extensions/copilot/src/platform/chat/test/common/commonTypes.spec.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/chatQuotaExceededPart.test.ts diff --git a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts index 102fd568fc568..7564482a989f9 100644 --- a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts +++ b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts @@ -2148,7 +2148,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { data: { capiError }, }; } - if (codePrefix === 'quota_exceeded' || codePrefix === 'free_quota_exceeded' || codePrefix === 'overage_limit_reached' || codePrefix === 'billing_not_configured') { + if (codePrefix === 'quota_exceeded' || codePrefix === 'free_quota_exceeded' || codePrefix === 'overage_limit_reached' || codePrefix === 'billing_not_configured' || codePrefix === 'additional_spend_limit_reached') { // Refresh the copilot token so isChatQuotaExceeded reflects the new state, // matching the HTTP 402 handler behavior. if (!this._authenticationService.copilotToken?.isChatQuotaExceeded) { @@ -2221,7 +2221,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { if (codePrefix === 'rate_limited' || codePrefix === 'user_model_rate_limited' || codePrefix === 'user_global_rate_limited' || codePrefix === 'integration_rate_limited' || codePrefix === 'model_overloaded' || codePrefix === 'agent_mode_limit_exceeded') { return { type: ChatFetchResponseType.RateLimited, reason: message, requestId, serverRequestId, retryAfter: undefined, rateLimitKey: '', isAuto, capiError }; } - if (codePrefix === 'quota_exceeded' || codePrefix === 'free_quota_exceeded' || codePrefix === 'overage_limit_reached' || codePrefix === 'billing_not_configured') { + if (codePrefix === 'quota_exceeded' || codePrefix === 'free_quota_exceeded' || codePrefix === 'overage_limit_reached' || codePrefix === 'billing_not_configured' || codePrefix === 'additional_spend_limit_reached') { return { type: ChatFetchResponseType.QuotaExceeded, reason: message, requestId, serverRequestId, capiError, retryAfter: undefined }; } if (code === 'content_filter') { diff --git a/extensions/copilot/src/platform/chat/common/commonTypes.ts b/extensions/copilot/src/platform/chat/common/commonTypes.ts index 5e535b5fceebc..938d281e5419e 100644 --- a/extensions/copilot/src/platform/chat/common/commonTypes.ts +++ b/extensions/copilot/src/platform/chat/common/commonTypes.ts @@ -386,6 +386,8 @@ function getQuotaHitMessage(fetchResult: ChatFetchError, copilotPlan: string | u args: ['https://support.github.com/contact'], comment: [`{Locked=']({'}`] }); + } else if (fetchResult.capiError?.code === 'additional_spend_limit_reached') { + return l10n.t(`You've reached your additional usage limit for your plan. Upgrade your plan to keep going.`); } else if (fetchResult.capiError?.code === 'billing_not_configured' && fetchResult.capiError?.message) { return fetchResult.capiError.message; } else if (fetchResult.capiError?.code && fetchResult.capiError?.message) { @@ -422,7 +424,8 @@ function getErrorDetailsFromChatFetchErrorInner(fetchResult: ChatFetchError, cop case ChatFetchResponseType.QuotaExceeded: details = { message: getQuotaHitMessage(fetchResult, copilotPlan, isUsageBasedBilling, quotaResetDate), - isQuotaExceeded: true + isQuotaExceeded: true, + ...(fetchResult.capiError?.code && { code: fetchResult.capiError.code }), }; break; case ChatFetchResponseType.BadRequest: diff --git a/extensions/copilot/src/platform/chat/test/common/commonTypes.spec.ts b/extensions/copilot/src/platform/chat/test/common/commonTypes.spec.ts new file mode 100644 index 0000000000000..f47059cc7aec9 --- /dev/null +++ b/extensions/copilot/src/platform/chat/test/common/commonTypes.spec.ts @@ -0,0 +1,125 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, test } from 'vitest'; +import { ChatFetchResponseType, getErrorDetailsFromChatFetchError, type ChatFetchError } from '../../common/commonTypes'; +import { GitHubOutageStatus } from '../../../github/common/githubService'; + +function makeQuotaExceededError(capiError?: { code?: string; message?: string }): ChatFetchError { + return { + type: ChatFetchResponseType.QuotaExceeded, + reason: 'quota exceeded', + requestId: 'req-1', + serverRequestId: 'srv-1', + retryAfter: undefined, + capiError, + }; +} + +describe('getErrorDetailsFromChatFetchError', () => { + describe('QuotaExceeded with additional_spend_limit_reached', () => { + test('returns upgrade message and additional_spend_limit_reached code', () => { + const result = getErrorDetailsFromChatFetchError( + makeQuotaExceededError({ code: 'additional_spend_limit_reached', message: 'Spend limit reached' }), + 'individual', + GitHubOutageStatus.None, + ); + + expect(result.isQuotaExceeded).toBe(true); + expect(result.code).toBe('additional_spend_limit_reached'); + expect(result.message).toContain('additional usage limit'); + expect(result.message).toContain('Upgrade'); + }); + }); + + describe('QuotaExceeded with quota_exceeded', () => { + test('returns per-plan message for individual plan', () => { + const result = getErrorDetailsFromChatFetchError( + makeQuotaExceededError({ code: 'quota_exceeded', message: 'Quota exceeded' }), + 'individual', + GitHubOutageStatus.None, + ); + + expect(result.isQuotaExceeded).toBe(true); + expect(result.message).toContain('premium model quota'); + }); + + test('returns per-plan message for free plan', () => { + const result = getErrorDetailsFromChatFetchError( + makeQuotaExceededError({ code: 'quota_exceeded', message: 'Quota exceeded' }), + 'free', + GitHubOutageStatus.None, + ); + + expect(result.isQuotaExceeded).toBe(true); + expect(result.message).toContain('monthly chat messages quota'); + }); + }); + + describe('QuotaExceeded with free_quota_exceeded', () => { + test('remaps to quota_exceeded and returns per-plan message', () => { + const result = getErrorDetailsFromChatFetchError( + makeQuotaExceededError({ code: 'free_quota_exceeded', message: 'Free quota exceeded' }), + 'free', + GitHubOutageStatus.None, + ); + + expect(result.isQuotaExceeded).toBe(true); + expect(result.message).toContain('monthly chat messages quota'); + }); + }); + + describe('QuotaExceeded with overage_limit_reached', () => { + test('returns support contact message', () => { + const result = getErrorDetailsFromChatFetchError( + makeQuotaExceededError({ code: 'overage_limit_reached', message: 'Overage limit reached' }), + 'individual', + GitHubOutageStatus.None, + ); + + expect(result.isQuotaExceeded).toBe(true); + expect(result.message).toContain('GitHub Support'); + }); + }); + + describe('QuotaExceeded without CAPI error code', () => { + test('preserves fetchResult.type as code when no capiError code', () => { + const result = getErrorDetailsFromChatFetchError( + makeQuotaExceededError(), + 'individual', + GitHubOutageStatus.None, + ); + + expect(result.isQuotaExceeded).toBe(true); + expect(result.code).toBe(ChatFetchResponseType.QuotaExceeded); + }); + + test('preserves fetchResult.type as code when capiError has no code', () => { + const result = getErrorDetailsFromChatFetchError( + makeQuotaExceededError({ message: 'Some message' }), + 'individual', + GitHubOutageStatus.None, + ); + + expect(result.isQuotaExceeded).toBe(true); + expect(result.code).toBe(ChatFetchResponseType.QuotaExceeded); + }); + }); + + describe('QuotaExceeded with unknown CAPI error code', () => { + test('shows server error message with unknown code', () => { + const result = getErrorDetailsFromChatFetchError( + makeQuotaExceededError({ code: 'unknown_error', message: 'Something went wrong' }), + 'individual', + GitHubOutageStatus.None, + ); + + expect(result.isQuotaExceeded).toBe(true); + expect(result.code).toBe('unknown_error'); + expect(result.message).toContain('Something went wrong'); + expect(result.message).toContain('unknown_error'); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuotaExceededPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuotaExceededPart.ts index d890c82de9e2f..907ec621f151a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuotaExceededPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuotaExceededPart.ts @@ -62,17 +62,22 @@ export class ChatQuotaExceededPart extends Disposable implements IChatContentPar const markdownContent = this._register(renderer.render(new MarkdownString(errorDetails.message))); dom.append(messageContainer, markdownContent.element); + const isAdditionalSpendLimitReached = errorDetails.code === 'additional_spend_limit_reached'; let primaryButtonLabel: string | undefined; - switch (chatEntitlementService.entitlement) { - case ChatEntitlement.EDU: - case ChatEntitlement.Pro: - case ChatEntitlement.ProPlus: - case ChatEntitlement.Max: - primaryButtonLabel = localize('manageBudget', "Manage Budget"); - break; - case ChatEntitlement.Free: - primaryButtonLabel = localize('upgradeToCopilotPro', "Upgrade to GitHub Copilot Pro"); - break; + if (isAdditionalSpendLimitReached) { + primaryButtonLabel = localize('upgradePlan', "Upgrade"); + } else { + switch (chatEntitlementService.entitlement) { + case ChatEntitlement.EDU: + case ChatEntitlement.Pro: + case ChatEntitlement.ProPlus: + case ChatEntitlement.Max: + primaryButtonLabel = localize('manageBudget', "Manage Budget"); + break; + case ChatEntitlement.Free: + primaryButtonLabel = localize('upgradeToCopilotPro', "Upgrade to GitHub Copilot Pro"); + break; + } } let hasAddedWaitWarning = false; @@ -118,7 +123,7 @@ export class ChatQuotaExceededPart extends Disposable implements IChatContentPar primaryButton.element.classList.add('chat-quota-error-button'); this._register(primaryButton.onDidClick(async () => { - const commandId = chatEntitlementService.entitlement === ChatEntitlement.Free ? 'workbench.action.chat.upgradePlan' : 'workbench.action.chat.manageAdditionalSpend'; + const commandId = chatEntitlementService.entitlement === ChatEntitlement.Free || isAdditionalSpendLimitReached ? 'workbench.action.chat.upgradePlan' : 'workbench.action.chat.manageAdditionalSpend'; telemetryService.publicLog2('workbenchActionExecuted', { id: commandId, from: 'chat-response' }); await commandService.executeCommand(commandId); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatQuotaExceededPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatQuotaExceededPart.test.ts new file mode 100644 index 0000000000000..4579ee29068a8 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/chatQuotaExceededPart.test.ts @@ -0,0 +1,232 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { mainWindow } from '../../../../../base/browser/window.js'; +import { Event } from '../../../../../base/common/event.js'; +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IMarkdownRenderer } from '../../../../../platform/markdown/browser/markdownRenderer.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { ChatEntitlement, IChatEntitlementService, IChatSentiment } from '../../../../services/chat/common/chatEntitlementService.js'; +import { IChatResponseErrorDetails } from '../../common/chatService/chatService.js'; +import { IChatErrorDetailsPart, IChatResponseViewModel } from '../../common/model/chatViewModel.js'; +import { IChatWidgetService } from '../../browser/chat.js'; +import { ChatQuotaExceededPart } from '../../browser/widget/chatContentParts/chatQuotaExceededPart.js'; + + +function createMockEntitlementService(entitlement: ChatEntitlement): IChatEntitlementService { + return { + _serviceBrand: undefined, + entitlement, + entitlementObs: observableValue({}, entitlement), + onDidChangeEntitlement: Event.None, + onDidChangeQuotaExceeded: Event.None, + onDidChangeQuotaRemaining: Event.None, + onDidChangeUsageBasedBilling: Event.None, + quotas: {}, + organisations: undefined, + isInternal: false, + sku: undefined, + copilotTrackingId: undefined, + previewFeaturesDisabled: false, + clientByokEnabled: false, + hasByokModels: false, + onDidChangeSentiment: Event.None, + sentiment: {} as IChatSentiment, + sentimentObs: observableValue({}, {} as IChatSentiment), + onDidChangeAnonymous: Event.None, + anonymous: false, + anonymousObs: observableValue({}, false), + acceptQuotas() { }, + clearQuotas() { }, + markAnonymousRateLimited() { }, + markSetupCompleted() { }, + setForceHidden() { }, + update() { return Promise.resolve(); }, + } as IChatEntitlementService; +} + +function createMockRenderer(): IMarkdownRenderer { + return { + render(markdown: MarkdownString) { + const el = mainWindow.document.createElement('div'); + el.textContent = markdown.value; + return { element: el, dispose() { } }; + }, + dispose() { }, + } as unknown as IMarkdownRenderer; +} + +function createMockElement(errorDetails: IChatResponseErrorDetails): IChatResponseViewModel { + return { + errorDetails, + sessionResource: URI.parse('test://session'), + } as unknown as IChatResponseViewModel; +} + +function createMockContent(): IChatErrorDetailsPart { + return { + kind: 'errorDetails', + errorDetails: { message: 'test', isQuotaExceeded: true }, + isLast: true, + }; +} + +suite('ChatQuotaExceededPart', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + let executedCommands: string[]; + + function createWidget(entitlement: ChatEntitlement, errorDetails: IChatResponseErrorDetails): ChatQuotaExceededPart { + executedCommands = []; + + const chatWidgetService = {} as IChatWidgetService; + const commandService = { + executeCommand(id: string) { + executedCommands.push(id); + return Promise.resolve(); + }, + } as unknown as ICommandService; + const telemetryService = { + publicLog2() { }, + } as unknown as ITelemetryService; + const entitlementService = createMockEntitlementService(entitlement); + const renderer = createMockRenderer(); + + const element = createMockElement(errorDetails); + const content = createMockContent(); + + const widget = new ChatQuotaExceededPart( + element, + content, + renderer, + chatWidgetService, + commandService, + telemetryService, + entitlementService, + ); + store.add(widget); + mainWindow.document.body.appendChild(widget.domNode); + return widget; + } + + function getPrimaryButton(widget: ChatQuotaExceededPart): HTMLElement | null { + return widget.domNode.querySelector('.chat-quota-error-button'); + } + + teardown(() => { + for (const el of mainWindow.document.body.querySelectorAll('.chat-quota-error-widget')) { + el.remove(); + } + }); + + suite('button label', () => { + test('shows "Manage Budget" for Pro user without additional_spend_limit_reached', () => { + const widget = createWidget(ChatEntitlement.Pro, { + message: 'Quota exceeded', + isQuotaExceeded: true, + }); + + const button = getPrimaryButton(widget); + assert.ok(button); + assert.strictEqual(button.textContent, 'Manage Budget'); + }); + + test('shows "Upgrade to GitHub Copilot Pro" for Free user', () => { + const widget = createWidget(ChatEntitlement.Free, { + message: 'Quota exceeded', + isQuotaExceeded: true, + }); + + const button = getPrimaryButton(widget); + assert.ok(button); + assert.strictEqual(button.textContent, 'Upgrade to GitHub Copilot Pro'); + }); + + test('shows "Upgrade" for Pro user with additional_spend_limit_reached', () => { + const widget = createWidget(ChatEntitlement.Pro, { + message: 'Spend limit reached', + isQuotaExceeded: true, + code: 'additional_spend_limit_reached', + }); + + const button = getPrimaryButton(widget); + assert.ok(button); + assert.strictEqual(button.textContent, 'Upgrade'); + }); + + test('shows "Upgrade" for ProPlus user with additional_spend_limit_reached', () => { + const widget = createWidget(ChatEntitlement.ProPlus, { + message: 'Spend limit reached', + isQuotaExceeded: true, + code: 'additional_spend_limit_reached', + }); + + const button = getPrimaryButton(widget); + assert.ok(button); + assert.strictEqual(button.textContent, 'Upgrade'); + }); + + test('shows "Manage Budget" for EDU user without additional_spend_limit_reached', () => { + const widget = createWidget(ChatEntitlement.EDU, { + message: 'Quota exceeded', + isQuotaExceeded: true, + }); + + const button = getPrimaryButton(widget); + assert.ok(button); + assert.strictEqual(button.textContent, 'Manage Budget'); + }); + }); + + suite('button command', () => { + test('Pro user clicks "Manage Budget" -> manageAdditionalSpend', async () => { + const widget = createWidget(ChatEntitlement.Pro, { + message: 'Quota exceeded', + isQuotaExceeded: true, + }); + + const button = getPrimaryButton(widget); + assert.ok(button); + button.click(); + await new Promise(r => setTimeout(r, 0)); + + assert.strictEqual(executedCommands[0], 'workbench.action.chat.manageAdditionalSpend'); + }); + + test('Free user clicks "Upgrade" -> upgradePlan', async () => { + const widget = createWidget(ChatEntitlement.Free, { + message: 'Quota exceeded', + isQuotaExceeded: true, + }); + + const button = getPrimaryButton(widget); + assert.ok(button); + button.click(); + await new Promise(r => setTimeout(r, 0)); + + assert.strictEqual(executedCommands[0], 'workbench.action.chat.upgradePlan'); + }); + + test('Pro user with additional_spend_limit_reached clicks "Upgrade" -> upgradePlan', async () => { + const widget = createWidget(ChatEntitlement.Pro, { + message: 'Spend limit reached', + isQuotaExceeded: true, + code: 'additional_spend_limit_reached', + }); + + const button = getPrimaryButton(widget); + assert.ok(button); + button.click(); + await new Promise(r => setTimeout(r, 0)); + + assert.strictEqual(executedCommands[0], 'workbench.action.chat.upgradePlan'); + }); + }); +}); From e1a9625b76b5460e3779a90596949bdfe29b8627 Mon Sep 17 00:00:00 2001 From: dileepyavan <52841896+dileepyavan@users.noreply.github.com> Date: Fri, 29 May 2026 16:02:29 -0700 Subject: [PATCH 22/27] [Windows_Sandboxing]Refactoring config creation for windows sandboxing and upgrading mxc (#318865) * refactoring config creation for windows sandboxing and upgrading mxc package * Avoid MXC SDK imports in shared sandbox contract * fixing tests --- package-lock.json | 8 +- package.json | 2 +- remote/package-lock.json | 8 +- remote/package.json | 2 +- .../node/copilot/agentHostSandboxEngine.ts | 6 +- .../test/node/copilotShellTools.test.ts | 14 +++ .../sandbox/browser/sandboxHelperService.ts | 6 +- .../sandbox/common/sandboxHelperIpc.ts | 18 ++- .../sandbox/common/sandboxHelperService.ts | 75 +++++++++++ src/vs/platform/sandbox/common/settings.ts | 1 + .../sandbox/common/terminalSandboxEngine.ts | 22 +++- .../common/terminalSandboxMxcRuntime.ts | 117 +++++++----------- src/vs/platform/sandbox/node/sandboxHelper.ts | 29 +++-- .../test/common/terminalSandboxEngine.test.ts | 89 +++++++++++-- .../common/sandboxSettingsReader.ts | 1 + .../terminalChatAgentToolsConfiguration.ts | 6 + .../common/terminalSandboxService.ts | 14 ++- .../browser/terminalSandboxService.test.ts | 51 +++++++- 18 files changed, 347 insertions(+), 122 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9b0ad2ad0e0ea..5f916fa8db113 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "@microsoft/dev-tunnels-management": "^1.3.41", "@microsoft/dev-tunnels-ssh": "^3.12.22", "@microsoft/dev-tunnels-ssh-tcp": "^3.12.22", - "@microsoft/mxc-sdk": "0.2.1", + "@microsoft/mxc-sdk": "0.3.0", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", "@vscode/codicons": "^0.0.46-14", @@ -2093,9 +2093,9 @@ "integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ==" }, "node_modules/@microsoft/mxc-sdk": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@microsoft/mxc-sdk/-/mxc-sdk-0.2.1.tgz", - "integrity": "sha512-1dL42Abc1ocapZR01aPeSEcvuzWuvOslmWNZvdYs6+yTVqAnpWrMk+aFf0Odry9SqJbcW9FABYzPlFtJW6clAQ==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@microsoft/mxc-sdk/-/mxc-sdk-0.3.0.tgz", + "integrity": "sha512-eAjVfS4+RdG03Wh/DemgaMmjkuTGPDDNR3xRxkRLRs6lCpezNZq8OdNLjCy4N9cKr/H9mlpYAwPQUs07/+BywA==", "license": "MIT", "dependencies": { "node-pty": "^1.2.0-beta.12", diff --git a/package.json b/package.json index 69ed8e96d54ba..7c5d7b20a8ffb 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,7 @@ "@microsoft/dev-tunnels-management": "^1.3.41", "@microsoft/dev-tunnels-ssh": "^3.12.22", "@microsoft/dev-tunnels-ssh-tcp": "^3.12.22", - "@microsoft/mxc-sdk": "0.2.1", + "@microsoft/mxc-sdk": "0.3.0", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", "@vscode/codicons": "^0.0.46-14", diff --git a/remote/package-lock.json b/remote/package-lock.json index c7b4ef63e2e3c..ad81948e98172 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -12,7 +12,7 @@ "@github/copilot-sdk": "1.0.0-beta.8", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@microsoft/mxc-sdk": "0.2.1", + "@microsoft/mxc-sdk": "0.3.0", "@parcel/watcher": "^2.5.6", "@vscode/copilot-api": "^0.4.2", "@vscode/deviceid": "^0.1.1", @@ -296,9 +296,9 @@ "integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ==" }, "node_modules/@microsoft/mxc-sdk": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@microsoft/mxc-sdk/-/mxc-sdk-0.2.1.tgz", - "integrity": "sha512-1dL42Abc1ocapZR01aPeSEcvuzWuvOslmWNZvdYs6+yTVqAnpWrMk+aFf0Odry9SqJbcW9FABYzPlFtJW6clAQ==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@microsoft/mxc-sdk/-/mxc-sdk-0.3.0.tgz", + "integrity": "sha512-eAjVfS4+RdG03Wh/DemgaMmjkuTGPDDNR3xRxkRLRs6lCpezNZq8OdNLjCy4N9cKr/H9mlpYAwPQUs07/+BywA==", "license": "MIT", "dependencies": { "node-pty": "^1.2.0-beta.12", diff --git a/remote/package.json b/remote/package.json index b02cfee3b9dba..91f54bd01fd34 100644 --- a/remote/package.json +++ b/remote/package.json @@ -7,7 +7,7 @@ "@github/copilot-sdk": "1.0.0-beta.8", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@microsoft/mxc-sdk": "0.2.1", + "@microsoft/mxc-sdk": "0.3.0", "@parcel/watcher": "^2.5.6", "@vscode/copilot-api": "^0.4.2", "@vscode/deviceid": "^0.1.1", diff --git a/src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts b/src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts index e23997d47bf36..7f0351267ec55 100644 --- a/src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts +++ b/src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts @@ -11,7 +11,7 @@ import { URI } from '../../../../base/common/uri.js'; import { IEnvironmentService, INativeEnvironmentService } from '../../../environment/common/environment.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { IProductService } from '../../../product/common/productService.js'; -import { ISandboxHelperService, type ISandboxDependencyStatus } from '../../../sandbox/common/sandboxHelperService.js'; +import { ISandboxHelperService, type ISandboxDependencyStatus, type IWindowsMxcPolicyContainment, type IWindowsMxcSandboxPolicy } from '../../../sandbox/common/sandboxHelperService.js'; import { ITerminalSandboxEngineHost, ITerminalSandboxRuntimeInfo, TerminalSandboxEngine } from '../../../sandbox/common/terminalSandboxEngine.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import { AgentHostSandboxConfigKey, sandboxConfigSchema, sandboxSettingIdToAgentHostKey } from '../../common/sandboxConfigSchema.js'; @@ -85,6 +85,10 @@ class AgentHostTerminalSandboxHost implements ITerminalSandboxEngineHost { return this._sandboxHelper.getWindowsMxcEnvironment(); } + async buildWindowsMxcSandboxPayload(commandLine: string, policy: IWindowsMxcSandboxPolicy, workingDirectory?: string, containerName?: string, containment?: IWindowsMxcPolicyContainment) { + return this._sandboxHelper.buildWindowsMxcSandboxPayload(commandLine, policy, workingDirectory, containerName, containment); + } + getSandboxSetting(settingId: string): T | undefined { // The agent host stores sandbox settings nested under a single // top-level `sandbox` object with prefix-free sub-keys (e.g. diff --git a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts index b08efc9659840..93f0cfbe1e46a 100644 --- a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts @@ -142,6 +142,20 @@ suite('CopilotShellTools', () => { checkSandboxDependencies: async () => undefined, getWindowsMxcFilesystemPolicy: async () => ({ readonlyPaths: [], readwritePaths: [] }), getWindowsMxcEnvironment: async () => [], + buildWindowsMxcSandboxPayload: async (commandLine, policy, workingDirectory, containerName = 'vscode-terminal-sandbox', containment = 'process') => ({ + version: policy.version, + containerId: containerName, + containment, + lifecycle: { destroyOnExit: true, preservePolicy: false }, + process: { commandLine, cwd: workingDirectory, timeout: policy.timeoutMs ?? 0 }, + filesystem: { + readwritePaths: [...(policy.filesystem?.readwritePaths ?? [])], + readonlyPaths: [...(policy.filesystem?.readonlyPaths ?? [])], + deniedPaths: [...(policy.filesystem?.deniedPaths ?? [])], + }, + network: { defaultPolicy: policy.network?.allowOutbound ? 'allow' : 'block' }, + ui: { disable: !(policy.ui?.allowWindows ?? false), clipboard: policy.ui?.clipboard ?? 'none', injection: policy.ui?.allowInputInjection ?? false }, + }), } satisfies ISandboxHelperService; } diff --git a/src/vs/platform/sandbox/browser/sandboxHelperService.ts b/src/vs/platform/sandbox/browser/sandboxHelperService.ts index 54cfa7895fffc..3ce552fee34ee 100644 --- a/src/vs/platform/sandbox/browser/sandboxHelperService.ts +++ b/src/vs/platform/sandbox/browser/sandboxHelperService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; -import { ISandboxDependencyStatus, ISandboxHelperService, IWindowsMxcFilesystemPolicy } from '../common/sandboxHelperService.js'; +import { ISandboxDependencyStatus, ISandboxHelperService, type IWindowsMxcConfig, IWindowsMxcFilesystemPolicy, type IWindowsMxcPolicyContainment, type IWindowsMxcSandboxPolicy } from '../common/sandboxHelperService.js'; class NullSandboxHelperService implements ISandboxHelperService { declare readonly _serviceBrand: undefined; @@ -26,6 +26,10 @@ class NullSandboxHelperService implements ISandboxHelperService { async getWindowsMxcEnvironment(): Promise { return undefined; } + + async buildWindowsMxcSandboxPayload(_commandLine: string, _policy: IWindowsMxcSandboxPolicy, _workingDirectory?: string, _containerName?: string, _containment?: IWindowsMxcPolicyContainment): Promise { + return undefined; + } } registerSingleton(ISandboxHelperService, NullSandboxHelperService, InstantiationType.Delayed); diff --git a/src/vs/platform/sandbox/common/sandboxHelperIpc.ts b/src/vs/platform/sandbox/common/sandboxHelperIpc.ts index e210254331fe6..baaea860db793 100644 --- a/src/vs/platform/sandbox/common/sandboxHelperIpc.ts +++ b/src/vs/platform/sandbox/common/sandboxHelperIpc.ts @@ -6,7 +6,15 @@ import { Event } from '../../../base/common/event.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { IChannel, IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; -import { ISandboxDependencyStatus, ISandboxHelperService, IWindowsMxcFilesystemPolicy } from './sandboxHelperService.js'; +import { ISandboxDependencyStatus, ISandboxHelperService, type IWindowsMxcConfig, IWindowsMxcFilesystemPolicy, type IWindowsMxcPolicyContainment, type IWindowsMxcSandboxPolicy } from './sandboxHelperService.js'; + +interface IBuildWindowsMxcSandboxPayloadArgs { + commandLine: string; + policy: IWindowsMxcSandboxPolicy; + workingDirectory?: string; + containerName?: string; + containment?: IWindowsMxcPolicyContainment; +} export const SANDBOX_HELPER_CHANNEL_NAME = 'sandboxHelper'; @@ -26,6 +34,10 @@ export class SandboxHelperChannel implements IServerChannel { return this.service.getWindowsMxcFilesystemPolicy() as Promise; case 'getWindowsMxcEnvironment': return this.service.getWindowsMxcEnvironment() as Promise; + case 'buildWindowsMxcSandboxPayload': { + const { commandLine, policy, workingDirectory, containerName, containment } = _arg as IBuildWindowsMxcSandboxPayloadArgs; + return this.service.buildWindowsMxcSandboxPayload(commandLine, policy, workingDirectory, containerName, containment) as Promise; + } } throw new Error('Invalid call'); @@ -48,4 +60,8 @@ export class SandboxHelperChannelClient implements ISandboxHelperService { getWindowsMxcEnvironment(): Promise { return this.channel.call('getWindowsMxcEnvironment'); } + + buildWindowsMxcSandboxPayload(commandLine: string, policy: IWindowsMxcSandboxPolicy, workingDirectory?: string, containerName?: string, containment?: IWindowsMxcPolicyContainment): Promise { + return this.channel.call('buildWindowsMxcSandboxPayload', { commandLine, policy, workingDirectory, containerName, containment } satisfies IBuildWindowsMxcSandboxPayloadArgs); + } } diff --git a/src/vs/platform/sandbox/common/sandboxHelperService.ts b/src/vs/platform/sandbox/common/sandboxHelperService.ts index fc754dc666cde..581c78f33564a 100644 --- a/src/vs/platform/sandbox/common/sandboxHelperService.ts +++ b/src/vs/platform/sandbox/common/sandboxHelperService.ts @@ -17,9 +17,84 @@ export interface IWindowsMxcFilesystemPolicy { readonly readwritePaths: string[]; } +/** Sandbox policy passed to the Windows MXC helper process. */ +export interface IWindowsMxcSandboxPolicy { + version: string; + filesystem?: { + readwritePaths?: string[]; + readonlyPaths?: string[]; + deniedPaths?: string[]; + clearPolicyOnExit?: boolean; + }; + network?: { + allowOutbound?: boolean; + allowLocalNetwork?: boolean; + allowedHosts?: string[]; + blockedHosts?: string[]; + proxy?: { builtinTestServer: true } | { localhost: number } | { url: string }; + }; + ui?: { + allowWindows?: boolean; + clipboard?: 'none' | 'read' | 'write' | 'all'; + allowInputInjection?: boolean; + }; + timeoutMs?: number; +} + +/** MXC payload returned by the Windows sandbox helper. */ +export interface IWindowsMxcConfig { + version: string; + containerId?: string; + containment?: IWindowsMxcPolicyContainment; + lifecycle?: { + destroyOnExit?: boolean; + preservePolicy?: boolean; + }; + process?: { + commandLine: string; + cwd?: string; + env?: string[]; + timeout?: number; + }; + processContainer?: { + name?: string; + leastPrivilege?: boolean; + capabilities?: string[]; + ui?: { + isolation: 'desktop' | 'handles' | 'atoms' | 'container'; + desktopSystemControl: boolean; + systemSettings: string; + ime: boolean; + }; + }; + filesystem?: { + readwritePaths?: string[]; + readonlyPaths?: string[]; + deniedPaths?: string[]; + clearPolicyOnExit?: boolean; + }; + network?: { + enforcementMode?: 'capabilities' | 'firewall' | 'both'; + defaultPolicy?: 'allow' | 'block'; + allowLocalNetwork?: boolean; + allowedHosts?: string[]; + blockedHosts?: string[]; + proxy?: { builtinTestServer: true } | { localhost: number } | { url: string }; + removeRulesOnExit?: boolean; + }; + ui?: { + disable: boolean; + clipboard: 'none' | 'read' | 'write' | 'all'; + injection: boolean; + }; +} + +export type IWindowsMxcPolicyContainment = 'process' | 'vm' | 'microvm' | 'processcontainer' | 'windows_sandbox' | 'wslc' | 'lxc' | 'hyperlight' | 'seatbelt' | 'isolation_session' | 'bubblewrap'; + export interface ISandboxHelperService { readonly _serviceBrand: undefined; checkSandboxDependencies(): Promise; getWindowsMxcFilesystemPolicy(): Promise; getWindowsMxcEnvironment(): Promise; + buildWindowsMxcSandboxPayload(commandLine: string, policy: IWindowsMxcSandboxPolicy, workingDirectory?: string, containerName?: string, containment?: IWindowsMxcPolicyContainment): Promise; } diff --git a/src/vs/platform/sandbox/common/settings.ts b/src/vs/platform/sandbox/common/settings.ts index 30c0144c96ae7..bcbbbe24a20ab 100644 --- a/src/vs/platform/sandbox/common/settings.ts +++ b/src/vs/platform/sandbox/common/settings.ts @@ -15,6 +15,7 @@ export const enum AgentSandboxSettingId { AgentSandboxLinuxFileSystem = 'chat.agent.sandbox.fileSystem.linux', AgentSandboxMacFileSystem = 'chat.agent.sandbox.fileSystem.mac', AgentSandboxWindowsFileSystem = 'chat.agent.sandbox.fileSystem.windows', + AgentSandboxWindowsSchemaVersion = 'chat.agent.sandbox.advanced.windows.schemaVersion', AgentSandboxAdvancedRuntime = 'chat.agent.sandbox.advanced.runtime', DeprecatedAgentSandboxEnabled = 'chat.agent.sandbox', DeprecatedAgentSandboxLinuxFileSystem = 'chat.agent.sandboxFileSystem.linux', diff --git a/src/vs/platform/sandbox/common/terminalSandboxEngine.ts b/src/vs/platform/sandbox/common/terminalSandboxEngine.ts index 015777c71ddd2..de281f063d738 100644 --- a/src/vs/platform/sandbox/common/terminalSandboxEngine.ts +++ b/src/vs/platform/sandbox/common/terminalSandboxEngine.ts @@ -15,7 +15,7 @@ import { IFileService } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; import { matchesDomainPattern, normalizeDomain } from '../../networkFilter/common/domainMatcher.js'; import { AgentNetworkDomainSettingId } from '../../networkFilter/common/settings.js'; -import { ISandboxDependencyStatus, IWindowsMxcFilesystemPolicy } from './sandboxHelperService.js'; +import { ISandboxDependencyStatus, type IWindowsMxcConfig, IWindowsMxcFilesystemPolicy, type IWindowsMxcPolicyContainment, type IWindowsMxcSandboxPolicy } from './sandboxHelperService.js'; import { AgentSandboxEnabledValue, AgentSandboxSettingId } from './settings.js'; import { IWindowsMxcTerminalSandboxRuntime } from './terminalSandboxMxcRuntime.js'; import { getTerminalSandboxReadAllowListForCommands } from './terminalSandboxReadAllowList.js'; @@ -78,6 +78,8 @@ export interface ITerminalSandboxEngineHost { getWindowsMxcFilesystemPolicy(): Promise; /** Resolves host environment variables needed by the Windows MXC process container. */ getWindowsMxcEnvironment(): Promise; + /** Builds a Windows MXC payload from a target-environment MXC sandbox policy. */ + buildWindowsMxcSandboxPayload(commandLine: string, policy: IWindowsMxcSandboxPolicy, workingDirectory?: string, containerName?: string, containment?: IWindowsMxcPolicyContainment): Promise; /** * Returns the effective value of a sandbox-related configuration setting, * or `undefined` when the setting is not configured. Implementations are @@ -552,6 +554,9 @@ export class TerminalSandboxEngine extends Disposable { const windowsFileSystemSetting = this._os === OperatingSystem.Windows ? this._getSettingValue(AgentSandboxSettingId.AgentSandboxWindowsFileSystem) ?? {} : {}; + const windowsSchemaVersion = this._os === OperatingSystem.Windows + ? this._getSettingValue(AgentSandboxSettingId.AgentSandboxWindowsSchemaVersion) + : undefined; const runtimeSetting = this._getSettingValue>(AgentSandboxSettingId.AgentSandboxAdvancedRuntime) ?? {}; const commandRuntimeSetting = getTerminalSandboxRuntimeConfigurationForCommands(this._os, this._commandAllowListCommandDetails); const commandRuntimeAllowReadPaths = this._getCommandRuntimeFileSystemPaths(commandRuntimeSetting, 'allowRead'); @@ -565,11 +570,11 @@ export class TerminalSandboxEngine extends Disposable { const filesystemPolicy = await this._getWindowsMxcFilesystemPolicy(); const env = await this._getWindowsMxcEnvironment(); allowWritePaths = await this._resolveFileSystemPaths([ - ...this._updateAllowWritePathsWithWorkspaceFolders(windowsFileSystemSetting.allowWrite, commandRuntimeAllowWritePaths), + ...this._updateAllowWritePathsWithWorkspaceFolders(windowsFileSystemSetting.allowWrite), ...filesystemPolicy.readwritePaths ]); - allowReadPaths = await this._resolveFileSystemPaths([...(await this._updateAllowReadPathsWithAllowWrite(windowsFileSystemSetting.allowRead, allowWritePaths, commandRuntimeAllowReadPaths)), ...filesystemPolicy.readonlyPaths]); - denyReadPaths = await this._resolveFileSystemPaths(this._updateDenyReadPathsWithHome(windowsFileSystemSetting.denyRead)); + allowReadPaths = await this._resolveFileSystemPaths([...(windowsFileSystemSetting.allowRead ?? []), ...filesystemPolicy.readonlyPaths]); + denyReadPaths = await this._resolveFileSystemPaths(windowsFileSystemSetting.denyRead ?? []); this._windowsMxcEnvironment = env; } else if (this._os === OperatingSystem.Macintosh) { allowWritePaths = await this._resolveFileSystemPaths(this._updateAllowWritePathsWithWorkspaceFolders(macFileSystemSetting.allowWrite, commandRuntimeAllowWritePaths)); @@ -582,18 +587,19 @@ export class TerminalSandboxEngine extends Disposable { denyReadPaths = await this._resolveFileSystemPaths(this._updateDenyReadPathsWithHome(linuxFileSystemSetting.denyRead)); denyWritePaths = await this._resolveFileSystemPaths(linuxFileSystemSetting.denyWrite); } - const sandboxSettings = this._os === OperatingSystem.Windows ? this._windowsMxcRuntime.createConfig({ + const sandboxSettings = this._os === OperatingSystem.Windows ? await this._windowsMxcRuntime.createConfig({ command: this._commandLine ?? '', shell: this._commandShell, cwd: this._commandCwd ?? this._getDefaultWindowsMxcCwd(), tempDir: this._tempDir, + schemaVersion: windowsSchemaVersion, allowNetwork, networkDomains: this.getResolvedNetworkDomains(), allowReadPaths, allowWritePaths, denyReadPaths, env: this._windowsMxcEnvironment ?? [], - }) : { + }, this._buildSandboxPayload) : { network: allowNetwork ? { allowedDomains: [], deniedDomains: [], enabled: false } : this.getResolvedNetworkDomains(), filesystem: { denyRead: denyReadPaths, @@ -611,6 +617,10 @@ export class TerminalSandboxEngine extends Disposable { return this._sandboxConfigPath; } + private readonly _buildSandboxPayload = (commandLine: string, policy: IWindowsMxcSandboxPolicy, workingDirectory?: string, containerName?: string, containment?: IWindowsMxcPolicyContainment): Promise => { + return this._host.buildWindowsMxcSandboxPayload(commandLine, policy, workingDirectory, containerName, containment); + }; + private _getCommandRuntimeFileSystemPaths(runtimeSetting: Record, key: 'allowRead' | 'allowWrite'): string[] { const filesystem = runtimeSetting.filesystem; if (!this._isObjectForSandboxConfigMerge(filesystem)) { diff --git a/src/vs/platform/sandbox/common/terminalSandboxMxcRuntime.ts b/src/vs/platform/sandbox/common/terminalSandboxMxcRuntime.ts index c8ed2c282b9e5..b0b07fa8ea314 100644 --- a/src/vs/platform/sandbox/common/terminalSandboxMxcRuntime.ts +++ b/src/vs/platform/sandbox/common/terminalSandboxMxcRuntime.ts @@ -6,51 +6,15 @@ import { win32 } from '../../../base/common/path.js'; import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; +import type { IWindowsMxcConfig, IWindowsMxcPolicyContainment, IWindowsMxcSandboxPolicy } from './sandboxHelperService.js'; import type { ITerminalSandboxResolvedNetworkDomains } from './terminalSandboxService.js'; -export interface IWindowsMxcProcessConfig { - commandLine: string; - cwd?: string; - env: string[]; - timeout: number; -} - -export interface IWindowsMxcFilesystemConfig { - readwritePaths: string[]; - readonlyPaths: string[]; - deniedPaths: string[]; -} - -export interface IWindowsMxcNetworkConfig { - defaultPolicy: 'allow' | 'block'; - allowedHosts?: string[]; - blockedHosts?: string[]; -} - -export interface IWindowsMxcConfig { - version: string; - containerId: string; - containment: 'processcontainer'; - lifecycle: { - destroyOnExit: boolean; - preservePolicy: boolean; - }; - process: IWindowsMxcProcessConfig; - filesystem: IWindowsMxcFilesystemConfig; - network: IWindowsMxcNetworkConfig; - ui: { - disable: boolean; - clipboard: 'none'; - injection: boolean; - allowWindows: boolean; - }; -} - export interface IWindowsMxcConfigOptions { command: string; shell?: string; cwd: URI | undefined; tempDir: URI; + schemaVersion?: string; allowNetwork: boolean; networkDomains: ITerminalSandboxResolvedNetworkDomains; allowReadPaths: string[]; @@ -59,6 +23,8 @@ export interface IWindowsMxcConfigOptions { env: string[]; } +export type IWindowsMxcBuildSandboxPayload = (commandLine: string, policy: IWindowsMxcSandboxPolicy, workingDirectory?: string, containerName?: string, containment?: IWindowsMxcPolicyContainment) => Promise; + export const IWindowsMxcTerminalSandboxRuntime = createDecorator('windowsMxcTerminalSandboxRuntime'); export interface IWindowsMxcTerminalSandboxRuntime { @@ -66,7 +32,7 @@ export interface IWindowsMxcTerminalSandboxRuntime { getExecutablePath(appRoot: string, arch: string | undefined): string; getRuntimeReadPaths(appRoot: string | undefined, executablePath: string | undefined): string[]; - createConfig(options: IWindowsMxcConfigOptions): IWindowsMxcConfig; + createConfig(options: IWindowsMxcConfigOptions, buildSandboxPayload: IWindowsMxcBuildSandboxPayload): Promise; wrapCommand(executablePath: string, configPath: string): string; wrapUnsandboxedCommand(command: string): string; toWindowsPath(uri: URI): string; @@ -82,6 +48,7 @@ export class WindowsMxcTerminalSandboxRuntime implements IWindowsMxcTerminalSand declare readonly _serviceBrand: undefined; private readonly _configVersion = '0.4.0-alpha'; + private readonly _containerName = 'vscode-terminal-sandbox'; getExecutablePath(appRoot: string, arch: string | undefined): string { const binArch = arch === 'arm64' ? 'arm64' : 'x64'; @@ -99,40 +66,37 @@ export class WindowsMxcTerminalSandboxRuntime implements IWindowsMxcTerminalSand return [...new Set(paths)]; } - createConfig(options: IWindowsMxcConfigOptions): IWindowsMxcConfig { + async createConfig(options: IWindowsMxcConfigOptions, buildSandboxPayload: IWindowsMxcBuildSandboxPayload): Promise { const tempDirPath = this.toWindowsPath(options.tempDir); const shell = options.shell ? this._quoteWindowsCommandLineArgument(options.shell) : 'pwsh.exe'; - return { - version: this._configVersion, - containerId: 'vscode-terminal-sandbox', - containment: 'processcontainer', - lifecycle: { - destroyOnExit: true, - preservePolicy: false, - }, - process: { - commandLine: `${shell} -NoProfile -ExecutionPolicy Bypass -Command ${this._quoteWindowsCommandLineArgument(options.command)}`, - cwd: options.cwd ? this.toWindowsPath(options.cwd) : tempDirPath, - env: [ - ...options.env - ], - timeout: 0, - }, + const commandLine = `${shell} -NoProfile -ExecutionPolicy Bypass -Command ${this._quoteWindowsCommandLineArgument(options.command)}`; + const cwd = options.cwd ? this.toWindowsPath(options.cwd) : tempDirPath; + const policy: IWindowsMxcSandboxPolicy = { + version: options.schemaVersion ?? this._configVersion, + timeoutMs: 0, filesystem: { - readwritePaths: [...new Set([...options.allowWritePaths])], - readonlyPaths: [...new Set([tempDirPath, ...(options.shell && win32.isAbsolute(options.shell) ? [win32.dirname(options.shell)] : []), ...options.allowReadPaths])], - deniedPaths: options.denyReadPaths, + readwritePaths: [...new Set(options.allowWritePaths.map(path => this._normalizeWindowsPath(path)))], + readonlyPaths: [...new Set([tempDirPath, ...(options.shell && win32.isAbsolute(options.shell) ? [win32.dirname(options.shell)] : []), ...options.allowReadPaths].map(path => this._normalizeWindowsPath(path)))], + deniedPaths: [...new Set(options.denyReadPaths.map(path => this._normalizeWindowsPath(path)))], }, - network: this._createNetworkConfig(options.allowNetwork, options.networkDomains), + network: this._createNetworkPolicy(options.allowNetwork, options.networkDomains), ui: { - disable: false, + allowWindows: true, clipboard: 'none', - injection: false, - allowWindows: true + allowInputInjection: false, }, }; + + const config = await buildSandboxPayload(commandLine, policy, cwd, this._containerName); + if (!config?.process) { + throw new Error('Unable to build Windows MXC sandbox payload'); + } + + config.process.env = [...options.env]; + + return config; } wrapCommand(executablePath: string, configPath: string): string { @@ -152,18 +116,27 @@ export class WindowsMxcTerminalSandboxRuntime implements IWindowsMxcTerminalSand } else { value = uri.fsPath; } - return value.replace(/\//g, '\\'); + return this._normalizeWindowsPath(value); } - private _createNetworkConfig(allowNetwork: boolean, networkDomains: ITerminalSandboxResolvedNetworkDomains): IWindowsMxcNetworkConfig { - if (allowNetwork) { - return { defaultPolicy: 'allow' }; - } - return { - defaultPolicy: 'block', - allowedHosts: networkDomains.allowedDomains, - blockedHosts: networkDomains.deniedDomains + private _normalizeWindowsPath(path: string): string { + return path.replace(/\//g, '\\'); + } + + private _createNetworkPolicy(allowNetwork: boolean, networkDomains: ITerminalSandboxResolvedNetworkDomains): NonNullable { + const allowedHosts = networkDomains.allowedDomains.length > 0 ? networkDomains.allowedDomains : undefined; + const blockedHosts = networkDomains.deniedDomains.length > 0 ? networkDomains.deniedDomains : undefined; + const allowOutbound = allowNetwork || !!allowedHosts?.length; + const network: NonNullable = { + allowOutbound, }; + if (allowOutbound && allowedHosts) { + network.allowedHosts = allowedHosts; + } + if (allowOutbound && blockedHosts) { + network.blockedHosts = blockedHosts; + } + return network; } private _quotePowerShellArgument(value: string): string { diff --git a/src/vs/platform/sandbox/node/sandboxHelper.ts b/src/vs/platform/sandbox/node/sandboxHelper.ts index 332eeea85f211..d15f3a3c1bd8a 100644 --- a/src/vs/platform/sandbox/node/sandboxHelper.ts +++ b/src/vs/platform/sandbox/node/sandboxHelper.ts @@ -7,7 +7,7 @@ import { getCaseInsensitive } from '../../../base/common/objects.js'; import { win32 } from '../../../base/common/path.js'; import { isLinux, isWindows } from '../../../base/common/platform.js'; import { findExecutable } from '../../../base/node/processes.js'; -import { ISandboxDependencyStatus, ISandboxHelperService, IWindowsMxcFilesystemPolicy } from '../common/sandboxHelperService.js'; +import { ISandboxDependencyStatus, ISandboxHelperService, type IWindowsMxcConfig, IWindowsMxcFilesystemPolicy, type IWindowsMxcPolicyContainment, type IWindowsMxcSandboxPolicy } from '../common/sandboxHelperService.js'; type FindCommand = (command: string) => Promise; @@ -39,13 +39,14 @@ export class SandboxHelperService implements ISandboxHelperService { return undefined; } - const { getAvailableToolsPolicy, getUserProfilePolicy } = await import('@microsoft/mxc-sdk'); + const { getAvailableToolsPolicy, getUserProfilePolicy, getTemporaryFilesPolicy } = await import('@microsoft/mxc-sdk'); const availableToolsPolicy = getAvailableToolsPolicy(process.env, { containerType: 'processcontainer' }); const userProfilePolicy = getUserProfilePolicy(); + const temporaryFilesPolicy = getTemporaryFilesPolicy(process.env); const psHome = await this._getPSHome(); return { - readonlyPaths: [...new Set([...availableToolsPolicy.readonlyPaths, ...userProfilePolicy.readonlyPaths, ...this._getTempReadPaths(), ...(psHome ? [psHome] : [])])], - readwritePaths: [...new Set([...availableToolsPolicy.readwritePaths, ...userProfilePolicy.readwritePaths])], + readonlyPaths: [...new Set([...availableToolsPolicy.readonlyPaths, ...userProfilePolicy.readonlyPaths, ...temporaryFilesPolicy.readonlyPaths, ...(psHome ? [psHome] : [])])], + readwritePaths: [...new Set([...availableToolsPolicy.readwritePaths, ...userProfilePolicy.readwritePaths, ...temporaryFilesPolicy.readwritePaths])], }; } @@ -81,6 +82,15 @@ export class SandboxHelperService implements ISandboxHelperService { return env; } + async buildWindowsMxcSandboxPayload(commandLine: string, policy: IWindowsMxcSandboxPolicy, workingDirectory?: string, containerName?: string, containment: IWindowsMxcPolicyContainment = 'process'): Promise { + if (!isWindows) { + return undefined; + } + + const { buildSandboxPayload } = await import('@microsoft/mxc-sdk'); + return buildSandboxPayload(commandLine, policy, workingDirectory, containerName, containment); + } + private async _getPSHome(): Promise { const psHome = getCaseInsensitive(process.env, 'PSHOME'); if (typeof psHome === 'string' && psHome) { @@ -95,15 +105,4 @@ export class SandboxHelperService implements ISandboxHelperService { const localAppData = getCaseInsensitive(process.env, 'LOCALAPPDATA'); return typeof localAppData === 'string' && localAppData ? localAppData : undefined; } - - private _getTempReadPaths(): string[] { - const paths: string[] = []; - for (const variable of ['TMP', 'TEMP']) { - const path = getCaseInsensitive(process.env, variable); - if (typeof path === 'string' && path) { - paths.push(path); - } - } - return paths; - } } diff --git a/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts b/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts index 3503c2c9c7c73..3f1bece0d0534 100644 --- a/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts +++ b/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts @@ -13,7 +13,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { IFileService } from '../../../files/common/files.js'; import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; -import type { ISandboxDependencyStatus, IWindowsMxcFilesystemPolicy } from '../../common/sandboxHelperService.js'; +import type { ISandboxDependencyStatus, IWindowsMxcConfig, IWindowsMxcFilesystemPolicy, IWindowsMxcPolicyContainment, IWindowsMxcSandboxPolicy } from '../../common/sandboxHelperService.js'; import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../common/settings.js'; import { ITerminalSandboxEngineHost, ITerminalSandboxRuntimeInfo, TerminalSandboxEngine } from '../../common/terminalSandboxEngine.js'; import { IWindowsMxcTerminalSandboxRuntime, WindowsMxcTerminalSandboxRuntime } from '../../common/terminalSandboxMxcRuntime.js'; @@ -63,6 +63,53 @@ suite('TerminalSandboxEngine', () => { async del(_uri: URI): Promise { } } + function buildMockWindowsMxcSandboxPayload(commandLine: string, policy: IWindowsMxcSandboxPolicy, workingDirectory?: string, containerName: string = 'vscode-terminal-sandbox', containment: IWindowsMxcPolicyContainment = 'process'): IWindowsMxcConfig { + const clearPolicy = policy.filesystem?.clearPolicyOnExit ?? true; + const network = { + defaultPolicy: policy.network?.allowOutbound ? 'allow' : 'block' as 'allow' | 'block', + ...(policy.network?.allowLocalNetwork !== undefined ? { allowLocalNetwork: policy.network.allowLocalNetwork } : {}), + ...(policy.network?.allowedHosts ? { allowedHosts: policy.network.allowedHosts } : {}), + ...(policy.network?.blockedHosts ? { blockedHosts: policy.network.blockedHosts } : {}), + ...(policy.network ? { enforcementMode: policy.network.allowedHosts?.length || policy.network.blockedHosts?.length ? 'both' as const : 'capabilities' as const } : {}), + }; + return { + version: policy.version, + containerId: containerName, + containment, + lifecycle: { + destroyOnExit: true, + preservePolicy: !clearPolicy, + }, + process: { + commandLine, + cwd: workingDirectory, + timeout: policy.timeoutMs ?? 0, + }, + processContainer: { + name: containerName, + leastPrivilege: false, + capabilities: policy.network?.allowOutbound ? ['internetClient'] : [], + ui: { + isolation: 'container', + desktopSystemControl: false, + systemSettings: 'none', + ime: false, + }, + }, + filesystem: { + readwritePaths: [...(policy.filesystem?.readwritePaths ?? [])], + readonlyPaths: [...(policy.filesystem?.readonlyPaths ?? [])], + deniedPaths: [...(policy.filesystem?.deniedPaths ?? [])], + }, + network, + ui: { + disable: !(policy.ui?.allowWindows ?? false), + clipboard: policy.ui?.clipboard ?? 'none', + injection: policy.ui?.allowInputInjection ?? false, + }, + }; + } + function createHost(overrides: Partial = {}): ITerminalSandboxEngineHost & { rootsEmitter: Emitter } { const rootsEmitter = new Emitter(); const defaultRuntime: ITerminalSandboxRuntimeInfo = { @@ -81,6 +128,7 @@ suite('TerminalSandboxEngine', () => { checkSandboxDependencies: (): Promise => Promise.resolve({ bubblewrapInstalled: true, socatInstalled: true }), getWindowsMxcFilesystemPolicy: (): Promise => Promise.resolve(undefined), getWindowsMxcEnvironment: (): Promise => Promise.resolve(undefined), + buildWindowsMxcSandboxPayload: (commandLine, policy, workingDirectory, containerName, containment): Promise => Promise.resolve(buildMockWindowsMxcSandboxPayload(commandLine, policy, workingDirectory, containerName, containment)), getSandboxSetting: (settingId: string): T | undefined => sandboxSettings.has(settingId) ? sandboxSettings.get(settingId) as T : undefined, onDidChangeSandboxSettings: sandboxSettingsEmitter.event, ...overrides, @@ -96,7 +144,7 @@ suite('TerminalSandboxEngine', () => { getSandboxTempDir: () => Promise.resolve(URI.from({ scheme: 'file', path: '/c:/Users/user/.test-data/tmp' })), getWorkspaceStorageReadRoot: () => Promise.resolve(URI.from({ scheme: 'file', path: '/c:/Users/user/workspaceStorage/workspace-id' })), getWriteRoots: () => [URI.from({ scheme: 'file', path: '/c:/workspace' })], - getWindowsMxcFilesystemPolicy: () => Promise.resolve({ readonlyPaths: ['C:\\tools\\node', 'C:\\tools\\python', 'C:\\Users\\user\\AppData\\Local\\Programs\\Git', 'C:\\Users\\user\\AppData\\Local\\Temp'], readwritePaths: [] }), + getWindowsMxcFilesystemPolicy: () => Promise.resolve({ readonlyPaths: ['C:\\tools\\node', 'C:\\tools\\python', 'C:\\Users\\user\\AppData\\Local\\Programs\\Git'], readwritePaths: ['C:\\Users\\user\\AppData\\Local\\Temp'] }), getWindowsMxcEnvironment: () => Promise.resolve([ 'SystemRoot=C:\\Windows', 'PATH=C:\\tools\\node;C:\\Windows\\System32', @@ -310,7 +358,8 @@ suite('TerminalSandboxEngine', () => { ok(wrapped.command.startsWith(`& 'C:\\app\\node_modules\\@microsoft\\mxc-sdk\\bin\\x64\\wxc-exec.exe'`), `Expected MXC executable. Actual: ${wrapped.command}`); ok(wrapped.command.includes(` '${configPath}'`), `Expected wrapped command to pass the MXC config path. Actual: ${wrapped.command}`); strictEqual(config.version, '0.4.0-alpha'); - strictEqual(config.containment, 'processcontainer'); + strictEqual(config.containment, 'process'); + strictEqual(config.processContainer.name, 'vscode-terminal-sandbox'); strictEqual(config.process.commandLine, '"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -NoProfile -ExecutionPolicy Bypass -Command "echo hello"'); strictEqual(normalizeWindowsPathForAssert(config.process.cwd), 'c:/workspace'); strictEqual(config.ui.disable, false); @@ -323,24 +372,23 @@ suite('TerminalSandboxEngine', () => { ok(config.process.env.includes('APPDATA=C:\\Users\\user\\AppData\\Roaming'), 'APPDATA should be injected into the MXC process env'); ok(config.process.env.includes('LOCALAPPDATA=C:\\Users\\user\\AppData\\Local'), 'LOCALAPPDATA should be injected into the MXC process env'); ok(config.process.env.includes('PSHOME=C:\\Program Files\\PowerShell\\7'), 'PSHOME should be injected into the MXC process env'); - deepStrictEqual(config.network, { defaultPolicy: 'allow' }); + deepStrictEqual(config.network, { defaultPolicy: 'allow', enforcementMode: 'capabilities' }); ok(config.filesystem.readwritePaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/workspace'), 'Workspace should be writable'); ok(config.filesystem.readwritePaths.some((path: string) => normalizeWindowsPathForAssert(path).endsWith('/.test-data/tmp')), 'Sandbox temp dir should be writable'); + ok(config.filesystem.readwritePaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/users/user/appdata/local/temp'), 'MXC temporary files policy should add host temp path to writable paths'); ok(config.filesystem.readonlyPaths.some((path: string) => normalizeWindowsPathForAssert(path).endsWith('/.test-data/tmp')), 'Sandbox temp dir should be readable through readonly paths'); - ok(config.filesystem.readonlyPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/app'), 'App root should be readable for MXC'); ok(config.filesystem.readonlyPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/tools/node'), 'MXC available tools policy should add tool paths to readonly paths'); ok(config.filesystem.readonlyPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/program files/powershell/7'), 'Resolved PowerShell executable directory should be readable'); ok(config.filesystem.readonlyPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/users/user/appdata/local/programs/git'), 'MXC user profile policy should add user profile paths to readonly paths'); - ok(config.filesystem.readonlyPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/users/user/appdata/local/temp'), 'MXC actual temp policy should add host temp path to readonly paths'); ok(!config.filesystem.deniedPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/users/user'), 'User home should not be denied by default on Windows'); }); test('wrapCommand applies Windows filesystem setting to MXC config', async () => { enableWindowsSandbox(); setSandboxSetting(AgentSandboxSettingId.AgentSandboxWindowsFileSystem, { - allowWrite: ['C:\\configured\\write'], - allowRead: ['C:\\configured\\read'], - denyRead: ['C:\\configured\\secret'], + allowWrite: ['C:/configured/write'], + allowRead: ['C:/configured/read'], + denyRead: ['C:/configured/secret'], }); const host = createWindowsHost(); const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, host)); @@ -348,15 +396,32 @@ suite('TerminalSandboxEngine', () => { await engine.wrapCommand('echo hello', false, 'pwsh'); const configPath = await engine.getSandboxConfigPath(); ok(configPath, 'Config path should be defined'); - const config = JSON.parse(createdFiles.get(configPath)!); + const serializedConfig = createdFiles.get(configPath)!; + const config = JSON.parse(serializedConfig); + ok(serializedConfig.includes('C:\\\\configured\\\\write'), 'Configured Windows allowWrite path should be escaped in the serialized MXC config'); + ok(serializedConfig.includes('C:\\\\configured\\\\read'), 'Configured Windows allowRead path should be escaped in the serialized MXC config'); + ok(serializedConfig.includes('C:\\\\configured\\\\secret'), 'Configured Windows denyRead path should be escaped in the serialized MXC config'); ok(config.filesystem.readwritePaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/configured/write'), 'Configured Windows allowWrite path should be writable'); ok(config.filesystem.readonlyPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/configured/read'), 'Configured Windows allowRead path should be readonly'); - ok(config.filesystem.readonlyPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/users/user/appdata/local/temp'), 'Host temp path from Windows policy should be readonly'); + ok(config.filesystem.readwritePaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/users/user/appdata/local/temp'), 'Host temp path from Windows policy should be writable'); ok(config.filesystem.deniedPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/configured/secret'), 'Configured Windows denyRead path should be denied'); ok(!config.filesystem.deniedPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/users/user'), 'User home should not be denied by default on Windows'); }); + test('wrapCommand applies configured Windows MXC schema version', async () => { + enableWindowsSandbox(); + setSandboxSetting(AgentSandboxSettingId.AgentSandboxWindowsSchemaVersion, '0.5.0-alpha'); + const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, createWindowsHost())); + + await engine.wrapCommand('echo hello', false, 'pwsh'); + const configPath = await engine.getSandboxConfigPath(); + ok(configPath, 'Config path should be defined'); + const config = JSON.parse(createdFiles.get(configPath)!); + + strictEqual(config.version, '0.5.0-alpha'); + }); + test('resolves Windows filesystem symlinks when writing MXC config', async () => { enableWindowsSandbox(); setSandboxSetting(AgentSandboxSettingId.AgentSandboxWindowsFileSystem, { @@ -431,7 +496,7 @@ suite('TerminalSandboxEngine', () => { ok(configPath, 'Config path should be defined'); const config = JSON.parse(createdFiles.get(configPath)!); - deepStrictEqual(config.network, { defaultPolicy: 'allow' }); + deepStrictEqual(config.network, { defaultPolicy: 'allow', enforcementMode: 'capabilities' }); }); test('uses OS-specific filesystem absolute path detection', async () => { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/sandboxSettingsReader.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/sandboxSettingsReader.ts index d0f1ba7dfdec0..abddf72bcd0c0 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/sandboxSettingsReader.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/sandboxSettingsReader.ts @@ -18,6 +18,7 @@ export const SANDBOX_SETTING_KEYS: readonly string[] = [ AgentSandboxSettingId.AgentSandboxLinuxFileSystem, AgentSandboxSettingId.AgentSandboxMacFileSystem, AgentSandboxSettingId.AgentSandboxWindowsFileSystem, + AgentSandboxSettingId.AgentSandboxWindowsSchemaVersion, AgentSandboxSettingId.AgentSandboxAdvancedRuntime, AgentSandboxSettingId.DeprecatedAgentSandboxEnabled, AgentSandboxSettingId.DeprecatedAgentSandboxLinuxFileSystem, diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index 678aad5bf9e7f..1aed18a25a1e3 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -739,6 +739,12 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary this._resolveSandboxDependencyStatus(), getWindowsMxcFilesystemPolicy: () => this._resolveWindowsMxcFilesystemPolicy(), getWindowsMxcEnvironment: () => this._resolveWindowsMxcEnvironment(), + buildWindowsMxcSandboxPayload: (commandLine, policy, workingDirectory, containerName, containment) => this._resolveWindowsMxcSandboxPayload(commandLine, policy, workingDirectory, containerName, containment), getSandboxSetting: (settingId: string): T | undefined => this._readSandboxSetting(settingId), onDidChangeSandboxSettings: Event.map(onDidChangeSandboxSettings, () => undefined), }; @@ -267,6 +268,17 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb return this._sandboxHelperService.getWindowsMxcEnvironment(); } + private async _resolveWindowsMxcSandboxPayload(commandLine: string, policy: IWindowsMxcSandboxPolicy, workingDirectory?: string, containerName?: string, containment?: IWindowsMxcPolicyContainment): Promise { + const connection = this._remoteAgentService.getConnection(); + if (connection) { + return connection.withChannel(SANDBOX_HELPER_CHANNEL_NAME, channel => { + const sandboxHelper = new SandboxHelperChannelClient(channel); + return sandboxHelper.buildWindowsMxcSandboxPayload(commandLine, policy, workingDirectory, containerName, containment); + }); + } + return this._sandboxHelperService.buildWindowsMxcSandboxPayload(commandLine, policy, workingDirectory, containerName, containment); + } + // ---- workbench-only flows ----------------------------------------------- async installMissingSandboxDependencies(missingDependencies: string[], sessionResource: URI | undefined, token: CancellationToken, options: ISandboxDependencyInstallOptions): Promise { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts index 086f87c5a0d29..8638a2685d243 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts @@ -27,7 +27,7 @@ import { IRemoteAgentEnvironment } from '../../../../../../platform/remote/commo import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder, IWorkspaceFoldersChangeEvent, IWorkspaceIdentifier, ISingleFolderWorkspaceIdentifier, WorkbenchState } from '../../../../../../platform/workspace/common/workspace.js'; import { testWorkspace } from '../../../../../../platform/workspace/test/common/testWorkspace.js'; import { ILifecycleService } from '../../../../../services/lifecycle/common/lifecycle.js'; -import { ISandboxDependencyStatus, ISandboxHelperService, IWindowsMxcFilesystemPolicy } from '../../../../../../platform/sandbox/common/sandboxHelperService.js'; +import { ISandboxDependencyStatus, ISandboxHelperService, type IWindowsMxcConfig, IWindowsMxcFilesystemPolicy, type IWindowsMxcPolicyContainment, type IWindowsMxcSandboxPolicy } from '../../../../../../platform/sandbox/common/sandboxHelperService.js'; import { IWindowsMxcTerminalSandboxRuntime, WindowsMxcTerminalSandboxRuntime } from '../../../../../../platform/sandbox/common/terminalSandboxMxcRuntime.js'; import { getTerminalSandboxRuntimeConfigurationForCommands } from '../../../../../../platform/sandbox/common/terminalSandboxRuntimeConfigurationPerOperation.js'; @@ -189,6 +189,51 @@ suite('TerminalSandboxService - network domains', () => { getWindowsMxcEnvironment(): Promise { return Promise.resolve(this.environment); } + + buildWindowsMxcSandboxPayload(commandLine: string, policy: IWindowsMxcSandboxPolicy, workingDirectory?: string, containerName: string = 'vscode-terminal-sandbox', containment: IWindowsMxcPolicyContainment = 'process'): Promise { + const clearPolicy = policy.filesystem?.clearPolicyOnExit ?? true; + return Promise.resolve({ + version: policy.version, + containerId: containerName, + containment, + lifecycle: { + destroyOnExit: true, + preservePolicy: !clearPolicy, + }, + process: { + commandLine, + cwd: workingDirectory, + timeout: policy.timeoutMs ?? 0, + }, + processContainer: { + name: containerName, + leastPrivilege: false, + capabilities: policy.network?.allowOutbound ? ['internetClient'] : [], + ui: { + isolation: 'container', + desktopSystemControl: false, + systemSettings: 'none', + ime: false, + }, + }, + filesystem: { + readwritePaths: [...(policy.filesystem?.readwritePaths ?? [])], + readonlyPaths: [...(policy.filesystem?.readonlyPaths ?? [])], + deniedPaths: [...(policy.filesystem?.deniedPaths ?? [])], + }, + network: { + defaultPolicy: policy.network?.allowOutbound ? 'allow' : 'block', + ...(policy.network ? { enforcementMode: policy.network.allowedHosts?.length || policy.network.blockedHosts?.length ? 'both' : 'capabilities' } : {}), + ...(policy.network?.allowedHosts ? { allowedHosts: policy.network.allowedHosts } : {}), + ...(policy.network?.blockedHosts ? { blockedHosts: policy.network.blockedHosts } : {}), + }, + ui: { + disable: !(policy.ui?.allowWindows ?? false), + clipboard: policy.ui?.clipboard ?? 'none', + injection: policy.ui?.allowInputInjection ?? false, + }, + }); + } } setup(() => { @@ -1241,7 +1286,8 @@ suite('TerminalSandboxService - network domains', () => { ok(wrapped.command.includes('node_modules\\@microsoft\\mxc-sdk\\bin\\arm64\\wxc-exec.exe'), `Wrapped command should use the MXC Windows executable. Actual: ${wrapped.command}`); ok(wrapped.command.includes(configPath), `Wrapped command should pass the MXC config path. Actual: ${wrapped.command}`); strictEqual(config.version, '0.4.0-alpha'); - strictEqual(config.containment, 'processcontainer'); + strictEqual(config.containment, 'process'); + strictEqual(config.processContainer.name, 'vscode-terminal-sandbox'); strictEqual(config.process.commandLine, '"c:\\program files\\powershell\\7\\pwsh.exe" -NoProfile -ExecutionPolicy Bypass -Command "echo test"'); strictEqual(config.process.cwd, 'c:\\workspace-one'); ok(config.process.env.includes('SystemRoot=c:\\windows'), 'SystemRoot should be injected into the MXC process env'); @@ -1254,7 +1300,6 @@ suite('TerminalSandboxService - network domains', () => { ok(config.process.env.includes('PSHOME=c:\\program files\\powershell\\7'), 'PSHOME should be injected into the MXC process env'); ok(config.filesystem.readwritePaths.includes('c:\\workspace-one'), 'Workspace folder should be writable in the MXC config'); ok(config.filesystem.readwritePaths.some((path: string) => path.includes('tmp_vscode_7')), 'Sandbox temp dir should be writable in the MXC config'); - ok(config.filesystem.readonlyPaths.includes('c:\\app'), 'VS Code app root should be readable in the MXC config'); ok(config.filesystem.readonlyPaths.includes('c:\\tools\\node'), 'MXC available tools policy should add tool paths to readonly paths'); ok(config.filesystem.readonlyPaths.includes('c:\\program files\\powershell\\7'), 'Resolved PowerShell executable directory should be readable in the MXC config'); ok(!config.filesystem.deniedPaths.includes('c:\\Users\\test'), 'User home should not be denied by default in the MXC config on Windows'); From 918b342f5253354a0747ce2a5a696c9b9c2f5bcb Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Fri, 29 May 2026 16:11:19 -0700 Subject: [PATCH 23/27] more perf improvements for pixel spinner (#319030) * more perf improvements for pixel spinner * fix comments * address comments --- .../browser/ui/pixelSpinner/pixelSpinner.css | 18 ++++--- .../browser/ui/pixelSpinner/pixelSpinner.ts | 53 ++++++++++++++++++- 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.css b/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.css index 104359581f762..59910e49d7645 100644 --- a/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.css +++ b/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.css @@ -14,11 +14,19 @@ height: 16px; color: currentColor; pointer-events: none; - contain: layout style size; + contain: layout style size paint; isolation: isolate; transform: translateZ(0); } +/* Pause all dot animations while the spinner is scrolled out of view. The + * `monaco-pixel-spinner-paused` class is toggled from JS by a per-window + * IntersectionObserver. Continuous CSS animations otherwise force the + * compositor to do work every vsync even for spinners that are offscreen. */ +.monaco-pixel-spinner.monaco-pixel-spinner-paused .monaco-pixel-spinner-dot { + animation-play-state: paused; +} + .monaco-pixel-spinner .monaco-pixel-spinner-dot { display: block; width: 2px; @@ -27,8 +35,8 @@ background-color: currentColor; opacity: 0; transform: translateY(-4px); - animation: monaco-pixel-spinner-dot-cycle 1820ms linear infinite; - will-change: transform, opacity; + animation: monaco-pixel-spinner-dot-cycle 1820ms infinite; + animation-timing-function: steps(6, jump-none); } .monaco-pixel-spinner .monaco-pixel-spinner-dot:nth-child(1) { @@ -114,8 +122,7 @@ animation: monaco-pixel-spinner-ring-pulse 1200ms linear infinite; opacity: 0.25; transform: none; - /* Ring keyframes only animate opacity. */ - will-change: opacity; + animation-timing-function: steps(4, jump-none); } .monaco-pixel-spinner.monaco-pixel-spinner-ring .monaco-pixel-spinner-dot:nth-child(1) { @@ -158,6 +165,5 @@ animation: none; opacity: 1; transform: translateY(0); - will-change: auto; } } diff --git a/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.ts b/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.ts index 384dee889bbdd..81c247cc5961f 100644 --- a/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.ts +++ b/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.ts @@ -3,7 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { h } from '../../dom.js'; +import { getWindow, h, onDidUnregisterWindow } from '../../dom.js'; +import { CodeWindow } from '../../window.js'; +import { IDisposable } from '../../../common/lifecycle.js'; import './pixelSpinner.css'; export interface IPixelSpinnerOptions { @@ -49,5 +51,54 @@ export function createPixelSpinner(parent?: HTMLElement, options?: IPixelSpinner root.appendChild(h('span.monaco-pixel-spinner-dot').root); } parent?.appendChild(root); + trackSpinner(root); return root; } + + +const PAUSED_CLASS = 'monaco-pixel-spinner-paused'; +const observersByWindow = new Map(); +let unregisterWindowListener: IDisposable | undefined; + +function getObserverFor(targetWindow: CodeWindow): IntersectionObserver | undefined { + if (typeof targetWindow.IntersectionObserver !== 'function') { + return undefined; + } + let observer = observersByWindow.get(targetWindow); + if (!observer) { + observer = new targetWindow.IntersectionObserver(entries => { + for (const entry of entries) { + const target = entry.target as HTMLElement; + if (!target.isConnected) { + observer!.unobserve(target); + continue; + } + target.classList.toggle(PAUSED_CLASS, !entry.isIntersecting); + } + }); + observersByWindow.set(targetWindow, observer); + + if (!unregisterWindowListener) { + unregisterWindowListener = onDidUnregisterWindow(window => { + const obs = observersByWindow.get(window); + if (obs) { + obs.disconnect(); + observersByWindow.delete(window); + } + }); + } + } + return observer; +} + +function trackSpinner(root: HTMLElement): void { + const observer = getObserverFor(getWindow(root)); + if (!observer) { + return; + } + // Start paused; the observer delivers an initial notification that resumes + // the spinner if it is actually on screen. + root.classList.add(PAUSED_CLASS); + observer.observe(root); +} + From ca1cfd5222ee5dd9b7e5f020b5cdd59a60fca418 Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Fri, 29 May 2026 16:15:17 -0700 Subject: [PATCH 24/27] Browser: Favorites (#319040) * Browser: Favorites * feedback * label --- .../browserView/common/browserView.ts | 4 + .../electron-browser/browserEditor.ts | 22 +- .../browserView.contribution.ts | 1 + .../features/browserFavoritesFeature.ts | 361 ++++++++++++++++++ .../electron-browser/media/browser.css | 31 ++ .../widgets/browserUrlBarWidget.ts | 34 +- .../widgets/browserUrlBarWidget.test.ts | 26 ++ 7 files changed, 453 insertions(+), 26 deletions(-) create mode 100644 src/vs/workbench/contrib/browserView/electron-browser/features/browserFavoritesFeature.ts diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index 2f78b1533b3e4..a1e58de5cbdf7 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -29,6 +29,10 @@ export enum BrowserViewCommandId { OpenExternal = `${commandPrefix}.openExternal`, OpenSettings = `${commandPrefix}.openSettings`, + // Favorites + AddFavorite = `${commandPrefix}.addFavorite`, + RemoveFavorite = `${commandPrefix}.removeFavorite`, + // Chat actions AddElementToChat = `${commandPrefix}.addElementToChat`, AddConsoleLogsToChat = `${commandPrefix}.addConsoleLogsToChat`, diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index 76bbbb6e3263e..a1ff08bc6ae03 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -110,7 +110,7 @@ export abstract class BrowserEditorContribution extends Disposable { get urlRenderers(): readonly IBrowserUrlRenderer[] { return []; } /** - * Optional URL bar suggestion providers (open tabs, history, bookmarks, + * Optional URL bar suggestion providers (open tabs, history, favorites, * search engines, ...). The navbar invokes each provider in sorted order * when the URL picker opens or its value changes, and renders the merged * suggestions below the built-in "Go to" entry. @@ -118,10 +118,9 @@ export abstract class BrowserEditorContribution extends Disposable { get urlSuggestionProviders(): readonly IBrowserUrlSuggestionProvider[] { return []; } /** - * Optional action providers for buttons rendered in the URL picker chrome - * (e.g. a bookmark toggle). The navbar collects buttons from each provider - * when the picker opens and refreshes them when a provider fires - * {@link IBrowserUrlPickerActionProvider.onDidChange}. + * Optional action providers for buttons rendered in the URL picker chrome. + * The navbar collects buttons from each provider when the picker opens + * and refreshes them when a provider fires {@link IBrowserUrlPickerActionProvider.onDidChange}. */ get urlPickerActionProviders(): readonly IBrowserUrlPickerActionProvider[] { return []; } @@ -296,7 +295,7 @@ export interface IBrowserUrlSuggestion { readonly iconPath?: { dark: URI; light?: URI }; /** * Optional per-item actions rendered as inline buttons on the - * suggestion's row (e.g. a delete button on a bookmark suggestion). + * suggestion's row (e.g. a delete button on a favorite suggestion). */ readonly actions?: readonly IBrowserUrlSuggestionAction[]; /** @@ -309,7 +308,7 @@ export interface IBrowserUrlSuggestion { /** * A per-item button rendered inline on a suggestion's row (e.g. a delete - * button on a bookmark). Extends {@link IQuickInputButton} so visual + * button on a favorite). Extends {@link IQuickInputButton} so visual * properties are configured the same way as any other picker button; adds * an {@link id} for identification and a {@link run} callback that receives * the active {@link BrowserEditorInput} so the action can operate on the @@ -331,7 +330,7 @@ export interface IBrowserUrlSuggestionContext { } /** - * A source of URL bar suggestions (open tabs, history, bookmarks, search + * A source of URL bar suggestions (open tabs, history, favorites, search * engines, ...). Contributions return providers via * {@link BrowserEditorContribution.urlSuggestionProviders}. */ @@ -349,16 +348,15 @@ export interface IBrowserUrlSuggestionProvider { /** Sort order between providers. Lower runs first. Defaults to 0. */ readonly order?: number; /** - * Fires when the set of suggestions or any suggestion's state has - * changed (e.g. a bookmark was removed via a per-item action). The - * navbar re-requests suggestions when this fires. + * Fires when the set of suggestions or any suggestion's state has changed. + * The navbar re-requests suggestions when this fires. */ readonly onDidChange?: Event; getSuggestions(context: IBrowserUrlSuggestionContext, token: CancellationToken): Promise; } /** - * A button rendered in the URL picker chrome (e.g. a bookmark toggle). + * A button rendered in the URL picker chrome. * Extends {@link IQuickInputButton} so visual properties (icon, tooltip, * toggle state, location) are configured the same way as any other picker * button; adds an {@link id} for identification and a {@link run} callback diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts index b8891fcd82269..839d9953c344e 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts @@ -23,6 +23,7 @@ import { BrowserViewCDPService } from './browserViewCDPService.js'; import './features/webContentsViewRendererFeature.js'; import './features/browserNavigationFeatures.js'; import './features/browserWelcomeFeature.js'; +import './features/browserFavoritesFeature.js'; import './features/browserDataStorageFeatures.js'; import './features/browserDevToolsFeature.js'; import './features/browserEditorChatFeatures.js'; diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserFavoritesFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserFavoritesFeature.ts new file mode 100644 index 0000000000000..d9864d751a59c --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserFavoritesFeature.ts @@ -0,0 +1,361 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize, localize2 } from '../../../../../nls.js'; +import { $ } from '../../../../../base/browser/dom.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { Button } from '../../../../../base/browser/ui/button/button.js'; +import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js'; +import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; +import { WorkbenchHoverDelegate } from '../../../../../platform/hover/browser/hover.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; +import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { QuickInputButtonLocation } from '../../../../../platform/quickinput/common/quickInput.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { BrowserViewCommandId } from '../../../../../platform/browserView/common/browserView.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IBrowserViewModel } from '../../common/browserView.js'; +import { + BROWSER_EDITOR_ACTIVE, + BrowserActionCategory, + BrowserActionGroup, + BrowserEditor, + BrowserEditorContribution, + BrowserWidgetLocation, + CONTEXT_BROWSER_HAS_URL, + IBrowserEditorWidget, + IBrowserUrlPickerAction, + IBrowserUrlPickerActionProvider, + IBrowserUrlSuggestion, + IBrowserUrlSuggestionAction, + IBrowserUrlSuggestionProvider, +} from '../browserEditor.js'; + +const CONTEXT_BROWSER_URL_IS_FAVORITED = new RawContextKey('browserUrlIsFavorited', false, localize('browser.urlIsFavorited', "Whether the current browser URL is a favorite")); + +/** + * Clickable star indicator shown in the URL bar's PostUrl slot when the + * current page is a favorite. Clicking it removes the favorite. + */ +class FavoriteIndicator extends Disposable { + readonly element: HTMLElement; + private readonly _button: Button; + private readonly _onDidClick = this._register(new Emitter()); + readonly onDidClick = this._onDidClick.event; + + constructor( + instantiationService: IInstantiationService, + private readonly _keybindingService: IKeybindingService, + ) { + super(); + const hoverDelegate = this._register(instantiationService.createInstance( + WorkbenchHoverDelegate, + 'element', + undefined, + { position: { hoverPosition: HoverPosition.ABOVE } } + )); + + this.element = $('.browser-favorite-indicator-container'); + this.element.style.display = 'none'; + this._button = this._register(new Button(this.element, { + supportIcons: true, + title: this._tooltip(), + small: true, + hoverDelegate + })); + this._button.element.classList.add('browser-favorite-indicator'); + this._button.label = `$(${Codicon.starFull.id})`; + this._button.element.setAttribute('aria-label', localize('browser.removeFavorite', "Remove from Favorites")); + this._register(this._button.onDidClick(() => this._onDidClick.fire())); + this._register(this._keybindingService.onDidUpdateKeybindings(() => { + this._button.setTitle(this._tooltip()); + })); + } + + private _tooltip(): string { + const kb = this._keybindingService.lookupKeybinding(BrowserViewCommandId.RemoveFavorite)?.getLabel(); + return kb + ? localize('browser.removeFavoriteWithKb', "Remove from Favorites ({0})", kb) + : localize('browser.removeFavorite', "Remove from Favorites"); + } + + setVisible(visible: boolean): void { + this.element.style.display = visible ? '' : 'none'; + } +} + +/** + * Workspace-scoped favorites: persists a set of favorite URLs and surfaces + * them as URL bar suggestions plus a toggle button in the picker chrome. + * + * Favorites are URL strings only — no titles, icons, or other metadata are + * persisted. We can't reliably capture rich metadata for arbitrary pages + * across reloads, and keeping the model simple avoids stale-display bugs. + */ +export class BrowserFavoritesFeature extends BrowserEditorContribution { + + private static readonly STORAGE_KEY = 'workbench.browser.favorites'; + + private readonly _onDidChangeState = this._register(new Emitter()); + private _urls = new Set(); + + private readonly _suggestionProvider: IBrowserUrlSuggestionProvider; + private readonly _actionProvider: IBrowserUrlPickerActionProvider; + private readonly _indicator: FavoriteIndicator; + private readonly _isFavoriteContext: IContextKey; + + constructor( + editor: BrowserEditor, + @IStorageService private readonly _storageService: IStorageService, + @IInstantiationService instantiationService: IInstantiationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IKeybindingService private readonly _keybindingService: IKeybindingService, + ) { + super(editor); + this._load(); + this._isFavoriteContext = CONTEXT_BROWSER_URL_IS_FAVORITED.bindTo(contextKeyService); + + this._indicator = this._register(new FavoriteIndicator(instantiationService, this._keybindingService)); + this._register(this._indicator.onDidClick(() => this.toggleCurrent())); + + // React to external storage updates (e.g. another window writing the key). + const storageListenerStore = this._register(new DisposableStore()); + this._register(this._storageService.onDidChangeValue( + StorageScope.WORKSPACE, BrowserFavoritesFeature.STORAGE_KEY, storageListenerStore, + )(() => { + this._load(); + this._refresh(); + this._onDidChangeState.fire(); + })); + + this._suggestionProvider = { + label: localize('browser.favorites', "Favorites"), + order: 50, + onDidChange: this._onDidChangeState.event, + getSuggestions: async ({ input }) => { + const suggestions: IBrowserUrlSuggestion[] = []; + const current = input.url; + for (const url of this._urls) { + if (url === current) { + continue; + } + const deleteAction: IBrowserUrlSuggestionAction = { + id: 'browser.favorites.delete', + iconClass: ThemeIcon.asClassName(Codicon.trash), + tooltip: localize('browser.removeFavorite', "Remove from Favorites"), + run: () => this._remove(url), + }; + suggestions.push({ + id: 'favorite:' + url, + label: url, + icon: Codicon.star, + apply: target => target.navigate(url), + actions: [deleteAction], + }); + } + return suggestions; + }, + }; + + this._actionProvider = { + onDidChange: this._onDidChangeState.event, + getActions: input => { + const url = input.url; + if (!url) { + return []; + } + const favorite = this._urls.has(url); + const tooltip = favorite + ? localize('browser.removeFavorite', "Remove from Favorites") + : localize('browser.addFavorite', "Add to Favorites"); + const action: IBrowserUrlPickerAction = { + id: 'browser.toggleFavorite', + iconClass: ThemeIcon.asClassName(favorite ? Codicon.starFull : Codicon.star), + tooltip, + alwaysVisible: true, + toggle: { checked: favorite }, + location: QuickInputButtonLocation.Input, + run: target => { + const u = target.url; + if (u) { + this._toggle(u); + } + }, + }; + return [action]; + }, + }; + } + + override get widgets(): readonly IBrowserEditorWidget[] { + return [{ location: BrowserWidgetLocation.PostUrl, element: this._indicator.element, order: 60 }]; + } + + override get urlSuggestionProviders(): readonly IBrowserUrlSuggestionProvider[] { + return [this._suggestionProvider]; + } + + override get urlPickerActionProviders(): readonly IBrowserUrlPickerActionProvider[] { + return [this._actionProvider]; + } + + protected override onModelAttached(model: IBrowserViewModel, store: DisposableStore): void { + // Button visuals, indicator visibility, and context key depend on input.url. + store.add(model.onDidNavigate(() => { + this._refresh(); + this._onDidChangeState.fire(); + })); + this._refresh(); + } + + override onModelDetached(): void { + this._isFavoriteContext.reset(); + this._indicator.setVisible(false); + } + + isFavorite(url: string): boolean { + return this._urls.has(url); + } + + toggleCurrent(): void { + const url = this.editor.model?.url; + if (url) { + this._toggle(url); + } + } + + private _refresh(): void { + const url = this.editor.model?.url ?? ''; + const favorite = !!url && this._urls.has(url); + this._isFavoriteContext.set(favorite); + this._indicator.setVisible(favorite); + } + + private _load(): void { + const raw = this._storageService.get(BrowserFavoritesFeature.STORAGE_KEY, StorageScope.WORKSPACE); + if (!raw) { + this._urls = new Set(); + return; + } + try { + const parsed: unknown = JSON.parse(raw); + this._urls = new Set( + Array.isArray(parsed) ? parsed.filter((u): u is string => typeof u === 'string') : [] + ); + } catch { + this._urls = new Set(); + } + } + + private _toggle(url: string): void { + if (this._urls.has(url)) { + this._urls.delete(url); + } else { + this._urls.add(url); + } + this._storageService.store( + BrowserFavoritesFeature.STORAGE_KEY, + JSON.stringify([...this._urls]), + StorageScope.WORKSPACE, + StorageTarget.USER, + ); + this._refresh(); + this._onDidChangeState.fire(); + } + + // Idempotent: callers that should never re-add a favorite (e.g. the per-item + // delete button on suggestions) must use this rather than `_toggle`. + private _remove(url: string): void { + if (!this._urls.has(url)) { + return; + } + this._urls.delete(url); + this._storageService.store( + BrowserFavoritesFeature.STORAGE_KEY, + JSON.stringify([...this._urls]), + StorageScope.WORKSPACE, + StorageTarget.USER, + ); + this._refresh(); + this._onDidChangeState.fire(); + } +} + +BrowserEditor.registerContribution(BrowserFavoritesFeature); + +// -- Actions ---------------------------------------------------------- + +class AddFavoriteAction extends Action2 { + static readonly ID = BrowserViewCommandId.AddFavorite; + + constructor() { + super({ + id: AddFavoriteAction.ID, + title: localize2('browser.addFavoriteAction', 'Add to Favorites'), + category: BrowserActionCategory, + icon: Codicon.star, + f1: true, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_URL_IS_FAVORITED.negate()), + menu: { + id: MenuId.BrowserActionsToolbar, + group: BrowserActionGroup.Page, + order: 5, + when: CONTEXT_BROWSER_URL_IS_FAVORITED.negate(), + }, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + when: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_URL_IS_FAVORITED.negate()), + primary: KeyMod.CtrlCmd | KeyCode.KeyD, + } + }); + } + + async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + if (browserEditor instanceof BrowserEditor) { + browserEditor.getContribution(BrowserFavoritesFeature)?.toggleCurrent(); + } + } +} + +class RemoveFavoriteAction extends Action2 { + static readonly ID = BrowserViewCommandId.RemoveFavorite; + + constructor() { + super({ + id: RemoveFavoriteAction.ID, + title: localize2('browser.removeFavoriteAction', 'Remove from Favorites'), + category: BrowserActionCategory, + icon: Codicon.starFull, + f1: true, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_URL_IS_FAVORITED), + menu: { + id: MenuId.BrowserActionsToolbar, + group: BrowserActionGroup.Page, + order: 5, + when: CONTEXT_BROWSER_URL_IS_FAVORITED, + }, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + when: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_URL_IS_FAVORITED), + primary: KeyMod.CtrlCmd | KeyCode.KeyD, + } + }); + } + + async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + if (browserEditor instanceof BrowserEditor) { + browserEditor.getContribution(BrowserFavoritesFeature)?.toggleCurrent(); + } + } +} + +registerAction2(AddFavoriteAction); +registerAction2(RemoveFavoriteAction); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css index d248082bb2eb5..c1f77d41b4d8b 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css +++ b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css @@ -243,6 +243,37 @@ } } + .browser-favorite-indicator-container { + display: flex; + align-items: center; + margin: 0; + flex-shrink: 0; + + .browser-favorite-indicator { + padding: 2px; + border-radius: var(--vscode-cornerRadius-small); + border-width: 0; + color: var(--vscode-descriptionForeground); + background-color: transparent; + outline: none !important; + + &:focus-visible { + outline: 1px solid var(--vscode-focusBorder) !important; + outline-offset: 0px !important; + } + + .codicon { + margin: 0; + font-size: 16px; + } + + &:hover { + background-color: var(--vscode-toolbar-hoverBackground) !important; + color: var(--vscode-foreground); + } + } + } + .browser-share-toggle-container { display: flex; align-items: center; diff --git a/src/vs/workbench/contrib/browserView/electron-browser/widgets/browserUrlBarWidget.ts b/src/vs/workbench/contrib/browserView/electron-browser/widgets/browserUrlBarWidget.ts index f2eac4787d649..e01555ffd67a6 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/widgets/browserUrlBarWidget.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/widgets/browserUrlBarWidget.ts @@ -68,10 +68,8 @@ export class BrowserUrlBarWidget extends Disposable { private readonly _pickerActionProviders: IBrowserUrlPickerActionProvider[] = []; private readonly _picker = this._register(new MutableDisposable>()); - // One-shot suppression of the display's "focus opens picker" path. Set - // when we programmatically move focus to the display (e.g. picker hide - // restoring focus) so the picker doesn't immediately re-open. private _suppressFocusOpen = false; + private _suppressBlurRevert = false; constructor( private readonly _host: IBrowserUrlBarHost, @@ -222,6 +220,12 @@ export class BrowserUrlBarWidget extends Disposable { if (this._picker.value) { return; } + // One-shot bypass after an Enter-commit on the display: keep the + // typed value visible until the navigation commits. + if (this._suppressBlurRevert) { + this._suppressBlurRevert = false; + return; + } // User left the URL bar without navigating; discard any in-progress // edit and snap back to the canonical URL. if ((this._urlDisplay.textContent ?? '') !== this._canonicalUrl) { @@ -249,7 +253,12 @@ export class BrowserUrlBarWidget extends Disposable { e.preventDefault(); const value = this._urlDisplay.textContent?.trim() ?? ''; if (value) { + // Suppress the next BLUR-revert: the user committed to + // this value, so we don't want it discarded just because + // `model.url` won't catch up until navigation commits. + this._suppressBlurRevert = true; this._host.input?.navigate(value); + this._host.ensureBrowserFocus(); } return; } @@ -437,9 +446,8 @@ export class BrowserUrlBarWidget extends Disposable { item.iconClass = ThemeIcon.asClassName(s.icon); } if (s.actions && s.actions.length > 0) { - // Per-item buttons (e.g. delete on a bookmark). We pass - // the action objects through directly so onDidTriggerItemButton - // hands them back to us as the IBrowserUrlSuggestionAction. + // Per-item buttons. We pass the action objects through directly + // so onDidTriggerItemButton hands them back to us as the IBrowserUrlSuggestionAction. item.buttons = s.actions; } items.push(item); @@ -518,8 +526,7 @@ export class BrowserUrlBarWidget extends Disposable { }; applyItems(picker.value); - // Re-run providers if any of them reports a state change while the - // picker is open (e.g. a bookmark removed via a per-item action). + // Re-run providers if any of them reports a state change while the picker is open. for (const provider of this._suggestionProviders) { if (provider.onDidChange) { disposables.add(provider.onDidChange(() => applyItems(picker.value))); @@ -547,7 +554,7 @@ export class BrowserUrlBarWidget extends Disposable { this._renderUrl(value); })); - // Mount provider-contributed picker actions (e.g. bookmark toggle). + // Mount provider-contributed picker actions. // Re-build buttons whenever any provider reports a state change so // dynamic actions (toggles, conditional buttons) stay in sync. const refreshButtons = () => { @@ -583,11 +590,10 @@ export class BrowserUrlBarWidget extends Disposable { } })); - // Per-item button (e.g. delete on a bookmark suggestion). We attached - // the IBrowserUrlSuggestionAction directly as the picker button, so - // the event hands it back to us by reference. Unlike onDidTriggerButton - // this does NOT count as "the user accepted the suggestion" — the - // picker stays open and the action runs in-place. + // Per-item button. We attached the IBrowserUrlSuggestionAction directly + // as the picker button, so the event hands it back to us by reference. + // Unlike onDidTriggerButton this does NOT count as "the user accepted the suggestion" + // — the picker stays open and the action runs in-place. disposables.add(picker.onDidTriggerItemButton(({ button }) => { const action = button as IBrowserUrlSuggestionAction; const input = this._host.input; diff --git a/src/vs/workbench/contrib/browserView/test/electron-browser/widgets/browserUrlBarWidget.test.ts b/src/vs/workbench/contrib/browserView/test/electron-browser/widgets/browserUrlBarWidget.test.ts index 97702714f9c70..1b1f69201094c 100644 --- a/src/vs/workbench/contrib/browserView/test/electron-browser/widgets/browserUrlBarWidget.test.ts +++ b/src/vs/workbench/contrib/browserView/test/electron-browser/widgets/browserUrlBarWidget.test.ts @@ -427,6 +427,32 @@ suite('BrowserUrlBarWidget', () => { ); }); + test('pressing Enter on the display navigates and preserves the typed text through the subsequent blur', () => { + const harness = makeHarness(); + const { widget, display, navigated } = harness; + widget.focusUrlInput(); + display.textContent = 'https://typed-into-display.test/'; + // `StandardKeyboardEvent` reads the (deprecated) numeric `keyCode`, + // so pass it explicitly (Enter == 13) rather than relying on `key`. + display.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 13, key: 'Enter', bubbles: true, cancelable: true } as KeyboardEventInit)); + display.blur(); + // `model.url` (canonical) hasn't caught up to the typed URL yet, but + // the BLUR-revert should be suppressed for an Enter-commit so the + // destination stays visible until the navigation commits. + assert.deepStrictEqual( + { + navigated: [...navigated], + display: display.textContent, + ensureBrowserFocusCalls: harness.ensureBrowserFocusCalls(), + }, + { + navigated: ['https://typed-into-display.test/'], + display: 'https://typed-into-display.test/', + ensureBrowserFocusCalls: 1, + }, + ); + }); + test('suggestion provider onDidChange reruns the load', async () => { const { widget, picker } = makeHarness(); const refresh = new Emitter(); From a7332261ad6b27620b7d4d06ec084b78a854ae34 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Sat, 30 May 2026 01:25:04 +0200 Subject: [PATCH 25/27] feat: add getWorkingDirectory method to agent host session providers and related services for distiguishing local from user customizations + several bug fixes (#319047) * feat: add getWorkingDirectory method to agent host session providers and related services for distiguishing local from user customizations + several bug fixes * update --- .../common/agentHostSessionsProvider.ts | 5 ++ .../browser/baseAgentHostSessionsProvider.ts | 5 ++ ...emoteAgentHostCustomizationHarness.test.ts | 3 + .../browser/agentHostCustomizationService.ts | 68 +++++++++++++------ .../agentCustomizationItemProvider.ts | 6 +- .../agentHostCustomizationService.ts | 10 +++ .../agentHost/agentHostLocalCustomizations.ts | 3 +- 7 files changed, 75 insertions(+), 25 deletions(-) diff --git a/src/vs/sessions/common/agentHostSessionsProvider.ts b/src/vs/sessions/common/agentHostSessionsProvider.ts index e7bd5df693430..eca1153d9b83b 100644 --- a/src/vs/sessions/common/agentHostSessionsProvider.ts +++ b/src/vs/sessions/common/agentHostSessionsProvider.ts @@ -120,6 +120,11 @@ export interface IAgentHostSessionsProvider extends ISessionsProvider { */ getCustomizations(sessionId: string): readonly Customization[]; + /** + * Returns the working directory for the session, if provided by the host. + */ + getWorkingDirectory(sessionId: string): string | undefined; + /** * Set (or clear) the selected custom agent for a session. Optional so * providers that don't expose custom agents can omit it. diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 5a0210a52d229..3386bf7085f6d 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -1698,6 +1698,11 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return sessionState?.customizations ?? []; } + getWorkingDirectory(sessionId: string): string | undefined { + const sessionState = this._lastSessionStates.get(sessionId); + return sessionState?.summary.workingDirectory; + } + // -- Session actions ------------------------------------------------------ async archiveSession(sessionId: string): Promise { diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts index 5cbd89d9a46c3..2c01c653b3cb7 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts @@ -150,6 +150,9 @@ function createTestCustomAgentsService(connection: MockAgentConnection, rootCust } return [...rootCustomizations, ...(sessionState.customizations ?? [])]; }, + getWorkingDirectory(sessionResource: URI): string | undefined { + return undefined; + } }; } diff --git a/src/vs/sessions/services/agentHost/browser/agentHostCustomizationService.ts b/src/vs/sessions/services/agentHost/browser/agentHostCustomizationService.ts index 043befb62263b..2919dae171c11 100644 --- a/src/vs/sessions/services/agentHost/browser/agentHostCustomizationService.ts +++ b/src/vs/sessions/services/agentHost/browser/agentHostCustomizationService.ts @@ -5,7 +5,7 @@ import { URI } from '../../../../base/common/uri.js'; import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; +import { combinedDisposable, Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; import { basename } from '../../../../base/common/resources.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IAgentHostCustomizationService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.js'; @@ -14,6 +14,7 @@ import { ISessionsProvidersService } from '../../sessions/browser/sessionsProvid import { ISessionsManagementService } from '../../sessions/common/sessionsManagement.js'; import { ISessionsProvider } from '../../sessions/common/sessionsProvider.js'; import { AgentCustomization, Customization, CustomizationType } from '../../../../platform/agentHost/common/state/sessionState.js'; +import { ISession } from '../../sessions/common/session.js'; export class AgentHostCustomizationService extends Disposable implements IAgentHostCustomizationService { declare readonly _serviceBrand: undefined; @@ -34,37 +35,56 @@ export class AgentHostCustomizationService extends Disposable implements IAgentH })); } - getCustomAgents(sessionResource: URI): readonly AgentCustomization[] { - const session = this._sessionsManagementService.activeSession.get(); - if (!session || session.resource.toString() !== sessionResource.toString()) { - return []; + private _getSession(sessionResource: URI): ISession | undefined { + const activeSession = this._sessionsManagementService.activeSession.get(); + if (activeSession && activeSession.resource.toString() === sessionResource.toString()) { + return activeSession; } + return this._sessionsManagementService.getSession(sessionResource); + } + private _getAHSProvider(session: ISession): IAgentHostSessionsProvider | undefined { const provider = this._sessionsProvidersService.getProvider(session.providerId); if (provider && isAgentHostProvider(provider)) { this._ensureProviderListener(provider); - const agents = provider.getCustomAgents(session.sessionId); - const activeMode = session.mode.get()?.id; - const result = agents.length === 0 && activeMode ? [this._agentFromMode(activeMode)] : agents; - return result; + return provider; } + return undefined; + } + getCustomAgents(sessionResource: URI): readonly AgentCustomization[] { + const session = this._getSession(sessionResource); + if (session) { + const provider = this._getAHSProvider(session); + if (provider) { + const agents = provider.getCustomAgents(session.sessionId); + const activeMode = session.mode.get()?.id; + return agents.length === 0 && activeMode ? [this._agentFromMode(activeMode)] : agents; + } + } return []; } getCustomizations(sessionResource: URI): readonly Customization[] { - const session = this._sessionsManagementService.getSession(sessionResource); - if (!session) { - return []; + const session = this._getSession(sessionResource); + if (session) { + const provider = this._getAHSProvider(session); + if (provider) { + return provider.getCustomizations(session.sessionId); + } } + return []; + } - const provider = this._sessionsProvidersService.getProvider(session.providerId); - if (provider && isAgentHostProvider(provider)) { - this._ensureProviderListener(provider); - return provider.getCustomizations(session.sessionId); + getWorkingDirectory(sessionResource: URI): string | undefined { + const session = this._getSession(sessionResource); + if (session) { + const provider = this._getAHSProvider(session); + if (provider) { + return provider.getWorkingDirectory(session.sessionId); + } } - - return []; + return undefined; } @@ -73,9 +93,15 @@ export class AgentHostCustomizationService extends Disposable implements IAgentH return; } - this._providerListeners.set(provider, provider.onDidChangeCustomAgents(() => { - this._onDidChangeCustomAgents.fire(); - })); + // Keep both subscriptions alive under one map key so replacing the provider entry disposes both together. + this._providerListeners.set(provider, combinedDisposable( + provider.onDidChangeCustomAgents(() => { + this._onDidChangeCustomAgents.fire(); + }), + provider.onDidChangeCustomizations(() => { + this._onDidChangeCustomizations.fire(); + }) + )); } private _agentFromMode(uri: string): AgentCustomization { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts index 117d38c03ab17..eeab0f9015b2e 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts @@ -204,9 +204,11 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto } } + const workingDirectory = this._customAgentsService.getWorkingDirectory(sessionResource); + for (const sessionCustomization of directoryCustomizations) { - const source = AICustomizationSources.local; // TODO - const groupKey = undefined; //sessionCustomization.clientId ? REMOTE_CLIENT_GROUP : REMOTE_HOST_GROUP; + const source = workingDirectory && sessionCustomization.uri.startsWith(workingDirectory) ? AICustomizationSources.local : AICustomizationSources.user; + const groupKey = sessionCustomization.clientId ? REMOTE_CLIENT_GROUP : undefined; for (const child of this.toDirectoryItems(sessionCustomization, source, groupKey)) { items.set(child.itemKey ?? child.uri.toString(), { ...child, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts index 049d05b732262..2314cfd4c7a85 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts @@ -30,6 +30,8 @@ export interface IAgentHostCustomizationService { getCustomAgents(sessionResource: URI): readonly AgentCustomization[]; getCustomizations(sessionResource: URI): readonly Customization[]; + + getWorkingDirectory(sessionResource: URI): string | undefined; } export class NullAgentHostCustomizationService implements IAgentHostCustomizationService { @@ -42,6 +44,9 @@ export class NullAgentHostCustomizationService implements IAgentHostCustomizatio getCustomizations(_sessionResource: URI): readonly Customization[] { return []; } + getWorkingDirectory(sessionResource: URI): string | undefined { + return undefined; + } } class WorkbenchAgentHostCustomizationService extends Disposable implements IAgentHostCustomizationService { @@ -101,6 +106,11 @@ class WorkbenchAgentHostCustomizationService extends Disposable implements IAgen return sessionState?.customizations ?? []; } + getWorkingDirectory(sessionResource: URI): string | undefined { + const sessionState = this._readSessionState(sessionResource); + return sessionState?.summary.workingDirectory; + } + private _readSessionState(sessionResource: URI): SessionState | undefined { const backendSession = this._resolveBackendSession(sessionResource); const value = backendSession ? this._ensureSessionStateSubscription(sessionResource, backendSession)?.sub.value : undefined; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts index 13f9d55fc8283..5b7d936293daf 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts @@ -35,8 +35,7 @@ export const SYNCABLE_PROMPT_TYPES: readonly PromptsType[] = [ */ export const SYNCABLE_STORAGE_SOURCES: readonly PromptsStorage[] = [ PromptsStorage.plugin, - PromptsStorage.extension, - PromptsStorage.user, + PromptsStorage.extension ]; export interface ILocalCustomizationFile { From 82e07a4d6e65757cdb694435492879111c091ec1 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Fri, 29 May 2026 16:31:56 -0700 Subject: [PATCH 26/27] Forward GitHub token in createSession/resumeSession RPC (#319029) Forward GitHub token in createSession/resumeSession RPC (#318693) The Copilot SDK has two GitHub-token slots: - Client-level (CopilotClientOptions.gitHubToken): hands the token to the spawned CLI subprocess via an environment variable. The CLI then does its own HTTP fetch to api.github.com to turn the bytes into an AuthInfo. If that fetch fails (slow/proxied network on the SSH host, transient 401, etc.) the CLI is left permanently unauthenticated because we also pass useLoggedInUser: false to disable the stored- OAuth fallback. Sessions created against that CLI inherit no AuthInfo and fail on first send with 'Session was not created with authentication info or custom provider'. - Session-level (SessionConfig.gitHubToken): the token travels inside the createSession RPC payload itself. The CLI resolves it as part of the create handler, so the session always carries its own AuthInfo regardless of whether the env-var bootstrap settled. Pass the cached _githubToken through _buildSessionConfig so both client.createSession and client.resumeSession get session-level auth. This makes session start independent of the fragile env-var bootstrap that fails for users with slow/proxied paths to api.github.com from the remote. Add a regression test asserting the token is forwarded. The client-level env-var path is left in place because client-scoped SDK calls (listModels, listSessions, etc.) have no per-call token override; those calls are tolerant of transient auth failures and are not user-facing. Fixes #318693 (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/copilot/copilotAgent.ts | 8 ++++++ .../agentHost/test/node/copilotAgent.test.ts | 28 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index caff84d032294..d0d9d0da5ac95 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -1547,6 +1547,14 @@ export class CopilotAgent extends Disposable implements IAgent { instructionDirectories: toSdkInstructionDirectories(plugins.flatMap(p => p.instructions)), systemMessage: COPILOT_AGENT_HOST_SYSTEM_MESSAGE, tools: [...shellTools, ...callbacks.clientTools], + // Pass the GitHub token at the session level. The SDK's + // client-level `gitHubToken` authenticates the CLI process, + // but each session also needs its own token resolved into a + // GitHub identity (login, Copilot plan, endpoints) to drive + // model routing and quota — without this the session + // errors with "Session was not created with authentication + // info or custom provider" on first send. See #318693. + gitHubToken: this._githubToken, // Enable infinite sessions so the SDK provisions a workspace // directory (containing `plan.md`, `checkpoints/`, `files/`). // The workspace is required for plan mode to work — without diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 768b61019919d..f191c505bc2b5 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -888,6 +888,34 @@ suite('CopilotAgent', () => { } }); + test('materialization forwards the GitHub token to the SDK at the session level (#318693)', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([]); + let capturedConfig: Parameters[0] | undefined; + client.createSession = async config => { + capturedConfig = config; + return new MockCopilotSession() as unknown as CopilotSession; + }; + + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); + try { + await agent.authenticate('https://api.github.com', 'gh-token-abc'); + + const result = await agent.createSession({ + session: AgentSession.uri('copilotcli', 'session-level-token'), + workingDirectory: URI.file('/workspace'), + }); + assert.strictEqual(result.provisional, true); + + await agent.sendMessage(result.session, 'hello'); + + assert.strictEqual(capturedConfig?.gitHubToken, 'gh-token-abc', + 'createSession should receive the GitHub token at session level so the SDK can resolve a per-session GitHub identity'); + } finally { + await disposeAgent(agent); + } + }); + test('materialization skips managed shell tools when root config disables the custom terminal tool', async () => { const sessionDataService = disposables.add(new TestSessionDataService()); const client = new TestCopilotClient([]); From 0905b9d32c459b7287141526ebdb55e6fd61b42a Mon Sep 17 00:00:00 2001 From: Anthony Kim <62267334+anthonykim1@users.noreply.github.com> Date: Fri, 29 May 2026 17:08:20 -0700 Subject: [PATCH 27/27] Agent Host Copilot CLI: Support async shell completion notifications (#318511) * Support Copilot async shell completions with AHP message origins * removed unused import * fix test --- .../common/state/protocol/.ahp-version | 2 +- .../channels-resource-watch/reducer.ts | 3 +- .../protocol/channels-session/actions.ts | 16 +- .../protocol/channels-session/commands.ts | 6 +- .../protocol/channels-session/reducer.ts | 6 +- .../state/protocol/channels-session/state.ts | 45 +++-- .../common/state/protocol/common/commands.ts | 3 +- .../common/state/protocol/version/registry.ts | 4 +- .../agentHost/common/state/sessionState.ts | 10 +- .../platform/agentHost/node/agentService.ts | 6 +- .../agentHost/node/agentSideEffects.ts | 15 +- .../node/claude/claudeAgentSession.ts | 4 +- .../node/claude/claudeReplayMapper.ts | 3 +- .../node/copilot/copilotAgentSession.ts | 66 +++++-- .../node/copilot/copilotSessionWrapper.ts | 5 + .../node/copilot/copilotSystemNotification.ts | 52 +++++ .../node/copilot/mapSessionEvents.ts | 14 +- .../test/node/agentHostStateManager.test.ts | 32 +-- .../agentHost/test/node/agentService.test.ts | 20 +- .../test/node/agentSideEffects.test.ts | 49 ++--- .../agentHost/test/node/claudeAgent.test.ts | 15 +- .../test/node/claudeReplayMapper.test.ts | 6 +- .../test/node/claudeSubagentRegistry.test.ts | 4 +- .../test/node/claudeSubagentResolver.test.ts | 5 +- .../agentHost/test/node/copilotAgent.test.ts | 6 +- .../test/node/copilotAgentSession.test.ts | 187 ++++++++++++++++-- .../test/node/historyRecordFixtures.ts | 10 +- .../test/node/protocol/realSdkTestHelpers.ts | 3 +- .../sessionFeatures.integrationTest.ts | 10 +- .../sessionLifecycle.integrationTest.ts | 2 +- .../test/node/protocol/testHelpers.ts | 3 +- .../test/node/protocolServerHandler.test.ts | 14 +- .../agentHost/test/node/reducers.test.ts | 4 +- .../browser/remoteAgentHost.contribution.ts | 1 + .../agentHost/agentHostChatContribution.ts | 1 + .../agentHost/agentHostSessionHandler.ts | 51 +++-- .../agentHost/stateToProgressAdapter.ts | 49 ++++- .../common/chatService/chatServiceImpl.ts | 23 ++- .../chat/common/chatSessionsService.ts | 10 +- .../agentHostChatContribution.test.ts | 139 ++++++++----- .../agentHostClientTools.test.ts | 10 +- .../stateToProgressAdapter.test.ts | 58 +++++- .../common/chatService/chatService.test.ts | 2 +- 43 files changed, 713 insertions(+), 261 deletions(-) create mode 100644 src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index 75217cb45e51c..847391941ffb7 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -dd91062 +eafb1d7 diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-resource-watch/reducer.ts b/src/vs/platform/agentHost/common/state/protocol/channels-resource-watch/reducer.ts index 79a656e66caf2..52683772409b1 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-resource-watch/reducer.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-resource-watch/reducer.ts @@ -7,7 +7,6 @@ // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts import { ActionType } from '../common/actions.js'; -import { softAssertNever } from '../common/reducer-helpers.js'; import type { ResourceWatchState } from './state.js'; import type { ResourceWatchAction } from '../action-origin.generated.js'; @@ -34,6 +33,6 @@ export function resourceWatchReducer(state: ResourceWatchState, action: Resource return state; } - softAssertNever(action as never, log); + (log ?? console.warn)(`Unhandled action type: ${JSON.stringify(action)}`); return state; } diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts index 55ddb371ecdab..945984d503b45 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts @@ -8,7 +8,7 @@ import { ActionType } from '../common/actions.js'; import type { StringOrMarkdown, ErrorInfo, FileEdit, UsageInfo } from '../common/state.js'; -import { ToolCallConfirmationReason, ToolCallCancellationReason, PendingMessageKind, type UserMessage, type ResponsePart, type ToolCallResult, type ToolResultContent, type ToolDefinition, type SessionActiveClient, type Customization, type SessionInputAnswer, type SessionInputRequest, type SessionInputResponseKind, type ConfirmationOption, type AgentSelection } from './state.js'; +import { ToolCallConfirmationReason, ToolCallCancellationReason, PendingMessageKind, type Message, type ResponsePart, type ToolCallResult, type ToolResultContent, type ToolDefinition, type SessionActiveClient, type Customization, type SessionInputAnswer, type SessionInputRequest, type SessionInputResponseKind, type ConfirmationOption, type AgentSelection } from './state.js'; import type { ModelSelection } from '../channels-root/state.js'; import type { ChangesetSummary } from '../channels-changeset/state.js'; @@ -62,7 +62,9 @@ export interface SessionCreationFailedAction { } /** - * User sent a message; server starts agent processing. + * A new message has been sent to the agent, and a new turn starts. + * + * A client is only allowed to send {@link MessageKind.User} messages. * * @category Session Actions * @version 1 @@ -72,8 +74,8 @@ export interface SessionTurnStartedAction { type: ActionType.SessionTurnStarted; /** Turn identifier */ turnId: string; - /** User's message */ - userMessage: UserMessage; + /** The new message */ + message: Message; /** If this turn was auto-started from a queued message, the ID of that message */ queuedMessageId?: string; } @@ -225,7 +227,7 @@ export interface SessionToolCallDeniedAction extends ToolCallActionBase { /** Why the tool was cancelled */ reason: ToolCallCancellationReason.Denied | ToolCallCancellationReason.Skipped; /** What the user suggested doing instead */ - userSuggestion?: UserMessage; + userSuggestion?: Message; /** Optional explanation for the denial */ reasonMessage?: StringOrMarkdown; /** ID of the selected confirmation option, if the server provided options */ @@ -684,6 +686,8 @@ export interface SessionTruncatedAction { * idle when a queued message is set, the server SHOULD immediately consume it * and start a new turn. * + * A client is only allowed to send {@link MessageKind.User} messages. + * * @category Session Actions * @version 1 * @clientDispatchable @@ -695,7 +699,7 @@ export interface SessionPendingMessageSetAction { /** Unique identifier for this pending message */ id: string; /** The message content */ - userMessage: UserMessage; + message: Message; } /** diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts index cf6d3c2bc5adf..69d02b6f67a9a 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts @@ -165,7 +165,7 @@ export interface FetchTurnsResult { */ export const enum CompletionItemKind { /** - * Completions for the text of a {@link UserMessage} the user is composing. + * Completions for the text of a {@link Message} the user is composing. * Each returned item carries an attachment that gets associated with the * message when accepted. */ @@ -235,7 +235,7 @@ export interface CompletionsParams extends BaseParams { * When the user accepts an item, the client SHOULD: * 1. Replace the range `[rangeStart, rangeEnd)` in the input with `insertText` * (or insert `insertText` at the cursor when the range is omitted). - * 2. Associate the item's `attachment` with the resulting {@link UserMessage}. + * 2. Associate the item's `attachment` with the resulting {@link Message}. * * @category Commands */ @@ -255,7 +255,7 @@ export interface CompletionItem { * * Note: this range refers to positions in the *current* input. The * attachment's own `rangeStart`/`rangeEnd` (when present) refer to - * positions in the final {@link UserMessage.text} after the item is + * positions in the final {@link Message.text} after the item is * accepted. */ rangeStart?: number; diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts index 7178ccb8c965e..3bf0d2e9d09eb 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts @@ -122,7 +122,7 @@ function endTurn( const turn: Turn = { id: active.id, - userMessage: active.userMessage, + message: active.message, responseParts, usage: active.usage, state: turnState, @@ -269,7 +269,7 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: ...state, activeTurn: { id: action.turnId, - userMessage: action.userMessage, + message: action.message, responseParts: [], usage: undefined, }, @@ -734,7 +734,7 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: // ── Pending Messages ────────────────────────────────────────────────── case ActionType.SessionPendingMessageSet: { - const entry: PendingMessage = { id: action.id, userMessage: action.userMessage }; + const entry: PendingMessage = { id: action.id, message: action.message }; if (action.kind === PendingMessageKind.Steering) { return { ...state, steeringMessage: entry }; } diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts index edb24cccf2523..b983170dd120a 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts @@ -36,8 +36,8 @@ export const enum PendingMessageKind { export interface PendingMessage { /** Unique identifier for this pending message */ id: string; - /** The message content */ - userMessage: UserMessage; + /** The message that will start the next turn */ + message: Message; } // ─── Session State ─────────────────────────────────────────────────────────── @@ -552,8 +552,8 @@ export const enum MessageAttachmentKind { export interface Turn { /** Turn identifier */ id: string; - /** The user's input */ - userMessage: UserMessage; + /** The message that initiated the turn */ + message: Message; /** * All response content in stream order: text, tool calls, reasoning, and content refs. * @@ -577,8 +577,8 @@ export interface Turn { export interface ActiveTurn { /** Turn identifier */ id: string; - /** The user's input */ - userMessage: UserMessage; + /** The message that initiated the turn */ + message: Message; /** * All response content in stream order: text, tool calls, reasoning, and content refs. * @@ -590,20 +590,41 @@ export interface ActiveTurn { } /** - * A user message and its associated attachments. + * Discriminant for Message types. * - * Attachments MAY be referenced inside {@link UserMessage.text} via their + * @category Turn Types + */ +export enum MessageKind { + User = 'user', + SystemNotification = 'systemNotification', +} + +/** + * A message that initiates or steers a turn. Messages can originate from the + * user or be system-generated (see {@link MessageKind}). + * + * Attachments MAY be referenced inside {@link Message.text} via their * {@link MessageAttachmentBase.range} field. Attachments without a range are * still associated with the message but do not correspond to a specific span * in the text. * * @category Turn Types */ -export interface UserMessage { +export interface Message { /** Message text */ text: string; + /** The origin of the message */ + origin: { kind: MessageKind }; /** File/selection attachments */ attachments?: MessageAttachment[]; + /** + * Additional provider-specific metadata for this message. + * + * Clients MAY look for well-known keys here to provide enhanced UI, and + * agent hosts MAY use it to carry context that does not fit any other + * field. Mirrors the MCP `_meta` convention. + */ + _meta?: Record; } /** @@ -619,7 +640,7 @@ export interface MessageAttachmentBase { label: string; /** - * If defined, the range in {@link UserMessage.text} that references this + * If defined, the range in {@link Message.text} that references this * attachment. This is a text range, not a byte range. */ range?: TextRange; @@ -711,7 +732,7 @@ export interface MessageResourceAttachment extends MessageAttachmentBase, Conten } /** - * An attachment associated with a {@link UserMessage}. + * An attachment associated with a {@link Message}. * * @category Turn Types */ @@ -1059,7 +1080,7 @@ export interface ToolCallCancelledState extends ToolCallBase, ToolCallParameterF /** Optional message explaining the cancellation */ reasonMessage?: StringOrMarkdown; /** What the user suggested doing instead */ - userSuggestion?: UserMessage; + userSuggestion?: Message; /** The confirmation option the user selected, if confirmation options were provided */ selectedOption?: ConfirmationOption; } diff --git a/src/vs/platform/agentHost/common/state/protocol/common/commands.ts b/src/vs/platform/agentHost/common/state/protocol/common/commands.ts index db734bf3c6fd3..1b9f65f25cd26 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/commands.ts @@ -96,7 +96,7 @@ export interface InitializeResult { /** Suggested default directory for remote filesystem browsing */ defaultDirectory?: URI; /** - * Characters that, when typed in a {@link UserMessage} input, SHOULD cause + * Characters that, when typed in a {@link Message} input, SHOULD cause * the client to issue a `completions` request with * {@link CompletionItemKind.UserMessage}. Typically includes characters like * `'@'` or `'/'`. @@ -351,6 +351,7 @@ export interface ResourceReadResult { * How {@link ResourceWriteParams.data} is placed within the target file. * * Each mode interprets {@link ResourceWriteParams.position} differently: + * * - `truncate` (default): rooted at the **start** of the file. The file is * truncated at `position` (0 by default) and `data` is written from that * offset, so the resulting file is `existing[0..position] + data`. With diff --git a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts index ca579aa499bae..f0d191172c633 100644 --- a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts @@ -16,7 +16,7 @@ import type { ServerNotificationMap } from '../messages.js'; * * Formatted as a [SemVer](https://semver.org) `MAJOR.MINOR.PATCH` string. */ -export const PROTOCOL_VERSION = '0.2.0'; +export const PROTOCOL_VERSION = '0.3.0'; /** * Every protocol version a client built from this source tree is willing @@ -35,7 +35,7 @@ export const PROTOCOL_VERSION = '0.2.0'; * `scripts/verify-release-metadata.ts`. */ export const SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([ - PROTOCOL_VERSION, + '0.3.0', ]); // ─── SemVer Comparison ─────────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index c50cd8cd33113..19d9b2427b391 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -32,13 +32,13 @@ import { type ToolResultContent, type ToolResultSubagentContent, type ToolResultTextContent, - type UserMessage, + type Message, } from './protocol/state.js'; // Re-export everything from the protocol state module export { ChangesetOperationScope, ChangesetStatus, CustomizationLoadStatus, - CustomizationType, MessageAttachmentKind, + CustomizationType, MessageAttachmentKind, MessageKind, PendingMessageKind, PolicyState, ResponsePartKind, @@ -76,7 +76,7 @@ export { type ToolResultSubagentContent, type ToolResultTextContent, type Turn, type URI, type UsageInfo, - type UserMessage + type Message } from './protocol/state.js'; export { @@ -370,10 +370,10 @@ export function createSessionState(summary: SessionSummary): SessionState { }; } -export function createActiveTurn(id: string, userMessage: UserMessage): ActiveTurn { +export function createActiveTurn(id: string, message: Message): ActiveTurn { return { id, - userMessage, + message, responseParts: [], usage: undefined, }; diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index fec7894c746b0..848019842dc29 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -1134,7 +1134,7 @@ export class AgentService extends Disposable implements IAgentService { return false; } const attachmentsRootStr = this._attachmentsRoot(channel).toString(); - return !!action.userMessage.attachments?.some(a => this._isRewritableAttachment(a, attachmentsRootStr)); + return !!action.message.attachments?.some(a => this._isRewritableAttachment(a, attachmentsRootStr)); } private _isRewritableAttachment(attachment: MessageAttachment, attachmentsRootStr: string): boolean { if (attachment.type === MessageAttachmentKind.EmbeddedResource) { @@ -1172,7 +1172,7 @@ export class AgentService extends Disposable implements IAgentService { * chance to make use of it. */ private async _rewriteUserMessageAttachments(channel: string, action: T, clientId: string): Promise { - const attachments = action.userMessage.attachments; + const attachments = action.message.attachments; if (!attachments?.length) { return action; } @@ -1181,7 +1181,7 @@ export class AgentService extends Disposable implements IAgentService { const rewritten = await Promise.all(attachments.map(a => this._rewriteSingleAttachment(a, attachmentsRoot, attachmentsRootStr, clientId))); return { ...action, - userMessage: { ...action.userMessage, attachments: rewritten }, + message: { ...action.message, attachments: rewritten }, }; } diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 3e5dabdd3202c..ac7eb7cea9bc2 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -21,6 +21,7 @@ import { ActionType, StateAction, type SessionToolCallCompleteAction } from '../ import { buildSubagentSessionUri, getToolFileEdits, + MessageKind, PendingMessageKind, ResponsePartKind, ROOT_STATE_URI, @@ -507,7 +508,7 @@ export class AgentSideEffects extends Disposable { this._stateManager.dispatchServerAction(subagentSessionUri, { type: ActionType.SessionTurnStarted, turnId, - userMessage: { text: '' }, + message: { text: '', origin: { kind: MessageKind.User } }, }); this._subagentSessions.set(subagentKey, subagentSessionUri); @@ -706,7 +707,7 @@ export class AgentSideEffects extends Disposable { // title is still the default placeholder to avoid clobbering a // title set by the user or provider before the first turn. const state = this._stateManager.getSessionState(channel); - const fallbackTitle = action.userMessage.text.trim().replace(/\s+/g, ' ').slice(0, 200); + const fallbackTitle = action.message.text.trim().replace(/\s+/g, ' ').slice(0, 200); if (state && state.turns.length === 0 && !state.summary.title && fallbackTitle.length > 0) { this._stateManager.dispatchServerAction(channel, { type: ActionType.SessionTitleChanged, @@ -723,9 +724,9 @@ export class AgentSideEffects extends Disposable { }); return; } - const attachments = action.userMessage.attachments; + const attachments = action.message.attachments; this._telemetryReporter.userMessageSent(agent.id, channel, state, 'direct', attachments); - agent.sendMessage(URI.parse(channel), action.userMessage.text, attachments, action.turnId).catch(err => { + agent.sendMessage(URI.parse(channel), action.message.text, attachments, action.turnId).catch(err => { const errCode = (err as { code?: number })?.code; this._logService.error(`[AgentSideEffects] sendMessage failed for session=${channel}: code=${errCode}, message=${err instanceof Error ? err.message : String(err)}, type=${err?.constructor?.name}`, err); this._stateManager.dispatchServerAction(channel, { @@ -943,7 +944,7 @@ export class AgentSideEffects extends Disposable { this._stateManager.dispatchServerAction(session, { type: ActionType.SessionTurnStarted, turnId, - userMessage: msg.userMessage, + message: msg.message, queuedMessageId: msg.id, }); @@ -957,9 +958,9 @@ export class AgentSideEffects extends Disposable { }); return; } - const attachments = msg.userMessage.attachments; + const attachments = msg.message.attachments; this._telemetryReporter.userMessageSent(agent.id, session, this._stateManager.getSessionState(session), 'queued', attachments); - agent.sendMessage(URI.parse(session), msg.userMessage.text, attachments, turnId).catch(err => { + agent.sendMessage(URI.parse(session), msg.message.text, attachments, turnId).catch(err => { this._logService.error('[AgentSideEffects] sendMessage failed (queued)', err); this._stateManager.dispatchServerAction(session, { type: ActionType.SessionError, diff --git a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts index 02f425207afc1..290d81d9d8b27 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts @@ -554,8 +554,8 @@ export class ClaudeAgentSession extends Disposable { return; } const contentBlocks = resolvePromptToContentBlocks( - steeringMessage.userMessage.text, - steeringMessage.userMessage.attachments, + steeringMessage.message.text, + steeringMessage.message.attachments, ); const sdkMessage: SDKUserMessage = { type: 'user', diff --git a/src/vs/platform/agentHost/node/claude/claudeReplayMapper.ts b/src/vs/platform/agentHost/node/claude/claudeReplayMapper.ts index 7b6dbef8ecc3e..b3c56bc9cfe40 100644 --- a/src/vs/platform/agentHost/node/claude/claudeReplayMapper.ts +++ b/src/vs/platform/agentHost/node/claude/claudeReplayMapper.ts @@ -13,6 +13,7 @@ import { ToolCallStatus, ToolResultContentType, TurnState, + MessageKind, type ResponsePart, type ToolCallCancelledState, type ToolCallCompletedState, @@ -361,7 +362,7 @@ class ReplayBuilder { const state = a.pendingToolUseIds.size === 0 ? TurnState.Complete : TurnState.Cancelled; const turn: Turn = { id: a.id, - userMessage: { text: a.userText }, + message: { text: a.userText, origin: { kind: MessageKind.User } }, responseParts: a.responseParts, usage: undefined, state, diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 8479c2a218ee2..9882c3b43c0dd 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -32,10 +32,11 @@ import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ISessionDatabase, ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../../common/sessionDataService.js'; import { MessageAttachmentKind, type FileEdit, type MessageAttachment, type ToolDefinition } from '../../common/state/protocol/state.js'; import { ActionType, type SessionAction } from '../../common/state/sessionActions.js'; -import { ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type PendingMessage, type SessionInputAnswer, type SessionInputOption, type SessionInputQuestion, type SessionInputRequest, type ToolCallResult, type ToolResultContent, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; +import { MessageKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type PendingMessage, type SessionInputAnswer, type SessionInputOption, type SessionInputQuestion, type SessionInputRequest, type ToolCallResult, type ToolResultContent, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import type { IExitPlanModeRequestParams, IExitPlanModeResponse } from './copilotAgent.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; +import { buildCopilotSystemNotification } from './copilotSystemNotification.js'; import { parseLeadingSlashCommand } from './copilotSlashCommandCompletionProvider.js'; import type { IUnsandboxedCommandConfirmationRequest, ShellManager } from './copilotShellTools.js'; import { getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellLanguage, getSubagentMetadata, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isShellTool, synthesizeSkillToolCall, tryStringify, type ITypedPermissionRequest } from './copilotToolDisplay.js'; @@ -497,7 +498,7 @@ export class CopilotAgentSession extends Disposable { this._emitAction({ type: ActionType.SessionTurnStarted, turnId: newTurnId, - userMessage: steering.userMessage, + message: steering.message, queuedMessageId: steering.id, }); // Mirror `resetTurnState` so per-turn counters/mappings (usage @@ -542,7 +543,7 @@ export class CopilotAgentSession extends Disposable { return undefined; } for (const [id, msg] of this._pendingSteeringFlips) { - if (msg.userMessage.text === content) { + if (msg.message.text === content) { this._pendingSteeringFlips.delete(id); return msg; } @@ -565,8 +566,7 @@ export class CopilotAgentSession extends Disposable { /** * Resets per-turn streaming state so the next text/reasoning chunk - * allocates a fresh response part. Called by the agent when a new turn - * starts (typically right before {@link send}). + * allocates a fresh response part for the new turn. */ resetTurnState(turnId: string): void { this._turnId = turnId; @@ -575,6 +575,21 @@ export class CopilotAgentSession extends Disposable { this._parentToolCallIdsByAgentId.clear(); } + private _completeActiveTurn(): void { + if (!this._turnId) { + return; + } + const turnId = this._turnId; + this._emitAction({ + type: ActionType.SessionTurnComplete, + turnId, + }); + this._turnId = ''; + this._currentMarkdownPartIds.clear(); + this._currentReasoningPartIds.clear(); + this._parentToolCallIdsByAgentId.clear(); + } + private _getEditFilePaths(parameters: unknown): string[] { return getEditFilePaths(parameters).map(path => this._resolveEditFilePath(path)); } @@ -912,10 +927,10 @@ export class CopilotAgentSession extends Disposable { return; } this._steeringMessagesInFlight.add(steeringMessage.id); - this._logService.info(`[Copilot:${this.sessionId}] Sending steering message: "${steeringMessage.userMessage.text.substring(0, 100)}"`); + this._logService.info(`[Copilot:${this.sessionId}] Sending steering message: "${steeringMessage.message.text.substring(0, 100)}"`); try { await this._wrapper.session.send({ - prompt: steeringMessage.userMessage.text, + prompt: steeringMessage.message.text, mode: 'immediate', }); this._pendingSteeringFlips.set(steeringMessage.id, steeringMessage); @@ -1584,6 +1599,38 @@ export class CopilotAgentSession extends Disposable { const wrapper = this._wrapper; const sessionId = this.sessionId; + this._register(wrapper.onSystemNotification(e => { + const notification = buildCopilotSystemNotification(e); + if (!notification) { + this._logService.trace(`[Copilot:${sessionId}] Ignoring system.notification kind=${e.data.kind.type}`); + return; + } + + this._logService.info(`[Copilot:${sessionId}] System notification received: kind=${e.data.kind.type}`); + if (this._turnId) { + this._emitAction({ + type: ActionType.SessionResponsePart, + turnId: this._turnId, + part: { + kind: ResponsePartKind.SystemNotification, + content: notification.content, + }, + }); + return; + } + + const turnId = generateUuid(); + this.resetTurnState(turnId); + this._emitAction({ + type: ActionType.SessionTurnStarted, + turnId, + message: { + text: notification.messageText, + origin: { kind: MessageKind.SystemNotification }, + }, + }); + })); + // Handle `user.message` events with three responsibilities: // // 1. Skip SDK-injected (`source !== 'user'`) messages outright — @@ -1820,10 +1867,7 @@ export class CopilotAgentSession extends Disposable { activity: undefined, }); } - this._emitAction({ - type: ActionType.SessionTurnComplete, - turnId: this._turnId, - }); + this._completeActiveTurn(); })); // The SDK emits a `skill` tool call (which we hide) and a richer diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts index d4b9db6fffd5c..0890c64e4b116 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts @@ -208,6 +208,11 @@ export class CopilotSessionWrapper extends Disposable { return this._onSystemMessage ??= this._sdkEvent('system.message'); } + private _onSystemNotification: Event> | undefined; + get onSystemNotification(): Event> { + return this._onSystemNotification ??= this._sdkEvent('system.notification'); + } + private _onSessionModeChanged: Event> | undefined; get onSessionModeChanged(): Event> { return this._onSessionModeChanged ??= this._sdkEvent('session.mode_changed'); diff --git a/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts b/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts new file mode 100644 index 0000000000000..a5d5c7836eff0 --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { SessionEventPayload } from '@github/copilot-sdk'; +import { localize } from '../../../../nls.js'; + +export interface ICopilotSystemNotification { + /** Body shown inside an active turn; cleaned from SDK `system.notification.data.content`. */ + readonly content: string; + /** Text for a new system-origin AHP turn; derived from SDK `data.kind` metadata, e.g. shell completion `description`. */ + readonly messageText: string; +} + +export function buildCopilotSystemNotification(event: SessionEventPayload<'system.notification'>): ICopilotSystemNotification | undefined { + const data = event.data; + const kind = data.kind; + const content = cleanSystemNotificationContent(data.content); + if (!content) { + return undefined; + } + + switch (kind.type) { + case 'shell_completed': + case 'shell_detached_completed': { + const description = kind.description; + const shellId = kind.shellId; + return { + content, + messageText: description + ? localize('agentHost.copilot.systemNotification.shellDescriptionCompleted', "`{0}` completed", description) + : shellId + ? localize('agentHost.copilot.systemNotification.shellIdCompleted', "Shell `{0}` completed", shellId) + : localize('agentHost.copilot.systemNotification.shellCompleted', "Shell completed"), + }; + } + case 'agent_completed': + return { + content, + messageText: localize('agentHost.copilot.systemNotification.agentCompleted', "Background agent completed"), + }; + default: + return undefined; + } +} + +function cleanSystemNotificationContent(content: string): string { + const trimmed = content.trim(); + const match = /^\s*([\s\S]*?)\s*<\/system_notification>$/.exec(trimmed); + return (match?.[1] ?? trimmed).trim(); +} diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts index cf3f066ae75a2..632c2994430d3 100644 --- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts @@ -12,7 +12,7 @@ import { generateUuid } from '../../../../base/common/uuid.js'; import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; import { IFileEditRecord, ISessionDatabase } from '../../common/sessionDataService.js'; import { MessageAttachmentKind, type MessageAttachment } from '../../common/state/protocol/state.js'; -import { ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type ResponsePart, type StringOrMarkdown, type ToolCallCompletedState, type ToolResultContent, type Turn, type UserMessage } from '../../common/state/sessionState.js'; +import { MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type Message, type ResponsePart, type StringOrMarkdown, type ToolCallCompletedState, type ToolResultContent, type Turn } from '../../common/state/sessionState.js'; import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getSubagentMetadata, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, synthesizeSkillToolCall } from './copilotToolDisplay.js'; import { buildSessionDbUri } from '../shared/fileEditTracker.js'; import { getMediaMime } from '../../../../base/common/mime.js'; @@ -169,15 +169,17 @@ interface ISubagentInfo { */ interface ITurnBuilder { id: string; - userMessage: UserMessage; + message: Message; readonly responseParts: ResponsePart[]; /** Tool starts seen but not yet completed in this turn, keyed by toolCallId. */ readonly pendingTools: Map; } function newTurnBuilder(id: string, text: string, attachments?: MessageAttachment[]): ITurnBuilder { - const userMessage: UserMessage = attachments?.length ? { text, attachments } : { text }; - return { id, userMessage, responseParts: [], pendingTools: new Map() }; + const message: Message = attachments?.length + ? { text, origin: { kind: MessageKind.User }, attachments } + : { text, origin: { kind: MessageKind.User } }; + return { id, message, responseParts: [], pendingTools: new Map() }; } function makeToolStartInfo(toolName: string, rawArguments: unknown, parentToolCallId: string | undefined, workingDirectory: URI | undefined): IToolStartInfo | undefined { @@ -214,7 +216,7 @@ function makeToolStartInfo(toolName: string, rawArguments: unknown, parentToolCa function finalizeTurn(builder: ITurnBuilder, state: TurnState): Turn { return { id: builder.id, - userMessage: builder.userMessage, + message: builder.message, responseParts: builder.responseParts, usage: undefined, state, @@ -354,7 +356,7 @@ export async function mapSessionEvents( }); } if (attachments?.length) { - builder.userMessage = { ...builder.userMessage, attachments }; + builder.message = { ...builder.message, attachments }; } } else { // A new top-level user message starts a new parent turn. diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index 4fc6d41c69e79..d4d9a5cd915f3 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -10,7 +10,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { NullLogService } from '../../../log/common/log.js'; import { ActionType, NotificationType, type ActionEnvelope, type INotification } from '../../common/state/sessionActions.js'; -import { SessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, buildSubagentSessionUri, buildSubagentSessionUriPrefix, isSubagentSession, parseSubagentSessionUri, type MarkdownResponsePart, type SessionState } from '../../common/state/sessionState.js'; +import { MessageKind, SessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, buildSubagentSessionUri, buildSubagentSessionUriPrefix, isSubagentSession, parseSubagentSessionUri, type MarkdownResponsePart, type SessionState } from '../../common/state/sessionState.js'; import { type SessionSummaryChangedParams } from '../../common/state/protocol/notifications.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { buildChangesetUri, buildSessionChangesetUri } from '../../common/changesetUri.js'; @@ -217,7 +217,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionUri, { type: ActionType.SessionTurnStarted, turnId: 'turn-1', - userMessage: { text: 'hello' }, + message: { text: 'hello', origin: { kind: MessageKind.User } }, }); assert.strictEqual(manager.getActiveTurnId(sessionUri), 'turn-1'); @@ -241,7 +241,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionUri, { type: ActionType.SessionTurnStarted, turnId: 'turn-1', - userMessage: { text: 'hello' }, + message: { text: 'hello', origin: { kind: MessageKind.User } }, }); const activeChanged = envelopes.filter(e => e.action.type === ActionType.RootActiveSessionsChanged); @@ -256,7 +256,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionUri, { type: ActionType.SessionTurnStarted, turnId: 'turn-1', - userMessage: { text: 'hello' }, + message: { text: 'hello', origin: { kind: MessageKind.User } }, }); const envelopes: ActionEnvelope[] = []; @@ -283,12 +283,12 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionUri, { type: ActionType.SessionTurnStarted, turnId: 'turn-1', - userMessage: { text: 'a' }, + message: { text: 'a', origin: { kind: MessageKind.User } }, }); manager.dispatchServerAction(session2Uri, { type: ActionType.SessionTurnStarted, turnId: 'turn-2', - userMessage: { text: 'b' }, + message: { text: 'b', origin: { kind: MessageKind.User } }, }); assert.strictEqual(manager.rootState.activeSessions, 2); @@ -311,7 +311,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionUri, { type: ActionType.SessionTurnStarted, turnId: 'turn-1', - userMessage: { text: 'hello' }, + message: { text: 'hello', origin: { kind: MessageKind.User } }, }); assert.strictEqual(manager.rootState.activeSessions, 1); @@ -352,7 +352,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionUri, { type: ActionType.SessionTurnStarted, turnId: 'turn-1', - userMessage: { text: 'hello' }, + message: { text: 'hello', origin: { kind: MessageKind.User } }, }); assert.strictEqual(manager.rootState.activeSessions, 1); @@ -374,12 +374,12 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionUri, { type: ActionType.SessionTurnStarted, turnId: 'turn-1', - userMessage: { text: 'a' }, + message: { text: 'a', origin: { kind: MessageKind.User } }, }); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionTurnStarted, turnId: 'turn-2', - userMessage: { text: 'b' }, + message: { text: 'b', origin: { kind: MessageKind.User } }, }); assert.strictEqual(manager.rootState.activeSessions, 1); @@ -402,7 +402,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionUri, { type: ActionType.SessionTurnStarted, turnId: 'turn-1', - userMessage: { text: 'hello' }, + message: { text: 'hello', origin: { kind: MessageKind.User } }, }); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionTurnComplete, @@ -432,7 +432,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionUri, { type: ActionType.SessionTurnStarted, turnId: 'turn-1', - userMessage: { text: 'hello' }, + message: { text: 'hello', origin: { kind: MessageKind.User } }, }); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionTurnCancelled, @@ -441,7 +441,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(session2Uri, { type: ActionType.SessionTurnStarted, turnId: 'turn-2', - userMessage: { text: 'hi' }, + message: { text: 'hi', origin: { kind: MessageKind.User } }, }); manager.removeSession(session2Uri); @@ -457,7 +457,7 @@ suite('AgentHostStateManager', () => { const turns = [ { id: 'turn-1', - userMessage: { text: 'hello' }, + message: { text: 'hello', origin: { kind: MessageKind.User } }, responseParts: [{ kind: ResponsePartKind.Markdown, id: 'p1', content: 'world' } satisfies MarkdownResponsePart], usage: undefined, state: TurnState.Complete, @@ -467,7 +467,7 @@ suite('AgentHostStateManager', () => { const state = manager.restoreSession(makeSessionSummary(), turns); assert.strictEqual(state.lifecycle, SessionLifecycle.Ready); assert.strictEqual(state.turns.length, 1); - assert.strictEqual(state.turns[0].userMessage.text, 'hello'); + assert.strictEqual(state.turns[0].message.text, 'hello'); assert.strictEqual((state.turns[0].responseParts[0] as MarkdownResponsePart).content, 'world'); }); @@ -584,7 +584,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionUri, { type: ActionType.SessionTurnStarted, turnId: 'turn-1', - userMessage: { text: 'hello' }, + message: { text: 'hello', origin: { kind: MessageKind.User } }, }); // Let the scheduler fire so _lastNotifiedSummaries now has status=InProgress. diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 61039a833357b..74139329deacc 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -23,7 +23,7 @@ import { AgentSession } from '../../common/agentService.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { ActionType, ActionEnvelope } from '../../common/state/sessionActions.js'; -import { ChangesetStatus, CustomizationType, MessageAttachmentKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, customizationId, type ChangesetState, type MarkdownResponsePart, type ToolCallCompletedState, type ToolCallResponsePart } from '../../common/state/sessionState.js'; +import { ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, customizationId, type ChangesetState, type MarkdownResponsePart, type ToolCallCompletedState, type ToolCallResponsePart } from '../../common/state/sessionState.js'; import { type MessageResourceAttachment } from '../../common/state/protocol/state.js'; import { IProductService } from '../../../product/common/productService.js'; import { AgentService } from '../../node/agentService.js'; @@ -117,7 +117,7 @@ suite('AgentService (node dispatcher)', () => { // Start a turn so there's an active turn to map events to service.dispatchAction( session.toString(), - { type: ActionType.SessionTurnStarted, turnId: 'turn-1', userMessage: { text: 'hello' } }, + { type: ActionType.SessionTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } } }, 'test-client', 1, ); @@ -219,7 +219,7 @@ suite('AgentService (node dispatcher)', () => { { type: ActionType.SessionTurnStarted, turnId: 'turn-1', - userMessage: { text: 'hello', attachments: attachments as never }, + message: { text: 'hello', origin: { kind: MessageKind.User }, attachments: attachments as never }, }, 'test-client', 1, ); @@ -1278,7 +1278,7 @@ suite('AgentService (node dispatcher)', () => { assert.ok(state, 'session should be in state manager'); assert.strictEqual(state!.lifecycle, SessionLifecycle.Ready); assert.strictEqual(state!.turns.length, 1); - assert.strictEqual(state!.turns[0].userMessage.text, 'Hello'); + assert.strictEqual(state!.turns[0].message.text, 'Hello'); const mdPart = state!.turns[0].responseParts.find((p): p is MarkdownResponsePart => p.kind === ResponsePartKind.Markdown); assert.ok(mdPart); assert.strictEqual(mdPart.content, 'Hi there!'); @@ -1458,7 +1458,7 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(state!.turns.length, 1, `Expected 1 turn but got ${state!.turns.length}`); const turn = state!.turns[0]; - assert.strictEqual(turn.userMessage.text, 'Review this code'); + assert.strictEqual(turn.message.text, 'Review this code'); // The parent turn should only have the parent tool call — inner // tool calls are excluded from the parent and belong to the @@ -1509,8 +1509,8 @@ suite('AgentService (node dispatcher)', () => { const state = service.stateManager.getSessionState(sessionResource.toString()); assert.ok(state); - assert.strictEqual(state!.turns.length, 1, `Expected 1 turn but got ${state!.turns.length}: ${state!.turns.map(t => `"${t.userMessage.text.substring(0, 40)}"`).join(', ')}`); - assert.strictEqual(state!.turns[0].userMessage.text, 'Run a sync subagent to do some searches, just testing subagent rendering'); + assert.strictEqual(state!.turns.length, 1, `Expected 1 turn but got ${state!.turns.length}: ${state!.turns.map(t => `"${t.message.text.substring(0, 40)}"`).join(', ')}`); + assert.strictEqual(state!.turns[0].message.text, 'Run a sync subagent to do some searches, just testing subagent rendering'); assert.strictEqual(state!.turns[0].state, TurnState.Complete); // Should have the parent subagent tool call with subagent content @@ -1568,7 +1568,7 @@ suite('AgentService (node dispatcher)', () => { // mid-response. service.dispatchAction( sessionResource.toString(), - { type: ActionType.SessionTurnStarted, turnId: 'turn-1', userMessage: { text: 'hello' } }, + { type: ActionType.SessionTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } } }, 'client-1', 1, ); @@ -1876,7 +1876,7 @@ suite('AgentService (node dispatcher)', () => { service.addSubscriber(sessionResource, 'client-1'); service.dispatchAction( sessionResource.toString(), - { type: ActionType.SessionTurnStarted, turnId: 'turn-1', userMessage: { text: 'hello' } }, + { type: ActionType.SessionTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } } }, 'client-1', 1, ); service.dispatchAction( @@ -2308,7 +2308,7 @@ suite('AgentService (node dispatcher)', () => { sourceState.turns = [{ id: sourceTurnId, state: TurnState.Complete, - userMessage: { text: 'hi' }, + message: { text: 'hi', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined, }]; diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 60826d45b052b..7d2193f8decb3 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -23,7 +23,7 @@ import { ISessionDataService } from '../../common/sessionDataService.js'; import type { RootConfigChangedAction } from '../../common/state/protocol/actions.js'; import { CustomizationType } from '../../common/state/protocol/state.js'; import { ActionType, ActionEnvelope, SessionAction } from '../../common/state/sessionActions.js'; -import { buildSubagentSessionUri, CustomizationLoadStatus, MessageAttachmentKind, PendingMessageKind, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, customizationId, type ClientPluginCustomization, type Customization } from '../../common/state/sessionState.js'; +import { buildSubagentSessionUri, CustomizationLoadStatus, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, customizationId, type ClientPluginCustomization, type Customization } from '../../common/state/sessionState.js'; import { IProductService } from '../../../product/common/productService.js'; import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; @@ -153,7 +153,7 @@ suite('AgentSideEffects', () => { } function startTurn(turnId: string): void { - stateManager.dispatchClientAction(sessionUri.toString(), { type: ActionType.SessionTurnStarted, turnId, userMessage: { text: 'hello' } }, + stateManager.dispatchClientAction(sessionUri.toString(), { type: ActionType.SessionTurnStarted, turnId, message: { text: 'hello', origin: { kind: MessageKind.User } } }, { clientId: 'test', clientSeq: 1 }, ); } @@ -195,7 +195,7 @@ suite('AgentSideEffects', () => { const action: SessionAction = { type: ActionType.SessionTurnStarted, turnId: 'turn-1', - userMessage: { text: 'hello world' }, + message: { text: 'hello world', origin: { kind: MessageKind.User } }, }; sideEffects.handleAction(sessionUri.toString(), action); @@ -221,7 +221,7 @@ suite('AgentSideEffects', () => { sideEffects.handleAction(sessionUri.toString(), { type: ActionType.SessionTurnStarted, turnId: 'turn-1', - userMessage: { text: 'hello world', attachments: [{ type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'direct.ts', displayKind: 'document' }] }, + message: { text: 'hello world', origin: { kind: MessageKind.User }, attachments: [{ type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'direct.ts', displayKind: 'document' }] }, }); assert.deepStrictEqual(telemetryService.events, [{ @@ -246,7 +246,7 @@ suite('AgentSideEffects', () => { const action: SessionAction = { type: ActionType.SessionTurnStarted, turnId: 'turn-1', - userMessage: { text: 'hello world', attachments: [{ type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'test.ts', displayKind: 'document' }] }, + message: { text: 'hello world', origin: { kind: MessageKind.User }, attachments: [{ type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'test.ts', displayKind: 'document' }] }, }; sideEffects.handleAction(sessionUri.toString(), action); @@ -264,8 +264,9 @@ suite('AgentSideEffects', () => { const action: SessionAction = { type: ActionType.SessionTurnStarted, turnId: 'turn-1', - userMessage: { + message: { text: 'hello world', + origin: { kind: MessageKind.User }, attachments: [{ type: MessageAttachmentKind.Resource, uri: fileUri.toString(), @@ -317,7 +318,7 @@ suite('AgentSideEffects', () => { noAgentSideEffects.handleAction(sessionUri.toString(), { type: ActionType.SessionTurnStarted, turnId: 'turn-1', - userMessage: { text: 'hello' }, + message: { text: 'hello', origin: { kind: MessageKind.User } }, }); const errorAction = envelopes.find(e => e.action.type === ActionType.SessionError); @@ -351,7 +352,7 @@ suite('AgentSideEffects', () => { sideEffects.handleAction(sessionUri.toString(), { type: ActionType.SessionTurnStarted, turnId: 'turn-1', - userMessage: { text: 'Fix the login bug' }, + message: { text: 'Fix the login bug', origin: { kind: MessageKind.User } }, }); const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged); @@ -370,7 +371,7 @@ suite('AgentSideEffects', () => { sideEffects.handleAction(sessionUri.toString(), { type: ActionType.SessionTurnStarted, turnId: 'turn-1', - userMessage: { text: ' ' }, + message: { text: ' ', origin: { kind: MessageKind.User } }, }); const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged); @@ -387,7 +388,7 @@ suite('AgentSideEffects', () => { sideEffects.handleAction(sessionUri.toString(), { type: ActionType.SessionTurnStarted, turnId: 'turn-1', - userMessage: { text: longMessage }, + message: { text: longMessage, origin: { kind: MessageKind.User } }, }); const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged); @@ -416,7 +417,7 @@ suite('AgentSideEffects', () => { sideEffects.handleAction(sessionUri.toString(), { type: ActionType.SessionTurnStarted, turnId: 'turn-2', - userMessage: { text: 'second message' }, + message: { text: 'second message', origin: { kind: MessageKind.User } }, }); const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged); @@ -442,7 +443,7 @@ suite('AgentSideEffects', () => { sideEffects.handleAction(sessionUri.toString(), { type: ActionType.SessionTurnStarted, turnId: 'turn-1', - userMessage: { text: 'hello' }, + message: { text: 'hello', origin: { kind: MessageKind.User } }, }); const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged); @@ -621,13 +622,13 @@ suite('AgentSideEffects', () => { type: ActionType.SessionPendingMessageSet as const, kind: PendingMessageKind.Steering, id: 'steer-1', - userMessage: { text: 'focus on tests' }, + message: { text: 'focus on tests', origin: { kind: MessageKind.User } }, }; stateManager.dispatchClientAction(sessionUri.toString(), action, { clientId: 'test', clientSeq: 1 }); sideEffects.handleAction(sessionUri.toString(), action); assert.strictEqual(agent.setPendingMessagesCalls.length, 1); - assert.deepStrictEqual(agent.setPendingMessagesCalls[0].steeringMessage, { id: 'steer-1', userMessage: { text: 'focus on tests' } }); + assert.deepStrictEqual(agent.setPendingMessagesCalls[0].steeringMessage, { id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); assert.deepStrictEqual(agent.setPendingMessagesCalls[0].queuedMessages, []); }); @@ -638,7 +639,7 @@ suite('AgentSideEffects', () => { type: ActionType.SessionPendingMessageSet as const, kind: PendingMessageKind.Queued, id: 'q-1', - userMessage: { text: 'queued message' }, + message: { text: 'queued message', origin: { kind: MessageKind.User } }, }; stateManager.dispatchClientAction(sessionUri.toString(), action, { clientId: 'test', clientSeq: 1 }); sideEffects.handleAction(sessionUri.toString(), action); @@ -660,7 +661,7 @@ suite('AgentSideEffects', () => { type: ActionType.SessionPendingMessageSet as const, kind: PendingMessageKind.Queued, id: 'q-uri', - userMessage: { text: 'queued message', attachments: [{ type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'queued.ts', displayKind: 'document' }] }, + message: { text: 'queued message', origin: { kind: MessageKind.User }, attachments: [{ type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'queued.ts', displayKind: 'document' }] }, }; stateManager.dispatchClientAction(sessionUri.toString(), action, { clientId: 'test', clientSeq: 1 }); @@ -680,7 +681,7 @@ suite('AgentSideEffects', () => { type: ActionType.SessionPendingMessageSet as const, kind: PendingMessageKind.Queued, id: 'q-telemetry', - userMessage: { text: 'queued message' }, + message: { text: 'queued message', origin: { kind: MessageKind.User } }, }; stateManager.dispatchClientAction(sessionUri.toString(), action, { clientId: 'test', clientSeq: 1 }); sideEffects.handleAction(sessionUri.toString(), action); @@ -706,7 +707,7 @@ suite('AgentSideEffects', () => { type: ActionType.SessionPendingMessageSet as const, kind: PendingMessageKind.Queued, id: 'q-rm', - userMessage: { text: 'will be removed' }, + message: { text: 'will be removed', origin: { kind: MessageKind.User } }, }; stateManager.dispatchClientAction(sessionUri.toString(), setAction, { clientId: 'test', clientSeq: 1 }); sideEffects.handleAction(sessionUri.toString(), setAction); @@ -730,11 +731,11 @@ suite('AgentSideEffects', () => { setupSession(); // Add two queued messages - const setA = { type: ActionType.SessionPendingMessageSet as const, kind: PendingMessageKind.Queued, id: 'q-a', userMessage: { text: 'A' } }; + const setA = { type: ActionType.SessionPendingMessageSet as const, kind: PendingMessageKind.Queued, id: 'q-a', message: { text: 'A', origin: { kind: MessageKind.User } } }; stateManager.dispatchClientAction(sessionUri.toString(), setA, { clientId: 'test', clientSeq: 1 }); sideEffects.handleAction(sessionUri.toString(), setA); - const setB = { type: ActionType.SessionPendingMessageSet as const, kind: PendingMessageKind.Queued, id: 'q-b', userMessage: { text: 'B' } }; + const setB = { type: ActionType.SessionPendingMessageSet as const, kind: PendingMessageKind.Queued, id: 'q-b', message: { text: 'B', origin: { kind: MessageKind.User } } }; stateManager.dispatchClientAction(sessionUri.toString(), setB, { clientId: 'test', clientSeq: 2 }); sideEffects.handleAction(sessionUri.toString(), setB); @@ -764,7 +765,7 @@ suite('AgentSideEffects', () => { type: ActionType.SessionPendingMessageSet as const, kind: PendingMessageKind.Queued, id: 'q-auto', - userMessage: { text: 'auto queued' }, + message: { text: 'auto queued', origin: { kind: MessageKind.User } }, }; stateManager.dispatchClientAction(sessionUri.toString(), setAction, { clientId: 'test', clientSeq: 1 }); sideEffects.handleAction(sessionUri.toString(), setAction); @@ -807,7 +808,7 @@ suite('AgentSideEffects', () => { type: ActionType.SessionPendingMessageSet as const, kind: PendingMessageKind.Queued, id: 'q-wait', - userMessage: { text: 'should wait' }, + message: { text: 'should wait', origin: { kind: MessageKind.User } }, }; stateManager.dispatchClientAction(sessionUri.toString(), setAction, { clientId: 'test', clientSeq: 1 }); sideEffects.handleAction(sessionUri.toString(), setAction); @@ -834,7 +835,7 @@ suite('AgentSideEffects', () => { type: ActionType.SessionPendingMessageSet as const, kind: PendingMessageKind.Steering, id: 'steer-rm', - userMessage: { text: 'steer me' }, + message: { text: 'steer me', origin: { kind: MessageKind.User } }, }; stateManager.dispatchClientAction(sessionUri.toString(), action, { clientId: 'test', clientSeq: 1 }); sideEffects.handleAction(sessionUri.toString(), action); @@ -1059,7 +1060,7 @@ suite('AgentSideEffects', () => { sideEffects.handleAction(sessionUri.toString(), { type: ActionType.SessionTurnStarted, turnId: 'turn-1', - userMessage: { text: 'hello world' }, + message: { text: 'hello world', origin: { kind: MessageKind.User } }, }); assert.deepStrictEqual(telemetryService.events, []); diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index c18d3b994fb67..b0ef070f7f335 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -38,7 +38,7 @@ import { FileService } from '../../../files/common/fileService.js'; import { IAgentMaterializeSessionEvent, AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../common/agentService.js'; import { AgentFeedbackAttachmentDisplayKind } from '../../common/agentFeedbackAttachments.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { CustomizationLoadStatus, CustomizationType, MessageAttachmentKind, ResponsePartKind, SessionInputResponseKind, SessionStatus, ToolResultContentType, buildSubagentSessionUri, customizationId, type ClientPluginCustomization, type Customization } from '../../common/state/sessionState.js'; +import { CustomizationLoadStatus, CustomizationType, MessageAttachmentKind, MessageKind, ResponsePartKind, SessionInputResponseKind, SessionStatus, ToolResultContentType, buildSubagentSessionUri, customizationId, type ClientPluginCustomization, type Customization } from '../../common/state/sessionState.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; import { ProtectedResourceMetadata, SessionInputAnswerState, SessionInputAnswerValueKind, ToolCallStatus, type SessionConfigState, type SessionInputRequest, type ToolDefinition } from '../../common/state/protocol/state.js'; @@ -4325,7 +4325,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { const longSend = ctx.agent.sendMessage(sessionUri, 'long task', undefined, 'turn-2'); await tick(); - ctx.agent.setPendingMessages!(sessionUri, { id: 'pending-1', userMessage: { text: 'switch topic' } }, []); + ctx.agent.setPendingMessages!(sessionUri, { id: 'pending-1', message: { text: 'switch topic', origin: { kind: MessageKind.User } } }, []); await tick(); await tick(); @@ -4345,7 +4345,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { test('setPendingMessages with empty steering and non-empty queued is a no-op', async () => { const { ctx, sessionUri, query, advance } = await materialize(); const before = query.drainedPrompts.length; - ctx.agent.setPendingMessages!(sessionUri, undefined, [{ id: 'q1', userMessage: { text: 'queued' } }]); + ctx.agent.setPendingMessages!(sessionUri, undefined, [{ id: 'q1', message: { text: 'queued', origin: { kind: MessageKind.User } } }]); await tick(); assert.strictEqual(query.drainedPrompts.length, before); advance.complete(); @@ -4361,7 +4361,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { const longSend = ctx.agent.sendMessage(sessionUri, 'long task', undefined, 'turn-2'); await tick(); - ctx.agent.setPendingMessages!(sessionUri, { id: 'pending-9', userMessage: { text: 'steer' } }, []); + ctx.agent.setPendingMessages!(sessionUri, { id: 'pending-9', message: { text: 'steer', origin: { kind: MessageKind.User } } }, []); // Microtask cycles let the FakeQuery's background drain pull the // steering entry off `_toYield`; that drain is when our session // fires `steering_consumed` (SDK ack semantics — mirrors Copilot's @@ -4528,7 +4528,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { disposables.add(ctx.agent.onDidSessionProgress(s => signals.push(s))); // Inject steering and capture its uuid via the iterable's drain. - ctx.agent.setPendingMessages!(created.session, { id: 'pending-steer', userMessage: { text: 'moo' } }, []); + ctx.agent.setPendingMessages!(created.session, { id: 'pending-steer', message: { text: 'moo', origin: { kind: MessageKind.User } } }, []); await tick(); await tick(); const query = ctx.sdk.warmQueries[0].produced!; @@ -4593,7 +4593,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { // Inject steering so the queue holds [original, steering] when // result#1 lands. - ctx.agent.setPendingMessages!(created.session, { id: 'pending-c1', userMessage: { text: 'steer' } }, []); + ctx.agent.setPendingMessages!(created.session, { id: 'pending-c1', message: { text: 'steer', origin: { kind: MessageKind.User } } }, []); await tick(); await tick(); @@ -4652,7 +4652,7 @@ suite('ClaudeAgent (Phase 13 — getSessionMessages)', () => { assert.strictEqual(turns.length, 1); assert.strictEqual(turns[0].id, 'u1'); - assert.strictEqual(turns[0].userMessage.text, 'hi'); + assert.strictEqual(turns[0].message.text, 'hi'); assert.strictEqual(sdk.getSessionMessagesCalls.length, 1); assert.deepStrictEqual(sdk.getSessionMessagesCalls[0], { sessionId, @@ -5021,4 +5021,3 @@ suite('ClaudeAgent — Phase 11 customizations', () => { - diff --git a/src/vs/platform/agentHost/test/node/claudeReplayMapper.test.ts b/src/vs/platform/agentHost/test/node/claudeReplayMapper.test.ts index d906cbfae7dbb..600271bbcaa2c 100644 --- a/src/vs/platform/agentHost/test/node/claudeReplayMapper.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeReplayMapper.test.ts @@ -85,7 +85,7 @@ suite('claudeReplayMapper', () => { assert.strictEqual(turns.length, 1); assert.strictEqual(turns[0].id, 'u1', 'Turn.id MUST equal user SessionMessage.uuid'); - assert.strictEqual(turns[0].userMessage.text, 'hello'); + assert.strictEqual(turns[0].message.text, 'hello'); assert.strictEqual(turns[0].usage, undefined, 'replay never has usage'); assert.strictEqual(turns[0].state, TurnState.Complete); assert.strictEqual(turns[0].responseParts.length, 1); @@ -263,8 +263,8 @@ suite('claudeReplayMapper', () => { assert.strictEqual(turns.length, 2, 'CLI-echo user envelopes must NOT start new turns'); assert.strictEqual(turns[0].id, 'u1'); - assert.strictEqual(turns[0].userMessage.text, 'what model are you'); + assert.strictEqual(turns[0].message.text, 'what model are you'); assert.strictEqual(turns[1].id, 'u2'); - assert.strictEqual(turns[1].userMessage.text, 'how about now'); + assert.strictEqual(turns[1].message.text, 'how about now'); }); }); diff --git a/src/vs/platform/agentHost/test/node/claudeSubagentRegistry.test.ts b/src/vs/platform/agentHost/test/node/claudeSubagentRegistry.test.ts index 0cff5609e65ec..c0189005f2c2a 100644 --- a/src/vs/platform/agentHost/test/node/claudeSubagentRegistry.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeSubagentRegistry.test.ts @@ -5,13 +5,13 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type Turn } from '../../common/state/protocol/state.js'; +import { MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type Turn } from '../../common/state/protocol/state.js'; import { scanTranscriptForAgentIds, SUBAGENT_ID_SUFFIX_REGEX, SubagentRegistry, SubagentSpawn } from '../../node/claude/claudeSubagentRegistry.js'; function makeAgentToolCallTurn(toolCallId: string, opts: { suffixText?: string; toolName?: string; status?: ToolCallStatus }): Turn { return { id: 'turn-' + toolCallId, - userMessage: { text: '' }, + message: { text: '', origin: { kind: MessageKind.User } }, responseParts: [{ kind: ResponsePartKind.ToolCall, toolCall: { diff --git a/src/vs/platform/agentHost/test/node/claudeSubagentResolver.test.ts b/src/vs/platform/agentHost/test/node/claudeSubagentResolver.test.ts index ced118a091ce9..329dfedd3b96f 100644 --- a/src/vs/platform/agentHost/test/node/claudeSubagentResolver.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeSubagentResolver.test.ts @@ -9,7 +9,7 @@ import { CancellationToken, CancellationTokenSource } from '../../../../base/com import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; -import { ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type Turn } from '../../common/state/protocol/state.js'; +import { MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type Turn } from '../../common/state/protocol/state.js'; import { buildSubagentSessionUri } from '../../common/state/sessionState.js'; import { IClaudeAgentSdkService } from '../../node/claude/claudeAgentSdkService.js'; import { scanTranscriptForAgentIds, SUBAGENT_ID_SUFFIX_REGEX, SubagentRegistry } from '../../node/claude/claudeSubagentRegistry.js'; @@ -66,7 +66,7 @@ class FakeSdkService implements IClaudeAgentSdkService { function makeAgentToolCallTurn(toolCallId: string, opts: { prompt?: string; suffixText?: string; toolName?: string; status?: ToolCallStatus.Completed }): Turn { return { id: 'turn-' + toolCallId, - userMessage: { text: '' }, + message: { text: '', origin: { kind: MessageKind.User } }, responseParts: [{ kind: ResponsePartKind.ToolCall, toolCall: { @@ -444,4 +444,3 @@ suite('claudeSubagentResolver — fetchParentTurns', () => { }); }); }); - diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index f191c505bc2b5..0d0e1d2233f16 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -31,7 +31,7 @@ import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPlu import { AgentSession, type AgentSignal, type IAgentActionSignal, type IAgentSessionMetadata } from '../../common/agentService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; -import { buildSubagentSessionUri, CustomizationLoadStatus, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, TurnState, customizationId, type ClientPluginCustomization, type Customization, type MarkdownResponsePart, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; +import { buildSubagentSessionUri, CustomizationLoadStatus, MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, TurnState, customizationId, type ClientPluginCustomization, type Customization, type MarkdownResponsePart, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; import { CustomizationType } from '../../common/state/protocol/state.js'; import { ActionType, type IDeltaAction, type SessionAction } from '../../common/state/sessionActions.js'; @@ -1200,7 +1200,7 @@ suite('CopilotAgent', () => { const fakeMessages: Turn[] = [ { id: 'u1', - userMessage: { text: 'hi' }, + message: { text: 'hi', origin: { kind: MessageKind.User } }, responseParts: [ { kind: ResponsePartKind.ToolCall, @@ -1312,7 +1312,7 @@ suite('CopilotAgent', () => { }) as TestableCopilotAgent; const fakeMessages: Turn[] = [ - { id: 'u1', userMessage: { text: 'hi' }, responseParts: [{ kind: ResponsePartKind.Markdown, id: 'a1', content: 'untouched reply' }], usage: undefined, state: TurnState.Complete }, + { id: 'u1', message: { text: 'hi', origin: { kind: MessageKind.User } }, responseParts: [{ kind: ResponsePartKind.Markdown, id: 'a1', content: 'untouched reply' }], usage: undefined, state: TurnState.Complete }, ]; agent.registerFakeSession(sessionId, { send: async () => { }, diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index b2e8a8b155403..ce67d5b0b5907 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -24,9 +24,10 @@ import { AgentSession, type AgentSignal, type IAgentActionSignal, type IAgentToo import { IDiffComputeService } from '../../common/diffComputeService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { ActionType, type SessionDeltaAction, type SessionErrorAction, type SessionInputRequestedAction, type SessionResponsePartAction, type SessionToolCallCompleteAction, type SessionToolCallReadyAction, type SessionToolCallStartAction } from '../../common/state/sessionActions.js'; -import { MessageAttachmentKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolCallStatus, ToolResultContentType, type ToolResultFileEditContent } from '../../common/state/sessionState.js'; +import { MessageAttachmentKind, MessageKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolCallStatus, ToolResultContentType, type ToolResultFileEditContent } from '../../common/state/sessionState.js'; import { CopilotAgentSession, IActiveClientSnapshot, SessionWrapperFactory } from '../../node/copilot/copilotAgentSession.js'; import { CopilotSessionWrapper } from '../../node/copilot/copilotSessionWrapper.js'; +import { buildCopilotSystemNotification } from '../../node/copilot/copilotSystemNotification.js'; import { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { createSessionDataService, createZeroDiffComputeService } from '../common/sessionTestHelpers.js'; @@ -169,6 +170,12 @@ function isAction(s: AgentSignal, type: ActionType): s is IAgentActionSignal { return s.kind === 'action' && s.action.type === type; } +function getActions(signals: readonly AgentSignal[]) { + return signals + .filter((s): s is IAgentActionSignal => s.kind === 'action') + .map(s => s.action); +} + function getInputRequest(signal: AgentSignal): SessionInputRequestedAction['request'] { assert.strictEqual(signal.kind, 'action'); if (signal.kind !== 'action') { throw new Error('unreachable'); } @@ -384,8 +391,9 @@ suite('CopilotAgentSession', () => { assert.deepStrictEqual(await session.getMessages(), [{ id: 'event-1', - userMessage: { + message: { text: '/act-on-feedback', + origin: { kind: MessageKind.User }, attachments: [expectedAttachment], }, responseParts: [], @@ -887,7 +895,7 @@ suite('CopilotAgentSession', () => { const { session, mockSession, signals } = await createAgentSession(disposables); session.resetTurnState('turn-original'); - await session.sendSteering({ id: 'steer-1', userMessage: { text: 'focus on tests' } }); + await session.sendSteering({ id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); // Sending the steering must not flip turns until the SDK has // echoed the user message back through the event stream. @@ -905,7 +913,7 @@ suite('CopilotAgentSession', () => { assert.strictEqual(turnComplete.turnId, 'turn-original'); assert.ok(turnStarted, 'should start a new turn for the steering message'); assert.notStrictEqual(turnStarted.turnId, 'turn-original'); - assert.deepStrictEqual(turnStarted.userMessage, { text: 'focus on tests' }); + assert.deepStrictEqual(turnStarted.message, { text: 'focus on tests', origin: { kind: MessageKind.User } }); assert.strictEqual(turnStarted.queuedMessageId, 'steer-1'); }); @@ -913,7 +921,7 @@ suite('CopilotAgentSession', () => { const { session, mockSession, signals } = await createAgentSession(disposables); session.resetTurnState('turn-original'); - await session.sendSteering({ id: 'steer-1', userMessage: { text: 'focus on tests' } }); + await session.sendSteering({ id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); mockSession.fire('user.message', { content: 'focus on tests', interactionId: 'interaction-steer', @@ -940,7 +948,7 @@ suite('CopilotAgentSession', () => { const { session, mockSession, signals } = await createAgentSession(disposables); session.resetTurnState('turn-original'); - await session.sendSteering({ id: 'steer-1', userMessage: { text: 'focus on tests' } }); + await session.sendSteering({ id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); // SDK injects an unrelated user.message (e.g. skill content) // with the steering's exact text but a non-'user' source. @@ -959,7 +967,7 @@ suite('CopilotAgentSession', () => { const { session, mockSession, signals } = await createAgentSession(disposables); session.resetTurnState('turn-original'); - await session.sendSteering({ id: 'steer-1', userMessage: { text: 'focus on tests' } }); + await session.sendSteering({ id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); mockSession.fire('user.message', { content: 'something completely different', } as SessionEventPayload<'user.message'>['data']); @@ -971,8 +979,8 @@ suite('CopilotAgentSession', () => { test('does not send the same steering message again before it is flipped', async () => { const { session, mockSession } = await createAgentSession(disposables); - await session.sendSteering({ id: 'steer-1', userMessage: { text: 'focus on tests' } }); - await session.sendSteering({ id: 'steer-1', userMessage: { text: 'focus on tests' } }); + await session.sendSteering({ id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); + await session.sendSteering({ id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); assert.strictEqual(mockSession.sendRequests.length, 1); }); @@ -980,7 +988,7 @@ suite('CopilotAgentSession', () => { test('fires steering_consumed on abort when the steering never reached its turn', async () => { const { session, signals } = await createAgentSession(disposables); - await session.sendSteering({ id: 'steer-1', userMessage: { text: 'focus on tests' } }); + await session.sendSteering({ id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); await session.abort(); const consumed = signals.find(s => s.kind === 'steering_consumed'); @@ -993,7 +1001,7 @@ suite('CopilotAgentSession', () => { mockSession.send = async () => { throw new Error('send failed'); }; - await session.sendSteering({ id: 'steer-fail', userMessage: { text: 'will fail' } }); + await session.sendSteering({ id: 'steer-fail', message: { text: 'will fail', origin: { kind: MessageKind.User } } }); const consumed = signals.find(s => s.kind === 'steering_consumed'); const turnStarted = signals.find(s => s.kind === 'action' && (s as IAgentActionSignal).action.type === ActionType.SessionTurnStarted); @@ -1002,6 +1010,145 @@ suite('CopilotAgentSession', () => { }); }); + // ---- system.notification ---- + + suite('system.notification', () => { + + test('translator handles supported kinds and ignores unsupported kinds', () => { + const base = { + id: 'evt-system', + parentId: null, + timestamp: new Date().toISOString(), + type: 'system.notification' as const, + }; + + assert.deepStrictEqual(buildCopilotSystemNotification({ + ...base, + data: { + content: '\nShell done\n', + kind: { type: 'shell_completed', shellId: 'shell-a', exitCode: 0, description: 'sleep 6' }, + }, + }), { + content: 'Shell done', + messageText: '`sleep 6` completed', + }); + + assert.deepStrictEqual(buildCopilotSystemNotification({ + ...base, + data: { + content: 'Detached done', + kind: { type: 'shell_detached_completed', shellId: 'detached-a' }, + }, + }), { + content: 'Detached done', + messageText: 'Shell `detached-a` completed', + }); + + assert.deepStrictEqual(buildCopilotSystemNotification({ + ...base, + data: { + content: 'Agent done', + kind: { type: 'agent_completed', agentId: 'agent-a', agentType: 'task', status: 'completed' }, + }, + }), { + content: 'Agent done', + messageText: 'Background agent completed', + }); + + assert.strictEqual(buildCopilotSystemNotification({ + ...base, + data: { + content: 'Agent idle', + kind: { type: 'agent_idle', agentId: 'agent-a', agentType: 'task' }, + }, + }), undefined); + }); + + test('idle notification starts a system-initiated turn without sending another SDK message', async () => { + const { mockSession, signals } = await createAgentSession(disposables); + + mockSession.fire('system.notification', { + content: '\nShell command completed\n', + kind: { type: 'shell_completed', shellId: 'shell-a', exitCode: 0, description: 'sleep 6' }, + } as SessionEventPayload<'system.notification'>['data']); + + assert.strictEqual(mockSession.sendRequests.length, 0, 'system notification should not call session.send'); + const actions = getActions(signals); + const turnStarted = actions.find(a => a.type === ActionType.SessionTurnStarted); + assert.ok(turnStarted, 'should synthesize a fresh turn'); + assert.deepStrictEqual(turnStarted.message, { text: '`sleep 6` completed', origin: { kind: MessageKind.SystemNotification } }); + }); + + test('routes subsequent SDK events into the generated system turn', async () => { + const { mockSession, signals } = await createAgentSession(disposables); + + mockSession.fire('system.notification', { + content: 'Shell command completed', + kind: { type: 'shell_completed', shellId: 'shell-a', exitCode: 0, description: 'sleep 6' }, + } as SessionEventPayload<'system.notification'>['data']); + const turnStarted = getActions(signals).find(a => a.type === ActionType.SessionTurnStarted)!; + + mockSession.fire('assistant.message_delta', { + deltaContent: 'Reading the shell output now.', + } as SessionEventPayload<'assistant.message_delta'>['data']); + + const responsePart = getActions(signals).find(a => a.type === ActionType.SessionResponsePart && a.part.kind === ResponsePartKind.Markdown); + assert.ok(responsePart, 'expected response part for follow-up assistant delta'); + assert.strictEqual((responsePart as SessionResponsePartAction).turnId, (turnStarted as { turnId: string }).turnId); + }); + + test('notification during an active turn appends a SystemNotification response part', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + session.resetTurnState('turn-active'); + + mockSession.fire('system.notification', { + content: 'Shell command completed', + kind: { type: 'shell_completed', shellId: 'shell-a', exitCode: 0, description: 'sleep 6' }, + } as SessionEventPayload<'system.notification'>['data']); + + const actions = getActions(signals); + assert.strictEqual(actions.find(a => a.type === ActionType.SessionTurnStarted), undefined, 'should not create a duplicate turn'); + const systemPart = actions.find(a => a.type === ActionType.SessionResponsePart && a.part.kind === ResponsePartKind.SystemNotification) as SessionResponsePartAction | undefined; + assert.ok(systemPart, 'expected system notification response part'); + assert.strictEqual(systemPart.turnId, 'turn-active'); + assert.strictEqual(systemPart.part.kind, ResponsePartKind.SystemNotification); + assert.strictEqual(systemPart.part.content, 'Shell command completed'); + }); + + test('generated system turn completes on session.idle', async () => { + const { mockSession, signals } = await createAgentSession(disposables); + + mockSession.fire('system.notification', { + content: 'Shell command completed', + kind: { type: 'shell_completed', shellId: 'shell-a', exitCode: 0, description: 'sleep 6' }, + } as SessionEventPayload<'system.notification'>['data']); + const turnStarted = getActions(signals).find(a => a.type === ActionType.SessionTurnStarted)!; + + mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); + + const turnComplete = getActions(signals).find(a => a.type === ActionType.SessionTurnComplete); + assert.ok(turnComplete, 'expected idle to complete the generated turn'); + assert.strictEqual((turnComplete as { turnId: string }).turnId, turnStarted.turnId); + }); + + test('events after a completed turn do not target the stale previous turn id', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + session.resetTurnState('turn-old'); + + mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); + mockSession.fire('assistant.message_delta', { + deltaContent: 'late text', + } as SessionEventPayload<'assistant.message_delta'>['data']); + + const lateMarkdownActions = getActions(signals) + .filter(a => a.type === ActionType.SessionResponsePart && a.part.kind === ResponsePartKind.Markdown) + .map(a => a as SessionResponsePartAction); + const lateMarkdown = lateMarkdownActions[lateMarkdownActions.length - 1]; + assert.ok(lateMarkdown, 'late event still emits a no-op action for the reducer'); + assert.notStrictEqual(lateMarkdown.turnId, 'turn-old'); + }); + }); + // ---- event mapping ---- suite('event mapping', () => { @@ -1382,14 +1529,22 @@ suite('CopilotAgentSession', () => { assert.strictEqual(signals.length, 0); }); - test('idle event is forwarded', async () => { - const { mockSession, signals } = await createAgentSession(disposables); + test('idle event completes the active turn', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + session.resetTurnState('turn-idle'); mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); assert.strictEqual(signals.length, 1); assert.ok(isAction(signals[0], ActionType.SessionTurnComplete)); }); + test('idle event without an active turn is ignored', async () => { + const { mockSession, signals } = await createAgentSession(disposables); + mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); + + assert.strictEqual(signals.length, 0); + }); + test('error event is forwarded', async () => { const { mockSession, signals } = await createAgentSession(disposables); mockSession.fire('session.error', { @@ -1532,11 +1687,11 @@ suite('CopilotAgentSession', () => { parts.push({ kind: part.kind }); } } - return { userMessage: turn.userMessage.text, parts }; + return { message: turn.message.text, parts }; }); assert.deepStrictEqual(actual, [{ - userMessage: 'inspect the workspace', + message: 'inspect the workspace', parts: [ { kind: ResponsePartKind.Markdown, content: 'I will inspect the workspace.' }, { kind: ResponsePartKind.ToolCall, toolCallId: 'tc-view', toolName: 'view', status: ToolCallStatus.Completed, success: true, content: undefined }, @@ -1806,7 +1961,7 @@ suite('CopilotAgentSession', () => { const turns = await session.getMessages(); assert.deepStrictEqual( - turns.map(t => ({ id: t.id, text: t.userMessage.text })), + turns.map(t => ({ id: t.id, text: t.message.text })), [ { id: 'sdk-evt-user-1', text: 'first prompt' }, { id: 'sdk-evt-user-2', text: 'second prompt' }, diff --git a/src/vs/platform/agentHost/test/node/historyRecordFixtures.ts b/src/vs/platform/agentHost/test/node/historyRecordFixtures.ts index 4e0668684139f..91a406c4828eb 100644 --- a/src/vs/platform/agentHost/test/node/historyRecordFixtures.ts +++ b/src/vs/platform/agentHost/test/node/historyRecordFixtures.ts @@ -8,7 +8,7 @@ import { generateUuid } from '../../../../base/common/uuid.js'; import { isString } from '../../../../base/common/types.js'; import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; import { IFileEditRecord, ISessionDatabase } from '../../common/sessionDataService.js'; -import { ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type ResponsePart, type StringOrMarkdown, type ToolCallCompletedState, type ToolResultContent, type Turn } from '../../common/state/sessionState.js'; +import { MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type Message, type ResponsePart, type StringOrMarkdown, type ToolCallCompletedState, type ToolResultContent, type Turn } from '../../common/state/sessionState.js'; import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getSubagentMetadata, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, synthesizeSkillToolCall } from '../../node/copilot/copilotToolDisplay.js'; import { buildSessionDbUri } from '../../node/shared/fileEditTracker.js'; import type { ISessionEvent, ISessionEventMessage, ISessionEventSkillInvoked, ISessionEventSubagentStarted, ISessionEventToolComplete, ISessionEventToolStart } from '../../node/copilot/mapSessionEvents.js'; @@ -117,7 +117,7 @@ export function buildTurnsFromHistory(messages: readonly IHistoryRecord[]): Turn const subagentsByToolCallId = new Map(); let currentTurn: { id: string; - userMessage: { text: string }; + message: Message; responseParts: ResponsePart[]; pendingTools: Map; } | undefined; @@ -125,7 +125,7 @@ export function buildTurnsFromHistory(messages: readonly IHistoryRecord[]): Turn const finalizeTurn = (turn: NonNullable, state: TurnState): void => { turns.push({ id: turn.id, - userMessage: turn.userMessage, + message: turn.message, responseParts: turn.responseParts, usage: undefined, state, @@ -134,7 +134,7 @@ export function buildTurnsFromHistory(messages: readonly IHistoryRecord[]): Turn const startTurn = (id: string, text: string): NonNullable => ({ id, - userMessage: { text }, + message: { text, origin: { kind: MessageKind.User } }, responseParts: [], pendingTools: new Map(), }); @@ -338,7 +338,7 @@ export function buildSubagentTurnsFromHistory( return [{ id: generateUuid(), - userMessage: { text: '' }, + message: { text: '', origin: { kind: MessageKind.User } }, responseParts, usage: undefined, state: TurnState.Complete, diff --git a/src/vs/platform/agentHost/test/node/protocol/realSdkTestHelpers.ts b/src/vs/platform/agentHost/test/node/protocol/realSdkTestHelpers.ts index 3caa397310758..8ed86b2aedc4b 100644 --- a/src/vs/platform/agentHost/test/node/protocol/realSdkTestHelpers.ts +++ b/src/vs/platform/agentHost/test/node/protocol/realSdkTestHelpers.ts @@ -24,6 +24,7 @@ import { generateUuid } from '../../../../../base/common/uuid.js'; import { SubscribeResult } from '../../../common/state/protocol/commands.js'; import { PROTOCOL_VERSION } from '../../../common/state/protocol/version/registry.js'; import { + MessageKind, ResponsePartKind, ROOT_STATE_URI, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolResultContentType, isSubagentSession, type SessionInputAnswer, type SessionInputRequest, type SessionState, type TerminalState, @@ -171,7 +172,7 @@ export function dispatchTurn(c: TestProtocolClient, session: string, turnId: str action: { type: 'session/turnStarted', turnId, - userMessage: { text }, + message: { text, origin: { kind: MessageKind.User } }, }, }); } diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts index 6ae9b010c6eeb..151538f8a87fe 100644 --- a/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts @@ -9,7 +9,7 @@ import { SubscribeResult } from '../../../common/state/protocol/commands.js'; import type { IModelChangedAction, IResponsePartAction, SessionAddedParams, ITitleChangedAction } from '../../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../../common/state/protocol/version/registry.js'; import type { ListSessionsResult } from '../../../common/state/sessionProtocol.js'; -import { PendingMessageKind, ResponsePartKind, ROOT_STATE_URI, type SessionState } from '../../../common/state/sessionState.js'; +import { MessageKind, PendingMessageKind, ResponsePartKind, ROOT_STATE_URI, type SessionState } from '../../../common/state/sessionState.js'; import { MOCK_AUTO_TITLE } from '../mockAgent.js'; import { createAndSubscribeSession, @@ -252,7 +252,7 @@ suite('Protocol WebSocket — Session Features', function () { type: 'session/pendingMessageSet', kind: PendingMessageKind.Queued, id: 'q-1', - userMessage: { text: 'hello' }, + message: { text: 'hello', origin: { kind: MessageKind.User } }, }, }); @@ -265,7 +265,7 @@ suite('Protocol WebSocket — Session Features', function () { const snapshot = await client.call('subscribe', { channel: sessionUri }); const state = snapshot.snapshot!.state as SessionState; assert.ok(state.turns.length >= 1); - assert.strictEqual(state.turns[state.turns.length - 1].userMessage.text, 'hello'); + assert.strictEqual(state.turns[state.turns.length - 1].message.text, 'hello'); // Queue should be empty after consumption assert.ok(!state.queuedMessages?.length, 'queued messages should be empty after consumption'); }); @@ -289,7 +289,7 @@ suite('Protocol WebSocket — Session Features', function () { type: 'session/pendingMessageSet', kind: PendingMessageKind.Queued, id: 'q-wait-1', - userMessage: { text: 'hello' }, + message: { text: 'hello', origin: { kind: MessageKind.User } }, }, }); @@ -336,7 +336,7 @@ suite('Protocol WebSocket — Session Features', function () { type: 'session/pendingMessageSet', kind: PendingMessageKind.Steering, id: 'steer-1', - userMessage: { text: 'Please be concise' }, + message: { text: 'Please be concise', origin: { kind: MessageKind.User } }, }, }); diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.integrationTest.ts index b2f634c956c3e..37815505933a3 100644 --- a/src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.integrationTest.ts @@ -113,7 +113,7 @@ suite('Protocol WebSocket — Session Lifecycle', function () { assert.ok(state.turns.length >= 1, `expected at least 1 restored turn but got ${state.turns.length}`); const turn = state.turns[0]; - assert.strictEqual(turn.userMessage.text, 'What files are here?'); + assert.strictEqual(turn.message.text, 'What files are here?'); assert.strictEqual(turn.state, 'complete'); const toolCallParts = turn.responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); assert.ok(toolCallParts.length >= 1, 'turn should have tool call response parts'); diff --git a/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts b/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts index 982c9d6715531..1eddb9f5df755 100644 --- a/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts +++ b/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts @@ -10,6 +10,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { SubscribeResult } from '../../../common/state/protocol/commands.js'; import type { ActionEnvelope } from '../../../common/state/sessionActions.js'; import type { SessionAddedParams } from '../../../common/state/protocol/notifications.js'; +import { MessageKind } from '../../../common/state/sessionState.js'; import { PROTOCOL_VERSION } from '../../../common/state/protocol/version/registry.js'; import { isJsonRpcNotification, @@ -313,7 +314,7 @@ export function dispatchTurnStarted(c: TestProtocolClient, session: string, turn action: { type: 'session/turnStarted', turnId, - userMessage: { text }, + message: { text, origin: { kind: MessageKind.User } }, }, }); } diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index f2128e2a57d00..4206572a62671 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -16,7 +16,7 @@ import { CompletionsParams, CompletionsResult, ListSessionsResult, ResourceReadR import { ActionType, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../common/state/protocol/version/registry.js'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, AHP_UNSUPPORTED_PROTOCOL_VERSION, type AhpNotification, type InitializeResult, type ProtocolMessage, type ReconnectResult, type ResourceListResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; -import { ResponsePartKind, SessionStatus, ChangesetStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type SessionSummary } from '../../common/state/sessionState.js'; +import { MessageKind, ResponsePartKind, SessionStatus, ChangesetStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type SessionSummary } from '../../common/state/sessionState.js'; import type { SessionAddedParams } from '../../common/state/protocol/notifications.js'; import type { IProtocolServer, IProtocolTransport } from '../../common/state/sessionTransport.js'; import { ProtocolServerHandler } from '../../node/protocolServerHandler.js'; @@ -415,7 +415,7 @@ suite('ProtocolServerHandler', () => { action: { type: ActionType.SessionTurnStarted, turnId: 'turn-1', - userMessage: { text: 'hello' }, + message: { text: 'hello', origin: { kind: MessageKind.User } }, }, })); @@ -825,7 +825,7 @@ suite('ProtocolServerHandler', () => { stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionTurnStarted, turnId: 'turn-1', - userMessage: { text: 'run it' }, + message: { text: 'run it', origin: { kind: MessageKind.User } }, }); stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionToolCallStart, @@ -882,7 +882,7 @@ suite('ProtocolServerHandler', () => { stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionTurnStarted, turnId: 'turn-1', - userMessage: { text: 'run it' }, + message: { text: 'run it', origin: { kind: MessageKind.User } }, }); stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionToolCallStart, @@ -930,7 +930,7 @@ suite('ProtocolServerHandler', () => { stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionTurnStarted, turnId: 'turn-1', - userMessage: { text: 'run it' }, + message: { text: 'run it', origin: { kind: MessageKind.User } }, }); stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionToolCallStart, @@ -988,7 +988,7 @@ suite('ProtocolServerHandler', () => { stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionTurnStarted, turnId: 'turn-1', - userMessage: { text: 'run it' }, + message: { text: 'run it', origin: { kind: MessageKind.User } }, }); stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionToolCallStart, @@ -1040,7 +1040,7 @@ suite('ProtocolServerHandler', () => { stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionTurnStarted, turnId: 'turn-1', - userMessage: { text: 'run it' }, + message: { text: 'run it', origin: { kind: MessageKind.User } }, }); stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionToolCallStart, diff --git a/src/vs/platform/agentHost/test/node/reducers.test.ts b/src/vs/platform/agentHost/test/node/reducers.test.ts index a0d4a40c1cee9..58489d64538bb 100644 --- a/src/vs/platform/agentHost/test/node/reducers.test.ts +++ b/src/vs/platform/agentHost/test/node/reducers.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { changesetReducer, sessionReducer } from '../../common/state/protocol/reducers.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { ChangesetStatus, CustomizationLoadStatus, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, type AgentCustomization, type ChangesetState, type Customization, type PluginCustomization, type SessionState } from '../../common/state/sessionState.js'; +import { ChangesetStatus, CustomizationLoadStatus, MessageKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, type AgentCustomization, type ChangesetState, type Customization, type PluginCustomization, type SessionState } from '../../common/state/sessionState.js'; import { CustomizationType } from '../../common/state/protocol/state.js'; function makeSession(): SessionState { @@ -30,7 +30,7 @@ function withActiveTurnAndToolCall(state: SessionState): SessionState { state = sessionReducer(state, { type: ActionType.SessionTurnStarted, turnId: 'turn-1', - userMessage: { text: 'hello' }, + message: { text: 'hello', origin: { kind: MessageKind.User } }, }); state = sessionReducer(state, { type: ActionType.SessionToolCallStart, diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts index d5cba618de7ff..957d9e7c1aac0 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -768,6 +768,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc capabilities: { supportsCheckpoints: true, supportsPromptAttachments: true, + supportsImageAttachments: true, }, })); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts index 508e6c935a061..b94ca59adc032 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -170,6 +170,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr capabilities: { supportsCheckpoints: true, supportsPromptAttachments: true, + supportsImageAttachments: true, }, })); 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 336c02ce03c62..77c023c64acb9 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -26,7 +26,7 @@ import { CompletionItemKind as AhpCompletionItemKind, type CompletionItem as Ahp import { ConfirmationOptionKind, TerminalClaimKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type SessionActiveClient } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, SessionTurnStartedAction, type ClientSessionAction, type SessionAction, type SessionInputCompletedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { buildSubagentSessionUri, getToolSubagentContent, MessageAttachmentKind, PendingMessageKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, type ClientPluginCustomization, type ICompletedToolCall, type MarkdownResponsePart, type MessageAttachment, type ModelSelection, type ReasoningResponsePart, type RootState, type SessionInputAnswer, type SessionInputRequest, type SessionState, type ToolCallResponsePart, type ToolCallState, type Turn, type UsageInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildSubagentSessionUri, getToolSubagentContent, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, type ClientPluginCustomization, type ICompletedToolCall, type MarkdownResponsePart, type Message, type MessageAttachment, type ModelSelection, type ReasoningResponsePart, type RootState, type SessionInputAnswer, type SessionInputRequest, type SessionState, type ToolCallResponsePart, type ToolCallState, type Turn, type UsageInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; @@ -38,7 +38,7 @@ import { ITerminalChatService } from '../../../../terminal/browser/terminal.js'; import { isAgentFeedbackVariableEntry, isImageVariableEntry, type IAgentFeedbackVariableEntry, type IChatRequestVariableEntry, type IImageVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; import { coerceImageBuffer } from '../../../common/chatImageExtraction.js'; import { ChatRequestQueueKind, ConfirmedReason, ElicitationState, IChatProgress, IChatQuestion, IChatQuestionAnswers, IChatService, IChatToolInvocation, ToolConfirmKind, type IChatMultiSelectAnswer, type IChatQuestionAnswerValue, type IChatSingleSelectAnswer, type IChatTerminalToolInvocationData } from '../../../common/chatService/chatService.js'; -import { IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionRequestHistoryItem, type IChatInputCompletionItem, type IChatInputCompletionsParams, type IChatInputCompletionsResult } from '../../../common/chatSessionsService.js'; +import { IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionRequestHistoryItem, type IChatInputCompletionItem, type IChatInputCompletionsParams, type IChatInputCompletionsResult, type IChatSessionServerRequest } from '../../../common/chatSessionsService.js'; import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { IChatEditingService } from '../../../common/editing/chatEditingService.js'; import { ILanguageModelsService } from '../../../common/languageModels.js'; @@ -56,7 +56,7 @@ import { IAgentHostSessionWorkingDirectoryResolver } from './agentHostSessionWor import { AgentHostSnapshotController } from './agentHostSnapshotController.js'; import { toolDataToDefinition } from './agentHostToolUtils.js'; import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js'; -import { activeTurnToProgress, completedToolCallToEditParts, completedToolCallToSerialized, finalizeToolInvocation, getTerminalContentUri, isSubagentTool, makeAhpTerminalToolSessionId, parseAhpTerminalToolSessionId, rawMarkdownToString, stringOrMarkdownToString, toolCallStateToInvocation, turnsToHistory, updateRunningToolSpecificData, usageInfoToChatUsage, userMessageToVariableData, type IToolCallFileEdit, type TurnModelLookup } from './stateToProgressAdapter.js'; +import { activeTurnToProgress, completedToolCallToEditParts, completedToolCallToSerialized, finalizeToolInvocation, getTerminalContentUri, isSubagentTool, makeAhpTerminalToolSessionId, messageToVariableData, parseAhpTerminalToolSessionId, rawMarkdownToString, stringOrMarkdownToString, toolCallStateToInvocation, turnsToHistory, updateRunningToolSpecificData, usageInfoToChatUsage, type IToolCallFileEdit, type TurnModelLookup } from './stateToProgressAdapter.js'; export { toolDataToDefinition }; // ============================================================================= @@ -104,6 +104,16 @@ interface IObserveTurnOptions { readonly subAgentInvocationId?: string; } +interface IStartServerRequestOptions { + readonly isSystemInitiated?: boolean; +} + +function userOriginMessage(text: string, attachments: readonly MessageAttachment[] | undefined): Message { + return attachments?.length + ? { text, origin: { kind: MessageKind.User }, attachments: [...attachments] } + : { text, origin: { kind: MessageKind.User } }; +} + function getCopilotCredits(usage: UsageInfo | undefined): number | undefined { const copilotUsage = usage?._meta?.copilotUsage; if (copilotUsage && typeof copilotUsage === 'object' && hasKey(copilotUsage, { totalNanoAiu: true })) { @@ -233,7 +243,7 @@ class AgentHostChatSession extends Disposable implements IChatSession { private readonly _onWillDispose = this._register(new Emitter()); readonly onWillDispose = this._onWillDispose.event; - private readonly _onDidStartServerRequest = this._register(new Emitter<{ prompt: string; variableData?: IChatRequestVariableData }>()); + private readonly _onDidStartServerRequest = this._register(new Emitter()); readonly onDidStartServerRequest = this._onDidStartServerRequest.event; readonly interruptActiveResponseCallback: IChatSession['interruptActiveResponseCallback']; @@ -304,13 +314,17 @@ class AgentHostChatSession extends Disposable implements IChatSession { * Resets the progress observable and signals listeners to create a new * request+response pair in the chat model. */ - startServerRequest(prompt: string, variableData?: IChatRequestVariableData): void { + startServerRequest(prompt: string, variableData?: IChatRequestVariableData, options?: IStartServerRequestOptions): void { this._logService.info('[AgentHost] Server-initiated request started'); transaction(tx => { this.progressObs.set([], tx); this.isCompleteObs.set(false, tx); }); - this._onDidStartServerRequest.fire({ prompt, variableData }); + this._onDidStartServerRequest.fire({ + prompt, + variableData, + isSystemInitiated: options?.isSystemInitiated, + }); } } @@ -608,10 +622,11 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const activeRawModelId = sessionState.activeTurn.usage?.model ?? fallbackRawModelId; history.push({ type: 'request', - prompt: sessionState.activeTurn.userMessage.text, + prompt: sessionState.activeTurn.message.text, participant: this._config.agentId, modelId: lookup.toLanguageModelId(activeRawModelId), - variableData: userMessageToVariableData(sessionState.activeTurn.userMessage, this._config.connectionAuthority), + variableData: messageToVariableData(sessionState.activeTurn.message, this._config.connectionAuthority), + isSystemInitiated: sessionState.activeTurn.message.origin.kind === MessageKind.SystemNotification, }); history.push({ type: 'response', @@ -858,12 +873,12 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // --- Steering --- if (currentSteering) { - if (currentSteering.id !== prevSteering?.id || currentSteering.text !== prevSteering.userMessage.text) { + if (currentSteering.id !== prevSteering?.id || currentSteering.text !== prevSteering.message.text) { this._dispatchAction(backendSession, { type: ActionType.SessionPendingMessageSet, kind: PendingMessageKind.Steering, id: currentSteering.id, - userMessage: { text: currentSteering.text, attachments: currentSteering.attachments }, + message: userOriginMessage(currentSteering.text, currentSteering.attachments), }); } } else if (prevSteering) { @@ -890,12 +905,12 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const prevQueuedById = new Map(prevQueued.map(q => [q.id, q])); for (const q of currentQueued) { const prev = prevQueuedById.get(q.id); - if (!prev || q.text !== prev.userMessage.text) { + if (!prev || q.text !== prev.message.text) { this._dispatchAction(backendSession, { type: ActionType.SessionPendingMessageSet, kind: PendingMessageKind.Queued, id: q.id, - userMessage: { text: q.text, attachments: q.attachments }, + message: userOriginMessage(q.text, q.attachments), }); } } @@ -1022,8 +1037,11 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // Signal the session to create a new request+response pair chatSession.startServerRequest( - activeTurn.userMessage.text, - userMessageToVariableData(activeTurn.userMessage, this._config.connectionAuthority), + activeTurn.message.text, + messageToVariableData(activeTurn.message, this._config.connectionAuthority), + { + isSystemInitiated: activeTurn.message.origin.kind === MessageKind.SystemNotification, + }, ); // Set up turn progress tracking — reuse the same state-to-progress @@ -1136,10 +1154,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const turnAction: SessionTurnStartedAction = { type: ActionType.SessionTurnStarted, turnId, - userMessage: { - text: request.message, - attachments: messageAttachments.length > 0 ? messageAttachments : undefined, - }, + message: userOriginMessage(request.message, messageAttachments), }; this._config.connection.dispatch(session.toString(), turnAction); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 14f696e025b1d..fba727173e830 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -8,11 +8,11 @@ import { escapeMarkdownLinkLabel, IMarkdownString, MarkdownString } from '../../ import { marked, type Token, type Tokens, type TokensList } from '../../../../../../base/common/marked/marked.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; -import { ToolCallStatus, TurnState, ResponsePartKind, getToolFileEdits, getToolOutputText, getToolSubagentContent, type ActiveTurn, type ICompletedToolCall, type ToolCallState, type Turn, FileEditKind, ToolResultContentType, type ToolResultContent, type UsageInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { MessageKind, ToolCallStatus, TurnState, ResponsePartKind, getToolFileEdits, getToolOutputText, getToolSubagentContent, type ActiveTurn, type ICompletedToolCall, type Message, type ToolCallState, type Turn, FileEditKind, ToolResultContentType, type ToolResultContent, type UsageInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { getToolKind } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { AGENT_HOST_SCHEME, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { getAgentFeedbackAttachmentMetadata, isAgentFeedbackAttachment } from '../../../../../../platform/agentHost/common/agentFeedbackAttachments.js'; -import { MessageAttachmentKind, type FileEdit, type MessageAttachment, type StringOrMarkdown, type TextRange, type UserMessage } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { MessageAttachmentKind, type FileEdit, type MessageAttachment, type StringOrMarkdown, type TextRange } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { type ChatExternalEditKind, type IChatExternalEdit, type IChatModifiedFilesConfirmationData, type IChatProgress, type IChatSearchToolInvocationData, type IChatTerminalToolInvocationData, type IChatToolInputInvocationData, type IChatToolInvocationSerialized, type IChatUsage, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { type IChatSessionHistoryItem } from '../../../common/chatSessionsService.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; @@ -75,6 +75,14 @@ export function isSubagentToolName(toolName: string): boolean { return SUBAGENT_TOOL_NAMES.has(toolName); } +function systemNotificationToProgress(content: StringOrMarkdown | undefined, connectionAuthority: string | undefined): IChatProgress | undefined { + if (!content) { + return undefined; + } + const value = stringOrMarkdownToString(content, connectionAuthority); + return { kind: 'progressMessage', content: typeof value === 'string' ? new MarkdownString(value) : value }; +} + /** * Returns true if this tool call spawns a subagent session, either because * the server reported `_meta.toolKind === 'subagent'` or because the tool @@ -142,8 +150,19 @@ export function turnsToHistory(backendSession: URI, turns: readonly Turn[], part const details = lookup?.toResponseDetails(rawModelId, turn.usage); // Request - const variableData = userMessageToVariableData(turn.userMessage, connectionAuthority); - history.push({ id: turn.id, type: 'request', prompt: turn.userMessage.text, participant: participantId, modelId, variableData }); + const variableData = messageToVariableData(turn.message, connectionAuthority); + const isSystemInitiated = turn.message.origin.kind === MessageKind.SystemNotification; + history.push({ + id: turn.id, + type: 'request', + prompt: turn.message.text, + participant: participantId, + modelId, + variableData, + ...(isSystemInitiated ? { + isSystemInitiated: true, + } : {}), + }); // Response parts — iterate the unified responseParts array const parts: IChatProgress[] = []; @@ -175,6 +194,14 @@ export function turnsToHistory(backendSession: URI, turns: readonly Turn[], part parts.push({ kind: 'thinking', value: rp.content }); } break; + case ResponsePartKind.SystemNotification: + { + const progress = systemNotificationToProgress(rp.content, connectionAuthority); + if (progress) { + parts.push(progress); + } + } + break; case ResponsePartKind.ContentRef: // Content references are not restored into history; // they are handled separately by the content provider. @@ -193,13 +220,13 @@ export function turnsToHistory(backendSession: URI, turns: readonly Turn[], part } /** - * Converts a turn's persisted {@link UserMessage} into the chat-layer + * Converts a turn's persisted {@link Message} into the chat-layer * {@link IChatRequestVariableData} shape so attachments survive a * history replay (and pending/server-initiated turn synthesis). Returns * `undefined` when the message has no convertible attachments. */ -export function userMessageToVariableData(userMessage: UserMessage, connectionAuthority: string): IChatRequestVariableData | undefined { - return messageAttachmentsToVariableData(userMessage.attachments, connectionAuthority); +export function messageToVariableData(message: Message, connectionAuthority: string): IChatRequestVariableData | undefined { + return messageAttachmentsToVariableData(message.attachments, connectionAuthority); } export function messageAttachmentsToVariableData(attachments: readonly MessageAttachment[] | undefined, connectionAuthority: string): IChatRequestVariableData | undefined { @@ -348,6 +375,14 @@ export function activeTurnToProgress(sessionResource: URI, activeTurn: ActiveTur } break; } + case ResponsePartKind.SystemNotification: + { + const progress = systemNotificationToProgress(rp.content, connectionAuthority); + if (progress) { + parts.push(progress); + } + } + break; case ResponsePartKind.ContentRef: break; } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index cee4bc1507453..3a7236e7dca31 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -739,7 +739,9 @@ export class ChatService extends Disposable implements IChatService { false, // Do not treat as requests completed, else edit pills won't show. message.modelId, undefined, - message.id + message.id, + message.isSystemInitiated, + message.systemInitiatedLabel ); } else { // response @@ -791,7 +793,7 @@ export class ChatService extends Disposable implements IChatService { // Handle server-initiated requests (e.g. consumed queued messages). if (providedSession.onDidStartServerRequest) { - disposables.add(providedSession.onDidStartServerRequest(({ prompt, variableData }) => { + disposables.add(providedSession.onDidStartServerRequest(({ prompt, variableData, isSystemInitiated, systemInitiatedLabel }) => { // Complete any in-flight request if (lastRequest?.response && !lastRequest.response.isComplete) { lastRequest.response.complete(); @@ -800,7 +802,22 @@ export class ChatService extends Disposable implements IChatService { // Create a new request in the model const agent = this.chatAgentService.getAgent(chatSessionType); const parsedRequest = parseAgentHostHistoryPrompt(prompt, agent); - lastRequest = model.addRequest(parsedRequest, variableData ?? { variables: [] }, 0, undefined, agent); + lastRequest = model.addRequest(parsedRequest, + variableData ?? { variables: [] }, + 0, // attempt + undefined, // modeInfo + agent, + undefined, // slashCommand + undefined, // confirmation + undefined, // locationData + undefined, // attachments + undefined, // isCompleteAddedRequest + undefined, // modelId + undefined, // userSelectedTools + undefined, // id + isSystemInitiated, + systemInitiatedLabel + ); // Reset progress tracking for the new turn lastProgressLength = 0; diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index a3b893449aa86..9a8d7869f8c85 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -217,6 +217,8 @@ export type IChatSessionHistoryItem = { variableData?: IChatRequestVariableData; modelId?: string; modeInstructions?: IChatRequestModeInstructions; + isSystemInitiated?: boolean; + systemInitiatedLabel?: string; } | { type: 'response'; parts: IChatProgress[]; @@ -226,6 +228,12 @@ export type IChatSessionHistoryItem = { export type IChatSessionRequestHistoryItem = Extract; +export interface IChatSessionServerRequest { + readonly prompt: string; + readonly variableData?: IChatRequestVariableData; + readonly isSystemInitiated?: boolean; + readonly systemInitiatedLabel?: string; +} /** * A set of well-known session types @@ -281,7 +289,7 @@ export interface IChatSession extends IDisposable { * queued message). The consumer should create a new request+response pair in * the model and prepare to receive progress via {@link progressObs}. */ - readonly onDidStartServerRequest?: Event<{ prompt: string; variableData?: IChatRequestVariableData }>; + readonly onDidStartServerRequest?: Event; /** * Editing session transferred from a previously-untitled chat session in `onDidCommitChatSessionItem`. diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 2e9dc5ae2d0e3..f56e1e7b63262 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { encodeBase64, VSBuffer } from '../../../../../../base/common/buffer.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { DisposableStore, IDisposable, IReference, toDisposable } from '../../../../../../base/common/lifecycle.js'; @@ -21,7 +22,7 @@ import { AgentFeedbackAttachmentDisplayKind, AgentFeedbackAttachmentMetadataKey import { ActionType, isSessionAction, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type INotification, type IToolCallConfirmedAction, type ITurnStartedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { CustomizationType, type ClientPluginCustomization, type ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, createSessionState, createActiveTurn, isAhpRootChannel, PolicyState, ResponsePartKind, StateComponents, buildSubagentSessionUri, ToolResultContentType, MessageAttachmentKind, type SessionState, type SessionSummary, RootState, type ToolCallState, type AgentInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, createSessionState, createActiveTurn, isAhpRootChannel, PolicyState, ResponsePartKind, StateComponents, buildSubagentSessionUri, ToolResultContentType, MessageAttachmentKind, MessageKind, type SessionState, type SessionSummary, RootState, type ToolCallState, type AgentInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { CompletionItemKind as AhpCompletionItemKind, type CompletionsParams, type CompletionsResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import { sessionReducer } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; @@ -31,7 +32,7 @@ import { ChatAgentLocation } from '../../../common/constants.js'; import { ChatRequestQueueKind, ElicitationState, IChatService, IChatMarkdownContent, IChatProgress, IChatTerminalToolInvocationData, IChatToolInputInvocationData, IChatToolInvocation, IChatToolInvocationSerialized, IChatUsage, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { IChatEditingService } from '../../../common/editing/chatEditingService.js'; import { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; -import { IChatSessionsService, type IChatSessionRequestHistoryItem } from '../../../common/chatSessionsService.js'; +import { IChatSessionsService, type IChatSessionRequestHistoryItem, type IChatSessionsExtensionPoint } from '../../../common/chatSessionsService.js'; import { ILanguageModelsService, type ILanguageModelChatMetadata } from '../../../common/languageModels.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; @@ -62,6 +63,7 @@ import { IChatWidgetService } from '../../../browser/chat.js'; import { ChatQuestionCarouselData } from '../../../common/model/chatProgressTypes/chatQuestionCarouselData.js'; import { ChatElicitationRequestPart } from '../../../common/model/chatProgressTypes/chatElicitationRequestPart.js'; import type { IChatModel, IChatPendingRequest, IChatRequestModel } from '../../../common/model/chatModel.js'; +import { convertBufferToScreenshotVariable } from '../../../browser/attachments/chatScreenshotContext.js'; // ---- Mock agent host service ------------------------------------------------ @@ -389,6 +391,7 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv const chatAgentService = new MockChatAgentService(); const chatWidgetService = new MockChatWidgetService(); + const chatSessionContributions: IChatSessionsExtensionPoint[] = []; const openerService: { openedUrls: (string | URI)[]; openShouldFail: boolean; openResult: boolean } & Partial = { openedUrls: [], openShouldFail: false, @@ -412,7 +415,10 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv instantiationService.stub(IChatSessionsService, { registerChatSessionItemController: () => toDisposable(() => { }), registerChatSessionContentProvider: () => toDisposable(() => { }), - registerChatSessionContribution: () => toDisposable(() => { }), + registerChatSessionContribution: contribution => { + chatSessionContributions.push(contribution); + return toDisposable(() => { }); + }, }); instantiationService.stub(IDefaultAccountService, { onDidChangeDefaultAccount: Event.None, getDefaultAccount: async () => null }); instantiationService.stub(IAuthenticationService, { onDidChangeSessions: Event.None, ...authServiceOverride }); @@ -538,7 +544,7 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv instantiationService.stub(IAgentHostActiveClientService, activeClientService); instantiationService.stub(IOpenerService, openerService as IOpenerService); - return { instantiationService, agentHostService, chatAgentService, chatWidgetService, chatService, openerService, activeClientService, seedActiveClient }; + return { instantiationService, agentHostService, chatAgentService, chatWidgetService, chatService, openerService, activeClientService, seedActiveClient, chatSessionContributions }; } function createContribution(disposables: DisposableStore, opts?: { authServiceOverride?: Partial; workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined; isNewSession?: (sessionResource: URI) => boolean }; languageModels?: ReadonlyMap; provisionalServiceOverride?: Partial }) { @@ -1092,7 +1098,7 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(agentHostService.turnActions.length, 1); assert.strictEqual(agentHostService.turnActions[0].action.type, 'session/turnStarted'); - assert.strictEqual((agentHostService.turnActions[0].action as ITurnStartedAction).userMessage.text, 'Hello'); + assert.strictEqual((agentHostService.turnActions[0].action as ITurnStartedAction).message.text, 'Hello'); assert.strictEqual(AgentSession.id(URI.parse(session)), 'new-turntest'); })); @@ -1176,7 +1182,7 @@ suite('AgentHostChatContribution', () => { agentHostService.fireAction({ channel: dispatch.channel.toString(), action: { type: 'session/turnComplete', turnId: action.turnId } as SessionAction, serverSeq: 2, origin: undefined }); await turnPromise; - assert.deepStrictEqual(agentHostService.turnActions.map(d => (d.action as ITurnStartedAction).userMessage.text), ['Recovered']); + assert.deepStrictEqual(agentHostService.turnActions.map(d => (d.action as ITurnStartedAction).message.text), ['Recovered']); })); test('rejects generic contributed-chat untitled resource', async () => { @@ -2251,7 +2257,7 @@ suite('AgentHostChatContribution', () => { lifecycle: SessionLifecycle.Ready, turns: [{ id: 'turn-1', - userMessage: { text: 'What is 2+2?' }, + message: { text: 'What is 2+2?', origin: { kind: MessageKind.User } }, responseParts: [{ kind: ResponsePartKind.Markdown, id: 'md-1', content: '4' }], usage: undefined, state: TurnState.Complete, @@ -2288,8 +2294,9 @@ suite('AgentHostChatContribution', () => { lifecycle: SessionLifecycle.Ready, turns: [{ id: 'turn-1', - userMessage: { + message: { text: '/act-on-feedback', + origin: { kind: MessageKind.User }, attachments: [{ type: MessageAttachmentKind.Simple, label: 'Feedback', @@ -2393,14 +2400,14 @@ suite('AgentHostChatContribution', () => { turns: [ { id: 'turn-1', - userMessage: { text: 'Q1' }, + message: { text: 'Q1', origin: { kind: MessageKind.User } }, responseParts: [{ kind: ResponsePartKind.Markdown, id: 'md-1', content: 'A1' }], usage: { model: 'opus-4.7', _meta: { cost: 1.5 } }, state: TurnState.Complete, }, { id: 'turn-2', - userMessage: { text: 'Q2' }, + message: { text: 'Q2', origin: { kind: MessageKind.User } }, responseParts: [{ kind: ResponsePartKind.Markdown, id: 'md-2', content: 'A2' }], usage: undefined, state: TurnState.Complete, @@ -2408,7 +2415,7 @@ suite('AgentHostChatContribution', () => { ], activeTurn: { id: 'turn-active', - userMessage: { text: 'Q3' }, + message: { text: 'Q3', origin: { kind: MessageKind.User } }, responseParts: [], usage: { _meta: { cost: 1 } }, }, @@ -2605,7 +2612,7 @@ suite('AgentHostChatContribution', () => { lifecycle: SessionLifecycle.Ready, turns: [{ id: 'turn-1', - userMessage: { text: 'run ls' }, + message: { text: 'run ls', origin: { kind: MessageKind.User } }, state: TurnState.Complete, responseParts: [{ kind: 'toolCall' as const, toolCall: { @@ -2650,7 +2657,7 @@ suite('AgentHostChatContribution', () => { lifecycle: SessionLifecycle.Ready, turns: [{ id: 'turn-1', - userMessage: { text: 'do something' }, + message: { text: 'do something', origin: { kind: MessageKind.User } }, state: TurnState.Complete, responseParts: [{ kind: 'toolCall' as const, toolCall: { status: 'completed' as const, toolCallId: 'tc-orphan', toolName: 'read_file', displayName: 'Read File', invocationMessage: 'Reading file', confirmed: 'not-needed' as const, success: false, pastTenseMessage: 'Reading file' }, @@ -2681,7 +2688,7 @@ suite('AgentHostChatContribution', () => { lifecycle: SessionLifecycle.Ready, turns: [{ id: 'turn-1', - userMessage: { text: 'search' }, + message: { text: 'search', origin: { kind: MessageKind.User } }, state: TurnState.Complete, responseParts: [{ kind: 'toolCall' as const, toolCall: { status: 'completed' as const, toolCallId: 'tc-g', toolName: 'grep', displayName: 'Grep', invocationMessage: 'Searching...', confirmed: 'not-needed' as const, success: true, pastTenseMessage: 'Searched for pattern' }, @@ -2874,11 +2881,39 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(agentHostService.turnActions.length, 1); const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; - assert.deepStrictEqual(turnAction.userMessage.attachments, [ + assert.deepStrictEqual(turnAction.message.attachments, [ { type: MessageAttachmentKind.Resource, uri: URI.file('/workspace/test.ts').toString(), label: 'test.ts', displayKind: 'document' }, ]); })); + test('screenshot variable becomes embedded image attachment', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); + const screenshotBuffer = VSBuffer.fromString('screenshot bytes'); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'describe this screenshot', + variables: { + variables: [ + convertBufferToScreenshotVariable(screenshotBuffer), + ], + }, + }); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); + await turnPromise; + + assert.strictEqual(agentHostService.turnActions.length, 1); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.message.attachments, [ + { + type: MessageAttachmentKind.EmbeddedResource, + label: 'Screenshot', + displayKind: 'image', + data: encodeBase64(screenshotBuffer), + contentType: 'image/png', + }, + ]); + })); + test('preserves _meta from variable entry on outgoing attachment', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); @@ -2895,7 +2930,7 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(agentHostService.turnActions.length, 1); const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; - assert.deepStrictEqual(turnAction.userMessage.attachments, [ + assert.deepStrictEqual(turnAction.message.attachments, [ { type: MessageAttachmentKind.Resource, uri: URI.file('/workspace/test.ts').toString(), label: 'test.ts', displayKind: 'document', _meta: { provider: 'fs', score: 0.42 } }, ]); })); @@ -2934,7 +2969,7 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(agentHostService.turnActions.length, 1); const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; - assert.deepStrictEqual(turnAction.userMessage.attachments, [{ + assert.deepStrictEqual(turnAction.message.attachments, [{ type: MessageAttachmentKind.Simple, label: 'Feedback', modelRepresentation: 'Feedback text for the model', @@ -2973,7 +3008,7 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(agentHostService.turnActions.length, 1); const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; - assert.deepStrictEqual(turnAction.userMessage.attachments, [ + assert.deepStrictEqual(turnAction.message.attachments, [ { type: MessageAttachmentKind.Resource, uri: URI.file('/workspace/src').toString(), label: 'src', displayKind: 'directory' }, ]); })); @@ -2994,7 +3029,7 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(agentHostService.turnActions.length, 1); const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; - assert.deepStrictEqual(turnAction.userMessage.attachments, [ + assert.deepStrictEqual(turnAction.message.attachments, [ { type: MessageAttachmentKind.Resource, uri: URI.file('/workspace/foo.ts').toString(), @@ -3031,7 +3066,7 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(agentHostService.turnActions.length, 1); const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; - assert.deepStrictEqual(turnAction.userMessage.attachments, [ + assert.deepStrictEqual(turnAction.message.attachments, [ { type: MessageAttachmentKind.Resource, uri: URI.file('/workspace/foo.ts').toString(), @@ -3072,7 +3107,7 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(agentHostService.turnActions.length, 1); const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; - assert.strictEqual(turnAction.userMessage.attachments, undefined); + assert.strictEqual(turnAction.message.attachments, undefined); })); test('non-file URI variables are skipped', () => runWithFakedTimers({ useFakeTimers: true }, async () => { @@ -3092,7 +3127,7 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(agentHostService.turnActions.length, 1); const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; - assert.strictEqual(turnAction.userMessage.attachments, undefined); + assert.strictEqual(turnAction.message.attachments, undefined); })); test('tool variables are skipped', () => runWithFakedTimers({ useFakeTimers: true }, async () => { @@ -3111,7 +3146,7 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(agentHostService.turnActions.length, 1); const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; - assert.strictEqual(turnAction.userMessage.attachments, undefined); + assert.strictEqual(turnAction.message.attachments, undefined); })); test('mixed variables extracts only supported types', () => runWithFakedTimers({ useFakeTimers: true }, async () => { @@ -3133,7 +3168,7 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(agentHostService.turnActions.length, 1); const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; - assert.deepStrictEqual(turnAction.userMessage.attachments, [ + assert.deepStrictEqual(turnAction.message.attachments, [ { type: MessageAttachmentKind.Resource, uri: URI.file('/workspace/a.ts').toString(), label: 'a.ts', displayKind: 'document' }, { type: MessageAttachmentKind.Resource, uri: URI.file('/workspace/lib').toString(), label: 'lib', displayKind: 'directory' }, ]); @@ -3150,7 +3185,7 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(agentHostService.turnActions.length, 1); const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; - assert.strictEqual(turnAction.userMessage.attachments, undefined); + assert.strictEqual(turnAction.message.attachments, undefined); })); // ---- Working-directory rebasing ----------------------------------- @@ -3208,7 +3243,7 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(agentHostService.turnActions.length, 1); const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; - assert.deepStrictEqual(turnAction.userMessage.attachments, [ + assert.deepStrictEqual(turnAction.message.attachments, [ { type: MessageAttachmentKind.Resource, uri: URI.file('/worktree/a.ts').toString(), label: 'a.ts', displayKind: 'document' }, { type: MessageAttachmentKind.Resource, uri: URI.file('/worktree/lib').toString(), label: 'lib', displayKind: 'directory' }, { @@ -3266,7 +3301,7 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(agentHostService.turnActions.length, 1); const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; - assert.deepStrictEqual(turnAction.userMessage.attachments, [ + assert.deepStrictEqual(turnAction.message.attachments, [ { type: MessageAttachmentKind.Resource, uri: URI.file('/source/a.ts').toString(), label: 'a.ts', displayKind: 'document' }, ]); })); @@ -3292,7 +3327,7 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(agentHostService.turnActions.length, 1); const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; - assert.deepStrictEqual(turnAction.userMessage.attachments, [ + assert.deepStrictEqual(turnAction.message.attachments, [ { type: MessageAttachmentKind.Resource, uri: URI.file('/elsewhere/elsewhere.ts').toString(), label: 'elsewhere.ts', displayKind: 'document' }, ]); })); @@ -3312,6 +3347,20 @@ suite('AgentHostChatContribution', () => { // Let async work settle await timeout(10); })); + + test('local agent contribution advertises image attachments', () => { + const { instantiationService, agentHostService, chatSessionContributions } = createTestServices(disposables); + disposables.add(instantiationService.createInstance(AgentHostContribution)); + + agentHostService.setRootState({ + agents: [{ provider: 'copilot' as const, displayName: 'Agent Host - Copilot', description: 'test', models: [] }], + activeSessions: 0, + }); + + assert.deepStrictEqual(chatSessionContributions.map(c => ({ type: c.type, supportsImageAttachments: c.capabilities?.supportsImageAttachments })), [ + { type: 'agent-host-copilot', supportsImageAttachments: true }, + ]); + }); }); // ---- IAgentConnection unification ------------------------------------- @@ -3625,7 +3674,7 @@ suite('AgentHostChatContribution', () => { // Turn dispatched via connection.dispatchAction assert.strictEqual(agentHostService.turnActions.length, 1); - assert.strictEqual((agentHostService.turnActions[0].action as ITurnStartedAction).userMessage.text, 'Test message'); + assert.strictEqual((agentHostService.turnActions[0].action as ITurnStartedAction).message.text, 'Test message'); })); }); @@ -3653,13 +3702,13 @@ suite('AgentHostChatContribution', () => { lifecycle: SessionLifecycle.Ready, turns: [{ id: 'turn-completed', - userMessage: { text: 'First message' }, + message: { text: 'First message', origin: { kind: MessageKind.User } }, responseParts: [{ kind: ResponsePartKind.Markdown as const, id: 'md-1', content: 'First response' }], usage: undefined, state: TurnState.Complete, }], activeTurn: { - ...createActiveTurn('turn-active', { text: 'Second message' }), + ...createActiveTurn('turn-active', { text: 'Second message', origin: { kind: MessageKind.User } }), responseParts: activeTurnParts, }, }; @@ -3871,7 +3920,7 @@ suite('AgentHostChatContribution', () => { lifecycle: SessionLifecycle.Ready, turns: [{ id: 'turn-done', - userMessage: { text: 'Hello' }, + message: { text: 'Hello', origin: { kind: MessageKind.User } }, responseParts: [{ kind: ResponsePartKind.Markdown as const, id: 'md-1', content: 'Hi' }], usage: undefined, state: TurnState.Complete, @@ -3938,7 +3987,7 @@ suite('AgentHostChatContribution', () => { modifiedAt: Date.now(), }), lifecycle: SessionLifecycle.Ready, - activeTurn: createActiveTurn('active-turn-1', { text: 'Working' }), + activeTurn: createActiveTurn('active-turn-1', { text: 'Working', origin: { kind: MessageKind.User } }), }); const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/restored-pending-sync' }); @@ -3961,7 +4010,7 @@ suite('AgentHostChatContribution', () => { type: ActionType.SessionPendingMessageSet, kind: 'queued', id: 'queued-request-1', - userMessage: { text, attachments: undefined }, + message: { text, origin: { kind: MessageKind.User } }, }); }); @@ -3979,7 +4028,7 @@ suite('AgentHostChatContribution', () => { modifiedAt: Date.now(), }), lifecycle: SessionLifecycle.Ready, - queuedMessages: [{ id: 'queued-request-1', userMessage: { text: 'old queued text' } }], + queuedMessages: [{ id: 'queued-request-1', message: { text: 'old queued text', origin: { kind: MessageKind.User } } }], }); const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/pending-text-update' }); @@ -4002,7 +4051,7 @@ suite('AgentHostChatContribution', () => { type: ActionType.SessionPendingMessageSet, kind: 'queued', id: 'queued-request-1', - userMessage: { text, attachments: undefined }, + message: { text, origin: { kind: MessageKind.User } }, }); }); @@ -4043,7 +4092,7 @@ suite('AgentHostChatContribution', () => { action: { type: 'session/turnStarted', turnId: serverTurnId, - userMessage: { text: 'queued message text' }, + message: { text: 'queued message text', origin: { kind: MessageKind.User } }, } as SessionAction, serverSeq: 3, origin: undefined, // Server-originated — no client origin @@ -4088,7 +4137,7 @@ suite('AgentHostChatContribution', () => { const serverTurnId = 'server-turn-progress'; agentHostService.fireAction({ channel: session, - action: { type: 'session/turnStarted', session, turnId: serverTurnId, userMessage: { text: 'auto queued' } } as SessionAction, + action: { type: 'session/turnStarted', session, turnId: serverTurnId, message: { text: 'auto queued', origin: { kind: MessageKind.User } } } as SessionAction, serverSeq: 3, origin: undefined, }); await timeout(10); @@ -4196,7 +4245,7 @@ suite('AgentHostChatContribution', () => { const serverTurnId = 'server-turn-tool-dedup'; agentHostService.fireAction({ channel: session, - action: { type: 'session/turnStarted', session, turnId: serverTurnId, userMessage: { text: 'queued' } } as SessionAction, + action: { type: 'session/turnStarted', session, turnId: serverTurnId, message: { text: 'queued', origin: { kind: MessageKind.User } } } as SessionAction, serverSeq: 3, origin: undefined, }); await timeout(10); @@ -4274,7 +4323,7 @@ suite('AgentHostChatContribution', () => { const serverTurnId = 'server-turn-md-initial'; agentHostService.fireAction({ channel: session, - action: { type: 'session/turnStarted', session, turnId: serverTurnId, userMessage: { text: 'queued' } } as SessionAction, + action: { type: 'session/turnStarted', session, turnId: serverTurnId, message: { text: 'queued', origin: { kind: MessageKind.User } } } as SessionAction, serverSeq: 3, origin: undefined, }); agentHostService.fireAction({ @@ -4327,7 +4376,7 @@ suite('AgentHostChatContribution', () => { // Add a queued message to the protocol state so it's tracked. agentHostService.fireAction({ channel: session, - action: { type: 'session/pendingMessageSet', session, kind: 'queued', id: 'q-1', userMessage: { text: 'will be consumed' } } as SessionAction, + action: { type: 'session/pendingMessageSet', session, kind: 'queued', id: 'q-1', message: { text: 'will be consumed', origin: { kind: MessageKind.User } } } as SessionAction, serverSeq: 3, origin: undefined, }); await timeout(10); @@ -4337,7 +4386,7 @@ suite('AgentHostChatContribution', () => { chatService.removePendingRequestCalls.length = 0; agentHostService.fireAction({ channel: session, - action: { type: 'session/turnStarted', session, turnId: 'server-turn-q', userMessage: { text: 'will be consumed' }, queuedMessageId: 'q-1' } as SessionAction, + action: { type: 'session/turnStarted', session, turnId: 'server-turn-q', message: { text: 'will be consumed', origin: { kind: MessageKind.User } }, queuedMessageId: 'q-1' } as SessionAction, serverSeq: 4, origin: undefined, }); await timeout(10); @@ -4373,7 +4422,7 @@ suite('AgentHostChatContribution', () => { // Set a steering message on the protocol state. agentHostService.fireAction({ channel: session, - action: { type: 'session/pendingMessageSet', session, kind: 'steering', id: 'steer-1', userMessage: { text: 'be more careful' } } as SessionAction, + action: { type: 'session/pendingMessageSet', session, kind: 'steering', id: 'steer-1', message: { text: 'be more careful', origin: { kind: MessageKind.User } } } as SessionAction, serverSeq: 3, origin: undefined, }); await timeout(10); @@ -4616,7 +4665,7 @@ suite('AgentHostChatContribution', () => { toolInput: '{}', confirmed: ToolCallConfirmationReason.NotNeeded, } as ToolCallState; - const activeTurn = createActiveTurn('child-turn-1', { text: 'do work' }); + const activeTurn = createActiveTurn('child-turn-1', { text: 'do work', origin: { kind: MessageKind.User } }); activeTurn.responseParts.push({ kind: ResponsePartKind.ToolCall, toolCall: innerTool }); return { ...createSessionState(summary), @@ -4703,7 +4752,7 @@ suite('AgentHostChatContribution', () => { fireChild({ type: 'session/turnStarted', turnId: childTurnId, - userMessage: { text: '' }, + message: { text: '', origin: { kind: MessageKind.User } }, } as SessionAction); fireChild({ type: 'session/toolCallStart', session: childSessionUri, turnId: childTurnId, diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts index eb07eebf489f4..2da289e845e35 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts @@ -17,7 +17,7 @@ import { ILogService, NullLogService } from '../../../../../../platform/log/comm import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { AgentSession, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; import { isSessionAction, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type INotification } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; -import { buildSubagentSessionUri, SessionLifecycle, SessionStatus, createSessionState, StateComponents, type SessionState, type SessionSummary, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildSubagentSessionUri, MessageKind, SessionLifecycle, SessionStatus, createSessionState, StateComponents, type SessionState, type SessionSummary, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { sessionReducer } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { ActionType } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; import { ToolCallConfirmationReason, ToolResultContentType } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -579,7 +579,7 @@ suite('AgentHostClientTools', () => { connection.applySessionAction(URI.parse(backendSession), { type: ActionType.SessionTurnStarted, turnId: 'turn-1', - userMessage: { text: 'run the task' }, + message: { text: 'run the task', origin: { kind: MessageKind.User } }, } as SessionAction); connection.applySessionAction(URI.parse(backendSession), { type: ActionType.SessionToolCallStart, @@ -626,7 +626,7 @@ suite('AgentHostClientTools', () => { connection.applySessionAction(URI.parse(backendSession), { type: ActionType.SessionTurnStarted, turnId: 'turn-1', - userMessage: { text: 'run the task' }, + message: { text: 'run the task', origin: { kind: MessageKind.User } }, } as SessionAction); connection.applySessionAction(URI.parse(backendSession), { type: ActionType.SessionToolCallStart, @@ -685,7 +685,7 @@ suite('AgentHostClientTools', () => { connection.applySessionAction(URI.parse(backendSession), { type: ActionType.SessionTurnStarted, turnId: 'turn-1', - userMessage: { text: 'do work' }, + message: { text: 'do work', origin: { kind: MessageKind.User } }, }); connection.applySessionAction(URI.parse(backendSession), { type: ActionType.SessionToolCallStart, @@ -710,7 +710,7 @@ suite('AgentHostClientTools', () => { connection.applySessionAction(URI.parse(subagentBackendSession), { type: ActionType.SessionTurnStarted, turnId: 'sub-turn-1', - userMessage: { text: '' }, + message: { text: '', origin: { kind: MessageKind.User } }, }); connection.applySessionAction(URI.parse(subagentBackendSession), { type: ActionType.SessionToolCallStart, diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index ff84bd01894ae..5ca8494d5c50e 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -7,8 +7,8 @@ import assert from 'assert'; import { autorun } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { ToolCallStatus, ToolCallConfirmationReason, ToolResultContentType, TurnState, ResponsePartKind, type ActiveTurn, type ICompletedToolCall, type ToolCallRunningState, type Turn, type ToolCallResponsePart, ToolCallCancellationReason } from '../../../../../../platform/agentHost/common/state/sessionState.js'; -import { IChatToolInvocation, IChatToolInvocationSerialized, type IChatMarkdownContent, type IChatUsage } from '../../../common/chatService/chatService.js'; +import { MessageKind, ToolCallStatus, ToolCallConfirmationReason, ToolResultContentType, TurnState, ResponsePartKind, type ActiveTurn, type ICompletedToolCall, type ToolCallRunningState, type Turn, type ToolCallResponsePart, ToolCallCancellationReason, type Message } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { IChatToolInvocation, IChatToolInvocationSerialized, type IChatMarkdownContent, type IChatProgressMessage, type IChatUsage } from '../../../common/chatService/chatService.js'; import { isToolResultInputOutputDetails, type IToolResultInputOutputDetails, ToolDataSource, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; import { turnsToHistory as rawTurnsToHistory, activeTurnToProgress as rawActiveTurnToProgress, toolCallStateToInvocation as rawToolCallStateToInvocation, finalizeToolInvocation as rawFinalizeToolInvocation, updateRunningToolSpecificData as rawUpdateRunningToolSpecificData } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js'; @@ -43,7 +43,7 @@ function createCompletedToolCall(overrides?: Partial): IComp function createTurn(overrides?: Partial): Turn { return { id: 'turn-1', - userMessage: { text: 'Hello' }, + message: { text: 'Hello', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined, state: TurnState.Complete, @@ -51,6 +51,10 @@ function createTurn(overrides?: Partial): Turn { }; } +function message(text: string, kind = MessageKind.User): Message { + return { text, origin: { kind } }; +} + function toolCallStateToInvocation(tc: Parameters[0], subAgentInvocationId?: string) { return rawToolCallStateToInvocation(tc, subAgentInvocationId, URI.file('/'), undefined); } @@ -110,7 +114,7 @@ suite('stateToProgressAdapter', () => { test('single turn produces request + response pair', () => { const turn = createTurn({ - userMessage: { text: 'Do something' }, + message: message('Do something'), responseParts: [{ kind: ResponsePartKind.ToolCall, toolCall: createCompletedToolCall() } as ToolCallResponsePart], }); @@ -134,6 +138,33 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(serialized.isComplete, true); }); + test('system-initiated turn preserves compact request label', () => { + const turn = createTurn({ + message: message('`sleep 6` completed', MessageKind.SystemNotification), + }); + + const history = turnsToHistory(URI.file('/'), [turn], 'participant-1'); + assert.strictEqual(history[0].type, 'request'); + if (history[0].type !== 'request') { return; } + assert.strictEqual(history[0].isSystemInitiated, true); + assert.strictEqual(history[0].prompt, '`sleep 6` completed'); + assert.strictEqual(history[0].systemInitiatedLabel, undefined); + }); + + test('system notification response part restores as progress message', () => { + const turn = createTurn({ + responseParts: [{ kind: ResponsePartKind.SystemNotification, content: 'Shell command completed' }], + }); + + const history = turnsToHistory(URI.file('/'), [turn], 'participant-1'); + const response = history[1]; + assert.strictEqual(response.type, 'response'); + if (response.type !== 'response') { return; } + const progress = response.parts[0] as IChatProgressMessage; + assert.strictEqual(progress.kind, 'progressMessage'); + assert.strictEqual(progress.content.value, 'Shell command completed'); + }); + test('generic completed tool call in history includes input/output details', () => { const turn = createTurn({ responseParts: [{ @@ -212,12 +243,12 @@ suite('stateToProgressAdapter', () => { test('per-turn model id and display name flow from usage.model', () => { const turn1 = createTurn({ id: 'turn-1', - userMessage: { text: 'first' }, + message: message('first'), usage: { model: 'gpt-5' }, }); const turn2 = createTurn({ id: 'turn-2', - userMessage: { text: 'second' }, + message: message('second'), usage: { model: 'opus-4.7' }, }); @@ -238,7 +269,7 @@ suite('stateToProgressAdapter', () => { }); test('falls back to session-level model when turn has no usage.model', () => { - const turn = createTurn({ userMessage: { text: 'first' } }); + const turn = createTurn({ message: message('first') }); const lookup = makeLookup('agent-host-copilot:', { 'gpt-5': 'GPT-5' }, 'gpt-5'); const history = turnsToHistory(URI.file('/'), [turn], 'p', lookup); @@ -277,7 +308,7 @@ suite('stateToProgressAdapter', () => { test('request history includes restored model id', () => { const turn = createTurn({ - userMessage: { text: 'Use restored model' }, + message: message('Use restored model'), }); const lookup = makeLookup('agent-host-copilot:', {}, 'gpt-5'); @@ -993,7 +1024,7 @@ suite('stateToProgressAdapter', () => { function createActiveTurnState(responseParts?: ActiveTurn['responseParts']): ActiveTurn { return { id: 'turn-active', - userMessage: { text: 'Do things' }, + message: message('Do things'), responseParts: responseParts ?? [], usage: undefined, }; @@ -1025,6 +1056,15 @@ suite('stateToProgressAdapter', () => { assert.strictEqual((result[0] as IChatMarkdownContent).content.value, 'Hello world'); }); + test('produces progress message for system notification', () => { + const result = activeTurnToProgress(URI.file('/'), createActiveTurnState([ + { kind: ResponsePartKind.SystemNotification, content: 'Shell command completed' }, + ]), undefined); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].kind, 'progressMessage'); + assert.strictEqual((result[0] as IChatProgressMessage).content.value, 'Shell command completed'); + }); + test('produces thinking progress for reasoning', () => { const result = activeTurnToProgress(URI.file('/'), createActiveTurnState([ { kind: ResponsePartKind.Reasoning, id: 'r-1', content: 'Let me think about this...' }, diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index c0f6c55f5f6b8..02ec675157071 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -1638,7 +1638,7 @@ suite('ChatService', () => { readonly progressObs?: ISettableObservable; readonly isCompleteObs?: ISettableObservable; readonly interruptActiveResponseCallback?: () => Promise; - readonly onDidStartServerRequest?: Event<{ prompt: string; variableData?: IChatRequestVariableData }>; + readonly onDidStartServerRequest?: Event<{ prompt: string; variableData?: IChatRequestVariableData; isSystemInitiated?: boolean; systemInitiatedLabel?: string }>; readonly history?: readonly IChatSessionHistoryItem[]; }